☰
给Claude桌面端加一条40像素状态栏:监控token、上下文与成本
2026/10/4 4:26:53 网站建设 项目流程

我日常把 Claude 桌面客户端当主力写作和编码助手用,用得越久越觉得有个地方特别别扭:对话框底部永远干干净净,你发出的每一轮消息消耗了多少上下文、当前到底在跑哪个模型、一次长回答等了多久、这次对话累计烧了多少 token,统统看不到。这些信息全部被锁在 Electron 壳子的黑盒里。前阵子看到有人把“状态栏”这个概念搬进 Claude 桌面端的想法——就是像 vim、tmux 底部那种常驻信息条——我觉得这个方向太对了,就自己动手做了一条,把模型名、上下文占用、token 用量、响应耗时、成本估算全部塞进底部一条 40 像素的横条里。这篇文章把整个方案的思考过程、实现细节和踩过的坑完整写出来,给同样盯着 token 消耗和上下文窗口发愁的人一份可以直接抄的作业。

1. 为什么桌面客户端需要一条状态栏

1.1 状态栏不是装饰,是信息密度问题

用过 vim 的人都知道,底部那条状态栏不是摆着好看的,它本质上是一个“环境感知显示器”:当前文件、光标位置、模式、Git 分支、行号,所有跟手头任务直接相关的状态都被压缩在一行里随时可见。tmux 也一样,会话名、时间、负载、窗口列表,一眼扫过去就知道自己身处哪个环境。

Claude 桌面客户端缺的正是这个东西。聊天界面本身把“对话内容”这个维度做到了极致,但把“运行状态”这个维度完全藏起来了。你会去猜模型版本,去数着字数估算 token,去截个时间戳算响应速度。偶尔用 API 调试时为了拿 usage 字段还得翻日志。这些零散动作本质都是在手工拼凑一条状态栏,既然这样,不如直接做一个。

1.2 一条状态栏能替你回答哪些问题

我做完这条状态栏之后,日常使用中它至少能回答下面几类问题:

  • 当前模型和版本:今天跑的是哪个模型、哪个日期版本,不用再点开设置猜。
  • 上下文窗口占用:当前对话已经吃掉了多少上下文,还剩多少。这个对长文档、长对话场景特别关键,快满的时候我会主动开新会话或精简内容。
  • 单次请求用量:最近一轮请求的输入 token、输出 token 分别是多少,对判断“是不是我提示词写太啰嗦了”有直接帮助。
  • 累计成本估算:按模型单价换算的当前会话成本。对把 Claude 当生产力工具用的团队或个人来说,这直接关系到预算管理。
  • 响应耗时:从发起请求到首个 token 的时间,以及总耗时。网络慢、服务端排队、输出长度拉满,都能从这里看出来。
  • 连接状态:API 端点连通性、当前走的是官方直连还是本地转发,调试时省一大半事。

1.3 什么人群会真正需要它

我总结下来,下面几类人最值得花半小时做这件事:重度日常用户,每天几十轮对话,想知道消耗量级;API 开发者和自动化脚本使用者,本来就对 usage、token、延迟敏感,聊个天也希望随时看到;做预算和报销的人,需要把“这周花了多少 API 费用”变成可视化数据;还有纯粹的技术洁癖患者,觉得一个工具软件不给用户任何运行指标就是不完整。

如果你只是偶尔问几个问题,那这条状态栏确实可有可无。但只要你开始频繁依赖它,就会发现自己回不去了。

2. 整体设计:状态栏放什么、数据从哪来

2.1 布局与信息优先级

状态栏最容易犯的错是贪多,把所有能拿到的数据都塞进去,结果一行长得没法看。我遵循的原则是:左中右三段式布局,左边放“当前会话身份信息”,中间放“本次请求的实时指标”,右边放“累计和状态类信息”。

具体字段分配如下:

区域显示内容刷新时机
左模型名和版本号每次请求开始时更新
中本轮 input tokens / output tokens每轮响应到达后更新
中请求耗时(首 token 延迟和总耗时)响应结束时更新
右上下文占用百分比,带进度条每次消息流式更新时刷新
右当前会话累计成本和连接状态每次响应后累加

