SLOPSHOPPER

runway

在输入框上方显示 Command Code 套餐额度:还剩多少、还能撑多久、会不会超

newpanebandcommandtoastnetwork
v0.1.0MITupdated 2026-10-05Jovan1666/claude-code-runway
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · runway
› fix the failing auth test and add an audit log call ╭──────────────────────────────╮ │ runway │ ⏺ Read(src/auth.ts) │ 额度条在输入框上方 · 按 1 或 /quota 看明细 │ ⎿ Read 6 lines ╰──────────────────────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /quota ⎿ runway: 额度暂时取不到:没找到 Command Code 凭据,或接口不可达。 ⟨Claude Code's own drawing⟩ 额度条 · 没找到 Command Code 凭据 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ 额度条 · 没找到 Command Code 凭据
README

runway

一个 Claude Code mod:把 Command Code 套餐额度画在输入框上方,让你一眼看清 三个额度窗口各自还剩多少、照当前速度会走到哪。

GOAT  5h ██████████████ 3.0%  │  周 ██████████████ 16%  │  月 ██████████████ 52%  1 详情
      └ 1 亮 + 13 暗          └ 2 亮 + 12 暗            └ 7 黄 + 7 红(会冲过上限)

最左边是会员名(Go / GOAT / Pro / Max)—— 那是你花钱买的那一档,会分好几种。 它的颜色跟着档位走(success / warning / error),所以「红色会员名 = 有问题」 这个信号还在,但不需要再写一个字。

曾经这里放的是档位词(宽裕 / 偏紧 / 吃紧 / 断粮)。用户原话: "这偏紧两个字放在这毫无意义……你要么就不加,或者你说啊,我们这是 goat 的会员。" 档位由颜色和三个窗口的条已经说清楚了,重复写一遍只是占地方。 拿不到会员名时才退回档位词 —— 总比空着强。

布局预览

<sub>上图由 tools/mock.mjs 调用真实的布局函数渲染,与真机同源;配色是近似值,以实际主题为准。</sub>

为什么需要它

Claude Code 的 statusLine 在 Claude Desktop 的 Code tab 里不渲染 —— 你自己写的 statusLine 脚本在终端里正常,切到 Desktop 就是一片空白。

mod 的 AbovePrompt band 是那个表面上唯一能常驻放东西的地方,所以有了这个。

落点条

三个窗口等权重地摆在一行,各自一条条,中间用 │ 分隔。

条上全程只用 █ 一个字符,靠颜色区分三段:

颜色含义
亮色(绿 / 黄 / 红)已经花掉的
红色按当前速度还将花掉的(只在会冲过上限时出现)
暗色照这个速度会剩下来的(余量)

会不会爆表不用读字:右半截变红、暗色余量段消失,就是同一件事的两种画法。 轴固定 0→100%,不画刻度 —— 条的末端就是上限。

撑得住时「已用」和「落点」同色合成一根完整的条,于是:

一根条一个颜色 = 安全;出现红色 = 会爆表。

这点和普通的进度条不一样。普通条只说「现在到哪了」;这一根同时说「照这么走下去会到哪」。

为什么是同一个字符

早期版本用 █ / ▒ / ░ 表示 100% / 50% / 25% 三种墨量。实际渲染出来,同一根条看起来 像三种不同的东西拼在一起,高矮粗细对不齐,很别扭。现在三段是同一个字形,天然对齐。

同理,没有图标。曾经用 ◑(半填充圆)表示档位,实测在真实字体里会渲染成一个 大圆圈,既不协调也没人认得。档位现在只靠颜色 + 词(宽裕 / 偏紧 / 吃紧 / 断粮 / 采样中)表达。

三个窗口,不是一个

设计上踩过两次坑,都记在这里:

  1. 第一版让「月」独占一根 30 格的大条,把 周 / 5h 挤成了没有分隔的小文字标 —— 看不到另外两个窗口,也分不清哪段是哪段。
  2. 第二版改成三条,但宽度分配算错了(没算段间距),窄屏下 5h / 周 还是被整段丢掉。

现在三条给同样的宽度、一起伸缩,谁也不会把谁挤没。挤不下时按这个顺序让位: 先丢条 → 再丢档位词,但三个窗口的标签和百分比永不丢。 50 列时退化成 5h 3.0% │ 周 16% │ 月 52% 1 详情,仍然一眼看得全。

顺序是反过来的:早期版本先丢档位词、再去纠结条。后果是窄屏下既没有条、也没有结论, 只剩三个裸数字。条和百分比说的是同一件事,档位词是整行唯一的结论 —— 该先牺牲谁很清楚。

如果连这个最精简的骨架都放不下(窄于约 20 列),退化成一个百分比(月 52%), 而不是什么都不画:空白和「mod 没装」长得一模一样,说不出任何事。

取色:条听节奏,不只听填充率

填充率 52% 看着安全,但按当前速度会提前 5 天烧完 —— 画成绿色就是骗人。 所以月额度的颜色由节奏决定:

情况档位词条的颜色
还有余量、撑得到周期结束宽裕绿
会提前烧完,但还能撑 > 3 天偏紧黄
会提前烧完,且只剩 ≤ 3 天可撑吃紧红(加粗)
余量为零断粮红(加粗)
周期太短、样本不足采样中不取色

判据是「还能撑多久」,不是「缺口多少天」。 这两者方向相反: 早 13 天烧完比早 1 天烧完严重得多,但按缺口天数判会给前者「偏紧/黄」、 后者「吃紧/红」—— 颜色和紧挨着的数字对着干(「吃紧」配「还能撑 15d」、「偏紧」配「还能撑 2.3d」)。 早期版本就是这么写的,README 和测试都把反向判据一起固化了,所以一直没被发现。

⚠️ 颜色写错,整条 band 会消失

引擎对 color / backgroundColor / borderColor 只做一次检查:

typeof t === "string" && /^[#a-zA-Z0-9_().,% -]{1,40}$/.test(t)

不过就判整棵 ui.render 树不合法 → 整棵丢弃、改画引擎自己的版本(= 空白), 屏幕上没有任何提示,只在日志里留一句 a hook returned a tree that does not validate。 而 claude plugin validate 抓不到这一条。

所以:

  • 冒号不在那个字符集里 → ansi:red 这种写法必然失败。用主题令牌 success / warning / error(它们在字符集内,而且会跟着明暗主题走)。
  • band 是中间件链,每个 mod 写 return Box({ children: [rest, mine] }) 时 三棵树会合并成一棵再一起校验 —— 一处颜色写错,三个 mod 一起消失。 这就是「mod 突然全没了、查了半天」的那次事故。
  • 代码里 textOf() 出门前会先过一遍这个字符集,不合法的颜色直接不写: 少一个颜色只是少一个颜色,不会连累别人。

tools/tree_lint.mjs 把引擎这套规则重实现了出来(属性白名单、枚举值、容量上限、 颜色字符集),tests/tree.test.ts 拿它驱动真实的 register.mjs, 跑 8 个数据态 × 10 个列宽 × 桌面/手机 × band/pane。它是一道网,不是保证 —— 引擎改规则而我们没跟上,它会漏。

滚动窗口的样本门槛比月度严得多

月度用「已过 5% 周期」就能给结论,因为它的消耗是匀速的。 滚动窗口不行 —— 实测 5h 窗口跑了 16 分钟、用了 7.4%,按 5% 门槛恰好放行, 外推出来是 139% 的假警报。

现在滚动窗口要求已过 20% 的窗口长度(5h 要跑满 1 小时、周窗口要过 1.4 天) 才给落点,之前那条只画普通的水平条。

(原版 commandcode-usage 干脆不在状态栏显示 5h 的预测,也是这个原因。)

交互

操作效果
空输入框里按 1打开详情面板(不用点、不用切焦点)
面板里改「试算」的数字 + 回车换一个花法会怎样 —— 纯本地算术,立刻算出新的断粮时刻
面板里 1 刷新 / 2 复制快照立即取数 / 复制一屏可贴的摘要
/quota打印完整的文本快照
/quota models打印完整的模型次数表(markdown 表格)。会强制重抓一次官方文档页 —— 显式要表就是要现在的真相
Esc关面板

面板里还有:

  • 一行结论 —— 面板第一行就是「够不够、什么时候用完」: 偏紧 预计 10/11 01:27 断粮 · 还能撑 5.3d。第二行是 周期末预计 $95 / $70.00 会超 $25.00。 这两件是用户打开面板想知道的唯二的事,所以它们在第一屏 —— 以前它们夹在三行裸数字和一串参考值中间,还得自己找。
  • 周期进度 —— 周期已过 47%,额度已用 72%。 单看「已用 72%」没有参照系:配上前半句才知道是花快了。差值超过 10 个点标黄。
  • 安全线 —— 每天 ≤ $2.50 就不会超。把「会超」这个结论翻译成一个当天就能执行的目标。 以前它只在试算成功之后才出现,等于藏在"你得先试算一次"后面。
  • 模型次数表 —— 「这个套餐里哪些模型便宜好用、官方有没有上新」。 带框线的两列表,默认列前 8 名(PANE_MODELS 可调):按每月次数降序 (次数最多 = 每美元买到最多请求),新增/改价的钉在最前面并在名字右侧标 ★新(绿)/ ↑价(黄)。放不下的走 /quota models。
  • 断粮时刻 —— 面板第一行给的是时刻(10/11 01:27)而不是"还剩 5.3 天"这个长度。 长度可以拖,时刻不能;人会自动把时刻跟日历上别的事对照,所以它比天数扎心得多。
  • 试算器 —— 输入「如果每天只花 $1.88」,它立刻告诉你 这样会烧到 04/01 00:13 —— 撑得到周期末。

面板的信息架构

从上到下是紧迫度递减的一条轴,块之间用空行分隔:

块回答视觉手段
结论(2 行)够不够 / 何时断 / 会超多少全篇唯一的加粗彩色
三个额度窗口(3 行)现在到哪了唯一的进度条 + 加粗百分比
节奏(4 行)照这花法会怎样、该改成怎样全暗,想深究才看
模型(标题 + N 行)哪些便宜好用参考信息
工具(试算 / 快照 / 按钮)动手最下

永不砍:第一行结论、三个窗口的名字 + 百分比(README 两次踩坑换来的硬约束)。

面板里那张表:试了两版框线,最后不画线

模型次数是两列对齐的表,但没有框线。这个结论是失败两次换来的:

版本做法怎么坏的
一框线用 box-drawing(┌─┬┐│)它们是 East Asian Ambiguous 宽度,占一格还是两格由字体决定,中文环境下常被渲染成两格。宽度按一格算 → 横线比表格长一倍,整张表散架
二换成 ASCII 框线(`+ -`)字符宽度没问题了,但一行被切成了好几个段(名字 / 标记 / 数字,为了给标记单独染色)。面板把每段渲染成独立的文本节点,而节点边界的空白会被吃掉 —— 补齐的空格正好落在边界上,列全歪
三不画线,一行一个单段字符串✅

第三版的两条要点:

  • 一行只能是 { text } 一个段。 补齐的空格是这段文字的一部分,不会被重算, 所以列对得齐。tests/catalog.test.ts 里有断言钉着:每行 r.length === 1。 (代价:整行一段,标记没法单独染色 —— 染了整行都会变绿。★新 / ↑价 靠字形本身醒目。)
  • 不画框线。 面板里没有 markdown 那种表格样式,ASCII 框线只会显得笨重; box-drawing 又赌不起字体。所以只留列、不留线。
模型次数 · 每月可调用次数(越多越省)· 51 个 · 官方 16 分钟前更新
模型                         每月调用
MiMo V2.6 Flash           ★新  64,900
LongCat 2.0               ★新  64,100
Jev                           595,000
DeepSeek V4 Flash (latest)    154,000
… 其余 45 个 · /quota models 看全表

顺带记一条同样重要的:对齐不能靠定宽 Box。 Box 的 width 只是 flex-basis, 而引擎给 Box 的默认值里有 flexShrink: 1 —— 主轴放不下时定宽会被一路压回内容宽, 短名字那行的数字就跑到左边去。真要用定宽得 width + minWidth 同值 + flexShrink: 0, 且列宽之和 ≤ 面板列数。证据:CLI 里 jg = {…, flexShrink:1, …} 与 props→yoga 桥接的 setFlexShrink/setWidth。

会话里的 /quota models 是两回事:它走 markdown 渲染,连续空格会被吃掉, 所以那里用真正的 markdown 表格(---: 让数字列右对齐),交给渲染器排 —— 那个反而是最好看的。

面板先适配侧边栏

点「详情」默认打开的是停靠的侧边栏,实测 bodyColumns = 50(band 是 95)。 一切以 50 列为准:表格在这个宽度下是 45 格,每一行精确等宽。 展开的大面板更宽,同一套布局自然铺开。

额度跨档时主动弹一次 toast(每档每周期只报一次)。读数陈旧超过两个刷新周期时整行转暗。

安装

需要 Claude Code v2.1.287 或更新(mods 是那个版本引入的)。

单独试跑

claude --plugin-dir /path/to/runway

热重载:改完存盘即生效。

常驻(含 Claude Desktop 的 Code tab)

Desktop 传不了命令行参数,所以用这个环境变量 —— 它就是为「拿不到 flag 的 app」设计的, 写在 ~/.claude/settings.json 里:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/runway"
  }
}

Windows 上多条路径用 ; 分隔,其他平台用 :。撤销就是删掉这一行。 用 claude plugin list 看 runway@inline 是否 loaded。

⚠️ 「mod 突然全不见了」——一行命令就能恢复

~/.claude/settings.json 是多个写入方共用的一个文件。桌面应用按它自己那份状态 整份覆盖时,会把别人写进去的键一起抹掉:enabledPlugins 里的 mod 没了, env.CLAUDE_CODE_PLUGIN_DIRS 整个键消失 —— 表现就是所有 mod 同时不显示, 而文件本身语法完全正确,看不出哪儿错了。(已实测过:claude plugin validate / claude plugin test 不会写这个文件,所以不是它们干的。)

恢复:

node tools/restore-settings.mjs            # 检查 + 补回(先自动备份)
node tools/restore-settings.mjs --dry-run  # 只看要改什么

它只碰那两个键,其余原样保留;幂等,好的时候什么都不做;写完会复读一遍确认没被 别的进程抢写。跑完要重启应用才会重新加载。

更抗覆盖的通道:《mod 参考》里写明 CLAUDE_CODE_PLUGIN_DIRS 可以从进程环境变量 或 settings 的 env 块读取。设成用户级环境变量就不会被应用覆盖 —— 代价是它要 重新登录(或重启资源管理器)才生效,而且别和 settings 里那份同时存在, 否则同一个目录会被注册两次、画两遍。

从 marketplace 装

把本仓库当作 marketplace 加进去:

claude plugin marketplace add Jovan1666/claude-code-runway
claude plugin install runway@claude-code-runway --scope user

数据来源

每一个显示出来的数字从哪来、多久更新一次、官方变了会怎样 —— 见 docs/data-sources.md。 那张表把 live / derived / 写死的 分得很清楚,也包括剩下的写死项和已知偏差。

打 api.commandcode.ai 的三个端点:

GET /alpha/billing/credits?orgId=         → windowLimits.{fiveHour,weekly} + credits
GET /alpha/billing/subscriptions?orgId=   → 计划与计费周期
GET /alpha/usage/summary?since=<周期起点>  → 花费与均价

/alpha/whoami 省掉了:实测它返回的 org 恒为 null,而 credits 并不需要 orgId。

凭据按顺序找:

  1. 环境变量 COMMAND_CODE_API_KEY / COMMANDCODE_API_KEY / CMD_API_KEY
  2. settings.json 的 env 段中名字含 commandcode 的项
  3. 配置文件里嵌套的 access.apiKey + 匹配 commandcode.ai 的 api.baseUrl, 按 ~/.zcode/v2/provider_config.json → ~/.config/opencode/opencode.json → ~/.pi/agent/settings.json → ~/.claude/settings.json 的顺序找

读不到凭据就什么都不画 —— 不占位置,也不报错。取数失败保留上一次读数并标注陈旧。

