diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 5efa19b..e47a68c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "command-code-usage", "source": "./command-code-usage", "version": "1.1.0", - "description": "Check your Command Code plan usage from inside your coding agent: 5-hour and weekly rolling windows, monthly credits or balance, plus a remaining-requests estimate and a burn-rate warning. Requires a Command Code plan.", + "description": "See your Command Code plan usage — 5-hour and weekly rolling windows, monthly credits or balance — right inside the conversation, with a remaining-requests estimate and a burn-rate warning. Requires a Command Code plan.", "displayName": "Command Code Usage", "category": "utilities", "homepage": "https://github.com/Jovan1666/zcode-command-code-usage", diff --git a/assets/command-code-usage/icon.png b/assets/command-code-usage/icon.png new file mode 100644 index 0000000..cab3f94 Binary files /dev/null and b/assets/command-code-usage/icon.png differ diff --git a/command-code-usage/.claude-plugin/plugin.json b/command-code-usage/.claude-plugin/plugin.json index f02843a..7dd4c64 100644 --- a/command-code-usage/.claude-plugin/plugin.json +++ b/command-code-usage/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "command-code-usage", "version": "1.1.0", - "description": "Check your Command Code plan usage from inside your coding agent: 5-hour and weekly rolling windows, monthly credits or balance, plus a remaining-requests estimate and a burn-rate warning. Requires a Command Code plan.", + "description": "See your Command Code plan usage — 5-hour and weekly rolling windows, monthly credits or balance — right inside the conversation, with a remaining-requests estimate and a burn-rate warning. Requires a Command Code plan.", "author": { "name": "Jovan1666", "url": "https://github.com/Jovan1666" diff --git a/command-code-usage/.zcode-plugin/plugin.json b/command-code-usage/.zcode-plugin/plugin.json index f02843a..9f873e8 100644 --- a/command-code-usage/.zcode-plugin/plugin.json +++ b/command-code-usage/.zcode-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "command-code-usage", "version": "1.1.0", - "description": "Check your Command Code plan usage from inside your coding agent: 5-hour and weekly rolling windows, monthly credits or balance, plus a remaining-requests estimate and a burn-rate warning. Requires a Command Code plan.", + "description": "See your Command Code plan usage — 5-hour and weekly rolling windows, monthly credits or balance — right inside the conversation, with a remaining-requests estimate and a burn-rate warning. Requires a Command Code plan.", "author": { "name": "Jovan1666", "url": "https://github.com/Jovan1666" @@ -10,5 +10,9 @@ "homepage": "https://github.com/Jovan1666/zcode-command-code-usage", "repository": "https://github.com/Jovan1666/zcode-command-code-usage", "commands": "./commands", - "skills": "./skills" + "skills": "./skills", + "description_i18n": { + "en": "See your Command Code plan usage — 5-hour and weekly rolling windows, monthly credits or balance — right inside the conversation, with a remaining-requests estimate and a burn-rate warning. Requires a Command Code plan.", + "zh-CN": "在对话里直接查看 Command Code 套餐用量——5 小时与每周滚动窗口、月度额度或余额,含剩余次数估算与超限预警。需要 Command Code 套餐。" + } } diff --git a/command-code-usage/README.md b/command-code-usage/README.md index 24dc039..2593929 100644 --- a/command-code-usage/README.md +++ b/command-code-usage/README.md @@ -1,25 +1,156 @@ -# command-code-usage +# Command Code Usage -The plugin payload. Full documentation is in the repository root: +See how much of your **Command Code** plan you have left, without leaving the conversation. -- [README.md](../README.md) — English -- [README.zh-CN.md](../README.zh-CN.md) — 简体中文 +Command Code plans (Go / GOAT / Pro / Max / Teams) pace your monthly credits with two **rolling +windows** — a 5-hour cap and a weekly cap. A window opens on your first request and resets a fixed +time later; it does not follow calendar days, and usage never carries over between windows. So +"can I still finish this task?" cannot be answered from the monthly balance alone. What matters is +how much of the *current* window is left and when it resets. -Quick reference: +This plugin reads all three numbers and renders them where you are already looking. -| Command | What it does | -|---|---| -| `/quota` | Show the usage panel in the conversation | -| `/usage` | Alias for `/quota` | -| `/quota --md` · `--compact` · `--json` · `--demo hot` | Other renderings | +``` +Command Code · GOAT 09-21 00:45 · 30d left in period +────────────────────────────────────────────────────────────────────────── +account your-name + +5-hour window ██░░░░░░░░░░░░░░░░░░░░░░ 6.3% $0.89 / $14.00 + resets 04:29 · in 3h 43m +weekly window █░░░░░░░░░░░░░░░░░░░░░░░ 2.5% $0.89 / $35.00 + resets 09-27 23:29 · in 6d 22h +monthly ░░░░░░░░░░░░░░░░░░░░░░░░ 1.3% $0.89 / $70.00 + $69.11 remaining + +this period 330 requests · 100% success · 96.3M in / 306.9K out tokens +estimate at your $0.0026 average, room for ≈ 5,064 more requests in the 5-hour window + (based on your actual model mix this period; pricier models go much shorter) +``` -```bash -# user-scope install (no marketplace needed) -node scripts/install-user-scope.mjs +When you are burning fast enough that a window will run out before it resets, it says so: -# the panel, standalone -node scripts/cc-usage.mjs ``` +⚠ at the current $4.30/h, the 5-hour window runs out before it resets — exhausted in ~15m 20s +``` + +## Components + +| Type | Name | What it does | +|---|---|---| +| Command | `/quota` | Renders the usage panel in the conversation | +| Command | `/usage` | Alias for `/quota` | +| Skill | `command-code-usage` | Teaches the agent to fetch the panel and to answer "is it enough to finish this task?" from the remaining-requests estimate rather than the monthly balance | + +No hooks, no MCP servers, no agents, no background processes. + +## Requirements + +- **A Command Code plan.** Without one there is nothing to show. On pay-as-you-go rather than a + subscription it still works, but shows a balance instead of windows. +- **Node.js** on `PATH`. The bundled scripts use only Node built-ins (`node:fs`, `node:http`, + `node:os`, `node:path`, `node:child_process`, `node:zlib`) and the global `fetch`, so there is + nothing to install. + +## Usage + +| Command | Result | +|---|---| +| `/quota` | The panel above | +| `/quota --md` | Markdown table, easier to copy | +| `/quota --compact` | One line, e.g. `CC GOAT · 5h 6% · weekly 2% · monthly 1.2% · $69.16 left` | +| `/quota --json` | Normalised fields plus the raw API responses | +| `/quota --demo hot` | Sample data — previews the warning state without touching the network | + +Asking in plain language works too: *"How much Command Code quota do I have left? Is it enough to +finish what we are doing?"* + +## Credentials and network access + +**Network:** the panel calls four read-only endpoints on `https://api.commandcode.ai` — +`/alpha/whoami`, `/alpha/billing/credits`, `/alpha/billing/subscriptions`, and +`/alpha/usage/summary`. No other host is contacted. Nothing is sent anywhere else, and there is no +telemetry. + +**The API key is resolved at runtime, in this order** (first hit wins): + +1. the environment variable `COMMAND_CODE_API_KEY`, `CMD_API_KEY` or `COMMANDCODE_API_KEY`; +2. `~/.commandcode/auth.json`, written by logging into the Command Code CLI; +3. `~/.zcode/v2/provider_config.json` — a provider whose `api.baseUrl` points at `commandcode.ai`, + i.e. the key you already configured in ZCode. + +The key is only ever placed in an `Authorization: Bearer` header. **The plugin does not write, +print, log or transmit the key**, and it contains no credentials. `--verbose` reports which source +was used (never the key itself). + +## Side effects + +- **Reads** the credential files listed above, and the API endpoints listed above. +- **Writes nothing** by default. No cache, no state, no config changes. +- `--html` writes one HTML file, only when you explicitly pass that flag, to the path you choose + (default `./command-code-usage.html`). +- `--serve` starts a local HTTP server on `127.0.0.1` (default port 8787) so a browser can poll the + panel. It is off unless you pass the flag, binds to loopback only, and stops with Ctrl+C. +- The panel shells out to `node /scripts/cc-usage.mjs`. That is the only process it starts. + +## How the two useful numbers are derived + +**"Room for ≈ N more requests"** = remaining allowance ÷ your average cost per request *this +period*. The average comes from your own usage, so the estimate adapts to any plan and any model +mix without hard-coding per-model rates. Because the baseline is your own average, **it stops +holding the moment you switch models** — the panel says so. Command Code's `/provider/v1/models` +returns a model list with no allowance factors or prices, so "how many requests of model X +specifically" cannot be computed from the API. + +**The warning** extrapolates your current burn rate. That path has a trap: an hour after a window +opens, extrapolating one hour of activity across seven days will always claim the weekly cap is +about to blow — pure noise. So the script enforces a minimum sample: **under 5% of the window +elapsed it draws no conclusion at all**. No warning therefore means "not enough data yet", not +"you are safe". + +## Account shapes it handles + +| Situation | What is shown | +|---|---| +| Subscription, plan in the known table | monthly allowance bar plus both windows | +| Subscription, plan not in the table (new or enterprise) | "allowance", with an explicit note that the total is inferred from spent + remaining | +| No active subscription (pay-as-you-go, enterprise pool) | balance only, no meaningless percentage | +| Organisation spend caps configured | extra limit rows (shapes it cannot recognise are skipped, never guessed) | +| No requests yet this period | no request-count estimate, and it says why | + +## Bundled scripts + +Besides the command the agent runs (`scripts/cc-usage.mjs`), the plugin ships two standalone tools. +Neither runs on its own; they exist for the cases described here. + +- `scripts/cc-usage.mjs` — the panel itself. Runnable directly: + `node scripts/cc-usage.mjs --compact`. The panel adds no state and no cache. +- `scripts/install-user-scope.mjs` — installs the commands and skill into your user-scope agent + directories (`~/.zcode/commands`, `~/.zcode/skills`) for users who prefer not to go through the + marketplace. It writes those files and keeps a small manifest of what it wrote so it can update + or remove them later. **Do not run it on top of a marketplace installation**: user-scope copies + are discovered first and would shadow the installed plugin. `--uninstall` removes them. +- `scripts/verify-discoverable.cjs` — a read-only diagnostic that re-implements ZCode's own command + parser, so a "my command does not show up" report can come with evidence. It reads files and + prints a report; it writes nothing. + +## Troubleshooting + +| Symptom | Cause and fix | +|---|---| +| `/quota` missing from the `/` menu | The catalogue is snapshotted when a session starts. Fully quit the app and reopen it. | +| Typing `/quota` sends it as a normal message | The command was not discovered. Run `node scripts/verify-discoverable.cjs .` — it re-implements ZCode's own parser and reports diagnostics. | +| "No Command Code credentials found" | Provide one of the three sources above. | +| HTTP 401 on every endpoint | The key is invalid or expired. Re-login, or re-enter it in the ZCode provider settings. | +| Numbers look stale | Window reset times move. Re-run the command rather than reusing an older reading. | +| Requests being rate-limited (429) | Check which window reports `exceeded`, then wait for the reset, buy extra credits, or upgrade. | + +## Contributing + +Issues and pull requests: . + +Not affiliated with Command Code. The plugin reads your own account's usage through the same +endpoints the official CLI uses. + +## License -This plugin contains no credentials. The Command Code API key is resolved at runtime from -`COMMAND_CODE_API_KEY`, `~/.commandcode/auth.json`, or the provider already configured in ZCode. +[MIT](https://github.com/Jovan1666/zcode-command-code-usage/blob/main/LICENSE) diff --git a/command-code-usage/README_CN.md b/command-code-usage/README_CN.md new file mode 100644 index 0000000..4f9375b --- /dev/null +++ b/command-code-usage/README_CN.md @@ -0,0 +1,144 @@ +# Command Code Usage + +在对话里直接看 **Command Code** 套餐还剩多少用量,不用离开对话。 + +Command Code 的套餐(Go / GOAT / Pro / Max / Teams)除了月度额度,还压着两个**滚动窗口**: +5 小时上限和每周上限。窗口从你第一次请求开始计时,到点重置,**不跟自然日/周走**,用量也不跨 +窗口结转。所以「这个任务还能不能跑完」光看月度余额是答不出来的——要看**当前窗口**还剩多少、 +什么时候重置。 + +这个插件把这三个数读出来,渲染在你本来就在看的地方。 + +``` +Command Code · GOAT 09-21 00:45 · 周期剩 30 天 +────────────────────────────────────────────────────────────────────────── +账号 your-name + +5 小时窗口 ██░░░░░░░░░░░░░░░░░░░░░░ 6.3% $0.89 / $14.00 + 重置 04:29 · 3h 43m 后 +每周窗口 █░░░░░░░░░░░░░░░░░░░░░░░ 2.5% $0.89 / $35.00 + 重置 09-27 23:29 · 6d 22h 后 +月度额度 ░░░░░░░░░░░░░░░░░░░░░░░░ 1.3% $0.89 / $70.00 + 剩 $69.11 + +本周期 330 次请求 · 成功率 100% · 入 96.3M / 出 306.9K tokens +预估 按本周期均单价 $0.0026 估算还能跑:5 小时窗口 ≈ 5,064 次 + (基于你本周期实际的模型组合;换更贵的模型次数会明显变少) +``` + +当消耗速度足以在重置前撞上限时,它会直说: + +``` +⚠ 按当前速度($4.30/小时),5 小时窗口会在重置前用完,约 15m 20s 后耗尽 +``` + +## 组件 + +| 类型 | 名称 | 作用 | +|---|---|---| +| 命令 | `/quota` | 在对话里渲染额度面板 | +| 命令 | `/usage` | `/quota` 的别名 | +| 技能 | `command-code-usage` | 教会 agent 取面板,并用剩余次数估算(而不是月度余额)回答「够不够跑完」 | + +没有 hook、没有 MCP server、没有 agent、没有后台常驻进程。 + +## 环境要求 + +- **一个 Command Code 套餐。** 没有套餐就没数可读。按量计费(非订阅)也能用,只是显示余额 + 而不是窗口。 +- **`PATH` 里有 Node.js。** 脚本只用 Node 内置模块(`node:fs`、`node:http`、`node:os`、 + `node:path`、`node:child_process`、`node:zlib`)和全局 `fetch`,不需要安装任何依赖。 + +## 用法 + +| 命令 | 结果 | +|---|---| +| `/quota` | 上面那个面板 | +| `/quota --md` | Markdown 表格,方便复制 | +| `/quota --compact` | 一行,例如 `CC GOAT · 5h 6% · 周 2% · 月 1.2% · 剩 $69.16` | +| `/quota --json` | 归一化字段,外加原始接口响应 | +| `/quota --demo hot` | 样例数据,不联网也能预览告警长什么样 | + +也可以直接问:*「我 Command Code 额度还剩多少?够不够把手上这个做完?」* + +## 凭证与网络访问 + +**网络:** 面板只调用 `https://api.commandcode.ai` 上四个只读端点——`/alpha/whoami`、 +`/alpha/billing/credits`、`/alpha/billing/subscriptions`、`/alpha/usage/summary`。 +不访问任何其他主机,不向任何其他位置发送数据,也没有遥测。 + +**API key 在运行时解析,按以下顺序,命中即用:** + +1. 环境变量 `COMMAND_CODE_API_KEY`、`CMD_API_KEY` 或 `COMMANDCODE_API_KEY`; +2. `~/.commandcode/auth.json`——登录 Command Code CLI 后生成; +3. `~/.zcode/v2/provider_config.json`——其中 `api.baseUrl` 指向 `commandcode.ai` 的 provider, + 也就是你已经在 ZCode 里配好的那把 key。 + +key 只会出现在 `Authorization: Bearer` 请求头里。**本插件不写入、不打印、不记录、不外传 key**, +插件内也不含任何凭证。`--verbose` 会报告用了哪个来源(但不显示 key 本身)。 + +## 副作用 + +- **读取**上面列出的凭证文件,以及上面列出的接口端点。 +- **默认不写任何东西。** 没有缓存、没有状态文件、不改配置。 +- `--html` 会写一个 HTML 文件——只在你显式传该参数时,写入你指定的路径 + (默认 `./command-code-usage.html`)。 +- `--serve` 会在 `127.0.0.1` 起一个本地 HTTP 服务(默认端口 8787)供浏览器轮询面板。不传参数 + 就不会启动;只绑定回环地址;Ctrl+C 停止。 +- 面板会调用 `node <插件目录>/scripts/cc-usage.mjs`。这是它唯一启动的进程。 + +## 两个有用的数是怎么来的 + +**「还能跑约 N 次」** = 剩余额度 ÷ 本周期均单价。均单价取自**你自己**这个周期的实际用量, +所以它自动适配任何套餐、任何模型组合,不需要把每个模型的费率硬编码进来。正因为基准是你自己的 +均值,**一换模型它就不再成立**——面板里写明了这一点。Command Code 的 `/provider/v1/models` +只返回模型清单,不含额度系数或单价,所以「某个特定模型还能跑几次」从接口层就算不出来。 + +**告警**按当前消耗速度外推。这条路有个陷阱:窗口刚开一小时,拿这一小时的速度去推七天,必然 +天天喊「周窗口要超限了」——纯噪音。所以脚本设了最小采样门槛:**不足窗口时长的 5% 就不出结论**。 +因此「没有告警」的意思是「样本还不够判断」,而不是「你安全」。 + +## 它怎么适配不同账号形态 + +| 情况 | 显示 | +|---|---| +| 有订阅、套餐在已知表里 | 月度额度进度条 + 两个窗口 | +| 有订阅、套餐不在表里(新套餐/企业套餐) | 显示「额度」,并明说总额是按「已花 + 剩余」推算 | +| 无有效订阅(按量计费 / 企业池) | 只显示余额,不套没有意义的百分比 | +| 组织配置了消费上限 | 追加限额行(认不出的字段形状直接跳过,不猜) | +| 本周期还没有请求 | 不给次数估算,并说明原因 | + +## 附带脚本 + +除了 agent 执行的那条命令(`scripts/cc-usage.mjs`),插件还带两个独立工具。它们不会自动运行, +只在下列场景下由你手动使用。 + +- `scripts/cc-usage.mjs` —— 面板本体。可直接运行:`node scripts/cc-usage.mjs --compact`。 + 面板不写状态、不建缓存。 +- `scripts/install-user-scope.mjs` —— 把命令与技能装进你的用户级 agent 目录 + (`~/.zcode/commands`、`~/.zcode/skills`),供不想走市场的用户使用。它会写入这些文件,并保留 + 一份「我写过什么」的小清单,以便日后更新或卸载。**不要在市场安装之上再跑它**:用户级副本的 + 发现优先级更高,会遮蔽已安装的插件。`--uninstall` 可移除。 +- `scripts/verify-discoverable.cjs` —— 只读诊断工具,复刻了 ZCode 自己的命令解析器, + 这样「我的命令不出现」这类问题可以带着证据来报。它只读文件、打印报告,不写任何东西。 + +## 排查 + +| 现象 | 原因与处理 | +|---|---| +| `/` 菜单里找不到 `/quota` | 命令清单在会话启动时快照。完全退出应用再打开。 | +| 输入 `/quota` 被当成普通消息发出去 | 命令没被发现。跑 `node scripts/verify-discoverable.cjs .`——它复刻了 ZCode 自己的解析器,会报出诊断。 | +| 提示找不到凭证 | 按上面三种来源提供其一。 | +| 每个端点都 HTTP 401 | key 无效或已过期。重新登录,或在 ZCode provider 设置里重新填。 | +| 数字看着不新 | 窗口重置时间一直在走。重新跑一次命令,别复用旧读数。 | +| 请求被限流(429) | 看哪个窗口报告 `exceeded`,然后等重置、买额外额度、或升级套餐。 | + +## 参与贡献 + +问题与 PR:。 + +与 Command Code 官方无关。本插件通过官方 CLI 使用的同一批端点读取你自己账号的用量。 + +## 许可证 + +[MIT](https://github.com/Jovan1666/zcode-command-code-usage/blob/main/LICENSE) diff --git a/marketplace.json b/marketplace.json index 347ea0d..0db4377 100644 --- a/marketplace.json +++ b/marketplace.json @@ -3,33 +3,44 @@ "plugins": [ { "name": "command-code-usage", - "source": "./command-code-usage", - "version": "1.1.0", - "description": "Check your Command Code plan usage from inside your coding agent: 5-hour and weekly rolling windows, monthly credits or balance, plus a remaining-requests estimate and a burn-rate warning. Requires a Command Code plan.", "displayName": "Command Code Usage", "displayName_i18n": { + "en": "Command Code Usage", "zh-CN": "Command Code 额度面板" }, + "source": "./command-code-usage", + "description": "See your Command Code plan usage — 5-hour and weekly rolling windows, monthly credits or balance — right inside the conversation, with a remaining-requests estimate and a burn-rate warning. Requires a Command Code plan.", "description_i18n": { - "zh-CN": "在 ZCode 对话里直接查看 Command Code 套餐用量(5 小时与每周滚动窗口、月度额度或余额),含剩余次数估算与超限预警。需要 Command Code 套餐。" + "en": "See your Command Code plan usage — 5-hour and weekly rolling windows, monthly credits or balance — right inside the conversation, with a remaining-requests estimate and a burn-rate warning. Requires a Command Code plan.", + "zh-CN": "在对话里直接查看 Command Code 套餐用量——5 小时与每周滚动窗口、月度额度或余额,含剩余次数估算与超限预警。需要 Command Code 套餐。" }, - "category": "utilities", - "homepage": "https://github.com/Jovan1666/zcode-command-code-usage", + "version": "1.1.0", "author": { "name": "Jovan1666", "url": "https://github.com/Jovan1666" }, - "examplePrompts": [ - "/quota", - "How much Command Code quota do I have left, and is it enough to finish this task?" + "homepage": "https://github.com/Jovan1666/zcode-command-code-usage", + "repository": "https://github.com/Jovan1666/zcode-command-code-usage", + "license": "MIT", + "keywords": [ + "quota", + "usage", + "command-code", + "billing", + "credits", + "rate-limit", + "limits", + "monitoring", + "tokens" ], - "examplePrompts_i18n": { - "zh-CN": [ - "/quota", - "我 Command Code 额度还剩多少?够不够把这个任务跑完?" - ] - } + "category": "utilities", + "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/command-code-usage/icon.png", + "requiresPaidPlan": true } ], - "description": "See your Command Code plan usage (5-hour and weekly rolling windows, monthly credits or balance) from inside your coding agent, with a remaining-requests estimate. Requires a Command Code plan." + "description": "Command Code plan usage panel for ZCode: rolling windows, credits, estimates.", + "description_i18n": { + "en": "Command Code plan usage panel for ZCode: rolling windows, credits, estimates.", + "zh-CN": "ZCode 用的 Command Code 套餐额度面板:滚动窗口、额度与次数估算。" + } } diff --git a/scripts/check.mjs b/scripts/check.mjs index 432ebe4..496bfe7 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -117,6 +117,8 @@ const zcodeEntryAllowed = new Set([ 'name', 'source', 'version', 'description', 'displayName', 'displayName_i18n', 'description_i18n', 'icon', 'category', 'homepage', 'privacyPolicy', 'termsOfService', 'heroImage', 'author', 'examplePrompts', 'examplePrompts_i18n', 'requiresPaidPlan', + // 官方 zai-org/zcode-plugins 也在用的字段 + 'keywords', 'license', 'repository', ]); const unknownZ = Object.keys(zMarket.plugins[0]).filter((k) => !zcodeEntryAllowed.has(k)); ok(unknownZ.length === 0, 'ZCode 市场条目字段均受支持', unknownZ.join(', ')); diff --git a/scripts/make-icon.mjs b/scripts/make-icon.mjs new file mode 100644 index 0000000..b77a308 --- /dev/null +++ b/scripts/make-icon.mjs @@ -0,0 +1,193 @@ +#!/usr/bin/env node +/** + * 生成插件图标 —— assets/command-code-usage/icon.png + * + * node scripts/make-icon.mjs + * + * 为什么不用图像生成模型:这就是一块纯几何图形(深色圆角底 + 三条渐变进度条), + * 代码画出来的结果确定、可复现、随仓库走,也不必依赖外部服务或 API key。 + * + * 画面内容对应该插件本身的形态:深色底 + 三条不同长度的进度条, + * 即面板里的 5 小时窗口 / 每周窗口 / 月度额度。 + * + * 实现:4 倍超采样 + 有符号距离场求覆盖率,再盒式降采样,得到抗锯齿边缘。 + * 只用 Node 内置模块(zlib 做 PNG 的 deflate)。 + */ + +import zlib from 'node:zlib'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const OUT = path.join(ROOT, 'assets', 'command-code-usage', 'icon.png'); + +const SIZE = 256; +const SS = 4; // 超采样倍数 +const W = SIZE * SS; + +/* ------------------------------------------------------------------ 绘图参数 */ + +const BG = [0x0b, 0x0d, 0x12]; // #0b0d12 +const BORDER = [0x2a, 0x30, 0x40]; // 细边,避免深色图标在深色界面上糊成一片 +const TRACK = [0x23, 0x28, 0x37]; // #232837 +const GRAD_FROM = [0x34, 0xd3, 0x99]; // #34d399 低用量 +const GRAD_TO = [0xa7, 0x8b, 0xfa]; // #a78bfa 高用量 +const LEVELS = [0.72, 0.44, 0.24]; // 与面板的三个进度条对应 + +const TILE = { x: 6, y: 6, w: 244, h: 244, r: 56 }; +const BAR = { x: 40, w: 176, h: 28, r: 14, gap: 18 }; + +/* ------------------------------------------------------------ 距离场与采样 */ + +// 圆角矩形有符号距离:<0 在内部,>0 在外部 +function sdRoundRect(px, py, cx, cy, hw, hh, r) { + const qx = Math.abs(px - cx) - (hw - r); + const qy = Math.abs(py - cy) - (hh - r); + const ax = Math.max(qx, 0); + const ay = Math.max(qy, 0); + return Math.hypot(ax, ay) + Math.min(Math.max(qx, qy), 0) - r; +} + +// 把距离换算成覆盖率(1px 过渡带),用于超采样下的平滑边缘 +function coverage(d) { + return Math.min(Math.max(0.5 - d, 0), 1); +} + +const lerp = (a, b, t) => a + (b - a) * t; +const mix = (c1, c2, t) => [lerp(c1[0], c2[0], t), lerp(c1[1], c2[1], t), lerp(c1[2], c2[2], t)]; + +/* -------------------------------------------------------------- 渲染(超采样) */ + +const buf = new Float32Array(W * W * 4); // RGBA,线性 0..1 + +function setPx(x, y, color, alpha) { + if (alpha <= 0) return; + const i = (y * W + x) * 4; + const a = Math.min(alpha, 1); + buf[i] = buf[i] * (1 - a) + (color[0] / 255) * a; + buf[i + 1] = buf[i + 1] * (1 - a) + (color[1] / 255) * a; + buf[i + 2] = buf[i + 2] * (1 - a) + (color[2] / 255) * a; + buf[i + 3] = buf[i + 3] * (1 - a) + a; +} + +// 三条进度条的几何位置(居中) +const bars = LEVELS.map((level, i) => { + const totalH = BAR.h * LEVELS.length + BAR.gap * (LEVELS.length - 1); + const top = (SIZE - totalH) / 2; + const y = top + i * (BAR.h + BAR.gap); + return { level, cy: y + BAR.h / 2 }; +}); + +for (let py = 0; py < W; py++) { + for (let px = 0; px < W; px++) { + const x = (px + 0.5) / SS; + const y = (py + 0.5) / SS; + + // 底:圆角方块 + const dTile = sdRoundRect(x, y, TILE.x + TILE.w / 2, TILE.y + TILE.h / 2, TILE.w / 2, TILE.h / 2, TILE.r); + setPx(px, py, BG, coverage(dTile)); + + // 描边:外缘附近一圈 + const borderBand = coverage(dTile) * (1 - coverage(dTile - 1.2)); + setPx(px, py, BORDER, borderBand * 0.85); + + // 三条进度条 + for (const bar of bars) { + const cx = BAR.x + BAR.w / 2; + const dTrack = sdRoundRect(x, y, cx, bar.cy, BAR.w / 2, BAR.h / 2, BAR.r); + const covTrack = coverage(dTrack); + if (covTrack <= 0) continue; + setPx(px, py, TRACK, covTrack); + + const fillW = Math.max(BAR.h, BAR.w * bar.level); + const dFill = sdRoundRect(x, y, BAR.x + fillW / 2, bar.cy, fillW / 2, BAR.h / 2, BAR.r); + const covFill = coverage(dFill); + if (covFill <= 0) continue; + + // 渐变按轨道整体位置取样:短条停在绿色端,长条延伸到紫色端 + const t = Math.min(Math.max((x - BAR.x) / BAR.w, 0), 1); + setPx(px, py, mix(GRAD_FROM, GRAD_TO, t), covFill); + } + } +} + +/* ------------------------------------------------------------- 降采样 + 编码 */ + +const out = Buffer.alloc(SIZE * SIZE * 4); +for (let y = 0; y < SIZE; y++) { + for (let x = 0; x < SIZE; x++) { + let r = 0, g = 0, b = 0, a = 0; + for (let dy = 0; dy < SS; dy++) { + for (let dx = 0; dx < SS; dx++) { + const i = ((y * SS + dy) * W + (x * SS + dx)) * 4; + const sa = buf[i + 3]; + // 按 alpha 加权,避免边缘出现暗边 + r += buf[i] * sa; + g += buf[i + 1] * sa; + b += buf[i + 2] * sa; + a += sa; + } + } + const n = SS * SS; + const o = (y * SIZE + x) * 4; + if (a > 0) { + out[o] = Math.round(Math.min(r / a, 1) * 255); + out[o + 1] = Math.round(Math.min(g / a, 1) * 255); + out[o + 2] = Math.round(Math.min(b / a, 1) * 255); + } + out[o + 3] = Math.round((a / n) * 255); + } +} + +function crc32(buf) { + let c; + const table = crc32.table ?? (crc32.table = (() => { + const t = new Int32Array(256); + for (let n = 0; n < 256; n++) { + c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c; + } + return t; + })()); + let crc = -1; + for (let i = 0; i < buf.length; i++) crc = (crc >>> 8) ^ table[(crc ^ buf[i]) & 0xff]; + return (crc ^ -1) >>> 0; +} + +function chunk(type, data) { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, 'ascii'), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body)); + return Buffer.concat([len, body, crc]); +} + +const ihdr = Buffer.alloc(13); +ihdr.writeUInt32BE(SIZE, 0); +ihdr.writeUInt32BE(SIZE, 4); +ihdr[8] = 8; // bit depth +ihdr[9] = 6; // colour type: RGBA +ihdr[10] = 0; // deflate +ihdr[11] = 0; // adaptive filtering +ihdr[12] = 0; // no interlace + +// 每行前加一个 filter 字节 0 +const raw = Buffer.alloc(SIZE * (SIZE * 4 + 1)); +for (let y = 0; y < SIZE; y++) { + raw[y * (SIZE * 4 + 1)] = 0; + out.copy(raw, y * (SIZE * 4 + 1) + 1, y * SIZE * 4, (y + 1) * SIZE * 4); +} + +const png = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), + chunk('IDAT', zlib.deflateSync(raw, { level: 9 })), + chunk('IEND', Buffer.alloc(0)), +]); + +fs.mkdirSync(path.dirname(OUT), { recursive: true }); +fs.writeFileSync(OUT, png); +console.log(`已写入 ${path.relative(ROOT, OUT)} ${SIZE}x${SIZE} ${(png.length / 1024).toFixed(1)} KB`);