上下文百分比我特意用一个小进度条表示,而不是只给数字。人眼对“一格一格变满”的敏感度远高于对数字变化的感知,这对判断“对话是不是快写满了”非常有用。

2.2 三种数据获取方案对比

状态栏的数据不是现成的,需要从正在运行的客户端里“挖”。我实际比较过三种方案:

  • DOM 抓取:直接用脚本读取聊天界面里的元素,把界面上已经渲染出来的文本提取出来。优点是简单,缺点是你只能拿到客户端愿意展示的内容,而且 Electron 内部 DOM 结构在每次升级后可能全变,脚本很容易一夜之间失效。
  • 网络请求拦截:在 Electron 层面拦截发往 Anthropic API 的请求,从请求和响应里拿模型名、token 用量、耗时。优点是数据完全真实、结构化,不依赖界面 DOM,而且能拿到界面上根本不展示的底层指标。
  • Claude Code CLI 日志:如果你同时在用命令行版的 Claude Code,它会把 usage 信息写在本地日志里,解析日志也是个数据来源。缺点是你得同时跑 CLI,桌面端的对话它记不到。

2.3 为什么我最后选网络拦截

我最终选了网络请求拦截这条路。原因很实在:桌面客户端本质上是 Electron 应用,所有对话都要走 HTTP 请求到 Anthropic API,这些请求里天然携带了模型名、用量统计、耗时等所有关键信息。拦截这一层,就等于在“数据的源头”架了一台仪表,既不依赖别人愿意在界面上展示什么,也不怕 UI 改版。

实现上有两条路:一是直接改 Electron 主进程的代码,把webRequest监听写进应用内部;二是用 Chrome DevTools Protocol(CDP)从外部连接调试端口,在运行时注入脚本并监听网络事件。第一条路数据最完美,但每次客户端更新可能被重置;第二条路不用碰软件本体,维护成本低。我生产用的就是 CDP 方案,它足够稳定也足够优雅。

3. 核心实现:CDP 注入与响应头解析

3.1 用远程调试端口启动桌面端

CDP 方案的第一步是把 Claude 桌面端以带调试端口的方式启动。Electron 应用普遍支持 Chromium 的调试开关,以 macOS 为例,命令是:

/Applications/Claude.app/Contents/MacOS/Claude --remote-debugging-port=9222

Windows 上路径一般是:

& "$env:LOCALAPPDATA\Programs\Claude\Claude.exe" --remote-debugging-port=9222

Linux 上通常是:

/opt/Claude/claude --remote-debugging-port=9222

启动后,直接在本地浏览器打开http://127.0.0.1:9222/json,能看到一个 JSON 列表,里面记录了当前可调试的页面类型,其中type为page的那一条就是聊天主界面。

3.2 CDP 连接与注入状态栏 DOM

拿到目标页面后,通过 WebSocket 连上它的webSocketDebuggerUrl,就可以用Runtime.evaluate往页面里塞 DOM 了。注入脚本的核心逻辑很简单:在页面底部创建一个 40 像素高的 fixed 元素,再通过Runtime.evaluate暴露一个全局更新函数,后续数据来了就直接叫这个函数刷新。

我用的连接脚本大致长这样:

