Notifications visuelles pour Claude Code dans Windows Terminal



Tags : #claude-code #terminal

Dernière modification le 21 août 2026. Créé le 28 avril 2026.

Plusieurs sessions Claude Code en parallèle dans le terminal Windows, une par projet. Laquelle vient de terminer ? Laquelle attend une saisie ?

Trois signaux visuels rendent l’identification immédiate :
- une icône de cloche sur le titre de l’onglet inactif,
- un flash de la fenêtre et de la barre des tâches, et
- une bannière éphémère qui nomme la session concernée.

Le dispositif :

Les hooks Claude Code. Deux événements, Notification (Claude attend une saisie) et Stop (Claude a fini son tour), déclenchent chacun deux commandes. La première fait sonner la cloche du terminal ; la seconde invoque un petit script Python qui produit une toast notification Windows. Dans ~/.claude/settings.json :

{
  "hooks": {
    "Notification": [{ "matcher": "", "hooks": [
      { "type": "command",
        "command": "bash ~/.claude/bell.sh" },
      { "type": "command", "async": true,
        "command": "python3 ~/.claude/notify.py 'Waiting for input'" }
    ]}],
    "Stop": [{ "matcher": "", "hooks": [
      { "type": "command",
        "command": "bash ~/.claude/bell.sh" },
      { "type": "command", "async": true,
        "command": "python3 ~/.claude/notify.py 'Finished'" }
    ]}]
  }
}

Faire sonner la cloche. La première version de ce billet employait ici printf '\007' > /dev/tty, qui présente le modeste défaut de ne jamais produire de cloche. Claude Code lance les commandes de hook sans terminal contrôleur : /dev/tty échoue avec ENXIO, et leur sortie standard est un tube que lit le processus parent, si bien qu’un simple printf '\007' s’y perd. Les deux échecs sont silencieux, et la toast continue d’apparaître ponctuellement, ce qui promet un agréable après-midi de diagnostic.

Le processus claude, lui, détient toujours le pty : il suffit de remonter la chaîne des parents pour le retrouver. Dans ~/.claude/bell.sh :

