1. 终端一切走,Claude 就变成黑箱
如果你也靠 Claude Code 写代码,我想你一定经历过这种状态:思路正顺,手一抖切到终端想看看模型跑到哪了,结果屏幕上还停在上一步的输出,你又只能切回去继续等。等再切过去,发现 Claude 其实早就在某个需要确认的节点上卡住了,正等着你回复。
这就是跑在终端里的 AI 编程助手最磨人的地方:它没有窗口,但又离不开窗口。
用 IDE 插件的时候,至少侧边栏还有个进度提示;用 Claude Code 这种纯 CLI 工具,一切走终端它就变成了一个黑箱。你既不知道任务跑完了没有,也不知道它是不是在等你输入。只能靠高频切换窗口去确认,而每次切换都在打断心流。写代码本身可能只要十分钟,频繁切窗口切出来的焦虑能把半小时都耗进去。
这篇文章要解决的,就是这个问题。
我会从零开始,在 Windows 11 上做一个《AI 编程状态悬浮球》:一个不占地方的小圆点,始终悬浮在屏幕上,用颜色告诉你 Claude Code 当前是“正在干活”“等你确认”还是“根本没启动”。单机它,可以一键把终端窗口拉回前台,省掉反复按 Alt+Tab 的麻烦。
整个方案不需要安装任何额外运行时,不依赖 Python、Node、Electron,一个.ps1脚本就能跑起来,内存占用能压在 20MB 以内。
无论你是在 Windows Terminal、VS Code 集成终端还是老式 conhost 窗口里跑 Claude Code,这套检测逻辑都适用。做完之后,你还能把它改造成适配 Codex CLI、Aider 等其他 AI 终端的通用状态球。
2. 方案选型:为什么最终用 PowerShell + WPF 而不是 Electron
先说结论:这个悬浮球的核心不是“画一个球”,而是“低开销地常驻桌面”。
我最早用 Electron 试过一版。功能确实好写,浏览器内核渲染一个圆点,状态切换动画丝滑,测出来内存直接吃了 200 多 MB。一个桌面上可有可无的状态指示器,这个代价我接受不了。而且 Electron 跑起来还要带一堆运行时,为了一个 20 像素的圆点安装整个工具链,属于用牛刀杀鸡。
后来也试过 Tauri。好看是好看,Rust 编译链太重了,维护一个小工具还要伺候 cargo 和 WebView2 的版本兼容,划不来。AutoHotkey 我也考虑过,做悬浮窗没问题,但它的强项是模拟键鼠操作,读 JSONL 日志、解析 Json、按命令行匹配进程这类数据处理活儿做起来很别扭。
最终留下来的是PowerShell + WPF组合,原因是它在这个场景下几乎完美匹配需求:
| 需求 | Electron | Tauri | AHK | PowerShell + WPF |
|---|---|---|---|---|
| 额外运行时 | Node.js | Rust + WebView2 | AHK 解释器 | Windows 自带 |
| 内存占用 | 200MB+ | 50MB 左右 | 约 5MB | 约 15-20MB |
| 无边框置顶窗口 | 支持但需配置 | 支持 | 支持 | 原生支持 |
| Json / 日志解析 | 容易 | 容易 | 别扭 | 原生 ConvertFrom-Json |
| 进程命令行匹配 | 需要写代码 | 需要写代码 | 一般 | Get-CimInstance 一行搞定 |
| 分发方式 | 一堆文件 | 一个 exe | 一个 exe | 一个 ps1 文件 |
最关键的是最后一行:一个.ps1文件就是全部。
在 Windows 11 上,PowerShell 和 .NET 框架都是系统自带组件。我的脚本通过Add-Type直接引用 WPF 程序集,然后用System.Windows.Window创建窗口,本质上和写一个 C# WPF 程序没什么区别,只是不用编译,改完保存就能跑。
这个方案最让我满意的一点是可读性。隔一个月回去看脚本还能一眼看懂,改颜色、调轮询频率、加状态逻辑都是改几行文本的事。
2.1 一个小前提:你不需要懂 WPF
没写过 WPF 也不用慌。
这个项目里用到的 WPF 能力非常有限:一个无边框窗口、一个圆形控件、几个鼠标事件、一个定时器。我在下面会把每一步拆开,你直接抄就能跑。
真正需要花心思的是状态检测逻辑,那才是这个悬浮球的灵魂。同理,Electron 的优势(复杂 UI、动画、跨平台)在这个场景里根本用不上。
3. 悬浮球的三块地基:置顶、拖动、低占用
3.1 无边框圆形窗口怎么搭
WPF 里做一个悬浮球形状的窗口,核心是三个窗口属性:
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" Width="56" Height="56" WindowStyle="None" AllowsTransparency="True" Background="Transparent" Topmost="True" ShowInTaskbar="False" ResizeMode="NoResize"> <Border Width="48" Height="48" CornerRadius="24" Background="#1E1E1E"> <Ellipse x:Name="StatusDot" Width="28" Height="28" Fill="#888888" HorizontalAlignment="Center" VerticalAlignment="Center"/> </Border> </Window>WindowStyle="None"去掉标题栏和边框。AllowsTransparency="True"+Background="Transparent"让窗口只剩下内容形状,没有方形白底。Topmost="True"是基础的置顶。ShowInTaskbar="False"防止任务栏出现一个多余按钮。
在 PowerShell 里创建这个窗口的姿势比较特别:先把上面的 XAML 写成 here-string,然后用[System.Windows.Markup.XamlReader]::Parse()解析,最后用Add-Type引入 WPF 程序集:
Add-Type -AssemblyName PresentationFramework Add-Type -AssemblyName PresentationCore Add-Type -AssemblyName WindowsBase [xml]$xaml = @" <Window ...> ... </Window> "@ $window = [System.Windows.Markup.XamlReader]::Load([System.Xml.XmlNodeReader]::new($xaml))这里有个很常见的坑:XamlReader加载出来的窗口对象,如果要通过名字操作控件(比如改StatusDot的颜色),直接用$window.StatusDot是不行的。需要在 XAML 里给控件加x:Name,加载后再用FindName绑定:
$dot = $window.FindName("StatusDot") $dot.Fill = [System.Windows.Media.Brushes]::Green3.2 Topmost 不够用:用 SetWindowPos 给置顶“续命”
大多数情况下,Topmost = True就能保证悬浮球盖在普通窗口上面。但 Windows 11 里有两个场景会让它失效:一个是全屏应用(游戏、播放器、远程桌面),另一个是某些窗口管理工具主动改写了 Z 序。
解决方式是调用 Win32 的SetWindowPos,用HWND_TOPMOST标志把窗口重新钉到置顶层:
Add-Type @" using System; using System.Runtime.InteropServices; public class Win32 { [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); public const uint SWP_NOSIZE = 0x0001; public const uint SWP_NOMOVE = 0x0002; public const uint SWP_NOACTIVATE = 0x0010; public static readonly IntPtr HWND_TOPMOST = new IntPtr(-1); } "@ $handle = [System.Windows.Interop.WindowInteropHelper]::new($window).Handle [Win32]::SetWindowPos($handle, [Win32]::HWND_TOPMOST, 0, 0, 0, 0, [Win32]::SWP_NOMOVE -bor [Win32]::SWP_NOSIZE -bor [Win32]::SWP_NOACTIVATE)注意这里有两个细节,都是实测过的:
SWP_NOACTIVATE必须带上,否则悬浮球会抢焦点。不带的话你正在终端打字,突然焦点被一个圆球抢走,Claude Code 的输入框就丢了。- 这个调用不是一劳永逸的。我是在定时器里每隔 2 秒顺手刷新一次,开销极小,但能有效防止被其他应用“压下去”。
3.3 拖动、位置记忆与点击交互
悬浮球默认停靠在屏幕右上角,但每个人习惯不同,所以得允许拖动。
WPF 里实现拖动窗口非常简单:
$border = $window.FindName("DragArea") $border.Add_MouseLeftButtonDown({ $window.DragMove() })注意DragMove()不能在鼠标右键按下时调用,否则会抛异常。所以事件里要判断一下按键:
$border.Add_MouseLeftButtonDown({ param($sender, $e) if ($e.LeftButton -eq [System.Windows.Input.MouseButtonState]::Pressed) { $window.DragMove() } })拖动之后还有一个值得做的功能:记住位置。不然每次开机悬浮球都回到默认位置,你又得拖一遍。我用注册表存位置:
$regPath = "HKCU:\Software\ClaudeStatusBall" Set-ItemProperty -Path $regPath -Name "X" -Value $window.Left Set-ItemProperty -Path $regPath -Name "Y" -Value $window.Top启动时先读注册表,有值就直接定位。
单击悬浮球,我绑定的是“激活 Claude Code 所在终端窗口”,这个逻辑放到下一章讲,因为要先解决状态检测的问题,再做交互闭环。
3.4 轮询别用 while,用 Timer
状态检测本质上是一个轮询循环:每隔一两秒去看一下 Claude Code 的状态,然后更新圆点颜色。
最容易想到的写法是:
while ($true) { Update-Status Start-Sleep -Seconds 2 }这样写的问题有两个:Start-Sleep 会占住一个线程,脚本退出不方便;UI 更新和轮询搅在一起,响应会变迟钝。
更优雅的做法是用 .NET 的System.Timers.Timer,让它在后台线程触发,然后在 UI 线程更新颜色:
$timer = [System.Timers.Timer]::new(1500) # 毫秒为单位的间隔 $timer.Add_Elapsed({ $window.Dispatcher.Invoke({ Update-Status }) }) $timer.Start()Dispatcher.Invoke是强制把更新操作发回 WPF 的 UI 线程,避免跨线程操作控件崩溃。
实测下来,这个脚本整体内存占用在 15MB 左右,CPU 基本为 0。挂在后台一个月不会给系统带来可感知的负担。
4. 状态感知:怎么知道 Claude Code 在不在干活
这是整个悬浮球最核心的部分。我踩了不少坑,下面按版本演进讲。
4.1 第一版:进程名匹配,误报率高到离谱
最初我的思路很简单:检测node进程是不是存在,存在就说明 Claude Code 在跑。
$running = [bool](Get-Process node -ErrorAction SilentlyContinue)结果自然是灾难级的误报:VS Code 开着就在跑 node,Chrome 某个插件也是 node,Windows 上各种 Electron 应用全是 node。悬浮球常年亮着“工作中”的绿灯,完全失去意义。
教训是:Claude Code 不是一个独立进程,它寄生在 node 进程里,靠进程名判断等于没判断。
4.2 第二版:命令行参数 + 窗口标题双重校验
Claude Code 本质上是通过node cli.js拉起来的,所以关键在命令行参数里。用Get-CimInstance查进程的命令行,比Get-Process靠谱得多:
$proc = Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" | Where-Object { $_.CommandLine -match 'claude' }这一下误报基本消除。如果某个 node 进程的命令行里包含claude,那它大概率就是 Claude Code 本体。
但只靠命令行还不够。components 场景是:Claude Code 已经退出了,但由于执行了一个子进程(比如调用了npm run test),那个子进程的命令行里也可能没有claude,然而它是 Claude 派生出来的。这种情况我会再做第二重校验——窗口标题。
Claude Code 在运行时会把它所在的终端窗口标题改成类似claude或包含当前项目名的文本。在 Windows Terminal 的独立标签页里改的是标签标题,在 conhost 窗口里改的是窗口标题。我的校验逻辑是:
function Test-ClaudeActive { $cliProc = Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" | Where-Object { $_.CommandLine -match 'claude' } if (-not $cliProc) { return $false } # 再校验终端窗口标题里也带着 claude 痕迹,防止误判 $hintFound = $false foreach ($p in $cliProc) { $ownerPid = $p.ParentProcessId # 沿着进程树找终端窗口,比对 MainWindowTitle } return $hintFound }这里要说明一句:进程命令行是强信号,窗口标题是辅助信号。如果强信号命中,即使标题没抓到,我也倾向于认为 Claude Code 在运行。因为标题抓取在 Windows Terminal 上的行为在不同版本里不一致,做成必要条件会导致漏报。
4.3 第三版:读 JSONL 日志,判断“干活中”还是“等你确认”
进程检测只能告诉我们 Claude Code 有没有在跑,但回答不了更关键的问题:它现在是在干活,还是在等你回复?
这个问题的答案藏在 Claude Code 的会话日志里。
Claude Code 会把当前会话完整记录为 JSONL 文件,路径在:
C:\Users\<你的用户名>\.claude\projects\目录名\时间戳.jsonl目录名是对项目路径做编码后的字符串。这些 JSONL 文件每一行是一条独立记录,包含type字段,常见的有user、assistant、summary、system等。assistant记录里还有消息内容和工具调用信息。
判断状态的基本思路是:
- 找到最近修改的那个
.jsonl文件(也就是当前会话)。 - 读取最后一行,解析出记录时间和类型。
- 算出它与当前时间的差值。
- 如果最近几秒内有新的
assistant记录且内容里有工具调用残留,说明模型正在执行任务。 - 如果最后一条记录已经很久没更新了,说明输出到了尽头,大概率在等你输入确认。
核心代码大概长这样:
function Get-ClaudeStatus { $dir = Join-Path $env:USERPROFILE ".claude\projects" if (-not (Test-Path $dir)) { return "stopped" } $latestFile = Get-ChildItem $dir -Recurse -Filter *.jsonl | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if (-not $latestFile) { return "stopped" } $age = (Get-Date) - $latestFile.LastWriteTime if ($age.TotalSeconds -gt 15) { return "waiting" # 有一阵子没有新输出,多半在等你 } $lastLine = Get-Content $latestFile.FullName -Tail 1 | ConvertFrom-Json if ($lastLine.type -eq "assistant") { return "working" } return "waiting" }这个版本已经非常好用了。颜色映射我做得比较保守:
| 状态 | 颜色 | 含义 |
|---|---|---|
stopped | 灰色 | 没有 Claude Code 会话在跑 |
working | 绿色 | 最近有持续输出,模型正在执行 |
waiting | 黄色 | 已经至少 15 秒没有新输出,需要你切过去看一眼 |
有人可能会问:15 秒是不是太短了,模型想一段时间不也很正常吗?
是的,单次“思考”超过 15 秒并不罕见。但我这个悬浮球的定位是“提醒你回去看一眼”,而不是“精确测量 Claude 的任务进度”。宁可多提醒一次,也不希望它卡在确认节点上等你十分钟。实际用下来,黄色状态出现时切过去,八成确实在等你输y或回答一个问题。
4.4 把状态球变成“回到 Claude 的传送门”
状态有了,就差交互闭环。
单击悬浮球时,我调用WScript.Shell的AppActivate方法,把 Claude Code 所在终端窗口拉到前台。关键是怎么找到那个窗口。
如果 Claude Code 是自己开的终端(比如你从 PowerShell 里直接启动的),那当前终端窗口就是宿主;如果在 VS Code 的集成终端里跑的,宿主是 VS Code 主进程。这里我采用了最实用的一层判断:取 Claude Code 进程的进程树根节点,从根节点的进程 PID 拿到 MainWindowHandle,然后激活它。
function Activate-ClaudeWindow { $nodeProc = Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" | Where-Object { $_.CommandLine -match 'claude' } | Select-Object -First 1 if (-not $nodeProc) { return } $shell = New-Object -ComObject WScript.Shell $shell.AppActivate($nodeProc.ParentProcessId) }这里也有个取舍:直接用 node 进程的父进程 PID 去激活,简单高效,但如果在 VS Code 里跑,它激活的是 VS Code 主窗口而不是某个终端标签。这反而不是坏事——你真正需要的往往就是“看到 VS Code 切回来”,终端标签页已经停在那个视图上了。
补充一个我自己觉得特别有用的增强逻辑:当状态从working变成waiting时,闪烁悬浮球 3 次。这是模型在“等你确认”的信号,比颜色变化醒目得多。
for ($i = 0; $i -lt 3; $i++) { $dot.Fill = [System.Windows.Media.Brushes]::OrangeRed Start-Sleep -Milliseconds 250 $dot.Fill = [System.Windows.Media.Brushes]::Yellow Start-Sleep -Milliseconds 250 }注意闪烁期间要跳过定时器的状态更新,不然颜色会被覆盖。
5. Windows 11 专属踩坑:置顶、DPI 和多显示器
5.1 全屏应用把悬浮球“挤掉”了
前面说的SetWindowPos+Topmost能解决 90% 的置顶问题,但碰到全屏应用仍然无解。全屏游戏、视频播放器、远程桌面连接窗口,它们处于比WS_EX_TOPMOST更高的 Z 序层级,悬浮球会被完全挡住。
这个问题的处理思路不是“硬刚”,而是检测到全屏应用时自动隐藏悬浮球。
$fg = Get-Process -Id (Get-WinEvent -FilterHashtable @{Id=...}) # 拿前台窗口更精确的做法是用 Win32GetForegroundWindow拿前台窗口句柄,然后获取它所在的进程主窗口,判断窗口矩形是否覆盖了整块屏幕。如果覆盖整个工作区且没有标题栏,我就判断当前处于全屏场景,隐藏悬浮球,退出全屏后再恢复显示。
这里贴一段简化的判定逻辑:
Add-Type @" using System; using System.Runtime.InteropServices; public class FgWin { [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect); [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; } } "@ $h = [FgWin]::GetForegroundWindow() $rect = New-Object FgWin+RECT [FgWin]::GetWindowRect($h, [ref]$rect) $screenW = [System.Windows.SystemParameters]::PrimaryScreenWidth $screenH = [System.Windows.SystemParameters]::PrimaryScreenHeight $isFullscreen = ($rect.Right - $rect.Left) -ge $screenW -and ($rect.Bottom - $rect.Top) -ge $screenH这个 4 行判断的生命力比想象中强。有了它,悬浮球不会在你写代码或全屏看文档时碍事。
5.2 125% 缩放下坐标漂移
这是 Windows 11 高 DPI 场景下的老大难问题。
PowerShell 进程默认不声明 DPI awareness,WPF 渲染的内容在 125% 或 150% 缩放下会整体放大,但GetWindowRect拿到的坐标、鼠标拖动的位移、注册表里存的坐标,都还是逻辑像素,放上去就对不齐。
解决办法是在启动时强制声明 PerMonitorV2 DPI awareness:
Add-Type @" using System; using System.Runtime.InteropServices; public class Dpi { [DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value); } "@ # PROCESS_PER_MONITOR_DPI_AWARE_V2 [Dpi]::SetProcessDpiAwarenessContext([IntPtr](-4)) | Out-Null在Add-Type -AssemblyName PresentationFramework之前调用,能避免不少奇怪问题。如果你用的还是老版本 PowerShell,也可以退而求其次用SetProcessDPIAware(),但 PerMonitorV2 对多显示器混合缩放的支持更好。
一个重要判断:这条代码要在创建任何窗口之前执行。放在脚本入口第一行,不要放在函数里。
5.3 多显示器与任务栏遮挡
多显示器场景下,悬浮球可以拖到任意屏幕。但有两个边界情况需要处理:
第一,负坐标。左侧屏幕的主坐标可能是负数。如果用户把悬浮球拖到了系统监视器之外(比如热插拔显示器后),下次启动窗口就找不回来了,屏幕上看不到,任务管理器里一堆残留 ps 进程。
我的做法是启动时对坐标做一次“工作区检查”:
$wa = [System.Windows.SystemParameters]::WorkArea if ($x -lt $wa.Left -or $x -gt $wa.Right -or $y -lt $wa.Top -or $y -gt $wa.Bottom) { $x = $wa.Right - $window.Width - 10 $y = $wa.Top + 10 }注意WorkArea只代表主显示器的工作区,如果要从副显示器取工作区,用System.Windows.Forms.Screen类遍历:
Add-Type -AssemblyName System.Windows.Forms $target = [System.Windows.Forms.Screen]::AllScreens | Where-Object { $_.WorkingArea.Contains($x, $y) } | Select-Object -First 1 if (-not $target) { $target = [System.Windows.Forms.Screen]::PrimaryScreen }这样即便副屏还开着,球也能拖到那块屏的任意角落,不用限制在主屏里。
第二,任务栏遮挡。悬浮球默认放在屏幕右上角,Win11 默认任务栏是居中的,右上角不算太危险。但如果你把任务栏设置成靠右,那右上角就会被“开始”按钮抢占,悬浮球放那儿就会被任务栏压住。我的解决思路是把默认位置改到右上角往左偏移 120 像素,同时上述工作区检查也能兜底。
5.4 开机自启的两种写法
悬浮球这种工具,开机自启几乎是刚需。我试过两种方式,各有适用场景。
第一种,注册表 Run 项:
$startup = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" Set-ItemProperty -Path $startup -Name "ClaudeStatusBall" -Value "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$PSScriptRoot\claude-status-ball.ps1`""注意-WindowStyle Hidden是必需的,不然每次开机都弹一个黑窗口。还要加-NoProfile,避免加载用户自己的 PowerShell Profile 配置导致启动变慢或报错。
第二种,启动文件夹快捷方式,适合不想碰注册表的人:
$link = Join-Path ([Environment]::GetFolderPath("Startup")) "ClaudeStatusBall.lnk" $ws = New-Object -ComObject WScript.Shell $sc = $ws.CreateShortcut($link) $sc.TargetPath = "powershell.exe" $sc.Arguments = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$PSScriptRoot\claude-status-ball.ps1`"" $sc.Save()这里有个隐藏坑:注册表方式启动的进程可能不继承你的用户环境变量。如果 Claude Code 是通过 nvm 或 fnm 安装的,启动脚本里要确保能找到 node 路径,最好在脚本开头显式设置$env:Path,或者用绝对路径调用检测逻辑。
6. 从我的脚本变成你的工具:自定义与扩展
6.1 把可变项收敛成配置
写工具最重要的是别人能改。我把所有可变项收敛到脚本顶部的配置块里:
$Config = @{ PollIntervalMs = 1500 # 状态轮询间隔 WaitingTimeout = 15 # 超过多少秒无输出判定为“等待确认” BallSize = 56 # 悬浮球窗口尺寸 DotSize = 28 # 内部状态圆点尺寸 WorkingColor = "#4CAF50" # 运行中颜色 WaitingColor = "#FFC107" # 等待输入颜色 StoppedColor = "#9E9E9E" # 未运行颜色 InactiveOpacity = 0.45 # 鼠标未悬停时的透明度 ActiveOpacity = 1.0 # 悬停时透明度 }改颜色、改大小、改判定阈值都只动这一段,不用在几百行脚本里大海捞针。
透明度这里多说一句。我一直开着InactiveOpacity,让悬浮球在平时半透明,鼠标悬停时才完全清晰。这样它在屏幕上既不碍眼,又时刻在那里。
$border.Add_MouseEnter({ $window.Opacity = $Config.ActiveOpacity }) $border.Add_MouseLeave({ $window.Opacity = $Config.InactiveOpacity })6.2 加一个全局快捷键
悬浮球解决的是“看状态”的问题,但有人(比如我)更习惯快捷键唤起。我给脚本加了一个Ctrl+Alt+C全局热键,按一下直接激活 Claude Code 窗口,不用移动鼠标去点悬浮球。
PowerShell 里没有原生的RegisterHotKey封装,需要写一小段 C#:
Add-Type @" using System; using System.Runtime.InteropServices; public class HotKey { [DllImport("user32.dll")] public static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk); [DllImport("user32.dll")] public static extern bool UnregisterHotKey(IntPtr hWnd, int id); } "@ # MOD_CONTROL=0x0002, MOD_ALT=0x0001, VK_C=0x43 [HotKey]::RegisterHotKey([IntPtr]::Zero, 1, 3, 0x43)热键消息监听需要窗口消息循环,所以我把悬浮球窗口作为宿主,在HwndSource上挂AddHook监听WM_HOTKEY(消息号 0x0312):
$src = [System.Windows.Interop.HwndSource]::FromHwnd($handle) $src.AddHook({ param($hwnd, $msg, $wParam, $lParam, $handled) if ($msg -eq 0x0312) { Activate-ClaudeWindow $handled = $true } })这样悬浮球和快捷键互为补充:鼠标在场用球,键盘在手用键,两条路都能回到 Claude Code。
6.3 适配 Codex、Aider 等其他 AI CLI
悬浮球的检测逻辑本质上可以抽象成三个接口:命令行匹配、输出日志路径、激活目标。
我做成了配置项,换一个 AI CLI 只需要改匹配规则:
| 工具 | 命令行匹配 | 日志/输出特征 | 激活目标 |
|---|---|---|---|
| Claude Code | CommandLine -match 'claude' | ~/.claude/projects/*.jsonl最近修改时间 | 父进程窗口 |
| Codex CLI | CommandLine -match 'codex' | ~/.codex/sessions/*.jsonl | 父进程窗口 |
| Aider | CommandLine -match 'aider' | 终端输出,无会话日志 | 父进程窗口 |
$Config["Platform"] = "claude" # claude / codex / aider在状态检测函数里加一个 switch:
switch ($Config.Platform) { "claude" { $procs = Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -match 'claude' } } "codex" { $procs = Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -match 'codex' } } "aider" { $procs = Get-CimInstance Win32_Process -Filter "Name='python.exe'" | Where-Object { $_.CommandLine -match 'aider' } } }对了,换平台的时候要注意日志路径差别很大。Aider 默认没有独立的会话日志文件,所以状态判定只能靠“进程存在 + 终端是否有输出”,准确性会弱一些。但至少“一键切回窗口”这个核心交互依然可用。
6.4 我实测后的几点体会
最后说几个我跑了大半个月才确认的经验,都是文档里不会写的东西。
第一,轮询间隔别调成 500ms 以下。我试过为了“更实时”把间隔改到 300ms,结果日志文件频繁读取,磁盘占用和 CPU 都有可见上升。一个状态球而已,1.5 秒的延迟完全感知不到,没必要为了微乎其时的实时性耗资源。
第二,状态判定以日志修改时间为主,别轻易去解析 JSONL 正文做复杂判定。一开始我试图判断每一条tool_use记录是不是还在执行,写出来的逻辑又长又脆——因为 Claude Code 自己的日志格式在版本迭代中变过好几轮。后来改用“文件最后写入时间”这个简单信号,反而更稳,因为它不依赖具体格式。
第三,这类桌面小工具最大的价值不是功能,而是“少打扰”。复杂的交互和花哨的动画都会增加你的认知负担。悬浮球就干一件事——让你知道该不该切回去看 Claude——多余的一件都没有。这个取舍,比技术方案本身更重要。
如果你也整天在终端和编辑器之间切来切去,可以先把状态检测那几行逻辑抄下来,跑通之后再加悬浮球。先把“知道状态”这件事解决,你至少能省掉一半的无意义窗口切换。