// statusline-client.js const http = require('http'); const WebSocket = require('ws'); function getJson(url) { return new Promise((resolve, reject) => { http.get(url, (res) => { let data = ''; res.on('data', (chunk) => (data += chunk)); res.on('end', () => resolve(JSON.parse(data))); }).on('error', reject); }); } function cdpSend(ws, id, method, params = {}) { return new Promise((resolve, reject) => { const handler = (msg) => { const parsed = JSON.parse(msg); if (parsed.id === id) { ws.off('message', handler); parsed.error ? reject(new Error(parsed.error.message)) : resolve(parsed.result); } }; ws.on('message', handler); ws.send(JSON.stringify({ id, method, params })); }); } (async () => { const targets = await getJson('http://127.0.0.1:9222/json'); const page = targets.find((t) => t.type === 'page' && t.url.includes('claude')); const ws = new WebSocket(page.webSocketDebuggerUrl); await new Promise((r) => ws.on('open', r)); // 注入状态栏 DOM await cdpSend(ws, 1, 'Runtime.evaluate', { expression: ` (function(){ if (document.getElementById('claude-statusline')) return; var bar = document.createElement('div'); bar.id = 'claude-statusline'; bar.style.cssText = 'position:fixed;bottom:0;left:0;right:0;height:40px;' + 'background:#1e1e2e;color:#cdd6f4;font:12px/40px monospace;' + 'padding:0 16px;display:flex;gap:24px;z-index:999999;' + 'border-top:1px solid #313244;'; document.body.appendChild(bar); window.__updateStatusLine = function(fields) { var parts = []; if (fields.model) parts.push('model: ' + fields.model); if (fields.inputTokens) parts.push('in: ' + fields.inputTokens); if (fields.outputTokens) parts.push('out: ' + fields.outputTokens); if (fields.cost) parts.push('cost: $' + fields.cost.toFixed(4)); if (fields.elapsed) parts.push(fields.elapsed + 'ms'); if (fields.ctxPct) { var pct = Math.min(100, Math.max(0, fields.ctxPct)); var bars = 20; var filled = Math.round(pct / 100 * bars); parts.push('ctx: [' + '='.repeat(filled) + ' '.repeat(bars - filled) + '] ' + pct + '%'); } bar.textContent = parts.join(' | '); }; })(); `, }); console.log('statusline injected'); })();

这段脚本里有个细节值得说明:我把更新函数挂在window上,而不是直接暴露元素引用。这样后续 CDP 调用可以用同一命名空间反复更新,而且页面里其他注入脚本也能复用,互不干扰。

3.3 从 X-LLM-Usage 响应头读取真实用量

DOM 注入只是搭好了壳,真正的数据核心在网络拦截。CDP 的Network.enable开启后,所有网络请求和响应事件都会推送过来。我们要盯两个事件:Network.requestWillBeSent和Network.responseReceived。

Anthropic API 在流式响应里,最省事的用量埋点是响应头x-llm-usage。这个头是一段 URL 编码后的 JSON,包含input_tokens、output_tokens、total_tokens,在比较新的版本里还会有cache_read_input_tokens、cache_creation_input_tokens这类缓存相关字段。判断某条响应是不是聊天接口,最简单的办法是看请求 URL 里带不带/v1/messages。

监听逻辑:

// 在同一个 WebSocket 连接上继续追加事件监听 let inputTokens = 0, outputTokens = 0, startTime = null, lastCost = 0; ws.on('message', (raw) => { const msg = JSON.parse(raw); if (!msg.method) return; if (msg.method === 'Network.requestWillBeSent') { const req = msg.params.request; if (req.url.includes('/v1/messages')) { startTime = Date.now(); } } if (msg.method === 'Network.responseReceived') { const resp = msg.params.response; if (resp.url.includes('/v1/messages')) { const headers = resp.headers; // 响应头里的 x-llm-usage 是 URL 编码的 JSON if (headers['x-llm-usage']) { const usage = JSON.parse(decodeURIComponent(headers['x-llm-usage'])); inputTokens += usage.input_tokens || 0; outputTokens += usage.output_tokens || 0; const elapsed = Date.now() - startTime; const model = (headers['x-llm-model'] || 'claude').trim(); const cost = estimateCost(model, usage.input_tokens, usage.output_tokens); currentView = ws; cdpSend(ws, 2, 'Runtime.evaluate', { expression: `window.__updateStatusLine(${JSON.stringify({ model, inputTokens: inputTokens, outputTokens: outputTokens, cost, elapsed, ctxPct: getCtxUsage(usage), })})`, }); } } } });

注意几个容易踩的细节。第一,x-llm-usage是 URL 编码的 JSON,一定要decodeURIComponent之后才能JSON.parse,直接 parse 会报错。第二,流式响应有可能一个请求多次 push 响应头,代码里我用了累加而不是覆盖,避免丢数据。第三,响应头在 CDP 里所有 key 都是小写,别写X-LLM-Usage,要写x-llm-usage。

