Visual notifications for Claude Code in Windows Terminal
Tags: #claude-code #terminal
Several Claude Code sessions running in parallel tabs of Windows Terminal, one per project. Which one just finished? Which one is waiting for an answer?
Three visual signals make it immediate:
- a bell icon on the inactive tab title,
- a window and taskbar flash, and
- an ephemeral toast banner naming the session concerned.
The setup:
Claude Code hooks. Two events, Notification (Claude is waiting for input) and Stop (Claude finished a turn), each fire two commands. The first rings the terminal bell; the second invokes a small Python helper that produces a Windows toast. In ~/.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'" }
]}]
}
}Ringing the bell. The first version of this post used printf '\007' > /dev/tty here, which has the modest defect of never producing a bell. Claude Code spawns hook commands with no controlling terminal, so /dev/tty fails with ENXIO, and their standard output is a pipe the parent reads, so a bare printf '\007' is swallowed there instead. Both failures are silent, and the toast keeps appearing on schedule, which makes for a pleasant afternoon of debugging.
The claude process itself still holds the pty, so walking up the parent chain finds it. In ~/.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'Field 7 of /proc/<pid>/stat is tty_nr, which packs a major and a minor device number; major 136 means a pty. It lands in $5 once the <pid> (<comm>) prefix is stripped, that prefix being variable-width and fond of parentheses. Everything here is a /proc read with no forks, so the hook costs nothing worth measuring.
Windows Terminal receives the BEL byte and draws the visuals, provided its bell style says so. In the profile settings:
"bellStyle": ["window", "taskbar"]The accepted values are all, audible, window, taskbar and none. There is no visual, whatever a first guess might suggest. Leaving out audible is deliberate: the bell goes silent, its visual companions remain.
The Python script. ~/.claude/notify.py reads the hook payload on standard input and asks PowerShell to display a Windows toast. The title carries the session name when the session has one (/rename in Claude Code), and falls back to the project folder taken from cwd. Live sessions are listed in ~/.claude/sessions/<claude-pid>.json, keyed by PID and by session id, so either one identifies ours. The XML uses scenario="urgent" with 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,
)The registry key. A registered AppUserModelID is also required, otherwise Windows refuses to bind the toast to a per-app entry in Settings > Notifications. Two reg add lines from any cmd prompt suffice:
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
The key name (ClaudeCode) must match the string passed to CreateToastNotifier in the script.
MODIFIED on 2026-08-21. The bell never actually rang. printf '\007' > /dev/tty fails in a hook, so it is now delivered to the pty by bell.sh. The bellStyle value visual does not exist and has been dropped. The toast title carries the session name when there is one.