ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

给Claude桌面端加一条40像素状态栏:监控token、上下文与成本

给Claude桌面端加一条40像素状态栏:监控token、上下文与成本 我日常把 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 ProtocolCDP从外部连接调试端口在运行时注入脚本并监听网络事件。第一条路数据最完美但每次客户端更新可能被重置第二条路不用碰软件本体维护成本低。我生产用的就是 CDP 方案它足够稳定也足够优雅。3. 核心实现CDP 注入与响应头解析3.1 用远程调试端口启动桌面端CDP 方案的第一步是把 Claude 桌面端以带调试端口的方式启动。Electron 应用普遍支持 Chromium 的调试开关以 macOS 为例命令是/Applications/Claude.app/Contents/MacOS/Claude --remote-debugging-port9222Windows 上路径一般是 $env:LOCALAPPDATA\Programs\Claude\Claude.exe --remote-debugging-port9222Linux 上通常是/opt/Claude/claude --remote-debugging-port9222启动后直接在本地浏览器打开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 21 # 等端口就绪后启动客户端 sleep 1 /Applications/Claude.app/Contents/MacOS/Claude --remote-debugging-port9222 这个脚本有个隐藏的时序问题node statusline-client.js启动时如果客户端还没起来/json列表是空的它会直接报错退出。我的解决办法是在脚本里加重试逻辑每 500ms 探测一次端口最多重试 20 次客户端起来后再连接。实测这个方案在 macOS、WindowsGit 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:none5.6 使用心态和安全提醒最后必须说一句给桌面客户端做信息增强本质是在自己电脑上调自己的工具所有改动都应该停留在本地。不要从网上下载来历不明的“破解版”或“增强包”也不要把自己的 API 密钥、响应日志这类敏感数据交给不信任的脚本。做这种自定义功能自己写、自己审、自己用是最安全也最有成就感的姿势。改动前记得备份app.asar真出问题还能一键恢复。我个人在实际操作中的体会是这套方案里最值钱的不是那行状态栏本身而是它逼我把 Electron 应用调试、CDP 协议、网络请求拦截这套链路完整摸了一遍。如果你只想要结果可以直接抄上面的脚本如果你愿意多花半小时把每一段代码都读明白以后遇到任何 Electron 应用想做信息增强你都能举一反三。最后再分享一个小技巧状态栏更新函数挂在window上之后你还可以在客户端里手动执行你调试脚本里的任何表达式临时加一个字段、改一个颜色都不用重新注入随改随生效调试体验相当顺滑。
返回列表