跳过正文
  1. 文章/

Claude Code WSL 通知与 WezTerm 窗口聚焦

·399 字·2 分钟
作者
Yang Hu

在 WSL 中运行 Claude Code 时,很容易错过它等待输入的时刻——尤其是当你切换到其他窗口的时候。本文记录了我搭建的通知系统:当 Claude 停止响应或请求权限时,会弹出 Windows 气泡通知,点击通知可直接聚焦到对应的 WezTerm 面板。

工作原理
#

Claude Code 在 ~/.claude/settings.json 中提供了钩子(hooks)系统,其中两个事件特别有用:

  • Stop — Claude 完成一次响应、等待用户输入时触发。钩子载荷包含 last_assistant_messagecwdtranscript_path
  • PermissionRequest — Claude 需要批准才能运行某个工具(Bash 命令、文件写入等)时触发。载荷包含 tool_nametool_input

两个钩子均以异步方式(async: true)运行 shell 命令,不会阻塞 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 }] }]
  }
}

脚本说明
#

~/.claude/notify-stop.sh
#

从钩子的标准输入 JSON 中提取标题、消息正文和 cwd,然后启动 PowerShell 发送通知:

 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
#

结构相同,但正文由工具名称和关键参数拼接而成:

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]')

通知示例:Bash: git statusEdit: src/main.go

~/.claude/notify-stop.ps1
#

PowerShell 脚本完成四件事:

  1. 已聚焦则跳过 — 检查目标 WezTerm 窗口是否已是前台窗口,若是则静默退出。
  2. 显示气泡通知 — 在 Windows 系统托盘显示标题和正文。
  3. 点击时查找正确面板 — 调用 wezterm.exe cli list --format json,按 cwd 过滤,获取面板 ID 和窗口标题。
  4. 激活并提升窗口activate-pane 切换到正确标签页,再通过 Win32 API 将窗口置于前台。

难点解析
#

环境变量无法跨越 WSL→PowerShell 边界
#

无法在 bash 中设置变量后用 $Env:VAR 在 PowerShell 中读取。解决方案:用 jq -n 从 bash 写入 JSON 文件,再在 PowerShell 中用 ConvertFrom-Json 读取。

找到正确的 WezTerm 窗口
#

wezterm cli list --format jsonfile:// URI 形式返回每个面板的 cwd(例如 file://pc/home/huyang/workdir)。使用 EndsWith 与钩子中的 cwd/home/huyang/workdir)匹配:

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

此查询在点击时执行,而非通知弹出时,以确保获取最新的窗口标题。

窗口标题编码不匹配
#

Claude 工作期间,WezTerm 会在活动面板标题前加上 (盲文点,U+2802)。通过 PowerShell 的 ConvertFrom-Json 读取时,这个字符变成了 Γ£│(UTF-8 字节被误读为 cp1252);而 Win32 的 GetWindowText 返回的是 ?

解决方案:比较前先裁掉两端字符串的非 ASCII、非字母数字前缀:

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 被 Windows 拦截
#

Windows 会阻止后台进程抢占前台窗口。keybd_event(Alt) 技巧在某些情况下有效,但更可靠的方案是 AttachThreadInput——在调用 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);

最终效果
#

  • Claude 完成响应 → 弹出 Windows 通知,显示最后一条消息及所属项目
  • Claude 请求权限 → 通知显示具体命令或文件路径
  • 点击任意通知 → 聚焦到正确的 WezTerm 窗口和面板
  • 若目标窗口已在前台,则不显示通知