#!/usr/bin/env bash
pid=$PPID
for _ in 1 2 3 4 5 6 7 8; do
    [ -r "/proc/$pid/stat" ] || break
    read -r line < "/proc/$pid/stat"
    set -- ${line#*") "}
    ppid=$2
    tty_nr=$5
    major=$(( (tty_nr >> 8) & 0xfff ))
    minor=$(( (tty_nr & 0xff) | ((tty_nr >> 12) & 0xfff00) ))
    if [ "$major" -eq 136 ]; then
        printf '\007' > "/dev/pts/$minor" 2>/dev/null && exit 0
    fi
    pid=$ppid
done
printf '\007' > /dev/tty 2>/dev/null || printf '\007'

Le champ 7 de /proc/<pid>/stat est tty_nr, qui encode un numéro de périphérique majeur et un mineur ; le majeur 136 désigne un pty. Il atterrit dans $5 une fois retiré le préfixe <pid> (<comm>), dont la largeur varie et qui affectionne les parenthèses. Tout se lit ici dans /proc, sans aucun fork : le hook ne coûte rien qui mérite d’être mesuré.

Windows Terminal reçoit le caractère BEL et affiche les signaux visuels, à condition que son style de cloche le permette. Dans les paramètres du profil :

"bellStyle": ["window", "taskbar"]

Les valeurs acceptées sont all, audible, window, taskbar et none. Il n’existe pas de valeur visual, quoi qu’en suggère un premier réflexe. L’absence d’audible est volontaire : la cloche devient silencieuse, ses compagnons visuels demeurent.

Le script Python. ~/.claude/notify.py lit le JSON transmis sur l’entrée standard et demande à PowerShell d’afficher une toast Windows. Le titre reprend le nom de la session lorsqu’elle en a reçu un (/rename dans Claude Code), et retombe sinon sur le nom du dossier projet tiré de cwd. Les sessions actives sont recensées dans ~/.claude/sessions/<pid-claude>.json, indexées par PID et par identifiant de session : l’un ou l’autre suffit à retrouver la nôtre. Le XML utilise scenario="urgent" avec Priority=High :

#!/usr/bin/env python3
import json
import os
import pathlib
import subprocess
import sys

status = sys.argv[1] if len(sys.argv) > 1 else "Notification"

try:
    data = json.load(sys.stdin) if not sys.stdin.isatty() else {}
except (json.JSONDecodeError, ValueError):
    data = {}

cwd = (data.get("cwd") or "").rstrip("/")
project = cwd.rsplit("/", 1)[-1] if cwd else ""


def session_name() -> str:
    """Name given to this session via /rename, or '' if it is unnamed.

    Claude Code records live sessions in ~/.claude/sessions/<claude-pid>.json;
    the hook payload carries session_id, and CLAUDE_PID is in the environment,
    so either one identifies our file.
    """
    d = pathlib.Path.home() / ".claude" / "sessions"
    session_id = data.get("session_id") or os.environ.get("CLAUDE_CODE_SESSION_ID")
    pid = os.environ.get("CLAUDE_PID")

    def read(path):
        try:
            return json.loads(path.read_text())
        except (OSError, ValueError):
            return None

    if pid:
        meta = read(d / f"{pid}.json")
        if meta and (not session_id or meta.get("sessionId") == session_id):
            return (meta.get("name") or "").strip()

    if session_id:
        for f in d.glob("*.json"):
            meta = read(f)
            if meta and meta.get("sessionId") == session_id:
                return (meta.get("name") or "").strip()

    return ""


name = session_name()
if name:
    title = f"Claude Code: {name}"
    body = f"{status} - {project}" if project else status
else:
    title = f"Claude Code: {project}" if project else "Claude Code"
    body = status


def ps_str(s: str) -> str:
    return s.replace("'", "''")


ps = (
    "& { "
    "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime] > $null; "
    "$t = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent("
    "[Windows.UI.Notifications.ToastTemplateType]::ToastText02); "
    "$r = $t.GetElementsByTagName('toast').Item(0); "
    "$r.SetAttribute('scenario','urgent'); "
    "$audio = $t.CreateElement('audio'); "
    "$audio.SetAttribute('silent','true'); "
    "$r.AppendChild($audio) > $null; "
    "$n = $t.GetElementsByTagName('text'); "
    f"$n.Item(0).AppendChild($t.CreateTextNode('{ps_str(title)}')) > $null; "
    f"$n.Item(1).AppendChild($t.CreateTextNode('{ps_str(body)}')) > $null; "
    "$x = [Windows.UI.Notifications.ToastNotification]::new($t); "
    "$x.Priority = [Windows.UI.Notifications.ToastNotificationPriority]::High; "
    "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ClaudeCode').Show($x) "
    "}"
)

subprocess.run(
    ["powershell.exe", "-NoProfile", "-Command", ps],
    stdout=subprocess.DEVNULL,
    stderr=subprocess.DEVNULL,
)

La clé de registre. Un AppUserModelID enregistré est également requis, sans quoi Windows refuse d’associer la toast à une entrée par application dans Paramètres > Notifications. Deux lignes reg add depuis une invite cmd suffisent :

reg add "HKCU\Software\Classes\AppUserModelId\ClaudeCode" /v DisplayName    /t REG_SZ    /d "Claude Code" /f
reg add "HKCU\Software\Classes\AppUserModelId\ClaudeCode" /v ShowInSettings /t REG_DWORD /d 1             /f

Le nom de la clé (ClaudeCode) doit correspondre à la chaîne passée à CreateToastNotifier dans le script.


MODIFIÉ le 2026-08-21. La cloche ne sonnait pas. printf '\007' > /dev/tty échoue dans un hook : c’est désormais bell.sh qui écrit sur le pty. La valeur visual de bellStyle n’existe pas et a été retirée. Le titre de la toast reprend le nom de la session lorsqu’elle en a un.