3.4 状态栏渲染与阈值变色

数据拿到手之后,最后一步是让状态栏读起来不费劲。我做了两处视觉处理。

一是上下文占用百分比加了颜色分级:小于 50% 显示默认色,50% 到 80% 变黄色,超过 80% 变红色并闪烁。原理就是人对颜色告警的响应速度远快于文字判断。

二是成本数字的精度控制。单轮 token 换算出来的成本经常是小数点后四位,全显示出来非常吵。我在状态栏里只保留四位有效位,并且在累计成本超过 1 美元时改用两位小数,这样既不影响记账精度,也不会让状态栏变成乱码。

渲染部分在注入的 DOM 脚本里更新即可,不用来回做 CDP 调用。每一条最新事件都直接重绘整条状态栏,反正只有几十个字的文本,性能毫无压力。

4. 实操过程:一条能用的状态栏诞生的完整步骤

4.1 环境准备

整套方案依赖 Node.js 环境,版本建议不低于 16,因为要用原生的fetch和比较新的语法。另外需要ws库,安装命令:

mkdir claude-statusline && cd claude-statusline npm init -y npm install ws

不需要任何前端框架,整个注入脚本就是原生 DOM 操作,所以依赖非常轻。这么做的好处是脚本可以被任何支持 WebSocket 的语言复刻,比如 Python 版只要换成websocket-client就能跑,核心流程完全一样。

4.2 把客户端以调试模式拉起

这一步主要是确认端口能出数据。启动前先关掉正在运行的 Claude 桌面端,避免两个实例抢同一个用户数据目录。启动后,在浏览器访问http://127.0.0.1:9222/json,如果能看到 JSON 列表,就说明调试口已经开了。

这个 JSON 列表里有几个关键字段:webSocketDebuggerUrl、type、url。脚本里按type筛选 page 是基本操作,但如果有多个页面,最好再加一层url过滤,避免连到 DevTools 面板或别的辅助页面上去。

4.3 跑注入脚本验证状态栏

把上面第三节的网络监听代码保存为statusline-client.js,运行:

node statusline-client.js

正常情况下,控制台会打印statusline injected,桌面客户端底部会立刻出现一条深色状态栏。此时在聊天框随便发一句话,状态栏应该能在响应流结束后刷出模型名、token 数和耗时。

如果注入成功但数据没出来,优先检查是不是请求 URL 里的路径和你判断的不一致。Claude 桌面端在不同版本调用的 API 端点路径不完全固定,最稳妥的办法是把resp.url直接打印出来看一眼,再决定匹配规则。

4.4 做成一条命令的启动器

每次都要手动先启动客户端再跑脚本,太麻烦了。我做了一个简单的启动器脚本,一条命令搞定全部环节:

#!/usr/bin/env bash # run-claude-statusline.sh pkill -f "Claude" || true sleep 1 # 后台起服务,日志丢弃 node statusline-client.js >/tmp/statusline.log 2>&1 & # 等端口就绪后启动客户端 sleep 1 /Applications/Claude.app/Contents/MacOS/Claude --remote-debugging-port=9222 &

这个脚本有个隐藏的时序问题:node statusline-client.js启动时如果客户端还没起来,/json列表是空的,它会直接报错退出。我的解决办法是在脚本里加重试逻辑,每 500ms 探测一次端口,最多重试 20 次,客户端起来后再连接。

实测这个方案在 macOS、Windows(Git Bash)、Linux 上都能跑通,不同平台只需要改可执行文件路径。

4.5 可选进阶:asar 补丁方案

CDP 方案有一个小缺点:每次都得手动开调试端口,虽然启动器脚本能自动化,但终究多了一层。追求更“原生”体验的话,可以走 asar 补丁路线。

Claude 桌面端的代码打包在app.asar里,流程是:备份原文件,用npx asar extract解开,在入口文件里加一行 preload 脚本引用,让主进程在创建窗口时注入我们的 status 脚本,再npx asar pack重新打包放回去。