刷新:会话启动取一次,之后每 180 秒一次,每轮对话结束后去抖(15 秒)补一次。 渲染路径永远不发网络请求 —— ui.render 每帧都跑。

滚动窗口的落点用重置时刻倒推窗口起点: elapsed = 窗口长度 − 距下次重置的时间(5h 窗口 5 小时、周窗口 7 天)。

模型次数表

「这个套餐下每个模型还能调用多少次」不在 API 里 —— /alpha/* 不暴露那张表。 官方只把它公布在公开文档页上,所以这一项走另一个来源:

GET https://commandcode.ai/docs/plans/<slug>      头:RSC: 1

返回的是 Next.js 的 flight 流(text/x-component,约 200 KB)。解析不按 record id (每次部署都会变),只按结构签名找那条记录。口径和官方页面一致:

每次成本 = 输入/1e6×输入单价 + 输出/1e6×输出单价 + 缓存读/1e6×缓存读单价
月次数   = 该模型的 budgetUsd ÷ 每次成本        5h = 月 × fiveHourFraction
                                                周 = 月 × weeklyFraction

每个模型有自己的 budgetUsd(GOAT 里 DeepSeek V4.1 Flash 是 $60、GPT-5.6 Sol 是 $70), 套餐级的 $70 不参与这条公式 —— 官方两处口径不同,不能互相推算,界面上也照实这么写。

刷新24 小时一次(模型上下架、调价是天级的事,跟着 180 秒抓等于敲人家的文档页)。失败隔 6 小时再试
变更标记每次抓回来和上一份比:★新 / ↑改价,并钉在表面(用户要的就是这个);内容没变就不推进"上一份",免得标记被洗掉
降级抓失败保留旧目录并标出「多久之前抓的」;从没抓到过就整段不显示,不影响额度条
页面官方 planId → 文档页 slug:individual-goat→goat、individual-go/-go-v1→go、individual-max/-ultra→max。Go 改过代,窗口系数按 planId 覆盖(老的 0.2/0.5,新的 0.3/0.6)

⚠️ 官方忽略 If-* 条件头,所以没有用 ETag 做变更探测,直接比内容指纹。

开发

node tools/preview.mjs        # 取真实数据,打印额度条 / 面板 / 命令输出(纯文本)
node tools/preview.mjs --json # 额外 dump 归一化后的对象
node tools/mock.mjs --demo    # 用合成数据写出带颜色的视觉稿 tools/mock.html
node tools/mock.mjs --real    # 用真实读数出图(默认拒绝,见下)
node tools/privacy-scan.mjs   # 提交前跑:仓库里有没有混进本机的真实读数
claude plugin validate .      # 静态检查:列出 hooks、$ 调用、读写哪些环境变量
claude plugin test .          # 纯函数测试,不需要会话或网络

隐私:真实账单数据不得入库

这个口子栽过三次,每次都是同一个动作 —— 把真机屏幕上的数字抄进测试夹具或文档 (金额、请求数、均价三者一起出现就等于公开账本;有一次还写进了"别抄真实数据"的警告注释里)。

所以有一道自动检查。它不维护"敏感值清单"(清单本身就是泄露),而是读本机此刻的读数 (mod 自己缓存的那份)再拿去 grep 仓库:

node tools/privacy-scan.mjs        # 命中就以非 0 退出,别提交
node tools/privacy-scan.mjs --all  # 连未跟踪的文件一起扫

局限(它是一道网,不是保证):只抓"此刻缓存里还在"的数;很久以前抄进去、 缓存已经滚过去的抓不到;四舍五入改写过也抓不到。所以夹具值仍然必须自己编, 照 tests/quota.test.ts 那份合成基准写,而且要编得像样。

claude plugin test 跑三个文件,66 条:

文件管什么
tests/quota.test.ts额度换算、落点条、排版、降级顺序;颜色必须过引擎的字符集
tests/catalog.test.ts模型目录的解析与换算、新增/改价、表格排版(列宽契约)
tests/tree.test.ts把真实的 register.mjs 产出的树喂进 tools/tree_lint.mjs:8 数据态 × 10 列宽 × 桌面/手机 × band/pane

tools/tree_lint.mjs 把引擎那套 ui.render 树校验重实现了一遍(属性白名单、 枚举值、容量上限、颜色字符集),常量逐条对得上 CLI 二进制。它是防「整条 band 静默消失」 的护栏 —— claude plugin validate 抓不到这一类。它是一道网,不是保证: 引擎改规则而这里没跟上,它会漏。

视觉稿默认拒绝真实读数。 这个工具的产物 tools/mock.html 就是拿来截图贴进 本文档的,真实读数一旦落到 docs/ 就跟着仓库一起公开了。 所以不带 --demo 时必须显式加 --real 才会取真实数据。

视觉稿的每一个候选框都按标注的列数定宽,标注里给出「实宽 / 可用」, 超宽标红。这是为了能看见溢出:早期版本用 overflow-x:auto 让浏览器自己横向回流, 于是 58 列的面板和 76 列的面板在视觉稿里一样宽 —— 面板比它声明的列数 宽 12 格这件事从来没被看出来过。

框的像素宽度只是近似:中文字体在浏览器里的字宽与终端的「列」不严格相等, 判定以标注里的数字为准。

2.1.288 上的一个坑:本仓库根目录同时有 .claude-plugin/marketplace.json, 而 2.1.288 的 claude plugin validate 遇到这种情况会只校验 marketplace、 跳过插件本身(不再输出 hooks: / calls: 那套静态分析)。这是 2.1.289 修掉的 已知 bug。在 2.1.288 上想看静态分析,复制一份去掉 marketplace 清单再校验:

T=$(mktemp -d) && cp -r . "$T/" && rm -f "$T/.claude-plugin/marketplace.json" && rm -rf "$T/.git"
claude plugin validate "$T"; rm -rf "$T"

tools/mock.mjs 为什么必须存在

mod 的 band 只在真实会话里渲染,写代码的模型看不到自己的输出。

这个项目的整个过程就是在证明这一点:早期版本纯靠想象设计,结果条糊成一坨、图标没人 认得、面板还因为一个未定义变量整片空白。后来把 layoutRow / layoutPane 抽成纯函数, hooks/register.mjs 把「段」变成元素树、tools/mock.mjs 把同一批「段」变成 HTML —— 同一份布局两个出口,于是浏览器里看到的就是屏幕上的,可以反复比对再定稿。

lib/quota.mjs 里不出现 $:mods 的静态检查只允许把 $ 传给同一文件里顶层声明的 函数,跨文件传会验证失败 —— 把纯逻辑分出去正好避开这条约束,顺便让它可测、可预览。

已知限制

  • 依赖的是官方「未公开」的接口。 commandcode.ai/docs 只承认 /provider/v1/* (chat / responses / models 那些),billing / credits / usage / plans 一律不在文档里 —— /alpha/* 是 Studio 网页自己在用的内部路由(实测探了 20 个候选端点,除 /alpha/whoami 外全 404,404 正文自述 "is not a registered API route")。所以它随时可能变更或收紧。 这也是为什么月额度只能靠"已花 + 余额"推 —— 官方没有给这个数的端点。
  • 只在终端与 Claude Desktop 的 Code tab 渲染。VS Code 面板、claude -p、云会话不绘制。
  • 只统计走 Command Code 这个 provider 的消耗。 切到别的 provider,这里的数字不会动。
  • 档位判定与落点外推用的是窗口内均速,不是最近一小时的速度。周期内速度变化大时会偏保守。
  • 月额度的"总量"是推出来的,不是接口给的:接口只给余额(monthlyCredits)和滚动窗口的上限, 没有"这个档位一个月多少"。所以用 本周期已花 + 当前余额 推(summary 带 since=<周期起点>)。 官方调价/改档时它会跟着变,但如果你另买了额度,余额含购买部分而"已花"不含 → 分母偏大、月百分比偏低。写死的档位上限只在连 summary 都拿不到时兜底。
  • 第三方模型的价格/次数与你实际付的钱无关。 模型次数表是官方文档页的口径 (按每个模型自己的月度份额 + 固定的 token 形状换算),不是你的实际调用次数。
  • 窄终端(< 110 / 144 列)里面板会静默不放置,此时弹 toast 说明。
  • 只在终端与 Desktop Code tab 渲染。VS Code 面板、claude -p、云会话不绘制 —— 那些场合用 /quota 命令。
  • mods API 是 EARLY ACCESS。在 2.1.288 上,ui.render 交回坏树可能让整个会话以 unrecoverable interface error 结束(2.1.289 才改成引擎自己兜底)。本 mod 在渲染层 做了 try/catch,但建议用 2.1.289 或更新。
  • 月额度条的颜色由节奏决定,所以填充率低但会超支时它是黄的 —— 刻意的,不是 bug。

关于对 token-weather 的依赖

plugin.json 里声明了 dependencies: ["token-weather@claude-code-playground-mods"]。

band 的多个 mod 是一条中间件链,某个 hook 返回自己的树而不调 next(e) 就短路了排在它 后面的所有 mod。上游的 token-weather 在有读数时正是这么做的,会把排在它后面的 band mod 整个吃掉 —— 表现是「装了但永远不显示」, 而且极难排查。

本机的副本已经修过(两处,同一个毛病):

文件改动
token-weather.mjs两条 drawing 路径都改成 Box({ children: [rest, mine] });e.props 而不是 e 读宽度;无读数时画一行状态而不是静默让位
replay-theater.mjs同样:两个分支都裹 next(e);e.props 读 maxRows / bodyColumns

这些改动没有推回上游 —— claude-code-playground 是 Anthropic 的仓库,不是我们的。 所以换一台机器重装这两个 mod,就得重新打一遍。

而「装了 mod 的插件里,声明了 dependencies 的那个先跑」,所以这是唯一能保证顺序的手段。 代价是 token-weather 会被连带启用。

本 mod 一定 await next(e) 并把别人的树嵌进外层 Box,不会反过来吃掉谁。 不想装 token-weather 的话,把这行删掉即可 —— 但如果它在你之后加载,这行就不会显示。

许可

MIT。见 LICENSE。

Command Code 是其各自所有者的商标;本项目与它没有隶属关系。

Source 3 files
hooks/register.mjs 728 lines
1// runway · hooks 模块
2//
3// 把 Command Code 套餐的"还剩多少 / 还能撑多久"画在输入框上方。
4//
5// 三条硬规矩(写错就白干):
6//   1. `$` 只能原样写 `$.noun.method(...)`,不能赋给变量、不能解构、
7//      不能传给对象成员方法。只允许传给**本文件顶层声明的函数**。
8//   2. `ui.render` 里**绝不发网络请求** —— 它按输入值缓存,但一
9//      invalidate 就会重跑,而 await 网络会把这一帧拖住。渲染只读模块变量。
10//   3. 必须 `await next(e)` 并把它嵌进自己的 Box,否则会抹掉
11//      token-weather(它在有读数时直接 return,短路口后的所有 band mod)。
12
13import {
14  computePace,
15  fmtDay,
16  fmtDays,
17  fmtMoney,
18  fnv1a,
19  layoutPane,
20  layoutRow,
21  modelTableText,
22  normalize,
23  scanForProviderKey,
24  snapshotText,
25  tierOf,
26} from '../lib/quota.mjs';
27import { buildCatalog, catalogDiff, catalogUrl, parsePlanEstimates } from '../lib/catalog.mjs';
28
29const PANE_ID = 'runway';
30const CACHE_KEY = 'runway.cache';
31const MARKS_KEY = 'runway.marks';
32const CATALOG_KEY = 'runway.catalog';
33
34const API_BASE = 'https://api.commandcode.ai';
35const TTL_MS = 180_000; // 与 cc-usage.mjs 的 cacheTtl 同值
36const TURN_DEBOUNCE_MS = 15_000;
37// 模型目录(官方文档页)换得慢 —— 天级。跟着 180s 去抓等于每三分钟打一次
38// 人家的文档站,既不礼貌也没意义。失败后隔 6h 再试,别死磕。
39const CATALOG_TTL_MS = 24 * 3600_000;
40const CATALOG_RETRY_MS = 6 * 3600_000;
41// `/quota models` 是"显式要现在这张表",所以强制重抓;但最多等这么久 ——
42// `$.http.fetch` 没有 timeout,网关黑洞会让它永远挂着,而这是个要立刻出结果的命令。
43const CATALOG_FORCE_WAIT_MS = 8000;
44// 取数的看门狗。$.http.fetch **没有 timeoutMs**(类型定义里 HttpInit 只有
45// method/headers/body/auth/socketPath),DNS 卡死或代理黑洞会让它永不 settle,
46// 而 inflight 只在 finally 里清空 —— 那样 refresh() 会永远返回同一个 pending
47// promise,此后一个请求都不再发,面板永久停在旧读数且只标"陈旧"。
48// 所以自己看时间:挂太久就当作失败,允许下一次重试。
49const FETCH_HUNG_MS = 45_000;
50
51// 凭据候选文件(相对 home)。本机命中的是第一条。
52const CONFIG_PATHS = [
53  '.zcode/v2/provider_config.json',
54  '.config/opencode/opencode.json',
55  '.pi/agent/settings.json',
56  '.claude/settings.json',
57];
58
59// ── 模块状态(热重载会清空,session.start 会重新灌回来)──
60let snap = null; // 当前读数;渲染只读它
61let snapAt = 0;
62let inflight = null;
63let inflightAt = 0;
64let lastTurnRefresh = 0;
65let lastErrorAt = 0; // 最近一次取数失败的时刻,手动刷新用它给回执
66let credFound = false; // 有没有找到凭据 —— 决定没读数时状态行写哪个原因
67
68// 模型次数目录。抓的是公开文档页(命令见 refreshCatalog),跟额度那三个
69// /alpha 接口完全不同的来源,所以生命周期也分开:它按天变,额度按秒变。
70let catalog = null;
71let catalogAt = 0;
72let catalogRetryAt = 0;
73let catalogInflight = null;
74let catalogInflightAt = 0;
75let prevModels = null; // 上一份目录的模型清单,用来比出「新增 / 改价」
76let catalogDigest = null; // 内容指纹:没变就不推进 prevModels,免得把 ★新 洗掉
77let catDiff = { added: new Set(), repriced: new Set(), removed: new Set() };
78
79let marks = { announcedCycle: null, announcedTier: null, sawDetail: false };
80let whatIf = null; // 试算输入的内容(纯本地计算,不发请求)
81const timers = [];
82
83// ════════════════════════════════════════════════════════════
84// 注册
85// ════════════════════════════════════════════════════════════
86export function register(on) {
87  on('session.start', async ($, e, next) => {
88    // 先把 next(e) 走完,别的 mod 的启动逻辑不受我们影响
89    const result = await next(e);
90
91    await hydrate($); // 只读本地 store,快
92    refresh($); // 不 await:网络请求可能永久挂起(见文件末的说明)
93    refreshCatalog($); // 同理,且它自己有 24h TTL,命中就立刻返回
94
95    const stop = $.clock.every(TTL_MS, () => {
96      refresh($);
97    });
98    timers.push(stop);
99
100    if (!marks.sawDetail) {
101      $.ui.toast('额度条在输入框上方 · 按 1 或 /quota 看明细');
102    }
103
104    // 命令最后注册;名字冲突时抛错,不能连累上面几件事
105    try {
106      await $.command.register({
107        name: 'quota',
108        description: '显示 Command Code 套餐额度与节奏',
109      });
110    } catch {
111      /* /quota 被别的插件占了,band 照常工作 */
112    }
113    return result;
114  });
115
116  on('turn.complete', async ($, e, next) => {
117    const result = await next(e);
118    // 子 agent 的 turn 不算,避免一节里刷很多次
119    if (!e.agentId && Date.now() - lastTurnRefresh > TURN_DEBOUNCE_MS) {
120      lastTurnRefresh = Date.now();
121      refresh($);
122    }
123    return result;
124  });
125
126  on('command.run', { command: 'quota' }, async ($, e) => {
127    const arg = String(e.args || '').trim().toLowerCase();
128    if (arg === 'models' || arg === 'm' || arg === '模型') {
129      if (!catalog) await hydrate($);
130      // 显式要这张表 = 要**现在**的真相,所以强制重抓一次
131      // (后台那份有 24h TTL,是给"随手看一眼"用的)。
132      //
133      // 但不能无限等:没有 timeout 的请求遇上网关黑洞会一直挂着,
134      // 而这是个要立刻出结果的命令。等一小会儿拿不到,就拿手里这份旧的 ——
135      // 表头会写清它是多久之前抓的,不会假装新鲜。
136      await Promise.race([
137        refreshCatalog($, true) || Promise.resolve(),
138        new Promise((r) => setTimeout(r, CATALOG_FORCE_WAIT_MS)),
139      ]);
140      return { text: modelTableText(catalog, catDiff, Date.now()) };
141    }
142    if (!snap) await hydrate($);
143    // 必须 await:refresh 是 async,不 await 的话下面那句判的还是 null,
144    // /quota 在冷启动时 100% 返回"取不到",而网络其实几十毫秒后就好了。
145    if (!snap) await refresh($);
146    if (!snap) {
147      return { text: '额度暂时取不到:没找到 Command Code 凭据,或接口不可达。' };
148    }
149    return { text: snapshotText(snap, Date.now()) };
150  });
151
152  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
153    const rest = await next(e); // 别人的 band(token-weather 就在里面)
154    // 宽度和问卷标记都在 **e.props** 里,不在 e 上。
155    // 以前读 e.bodyColumns / e.hasSurvey,两者恒为 undefined ——
156    // 后果是 band 永远按兜底的 80 列排版(宽屏浪费、窄屏溢出),
157    // 而且问卷出现时不让位,两块内容叠在一起。
158    // 这句在 try 之外,所以对 props 缺失也要免疫(缺了就当没有问卷)。
159    if (e.props && e.props.hasSurvey) return rest; // 有问卷时让位
160    // 2.1.288 上,ui.render 抛错或交回坏树会让**整个会话**以
161    // "unrecoverable interface error" 结束(2.1.289 才改成引擎自己兜底)。
162    // 所以在自己这层就吞掉,画不出来就让位。
163    try {
164      // 没读数时不再直接让位:那样"没装"和"装了但取不到数"长得一模一样,
165      // 只能靠猜该去查配置还是该去查网络。改画一行状态,把原因写在屏幕上。
166      const row = snap ? renderRow($, e) : renderStatus($, e);
167      if (!row) return rest;
168      if (!rest) return row;
169      const { Box } = $.ui.resolve(e);
170      return Box({ flexDirection: 'column', children: [rest, row] });
171    } catch {
172      // 画不出来就整块让位。引擎那边(2.1.288)交回坏树会让整个会话
173      // 以 "unrecoverable interface error" 结束,所以宁可不画。
174      return rest;
175    }
176  });
177
178  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
179    if (e.requestId !== PANE_ID || !snap) return next(e);
180    const rest = await next(e);
181    try {
182      return renderPane($, e);
183    } catch {
184      return rest;
185    }
186  });
187
188  on('session.end', async ($, e, next) => {
189    for (const timer of timers.splice(0)) {
190      try {
191        // $.clock.every 返回的是 { cancel() } 对象,不是函数。
192        // 以前这里写 `typeof stop === 'function'` → 恒为 false →
193        // 定时器在 session.end 从来没被取消过一次。
194        if (timer && typeof timer.cancel === 'function') timer.cancel();
195      } catch {
196        /* 定时器可能已经不在 */
197      }
198    }
199    return next(e);
200  });
201}
202
203// ════════════════════════════════════════════════════════════
204// 取数(全部接受 $ 作为参数的函数都在本文件顶层)
205// ════════════════════════════════════════════════════════════
206
207function readDoc(raw) {
208  if (raw && typeof raw === 'object') return raw;
209  if (typeof raw === 'string' && raw) {
210    try {
211      return JSON.parse(raw);
212    } catch {
213      return null;
214    }
215  }
216  return null;
217}
218
219async function hydrate($) {
220  try {
221    const doc = readDoc(await $.store.get(CACHE_KEY));
222    if (doc && doc.view) {
223      snap = doc.view;
224      snapAt = doc.savedAt || 0;
225    }
226  } catch {
227    /* store 读不到就等着网络 */
228  }
229  try {
230    const m = readDoc(await $.store.get(MARKS_KEY));
231    if (m && typeof m === 'object') {
232      marks = {
233        announcedCycle: m.announcedCycle ?? null,
234        announcedTier: m.announcedTier ?? null,
235        sawDetail: Boolean(m.sawDetail),
236      };
237    }
238  } catch {
239    /* 同上 */
240  }
241  try {
242    const c = readDoc(await $.store.get(CATALOG_KEY));
243    if (c && c.catalog && Array.isArray(c.catalog.models)) {
244      catalog = c.catalog;
245      catalogAt = c.catalog.fetchedAt || c.savedAt || 0;
246      prevModels = Array.isArray(c.prevModels) ? c.prevModels : null;
247      catalogDigest = c.digest ?? null;
248      // 重新比一遍,这样"★新"在重启后依然认得出
249      catDiff = catalogDiff(prevModels ? { models: prevModels } : null, catalog);
250    }
251  } catch {
252    /* 没目录也能用,只是面板里少一段 */
253  }
254}
255
256function saveMarks($) {
257  $.store.set(MARKS_KEY, marks).catch(() => {});
258}
259
260// 凭据:先三个字面量环境变量,再 settings 的 env 段,最后读配置文件。
261// (`$.env.get` 只吃字符串字面量,所以"遍历环境变量找含 commandcode 的键"这条路走不通。)
262async function findCredential($) {
263  const KEY_SHAPE = /^(user_|cc_)[A-Za-z0-9_-]{8,}$/;
264
265  const a = await $.env.get('COMMAND_CODE_API_KEY');
266  if (a && KEY_SHAPE.test(a.trim())) return { apiKey: a.trim(), apiBase: null };
267
268  const b = await $.env.get('COMMANDCODE_API_KEY');
269  if (b && KEY_SHAPE.test(b.trim())) return { apiKey: b.trim(), apiBase: null };
270
271  const c = await $.env.get('CMD_API_KEY');
272  if (c && KEY_SHAPE.test(c.trim())) return { apiKey: c.trim(), apiBase: null };
273
274  try {
275    const s = await $.settings.read();
276    const env = s && typeof s.env === 'object' && s.env ? s.env : null;
277    if (env) {
278      for (const name of Object.keys(env)) {
279        if (!/commandcode/i.test(name)) continue;
280        const val = env[name];
281        if (typeof val === 'string' && KEY_SHAPE.test(val.trim())) {
282          return { apiKey: val.trim(), apiBase: null };
283        }
284      }
285    }
286  } catch {
287    /* settings 读不到就继续 */
288  }
289
290  let home = '';
291  try {
292    home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || '';
293  } catch {
294    home = '';
295  }
296  if (!home) return null;
297  const sep = home.includes('\\') ? '\\' : '/';
298
299  for (const rel of CONFIG_PATHS) {
300    const path = home + sep + rel.split('/').join(sep);
301    let text;
302    try {
303      text = await $.fs.read(path);
304    } catch {
305      continue; // 不存在,或超过单文件 4 MiB
306    }
307    let doc;
308    try {
309      doc = JSON.parse(text);
310    } catch {
311      continue;
312    }
313    const hit = scanForProviderKey(doc);
314    if (hit) {
315      return {
316        // https://api.commandcode.ai/provider/v1 → https://api.commandcode.ai
317        apiBase: hit.baseUrl.replace(/\/provider\/v\d+\/?$/, '').replace(/\/+$/, ''),
318        apiKey: hit.apiKey,
319      };
320    }
321  }
322  return null;
323}
324
325async function getJson($, url, headers) {
326  const res = await $.http.fetch(url, { method: 'GET', headers });
327  // $.http.fetch 对非 2xx 不抛错,必须自己判,否则会解析到错误正文
328  if (!res || !res.ok) throw new Error('HTTP ' + (res ? res.status : 'no-response'));
329  const body = JSON.parse(res.text);
330  if (!body || typeof body !== 'object') throw new Error('non-JSON body');
331  return body;
332}
333
334// 模型目录走的是**公开文档页**,不是 /alpha 接口 ——
335// 官方只在 docs 站上公布「各模型能用多少次」这张表,API 不暴露它。
336// 头只有 `RSC: 1`(Next.js 的 flight 流),不需要凭据。
337function refreshCatalog($, force) {
338  if (catalogInflight && Date.now() - catalogInflightAt < FETCH_HUNG_MS) return catalogInflight;
339  const now = Date.now();
340  if (!force) {
341    if (catalog && now - catalogAt < CATALOG_TTL_MS) return null;
342    if (catalogRetryAt && now < catalogRetryAt) return null;
343  }
344  const planId = snap && snap.plan ? snap.plan.id : null;
345  const url = catalogUrl(planId);
346  // 没有对应文档页的档位(比如 teams-pro)就干脆不抓,别去猜。
347  if (!url) return null;
348
349  catalogInflightAt = now;
350  catalogInflight = (async () => {
351    try {
352      const res = await $.http.fetch(url, { method: 'GET', headers: { RSC: '1' } });
353      if (!res || !res.ok) throw new Error('HTTP ' + (res ? res.status : 'no-response'));
354      const raw = parsePlanEstimates(res.text);
355      if (!raw) throw new Error('parse-mismatch');
356      const next = buildCatalog(raw, planId, Date.now());
357      if (!next) throw new Error('empty-catalog');
358
359      // 只有内容真的变了才把"上一份"往前挪 ——
360      // 否则每隔 24h 重抓一次同样的内容,会把 ★新 的标记洗掉。
361      const digest = fnv1a(next.models.map((m) => m.name + '|' + m.budgetUsd + '|' + m.costPerRequest).join('\n'));
362      const changed = digest !== catalogDigest;
363      if (changed) {
364        prevModels = catalog ? catalog.models : prevModels;
365        catDiff = catalogDiff(prevModels ? { models: prevModels } : null, next);
366      }
367      catalog = next;
368      catalogAt = next.fetchedAt;
369      catalogDigest = digest;
370      catalogRetryAt = 0;
371      saveCatalog($);
372      $.ui.invalidate('ui.render');
373    } catch {
374      // 抓不到就保留旧目录;界面会标出"多久之前抓的"。
375      // 隔 6h 再试,别每三分钟敲人家的文档页一次。
376      catalogRetryAt = Date.now() + CATALOG_RETRY_MS;
377    } finally {
378      catalogInflight = null;
379    }
380  })();
381  return catalogInflight;
382}
383
384function saveCatalog($) {
385  try {
386    $.store
387      .set(CATALOG_KEY, { savedAt: Date.now(), catalog, prevModels, digest: catalogDigest })
388      .catch(() => {});
389  } catch {
390    /* 存不下不影响这次会话 */
391  }
392}
393
394// 重新取数。不 await 网络的那条路是刻意为之:$.http.fetch 没有 timeoutMs,
395// 而 $ 调用的耗时又不计入 hook 预算 —— 挂起会一直挂着,所以绝不让它挡住 hook。
396// 代价是必须自己看时间(见 FETCH_HUNG_MS),否则一次挂起就永久静默。
397function refresh($) {
398  if (inflight && Date.now() - inflightAt < FETCH_HUNG_MS) return inflight;
399  inflightAt = Date.now();
400  inflight = (async () => {
401    try {
402      const cred = await findCredential($);
403      if (!cred) {
404        credFound = false;
405        lastErrorAt = Date.now();
406        return;
407      }
408      credFound = true;
409      const digest = fnv1a(cred.apiKey);
410
411      const cached = readDoc(await $.store.get(CACHE_KEY));
412      const sameAccount = cached && cached.digest === digest;
413      if (sameAccount && cached.view && Date.now() - (cached.savedAt || 0) < TTL_MS) {
414        adopt(cached.view, cached.savedAt, $);
415        return;
416      }
417
418      const base = (cred.apiBase || API_BASE).replace(/\/+$/, '');
419      const headers = {
420        Authorization: 'Bearer ' + cred.apiKey,
421        'Content-Type': 'application/json',
422        'User-Agent': 'runway-mod/0.1.0',
423      };
424
425      // whoami 整个省掉:实测 org 恒为 null,credits 不需要 orgId。
426      // 三个请求**并行**。
427      //
428      // 以前 summary 串行等 subscriptions,就为了拿它的周期起点当 `since`。
429      // 实测(2026-10-05 逐端点验过)不必:
430      //   · `since` 是已注册参数(传坏值 400;`from`/`start`/`after` 那些一律被忽略);
431      //   · 但它只是"事件时间下界",而**不传时返回的本来就是当前计费周期**
432      //     (响应里 `periodBasis: "billing-period"`)—— 传周期起点、传更早、不传,三者数值相同。
433      // 所以周期起点优先用上一轮的缓存,拿不到就不传(等价),换来少一次往返。
434      //
435      // 顺带:这正是"已花 + 余额 = 月总额"成立的原因 —— summary 就是本周期口径。
436      const sinceMs = snap && snap.plan ? snap.plan.periodStartMs : null;
437      const since = sinceMs ? new Date(sinceMs).toISOString() : null;
438      const [credits, subs, summary] = await Promise.all([
439        getJson($, base + '/alpha/billing/credits', headers),
440        getJson($, base + '/alpha/billing/subscriptions', headers),
441        getJson(
442          $,
443          base + '/alpha/usage/summary' + (since ? '?since=' + encodeURIComponent(since) : ''),
444          headers,
445        ),
446      ]);
447
448      const now = Date.now();
449      // 不把 apiBase / 凭据来源写进 view:它们对界面没用,
450      // 而 ~/.claude 是同步、备份、共享配置的常见目标,没有必要
451      // 在磁盘上留一份"私有网关地址 + 个人消费"的长期记录。
452      const view = normalize({ credits, subscription: subs, summary }, { now });
453      await $.store.set(CACHE_KEY, { savedAt: now, digest, view });
454      adopt(view, now, $);
455      // 额度到手了才知道 planId,正好此时决定要不要抓目录(它自己有 24h TTL)
456      refreshCatalog($);
457    } catch {
458      // 取失败:保留旧读数(下次仍会重试),界面自己会标"陈旧"。
459      // 记下时刻好让手动刷新能给一句诚实的回执;再 invalidate 一次 ——
460      // ui.render 是按输入值缓存的,不 invalidate 的话"快照 N 秒前"永远不动。
461      lastErrorAt = Date.now();
462      try {
463        $.ui.invalidate('ui.render');
464      } catch {
465        /* 还没有界面可刷新 */
466      }
467    } finally {
468      inflight = null;
469    }
470  })();
471  return inflight;
472}
473
474function adopt(view, at, $) {
475  snap = view;
476  snapAt = at || Date.now();
477  $.ui.invalidate('ui.render');
478  announce($, view);
479}
480
481// ── 事件驱动预警:只有跨档才说话,且每档每周期只报一次 ──
482function announce($, v) {
483  const pace = computePace(v, Date.now());
484  const tier = tierOf(v, pace);
485  const cycleKey = v.plan.periodStartMs ? String(v.plan.periodStartMs) : 'unknown';
486  if (marks.announcedCycle !== cycleKey) {
487    marks.announcedCycle = cycleKey;
488    marks.announcedTier = null;
489  }
490  if (marks.announcedTier === tier.word) return;
491  marks.announcedTier = tier.word;
492  saveMarks($);
493
494  // 宽裕与采样中不值得打断
495  if (tier.word === '宽裕' || tier.word === '采样中') return;
496  if (tier.word === '断粮') {
497    $.ui.toast('额度已用尽 · 剩 ' + fmtMoney(v.monthly.remaining));
498    return;
499  }
500  if (!pace || pace.insufficient) return;
501  $.ui.toast(
502    '额度' +
503      tier.word +
504      ' · 按本周期均速还能撑 ' +
505      fmtDays(pace.runwayDays) +
506      ',比周期结束早 ' +
507      fmtDays(pace.gapDays),
508  );
509}
510
511// ════════════════════════════════════════════════════════════
512// 渲染:输入框上方那一行
513// ════════════════════════════════════════════════════════════
514
515// 段 → 元素。颜色只有两个来源:
516//   · 主题令牌(success/warning/error/rate_limit_empty)—— 明暗自适应,推荐
517//   · 裸色名(green/yellow/red)—— 引擎接受,但不跟明暗主题走
518//   · dimColor —— 次要信息
519// 写死 hex 也可以,只是不跟明暗主题走,浅色主题下容易糊。
520// (旧注释说"非法值不报错,表现为整行消失"——**那是错的**,代价是白查了一轮:
521//   非法颜色会让**整棵 band 树**被判不合法。)
522//
523// 引擎对 color / backgroundColor / borderColor 只做一次检查:
524//   typeof t === 'string' && /^[#a-zA-Z0-9_().,% -]{1,40}$/.test(t)
525// 不过就判**整棵 ui.render 树**不合法 → 整棵丢弃、改画引擎自己的(= 空白)。
526// band 是中间件链,三棵树的合并体一起被校验,所以**一处颜色写错,三块内容一起消失**,
527// 而且屏幕上没有一行提示 —— 这就是那个"mod 突然全没了"的事故。
528//
529// 所以颜色在**出门之前**先过一遍这道字符集:合法的才带上,不合法的直接不写
530// (少一个颜色只是少一个颜色,不会连累任何人)。引擎那边将来放宽了也没关系,
531// 这里只是把非法值挡掉,合法值原样透传。
532const COLOR_RE = /^[#a-zA-Z0-9_().,% -]{1,40}$/;
533
534function safeColor(v) {
535  return typeof v === 'string' && COLOR_RE.test(v) ? v : null;
536}
537
538function textOf(Text, key, s) {
539  const props = { key, children: s.text };
540  const color = safeColor(s.color);
541  const bg = safeColor(s.bg);
542  if (color) props.color = color;
543  if (bg) props.backgroundColor = bg;
544  if (s.bold) props.bold = true;
545  if (s.dim) props.dimColor = true;
546  return Text(props);
547}
548
549function meterNode(Box, Text, key, parts) {
550  return Box({
551    key,
552    flexDirection: 'row',
553    children: parts.map((p, j) => textOf(Text, key + '-' + j, p)),
554  });
555}
556
557// 没有读数时画这一行,把原因写在屏幕上。
558// 「没装」和「装了但取不到数」在静默让位时长得一模一样 —— 用户只能靠猜。
559function renderStatus($, e) {
560  const { Box, Text } = $.ui.resolve(e);
561  const why = credFound ? '取数失败 · 接口或网络不可达' : '没找到 Command Code 凭据';
562  return Box({
563    flexDirection: 'row',
564    paddingX: 1,
565    children: [Text({ dimColor: true, children: `额度条 · ${why}` })],
566  });
567}
568
569function renderRow($, e) {
570  const { Box, Text, Button } = $.ui.resolve(e);
571  // 可用宽度在 **e.props** 里。以前读 e.bodyColumns,恒为 undefined,
572  // 于是这一行永远按兜底的 80 列排版:宽屏浪费、窄屏溢出被截。
573  // 再减去 Box 自己的 paddingX: 1(左右各一格)—— 这两格以前完全没进预算,
574  // 80 列的终端上这一行实际占 82 格,会折行。
575  const cols = Math.max(20, (e.props?.bodyColumns ?? 80) - 2);
576  const now = Date.now();
577  const v = snap;
578  const pace = computePace(v, now);
579  const tier = tierOf(v, pace);
580  const stale = now - snapAt > TTL_MS * 2;
581  const segs = layoutRow(v, pace, tier, cols, now, stale);
582  if (!segs.length) return null;
583
584  const children = [];
585  segs.forEach((s, i) => {
586    if (i > 0) children.push(Text({ key: 'rw-sp' + i, children: '  ' }));
587    if (s.kind === 'button') {
588      children.push(
589        Button({
590          key: 'rw-' + s.id,
591          label: s.label,
592          hotkey: s.hotkey,
593          plain: true,
594          onPress: () => {
595            openDetail($);
596          },
597        }),
598      );
599    } else if (s.kind === 'meter') {
600      children.push(meterNode(Box, Text, 'rw-' + s.id, s.parts));
601    } else {
602      children.push(textOf(Text, 'rw-' + s.id, s));
603    }
604  });
605  return Box({ flexDirection: 'row', paddingX: 1, children });
606}
607
608// ════════════════════════════════════════════════════════════
609// 交互
610// ════════════════════════════════════════════════════════════
611
612// 试算:纯本地算术,不打接口、不改任何东西
613function setWhatIf($, text) {
614  whatIf = String(text == null ? '' : text).trim() || null;
615  $.ui.invalidate('ui.render');
616}
617
618async function openDetail($) {
619  marks.sawDetail = true;
620  saveMarks($);
621  const placed = await $.ui.open({
622    id: PANE_ID,
623    title: '额度',
624    focus: true,
625    closeOnEscape: true,
626    // 面板内容 17 行起(用了试算 19 行),原来声明 18 就装不下,
627    // 底部的「刷新 / 复制快照」按钮落在窗框之外点不到。
628    // 宽度同理:固定部分就有 53 格,58 列会让「周」行折行。
629    // rows 是"想要多高",不是硬性要求(引擎按能给的给),所以这里按内容给足。
630    rows: 30,
631    columns: 78,
632  });
633  // < 144 列(从没开过)/ < 110 列时面板会静默不放置。不提示的话,
634  // 用户按了 1 什么也没发生,而且没有任何报错。
635  if (placed && placed.isPlaced === false) {
636    $.ui.toast('终端太窄,放不下面板 · 拉宽后再按 1');
637  }
638}
639
640function copySnapshot($, surface) {
641  $.ui.copy({ text: snapshotText(snap, Date.now()), surface });
642  $.ui.toast('额度快照已复制');
643}
644
645function manualRefresh($) {
646  const startedAt = Date.now();
647  $.ui.toast('正在刷新…');
648  refresh($).then(() => {
649    $.ui.invalidate('ui.render');
650    // refresh 内部把错误全吞了(网络路径不该抛给 hook),所以只能靠
651    // "这次尝试期间有没有记下失败"来给回执。否则刷新成功与失败长得一模一样,
652    // 用户只能自己盯着"快照 N 秒前"推断。
653    $.ui.toast(lastErrorAt >= startedAt ? '刷新失败 · 显示的是旧读数' : '已更新');
654  });
655}
656
657function renderPane($, e) {
658  const { Box, Text, Button, Input } = $.ui.resolve(e);
659  const now = Date.now();
660  const pace = computePace(snap, now);
661  const tier = tierOf(snap, pace);
662  const stale = now - snapAt > TTL_MS * 2;
663  const cols = Math.max(30, e.props?.bodyColumns ?? 56);
664
665  // 布局是纯函数,这里只负责把段变成元素、把按钮接到处理函数
666  const handlers = {
667    refresh: () => {
668      manualRefresh($);
669    },
670    copy: (press) => {
671      copySnapshot($, press.surface);
672    },
673  };
674
675  const children = [];
676  layoutPane(snap, pace, tier, cols, now, stale, whatIf, { catalog, diff: catDiff }).forEach((r, i) => {
677    if (r.kind === 'gap') {
678      children.push(Text({ key: 'rw-pg' + i, children: ' ' }));
679      return;
680    }
681    const kids = r.segs.map((s) => {
682      if (s.kind === 'meter') return meterNode(Box, Text, 'rw-' + s.id, s.parts);
683      if (s.kind === 'input') {
684        // mobile 的元素表里**没有 Input**(类型定义原文:"No `Input` or `Select`"),
685        // 而 Pane 在 mobile 上也会触发。直接调用 Input(...) 会抛错、
686        // 被上面的 catch 吞掉,于是整个面板在手机上是一片空白。
687        // 画不了输入框就跳过这一行 —— 试算是附加功能,不该拖垮整个面板。
688        if (!Input) return null;
689        return Input({
690          key: 'rw-' + s.id,
691          label: s.label,
692          placeholder: s.placeholder,
693          submitLabel: s.submitLabel,
694          onSubmit: (text) => {
695            setWhatIf($, text);
696          },
697        });
698      }
699      if (s.kind === 'button') {
700        const props = {
701          key: 'rw-' + s.id,
702          label: s.label,
703          hotkey: s.hotkey,
704          plain: true,
705          onPress: (press) => {
706            handlers[s.id](press);
707          },
708        };
709        // autoFocus 只接受 true。落在后果最小的控件上:焦点在"复制",
710        // 回车是复制而不是刷新(照抄 blast-radius 把 autoFocus 给 Cancel)。
711        if (s.id === 'copy') props.autoFocus = true;
712        return Button(props);
713      }
714      // 注意:**不要**在这里靠 `s.width` 去包定宽 Box 做列对齐。
715      //
716      // 踩过:Box 的 `width` 是 flex-basis,而引擎给 Box 的默认值里有
717      // `flexShrink: 1` —— 主轴放不下时定宽会被一路压回内容宽,
718      // 于是"短名字那行的数字跑到左边去"。真要做固定列,三件事缺一不可:
719      //   width + minWidth 同值 + **flexShrink: 0**,并且**列宽之和 ≤ bodyColumns**
720      //   (关掉压缩后放不下就是溢出被裁,不会自己缩)。
721      // 但模型那块现在走的是"一行一个模型",本来就不需要对齐,所以不引入这套机制。
722      return textOf(Text, 'rw-' + s.id, s);
723    });
724    children.push(Box({ key: 'rw-pr' + i, flexDirection: 'row', children: kids.filter(Boolean) }));
725  });
726  return Box({ flexDirection: 'column', children });
727}
728
lib/quota.mjs 1379 lines
1// runway · 纯函数层
2//
3// 这个文件里不出现 `$`。所有函数只吃普通数据、吐普通数据,
4// 所以 hooks 模块可以 import 它,`claude plugin test` 也能直接测它。
5// (mods 的静态检查只允许把 `$` 传给同一文件里顶层声明的函数,
6//   跨文件传 `$` 会验证失败 —— 把纯逻辑分出来正好避开这条约束。)
7
8import { catalogAge, fmtCount } from './catalog.mjs';
9
10// ────────────────────────────────────────────────────────────
11// 套餐上限表(照抄 cc-usage.mjs 的 PLANS,值来自 Command Code 公开档位)
12// 接口没返回 cap 时用它兜底。
13// ────────────────────────────────────────────────────────────
14export const PLANS = {
15  'individual-go': { name: 'Go', monthly: 10, fiveHour: 3, weekly: 6 },
16  'individual-goat': { name: 'GOAT', monthly: 70, fiveHour: 14, weekly: 35 },
17  'individual-pro': { name: 'Pro', monthly: 80, fiveHour: 16, weekly: 40 },
18  'individual-max-10x': { name: 'Max 10x', monthly: 150, fiveHour: 45, weekly: 90 },
19  'individual-max-20x': { name: 'Max 20x', monthly: 300, fiveHour: 90, weekly: 180 },
20  'teams-pro': { name: 'Team Pro', monthly: 40, fiveHour: 12, weekly: 24 },
21};
22
23// 从**接口实时返回的 planId** 推一个能看的会员名。
24//
25// 为什么需要它:`PLANS` 是一张**写死**的表。官方加一档、或者改 id(比如
26// `individual-max-10x` 变成 `individual-max`),表里就没有 —— 面板上那一格
27// 会是空的。而这些东西是**会变的**,写死的表永远追不上。
28//
29// 所以:表里查得到就用表(手写的名字更好看,比如 GOAT 全大写),
30// 查不到就从 id 推。**永远不会空白。**
31export function prettyPlanName(planId) {
32  const id = safeLabel(planId, 40);
33  if (!id) return '';
34  const tail = id.replace(/^(individual|teams?|business|enterprise)[-_]/i, '');
35  return tail
36    .split(/[-_]/)
37    .filter(Boolean)
38    .map((w) => (/^[0-9]/.test(w) ? w : w.charAt(0).toUpperCase() + w.slice(1)))
39    .join(' ');
40}
41
42export function planInfo(planId) {
43  if (!planId || typeof planId !== 'string') return null;
44  const norm = planId.toLowerCase().replace(/_/g, '-');
45  // **精确匹配**,不做前缀匹配。
46  //
47  // 以前是"长键优先"的前缀匹配,没有词边界约束 —— 于是任何以已知键开头的新档位
48  // 都会被误配:实测 `individual-gold` → 命中 Go(月 $10)、`individual-go-ultra` → 也是 Go。
49  // 后果不只是名字错:**额度上限跟着错**,所有百分比都按那个错的上限算。
50  // 查不到就走 prettyPlanName(从实时 planId 推名字),宁可没有上限也不要错的上限。
51  const key = Object.keys(PLANS).find((k) => norm === k);
52  return key ? { id: planId, ...PLANS[key] } : null;
53}
54
55// ────────────────────────────────────────────────────────────
56// 显示宽度:CJK 与全角记 2 格,其余记 1 格。
57// 块字符(█▓░│ 等 U+25xx / U+2500)在这里按 1 格算 —— 与 token-weather
58// 的图表同一处理方式,在同一个渲染器里表现一致。
59// ────────────────────────────────────────────────────────────
60export function dispWidth(s) {
61  let w = 0;
62  for (const ch of String(s)) {
63    const cp = ch.codePointAt(0);
64    const wide =
65      cp >= 0x1100 &&
66      (cp <= 0x115f ||
67        cp === 0x2329 ||
68        cp === 0x232a ||
69        (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) ||
70        (cp >= 0xac00 && cp <= 0xd7a3) ||
71        (cp >= 0xf900 && cp <= 0xfaff) ||
72        (cp >= 0xfe30 && cp <= 0xfe6f) ||
73        (cp >= 0xff00 && cp <= 0xff60) ||
74        (cp >= 0xffe0 && cp <= 0xffe6));
75    w += wide ? 2 : 1;
76  }
77  return w;
78}
79
80// ────────────────────────────────────────────────────────────
81// FNV-1a。只用来判断"这份缓存属于哪个账号",不是加密用途。
82// 照抄 cc-usage.mjs。
83// ────────────────────────────────────────────────────────────
84export function fnv1a(text) {
85  let h = 0x811c9dc5;
86  for (let i = 0; i < text.length; i += 1) {
87    h ^= text.charCodeAt(i);
88    h = Math.imul(h, 0x01000193) >>> 0;
89  }
90  return h.toString(16).padStart(8, '0');
91}
92
93// ────────────────────────────────────────────────────────────
94// 凭据的信任边界。
95//
96// 配置文件里的 `api.baseUrl` 是**不可信输入** —— 这个项目的用法就是
97// "别人给你一份 provider 配置模板、你把 key 填进去",所以那个文件里的
98// 地址不能当作可信来源。而我们会把 API key 当 Bearer 头打到这个地址上。
99//
100// 之前这里只做 `/commandcode\.ai/i` 子串匹配,实测这些都会命中:
101//   https://evil.tld/commandcode.ai/provider/v1     → 配置里的任意域名
102//   https://commandcode.ai.evil.tld/provider/v1     → 抢注的近似域名
103//   https://evil.tld/x?u=commandcode.ai             → 参数里带一下就够
104//   http://api.commandcode.ai/provider/v1           → 明文,key 裸奔
105// 等于一个"把 key 寄给配置文件作者"的通道,且不需要对方能读我们的文件。
106//
107// 所以命中条件收紧成:**精确主机 + 必须 https**。
108// ────────────────────────────────────────────────────────────
109export const ALLOWED_HOSTS = new Set(['api.commandcode.ai']);
110
111export function hostOf(url) {
112  if (typeof url !== 'string' || !url.trim()) return null;
113  try {
114    const u = new URL(url.trim());
115    return u.protocol === 'https:' ? u.host.toLowerCase() : null;
116  } catch {
117    return null;
118  }
119}
120
121export function isTrustedBase(url) {
122  const host = hostOf(url);
123  return host !== null && ALLOWED_HOSTS.has(host);
124}
125
126// 配置文件里的 key 也要过形状检查。以前这里只要求"是个非空字符串",
127// 于是配置里任何一个字符串都能让本模块发出带 Bearer 的请求。
128// 用宽松但真实的形状(可打印、无空白、20 字符以上),
129// 而不是要求 user_ / cc_ 前缀 —— 前缀是接口的实现细节,用它会把合法 key 挡在门外。
130export function looksLikeKey(value) {
131  return typeof value === 'string' && /^[A-Za-z0-9_.\-]{20,256}$/.test(value.trim());
132}
133
134// ────────────────────────────────────────────────────────────
135// 在一份配置对象里递归找 Command Code 的凭据。
136// 命中条件:同一个节点上既有 access.apiKey 又有**可信的** api.baseUrl。
137// 照抄 cc-usage.mjs 的 scanForProviderKey(深度上限 8)。
138// ────────────────────────────────────────────────────────────
139export function scanForProviderKey(node, depth = 0) {
140  if (!node || typeof node !== 'object' || depth > 8) return null;
141  if (Array.isArray(node)) {
142    for (const item of node) {
143      const hit = scanForProviderKey(item, depth + 1);
144      if (hit) return hit;
145    }
146    return null;
147  }
148  const key = node.access && node.access.apiKey;
149  const url = node.api && node.api.baseUrl;
150  if (looksLikeKey(key) && isTrustedBase(url)) {
151    return { apiKey: key.trim(), baseUrl: url.trim() };
152  }
153  for (const value of Object.values(node)) {
154    const hit = scanForProviderKey(value, depth + 1);
155    if (hit) return hit;
156  }
157  return null;
158}
159
160// ────────────────────────────────────────────────────────────
161// 不可信字符串 → 单行可见文本。
162//
163// planId 来自接口响应,之前它被原样写进面板和"复制快照"的文本里 ——
164// 而快照的设计目的就是给人贴到 issue / 群里。于是响应里一个带换行的
165// planId 就能伪造出额外的行(例如伪造一行"凭证 user_xxx"),
166// ANSI/OSC 序列还能改窗口标题或覆写同一行。
167// 摈弃控制字符,并限长。
168// ────────────────────────────────────────────────────────────
169export function safeLabel(value, max = 32) {
170  if (value === null || value === undefined) return '';
171  return String(value)
172    .replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
173    .trim()
174    .slice(0, max);
175}
176
177
178// ────────────────────────────────────────────────────────────
179// 归一化:把三个端点的原始响应算成界面要用的数。
180//
181// ⚠ 两个易混字段(名字同源、语义相反):
182//   credits.credits.monthlyCredits  = 月度**余额**
183//   summary.totalMonthlyCredits     = 月度**已花**
184// 这里用前者,后者不碰。
185// ────────────────────────────────────────────────────────────
186function num(v) {
187  const n = Number(v);
188  return Number.isFinite(n) ? n : 0;
189}
190
191function windowOf(spec, fallbackCap, now) {
192  if (!spec || typeof spec !== 'object') return null;
193  const used = Math.max(0, num(spec.used));
194  const capFromApi = num(spec.cap);
195  const cap = capFromApi || fallbackCap || 0;
196  // 上限**是不是接口给的**。不是的话(拿档位表兜底)就不给百分比 ——
197  // 那个百分比会照着兜底值算出来,官方改档后它就是错的,而界面上看不出它是错的。
198  // 宁可显示 `—`,也不显示一个可能是假的数。
199  const capKnown = capFromApi > 0;
200  const resetAt = num(spec.resetAt) || null; // 0 归一成 null:实测接口会给 0
201  return {
202    used,
203    cap,
204    capKnown,
205    percent: capKnown && cap > 0 ? Math.min((used / cap) * 100, 100) : null,
206    rawPercent: capKnown && cap > 0 ? (used / cap) * 100 : null,
207    remaining: Math.max(0, cap - used),
208    resetAt,
209    resetsInMs: resetAt !== null ? Math.max(0, resetAt - now) : null,
210    exceeded: Boolean(spec.exceeded),
211    // 实测存在 started:true 但 used:0 的合法态,两个条件都要看
212    started: used > 0 || (resetAt !== null && resetAt > now),
213  };
214}
215
216export function normalize(raw, meta) {
217  const now = meta.now;
218  const sub = raw.subscription && raw.subscription.data ? raw.subscription.data : null;
219  const plan = planInfo(sub && sub.planId);
220  const c = (raw.credits && raw.credits.credits) || {};
221  const wl = (raw.credits && raw.credits.windowLimits) || null;
222  const sm = raw.summary || {};
223
224  // 字段缺失 ≠ 余额为 0。以前这里把缺失的 monthlyCredits 直接当成 0,
225  // 于是 totalPool 取档位上限、monthlyUsed = 上限 → 100% 已用、remaining 0,
226  // tierOf 判「断粮」,announce() 还会弹一条「额度已用尽 · 剩 $0.00」。
227  // 一个字段没返回就能伪造出"额度花光"的警报。
228  const hasMonthly = Number.isFinite(Number(c.monthlyCredits));
229  const monthlyRemaining = hasMonthly ? Math.max(0, num(c.monthlyCredits)) : 0;
230  const purchasedRemaining = Math.max(0, num(c.purchasedCredits));
231  const freeRemaining = Math.max(0, num(c.freeCredits));
232  const totalRemaining = monthlyRemaining + purchasedRemaining + freeRemaining;
233  const totalSpent = Math.max(0, num(sm.totalCost));
234
235  // 官方文档:无订阅账号与 Enterprise 自定义池**没有滚动窗口上限**,只受余额约束。
236  // 那种情况下把两个窗口当"不适用"渲染 —— 比"发现 used>0 才被动判断"更安全。
237  // (实测 GOAT 是 limited:true;false 态没法在本账号观测到,所以按文档保守处理。)
238  const limited = wl ? Boolean(wl.limited) : null;
239  const active = Boolean(sub && sub.status === 'active');
240  const planMonthly = active && plan ? plan.monthly : null;
241  // 月**总量**从哪来?接口不给这个数 —— 它给的是余额(`monthlyCredits`)和滚动窗口的上限,
242  // 没有"这个档位一个月多少"。所以以前这里用 `PLANS`,一张**写死的表**。
243  // 但那张表会过期:官方调价、改档,它不跟着变,而月百分比正是照着它算的。
244  //
245  // 活数据里有一个自洽的口径:**本周期已花 + 当前余额 = 本周期总量**
246  // (summary 是带 `since=<周期起点>` 取的,所以就是本周期)。优先用它,
247  // 写死的档位上限只在连 summary 都拿不到时兜底。
248  //
249  // 已知偏差:用户**另买了额度**时,余额含购买部分而"已花"不含 → 分母偏大、百分比偏低。
250  // 写进 README 的已知限制了。
251  const poolFromLive = totalSpent + totalRemaining;
252  const poolFromPlan = planMonthly !== null ? planMonthly + purchasedRemaining + freeRemaining : null;
253  const totalPool = poolFromLive > 0 ? poolFromLive : poolFromPlan ?? 0;
254  const monthlyUsed = Math.max(0, totalPool - totalRemaining);
255
256  // Date.parse 对不可解析的字符串返回 NaN,而 NaN !== null ——
257  // 于是 Math.max(0, NaN) 会把「周期还剩 NaN 天」直接画到屏幕上。
258  const parseAt = (s) => {
259    const t = s ? Date.parse(s) : NaN;
260    return Number.isFinite(t) ? t : null;
261  };
262  const periodStartMs = parseAt(sub && sub.currentPeriodStart);
263  const periodEndMs = parseAt(sub && sub.currentPeriodEnd);
264
265  return {
266    fetchedAt: now,
267    plan: {
268      id: plan ? plan.id : null,
269      // planId 来自接口响应,会被写进面板和"复制快照"(而快照的设计目的是给人贴出去)。
270      // 不剥控制字符的话,一个带换行的 planId 就能在快照里伪造出额外的行。
271      // 表里查得到就用表(手写的名字更好看),查不到就从**接口给的 planId** 推 ——
272      // 官方加档或改 id 时,这里不会空白。
273      name: plan ? plan.name : sub ? prettyPlanName(sub.planId) : '',
274      status: sub ? sub.status : null,
275      active,
276      monthlyTotal: planMonthly,
277      periodStartMs,
278      periodEndMs,
279      periodDays:
280        periodStartMs && periodEndMs ? Math.max(0, (periodEndMs - periodStartMs) / 86400000) : null,
281      daysLeft:
282        periodEndMs !== null ? Math.max(0, Math.ceil((periodEndMs - now) / 86400000)) : null,
283    },
284    monthly: {
285      used: monthlyUsed,
286      total: totalPool,
287      remaining: totalRemaining,
288      // **字段缺失 ≠ 额度用完。**
289      //
290      // 余额(monthlyCredits)缺失时"还剩多少"根本不知道,而 used 是按"总量 − 余额"
291      // 推的 → 总量退化成"已花",于是 percent 恒等于 100%。后果:tierOf 判「断粮」、
292      // announce() 弹「额度已用尽 · 剩 $0.00」—— **一个字段没返回,就伪造出最严重的那个警报**。
293      // (这段注释以前写着"已修好",其实没有:旧代码换成"总量=已花"之后同样是 100%。)
294      //
295      // 所以这里显式区分:拿不到余额就给 null,界面显示 `—`,档位走「无数据」。
296      unknown: !hasMonthly,
297      percent: hasMonthly && totalPool > 0 ? Math.min((monthlyUsed / totalPool) * 100, 100) : null,
298      rawPercent: hasMonthly && totalPool > 0 ? (monthlyUsed / totalPool) * 100 : null,
299      belowThreshold: Boolean(c.belowThreshold),
300    },
301    windows: limited === false ? { limited: false, fiveHour: null, weekly: null } : {
302      limited,
303      fiveHour: windowOf(wl && wl.fiveHour, plan && plan.fiveHour, now),
304      weekly: windowOf(wl && wl.weekly, plan && plan.weekly, now),
305    },
306    avgCostPerRequest: num(sm.averageCost) || null,
307    requestCount: num(sm.totalCount) || null,
308  };
309}
310
311// ────────────────────────────────────────────────────────────
312// 节奏外推:把"用了多少 %"翻译成"还剩多久"。
313//
314// 样本门控照抄 cc-usage.mjs 的 minSample(10 分钟与 5% 周期取大者):
315// 短样本外推几乎一直在报警 —— 一次正常爆发在 25 分钟处会被外推成 140%。
316// 门控不过就不给结论,界面显示"采样中"。
317// ────────────────────────────────────────────────────────────
318export function computePace(v, now) {
319  const startMs = v.plan.periodStartMs;
320  const endMs = v.plan.periodEndMs;
321  if (!startMs || !endMs || endMs <= startMs) return null;
322  const totalMs = endMs - startMs;
323  const elapsedMs = Math.min(now, endMs) - startMs;
324  const minSample = Math.max(10 * 60_000, totalMs * 0.05);
325  const daysLeftMs = Math.max(0, endMs - now);
326
327  if (elapsedMs < minSample || !(v.monthly.used > 0)) {
328    return {
329      insufficient: true,
330      elapsedMs,
331      totalMs,
332      sampleDays: elapsedMs / 86400000,
333      daysLeftDays: daysLeftMs / 86400000,
334    };
335  }
336
337  const perMs = v.monthly.used / elapsedMs;
338  const remainingMs = v.monthly.remaining / perMs;
339  return {
340    insufficient: false,
341    elapsedMs,
342    totalMs,
343    sampleDays: elapsedMs / 86400000,
344    perDay: perMs * 86400000,
345    runwayDays: remainingMs / 86400000,
346    daysLeftDays: daysLeftMs / 86400000,
347    // > 0 表示会在周期结束前耗尽
348    gapDays: (daysLeftMs - remainingMs) / 86400000,
349    projectedTotal: perMs * totalMs,
350    willExceed: perMs * totalMs > v.monthly.total,
351  };
352}
353
354// ────────────────────────────────────────────────────────────
355// 档位。图标取 Geometric Shapes 块(单宽),不用 emoji。
356// color 一律用主题令牌,明暗主题都会自适应。
357// ────────────────────────────────────────────────────────────
358export function tierOf(v, pace) {
359  // 先判"根本没数据",再判"用完了" —— 顺序反了会把缺失当成耗尽(见 normalize 里的注释)
360  if (v.monthly.unknown) {
361    return { icon: '◌', word: '无数据', color: null, bold: false };
362  }
363  if (v.monthly.remaining <= 0 || v.monthly.percent >= 100) {
364    return { icon: '●', word: '断粮', color: 'error', bold: true };
365  }
366  if (!pace || pace.insufficient) {
367    return { icon: '◌', word: '采样中', color: null, bold: false };
368  }
369  if (!pace.willExceed) {
370    return { icon: '○', word: '宽裕', color: 'success', bold: false };
371  }
372  // 严重度看「还能撑多久」,不看「缺口多少天」。
373  // 判据原来是 gapDays <= 3 → 吃紧,方向是反的:gapDays 是"比周期结束早多少天烧完",
374  // 越大越糟。实测它给出自相矛盾的画面 ——「吃紧」+红+加粗 配"还能撑 15d,早 1.2d 烧完",
375  // 而「偏紧」+黄 配"还能撑 2.3d,早 13.7d 烧完"。颜色和紧挨着的数字对着干。
376  if (pace.runwayDays <= 3) {
377    return { icon: '◕', word: '吃紧', color: 'error', bold: true };
378  }
379  return { icon: '◑', word: '偏紧', color: 'warning', bold: false };
380}
381
382// ────────────────────────────────────────────────────────────
383// 进度条:用**背景色实心块**画,不是块字符。
384//
385// 为什么换掉 █▓░:它们是 U+2588 块字符,宽度是 East-Asian Ambiguous,
386// 取决于字体按单宽还是双宽渲染;而且多个块字符挨在一起时,
387// 有些字体会在每个字形之间留缝,整根条糊成一片灰。
388// 背景色涂在空格上则与字体无关 —— 任何字体下都是实心矩形。
389//
390// 取色用原始的 ANSI 色名(green/yellow/red)而不是主题令牌:
391// success/warning/error 是为**文字**调的前景色,天生柔和,
392// 当背景用永远不够鲜明。轨道用主题令牌 rate_limit_empty ——
393// 它就是 /usage 那根用量条的空槽色,明暗主题都合适。
394// ────────────────────────────────────────────────────────────
395// ⚠ 取色只有三种合法写法,别写错 —— 写错的代价不是"没颜色",是**整条 band 消失**。
396//
397// 引擎对 color / backgroundColor / borderColor 只做一次检查(cli.js 里 PUt):
398//
399//     typeof t === 'string' && /^[#a-zA-Z0-9_().,% -]{1,40}$/.test(t)
400//
401// 不过就判整棵 ui.render 树不合法,**整棵树被丢弃、改画引擎自己的(= 空白)**,
402// 只在日志里留一句 "a hook returned a tree that does not validate"。
403// 也就是说:一处颜色写错,三个 mod 一起消失,且屏幕上没有任何提示。
404//
405// 所以这里绝不能写 `ansi:red` —— **冒号不在那个字符集里**,直接判不合法。
406// (这个错误我犯过一次,代价是 band 空白了很久,查了整整一轮才定位。)
407//
408// 用主题令牌 success / warning / error:它们在字符集内,主题里确实有,
409// 而且会跟着明暗主题走 —— 用户是 light 主题,写死 hex 在浅色底上会不对。
410export function levelColor(pct) {
411  // 不知道多少 → 不取色。涂成绿色会让人以为"很安全",而其实是没有数据。
412  if (!Number.isFinite(Number(pct))) return null;
413  if (pct >= 85) return 'error';
414  if (pct >= 60) return 'warning';
415  return 'success';
416}
417
418// 月额度条的取色:**听节奏,不是只听填充率**。
419// 填充率 56% 看着很安全,但如果按当前速度会提前烧完,
420// 画成绿色就是骗人 —— 绿色配旁边红色的「还能撑 11d」自相矛盾。
421export function quotaColor(pct, short, runwayDays) {
422  if (!Number.isFinite(Number(pct))) return null;
423  if (short) return runwayDays <= 3 ? 'error' : 'warning';
424  return levelColor(pct);
425}
426
427export function meterParts(pct, cells, fill) {
428  return landingParts(pct, null, cells, fill).parts;
429}
430
431// ────────────────────────────────────────────────────────────
432// 落点条:一根条讲完 过去 / 未来 / 余量。
433//
434//   █  已经花掉的
435//   ▒  按当前速度**还将花掉**的
436//   ░  照这个速度**会剩下来的**
437//
438// 会不会爆表不用读字:**右半截变红 = 会冲过上限**,
439// 余量格消失也说明同一件事。轴固定 0→100%,不画刻度 —— 条的末端就是上限。
440//
441// projPct 传 null 就退化成普通水平条(那个窗口样本不足、算不出落点时)。
442// ────────────────────────────────────────────────────────────
443export function landingParts(usedPct, projPct, cells, color) {
444  const n = Math.max(0, Math.floor(cells));
445  // 0 格直接返回空。否则下面"极小填充率也至少给一格"那条规则会在 n=0 时
446  // 吐出 1 格 —— 破坏"总宽恒等于给定格数"这条不变量(fuzz 里 81 例)。
447  if (n <= 0) return { parts: [], over: false };
448  const u = Math.min(Math.max(Number(usedPct) || 0, 0), 100);
449  const p = projPct == null ? u : Math.min(Math.max(Number(projPct) || 0, u), 999);
450  const over = p > 100;
451  const usedCells = u <= 0 ? 0 : Math.max(1, Math.round((u / 100) * n));
452  const projCells = Math.max(usedCells, Math.min(n, Math.round((Math.min(p, 100) / 100) * n)));
453
454  // **全程只用 █,靠颜色区分**,不混用 █ / ▒ / ░ 三种密度字符。
455  // 混用的后果:100% / 50% / 25% 三种"重量"拼在一起,同一根条看起来像三种东西,
456  // 高矮粗细都对不齐 —— 这是用户直接指出来的。
457  // 现在实心段 / 落点段 / 余量段是同一个字形,只有颜色不同,天然对齐。
458  const parts = [];
459  // 已用段加粗、落点段不加粗 —— 这样即使颜色全丢了(灰阶终端、色盲、
460  // 主题把 ansi 色映射成同色),"到哪为止是已经花掉的"依然读得出来。
461  // 以前两段的 bold / dim 完全相同,唯一区别就是颜色。
462  if (usedCells > 0) parts.push({ text: '█'.repeat(usedCells), color, bold: true, dim: false });
463  const added = projCells - usedCells;
464  if (added > 0) {
465    // 会冲过上限 → 落点段整段变红;撑得住 → 和已用段同色,合成一根完整的条
466    parts.push({ text: '█'.repeat(added), color: over ? 'error' : color, bold: false, dim: false });
467  }
468  if (n - projCells > 0) {
469    parts.push({ text: '█'.repeat(n - projCells), color: null, bold: false, dim: true });
470  }
471  return { parts, over };
472}
473
474// ────────────────────────────────────────────────────────────
475// 「还剩 11 天」是个长度,可以拖;「10/26 04:00」是个时刻,不能。
476// 人会自动把时刻跟日历上别的事对照,所以它比天数扎心得多。
477// ────────────────────────────────────────────────────────────
478export function exhaustAtMs(remaining, dailyRate, now) {
479  if (!(remaining > 0) || !(dailyRate > 0)) return null;
480  return now + (remaining / dailyRate) * 86400000;
481}
482
483export function fmtDay(ms) {
484  // 光判 isFinite 不够:试算框输入一个极小的日花费会算出 3e30 这种
485  // "有限但超出 Date 范围"的值,new Date(3e30) 是 Invalid Date,
486  // getMonth() 返回 NaN → 屏幕上出现「NaN/NaN NaN:NaN」。
487  if (ms == null || !Number.isFinite(Number(ms)) || Math.abs(Number(ms)) > MAX_DATE_MS) return '—';
488  const d = new Date(ms);
489  const p = (n) => String(n).padStart(2, '0');
490  return p(d.getMonth() + 1) + '/' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
491}
492
493// ────────────────────────────────────────────────────────────
494// 滚动窗口的落点。用重置时刻倒推窗口何时开始:
495//   elapsed = 窗口长度 − 距下次重置的时间
496// 5h 窗口长度 5 小时,周窗口 7 天。样本不足就不给结论。
497// ────────────────────────────────────────────────────────────
498// ────────────────────────────────────────────────────────────
499// 「每天花多少才不会超」——把「周期末会超」这个结论翻译成当天就能执行的目标。
500//
501// daysLeft 用精确天数,但**下限压到 1 天**:剩不到一天时,「每天不超过 $X」
502// 这句话本来就没有意义(今天之内花掉就完了),按半天摊反而会把目标报成两倍,
503// 看着像是"还能多花一倍"。压到一天给出的是最保守、也是最好执行的那个数。
504// ────────────────────────────────────────────────────────────
505export function safeDailyBudget(remaining, endMs, now) {
506  const left = Number(remaining);
507  if (!Number.isFinite(left) || left <= 0) return null;
508  if (!endMs || endMs <= now) return null;
509  return left / Math.max(1, (endMs - now) / 86400000);
510}
511
512// ────────────────────────────────────────────────────────────
513// 周期进度:这个账期过了百分之几。
514//
515// 单独看「额度已用 80%」没有参照系 —— 配上「周期才过 50%」才知道是花快了。
516// 它不依赖采样,所以「采样中」时这一行照样给得出来。
517// ────────────────────────────────────────────────────────────
518export function cycleProgress(pace) {
519  if (!pace || !pace.totalMs || pace.totalMs <= 0) return null;
520  return Math.min(100, Math.max(0, (pace.elapsedMs / pace.totalMs) * 100));
521}
522
523export function projectWindow(spec, periodMs, now) {
524  if (!spec || !spec.started || !spec.resetAt || !(spec.cap > 0)) return null;
525  // 重置时刻已经过去 → 这是一份过期读数。以前 leftMs 被夹到 0,
526  // elapsedMs 于是等于整个窗口长度、projected 恒等于 used → 恒定报"安全",
527  // 把"早就该重置了"误读成"很安全"。
528  if (spec.resetAt <= now) return null;
529  const leftMs = Math.max(0, spec.resetAt - now);
530  const elapsedMs = periodMs - Math.min(leftMs, periodMs);
531  if (elapsedMs <= 0 || !(spec.used > 0)) return null;
532  // 样本门槛要比月度那道严得多。
533  // 月度用 5% 周期(1.5 天)就够,因为它的消耗是匀速的;
534  // 但滚动窗口一开场往往是突发 —— 实测 5h 窗口跑了 16 分钟、用了 7.4%,
535  // 按 5% 门槛(15 分钟)刚好放行,外推出来是 139%,纯噪声。
536  // 原版 cc-usage 干脆不在状态栏显示 5h 的 pace,就是这个原因。
537  // 取 20%:5h 窗口要跑满 1 小时、周窗口要过 1.4 天才给结论。
538  const minSample = Math.max(30 * 60_000, periodMs * 0.2);
539  if (elapsedMs < minSample) return null;
540  const projected = (spec.used / elapsedMs) * periodMs;
541  return {
542    projectedPct: (projected / spec.cap) * 100,
543    willExceed: projected > spec.cap,
544    overBy: Math.max(0, projected - spec.cap),
545    sampleMs: elapsedMs,
546  };
547}
548
549// ────────────────────────────────────────────────────────────
550// 格式化
551// ────────────────────────────────────────────────────────────
552const MAX_DATE_MS = 8.64e15; // ECMAScript Date 的合法上限(±1e8 天)
553
554export function fmtMoney(n) {
555  if (n === null || n === undefined || !Number.isFinite(Number(n))) return '—';
556  const v = Number(n);
557  const a = Math.abs(v);
558  // 均单价在 $0.003 量级,固定两位会把它压成 $0.00 —— 看着像坏了
559  if (a === 0) return '$0.00';
560  if (a < 0.01) return '$' + v.toFixed(4);
561  if (a < 1) return '$' + v.toFixed(3);
562  if (a >= 100) return '$' + v.toFixed(0);
563  return '$' + v.toFixed(2);
564}
565
566export function fmtDays(d) {
567  if (d === null || d === undefined || !Number.isFinite(Number(d))) return '—';
568  const v = Number(d);
569  if (v < 0) return '0d';
570  if (v >= 10) return v.toFixed(0) + 'd';
571  return v.toFixed(1) + 'd';
572}
573
574export function fmtPct(p) {
575  // null / undefined = **不知道**,不是 0。
576  // `Number(null)` 是 0,所以原来那个 `Number.isFinite(Number(p))` 会把"没有数据"
577  // 画成 `0.0%` —— 看起来像"一点没用",而其实是"拿不到"。
578  if (p === null || p === undefined) return '—';
579  if (!Number.isFinite(Number(p))) return '—';
580  const v = Number(p);
581  return (v >= 10 ? v.toFixed(0) : v.toFixed(1)) + '%';
582}
583
584// 照抄 cc-usage.mjs 的 resetText:>= 1 天显示日期,否则显示倒计时
585export function resetText(w, now) {
586  const at = w && Number(w.resetAt);
587  if (!at || !Number.isFinite(at) || Math.abs(at) > MAX_DATE_MS) return null;
588  const ms = w.resetsInMs != null ? w.resetsInMs : Math.max(0, w.resetAt - now);
589  if (ms >= 86400000) {
590    const d = new Date(w.resetAt);
591    const p = (n) => String(n).padStart(2, '0');
592    return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())} 重置`;
593  }
594  const t = Math.max(0, Math.floor(ms / 1000));
595  const h = Math.floor(t / 3600);
596  const m = Math.floor((t % 3600) / 60);
597  if (h > 0) return `${h}h${m > 0 ? m + 'm' : ''}后重置`;
598  if (m > 0) return `${m}m后重置`;
599  return `${t}s后重置`;
600}
601
602// ────────────────────────────────────────────────────────────
603// 按宽度裁段:优先丢 prio 大的,prio 0 永不丢。
604// ────────────────────────────────────────────────────────────
605const SEG_GAP = 2;
606// plain 按钮的实宽:热键 + ": " + 标签。
607// 以前手写常量("1 详情" = 6),漏了冒号和空格,宽度预算因此永远少 1 格。
608// (Desktop 上画的是原生按钮,宽度由控件决定 —— 这里是保守估计。)
609const plainButtonW = (label, hotkey) => dispWidth(String(hotkey)) + 2 + dispWidth(label);
610const BUTTON_W = plainButtonW('详情', '1');
611
612export function fitSegments(segs, cols, gap = SEG_GAP) {
613  // 丢掉的是段对象本身,不是 id —— id 可能重复(两个分隔段的 id 都是 'sep')。
614  const dropped = new Set();
615  const alive = () => segs.filter((s) => !dropped.has(s));
616  // 每丢一段都要重算剩余宽度。原来 total() 一直按完整列表算,
617  // 于是"还超宽"恒为真,一超宽就把所有 prio > 0 的段**一次全丢光**,
618  // 留一堆本该被保留的段和一片空白。
619  const total = () => {
620    const now = alive();
621    return now.reduce((a, s) => a + s.width, 0) + gap * Math.max(0, now.length - 1);
622  };
623  for (const seg of segs.filter((s) => s.prio > 0).sort((a, b) => b.prio - a.prio)) {
624    if (total() <= cols) break;
625    dropped.add(seg);
626  }
627  return alive();
628}
629
630// ────────────────────────────────────────────────────────────
631// 布局:把一行拆成渲染无关的"段"。
632//   { id, prio, width, kind:'text'|'meter'|'button', text?, parts?, label?, hotkey?,
633//     color, bg, bold, dim }
634// hooks 模块把段变成元素树,tools/mock.mjs 把段变成 HTML,
635// 同一份布局两个出口 —— 所以我在浏览器里看到的排版就是屏幕上的排版。
636//
637// 显示顺序:月(主窗口) → 主条 → 还能撑多久 → 剩多少 → 5h 小条 → 周 小条 → 详情入口
638// 主条是这个设计的招牌,放不下时它主动挤掉小窗口,而不是自己被丢掉。
639// ────────────────────────────────────────────────────────────
640export const WEEK_MS = 7 * 86400000;
641export const FIVE_HOUR_MS = 5 * 3600000;
642
643// 月窗口画成"落点条"需要的三个数。
644//
645// 这里以前是一个通用的 hero 抽象(在 月 / 周 / 5h 之间切主窗口),
646// 但切换入口从没做出来(layoutPane 只以 'monthly' 调用它),于是
647// HERO_ORDER / HERO_LABEL / heroSpecFor 和一个不可达分支长期挂着,
648// register.mjs 还因此引用了一个**没 import 的 HERO_ORDER** ——
649// 那行抛的 ReferenceError 被 catch 吞掉,把 marks 恢复整条逻辑废掉了
650// (后果:引导 toast 每次启动都弹、档位 toast 每个进程重报一次)。
651// 砍掉抽象,只留真正在用的那条路径。
652export function monthlyHero(v, pace) {
653  const m = v.monthly;
654  const canProj = Boolean(pace && !pace.insufficient && m.total > 0);
655  return {
656    usedPct: m.percent,
657    projPct: canProj ? (pace.projectedTotal / m.total) * 100 : null,
658    color: quotaColor(m.percent, Boolean(canProj && pace.willExceed), pace && pace.runwayDays),
659  };
660}
661
662// 布局:把一行拆成渲染无关的"段"。
663//   { id, prio, width, kind:'text'|'meter'|'button', text?, parts?, label?, hotkey?, ... }
664//
665// 显示顺序:主窗口 → 落点条 → 落点结论 → 金额 → 其它两个窗口的小标 → 详情 / 切窗口
666// 落点条是招牌,挤不下时它主动挤掉数字,而不是自己被丢掉。
667// ────────────────────────────────────────────────────────────
668// 三个窗口等权重地摆在一行里,各自一条落点条,中间用 │ 分隔。
669//
670// 两次教训:
671//   1. 让"月"独占一根 30 格大条,把 周/5h 挤成没有分隔的小文字标 ——
672//      用户看不到另外两个窗口,也分不清哪段是哪段。
673//   2. 就算改成三条,只要宽度分配算错(没算段间距),窄屏下 5h/周 还是会被整段丢掉。
674// 所以这里**三条给同样的宽度、一起伸缩**,谁也不会把谁挤没。
675const BAR_MIN = 4;
676const BAR_MAX = 14;
677// 面板里的条可以更长 —— 它单独占一行,不用跟另外两根抢地方。
678const PANE_BAR_MAX = 22;
679// 面板里最多列几个模型。剩下的交给 /quota models。
680const PANE_MODELS = 6;
681
682export function layoutRow(v, pace, tier, cols, now, stale) {
683  const pacePr =
684    pace && !pace.insufficient && v.monthly.total > 0
685      ? {
686          projectedPct: (pace.projectedTotal / v.monthly.total) * 100,
687          willExceed: pace.willExceed,
688          overBy: Math.max(0, pace.projectedTotal - v.monthly.total),
689        }
690      : null;
691
692  const defs = [
693    { key: 'five', label: '5h', spec: v.windows.fiveHour, pr: projectWindow(v.windows.fiveHour, FIVE_HOUR_MS, now) },
694    { key: 'week', label: '周', spec: v.windows.weekly, pr: projectWindow(v.windows.weekly, WEEK_MS, now) },
695    { key: 'mon', label: '月', spec: v.monthly, pr: pacePr },
696  ];
697
698  const T = (id, s, o = {}) => ({
699    id,
700    prio: o.prio ?? 0,
701    kind: 'text',
702    width: dispWidth(s),
703    text: s,
704    color: o.color ?? null,
705    bg: null,
706    bold: Boolean(o.bold),
707    dim: Boolean(o.dim),
708  });
709  // 窗口数据缺失时显示「—」并转暗,不能显示 0.0% ——
710  // 0.0% 与"这个窗口一点没用"完全同形,而面板对同一份数据是跳过整行,
711  // 两处口径不一致会让人以为额度真的没用过。
712  const labelOf = (d) => T(d.key + 'l', d.label, { bold: true, dim: stale || !d.spec });
713  const pctOf = (d) =>
714    d.spec
715      ? T(d.key + 'p', fmtPct(d.spec.percent), { bold: true, dim: stale })
716      : T(d.key + 'p', '—', { dim: true });
717  const SEP = () => T('sep', ' │ ', { dim: true });
718  // 详情入口可以丢(/quota 与快捷键仍在),三个窗口的百分比不能丢。
719  const detail = { id: 'detail', kind: 'button', width: BUTTON_W, label: '详情', hotkey: '1', prio: 1 };
720
721  // 档位词就是这一行的结论,**永远显示**。
722  //
723  // 以前它只是"没有超支结论时的替补",于是恰恰在额度吃紧的时候(有超支结论)
724  // 反而不显示 —— 用户看到的是三个没有任何结论的裸数字。
725  // 而那个替补上去的「月超 $33.80」是没有行动价值的:超了就是超了,
726  // 知道超多少既不能少花钱也不能多额度(用户原话)。
727  // 想知道"超多少"就去按 1 看明细,那里有金额和断粮时刻。
728  // 开头那个词是**会员名**(Go / GOAT / Pro / Max),不是档位词。
729  //
730  // 档位词(宽裕 / 偏紧 / 吃紧 / 断粮)放在这儿没有意义 —— 用户原话:
731  // "这偏紧两个字放在这毫无意义,你要么就不加,或者你说啊,我们这是 goat 的会员"。
732  // 会员名是他自己花钱买的那一档、会分好几种,而档位由**颜色**和三个窗口的条
733  // 已经说清楚了(颜色跟着档位走,红色 = 有问题)。
734  // 拿不到会员名时退回档位词 —— 总比空着强。
735  const planSeg = tier && (v.plan.name || tier.word)
736    ? T('plan', v.plan.name || tier.word, {
737        color: stale ? null : tier.color,
738        bold: !stale,
739        dim: stale,
740      })
741    : null;
742
743
744  const skeleton = (withTier) => {
745    const a = [];
746    if (planSeg && withTier) a.push(planSeg);
747    defs.forEach((d, i) => {
748      if (i > 0) a.push(SEP());
749      a.push(labelOf(d), pctOf(d));
750    });
751    a.push(detail);
752    return a;
753  };
754  // 三条各占 n + SEG_GAP
755  const fitBars = (a) => Math.min(BAR_MAX, Math.floor((cols - widthOfSegs(a) - 3 * SEG_GAP) / 3));
756
757  // 降级顺序:**先丢条,再丢档位词**。
758  // 条和百分比说的是同一件事,百分比永不丢,所以条是第一个该牺牲的;
759  // 而档位词是这一行唯一的结论,比条更值钱。
760  // (以前是反的:先把档位词丢掉、再去纠结条 —— 于是 60 列以下既没有条也没有结论,
761  //   只剩三个裸数字。)
762  // 三个窗口的标签与百分比**永不丢** —— 这是踩过两次坑换来的硬约束
763  // (用户原话:"只能看到5小时的限额看不到周限额")。
764  let withTier = Boolean(planSeg);
765  let n = fitBars(skeleton(withTier));
766  if (n < BAR_MIN) {
767    n = 0;
768    // 连档位词都塞不下时它才让位。
769    if (widthOfSegs(skeleton(true)) > cols) withTier = false;
770  }
771
772  const out = [];
773  if (planSeg && withTier) out.push(planSeg);
774  defs.forEach((d, i) => {
775    if (i > 0) out.push(SEP());
776    out.push(labelOf(d));
777    if (n > 0 && d.spec) {
778      const pct = d.spec.percent;
779      const color = stale
780        ? null
781        : d.key === 'mon'
782          ? quotaColor(pct, Boolean(pacePr && pacePr.willExceed), pace && pace.runwayDays)
783          : levelColor(pct);
784      out.push({
785        id: d.key + 'bar',
786        // 条是最先该牺牲的:百分比已经说明了同一件事,而窗口的百分比永不丢。
787        // 以前这里也是 prio 0(永不可丢),于是 fitSegments 其实是个空操作。
788        prio: 2,
789        kind: 'meter',
790        width: n,
791        parts: landingParts(pct, d.pr ? d.pr.projectedPct : null, n, color).parts,
792      });
793    }
794    out.push(pctOf(d));
795  });
796  out.push(detail);
797
798  const fitted = fitSegments(out, cols);
799  if (widthOfSegs(fitted) <= cols) return fitted;
800  // 连最精简的骨架都放不下时,**绝不退化成"什么都不画"**。
801  //
802  // 这一条是踩出来的:band 在 Desktop Code tab 上的可用列数比终端窄得多
803  // (终端里 80 格够用,那个界面上不够),而三个窗口加「详情」的最小骨架
804  // 要 45 格。放不下就整行不画,结果是**一整行空白 —— 和「mod 没装」
805  // 长得一模一样**,只能靠猜该去查配置还是该去查宽度。
806  // 空白说不出任何事,所以退化成主窗口的一个百分比:约 6 格,任何宽度都放得下,
807  // 而且它仍然是那个唯一的问题(额度还够不够)。
808  return compactRow(v, pace, cols);
809}
810
811// 放不下时的最小可用形态:主窗口百分比 + 档位颜色。
812function compactRow(v, pace, cols) {
813  const tier = tierOf(v, pace);
814  for (const text of ['月 ' + fmtPct(v.monthly.percent), fmtPct(v.monthly.percent)]) {
815    const width = dispWidth(text);
816    if (width > cols) continue;
817    return [{
818      id: 'monp',
819      prio: 0,
820      kind: 'text',
821      width,
822      text,
823      color: tier.color,
824      bg: null,
825      bold: true,
826      dim: false,
827    }];
828  }
829  return [];
830}
831
832function widthOfSegs(a) {
833  return a.reduce((x, s) => x + s.width, 0) + SEG_GAP * (a.length - 1);
834}
835
836// ────────────────────────────────────────────────────────────
837// 详情面板的布局,和 layoutRow 同一套"段"模型。
838// 返回 { rows: [ {kind:'row', segs:[...]} | {kind:'gap'} ] },
839// 由调用方把段变成元素(hooks)或 HTML(tools/mock.mjs)。
840// ────────────────────────────────────────────────────────────
841export function layoutPane(v, pace, tier, cols, now, stale, whatIf, extra) {
842  const m = v.monthly;
843  const fh = v.windows.fiveHour;
844  const wk = v.windows.weekly;
845  // 条长是**量出来的**,不是猜的。
846  //
847  // 以前写死 `PANE_FIXED = 54`("名称 5 + 百分比 6 + 金额 16 + 重置文案 26"),
848  // 那是在 74 列的面板里数的。而面板在 Desktop Code tab 上比这个窄得多,
849  // 于是 `cols - 54` 小于等于 0 → 条长度算成 0 格 → **整根条一格都不画**。
850  // 屏幕上就是一堆没有条的百分比,看起来像残废 —— 用户报的就是这个。
851  //
852  // 现在只把"条那一行上必须有"的东西算进固定宽度:名称 + 百分比 + 金额。
853  // 金额宽度按三个窗口里最长的那个量。重置文案不进去 —— 它是 prio 3,
854  // 挤不下时本来就该让位。
855  const LABEL_W = 5;
856  const PCT_W = 6;
857  const moneyOf = (spec, cap) =>
858    '  ' + fmtMoney(spec ? spec.used : 0) + ' / ' + fmtMoney(cap);
859  const MONEY_W = Math.max(
860    dispWidth(moneyOf(m, m.total)),
861    dispWidth(moneyOf(fh, fh ? fh.cap : 0)),
862    dispWidth(moneyOf(wk, wk ? wk.cap : 0)),
863  );
864  const cells = Math.max(0, Math.min(PANE_BAR_MAX, cols - (LABEL_W + PCT_W + MONEY_W)));
865
866  const T = (id, s, opts = {}) => ({
867    id,
868    prio: opts.prio ?? 0,
869    kind: 'text',
870    width: dispWidth(s),
871    text: s,
872    color: opts.color ?? null,
873    bg: null,
874    bold: Boolean(opts.bold),
875    dim: Boolean(opts.dim),
876  });
877  const M = (id, pct, n, fill) => ({
878    id,
879    prio: 1,
880    kind: 'meter',
881    width: n,
882    parts: meterParts(pct, n, fill || levelColor(pct)),
883  });
884  const pad = (s, n) => s + ' '.repeat(Math.max(0, n - dispWidth(s)));
885
886  const rows = [];
887  // 面板每一行也按可用列数裁一遍:renderPane 把段直接相邻排列(没有分隔符),
888  // 所以 gap 用 0。以前面板完全不做裁剪,58 列的面板里排到 71 格宽。
889  const row = (...segs) => rows.push({ kind: 'row', segs: fitSegments(segs, cols, 0) });
890  const gap = () => rows.push({ kind: 'gap' });
891
892  // ── 块 1 · 结论 ──
893  //
894  // 打开面板想知道两件事:**够不够、什么时候用完**。所以它们在第一条线上,
895  // 而不是像以前那样夹在三行裸数字和一堆参考值中间。
896  // 排列意图是「结论 → 证据 → 参考 → 动作」,越往下越是想深究才看的。
897  //
898  // 不带图标:◑ 这类 Geometric Shapes 在有些字体里会渲染成一个大圆圈,
899  // 既不好看也没人认得。档位靠**颜色 + 词**表达就够了。
900  {
901    const phrase =
902      tier.word === '断粮'
903        ? '额度已用完'
904        : tier.word === '采样中'
905          ? '样本不足,暂不外推'
906          : pace && !pace.insufficient
907            ? pace.willExceed
908              ? '预计 ' +
909                fmtDay(
910                  pace.perDay > 0 ? exhaustAtMs(m.remaining, pace.perDay, now) : null,
911                ) +
912                ' 断粮 · 还能撑 ' +
913                fmtDays(pace.runwayDays)
914              : '撑得到周期末 · 还能撑 ' + fmtDays(pace.runwayDays)
915            : '—';
916    const planTail =
917      (v.plan.name || '—') +
918      (v.plan.daysLeft != null ? ' · 周期还剩 ' + v.plan.daysLeft + ' 天' : '');
919    // 这一行三段都是**结论**,一段都不能被 fitSegments 丢掉(prio 0 丢不掉),
920    // 所以合起来超宽时它们只会被面板边缘**裁掉** —— 而这正是窄面板会发生的事:
921    // `columns: 78` 只是"请求值",窄窗口或用户拖动之后面板可能只有三四十列。
922    // 因此放不下就换行,不裁。
923    const h1 = T('h1', tier.word, { color: stale ? null : tier.color, bold: !stale, dim: stale });
924    const phraseOpts = {
925      bold: !stale && tier.word !== '宽裕',
926      dim: stale,
927      color: stale || tier.word !== '断粮' ? null : 'error',
928    };
929    const h2 = T('h2', '    ' + phrase, phraseOpts);
930    const h3 = T('h3', '   ·   ' + planTail, { dim: true, prio: 2 });
931    // 判据只看**丢不掉的两段**(档位词 + 短语);第三段(会员名 · 周期)prio 2,
932    // 放不下时 fitSegments 自己会丢掉它,不该为了它换行。
933    if (h1.width + h2.width <= cols) {
934      row(h1, h2, h3);
935    } else {
936      row(h1);
937      row(T('h2b', truncTo(phrase, cols), phraseOpts));
938      if (dispWidth('   ·   ' + planTail) <= cols) {
939        row(T('h3b', '   ·   ' + planTail, { dim: true }));
940      }
941    }
942    if (pace && !pace.insufficient) {
943      const over = pace.projectedTotal - m.total;
944      row(
945        T('h4', '周期末预计  ', { dim: true }),
946        T('h5', fmtMoney(pace.projectedTotal) + ' / ' + fmtMoney(m.total), { prio: 1 }),
947        T(
948          'h6',
949          over > 0 ? '   会超 ' + fmtMoney(over) : '   富余 ' + fmtMoney(Math.max(0, -over)),
950          over > 0 ? { color: 'warning', bold: true, prio: 1 } : { color: 'success', prio: 2 },
951        ),
952      );
953    }
954  }
955  gap();
956
957  const winRow = (key, name, spec, fill) => {
958    const pct = spec ? spec.percent : 0;
959    const segs = [
960      T(key + 'n', pad(name, 5), { dim: true, bold: true }),
961      T(key + 'p', pad(fmtPct(pct), 6), { bold: true }),
962    ];
963    if (cells > 0) segs.push(M(key + 'm', pct, cells, fill));
964    segs.push(
965      // 月窗口的上限字段叫 total,滚动窗口叫 cap
966      T(
967        key + 'v',
968        '  ' +
969          fmtMoney(spec ? spec.used : 0) +
970          ' / ' +
971          fmtMoney(spec && spec.total != null ? spec.total : spec && spec.cap),
972        { prio: 2 },
973      ),
974    );
975    const r = spec ? resetText(spec, now) : null;
976    if (r) segs.push(T(key + 'r', '     ' + r, { dim: true, prio: 3 }));
977    row(...segs);
978  };
979
980  // 月条画成落点条:实心=已花,斜纹=还将花掉
981  {
982    const h = monthlyHero(v, pace);
983    const segs = [
984      T('mn', pad('月', 5), { dim: true, bold: true }),
985      T('mp', pad(fmtPct(m.percent), 6), { bold: true }),
986    ];
987    if (cells > 0) {
988      segs.push({
989        id: 'mm',
990        prio: 1,
991        kind: 'meter',
992        width: cells,
993        parts: landingParts(h.usedPct, h.projPct, cells, stale ? null : h.color).parts,
994      });
995    }
996    segs.push(T('mv', '  ' + fmtMoney(m.used) + ' / ' + fmtMoney(m.total), { prio: 2 }));
997    row(...segs);
998  }
999  // 「还能撑多久 / 断粮时刻」已经并进上面的结论行(第 1 行),这里不再说第二遍。
1000  // 以前它在第 3 行、绝对时刻在第 14 行,同一件事说两遍,还都排在参考值的后面。
1001
1002  if (fh && fh.started) {
1003    winRow('h', '5h', fh);
1004  } else if (fh) {
1005    row(
1006      T('hn', pad('5h', 5), { dim: true, bold: true }),
1007      T('hp', '未启动', { dim: true }),
1008      T('hv', '          上限 ' + fmtMoney(fh.cap), { dim: true, prio: 2 }),
1009    );
1010  }
1011  if (wk) winRow('w', '周', wk);
1012  gap();
1013
1014  // 「周期过了百分之几」是「额度用了百分之几」的参照系 ——
1015  // 单看「已用 80%」没有意义,配上「周期才过 50%」才知道是花快了。
1016  // 它只跟时间有关,所以「采样中」时照样给得出来。
1017  // 参考行 = 标签 + 值。值放不下就**整行不画** —— 只剩一个孤零零的标签比没有更糟
1018  // (窄面板上出现过「均单价    」这种残行)。标签和值一起进退。
1019  const refRow = (id, label, valueSeg) => {
1020    if (dispWidth(pad(label, 10)) + valueSeg.width > cols) return;
1021    row(T(id + 'a', pad(label, 10), { dim: true }), valueSeg);
1022  };
1023
1024  {
1025    const cycle = cycleProgress(pace);
1026    if (cycle != null) {
1027      refRow(
1028        'd0',
1029        '进度',
1030        T('d0b', '周期已过 ' + Math.round(cycle) + '%,额度已用 ' + fmtPct(m.percent), {
1031          dim: true,
1032          color: m.percent - cycle > 10 ? 'warning' : null,
1033        }),
1034      );
1035    }
1036  }
1037  refRow(
1038    'd1',
1039    '均速',
1040    T(
1041      'd1b',
1042      pace && !pace.insufficient
1043        ? fmtMoney(pace.perDay) + '/天(样本 ' + fmtDays(pace.sampleDays) + ')'
1044        : '样本不足,暂不外推',
1045      { dim: true },
1046    ),
1047  );
1048  // 「每天不超过多少就不会超」以前只在试算成功之后才出现 ——
1049  // 它是一个可执行的目标,不该藏在"你得先试算一次"后面。
1050  if (pace && !pace.insufficient) {
1051    const budget = safeDailyBudget(m.remaining, v.plan.periodEndMs, now);
1052    refRow(
1053      'd2',
1054      '安全线',
1055      T(
1056        'd2b',
1057        budget != null ? '每天 ≤ ' + fmtMoney(budget) + ' 就不会超' : '已无余量,怎么花都会超',
1058        budget != null ? { color: 'success' } : { color: 'error' },
1059      ),
1060    );
1061  }
1062  refRow(
1063    'd3',
1064    '均单价',
1065    T(
1066      'd3b',
1067      (v.avgCostPerRequest ? fmtMoney(v.avgCostPerRequest) + '/次' : '—') +
1068        (v.requestCount ? '   ·   本期 ' + v.requestCount + ' 次请求' : ''),
1069      { dim: true },
1070    ),
1071  );
1072
1073  // ── 模型次数表 ──
1074  // 「这个套餐里哪些模型便宜好用、官方有没有上新」—— 用户用 GOAT 就是冲着这个来的。
1075  // 目录来自公开文档页(lib/catalog.mjs),不在这里取数,只排版。
1076  if (extra && extra.catalog) {
1077    gap();
1078    let mi = 0;
1079    for (const r of modelTable(extra.catalog, extra.diff, cols, now, PANE_MODELS)) {
1080      if (r.gap) {
1081        gap();
1082        continue;
1083      }
1084      row(...r.map((x) => T('m' + mi++, x.text, x)));
1085    }
1086  }
1087
1088  // 「读数是什么时候取的」属于工具区(和刷新按钮挨着),不属于参考区 ——
1089  // 以前它夹在模型表和试算之间,把两块内容切开了。
1090  gap();
1091
1092  // 试算:换一个花法会怎样。纯本地计算,不打接口。
1093  //
1094  // 输入框是引擎画的原生控件,宽度我们说了不算(`width` 只是本文件排版用的预算)。
1095  // 面板窄到放不下时**整行不画** —— 和 mobile 上跳过它同一个理由:附加功能
1096  // 不该被面板边缘裁掉半截,也不该把面板撑出去。
1097  const WHATIF_W = 22;
1098  if (10 + WHATIF_W <= cols) {
1099    row(
1100      T('w1', pad('试算', 10), { dim: true }),
1101      {
1102        id: 'whatif',
1103        kind: 'input',
1104        width: WHATIF_W,
1105        label: '每天 $ ',
1106        placeholder: pace && !pace.insufficient ? pace.perDay.toFixed(2) : '2.00',
1107        submitLabel: '算',
1108      },
1109    );
1110    if (whatIf != null) {
1111      const rate = Number(whatIf);
1112      if (!Number.isFinite(rate) || rate <= 0) {
1113        row(T('w2', pad('', 10), {}), T('w3', '填一个大于 0 的数', { dim: true }));
1114      } else {
1115        const ends = v.plan.periodEndMs;
1116        // 余量为 0 时 exhaustAtMs 返回 null。以前这里不判 null,
1117        // 而 `ends - null` 得到的是"从 1970 年到现在"的毫秒数,
1118        // 于是屏幕上出现「这样会烧到 —,比周期末早 20543d 断」。
1119        const at = exhaustAtMs(m.remaining, rate, now);
1120        const safe = at != null && ends != null && at >= ends;
1121        const verdict =
1122          at == null
1123            ? '已经没有余量了,怎么花都会超'
1124            : rate === (pace && pace.perDay)
1125              ? '就是当前速度'
1126              : '这样会烧到 ' +
1127                fmtDay(at) +
1128                (safe
1129                  ? ' —— 撑得到周期末'
1130                  : ends != null
1131                    ? ',比周期末早 ' + fmtDays((ends - at) / 86400000) + ' 断'
1132                    : '');
1133        // 裁到剩下的宽度里 —— 这句话长度随数据变,窄面板上会超。
1134        row(
1135          T('w2', pad('', 10), {}),
1136          T('w3', truncTo(verdict, Math.max(4, cols - 10)), safe ? { color: 'success' } : { color: 'error', bold: true }),
1137        );
1138      }
1139      // 以前这里还有一句「也就是每天不超过 $X 就不会超」——
1140      // 现在「安全线」是常驻行,不用非得试算一次才能看到。
1141    }
1142  }
1143  gap();
1144
1145  row(
1146    T('f1', stale ? '读数可能已过期 · ' : '快照 ', { dim: true }),
1147    T(
1148      'f2',
1149      Math.max(0, Math.round((now - snapAtSafe(v, now)) / 1000)) + ' 秒前' + (stale ? '(刷新失败,显示的是旧读数)' : ''),
1150      { dim: true, prio: 1 },
1151    ),
1152  );
1153  gap();
1154
1155  row(
1156    { id: 'refresh', kind: 'button', width: plainButtonW('刷新', '1'), label: '刷新', hotkey: '1' },
1157    T('bs', '   '),
1158    { id: 'copy', kind: 'button', width: plainButtonW('复制快照', '2'), label: '复制快照', hotkey: '2' },
1159  );
1160
1161  return rows;
1162}
1163
1164// 面板里显示"快照多久之前",但要和 hooks 侧真正记的抓取时刻一致。
1165// 纯函数拿不到那个模块变量,所以由调用方通过 v.fetchedAt 带进来。
1166function snapAtSafe(v, now) {
1167  const t = Number(v && v.fetchedAt);
1168  return Number.isFinite(t) && t > 0 ? t : now;
1169}
1170
1171// ────────────────────────────────────────────────────────────
1172// 可复制的纯文本快照:一屏自解释,带上口径与时间戳,
1173// 能直接贴给同事或存进 issue。
1174// ────────────────────────────────────────────────────────────
1175export function snapshotText(v, now) {
1176  const pace = computePace(v, now);
1177  const tier = tierOf(v, pace);
1178  const d = new Date(now);
1179  const p = (n) => String(n).padStart(2, '0');
1180  const lines = [
1181    `runway · ${v.plan.name || '—'} · ${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`,
1182    `月额度   ${fmtPct(v.monthly.percent)} 已用 · 剩 ${fmtMoney(v.monthly.remaining)} / ${fmtMoney(v.monthly.total)}`,
1183    `周期     剩 ${v.plan.daysLeft != null ? v.plan.daysLeft + ' 天' : '—'}`,
1184  ];
1185  const fh = v.windows.fiveHour;
1186  const wk = v.windows.weekly;
1187  if (fh) {
1188    lines.push(
1189      `5h 窗口  ${fh.started ? `${fmtPct(fh.percent)} 已用 · ${fmtMoney(fh.used)} / ${fmtMoney(fh.cap)}${fh.resetsInMs != null ? ' · ' + resetText(fh, now) : ''}` : `未启动(上限 ${fmtMoney(fh.cap)})`}`,
1190    );
1191  }
1192  if (wk) {
1193    lines.push(
1194      `周窗口   ${fmtPct(wk.percent)} 已用 · ${fmtMoney(wk.used)} / ${fmtMoney(wk.cap)}`,
1195    );
1196  }
1197  if (pace && !pace.insufficient) {
1198    lines.push(
1199      `节奏     ${tier.word} · 均速 ${fmtMoney(pace.perDay)}/天(样本 ${fmtDays(pace.sampleDays)})`,
1200    );
lib/catalog.mjs 199 lines
1// runway · 模型次数目录(纯函数,不出现 `$`,可测可预览)
2//
3// 「这个套餐下,每个模型大概还能调用多少次」—— Command Code 只在**公开文档页**上
4// 公布这张表,`/alpha/*` 接口不暴露它。所以这里解析的是 docs 站的 RSC 流:
5//
6//     GET https://commandcode.ai/docs/plans/<slug>     头:RSC: 1
7//     → text/x-component(Next.js 的 flight 流,约 200 KB,公开页、不需要凭据)
8//
9// 口径(照抄官方页面的算法,dsh-commandcode-quota 的 catalog.mjs 是同一套):
10//
11//     每次成本 = 输入/1e6×输入单价 + 输出/1e6×输出单价 + 缓存读/1e6×缓存读单价
12//     月次数   = 该模型的 budgetUsd ÷ 每次成本
13//     5h 次数  = 月次数 × fiveHourFraction
14//     周次数   = 月次数 × weeklyFraction
15//
16// **每个模型有自己的 budgetUsd**(GOAT 里 DeepSeek V4.1 Flash 是 $60、GPT-5.6 Sol 是 $70),
17// 套餐级的那个 $70 不参与这条公式 —— 官方两处口径不同,不能互相推算。
18
19// planId → docs 页 slug。官方 planId 与「套餐名」不是一回事,别拿 PLANS 的键来对。
20const PLAN_SLUGS = {
21  'individual-go': 'go',
22  'individual-go-v1': 'go',
23  'individual-goat': 'goat',
24  'individual-pro': 'pro',
25  'individual-pro-v1': 'pro',
26  'individual-max': 'max',
27  'individual-ultra': 'max', // 官方没有独立专页,按 Max 推断
28};
29
30// ⚠️ 这里**曾经**有一张写死的 GO_FRACTIONS 表(individual-go → 0.2/0.5,
31// individual-go-v1 → 0.3/0.6),理由是"页面 props 只反映当前那一代,老套餐会低估"。
32//
33// 2026-10-05 实测把它推翻了:**Go 页面此刻给的正是 0.3 / 0.6**,而那张写死的表
34// 对 `individual-go` 覆盖成 0.2 / 0.5 —— 于是 5h/周的可调用次数**比真值低三分之一**。
35// 这就是"拿写死的盖住实时的"最典型的翻车方式。
36//
37// 现在**以页面为准**(它就是官方此刻公布的口径)。至于某个老套餐的历史系数是否与
38// 当前页面不同 —— 那是个假设,没有证据,不该拿它去覆盖实测数据。
39export const GO_FRACTIONS = {};
40
41export function planSlug(planId) {
42  if (!planId || typeof planId !== 'string') return null;
43  return PLAN_SLUGS[planId.toLowerCase().replace(/_/g, '-')] ?? null;
44}
45
46export function catalogUrl(planId) {
47  const slug = planSlug(planId);
48  return slug ? 'https://commandcode.ai/docs/plans/' + slug : null;
49}
50
51// ── 解析 ──
52// flight 流是「一行一条记录」:`<id>:<JSON>`;id 每次部署都会变,
53// 所以只按**结构签名**找那条记录(有 rows、有 budgetUsd、有两个窗口系数),
54// 绝不按 id 匹配。
55
56function findEstimate(node) {
57  if (!node || typeof node !== 'object') return null;
58  if (Array.isArray(node)) {
59    for (const x of node) {
60      const r = findEstimate(x);
61      if (r) return r;
62    }
63    return null;
64  }
65  if (
66    typeof node.fiveHourFraction === 'number' &&
67    typeof node.weeklyFraction === 'number' &&
68    Array.isArray(node.rows) &&
69    node.rows.some((r) => r && typeof r.budgetUsd === 'number' && isObj(r.rates) && isObj(r.shape))
70  ) {
71    return node;
72  }
73  for (const v of Object.values(node)) {
74    const r = findEstimate(v);
75    if (r) return r;
76  }
77  return null;
78}
79
80function isObj(v) {
81  return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
82}
83
84const flightRe = /^([0-9a-f]+):(.*)$/;
85
86export function parsePlanEstimates(text) {
87  if (typeof text !== 'string' || !text) return null;
88  // 压缩过的响应不能当文本解析 —— 早失败好过解析出半张表。
89  if (text.charCodeAt(0) === 0x1f && text.charCodeAt(1) === 0x8b) return null;
90  if (!text.includes('budgetUsd') || !text.includes('fiveHourFraction')) return null;
91
92  for (const line of text.split('\n')) {
93    const m = flightRe.exec(line);
94    if (!m) continue;
95    let v;
96    try {
97      v = JSON.parse(m[2]);
98    } catch {
99      continue;
100    }
101    const est = findEstimate(v);
102    if (!est) continue;
103    const rows = est.rows.filter(
104      (r) => r && typeof r.name === 'string' && r.name && typeof r.budgetUsd === 'number' && isObj(r.rates) && isObj(r.shape),
105    );
106    if (!rows.length) continue;
107    return {
108      rows,
109      fiveHourFraction: est.fiveHourFraction,
110      weeklyFraction: est.weeklyFraction,
111    };
112  }
113  return null;
114}
115
116// ── 换算 ──
117
118function costOf(row) {
119  const r = row.rates;
120  const s = row.shape;
121  const n = (x) => (Number.isFinite(Number(x)) ? Number(x) : 0);
122  return (
123    (n(s.inputTokens) / 1e6) * n(r.inputCost) +
124    (n(s.outputTokens) / 1e6) * n(r.outputCost) +
125    (n(s.cacheReadTokens) / 1e6) * n(r.cacheReadCost)
126  );
127}
128
129// 官方展示口径:有效数字 3 位,再按 en-US 加千位分隔(154,000 / 30,800)。
130export function fmtCount(n) {
131  if (!Number.isFinite(n)) return '—';
132  return Number(n.toPrecision(3)).toLocaleString('en-US');
133}
134
135// 原始解析结果 → 可直接上屏的目录。按**每月次数降序**:
136// 次数最多 = 每美元能买到的请求最多 = 最该被看见的那一档。
137export function buildCatalog(raw, planId, at) {
138  if (!raw || !Array.isArray(raw.rows) || !raw.rows.length) return null;
139  const slug = planSlug(planId);
140  const go = GO_FRACTIONS[String(planId || '').toLowerCase().replace(/_/g, '-')];
141  const fh = go ? go.fiveHour : raw.fiveHourFraction;
142  const wk = go ? go.weekly : raw.weeklyFraction;
143
144  const models = [];
145  for (const row of raw.rows) {
146    const cost = costOf(row);
147    const monthly = cost > 0 ? row.budgetUsd / cost : null; // cost 为 0 → 官方显示 Free
148    models.push({
149      name: row.name,
150      budgetUsd: row.budgetUsd,
151      costPerRequest: cost > 0 ? cost : null,
152      monthly,
153      fiveHour: monthly == null ? null : monthly * fh,
154      weekly: monthly == null ? null : monthly * wk,
155      hasTimeOfDay: isObj(row.timeOfDay),
156    });
157  }
158  // 次数算不出来的(Free)排在最后,而不是当成 0 混在中间。
159  models.sort((a, b) => (b.monthly ?? -1) - (a.monthly ?? -1));
160  return { slug, planId: planId ?? null, fiveHourFraction: fh, weeklyFraction: wk, models, fetchedAt: at ?? null };
161}
162
163// ── 和上一份比:哪些是新的、哪些改了价 ──
164// 用户要的就是这个:「看看它有没有更新哪些新的又便宜又好用的模型」。
165export function catalogDiff(prev, next) {
166  const empty = { added: new Set(), repriced: new Set(), removed: new Set() };
167  if (!next || !Array.isArray(next.models)) return empty;
168  if (!prev || !Array.isArray(prev.models) || !prev.models.length) return empty;
169
170  const before = new Map(prev.models.map((m) => [m.name, m]));
171  const added = new Set();
172  const repriced = new Set();
173  for (const m of next.models) {
174    const old = before.get(m.name);
175    if (!old) {
176      added.add(m.name);
177      continue;
178    }
179    // 单价或月度份额变了 = 官方调过价。比字符串的话会误报,所以比数值。
180    if (Math.abs((old.costPerRequest ?? 0) - (m.costPerRequest ?? 0)) > 1e-9 || (old.budgetUsd ?? 0) !== (m.budgetUsd ?? 0)) {
181      repriced.add(m.name);
182    }
183  }
184  const now = new Set(next.models.map((m) => m.name));
185  const removed = new Set(prev.models.map((m) => m.name).filter((n) => !now.has(n)));
186  return { added, repriced, removed };
187}
188
189// 目录本身的新鲜度。官方是实时更新的(模型上下架、调价),
190// 所以「什么时候抓的」必须能看见 —— 不然一张过期表比没有表更误导。
191export function catalogAge(catalog, now) {
192  if (!catalog || !catalog.fetchedAt) return null;
193  const ms = Math.max(0, (Number(now) || 0) - catalog.fetchedAt);
194  if (ms < 90_000) return '刚刚更新';
195  if (ms < 3600_000) return Math.round(ms / 60_000) + ' 分钟前更新';
196  if (ms < 48 * 3600_000) return Math.round(ms / 3600_000) + ' 小时前更新';
197  return Math.round(ms / 86400_000) + ' 天前更新';
198}
199