
在实际开发中越来越多 Windows 开发者把 Claude Code 和 Codex 这类命令行 AI 编程工具放进日常编码流程。它们能补全代码、改写函数、生成测试但使用额度却常常变成“黑盒”项目写到一半调用突然失败才知道额度已经耗尽组织订阅被禁用时错误信息更是直接打断工作流。CC Meter 就是针对这个场景的一类 Windows 托盘仪表盘工具它常驻任务栏右下角用一个小图标实时展示 Claude Code 和 Codex 的额度占用情况。下面从需求背景、技术选型、最小实现、数据读取、验证方式和排错路径几个层面完整拆解这类托盘仪表盘工具的关键设计。1. 先理解 CC Meter 要解决什么问题1.1 命令行 AI 编程工具的额度为什么难跟踪Claude Code 和 Codex 并不是单纯的本地命令它们背后连接着云端模型服务和对应账户体系。使用方通常会受到两种限制一种是订阅计划本身的额度比如一定周期内可用的消息数、项目数或续费额度另一种是更底层的调用速率限制也就是短时间内请求过于频繁会被临时拒绝。这两类限制的共同特点是平常看不见触发时才报错。错误信息往往出现在任务执行到一半的时候比如“your organization has disabled claude subscription access”这类提示用户不得不停下来处理账户问题。更麻烦的是如果同时使用 Claude Code 和 Codex两个工具各自的限制标准、重置周期、查询方式都不一样靠脑子记住剩余量完全不现实。CC Meter 的核心价值就在这里把两个独立 CLI 工具的额度状态聚合到一个 Windows 托盘图标上让开发者瞄一眼就知道当前是否安全而不需要主动去执行查询命令。1.2 托盘仪表盘相比命令行查询的优势额度查询并不是只有托盘一条路但不同方式的体验差异很大放在一起对比会更清楚。查询方式优点缺点适用场景手动执行 CLI 命令数据直接、结果权威打断思路、要记命令、还要解析输出偶尔想确认准确数值打开 Web 控制台信息完整可看明细慢、容易分心需要查账单、历史和明细托盘仪表盘常驻一眼可见、自动刷新、低打扰只能展示关键状态精度有限日常编码中持续关注额度托盘方案的本质是“状态外置”。它不要求用户主动发起任何操作而是由后台定时轮询把消耗情况编译成一个极简的视觉信号是绿色、黄色还是红色。对开发者来说这比在命令行和网页之间来回切换要自然得多。1.3 CC Meter 类工具的设计目标一个合格的 CC Meter 工具需要满足五个目标常驻不打扰只有托盘图标没有主窗口不抢焦点。自动刷新按固定周期获取最新额度不需要手动触发。状态分级把连续百分比转换为 Normal、Warning、Critical 几个离散状态。详情可查鼠标悬停时显示来自 Claude Code 和 Codex 的原始文本。降级不崩溃某个数据源不可用时图标保持“未知”状态而不是卡死或报错。这五个目标直接影响后续所有技术选型。比如选 C# WinForms 就是因为它对托盘图标支持最原生选聚合器而不是直接读两个命令则是为了把失败隔离在单条数据链路里。2. Windows 托盘工具的技术选型2.1 常见方案对比在 Windows 上做常驻托盘小工具常见方案有 C# WinForms、WPF、Python 加 pystray、Electron 和 Tauri。它们都能实现“右下角放一个图标”但开发体验和运行成本差别明显。方案托盘支持打包体积资源占用开发效率适合场景C# WinForms .NET 8原生 NotifyIcon小低高Windows 专用常驻工具WPF需要额外封装较大中中需要复杂窗口界面Python pystray第三方库中中高快速原型、跨平台Electron社区方案大高中需要 Web 技术栈界面Tauri插件支持较小低中跨平台 Web UI对于 CC Meter 这种“只显示一个托盘图标、定时读数据”的场景C# WinForms 是最稳的选择。它直接内置NotifyIcon类不依赖第三方托盘库单文件发布后体积小运行时内存占用远低于 Electron系统托盘、上下文菜单、图标绘制都是原生能力。2.2 环境准备清单在开始写代码前先确认基础环境避免后面排查半天发现是环境问题。检查项要求说明操作系统Windows 10 或 Windows 11 64 位托盘机制和图标渲染表现最稳定.NET SDK8.0 或更高如果从源码构建需要 SDK只使用发布包则不需要Claude Code已安装并完成登录安装方式以官方文档为准常见是 npm 全局安装Codex CLI已安装并完成登录同样以官方文档为准终端PowerShell 或 Windows Terminal用于运行构建命令和手动验证 CLI 输出如果 Claude Code 和 Codex 是通过 npm 安装的常见安装命令是npm install -g anthropic-ai/claude-code和npm install -g openai/codex。不同版本、不同操作系统的安装方式可能有差异落地前先在命令行运行claude --version和codex --version确认可用。2.3 项目结构设计以 C# 实现为例推荐把项目拆成三层UI 层负责托盘图标和菜单服务层负责读取和聚合额度模型层定义数据结构。CCMeter/ ├── CCMeter.csproj ├── app.manifest ├── Program.cs ├── CcMeterContext.cs ├── Services/ │ ├── IUsageProvider.cs │ ├── ClaudeUsageProvider.cs │ ├── CodexUsageProvider.cs │ └── UsageAggregator.cs ├── Ui/ │ └── GaugeRenderer.cs └── Logs/ └── FileLogger.cs这个结构的主要作用是隔离变化。Claude Code 和 Codex 的输出格式可能随版本变化但它们只影响各自的 Provider托盘菜单和绘制逻辑不关心数据来自哪里只关心聚合后的百分比和状态。这样修 bug 时不需要在 UI 代码里翻找命令解析逻辑。3. 用最小示例展示托盘仪表盘的核心实现下面代码用于说明 CC Meter 这类工具的核心实现思路实际项目需要根据自己的包名、数据来源和 .NET 版本调整。示例假设用 .NET 8 的 WinForms 工程。先看工程文件Project SdkMicrosoft.NET.Sdk PropertyGroup OutputTypeWinExe/OutputType TargetFrameworknet8.0-windows/TargetFramework UseWindowsFormstrue/UseWindowsForms Nullableenable/Nullable ImplicitUsingsenable/ImplicitUsings ApplicationManifestapp.manifest/ApplicationManifest /PropertyGroup /ProjectOutputType使用WinExe这样程序启动时不会弹出黑色控制台窗口。UseWindowsForms必须为 truenet8.0-windows指定 Windows 目标框架。app.manifest用来声明 DPI 感知避免高分屏下图标模糊。3.1 先定义数据读取接口不同 CLI 工具的读取方式不同所以第一步是抽象一个统一接口。using System.Threading; using System.Threading.Tasks; namespace CCMeter.Services; public interface IUsageProvider { string ProviderName { get; } TaskProviderUsage? GetUsageAsync(CancellationToken ct); } public sealed record ProviderUsage( string ProviderName, decimal? UsedPercent, // 0 到 100null 表示未知 decimal? UsedValue, // 已使用量null 表示未知 decimal? LimitValue, // 额度总量null 表示未知 string StatusText, // 用于悬浮提示的简短文本 string RawText); // 原始输出便于排查接口只暴露一个GetUsageAsync方法。返回ProviderUsage?而不是直接抛异常是因为数据源失败是常态调用方必须处理“未知”状态。UsedPercent用decimal?表示解析不出来就是 null绝不用 0 冒充——0 是有明确含义的未知和 0 会让用户产生错误判断。3.2 实现托盘上下文与定时刷新WinForms 里不用 Form而是用ApplicationContext管理托盘图标生命周期。using System; using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; using CCMeter.Services; using CCMeter.Ui; namespace CCMeter; public sealed class CcMeterContext : ApplicationContext { private readonly NotifyIcon _notifyIcon; private readonly System.Windows.Forms.Timer _refreshTimer; private readonly UsageAggregator _aggregator new(); private CancellationTokenSource _cts new(); public CcMeterContext() { _notifyIcon new NotifyIcon { Icon GaugeRenderer.CreateIcon(0, UsageState.Unknown), Text CC Meter: 初始化中, Visible true }; var menu new ContextMenuStrip(); menu.Items.Add(立即刷新, null, async (_, _) await RefreshAsync()); menu.Items.Add(退出, null, (_, _) Exit()); _notifyIcon.ContextMenuStrip menu; _notifyIcon.DoubleClick async (_, _) await RefreshAsync(); _refreshTimer new System.Windows.Forms.Timer { Interval 30_000 }; _refreshTimer.Tick async (_, _) await RefreshAsync(); _refreshTimer.Start(); _ RefreshAsync(); } private async Task RefreshAsync() { try { var snapshot await _aggregator.GetOverviewAsync(_cts.Token); var (icon, text) GaugeRenderer.CreateTrayState(snapshot); _notifyIcon.Icon icon; _notifyIcon.Text text.Length 63 ? text[..63] : text; } catch (Exception ex) { FileLogger.Write(ex.ToString()); } } private void Exit() { _refreshTimer.Stop(); _cts.Cancel(); _notifyIcon.Visible false; _notifyIcon.Dispose(); Application.Exit(); } }这里有几个关键点。第一定时器用的是System.Windows.Forms.Timer它的Tick在 UI 线程执行所以可以直接更新NotifyIcon不需要额外做线程切换。如果换成System.Timers.Timer回调在线程池线程执行更新 UI 就必须通过Invoke代码会复杂很多。第二NotifyIcon.Text有长度限制超过 63 个字符会被系统截断或抛出异常所以刷新前要做截断处理。第三RefreshAsync内部整体 try-catch异常只写日志不让程序崩溃也不弹错误框。用户正在写代码时托盘工具弹窗是最差体验。第四菜单和双击事件里都用asynclambda 调用异步方法这是典型的 async void 场景。正因为事件处理器不能返回 Task内部必须有完整异常处理否则异常会直接落到 UI 线程未处理异常上。3.3 绘制仪表盘图标托盘图标的仪表盘效果实际上是动态生成 16x16 位图再转成 Icon。using System; using System.Drawing; using System.Drawing.Drawing2D; using CCMeter.Services; namespace CCMeter.Ui; public enum UsageState { Unknown, Normal, Warning, Critical } public static class GaugeRenderer { public static (Icon Icon, string Text) CreateTrayState(UsageOverview overview) { var state overview.MaxUsedPercent switch { 70m UsageState.Normal, 90m UsageState.Warning, _ UsageState.Critical }; var text string.Join( | , overview.Providers.Select(p ${p.ProviderName}: {p.StatusText})); return (CreateIcon(overview.MaxUsedPercent, state), text); } public static Icon CreateIcon(decimal percent, UsageState state) { using var bmp new Bitmap(16, 16); using (var g Graphics.FromImage(bmp)) { g.SmoothingMode SmoothingMode.AntiAlias; var rect new Rectangle(1, 1, 14, 14); g.DrawArc(Pens.Gray, rect, 0, 360); var sweep (float)(Math.Clamp(percent, 0, 100) * 360m / 100m); var pen state switch { UsageState.Normal Pens.Green, UsageState.Warning Pens.Orange, UsageState.Critical Pens.Red, _ Pens.Gray }; if (sweep 0) { g.DrawArc(pen, rect, -90, sweep); } } IntPtr hIcon bmp.GetHicon(); try { return (Icon)Icon.FromHandle(hIcon).Clone(); } finally { DestroyIcon(hIcon); } } [System.Runtime.InteropServices.DllImport(user32.dll)] [return: System.Runtime.InteropServices.MarshalAs( System.Runtime.InteropServices.UnmanagedType.Bool)] private static extern bool DestroyIcon(IntPtr handle); }绘制逻辑是经典圆弧仪表盘画一个灰色底圆再按百分比画一个从 12 点方向顺时针延伸的彩色圆弧。颜色状态分四档灰色表示未知绿色正常橙色警告红色临界。这里必须注意GetHicon返回的句柄要调用DestroyIcon释放否则每次刷新都会泄漏一个 GDI 句柄。托盘工具通常长期运行几小时不退出句柄泄漏到一定数量会导致图标绘制异常。3.4 聚合器与失败隔离聚合器负责同时调用多个 Provider并计算最终展示的百分比。using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; namespace CCMeter.Services; public sealed class UsageAggregator { private readonly IReadOnlyListIUsageProvider _providers new IUsageProvider[] { new ClaudeUsageProvider(), new CodexUsageProvider() }; public async TaskUsageOverview GetOverviewAsync(CancellationToken ct) { var results new ListProviderUsage(); foreach (var provider in _providers) { try { var usage await provider.GetUsageAsync(ct); if (usage is not null) { results.Add(usage); } } catch (OperationCanceledException) { throw; } catch { // 单个数据源失败不拖垮整体记录日志后继续 FileLogger.Write(${provider.ProviderName} 读取失败); } } var maxPercent results .Where(r r.UsedPercent.HasValue) .Select(r r.UsedPercent!.Value) .DefaultIfEmpty(0) .Max(); return new UsageOverview(results, maxPercent); } } public sealed record UsageOverview( IReadOnlyListProviderUsage Providers, decimal MaxUsedPercent);聚合策略是用最大值而不是平均值。原因很简单CC Meter 要做的是风险提醒只要 Claude Code 或 Codex 有一个接近上限用户就应该知道平均一下反而会把风险稀释掉。3.5 关键参数说明托盘工具虽然简单但参数如果拍脑袋定后面会踩很多坑。参数示例值调大的影响调小的意义建议刷新间隔30 秒数据滞后频繁启动子进程、CPU 升高默认 30 到 60 秒非活跃可延长警告阈值70%提醒太晚提醒频繁70% 起步按个人习惯调整临界阈值90%风险感知慢过于紧张90% 比较合理日志保留7 天占用磁盘难以回溯启动时清理超过 7 天的日志刷新间隔是最容易出问题的参数。很多人一开始习惯设置 5 秒刷新觉得这样“实时”。但每次刷新都要启动 CLI 子进程、等待输出、解析如果两个工具一起轮询CPU 和进程创建开销都不小甚至可能触发 CLI 服务端的速率限制。托盘仪表盘并不需要毫秒级实时30 秒到 1 分钟已经能覆盖绝大多数场景。4. Claude Code 和 Codex 的额度数据怎么接CC Meter 类工具最核心也最容易出错的部分是数据源读取。这里没有通用的“官方查询接口”可以直接在代码里写死因为不同版本、不同登录方式、不同账户类型返回的数据结构都不一样。稳妥做法是先搞清楚当前 CLI 版本能提供什么。4.1 先手动确认 CLI 的输出能力写代码之前先手动执行一遍查询命令确认输出格式。可以在命令行运行claude --help和codex --help看当前版本是否支持status、limits、usage、--json等参数。熟悉命令之后重点确认三件事是否支持结构化输出比如 JSON。结构化输出最容易解析。登录信息保存在哪里。很多 CLI 会把登录 token 和配置放在用户目录下Provider 可以通过读取配置文件判断是否已登录。额度字段是否包含“已用、总量、百分比”三个值。有的只提供剩余量需要自己换算。由于 CLI 版本更新频繁网上教程里的命令可能已经失效所以每次升级工具后都要重新过一遍--help输出。4.2 用子进程调用 CLI 并捕获输出Provider 最常见的实现方式是用子进程调用 CLI捕获标准输出然后解析。下面是 Universal 的子进程调用示例。using System.Diagnostics; using System.Text; private static async Taskstring RunCliAsync( string fileName, string arguments, CancellationToken ct) { var startInfo new ProcessStartInfo { FileName fileName, Arguments arguments, RedirectStandardOutput true, RedirectStandardError true, UseShellExecute false, CreateNoWindow true, StandardOutputEncoding Encoding.UTF8, StandardErrorEncoding Encoding.UTF8 }; using var process Process.Start(startInfo)!; var stdout await process.StandardOutput.ReadToEndAsync(ct); var stderr await process.StandardError.ReadToEndAsync(ct); await process.WaitForExitAsync(ct); if (process.ExitCode ! 0) { throw new InvalidOperationException($CLI 执行失败{stderr}); } return stdout; }几个细节值得注意。CreateNoWindow true确保每次刷新都不会弹出控制台窗口。UseShellExecute false是重定向标准输出和标准错误的前提。StandardOutputEncoding Encoding.UTF8和StandardErrorEncoding Encoding.UTF8要一起设置否则遇到中文字符或非 UTF-8 输出时会出现乱码这也是 Windows 下解析命令行输出最常见的问题之一。ReadToEndAsync拿到的是完整输出解析逻辑应该等待进程完全退出后再执行避免读到一半的数据。4.3 解析策略字段缺失要降级而不是报错CLI 输出通常是 JSON解析时不要用强类型反序列化到一个固定类因为字段名一旦变化就会抛异常。更稳妥的方式是用JsonDocument读取字段缺失时返回 null。using System.Text.Json; private static decimal? GetPercent(JsonElement root) { // 以下字段名只是示例实际以 CLI 输出为准 if (root.TryGetProperty(usage, out var usage) usage.TryGetProperty(percent, out var percent) percent.TryGetDecimal(out var value)) { return value; } if (root.TryGetProperty(limits, out var limits) limits.TryGetProperty(usedPercent, out var usedPercent) usedPercent.TryGetDecimal(out var used)) { return used; } return null; }这个方法的返回值是decimal?读取不到任何字段时返回 null。调用方把这个 null 直接映射成“未知”状态图标保持灰色而不是显示 0%。输出格式变化时最怕的不是解析失败而是解析失败后用户看到错误颜色。比如某个版本把百分比字段改了个名旧代码解析不到值返回了默认值 0用户看到绿色图标就以为额度充足实际上额度已经快完了。这是数据降级逻辑里最危险的一种情况。4.4 未知状态要有时间信息当某个数据源持续读不到数据时悬浮提示应该包含“多久之前更新过”的信息。比如 ClaudeCode: 未知5 分钟前更新