这个方案做出来的效果是开箱即用,不需要调试端口。代价也很明显:客户端每次自动更新都会覆盖掉你的改动,需要重新打补丁;而且动主进程代码等于改了应用签名区域的依赖关系,某些平台可能出现启动校验失败。我的建议是,日常玩耍用 CDP 方案足够,有时间折腾再用 asar 方案做成“分发版”。

5. 常见问题与排查实录

5.1 连不上 9222 端口

最常见的原因是前一个客户端实例没退干净。Electron 应用对单例模式处理得很好,第二个实例往往只是向第一个实例发送消息后立刻退出,所以你第二次启动时看到的进程可能根本没带调试参数。解决办法是先把所有 Claude 相关进程杀掉,再重新用带参命令启动。

另一个原因是 macOS 上如果用户从 Dock 图标启动,命令行参数会被吃掉。必须确认你是从终端直接执行二进制文件,而不是双击图标。

5.2 状态栏注入成功但数据不动

数据不动基本可以判定是网络事件没匹配上。三个排查方向:第一,打开 CDP 日志,看Network.responseReceived到底有没有推过来,没有的话说明Network.enable调用太晚,错过了请求;第二,打印实际请求 URL,确认你的匹配字符串没写错;第三,确认是用流式还是非流式,Claude 桌面端默认流式,响应头里x-llm-usage几乎必然存在,如果拿不到就检查是不是响应头帽写错了大小写。

5.3 上下文百分比显示成 NaN

出现 NaN 一定是usage对象里的字段名不是你预期的那个。不同 API 版本字段名有出入,有的叫input_tokens,有的可能带cache_read_input_tokens,算上下文占用时要把缓存类 token 也算进去。最稳的做法是先JSON.stringify打印原始 usage 对象,看清楚字段再写计算逻辑。

5.4 客户端自动更新后被还原

这是所有注入方案都绕不开的宿命。Electron 应用升级时会重建整个安装目录,CDP 方案好在不碰应用本体,更新后只要重新跑一遍启动器就能恢复。asar 补丁方案就得重新打一次补丁。我的经验是准备两个 shell 脚本:一个负责“启动并注入”,一个负责“打完补丁后验证”,每次更新后跑一遍就能快速恢复工作状态。

5.5 常见问题速查表

现象可能原因解决办法
端口 9222 无法访问客户端未带调试参数启动杀进程后用--remote-debugging-port重启
连接成功但没状态栏注入时机太早,DOM 未加载等待document.body存在后再注入
状态栏有了但数据全 0x-llm-usage解析失败打印原始响应头,核对 URL 编码和字段名
上下文百分比 NaNusage 字段名不符打印完整 usage JSON 后适配
自动更新后失效应用升级覆盖了旧环境用启动器脚本重新拉起 CDP 注入
状态栏挡住底部交互反馈按钮或输入框被遮把 bar 高度降到 32px 以下,或用 pointer-events:none

5.6 使用心态和安全提醒

最后必须说一句:给桌面客户端做信息增强,本质是在自己电脑上调自己的工具,所有改动都应该停留在本地。不要从网上下载来历不明的“破解版”或“增强包”,也不要把自己的 API 密钥、响应日志这类敏感数据交给不信任的脚本。做这种自定义功能,自己写、自己审、自己用,是最安全也最有成就感的姿势。改动前记得备份app.asar,真出问题还能一键恢复。

我个人在实际操作中的体会是,这套方案里最值钱的不是那行状态栏本身,而是它逼我把 Electron 应用调试、CDP 协议、网络请求拦截这套链路完整摸了一遍。如果你只想要结果,可以直接抄上面的脚本;如果你愿意多花半小时把每一段代码都读明白,以后遇到任何 Electron 应用想做信息增强,你都能举一反三。最后再分享一个小技巧:状态栏更新函数挂在window上之后,你还可以在客户端里手动执行你调试脚本里的任何表达式,临时加一个字段、改一个颜色,都不用重新注入,随改随生效,调试体验相当顺滑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询