Skip to main content
  1. Posts/

Claude Code WSL Notifications with WezTerm Focus

Author
Yang Hu

When running Claude Code inside WSL, it’s easy to miss when it’s waiting for your input — especially if you’ve switched to another window while it works. This post documents the notification system I set up: Windows toast notifications that fire when Claude stops and when it needs permission, with click-to-focus on the exact WezTerm pane.

How It Works
#

Claude Code has a hooks system in ~/.claude/settings.json. Two events are useful here:

  • Stop — fires when Claude finishes a response and is waiting for input. The hook payload includes last_assistant_message, cwd, and transcript_path.
  • PermissionRequest — fires when Claude needs approval to run a tool (Bash command, file write, etc.). The payload includes tool_name and tool_input.

Both hooks run shell commands asynchronously (async: true) so they never block Claude.

1
2
3
4
5
6
{
  "hooks": {
    "PermissionRequest": [{ "hooks": [{ "type": "command", "command": "bash ~/.claude/notify-permission.sh", "async": true }] }],
    "Stop":              [{ "hooks": [{ "type": "command", "command": "bash ~/.claude/notify-stop.sh",       "async": true }] }]
  }
}

The Scripts
#

~/.claude/notify-stop.sh
#

Extracts the title, message body, and cwd from the hook’s stdin JSON, then launches a PowerShell notification:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#!/bin/bash
INPUT=$(cat)
TITLE=$(echo "$INPUT" | jq -r '"Claude [" + (.cwd | split("/") | last) + "]"')
BODY=$(echo "$INPUT"  | jq -r '.last_assistant_message // "" | .[0:200]')
CWD=$(echo "$INPUT"   | jq -r '.cwd')

jq -n --arg t "$TITLE" --arg b "$BODY" --arg c "$CWD" \
  '{title:$t,body:$b,cwd:$c}' > /tmp/claude-notif.json

JSON_WIN=$(wslpath -w /tmp/claude-notif.json)
PS1_WIN=$(wslpath -w ~/.claude/notify-stop.ps1)
powershell.exe -ExecutionPolicy Bypass -File "$PS1_WIN" "$JSON_WIN"

~/.claude/notify-permission.sh
#

Same structure, but builds the body from the tool name and its key argument:

1
2
3
4
5
6
7
BODY=$(echo "$INPUT" | jq -r '
  (.tool_name) + ": " + (
    if .tool_name == "Bash" then .tool_input.command
    elif (.tool_name == "Write" or .tool_name == "Edit") then .tool_input.file_path
    else (.tool_input | tostring)
    end
  ) | .[0:200]')

This gives notifications like Bash: git status or Edit: src/main.go.

~/.claude/notify-stop.ps1
#

The PowerShell script does four things:

  1. Skip if already focused — check if the target WezTerm window is already the foreground window; if so, exit silently.
  2. Show balloon notification — Windows system tray balloon with title and body.
  3. On click: find the right pane — query wezterm.exe cli list --format json, filter by cwd, get the pane ID and window title.
  4. Activate and raise the windowactivate-pane switches to the right tab, then Win32 APIs bring the window to front.

The Hard Parts
#

Environment variables don’t cross WSL→PowerShell
#

You can’t set a bash variable and read it with $Env:VAR in PowerShell. The fix: write a JSON file from bash (using jq -n) and read it with ConvertFrom-Json in PowerShell.

Finding the right WezTerm window
#

wezterm cli list --format json returns each pane’s cwd as a file:// URI (e.g. file://pc/home/huyang/workdir). Matching against the hook’s cwd (/home/huyang/workdir) uses EndsWith:

1
Where-Object { $_.cwd -and $_.cwd.EndsWith($script:cwd) }

We do this lookup at click time, not at notification time, to get the freshest window title.

Window title encoding mismatch
#

WezTerm prefixes the active pane title with (a braille dot, U+2802) while Claude is working. When piped through PowerShell’s ConvertFrom-Json, this arrives as Γ£│ (UTF-8 bytes misread as cp1252). Win32 GetWindowText returns ? for the same character.

The fix: strip all leading non-ASCII and non-alphanumeric characters from both strings before comparing:

1
2
3
4
5
public static string StripPrefix(string s) {
    int i = 0;
    while (i < s.Length && (s[i] > 127 || !char.IsLetterOrDigit(s[i]))) i++;
    return s.Substring(i);
}

SetForegroundWindow is blocked by Windows
#

Windows blocks foreground-window stealing from background processes. keybd_event(Alt) tricks help in some cases, but the reliable fix is AttachThreadInput — attach your thread to the current foreground thread before calling SetForegroundWindow:

1
2
3
4
5
6
7
var fg = GetForegroundWindow();
uint fgThread; GetWindowThreadProcessId(fg, out fgThread);
uint myThread = GetCurrentThreadId();
AttachThreadInput(myThread, fgThread, true);
BringWindowToTop(found);
SetForegroundWindow(found);
AttachThreadInput(myThread, fgThread, false);

Result
#

  • Claude finishes a response → Windows notification showing the last message and which project it’s from
  • Claude needs permission → notification showing the exact command/file
  • Clicking either notification focuses the correct WezTerm window and pane
  • No notification if you’re already looking at that window