
1. 终端一切走Claude 就变成黑箱如果你也靠 Claude Code 写代码我想你一定经历过这种状态思路正顺手一抖切到终端想看看模型跑到哪了结果屏幕上还停在上一步的输出你又只能切回去继续等。等再切过去发现 Claude 其实早就在某个需要确认的节点上卡住了正等着你回复。这就是跑在终端里的 AI 编程助手最磨人的地方它没有窗口但又离不开窗口。用 IDE 插件的时候至少侧边栏还有个进度提示用 Claude Code 这种纯 CLI 工具一切走终端它就变成了一个黑箱。你既不知道任务跑完了没有也不知道它是不是在等你输入。只能靠高频切换窗口去确认而每次切换都在打断心流。写代码本身可能只要十分钟频繁切窗口切出来的焦虑能把半小时都耗进去。这篇文章要解决的就是这个问题。我会从零开始在 Windows 11 上做一个《AI 编程状态悬浮球》一个不占地方的小圆点始终悬浮在屏幕上用颜色告诉你 Claude Code 当前是“正在干活”“等你确认”还是“根本没启动”。单机它可以一键把终端窗口拉回前台省掉反复按 AltTab 的麻烦。整个方案不需要安装任何额外运行时不依赖 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组合原因是它在这个场景下几乎完美匹配需求需求ElectronTauriAHKPowerShell WPF额外运行时Node.jsRust WebView2AHK 解释器Windows 自带内存占用200MB50MB 左右约 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 xmlnshttp://schemas.microsoft.com/winfx/2006/xaml/presentation Width56 Height56 WindowStyleNone AllowsTransparencyTrue BackgroundTransparent TopmostTrue ShowInTaskbarFalse ResizeModeNoResize Border Width48 Height48 CornerRadius24 Background#1E1E1E Ellipse x:NameStatusDot Width28 Height28 Fill#888888 HorizontalAlignmentCenter VerticalAlignmentCenter/ /Border /WindowWindowStyleNone去掉标题栏和边框。AllowsTransparencyTrueBackgroundTransparent让窗口只剩下内容形状没有方形白底。TopmostTrue是基础的置顶。ShowInTaskbarFalse防止任务栏出现一个多余按钮。在 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 开着就在跑 nodeChrome 某个插件也是 nodeWindows 上各种 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 全屏应用把悬浮球“挤掉”了前面说的SetWindowPosTopmost能解决 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 FgWinRECT [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 awarenessWPF 渲染的内容在 125% 或 150% 缩放下会整体放大但GetWindowRect拿到的坐标、鼠标拖动的位移、注册表里存的坐标都还是逻辑像素放上去就对不齐。解决办法是在启动时强制声明 PerMonitorV2 DPI awarenessAdd-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 加一个全局快捷键悬浮球解决的是“看状态”的问题但有人比如我更习惯快捷键唤起。我给脚本加了一个CtrlAltC全局热键按一下直接激活 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_CONTROL0x0002, MOD_ALT0x0001, VK_C0x43 [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 CodeCommandLine -match claude~/.claude/projects/*.jsonl最近修改时间父进程窗口Codex CLICommandLine -match codex~/.codex/sessions/*.jsonl父进程窗口AiderCommandLine -match aider终端输出无会话日志父进程窗口$Config[Platform] claude # claude / codex / aider在状态检测函数里加一个 switchswitch ($Config.Platform) { claude { $procs Get-CimInstance Win32_Process -Filter Namenode.exe | Where-Object { $_.CommandLine -match claude } } codex { $procs Get-CimInstance Win32_Process -Filter Namenode.exe | Where-Object { $_.CommandLine -match codex } } aider { $procs Get-CimInstance Win32_Process -Filter Namepython.exe | Where-Object { $_.CommandLine -match aider } } }对了换平台的时候要注意日志路径差别很大。Aider 默认没有独立的会话日志文件所以状态判定只能靠“进程存在 终端是否有输出”准确性会弱一些。但至少“一键切回窗口”这个核心交互依然可用。6.4 我实测后的几点体会最后说几个我跑了大半个月才确认的经验都是文档里不会写的东西。第一轮询间隔别调成 500ms 以下。我试过为了“更实时”把间隔改到 300ms结果日志文件频繁读取磁盘占用和 CPU 都有可见上升。一个状态球而已1.5 秒的延迟完全感知不到没必要为了微乎其时的实时性耗资源。第二状态判定以日志修改时间为主别轻易去解析 JSONL 正文做复杂判定。一开始我试图判断每一条tool_use记录是不是还在执行写出来的逻辑又长又脆——因为 Claude Code 自己的日志格式在版本迭代中变过好几轮。后来改用“文件最后写入时间”这个简单信号反而更稳因为它不依赖具体格式。第三这类桌面小工具最大的价值不是功能而是“少打扰”。复杂的交互和花哨的动画都会增加你的认知负担。悬浮球就干一件事——让你知道该不该切回去看 Claude——多余的一件都没有。这个取舍比技术方案本身更重要。如果你也整天在终端和编辑器之间切来切去可以先把状态检测那几行逻辑抄下来跑通之后再加悬浮球。先把“知道状态”这件事解决你至少能省掉一半的无意义窗口切换。