输入框下方是三列对齐的模型/用量/状态面板,上方是一只会按工具做动作、带着子代理小螃蟹来回走的像素螃蟹(终端;Windows / macOS / Linux);配速预警和螃蟹情绪、每轮收据、一键压缩、额度恢复提醒、子代理看板 (/hud agents)、点档位切换

English | 简体中文
Desktop-app comforts for the Claude Code terminal
An animated, moody pixel-crab usage panel · one-click file links · code cards you can copy or fill in
<img src="assets/usage-hud.gif" alt="usage-hud: a pixel-crab usage panel under the prompt" width="900">
Claude Code mods are plugins made of function hooks: one folder and a few dozen lines of TypeScript can change what Claude Code itself draws. This repository holds two mods. They don't depend on each other, so you can install just one:
| Mod | What it does |
|---|---|
| usage-hud | A usage panel under the prompt and a pixel crab above it. The crab acts out the tool Claude is using, walks with your subagents, and its mood follows how fast you burn your quota. The panel shows model and effort, project and branch, session time and cost, context / 5-hour / weekly usage and tokens, all at a glance |
| html-shelf | File paths in Claude's replies (HTML, PDF, images, video, folders …) open with one click. Code blocks, and commands written inline, are drawn as cards you can copy or drop into the prompt |
The UI text is in Chinese. The GIFs on this page show exactly what you get.
<img src="assets/quickstart.gif" alt="Three steps: add the marketplace, install the two mods, start claude" width="900">
1. Add the plugin marketplace (run in your terminal)
claude plugin marketplace add ronanworks/claude-code-mods
2. Install the two mods (or only the one you want)
claude plugin install usage-hud@claude-code-mods
claude plugin install html-shelf@claude-code-mods
3. Restart claude
Run /plugin: the dim line under the tabs reads 2 mods active with both names. From then on every new session, in the terminal and in the desktop app, loads them.
Clicking and hovering need Claude Code's fullscreen rendering. If it's off, run
/tui fullscreeninside Claude Code.
Clone the repository, then put the absolute paths of the two folders in the env block of ~/.claude/settings.json. Separate them with ; on Windows and : on macOS / Linux. Mods loaded this way reload in terminal sessions as soon as you save a file.
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-code-mods/usage-hud:/path/to/claude-code-mods/html-shelf"
}
}
To try them once without changing any settings:
claude --plugin-dir ./claude-code-mods/usage-hud --plugin-dir ./claude-code-mods/html-shelf
Updating: run /plugin marketplace update claude-code-mods inside Claude Code, or turn on auto-update for this marketplace under /plugin → Marketplaces.
See what a mod does before you install it: a mod runs with your permissions. Clone the repository and run claude plugin validate ./usage-hud; the hooks: and calls: lines list the events it handles and what it calls.
In the terminal, usage-hud draws two things: a usage panel under the prompt, and a pixel crab that walks in a strip above it.
The panel is a 3 × 3 grid whose labels line up in columns:
| Column 1 | Column 2 | Column 3 | |
|---|---|---|---|
| Row 1 | Model + effort level | Project + git branch | Session time + cost |
| Row 2 | Context usage | 5-hour usage | Weekly usage |
| Row 3 | Status | Most-used tools | Total tokens + output (out) |
The 5-hour and weekly bars each carry a bright │ tick that marks how much of the window has passed, and a short countdown to the reset follows the bar, such as 1h54m. When the colored part runs past the tick, you are using quota faster than the clock.
The crab above the prompt (terminal only) lives in a strip right above the input box: one blank row that keeps it apart from the conversation, then three rows of crab. It follows what Claude is doing:
| Claude is | The crab |
|---|---|
| Thinking or replying | Walks sideways, eyes on where it's going; thinking dots rise while it thinks |
| Reading or searching | Stops and reads a page while a scan line moves |
| Writing or editing a file | Stops and taps with its right claw as the page fills with text |
| Running a command | Stops and types with both claws, terminal cursor blinking |
| On the web | Stops beside a spinning globe |
| Running subagents | Each running subagent adds a baby crab to the line; when it finishes, its crab waves and leaves. The status cell says how many ("+3 agents") |
| Done with a turn | Jumps with claws up, gold sparkles |
| Idle / idle for 5 minutes | Strolls a few steps now and then, blinks and looks around / sleeps, blowing bubbles |
| You type / you send | Stops and looks down at the input box / jumps |
| At ≥ 80% context | Turns red and sweats; at ≥ 95% flashes red |
The crab also has moods that follow your quota pace (pace = actual usage ÷ the usage expected for the time elapsed):
| Quota | The crab | The panel |
|---|---|---|
| Using less than the clock (pace < 0.8) | Wears sunglasses, strolls | Normal |
| Clearly ahead of pace, on track to run out before the reset | Sweats, walks faster | The percentage and the countdown turn red: 40m用完 (empty in 40m) |
| Runs out within 30 minutes, or ≥ 95% used | Panics with claws up and a "!", scurries | Same as above |
/tui fullscreen), point at the crab: it stops, waves, and holds a bubble with your usage and a tip until you move away. Point elsewhere on the strip and its eyes follow the pointer. No click needed; a click moves the keyboard focus to the strip, and Esc gives it back.[-] at the strip's right end: click it or press ctrl+x ctrl+a to fold the strip away. /hud crab off turns it off for good. In a short terminal the strip drops its blank row first, then shrinks to two rows, one row, or hides.More practical touches
· $0.42 · 2 files changed +5 -1 · 3 tool calls (terminal only)./compact./hud agents, or click "+N agents" in the status cell, opens a side pane. It shows how long each subagent has run, how long since it last did something, and its last tool. Anything silent for over 5 minutes is flagged red as possibly stuck.Clickable parts: model name → /model, effort level → /effort, project name → opens the project folder, context → /context, 5-hour / weekly → /usage, token → a breakdown, "+N agents" → the subagent board.
The panel shrinks with the window: 81 columns or more shows the full 3 × 3 grid, 51–80 columns (such as macOS's default 80) a 3 × 2 grid, and under 51 columns a single line. In the terminal the panel always stays under the prompt.
| Command | What it does |
|---|---|
/hud | Cycles full → compact (one line) → hidden |
/hud agents | Opens the subagent board; Esc closes it |
/hud crab, /hud crab on, /hud crab off | Toggles the crab above the prompt (on by default) |
In the desktop app, the panel becomes a dedicated SVG card (crab + dashboard) above the prompt, borderless, following the light or dark theme:
<img src="assets/client.gif" alt="usage-hud in the desktop app: an SVG card above the prompt" width="900">
/cost shows. Subscribers aren't charged this amount.<img src="assets/html-shelf.gif" alt="html-shelf: copy a code card with one click, open an HTML link in the browser" width="900">
code , or text`) open in their default app with one click. Only files that exist become links.Write(...) row gets an Open button./open: /open opens the most recent file, /open 3 the third most recent, and /open list lists the last 10 as clickable links.!. It puts the command into the prompt prefixed with !, and it runs on your machine only when you press Enter. A draft you're still typing is never overwritten. python -m unittest -v x.py ) get a card too, placed after that paragraph, list item or table. Only clear commands count: ones that start with !`, or a known program (python, powershell, git, npm, claude …) with arguments. The same command gets one card, at most 6 per reply.| Item | Notes |
|---|---|
| Claude Code | Terminal 2.1.287 or later, desktop app 2.1.286 or later (the official requirement for mods). Tested on 2.1.289 (Windows) and 2.1.290 (WSL) |
| OS | Windows: tested on Windows 11 + Windows Terminal. Linux: plugin tests and the open commands verified in WSL (Ubuntu 22.04). macOS: covered by mocked tests only; issues are welcome |
| Opening files | Windows: explorer.exe / cmd start; macOS: open; Linux: xdg-open, then gio open; in WSL, Windows' default apps first (wslview, else explorer.exe via wslpath) |
| Rendering | Clicking and hovering need fullscreen rendering (/tui fullscreen) |
| Dependencies | Node.js on your PATH (usage-hud counts tokens with a small script), git (for the branch); on Linux outside WSL, xdg-utils to open files |
CLAUDE_CONFIG_DIR, usage-hud looks for session transcripts there.Removing the marketplace also uninstalls the mods you installed from it:
claude plugin marketplace remove claude-code-mods
If you loaded the folders directly, remove their paths from CLAUDE_CODE_PLUGIN_DIRS and restart claude.
Check and test a mod:
claude plugin validate ./usage-hud
claude plugin test ./usage-hud
/reload-plugins there.version in plugin.json after each change: usage-hud reloads itself in the desktop app when the version changes, and marketplace users only receive a new version.│ ─ ━ ✓ and block characters. Glyphs such as • ⏱ ↻ ✦ have an ambiguous width in some terminals and overlap their neighbours.node tools/preview-client.mjs renders a preview of the desktop panel to tools/out/client.html without opening the app.tools/demo/ holds the scripts that render the GIFs on this page.usage-hud/
hooks/register.tsx terminal panel and events
hooks/desktop.ts desktop panel (SVG)
scripts/count-tokens.js counts a session's tokens, subagents included
html-shelf/
hooks/register.tsx links, code cards, /open
tools/ preview and GIF scripts
assets/ GIFs for this README
MIT © 2026 Ronan
hooks/register.tsx 2454 lines1import type { Register } from 'claude-code'
2import { crabSvg, dashSvg, type DashData } from './desktop'
3import {
4 FRAME_MS,
5 SPRITE_W,
6 MINI_W,
7 DIM,
8 VALUE,
9 WARN,
10 COL,
11 mix,
12 isWide,
13 dw,
14 clip,
15 encode,
16 scenePx,
17 miniPx,
18 type Mood,
19 type ToolKind,
20 type Scene,
21 type Rows,
22} from './sprites'
23import { tipFit } from './sprites'
24import type { WalkProps } from './walkway' // 散步道的 Client 模块 (只取类型)
25export { previewLane, tipFit } from './sprites'
26export type { Mood } from './sprites'
27
28// usage-hud v0.5: 用量面板 (终端版 + 客户端版)
29//
30// 客户端 (桌面 app 的 Code 标签页): 输入框上方一张卡片, 左边 SVG 像素螃蟹 (动作由 SMIL 动画循环播放),
31// 右边 SVG 仪表盘 (自带深色底板), 下面一行可点的链接; 绘制代码在 ./desktop.ts
32//
33// 位置 (终端, v0.16 起固定): 面板在输入框下方 (画在提示行 PromptHint 里, 引擎自己的提示行保留在最下面);
34// 螃蟹在输入框正上方的横栏 (AbovePrompt) 里散步 (Client 模块 walkway.tsx; /hud crab 开关); /hud top|bottom 只回一句话
35// 螃蟹的像素画 (面板那只 + 散步道) 在 sprites.ts, hooks 和 Client 共用
36//
37// 完整版是 "3 行 x 3 列" 的对齐网格 (v0.16 起终端面板里没有螃蟹), 每列起点在三行里完全一致:
38// 模型 Opus 5.5 ▁▂▃▅▇ medium 项目 my-app main 会话 6d18h $199.91
39// 上下文 ━━━━━━━━ 14% 140k/1.0M 5小时 ━━━━━━│━━━━━━ 14% 3h05m 本周 ━━│━━━━━━━━━━ 43% 1d18h
40// 状态 ▃▅▂ 读文件 12s 工具 Bash 12 Read 4 子代理 2 个运行中
41//
42// 螃蟹动画 (全部用像素画, 不用可能宽度不一的符号):
43// 思考 -> 眼睛往右看 + 冒出思考点点 读/搜 -> 看一页纸, 扫描线上下移动
44// 改/写 -> 右钳子敲击 + 纸上逐渐写满字 跑命令 -> 双钳交替敲键盘 + 终端光标闪烁
45// 上网 -> 旋转的小地球 派子代理 / 子代理在跑 -> 散步道里跟着走的小螃蟹 (v0.14)
46// 其他工具 -> 转动的小齿轮 一轮结束 -> 举钳跳跃 + 金色闪光
47// 闲置 -> 眨眼 / 左右张望 / 偶尔挥手 5 分钟没动 -> 闭眼呼吸 + 冒泡泡
48// 上下文 >=80% -> 变红冒汗, >=95% -> 红色闪烁报警
49// 数字变化时会滚动过渡; 工作时上下文条有流光; 5小时/本周 >=90% 时用量条呼吸闪烁
50//
51// 可点击: 模型名 -> /model, 项目名 -> 打开项目文件夹, 上下文 -> /context, 5小时/本周 -> /usage
52// /hud: 完整 -> 精简(1 行) -> 隐藏 循环; /hud agents 子代理看板; /hud crab 开关散步道
53//
54// v0.12 新增:
55// 配速预警: 按 5小时/本周 窗口已过的时间算 "应该用到多少", 预计重置前会用完时, 用量条后面改成红色 "约40m后用完"
56// 螃蟹情绪: 取 5小时 和 本周 里更紧张的那个 -> 悠闲 (戴墨镜) / 正常 / 冒汗 (汗滴) / 慌张 (举钳 + "!")
57// 每轮收据: 每轮结束那行 (Crunched for 9m 6s) 后面追加 "· $0.42 · 改 3 个文件 +120 -30 · 工具 12 次"
58// 一键压缩: 上下文 >=75% 时 "上下文" 那格出现 [压缩] 按钮 -> /compact; 额度重置后弹一次 "已恢复" 提示
59// 子代理看板: /hud agents 或点状态格里的 "+N代理" -> 侧边面板, 列出每个子代理的时长/最后动静/工具/状态
60// 点档位文字 -> /effort 打开档位滑块
61// 子代理小螃蟹: 有子代理在跑时, 右侧 3 列让给小螃蟹, 干活时也看得见
62//
63// v0.13 改动:
64// 时间刻度: 5小时/本周 的用量条上画一道亮色 │, 位置 = 窗口已过时间 / 窗口长度; 彩色段超过刻度 = 用得比时间快
65// 文字缩短: 平时只写重置倒计时 "1h54m" (暗色); 会用完时百分比变红, 文字写红色的 "40m用完"
66// (v0.13.1: 预计用完的时间一定带 "用完"; 放不下就换 "4d用完", 再放不下照常写暗色的重置倒计时)
67// 完整版 5小时/本周 的附加那段从 11 列缩到 7 列, 省下的给用量条; 中等版三根条仍然一样长
68// 子代理小螃蟹: 不管几个子代理都只画一只 (3x2, 没有眼睛), 右下角慢慢跳; 数量看 "+N代理"
69//
70// v0.14 新增: 螃蟹散步道 (输入框正上方的横栏, 只在终端, 高 2 行; 只剩 1 行时画 1 行版)
71// 主会话一轮在跑 -> 大螃蟹横着走, 碰到两端掉头, 速度跟心情 (悠闲慢 / 正常 / 冒汗快 / 慌张小跑)
72// 闲着 -> 趴着偶尔眨眼; 5 分钟没动静 -> 睡着 (闭眼 + z); 一轮结束 -> 举钳跳
73// 每个运行中的子代理一只小螃蟹, 排成一队; 放不下画几只 + 暗色 "+N"; 子代理结束 -> 挥手 1.5 秒后离场
74// 主会话闲着、后台子代理还在跑 -> 大螃蟹照样走, 小螃蟹排成一队跟在后面 (v0.15 起; 状态写 "子代理在跑");
75// 面板里的螃蟹不再带小螃蟹
76// 气泡 (「」, 约 5 秒, 新的顶掉旧的): 搞定 3m12s / 压缩完了 / 额度刷新了 / 慢点!40m用完 (红) / 上下文快满了 / 等你点头
77// 鼠标停在横栏上 (只在全屏模式) -> 一行用量摘要 + 小贴士
78//
79// v0.16:
80// 终端面板去掉螃蟹, 网格占满整行; 各档分界前移 17 列 (完整版 81 列起, 中等版 51-80 列, 精简版不到 51 列)
81// 终端面板固定在输入框下方, 螃蟹固定在上方 (store 里存的 position 一律不读)
82// 散步道顶上加一行 "天空行" 隔开正文和螃蟹 (maxRows >=4: 天空 + 3 行版; 3: 天空 + 2 行版; 2 / 1: 没有天空行)
83// 散步道大螃蟹拿到面板螃蟹的整套动作 (scenePx): 用工具时停下做动作、道具画在右边; 在想时走, 头顶冒思考点点
84// 粒子加量 (走路同时 3-5 粒, 小跑 5-7 粒, 上限 24); 小螃蟹改成 7x4, 眼睛四周都是身体
85// 散步道整个搬进 Client 模块: 鼠标停在大螃蟹上 -> 停下举钳 + 气泡 (停下时定好), 离开约 0.5 秒后接着走
86//
87// v0.16.3 (按第三次实测改):
88// 后台子代理的结果在主线程闲着时送回来, 不经过 prompt.submit, 直接是一个没有 prompt.submit 的 turn.start:
89// 这种 turn.start 算 "结果送到了一次"; 已经庆祝过的提问再来这种段, 只记账不再庆祝
90// 收据: 引擎那行 (TurnDuration) 的外层是 width 100%, 和收据并排时两边按比例挤, 收据被推到最右边还截短;
91// 现在按引擎那行字的宽度给它定宽, 收据紧跟在后面, 放不下先省 "改 N 个文件", 最后才截
92// 工具道具: 工具只跑几十毫秒时, 散步道的帧钟 (150ms) 可能一次也看不到它; 改成按工具序号认, 每次至少做 4 帧
93//
94// v0.16.4 (按第四次实测改):
95// 收据: 引擎节点的祖先 Box 不能带 width / height / minWidth / minHeight / display / overflow / position /
96// top / left / right / bottom (带了引擎就拒绝整棵树, 自己画, 收据全没了). 现在引擎那行原样放着, 收据放进
97// 旁边一个 flexShrink 0 的 Box, 靠右; 先挑一种写法保证 "引擎那行字的宽 + 收据宽 <= 列数"
98// "搞定" 改成纯防抖: 主线程一段结束、$.agent.list() 里没有在跑的子代理 -> 等 2 秒; 这 2 秒里来了 turn.start
99// 或 prompt.submit 就取消; 这一段结束前 2 秒内有子代理刚结束 -> 多等 2 秒 (只延一次); 不再记 "欠几次通知"
100// 调试: 环境变量 USAGE_HUD_DEBUG=1 时, 把提问 / 每段 / 子代理 / prompt.submit / 防抖的事件带时间写进 debug 日志
101// 工具道具: 散步道一收到新的工具序号就画 (不等下一帧), 从那一刻起至少 600ms
102//
103// v0.15 散步道 v2:
104// 横栏放得下 3 行就画 3 行版: 大螃蟹用面板那套 12x6 画法 (走路腿交替、眼睛看前面、会眨眼、钳子偶尔夹一下,
105// 冒汗 / 慌张 / 庆祝 / 睡觉和面板一样); 小螃蟹 9x4 排队跟着走; 不够 3 行退到 2 行 / 1 行版
106// 大螃蟹一直有动静: 主会话或子代理在跑都走; 全闲时每 20-40 秒溜达几格再东张西望; 5 分钟没有任何动静才睡
107// 打字 -> 停下低头看输入框 (停手 1.5 秒恢复); 发出消息 -> 跳一下, 落地冒尘土
108// 粒子: 走路扬尘、小跑甩汗、庆祝金色闪光、睡觉冒泡泡、小螃蟹离场冒一团 (盲文点, 只画在空格子里, 最多 16 个)
109// 悬停: 只以大螃蟹的格子为悬停区域 -> 举钳打招呼 + 旁边冒一个用量摘要气泡 (不再盖住整个顶行)
110// 子代理看板只留引擎的 x 和 Esc 关闭
111//
112// 螃蟹各种状态怎么叠 (从高到低, 只有一个能决定 "姿势"):
113// 1. 庆祝 (一轮刚结束) -> 举钳跳 + 闪光, 情绪标记暂时不画
114// 2. 工作中 (按工具做动作) -> 动作照常; 情绪只叠小标记: 冒汗 = 头边汗滴, 慌张 = 头边汗滴和红色 "!" 交替闪
115// 3. 闲置 + 慌张 -> 双钳举起 + 左右发抖 + 右侧大 "!" (慌张会把睡着的螃蟹叫醒)
116// 4. 睡觉 (5 分钟没动) -> 闭眼呼吸 + 泡泡 (冒汗时照样有汗滴)
117// 5. 闲置 -> 眨眼/张望/挥手; 悠闲时改戴墨镜 (不眨眼不张望)
118// 身体颜色永远只表示上下文 (>=80% 变红, >=95% 闪); 上下文 >=80% 也冒汗, 和情绪的汗滴是同一颗
119// 右侧 3 列 (x 12-14) 一律给道具/泡泡/闪光/大 "!" (v0.14 起子代理小螃蟹在散步道里, 不再占这里)
120
121const LAYOUTS = ['full', 'compact', 'off'] as const
122type Layout = (typeof LAYOUTS)[number]
123
124const SLEEP_AFTER_MS = 5 * 60_000
125const PRESS_GAP_MS = 800 // 双击只算一次
126const COST_MILESTONES = [5, 10, 20, 50, 100, 200, 500, 1000]
127const USAGE_URL = 'https://claude.ai/settings/usage'
128const LABEL_W = 6
129const GAP = 3
130const HPAD = 2 // 面板左右各留的空格数
131const COMPACT_AT = 75 // 上下文到这个百分比出现 [压缩] 按钮
132const STUCK_MS = 5 * 60_000 // 子代理运行中超过这么久没有工具动作 -> "可能卡住"
133const AGENTS_PANE = 'hud-agents'
134const KEEP_ENDED = 10 // 看板里保留最近结束的子代理个数
135// 额度窗口长度
136const WINDOW_MS: Record<string, number> = { five_hour: 5 * 3600_000, seven_day: 7 * 86400_000 }
137// 窗口刚开始时外推不可靠, 已过时间不够就不算配速 (不报警、不影响情绪):
138// 5小时 = 窗口的 2% (6 分钟); 本周 = 满 1 天 (一周开头几个小时的用量起伏大, 按它外推会早早报警)
139const MIN_ELAPSED_FRAC = 0.02
140const MIN_ELAPSED_MS: Record<string, number> = { seven_day: 86400_000 }
141const PANIC_RUNOUT_MS = 30 * 60_000
142// 线性外推下 "配速 > 1" 就等于 "会在重置前用完", 稍微用快一点就报警太吵: 配速到 1.15 才算会用完
143const PACE_WARN = 1.15
144
145// ---------------- 会话状态 (模块变量) ----------------
146let layout: Layout = 'full'
147let engineWorking = false
148let turnStartedAt = 0
149let lastTurnMs = 0
150let lastTurnTools = 0
151let turnTools = 0
152let totalTools = 0
153let currentTool = ''
154// 主线程每用一次工具加 1, 连同工具种类推给散步道: 工具只跑几十毫秒时, 散步道 150ms 一帧的帧钟可能一次也看不到
155// currentTool, 按序号认就不会漏 (每次至少做 TOOL_HOLD_FRAMES 帧动作)
156let toolSeq = 0
157let toolLast: ToolKind | '' = ''
158let toolCounts: Record<string, number> = {}
159let effort = ''
160let modelId = ''
161let cwd = ''
162let project = ''
163let branch = ''
164let dirty = 0
165let celebrateUntil = 0
166let lastActive = 0
167let lastPct = 0
168// 客户端横栏的宽度 (格数), 记进 store 方便按实际截图校准像素换算
169let lastDesktopCols = 0
170let savedDesktopCols = 0
171// 这个会话有没有终端在显示: 只有终端需要每 0.15 秒重画来放像素动画;
172// 只有客户端时只在数据变化时重画 (客户端的螃蟹动画由 SVG 自己播放, 频繁重画反而会闪)
173let hasTerminal = true
174// 自动更新: 客户端会话不会热重载, 发现磁盘上的版本号变了就自己执行一次 /reload-plugins
175let loadedVersion = ''
176let reloadAsked = false
177// token 统计 = 会话记录里的历史 (启动时用脚本数一遍) + 之后每次请求的实时累加
178type Tok = { input: number; output: number; cacheRead: number; cacheWrite: number }
179const zeroTok = (): Tok => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 })
180let tokBase: Tok = zeroTok()
181let tokLive: Tok = zeroTok()
182let tokState: 'idle' | 'counting' | 'ready' | 'failed' = 'idle'
183let transcriptPath = ''
184let resumeCtx: number | undefined
185let agentsNow = 0
186let frame = 0
187let costMilestone = 0
188let milestonePrimed = false
189let warnedContext = false
190let nightNoticed = false
191let tweening = false
192const lastPress: Record<string, number> = {}
193const shown: Record<string, number> = {}
194let mood: Mood = 'normal'
195let tick = 0 // 定时器跳了几次 (面板隐藏时 frame 不走, 额度提醒照样要查)
196
197// ---- 每轮收据 ----
198// 只算主线程的回合 (turn.start 只有主线程有; turn.complete 带 agentId 的是子代理的回合)
199// 工具次数和改文件包括子代理在这一轮里做的
200type TurnRun = { turnId: string; startedAt: number; usd0?: number; files: Set<string>; add: number; del: number; tools: number }
201// pending: 这一段结束时引擎那行可能写成 "Waiting for N background agents to finish" 的 N 的上限
202// (这次提问里跑过的子代理个数; 0 = 一定不是 Waiting). 只用来估宽, 宁可估宽
203type Receipt = { turnId: string; startedAt: number; completedAt: number; durationMs: number; usd?: number; files: number; add: number; del: number; tools: number; boundTo?: string; pending?: number }
204let turnRun: TurnRun | undefined
205// 最近一段的收据: seg = 这一段, cum = 从提问开始的累计; 只有最近一段能被配对, 一对只配一行
206type ReceiptPair = { seg: Receipt; cum: Receipt; startedAt: number; completedAt: number; boundTo?: string }
207let lastReceipt: ReceiptPair | undefined
208
209// ---- 一次提问 (v0.16.1 / v0.16.2) ----
210// Claude Code 2.1.289 起 Agent 子代理默认放到后台跑: 一次提问里主线程会结束好几段 (每段一个不带 agentId 的
211// turn.complete); 子代理结束后, 它的结果送回主线程, 有两条路 (v0.16.3 按录像和会话记录核实):
212// 主线程正在跑: 塞进这一段, 走 prompt.submit (origin.kind = 'task-notification', 带 turnId)
213// 主线程闲着: 引擎从队列里取出来另起一段, 不经过 prompt.submit, 直接 turn.start (前面没有 prompt.submit)
214// 最后一段结束那行引擎写的是整次提问的时长 ("Churned for 34s")
215// 怎么划分 "一次提问" (v0.16.2 起看 prompt.submit 的来源, 不看 turn.start 带不带字):
216// origin 不是 task-notification (用户回车 / 手机 / SDK / 定时任务 / 别的会话 ...) = 新提问, 上一次没做完的作废
217// task-notification = 同一次提问的延续; 前面没有 prompt.submit 的 turn.start = 引擎自己接着跑 (闲着时送回的结果), 也是延续
218// 什么时候算做完 (v0.16.4 起纯防抖, 不再记 "欠几次通知"): 主线程结束一段、$.agent.list() 里没有在跑的子代理
219// -> 等 END_WAIT_MS (2 秒); 这段时间里来了 turn.start 或 prompt.submit 就取消, 接着等下一段; 开始等的时候
220// 前 2 秒内有子代理刚结束 (它的结果可能还在路上) -> 再多等 END_WAIT_MS (只延一次); 什么都没来才算做完 ->
221// 只庆祝一次, "搞定 N" 的 N = 从用户发出这条提问到最后一段结束
222// 已经庆祝过的提问又来了迟到的一段: 接着记账, 不再庆祝
223// 收据: 每一行 TurnDuration 同时拿去比 "这一段的时长" 和 "从提问开始的累计时长", 对上累计的显示整次提问的合计
224const END_WAIT_MS = 2000
225type Ask = {
226 startedAt: number
227 usd0?: number
228 files: Set<string>
229 add: number
230 del: number
231 tools: number
232 mainTools: number
233 segments: number
234 bgKids: Set<string> // 这次提问里跑过的子代理 (估引擎那行会不会写成 "Waiting for N background agents")
235 lastEndAt: number // 主线程最后一段结束的时间
236 celebrated: boolean
237}
238const newAsk = (at: number, usd0?: number): Ask => ({
239 startedAt: at,
240 usd0,
241 files: new Set(),
242 add: 0,
243 del: 0,
244 tools: 0,
245 mainTools: 0,
246 segments: 0,
247 bgKids: new Set(),
248 lastEndAt: 0,
249 celebrated: false,
250})
251let askFromNext = false // 用户在主线程跑着时打了字 (排队): 下一个 turn.start 才算新提问开始
252let submitSinceTurn = '' // 上一个 turn.start 之后来过的 prompt.submit 的来源 ('' = 没有)
253let endToken = 0 // "提问做完" 的防抖: 每次取消 / 重排都换一个号
254let endArmed = 0 // 正在等的那个号 (0 = 没在等)
255let mainOpen = false // 主线程的一段正在跑 (只由 turn.start / turn.complete 改; 渲染时的 isWorking 不算, 免得测试或时序把它冲掉)
256let agentsBusy = 0 // $.agent.list() 里在跑 (running) 的子代理; 防抖只看它
257let lastKidEndAt = -Infinity // 最近一个子代理结束的时间 (防抖开始时 2 秒内有子代理刚结束 -> 多等 2 秒)
258// 调试: 环境变量 USAGE_HUD_DEBUG=1 时把事件带时间写进 debug 日志 ($.ui.log, to: debug); 不设就什么都不写
259let debugOn = false
260let ask: Ask | undefined
261const receiptOf: Record<string, Receipt | null> = {} // TurnDuration 行的 requestId -> 收据 (null = 确定不配)
262// 子代理的 token: 只算主线程和认得的子代理 (和会话记录一样); 引擎自己的分叉 (压缩 / 记忆 ...) 的 id 谁也不认得,
263// 不算. 认出来之前先记在这里, 认出来 (SubagentStart / $.agent.list()) 时再加进去
264const tokPending = new Map<string, Tok>()
265const rowSeenAt: Record<string, number> = {} // TurnDuration 行第一次画出来的时间
266// 配上了收据的行: 引擎那行多宽、收据写哪种, 按列数记下 (面板动画每 0.15 秒让所有行重画, 不必每次重算)
267const fitOf: Record<string, { cols: number; fit: { engineW: number; text: string; width?: number } | undefined }> = {}
268
269// ---- 额度恢复提醒 ----
270type Watch = { armed: boolean; resetAt: number }
271const watch: Record<string, Watch> = {}
272
273// ---- 子代理看板 ----
274type Kid = {
275 id: string
276 ran?: boolean // 见过它在跑 (结束时才算一次 "子代理结束")
277 endNoted?: boolean // 这次结束已经记过
278 type: string
279 desc: string
280 startedAt: number
281 lastAt: number
282 lastTool: string
283 tools: number
284 status: string
285 endedAt?: number
286 note: string
287 known: boolean // SubagentStart 或 $.agent.list() 认过; 只从 tool.call 看到的 id (引擎自己的分叉) 不显示
288 listed: boolean
289}
290const kids = new Map<string, Kid>()
291
292// ---------------- 颜色 (螃蟹的颜色 COL 在 sprites.ts) ----------------
293const TRACK = 0x3f3f46
294const TICK = 0xe5e5e5 // 用量条上的时间刻度 │: 绿/黄/红的彩色段和暗色段里都看得清
295const ACCENT = '#d97757'
296const VIOLET = '#a78bfa'
297const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
298const EFFORT_COLOR: Record<string, string> = {
299 low: '#a1a1aa',
300 medium: '#60a5fa',
301 high: '#fbbf24',
302 xhigh: '#fb923c',
303 max: '#f87171',
304}
305
306const hex = (n: number) => '#' + n.toString(16).padStart(6, '0')
307function heat(t: number): number {
308 const g = 0x4ade80
309 const y = 0xfbbf24
310 const r = 0xf87171
311 return t < 0.6 ? mix(g, y, t / 0.6) : mix(y, r, Math.min(1, (t - 0.6) / 0.4))
312}
313
314// 外部来的名字只留安全字符, 其余换成 ?
315function safe(s: string): string {
316 let out = ''
317 for (const ch of s) {
318 const cp = ch.codePointAt(0) ?? 63
319 out += (cp >= 0x20 && cp <= 0x7e) || (cp >= 0xc0 && cp <= 0x24f) || isWide(cp) ? ch : '?'
320 }
321 return out
322}
323const padL = (s: string, n: number) => ' '.repeat(Math.max(0, n - dw(s))) + s
324const padR = (s: string, n: number) => s + ' '.repeat(Math.max(0, n - dw(s)))
325
326function prettyModel(id: string): string {
327 const m = id.match(/(opus|sonnet|haiku|fable)[-_ ]?(\d+)(?:[-_.](\d{1,2}))?/i)
328 if (!m) return safe(id) || '--'
329 const name = m[1][0].toUpperCase() + m[1].slice(1).toLowerCase()
330 return name + ' ' + m[2] + (m[3] ? '.' + m[3] : '') + (/\[1m\]/i.test(id) ? ' 1M' : '')
331}
332function dur(ms: number): string {
333 const s = Math.max(0, Math.round(ms / 1000))
334 if (s < 60) return s + 's'
335 const m = Math.floor(s / 60)
336 if (m < 60) return m + 'm' + String(s % 60).padStart(2, '0') + 's'
337 const h = Math.floor(m / 60)
338 if (h < 24) return h + 'h' + String(m % 60).padStart(2, '0') + 'm'
339 return Math.floor(h / 24) + 'd' + (h % 24) + 'h'
340}
341// 重置倒计时, 最多 5 列: 45m / 3h05m / 15h / 1d18h
342function durShort(ms: number): string {
343 const m = Math.max(0, Math.round(ms / 60000))
344 if (m < 60) return m + 'm'
345 const h = Math.floor(m / 60)
346 if (h < 10) return h + 'h' + String(m % 60).padStart(2, '0') + 'm'
347 if (h < 24) return h + 'h'
348 return Math.floor(h / 24) + 'd' + (h % 24) + 'h'
349}
350// 粗一点的时长, 最多 3 列: 40m / 13h / 4d (往下取整, 宁可说早); 给 "4d用完" 这种放进 7 列用
351function durCoarse(ms: number): string {
352 const m = Math.max(0, Math.floor(ms / 60000))
353 if (m < 60) return m + 'm'
354 const h = Math.floor(m / 60)
355 if (h < 24) return h + 'h'
356 return Math.floor(h / 24) + 'd'
357}
358// 大数: 999 / 12.3k / 4.56M / 345.4M / 2.26B
359function big(n: number): string {
360 if (n < 1000) return String(Math.round(n))
361 if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0) + 'k'
362 if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 2 : 1) + 'M'
363 return (n / 1e9).toFixed(2) + 'B'
364}
365// ---------------- 配速 (纯函数) ----------------
366// 已过时间 = 窗口长度 - (重置时刻 - 现在); 应用量 = 已过 / 窗口 x 100; 配速 = 实际用量 / 应用量
367// 预计用完 = 现在 + (100 - 实际用量) / (实际用量 / 已过时间)
368// 注意: 线性外推下 "会在重置前用完" 和 "配速 > 1" 是同一件事 (100 x 已过 < 用量 x 窗口)
369// elapsedFrac = 窗口已过的比例 (0-1), 用量条上的时间刻度画在这里; 窗口刚开始 (配速不外推) 时也有
370export type Pace = { pct: number; ratio?: number; runOutIn?: number; resetIn?: number; elapsedFrac?: number; willRunOut: boolean }
371type Limit = { kind: string; percentUsed: number; resetsAt?: string }
372
373export function paceOf(l: Limit | undefined, now: number): Pace | undefined {
374 if (!l || typeof l.percentUsed !== 'number') return undefined
375 const pct = l.percentUsed
376 const win = WINDOW_MS[l.kind]
377 const at = l.resetsAt ? Date.parse(l.resetsAt) : NaN
378 if (!win || !isFinite(at)) return { pct, willRunOut: false }
379 const resetIn = Math.max(0, at - now)
380 const elapsed = Math.min(win, win - resetIn)
381 const elapsedFrac = Math.max(0, Math.min(1, elapsed / win))
382 const minElapsed = MIN_ELAPSED_MS[l.kind] ?? win * MIN_ELAPSED_FRAC
383 if (pct < 1 || elapsed < minElapsed || resetIn <= 0) return { pct, resetIn, elapsedFrac, willRunOut: false }
384 const ratio = pct / ((elapsed / win) * 100)
385 const runOutIn = pct >= 100 ? 0 : (100 - pct) / (pct / elapsed)
386 return { pct, ratio, runOutIn, resetIn, elapsedFrac, willRunOut: runOutIn < resetIn && ratio >= PACE_WARN }
387}
388
389const MOOD_RANK: Record<Mood, number> = { chill: 0, normal: 1, sweat: 2, panic: 3 }
390function moodOne(p: Pace | undefined): Mood | undefined {
391 if (!p) return undefined
392 if (p.pct >= 95 || (p.willRunOut && (p.runOutIn ?? Infinity) <= PANIC_RUNOUT_MS)) return 'panic'
393 if (p.ratio === undefined) return 'normal'
394 if (p.ratio >= 1.2 || p.willRunOut) return 'sweat'
395 if (p.ratio < 0.8) return 'chill'
396 return 'normal'
397}
398// 5小时 和 本周 里更紧张的那个; 两个都没有读数 (不是订阅账号) 就是 正常
399export function moodOf(...paces: Array<Pace | undefined>): Mood {
400 const ms = paces.map(moodOne).filter((m): m is Mood => !!m)
401 if (!ms.length) return 'normal'
402 return ms.reduce((a, b) => (MOOD_RANK[b] > MOOD_RANK[a] ? b : a))
403}
404
405// 用量条后面那段. 规矩: 只有一个时间 = 重置倒计时 (暗色); 预计用完的时间一定带 "用完" 两个字 (红色)
406// 会用完 -> "4d12h用完", 放不下换粗一点的 "4d用完"; 还放不下就照常写暗色的重置倒计时, 只靠红色百分比提醒
407// (以前放不下时只写红色的 "4d12h", 用户会以为是 4 天半后重置)
408function limitExtra(p: Pace | undefined, extraW: number): { text: string; warn: boolean } {
409 if (!p || p.resetIn === undefined) return { text: '', warn: false }
410 if (p.willRunOut && p.runOutIn !== undefined) {
411 for (const d of [durShort(p.runOutIn), durCoarse(p.runOutIn)]) {
412 if (dw(d + '用完') <= extraW) return { text: d + '用完', warn: true }
413 }
414 }
415 return { text: durShort(p.resetIn), warn: false }
416}
417// 时间刻度落在条的第几格: 已过比例 x 条长, 四舍五入, 夹在条里; 没有重置时间 -> -1 (不画)
418// 用四舍五入 (和彩色段的格数同一种取整): 用量正好跟上时间时, 彩色段的最后一格正好挨着刻度
419function tickCell(p: Pace | undefined, bw: number): number {
420 if (p?.elapsedFrac === undefined || bw <= 0) return -1
421 return Math.min(bw - 1, Math.max(0, Math.round(p.elapsedFrac * bw)))
422}
423
424function addTok(t: Tok, u: any) {
425 t.input += u?.input_tokens || 0
426 t.output += u?.output_tokens || 0
427 t.cacheRead += u?.cache_read_input_tokens || 0
428 t.cacheWrite += u?.cache_creation_input_tokens || 0
429}
430function tokSum(): Tok {
431 return {
432 input: tokBase.input + tokLive.input,
433 output: tokBase.output + tokLive.output,
434 cacheRead: tokBase.cacheRead + tokLive.cacheRead,
435 cacheWrite: tokBase.cacheWrite + tokLive.cacheWrite,
436 }
437}
438const tokTotal = (t: Tok) => t.input + t.output + t.cacheRead + t.cacheWrite
439function tok(n: number | undefined): string {
440 if (n === undefined) return '--'
441 return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1000 ? Math.round(n / 1000) + 'k' : String(n)
442}
443function eq(f: number): string {
444 const H = '▁▂▃▄▅▆▇'
445 const wave = [0, 2, 4, 6, 4, 2]
446 return [0, 2, 4].map(o => H[wave[(f + o) % wave.length]]).join('')
447}
448// 数字滚动过渡
449function ease(key: string, target: number | undefined): number | undefined {
450 if (target === undefined) return undefined
451 const cur = shown[key] ?? 0
452 const nxt = Math.abs(target - cur) < 0.6 ? target : cur + (target - cur) * 0.35
453 shown[key] = nxt
454 if (nxt !== target) tweening = true
455 return nxt
456}
457
458// ---------------- 工具分类 ----------------
459function toolKind(t: string): ToolKind {
460 if (!t) return 'think'
461 if (/^(Read|Grep|Glob|LS|NotebookRead)$/.test(t)) return 'read'
462 if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(t)) return 'edit'
463 if (/^(Bash|PowerShell|BashOutput|KillShell|Monitor)$/.test(t)) return 'bash'
464 if (/^(WebSearch|WebFetch)$/.test(t)) return 'web'
465 if (/^(Agent|Task|SendMessage)$/.test(t)) return 'agent'
466 return 'other'
467}
468const TOOL_CN: Record<string, string> = {
469 Read: '读文件',
470 Grep: '搜内容',
471 Glob: '找文件',
472 Edit: '改文件',
473 MultiEdit: '改文件',
474 Write: '写文件',
475 NotebookEdit: '改笔记本',
476 Bash: '跑命令',
477 PowerShell: '跑命令',
478 WebSearch: '搜网页',
479 WebFetch: '读网页',
480 Agent: '派子代理',
481 Task: '派子代理',
482 SendMessage: '发消息',
483 TodoWrite: '记待办',
484 Skill: '用技能',
485 ToolSearch: '找工具',
486}
487// MCP 工具名是 mcp__<服务>__<工具>, 服务名本身可能带下划线 (Claude_Browser), 所以按 "__" 拆
488function mcpParts(t: string): [string, string] | undefined {
489 if (!t.startsWith('mcp__')) return undefined
490 const rest = t.slice(5)
491 const cut = rest.indexOf('__')
492 return cut > 0 ? [rest.slice(0, cut), rest.slice(cut + 2)] : [rest, '']
493}
494// 统计行里的短名: MCP 工具只留服务名
495function shortTool(t: string): string {
496 return safe(mcpParts(t)?.[0] ?? t)
497}
498function toolLabel(t: string): string {
499 if (!t) return '思考中'
500 if (TOOL_CN[t]) return TOOL_CN[t]
501 const m = mcpParts(t)
502 return safe(m ? m[0] + ':' + m[1] : t)
503}
504
505const KAOMOJI: Record<string, string> = {
506 idle: '(o_o)',
507 blink: '(-_-)',
508 work: '(o_o)/',
509 work2: '\\(o_o)',
510 hot: '(;o_o)',
511 jump: '\\(^o^)/',
512 sleep: '(-_-)zZ',
513 chill: '(B_B)',
514 panic: '\\(O_O)/!',
515}
516function kaomoji(s: Scene): string {
517 const md = s.mood ?? 'normal'
518 if (s.celebrating) return KAOMOJI.jump
519 if (s.working) return Math.floor(frame / 2) % 2 ? KAOMOJI.work : KAOMOJI.work2
520 if (md === 'panic') return KAOMOJI.panic
521 if (s.pct >= 80 || md === 'sweat') return KAOMOJI.hot
522 if (s.sleeping) return KAOMOJI.sleep
523 if (md === 'chill') return KAOMOJI.chill
524 return frame % 30 === 0 ? KAOMOJI.blink : KAOMOJI.idle
525}
526
527// ---------------- 小部件 ----------------
528function label(els: any, key: string, text: string, width: number, onPress?: () => void): any[] {
529 const { Text, Button } = els
530 const pad = ' '.repeat(Math.max(0, width - dw(text)) + 1)
531 return onPress
532 ? [<Button key={key} label={text} plain dimColor onPress={onPress} />, <Text key={key + '-p'}>{pad}</Text>]
533 : [
534 <Text key={key} color={DIM}>
535 {text + pad}
536 </Text>,
537 ]
538}
539
540// tickAt: 时间刻度画在第几格 (-1 = 不画); 那一格的 ━ 换成亮色 │, 条的总宽度不变
541function barParts(els: any, key: string, pct: number | undefined, width: number, shimmerAt: number, pulse: number, tickAt = -1): any[] {
542 const { Text } = els
543 const p = pct === undefined ? 0 : Math.max(0, Math.min(100, pct)) / 100
544 // 只用整格: 半格字符在终端里会留一道缝 (实测)
545 const full = p > 0 ? Math.max(1, Math.round(p * width)) : 0
546 // 窗口刚开始、用量只占 1 格时, 刻度和那格彩色都在第 0 格: 刻度让到第 1 格, 彩色照样看得见
547 const tick = tickAt === 0 && full === 1 && width > 1 ? 1 : tickAt
548 const cells: any[] = []
549 for (let i = 0; i < width; i++) {
550 const lit = i < full
551 let ch = '━'
552 let col = lit ? heat(i / Math.max(1, width - 1)) : TRACK
553 if (lit && pulse > 0) col = mix(col, 0xffffff, pulse)
554 if (lit && Math.abs(i - shimmerAt) < 1) col = mix(col, 0xffffff, 0.6)
555 // 刻度最后定: 不跟着呼吸 / 流光变色, 彩色段和暗色段里都一样亮
556 if (i === tick) {
557 ch = '│'
558 col = TICK
559 }
560 cells.push(
561 <Text key={key + '-c' + i} color={hex(col)}>
562 {ch}
563 </Text>,
564 )
565 }
566 return cells
567}
568
569type MeterOpts = {
570 key: string
571 label: string
572 labelW: number
573 onPress: () => void
574 labelKey?: string // 标签按钮的 key (默认 btn-<key>)
575 pct?: number
576 pctColor?: string // 百分比的颜色 (默认按用量冷暖)
577 bw: number
578 extra: string
579 extraW: number
580 extraColor?: string
581 extraParts?: any[] // 自己画附加那段 (调用方保证正好 1 + extraW 列)
582 shimmerAt: number
583 pulse: number
584 tickAt?: number // 时间刻度在第几格 (tickCell 算; 不给 = 不画)
585}
586function meter(els: any, o: MeterOpts): any[] {
587 const { Text } = els
588 const shownPct = ease(o.key, o.pct)
589 const pctText = shownPct === undefined ? '--' : Math.round(shownPct) + '%'
590 const pctColor = o.pctColor ?? (o.pct === undefined ? DIM : hex(heat(Math.min(100, o.pct) / 100)))
591 const parts = [
592 ...label(els, o.labelKey ?? 'btn-' + o.key, o.label, o.labelW, o.onPress),
593 ...barParts(els, o.key, shownPct, o.bw, o.shimmerAt, o.pulse, o.tickAt ?? -1),
594 <Text key={o.key + '-pct'} color={pctColor} bold>
595 {' ' + padL(pctText, 4)}
596 </Text>,
597 ]
598 if (o.extraW > 0 && o.extraParts) parts.push(...o.extraParts)
599 else if (o.extraW > 0) {
600 parts.push(
601 <Text key={o.key + '-x'} color={o.extraColor ?? DIM} bold={o.extraColor ? true : undefined}>
602 {' ' + padR(clip(o.extra, o.extraW), o.extraW)}
603 </Text>,
604 )
605 }
606 return parts
607}
608
609// 上下文 >= 75%: 附加那段换成 [压缩] 按钮; 放得下 (>= 10 列) 就在前面留已用 token 数
610// 宽度和原来那段完全一样: 1 + extraW 列
611function compactParts(els: any, extraW: number, used: string, onCompact: () => void): any[] {
612 const { Text, Button } = els
613 const btn = '压缩'
614 if (extraW >= 10) {
615 return [
616 <Text key="cp-lead" color={DIM}>
617 {' ' + padR(clip(used, extraW - 5), extraW - 5) + ' '}
618 </Text>,
619 <Button key="btn-compact" label={btn} plain onPress={onCompact} />,
620 ]
621 }
622 return [<Text key="cp-lead"> </Text>, <Button key="btn-compact" label={btn} plain onPress={onCompact} />, <Text key="cp-pad">{' '.repeat(Math.max(0, extraW - dw(btn)))}</Text>]
623}
624const meterWidth = (o: { label: string; labelW: number; bw: number; extraW: number }) =>
625 Math.max(o.labelW, dw(o.label)) + 1 + o.bw + 5 + (o.extraW > 0 ? 1 + o.extraW : 0)
626
627// 档位: 五格保持彩色 (按钮不能上色), 档位文字做成按钮 -> /effort 打开档位滑块
628// room = 模型名后面还剩几列; 放不下时先把档位缩写 (medium -> med), 再放不下就只留缩写按钮不画五格
629// (以前窄的时候档位文字会被格子截掉; 现在它是点击目标, 要保证看得见)
630const EFFORT_SHORT: Record<string, string> = { low: 'low', medium: 'med', high: 'hi', xhigh: 'xhi', max: 'max' }
631function effortParts(els: any, key: string, onEffort: () => void, room = Infinity): any[] {
632 const { Button } = els
633 const word = effort ? clip(safe(effort), 6) : '--'
634 const short = EFFORT_SHORT[effort] ?? word
635 if (room < 6 + dw(word)) {
636 if (room < 6 + dw(short)) return [<Button key="btn-effort" label={short} plain onPress={onEffort} />]
637 return effortPips(els, key, short, onEffort)
638 }
639 return effortPips(els, key, word, onEffort)
640}
641function effortPips(els: any, key: string, word: string, onEffort: () => void): any[] {
642 const { Text, Button } = els
643 const i = EFFORTS.indexOf(effort)
644 const color = EFFORT_COLOR[effort] ?? DIM
645 const bars = '▁▂▃▅▇'
646 const parts: any[] = []
647 for (let k = 0; k < 5; k++) {
648 parts.push(
649 <Text key={key + '-e' + k} color={k <= i ? color : hex(TRACK)}>
650 {bars[k]}
651 </Text>,
652 )
653 }
654 parts.push(<Text key={key + '-ews'}> </Text>, <Button key="btn-effort" label={word} plain onPress={onEffort} />)
655 return parts
656}
657
658function cell(els: any, key: string, w: number, parts: any[]) {
659 const { Box } = els
660 return (
661 <Box key={key} width={w} flexShrink={0} flexDirection="row" overflow="hidden">
662 {parts}
663 </Box>
664 )
665}
666
667type Seg = { key: string; w: number; prio: number; parts: any[] }
668function fit(segs: Seg[], width: number, gap: number): Seg[] {
669 const order = segs.map((s, i) => ({ s, i })).sort((a, b) => a.s.prio - b.s.prio)
670 const keep = new Set<number>()
671 let total = 0
672 for (const { s, i } of order) {
673 const add = s.w + (keep.size ? gap : 0)
674 if (total + add <= width) {
675 keep.add(i)
676 total += add
677 }
678 }
679 return segs.filter((_, i) => keep.has(i))
680}
681
682// ---------------- 调用 $ 的动作 (必须是顶层函数) ----------------
683function redraw($: any) {
684 $.ui.invalidate('ui.render')
685}
686
687async function pressOk($: any, key: string): Promise<boolean> {
688 const now = await $.clock.now()
689 if (now - (lastPress[key] ?? 0) < PRESS_GAP_MS) return false
690 lastPress[key] = now
691 return true
692}
693
694function utf16Base64(s: string): string {
695 const bytes: number[] = []
696 for (let i = 0; i < s.length; i++) {
697 const c = s.charCodeAt(i)
698 bytes.push(c & 255, c >> 8)
699 }
700 return new Uint8Array(bytes).toBase64()
701}
702
703// ---- 跨平台 (html-shelf 里有同样一份; 带 $ 的函数必须写在本文件里, 不能 import) ----
704type OS = 'windows' | 'mac' | 'linux'
705
706// 按工作目录缓存: 同一个目录只判断一次, 换了目录 (或测试里换了系统) 再判断
707let knownOS: { key: string; os: OS } | undefined
708
709// 判断顺序: 环境变量 OS=Windows_NT (Windows 自带) → 路径像 C:\ → 有 /System/Library/CoreServices 就是 macOS → 其余按 Linux
710async function detectOS($: any, dir: string): Promise<OS> {
711 if (knownOS && knownOS.key === dir) return knownOS.os
712 let found: OS = 'linux'
713 try {
714 if ((await $.env.get('OS')) === 'Windows_NT' || /^[A-Za-z]:[\\/]/.test(dir)) found = 'windows'
715 else if (await $.fs.exists('/System/Library/CoreServices')) found = 'mac'
716 } catch {}
717 knownOS = { key: dir, os: found }
718 return found
719}
720
721const sepOf = (sys: OS) => (sys === 'windows' ? '\\' : '/')
722
723// 用户主目录: Windows 用 USERPROFILE, 其余用 HOME
724async function homeDir($: any, sys: OS): Promise<string> {
725 const h = (sys === 'windows' ? await $.env.get('USERPROFILE') : '') || (await $.env.get('HOME')) || ''
726 return h.replace(/[\\/]+$/, '')
727}
728
729// Claude Code 的配置目录: 设置了 CLAUDE_CONFIG_DIR 就用它, 否则 ~/.claude
730async function claudeDir($: any, sys: OS): Promise<string> {
731 const custom = await $.env.get('CLAUDE_CONFIG_DIR')
732 if (custom) return custom.replace(/[\\/]+$/, '')
733 const home = await homeDir($, sys)
734 return home ? home + sepOf(sys) + '.claude' : ''
735}
736
737// 跑一个命令, 退出码 0 算成功; 命令不存在会抛错, 也算失败
738async function ranOk($: any, argv: string[]): Promise<boolean> {
739 try {
740 const r = await $.process.run(argv, { timeoutMs: 10_000 })
741 return r.exitCode === 0
742 } catch {
743 return false
744 }
745}
746
747// WSL 里一般没有 Linux 的浏览器和文件管理器: 交给 Windows 那边的默认程序.
748// 先试 wslview (wslu 包), 再经 wslpath 把路径转成 Windows 写法交给 explorer.exe
749// (explorer.exe 成功也常返回 1, 所以 exit 0); 网址直接交给 explorer.exe
750function wslTries(target: string): string[][] {
751 const viaExplorer = /^[a-z][a-z0-9+.-]*:\/\//i.test(target)
752 ? ['sh', '-c', 'explorer.exe "$1"; exit 0', 'sh', target]
753 : ['sh', '-c', 'explorer.exe "$(wslpath -w "$1")"; exit 0', 'sh', target]
754 return [['wslview', target], viaExplorer]
755}
756
757// macOS / Linux: 用系统默认程序打开 (文件夹 → 文件管理器, 网址 → 浏览器); 都不行就抛错
758async function openPosix($: any, target: string, sys: OS): Promise<void> {
759 const tries: string[][] = []
760 if (sys === 'mac') tries.push(['open', target])
761 else {
762 if (await $.env.get('WSL_DISTRO_NAME')) tries.push(...wslTries(target))
763 tries.push(['xdg-open', target], ['gio', 'open', target])
764 }
765 for (const argv of tries) if (await ranOk($, argv)) return
766 throw new Error('没能打开, 试过: ' + tries.map(a => (a[0] === 'sh' ? 'explorer.exe' : a[0])).join(' / '))
767}
768// ---- 跨平台 完 ----
769
770// 项目根目录 ($.session.root): 会话开始的地方, Claude 在终端里 cd 进子目录不会改它;
771// 拿不到 (老版本引擎) 再用当前目录 ($.session.cwd)
772async function projectDir($: any): Promise<string> {
773 try {
774 const r = await $.session.root()
775 if (r) return r
776 } catch {}
777 return await $.session.cwd()
778}
779
780// Windows: Claude Code 启动子进程时把窗口设成隐藏, 直接跑 explorer.exe 打开的文件夹窗口也会是隐藏的;
781// 经 cmd 的 start 转一手, 新窗口按正常方式显示 (已在本机验证). macOS 用 open, Linux 用 xdg-open
782async function openProject($: any) {
783 if (!(await pressOk($, 'project'))) return
784 try {
785 const here = cwd || (await projectDir($))
786 const sys = await detectOS($, here)
787 if (sys !== 'windows') {
788 await openPosix($, here, sys)
789 } else {
790 const dir = here.replace(/\//g, '\\')
791 if (/^[^&^|<>()%!"]+$/.test(dir)) {
792 await $.process.run(['cmd.exe', '/d', '/c', 'start', 'usage hud', dir], { timeoutMs: 10_000 })
793 } else {
794 const script = "Start-Process -FilePath '" + dir.replace(/'/g, "''") + "'"
795 await $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-EncodedCommand', utf16Base64(script)], {
796 timeoutMs: 20_000,
797 })
798 }
799 }
800 $.ui.toast('已打开项目文件夹')
801 } catch (err) {
802 $.ui.toast('打开失败: ' + String(err))
803 }
804}
805
806async function runSlash($: any, command: string, fallbackUrl?: string) {
807 if (!(await pressOk($, 'cmd-' + command))) return
808 try {
809 await $.command.run({ command })
810 } catch (err) {
811 if (fallbackUrl) {
812 try {
813 const sys = await detectOS($, cwd || (await projectDir($)))
814 if (sys === 'windows') await $.process.run(['cmd.exe', '/d', '/c', 'start', 'usage hud', fallbackUrl], { timeoutMs: 10_000 })
815 else await openPosix($, fallbackUrl, sys)
816 } catch {}
817 } else {
818 $.ui.toast('/' + command + ' 运行失败: ' + String(err))
819 }
820 }
821}
822
823// ---- 额度恢复提醒 ----
824// 5小时 (本周同理) 用到 >=30% 后 "上膛"; 之后 resetsAt 已过, 或用量从高位掉到 <5%, 就提醒一次并退膛
825// 用量是最后一次 API 回复带回来的: 闲着的时候读数不变, 所以主要靠 "resetsAt 已过" 来发现
826// 已经过了重置时刻的旧读数不会再上膛, 所以不会重复提醒
827const WATCHED: Array<[string, string]> = [
828 ['five_hour', '5 小时'],
829 ['seven_day', '本周'],
830]
831function checkResets($: any, limits: Limit[], now: number) {
832 for (const [kind, name] of WATCHED) {
833 const l = limits.find(x => x.kind === kind)
834 if (!l) continue
835 const at = l.resetsAt ? Date.parse(l.resetsAt) : NaN
836 const w = (watch[kind] ??= { armed: false, resetAt: NaN })
837 if (w.armed && ((isFinite(w.resetAt) && now >= w.resetAt) || l.percentUsed < 5)) {
838 w.armed = false
839 $.ui.toast(`${name}额度已恢复,可以继续了`, { timeoutMs: 8000 })
840 sayNow('额度刷新了', now)
841 }
842 if (l.percentUsed >= 30 && !(isFinite(at) && at <= now)) {
843 w.armed = true
844 w.resetAt = at
845 }
846 }
847}
848
849async function watchLimits($: any) {
850 try {
851 const u = await $.session.usage()
852 checkResets($, (u.rateLimits ?? []) as Limit[], await $.clock.now())
853 } catch {}
854}
855
856// ---- 子代理看板 ----
857function kidOf(id: string, now: number): Kid {
858 let k = kids.get(id)
859 if (!k) {
860 k = { id, type: '', desc: '', startedAt: now, lastAt: now, lastTool: '', tools: 0, status: 'running', note: '', known: false, listed: false }
861 kids.set(id, k)
862 }
863 return k
864}
865// 已结束的只留最近 10 个; 没人认领的 id (引擎自己的分叉) 只留最近 50 个
866function pruneKids() {
867 const ended = [...kids.values()].filter(k => k.endedAt !== undefined).sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
868 for (const k of ended.slice(KEEP_ENDED)) kids.delete(k.id)
869 const strays = [...kids.values()].filter(k => !k.known).sort((a, b) => b.lastAt - a.lastAt)
870 for (const k of strays.slice(50)) kids.delete(k.id)
871}
872// 用 $.agent.list() 补上描述、类型、状态; 列表里消失了的当作已结束; 同时更新运行中个数
873async function refreshAgents($: any, now: number) {
874 let list: any[]
875 try {
876 list = await $.agent.list()
877 } catch {
878 return
879 }
880 const seen = new Set<string>()
881 for (const a of list ?? []) {
882 if (!a?.id) continue
883 seen.add(a.id)
884 const k = kidOf(a.id, now)
885 kidKnown(k)
886 k.listed = true
887 if (a.description) k.desc = safe(String(a.description))
888 if (a.type) k.type = safe(String(a.type))
889 if (a.status) k.status = String(a.status)
890 if (k.status === 'running') {
891 k.endedAt = undefined
892 if (!k.ran) dbg($, now, `子代理在跑 ${k.id} (列表)`)
893 k.ran = true
894 if (ask) ask.bgKids.add(k.id)
895 } else if (k.endedAt === undefined) {
896 k.endedAt = now
897 kidEnded($, k, now, '列表 ' + k.status)
898 }
899 }
900 for (const k of kids.values()) {
901 if (k.listed && !seen.has(k.id) && k.endedAt === undefined) {
902 k.endedAt = now
903 if (k.status === 'running') k.status = 'completed'
904 kidEnded($, k, now, '列表里没了')
905 }
906 }
907 agentsNow = (list ?? []).filter((a: any) => a?.status === 'running').length
908 agentsBusy = (list ?? []).filter((a: any) => BUSY.has(String(a?.status))).length
909 pruneKids()
910}
911// 只认 running (任务书: "没有在跑的子代理"); pending / waiting 不算: 列表里可能一直挂着别的会话留下的条目, 算了会永远不庆祝
912const BUSY = new Set(['running'])
913// 同步子代理, 再看要不要开始 "提问做完" 的防抖 (最后一个子代理刚结束、主线程闲着)
914async function syncAgents($: any, now: number) {
915 await refreshAgents($, now)
916 armEnd($, now, false)
917}
918
919// 子代理认出来了: 先前记下的 token 加进去
920function kidKnown(k: Kid) {
921 k.known = true
922 const t = tokPending.get(k.id)
923 if (t) {
924 tokPending.delete(k.id)
925 tokLive.input += t.input
926 tokLive.output += t.output
927 tokLive.cacheRead += t.cacheRead
928 tokLive.cacheWrite += t.cacheWrite
929 }
930}
931// 一个子代理结束了: 记下时间 (防抖开始时 2 秒内有子代理刚结束, 它的结果可能还在路上 -> 多等 2 秒)
932function kidEnded($: any, k: Kid, now: number, how: string) {
933 if (!k.ran || k.endNoted) return
934 k.endNoted = true
935 lastKidEndAt = now
936 dbg($, now, `子代理结束 ${k.id} (${how})`)
937}
938
939// "提问做完" 的防抖: 主线程闲着、$.agent.list() 里没有在干活的子代理时才排; force = 主线程刚结束一段 (重新计时)
940// 等 2 秒; 开始等的时候前 2 秒内有子代理刚结束 -> 等 4 秒 (只延这一次)
941function armEnd($: any, now: number, force: boolean) {
942 const q = ask
943 if (!q || q.celebrated || mainOpen) return
944 if (agentsBusy > 0) return // 还有子代理在跑: 等它们
945 if (!force && endArmed) return
946 const late = now - lastKidEndAt < END_WAIT_MS
947 const wait = END_WAIT_MS + (late ? END_WAIT_MS : 0)
948 const token = ++endToken
949 endArmed = token
950 dbg($, now, `防抖开始 等 ${wait}ms` + (late ? ' (2 秒内有子代理刚结束, 多等 2 秒)' : '') + (force ? ' (一段刚结束)' : ''))
951 $.clock.after(wait, () => void endAsk($, token))
952}
953function cancelEnd($: any, now: number, why: string) {
954 if (endArmed) dbg($, now, `防抖取消 (${why})`)
955 endToken += 1
956 endArmed = 0
957}
958// 等够了, 什么都没来: 这次提问做完, 庆祝一次
959async function endAsk($: any, token: number) {
960 if (token !== endToken) return
961 endArmed = 0
962 const q = ask
963 if (!q || q.celebrated || mainOpen) return
964 const now = await $.clock.now()
965 await refreshAgents($, now)
966 if (token !== endToken || mainOpen) return
967 if (agentsBusy > 0) {
968 dbg($, now, '防抖到点, 但又有子代理在跑: 不庆祝, 等它们')
969 return
970 }
971 q.celebrated = true
972 lastTurnMs = (q.lastEndAt || now) - q.startedAt
973 lastTurnTools = q.mainTools
974 celebrateUntil = now + (lastTurnMs > 180_000 ? 2600 : 1400)
975 celebSeq += 1
976 celebFrames = Math.round((celebrateUntil - now) / FRAME_MS)
977 sayNow('搞定 ' + dur(lastTurnMs), now)
978 dbg($, now, `防抖触发: 搞定 ${dur(lastTurnMs)} (从提问开始到最后一段结束 ${lastTurnMs}ms)`)
979 // 庆祝结束时再画一次, 让只有客户端的会话也能回到平常的样子
980 $.clock.after(celebrateUntil - now + 100, () => redraw($))
981 redraw($)
982}
983
984// 调试日志: "[usage-hud] 2026-10-07T12:01:16.533Z 防抖开始 等 2000ms"; 不开就什么都不写
985function dbg($: any, now: number, text: string) {
986 if (!debugOn) return
987 let at = String(now)
988 try {
989 at = new Date(now).toISOString()
990 } catch {}
991 try {
992 $.ui.log(`[usage-hud] ${at} ${text}`, { to: 'debug' })
993 } catch {}
994}
995
996async function openAgents($: any, fromPress: boolean) {
997 if (fromPress && !(await pressOk($, 'agents'))) return
998 try {
999 const r = await $.ui.open({ id: AGENTS_PANE, title: '子代理', focus: true, closeOnEscape: true })
1000 if (r && r.isPlaced === false) $.ui.toast('子代理看板等终端再宽一点才能显示: ' + String(r.reason ?? ''))
1001 } catch (err) {
1002 $.ui.toast('子代理看板打不开: ' + String(err))
1003 }
1004}
1005
1006async function refreshRepo($: any) {
1007 try {
1008 // 项目名和分支都按项目根目录算, Claude 在终端里 cd 进子目录也不变
1009 cwd = await projectDir($)
1010 project = safe(cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd)
1011 const b = await $.process.run(['git', 'branch', '--show-current'], { cwd, timeoutMs: 5000 })
1012 branch = b.exitCode === 0 ? safe(b.stdout.trim()) : ''
1013 dirty = 0
1014 if (branch) {
1015 const s = await $.process.run(['git', 'status', '--porcelain', '-uno'], { cwd, timeoutMs: 5000 })
1016 dirty = s.exitCode === 0 ? s.stdout.split('\n').filter((l: string) => l.trim()).length : 0
1017 }
1018 } catch {
1019 branch = ''
1020 }
1021}
1022
1023async function readEffortFromSettings($: any, force: boolean) {
1024 if (effort && !force) return
1025 try {
1026 const s: any = await $.settings.read()
1027 const v = s?.modelSettings?.[modelId]?.effortLevel ?? s?.effortLevel
1028 if (typeof v === 'string' && v) effort = v
1029 } catch {}
1030}
1031
1032async function loadPrefs($: any) {
1033 try {
1034 const l = await $.store.get('layout')
1035 if (LAYOUTS.includes(l)) layout = l
1036 // v0.16: 终端版面板固定在输入框下方, store 里以前存的 position (测试曾写进过 above) 一律不读
1037 // 散步道默认开; store 里没有记录也当开 (不沿用上一个会话模块里的值)
1038 crabOn = (await $.store.get('crab')) !== false
1039 } catch {}
1040}
1041
1042async function checkMilestones($: any) {
1043 try {
1044 const u = await $.session.usage()
1045 const usd = u.cost?.usd ?? 0
1046 const hit = COST_MILESTONES.filter(x => usd >= x).pop() ?? 0
1047 if (!milestonePrimed) {
1048 milestonePrimed = true // 第一次只记下当前档位: 恢复的老会话不补发提示
1049 costMilestone = hit
1050 } else if (hit > costMilestone) {
1051 costMilestone = hit
1052 $.ui.toast(`本会话已花 $${hit},螃蟹替你记着账`)
1053 }
1054 checkResets($, (u.rateLimits ?? []) as Limit[], await $.clock.now())
1055 const pct = u.context.percent ?? 0
1056 if (pct >= 80 && !warnedContext) {
1057 warnedContext = true
1058 $.ui.toast(`上下文已用 ${pct}%,螃蟹开始冒汗了,可以考虑 /compact`)
1059 }
1060 if (pct < 50) warnedContext = false
1061 } catch {}
1062}
1063
1064// 会话记录文件: ~/.claude/projects/<工作目录里非字母数字都换成 ->/<会话 id>.jsonl (设置了 CLAUDE_CONFIG_DIR 就在它下面)
1065// 会话记录按"启动时的目录"存放; 中途 cd 过的话这里猜错, 统计脚本会再按会话 id 去各项目目录里找
1066type Where = { guess: string; id: string; root: string }
1067async function guessTranscript($: any): Promise<Where> {
1068 try {
1069 const id = await $.session.id()
1070 const here = await projectDir($)
1071 const sys = await detectOS($, here)
1072 const sep = sepOf(sys)
1073 const base = await claudeDir($, sys)
1074 const root = base ? base + sep + 'projects' : ''
1075 const dir = here.replace(/[^a-zA-Z0-9]/g, '-')
1076 return { guess: root ? root + sep + dir + sep + id + '.jsonl' : '', id, root }
1077 } catch {
1078 return { guess: '', id: '', root: '' }
1079 }
1080}
1081
1082// 用 scripts/count-tokens.js 把会话记录 (含子代理) 里已有的 token 数一遍; 实时累加从这一刻重新开始
1083async function countTokens($: any, where: Where) {
1084 if (!where.guess && !where.id) {
1085 tokState = 'failed' // 找不到会话记录: 只显示本次启动以来的实时累计
1086 return
1087 }
1088 transcriptPath = where.guess
1089 tokState = 'counting'
1090 tokLive = zeroTok()
1091 try {
1092 const sep = sepOf(await detectOS($, cwd || (await projectDir($))))
1093 const script = $.plugin.root.replace(/[\\/]+$/, '') + sep + 'scripts' + sep + 'count-tokens.js'
1094 const r = await $.process.run(['node', script, where.guess, where.id, where.root], { timeoutMs: 120_000 })
1095 const j = JSON.parse(r.stdout)
1096 if (!j.found) throw new Error('transcript not found')
1097 if (j.path) transcriptPath = j.path
1098 tokBase = { input: j.input || 0, output: j.output || 0, cacheRead: j.cacheRead || 0, cacheWrite: j.cacheWrite || 0 }
1099 tokState = 'ready'
1100 } catch {
1101 tokBase = zeroTok()
1102 tokState = 'failed'
1103 }
1104 redraw($)
1105}
1106
1107function showTokenDetail($: any) {
1108 const t = tokSum()
1109 const scope = tokState === 'ready' ? '本会话(含子代理)' : '本次启动以来'
1110 $.ui.toast(
1111 `${scope}共 ${big(tokTotal(t))} token:读缓存 ${big(t.cacheRead)},写缓存 ${big(t.cacheWrite)},新输入 ${big(t.input)},输出 ${big(t.output)}。读缓存最便宜,单价约为新输入的十分之一。`,
1112 { timeoutMs: 9000 },
1113 )
1114}
1115
1116async function diskVersion($: any): Promise<string> {
1117 try {
1118 const pj = JSON.parse(await $.fs.read($.plugin.root.replace(/[\\/]+$/, '') + '/.claude-plugin/plugin.json'))
1119 return String(pj.version ?? '')
1120 } catch {
1121 return ''
1122 }
1123}
1124
1125// 只在客户端会话里做 (终端会话的插件目录本来就会被监视, 改了自动重载)
1126// 重载后新模块会重新记下版本号, 所以不会反复重载
1127async function checkForUpdate($: any) {
1128 if (reloadAsked || hasTerminal || !loadedVersion) return
1129 const v = await diskVersion($)
1130 if (v && v !== loadedVersion) {
1131 reloadAsked = true
1132 try {
1133 await $.command.run({ command: 'reload-plugins' })
1134 } catch {
1135 reloadAsked = false
1136 }
1137 }
1138}
1139
1140function needsFrame(now: number): boolean {
1141 // 只有客户端: 每 15 秒刷新一次时钟, 其余靠事件 (工具开始/结束、一轮结束、用量变化) 触发
1142 if (!hasTerminal) return frame % 100 === 0
1143 // 散步道的 props 变了 (比如冒了新气泡): 推一次 (散步道本身的动画在 Client 里, 不靠这里)
1144 if (laneDirty) {
1145 laneDirty = false
1146 return true
1147 }
1148 if (engineWorking || tweening || agentsNow > 0 || lastPct >= 80 || mood === 'sweat' || mood === 'panic') return true
1149 if (now < celebrateUntil + 2 * FRAME_MS) return true
1150 if (lastActive > 0 && now - lastActive > SLEEP_AFTER_MS) return frame % 2 === 0
1151 const cyc = frame % 160
1152 if (frame % 30 <= 1) return true
1153 if ((cyc >= 60 && cyc <= 77) || (cyc >= 120 && cyc <= 137)) return true
1154 return frame % 13 === 0
1155}
1156
1157// ---------------- 画面 ----------------
1158// 终端版和客户端版共用的一次取数
1159async function snapshot($: any, working: boolean) {
1160 const now = await $.clock.now()
1161 engineWorking = working
1162 tweening = false
1163 let u: any = { context: { window: 0 }, rateLimits: [] }
1164 try {
1165 u = await $.session.usage()
1166 } catch {}
1167 const ctxWindow: number | undefined = u.context?.window || undefined
1168 const ctxTokens: number | undefined = u.context?.tokens ?? resumeCtx
1169 const pct: number | undefined =
1170 u.context?.percent ?? (resumeCtx !== undefined && ctxWindow ? Math.min(100, Math.round((resumeCtx / ctxWindow) * 100)) : undefined)
1171 lastPct = pct ?? 0
1172 await syncAgents($, now)
1173 const limits = (u.rateLimits ?? []) as Limit[]
1174 const five = limits.find(l => l.kind === 'five_hour')
1175 const week = limits.find(l => l.kind === 'seven_day')
1176 const p5 = paceOf(five, now)
1177 const pw = paceOf(week, now)
1178 mood = moodOf(p5, pw)
1179 const scene: Scene = {
1180 working,
1181 kind: toolKind(currentTool),
1182 pct: lastPct,
1183 celebrating: now < celebrateUntil,
1184 sleeping: !working && lastActive > 0 && now - lastActive > SLEEP_AFTER_MS,
1185 agents: agentsNow,
1186 mood,
1187 }
1188 return { now, u, ctxWindow, ctxTokens, pct, five, week, p5, pw, scene, tk: tokSum() }
1189}
1190
1191// ---------------- 客户端 (桌面 app) 版 ----------------
1192// 客户端横栏一格约 7.35 像素 (按 81 格 ≈ 596 像素的实测截图校准)
1193const DESKTOP_PX_PER_COL = 7.35
1194
1195async function buildDesktop($: any, els: any, cols: number, working: boolean) {
1196 const { Box, Svg } = els
1197 const s = await snapshot($, working)
1198 // 会在重置前用完 -> "40m 用完" (红色), 否则只写重置倒计时 "1h20m"; tick = 窗口已过比例 (条上的时间刻度)
1199 const limitMeter = (l: Limit | undefined, p: Pace | undefined): DashData['five'] =>
1200 p?.willRunOut && p.runOutIn !== undefinedhooks/desktop.ts 381 lines1// usage-hud 客户端 (桌面 app) 专用画面: 全部画成 SVG, 不依赖客户端的比例字体排版
2// - crabSvg: 像素螃蟹, 动作用 SVG 自带的 SMIL 动画循环播放 (只在状态变化时换图, 动画不会被打断)
3// - dashSvg: 3 行 x 3 列的仪表盘, 自带深色底板, 浅色/深色主题下都看得清
4// 纯函数, 不调用 $
5
6export type CrabMode = 'idle' | 'work' | 'celebrate' | 'sleep'
7export type CrabKind = 'think' | 'read' | 'edit' | 'bash' | 'web' | 'agent' | 'other'
8export type CrabMood = 'chill' | 'normal' | 'sweat' | 'panic'
9export type CrabState = { mode: CrabMode; kind: CrabKind; heat: 'ok' | 'hot' | 'crit'; agents: number; mood?: CrabMood }
10
11// warn: 预计重置前用完, 百分比和说明文字画成红色
12// tick: 窗口已过的比例 (0-1), 条上画一根亮色细竖线 (时间刻度); 不给就不画 (上下文那根没有)
13export type Meter = { pct?: number; extra: string; warn?: boolean; tick?: number }
14export type DashData = {
15 model: string
16 effort: string
17 project: string
18 branch: string
19 session: string
20 cost: string
21 ctx: Meter
22 five: Meter
23 week: Meter
24 status: { text: string; tone: 'work' | 'agents' | 'idle' }
25 tools: string
26 tokenTotal: string
27 tokenOutput: string
28}
29
30const C = {
31 body: '#d97757',
32 hot: '#e5484d',
33 eye: '#1c1917',
34 eyeShut: '#7c3f2c',
35 sweat: '#60a5fa',
36 spark: '#facc15',
37 bubble: '#a1a1aa',
38 paper: '#d4d4d8',
39 ink: '#52525b',
40 scan: '#60a5fa',
41 term: '#3f3f46',
42 cursor: '#4ade80',
43 sea: '#3b82f6',
44 land: '#4ade80',
45 gear: '#a1a1aa',
46 shades: '#09090b',
47 bridge: '#52525b',
48 alarm: '#ef4444',
49 kid: '#f2a07b', // 小螃蟹浅一号, 和大螃蟹分得开
50 kidLeg: '#a4553d',
51 warn: '#f87171',
52 tick: '#e5e5e5', // 时间刻度: 彩色段和暗色轨道上都看得清
53}
54const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
55const EFFORT_COLOR: Record<string, string> = { low: '#a1a1aa', medium: '#60a5fa', high: '#fbbf24', xhigh: '#fb923c', max: '#f87171' }
56
57export function esc(s: string): string {
58 return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
59}
60
61// ---------------- 螃蟹 ----------------
62const px = (x: number, y: number, fill: string, w = 1, h = 1) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}"/>`
63// 离散切换的 SMIL 动画
64const steps = (attr: string, values: string[], dur: number, begin = 0) =>
65 `<animate attributeName="${attr}" values="${values.join(';')}" keyTimes="${values.map((_, i) => (i / values.length).toFixed(3)).join(';')}" dur="${dur}s" begin="${begin}s" calcMode="discrete" repeatCount="indefinite"/>`
66const moves = (values: string[], dur: number, begin = 0) =>
67 `<animateTransform attributeName="transform" type="translate" values="${values.join(';')}" keyTimes="${values.map((_, i) => (i / values.length).toFixed(3)).join(';')}" dur="${dur}s" begin="${begin}s" calcMode="discrete" repeatCount="indefinite"/>`
68
69function arm(side: 'L' | 'R', pose: 'out' | 'up', fill: string): string {
70 if (side === 'L') return pose === 'out' ? px(0, 2, fill, 2, 1) : px(0, 0, fill, 1, 2)
71 return pose === 'out' ? px(10, 2, fill, 2, 1) : px(11, 0, fill, 1, 2)
72}
73// 两个姿势来回切换的胳膊
74function armSwap(side: 'L' | 'R', fill: string, dur: number, startUp: boolean): string {
75 const a = startUp ? ['1', '0'] : ['0', '1']
76 const b = startUp ? ['0', '1'] : ['1', '0']
77 return `<g>${steps('opacity', a, dur)}${arm(side, 'up', fill)}</g><g>${steps('opacity', b, dur)}${arm(side, 'out', fill)}</g>`
78}
79
80// tile: 客户端里给螃蟹配一块和仪表盘同色同高的深色底板, 也挡住小窗框可能的白底
81// 子代理小螃蟹: 客户端可以用细像素, 画一只 6x5 的正面小螃蟹 (终端版只有 3 列宽, 画不了这么细)
82// C = 钳子和身体 (浅一号的橙色, 和大螃蟹分得开), E = 眼睛 (深色, 四周都是身体), L = 腿; 腿两帧交替 = 在走
83// 1-2 只: 每个细像素 0.5 单位 (整只 3 x 2.5); 3 只: 0.4 单位 (整只 2.4 x 2), 竖着排在右侧 x 13-16
84const KID_BODY = ['C....C', 'CC..CC', 'CECCEC', '.CCCC.']
85const KID_LEGS = ['L.LL.L', '.L..L.']
86function kidSvg(x: number, y: number, u: number, phase: number, hop: string[] | null): string {
87 const paint = (rows: string[], y0: number) => {
88 let out = ''
89 rows.forEach((row, dy) => {
90 for (let dx = 0; dx < row.length; dx++) {
91 const ch = row[dx]
92 const fill = ch === 'C' ? C.kid : ch === 'E' ? C.eye : ch === 'L' ? C.kidLeg : ''
93 if (fill) out += `<rect x="${+(x + dx * u).toFixed(2)}" y="${+(y + (y0 + dy) * u).toFixed(2)}" width="${u}" height="${u}" fill="${fill}"/>`
94 }
95 })
96 return out
97 }
98 const a = phase % 2 ? ['0', '1'] : ['1', '0']
99 const b = a.map(v => (v === '1' ? '0' : '1'))
100 const legs = `<g>${steps('opacity', a, 0.5)}${paint([KID_LEGS[0]], 4)}</g><g>${steps('opacity', b, 0.5)}${paint([KID_LEGS[1]], 4)}</g>`
101 return `<g class="kid">${hop ? moves(hop, 1, -phase * 0.17) : ''}${paint(KID_BODY, 0)}${legs}</g>`
102}
103
104export function crabSvg(st: CrabState, scale = 6, tile?: { w: number; h: number }): string {
105 const body = st.heat === 'ok' ? C.body : C.hot
106 const parts: string[] = []
107 const fx: string[] = []
108 const mood = st.mood ?? 'normal'
109 const working = st.mode === 'work'
110 const nKids = Math.min(3, Math.max(0, st.agents))
111 const zoneFree = nKids === 0 // 右侧有子代理时让给小螃蟹 (和终端版同一套规则)
112 // 闲置 (或睡着) 时慌张: 双钳举起 + 左右发抖, 睡着的也叫醒
113 const panicIdle = mood === 'panic' && (st.mode === 'idle' || st.mode === 'sleep')
114 const mode: CrabMode = panicIdle ? 'idle' : st.mode
115 const chill = mood === 'chill' && mode === 'idle'
116 const look = working && st.kind !== 'bash' && st.kind !== 'agent' ? 1 : 0
117
118 // 身体 (含颜色闪烁)
119 const bodyAnim = st.heat === 'crit' ? steps('fill', [C.hot, C.body], 0.6) : ''
120 parts.push(`<rect x="2" y="0" width="8" height="4" fill="${body}">${bodyAnim}</rect>`)
121
122 // 胳膊
123 if (mode === 'celebrate' || panicIdle) parts.push(arm('L', 'up', body), arm('R', 'up', body))
124 else if (working && st.kind === 'bash') parts.push(armSwap('L', body, 0.3, true), armSwap('R', body, 0.3, false))
125 else if (working && st.kind === 'edit') parts.push(arm('L', 'out', body), armSwap('R', body, 0.3, true))
126 else if (mode === 'idle') {
127 // 每 12 秒挥一次右钳
128 parts.push(arm('L', 'out', body))
129 const v = ['1', '1', '1', '1', '1', '1', '1', '1', '1', '0', '1', '0']
130 parts.push(`<g>${steps('opacity', v, 12)}${arm('R', 'out', body)}</g>`)
131 parts.push(`<g>${steps('opacity', v.map(x => (x === '1' ? '0' : '1')), 12)}${arm('R', 'up', body)}</g>`)
132 } else parts.push(arm('L', 'out', body), arm('R', 'out', body))
133
134 // 腿: 走路时两对腿交替抬起
135 const legsA = px(2, 4, body) + px(7, 4, body)
136 const legsB = px(4, 4, body) + px(9, 4, body)
137 if (working || panicIdle) parts.push(`<g>${steps('opacity', ['1', '1', '1', '0'], panicIdle ? 0.4 : 0.8)}${legsA}</g><g>${steps('opacity', ['1', '0', '1', '1'], panicIdle ? 0.4 : 0.8)}${legsB}</g>`)
138 else parts.push(legsA + legsB)
139
140 // 眼睛: 睡觉闭眼; 其余时候会眨眼, 闲置时左右张望
141 const eyesOpen = px(4 + look, 1, C.eye) + px(7 + look, 1, C.eye)
142 const eyesShut = px(4 + look, 1, C.eyeShut) + px(7 + look, 1, C.eyeShut)
143 if (mode === 'sleep') parts.push(eyesShut)
144 else if (chill) {
145 // 悠闲: 戴墨镜, 不眨眼不张望
146 parts.push(px(3, 1, C.shades, 2, 1), px(5, 1, C.bridge, 2, 1), px(7, 1, C.shades, 2, 1))
147 } else {
148 const blinkOpen = ['1', '1', '1', '1', '1', '1', '1', '1', '1', '1', '1', '1', '1', '1', '0']
149 const glance = panicIdle ? moves(['-1 0', '1 0'], 0.3) : mode === 'idle' ? moves(['0 0', '0 0', '0 0', '0 0', '-1 0', '1 0', '0 0', '0 0'], 16) : ''
150 parts.push(
151 `<g>${glance}<g>${steps('opacity', blinkOpen, 4.5)}${eyesOpen}</g><g>${steps('opacity', blinkOpen.map(x => (x === '1' ? '0' : '1')), 4.5)}${eyesShut}</g></g>`,
152 )
153 }
154
155 // 头边汗珠: 上下文告急, 或额度 冒汗 / 慌张
156 if ((st.heat !== 'ok' || mood === 'sweat' || mood === 'panic') && mode !== 'celebrate')
157 fx.push(`<rect x="1" y="0" width="1" height="1" fill="${C.sweat}">${steps('y', ['0', '1', '2'], 0.9)}${steps('opacity', ['1', '1', '0'], 0.9)}</rect>`)
158 // 慌张的 "!": 闲置且右侧空着 -> 右侧竖一个大 "!"; 否则头顶上方一个红点闪
159 if (mood === 'panic' && mode !== 'celebrate') {
160 if (panicIdle && zoneFree) fx.push(`<g>${steps('opacity', ['1', '0.35'], 0.5)}${px(14, -1, C.alarm, 1, 3)}${px(14, 3, C.alarm)}</g>`)
161 else fx.push(`<rect x="1" y="-1" width="1" height="1" fill="${C.alarm}">${steps('opacity', ['1', '0'], 0.5)}</rect>`)
162 }
163
164 // 右边 3 像素宽的道具区 (x 13-15); 有子代理时让给小螃蟹
165 if (mode === 'celebrate') {
166 const spots: Array<[number, number]> = [[13, 0], [15, 1], [14, 3], [13, 6], [15, 5], [1, 0], [10, 0]]
167 spots.forEach(([x, y], i) => {
168 if (zoneFree || x < 13) fx.push(`<rect x="${x}" y="${y - 1}" width="1" height="1" fill="${C.spark}">${steps('opacity', ['1', '0', '1'], 0.6, -i * 0.2)}</rect>`)
169 })
170 } else if (!zoneFree) {
171 // 道具 / 泡泡让位
172 } else if (mode === 'sleep') {
173 fx.push(`<rect x="14" y="5" width="1" height="1" fill="${C.bubble}">${steps('y', ['5', '4', '3', '2', '1', '0'], 2.4)}</rect>`)
174 fx.push(`<rect x="15" y="5" width="1" height="1" fill="${C.bubble}">${steps('y', ['2', '1', '0', '5', '4', '3'], 2.4)}</rect>`)
175 } else if (working) {
176 switch (st.kind) {
177 case 'think':
178 fx.push(`<rect x="13" y="4" width="1" height="1" fill="${C.bubble}">${steps('opacity', ['0', '1', '1', '1'], 1.6)}</rect>`)
179 fx.push(`<rect x="14" y="2" width="1" height="1" fill="${C.bubble}">${steps('opacity', ['0', '0', '1', '1'], 1.6)}</rect>`)
180 fx.push(`<rect x="15" y="0" width="1" height="1" fill="${C.bubble}">${steps('opacity', ['0', '0', '0', '1'], 1.6)}</rect>`)
181 break
182 case 'read':
183 fx.push(px(13, 0, C.paper, 3, 4), `<rect x="13" y="0" width="3" height="1" fill="${C.scan}">${steps('y', ['0', '1', '2', '3'], 1.2)}</rect>`)
184 break
185 case 'edit': {
186 fx.push(px(13, 0, C.paper, 3, 4))
187 for (let i = 0; i < 12; i++) {
188 const on = Array.from({ length: 13 }, (_, k) => (k > i ? '1' : '0'))
189 fx.push(`<rect x="${13 + (i % 3)}" y="${Math.floor(i / 3)}" width="1" height="1" fill="${C.ink}">${steps('opacity', on, 2.6)}</rect>`)
190 }
191 break
192 }
193 case 'bash':
194 fx.push(px(13, 0, C.term, 3, 4), px(13, 1, C.cursor), `<rect x="14" y="3" width="1" height="1" fill="${C.cursor}">${steps('opacity', ['1', '0'], 0.8)}</rect>`)
195 break
196 case 'web':
197 fx.push(px(14, 0, C.sea), px(15, 1, C.sea), px(14, 2, C.sea), px(13, 1, C.sea), px(14, 1, C.sea))
198 fx.push(`<rect width="1" height="1" fill="${C.land}">${steps('x', ['14', '15', '14', '13'], 1.2)}${steps('y', ['0', '1', '2', '1'], 1.2)}</rect>`)
199 break
200 case 'agent':
201 break
202 default: {
203 fx.push(px(14, 1, C.gear))
204 const orbit: Array<[number, number]> = [[13, 0], [14, 0], [15, 0], [15, 1], [15, 2], [14, 2], [13, 2], [13, 1]]
205 fx.push(`<rect width="1" height="1" fill="${C.gear}">${steps('x', orbit.map(o => String(o[0])), 1.2)}${steps('y', orbit.map(o => String(o[1])), 1.2)}</rect>`)
206 }
207 }
208 }
209 // 子代理小螃蟹: 任何状态下都画, 盖在最上面; 1 只上下跳着走, 2-3 只排成一列走
210 if (nKids === 1) fx.push(kidSvg(13, 1.5, 0.5, 0, ['0 0', '0 -0.5', '0 0', '0 0.5']))
211 else if (nKids === 2) fx.push(kidSvg(13, -0.5, 0.5, 0, ['0 0', '0 0.5']), kidSvg(13, 2.75, 0.5, 1, ['0 0', '0 0.5']))
212 else if (nKids >= 3) for (let i = 0; i < 3; i++) fx.push(kidSvg(13.3, -0.9 + i * 2.3, 0.4, i, null))
213
214 // 走路时整只上下颠; 睡觉时慢慢呼吸
215 const bob = working ? moves(['0 0', '0 1', '0 0', '0 1'], 0.8) : mode === 'celebrate' ? moves(['0 0', '0 -1'], 0.4) : mode === 'sleep' ? moves(['0 0', '0 1'], 2.4) : ''
216 const W = 16
217 const H = 7
218 const art = `<g>${bob}${parts.join('')}</g>${fx.join('')}`
219 // color-scheme: 让小窗框跟随客户端的深/浅色主题, 不再垫白底
220 const cs = ` style="color-scheme:light dark;background:transparent"`
221 if (!tile) {
222 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W * scale}" height="${H * scale}" viewBox="0 -1 ${W} ${H}" shape-rendering="crispEdges"${cs}>${art}</svg>`
223 }
224 const ox = (tile.w - W * scale) / 2
225 const oy = (tile.h - H * scale) / 2 + scale // viewBox 从 y=-1 开始, 往下补一格
226 return (
227 `<svg xmlns="http://www.w3.org/2000/svg" width="${tile.w}" height="${tile.h}" viewBox="0 0 ${tile.w} ${tile.h}"${cs}>` +
228 `<rect x="0.5" y="0.5" width="${tile.w - 1}" height="${tile.h - 1}" rx="10" fill="${T.panel}" stroke="${T.border}"/>` +
229 `<g transform="translate(${ox} ${oy}) scale(${scale})" shape-rendering="crispEdges">${art}</g></svg>`
230 )
231}
232
233// ---------------- 仪表盘 ----------------
234// 估算文字宽度 (px): 中文按 1 个字号, 西文按 0.56 个字号
235export function textW(s: string, size: number): number {
236 let w = 0
237 for (const ch of s) {
238 const cp = ch.codePointAt(0) ?? 0
239 if (cp >= 0x2e80) w += size
240 else if (ch === ' ') w += size * 0.3
241 else if (/[A-Z0-9%$]/.test(ch)) w += size * 0.62
242 else w += size * 0.52
243 }
244 return w
245}
246function heat(t: number): string {
247 const lerp = (a: number[], b: number[], k: number) => a.map((v, i) => Math.round(v + (b[i] - v) * k))
248 const g = [74, 222, 128]
249 const y = [251, 191, 36]
250 const r = [248, 113, 113]
251 const c = t < 0.6 ? lerp(g, y, t / 0.6) : lerp(y, r, Math.min(1, (t - 0.6) / 0.4))
252 return '#' + c.map(v => v.toString(16).padStart(2, '0')).join('')
253}
254
255const FONT = `'Segoe UI Variable Text','Segoe UI','Microsoft YaHei UI','PingFang SC','Noto Sans CJK SC',sans-serif`
256// 直接画在客户端的深灰卡片上: 不再自带底板和边框 (panel/border 只给可选的螃蟹底板用)
257const T = { value: '#e6e6e8', muted: '#8e8e96', sep: '#5c5c63', accent: '#e08a68', violet: '#b49cfc', track: '#3a3a40', panel: '#17171a', border: '#2a2a2f' }
258
259function text(x: number, y: number, s: string, fill: string, size = 13, weight = 400, extra = ''): string {
260 return `<text x="${x.toFixed(1)}" y="${y}" fill="${fill}" font-size="${size}" font-weight="${weight}"${extra}>${esc(s)}</text>`
261}
262
263// 一段连续的文字: 各段由浏览器自己排, 不用估宽度, 也就不会叠在一起
264// anchor='end' 时整段右对齐到 x (用于最右一列, 让三行的右边缘对齐)
265type Run = [string, string, number?, number?] | null // 文字, 颜色, 字重, 字号
266function flow(x: number, y: number, runs: Run[], size = 13, anchor: 'start' | 'end' = 'start'): string {
267 const spans = runs
268 .filter((r): r is [string, string, number?, number?] => !!r && r[0] !== '')
269 .map(([s, fill, w, fs]) => `<tspan fill="${fill}"${w ? ` font-weight="${w}"` : ''}${fs ? ` font-size="${fs}"` : ''}>${esc(s)}</tspan>`)
270 .join('')
271 return `<text x="${x.toFixed(1)}" y="${y}" font-size="${size}" text-anchor="${anchor}" xml:space="preserve" style="white-space:pre">${spans}</text>`
272}
273const SEP: Run = [' · ', T.sep]
274
275// "2h12m" -> "2h", "1d18h" -> "1d", "240k / 1.0M" -> "240k"; 会用完的说明缩短时留着 "用完": "4d12h 用完" -> "4d 用完"
276// (只剩一个时间会被看成重置倒计时); 旧写法 "约 40m 后用完" / "2h12m 后重置" 也认
277function shortExtra(s: string): string {
278 const t = s.replace(/^约\s*/, '').trim()
279 const out = t.match(/^(\S+?)\s*后?用完$/)
280 if (out) return coarseDur(out[1]) + ' 用完'
281 return coarseDur(t.replace(/\s*后?重置$/, '').split('/')[0].trim())
282}
283function coarseDur(t: string): string {
284 const hm = t.match(/^(\d+)h\d+m$/)
285 if (hm) return hm[1] + 'h'
286 const dh = t.match(/^(\d+)d\d+h$/)
287 if (dh) return dh[1] + 'd'
288 return t
289}
290
291// 一根用量条, 正好撑满一列: 标签贴列的左边, 百分比和说明贴列的右边, 中间的细条吃掉剩下的宽度
292// 说明按这一列的宽度给全文 / 缩写 / 不给; id 带尺寸前缀, 同一页面有几张图时不会互相引用错
293function meterSvg(id: string, x0: number, y: number, cw: number, label: string, m: Meter, labelW = 0): string {
294 // labelW: 固定的标签宽度, 让细条和上下两行的值从同一个位置开始 (和终端版一样)
295 const lw = labelW || textW(label, 12) + 8
296 const p = m.pct === undefined ? 0 : Math.max(0, Math.min(100, m.pct)) / 100
297 const pctText = m.pct === undefined ? '--' : Math.round(m.pct) + '%'
298 // 右边那段 (百分比 + 说明) 的估计宽度, 留一点余量, 防止压到细条
299 const tail = (e: string) => (textW(pctText, 12.5) + (e ? 6 + textW(e, 11) : 0)) * 1.08 + 4
300 const options = m.extra ? [m.extra, shortExtra(m.extra), ''] : ['']
301 const extra = options.find(e => cw - lw - tail(e) - 10 >= 40) ?? ''
302 const bx = x0 + lw
303 const bw = Math.max(16, cw - lw - tail(extra) - 10)
304 const out = [text(x0, y, label, T.muted, 12), `<rect x="${bx.toFixed(1)}" y="${y - 6}" width="${bw.toFixed(1)}" height="5" rx="2.5" fill="${T.track}"/>`]
305 if (p > 0) {
306 out.push(
307 `<linearGradient id="g${id}" gradientUnits="userSpaceOnUse" x1="${bx.toFixed(1)}" y1="0" x2="${(bx + bw).toFixed(1)}" y2="0"><stop offset="0" stop-color="${heat(0)}"/><stop offset="0.6" stop-color="${heat(0.6)}"/><stop offset="1" stop-color="${heat(1)}"/></linearGradient>`,
308 `<rect x="${bx.toFixed(1)}" y="${y - 6}" width="${Math.max(5, bw * p).toFixed(1)}" height="5" rx="2.5" fill="url(#g${id})"/>`,
309 )
310 }
311 // 时间刻度: 1.5 像素宽的亮色细竖线, 上下各比条高出 1.5 像素, 夹在条的两端之内
312 if (m.tick !== undefined && isFinite(m.tick)) {
313 const tw = 1.5
314 const tx = Math.max(bx, Math.min(bx + bw - tw, bx + bw * Math.max(0, Math.min(1, m.tick)) - tw / 2))
315 out.push(`<rect class="tick" x="${tx.toFixed(1)}" y="${y - 7.5}" width="${tw}" height="8" fill="${C.tick}"/>`)
316 }
317 // 会用完: 百分比和说明都画成红色
318 const pctFill = m.pct === undefined ? T.muted : m.warn ? C.warn : heat(p)
319 out.push(flow(x0 + cw, y, [[pctText, pctFill, 700, 12.5], extra ? [' ' + extra, m.warn ? C.warn : T.muted, m.warn ? 700 : 0, 11] : null], 12.5, 'end'))
320 return out.join('')
321}
322
323// 和终端版同一种排法: 3 行 x 3 列, 每列的标签在同一条竖线上, 值和用量条都从标签后同一个位置开始
324// 模型 Opus 5.5 ▁▂▄ medium 项目 my-app main 本会话 1h19m $64.93
325// 上下文 ━━━━━━━━━━ 34% 340k 5小时 ━━━━━━━━━ 34% 1h52m 本周 ━━━━━━━━━ 48% 1d18h
326// 状态 ✓ 待命 工具 Bash 12 Read 4 token 79.7M out 212k
327// 用量条撑满所在列, 第三列的条正好到右边缘; 一列放不下的文字在右端渐隐, 不会压到旁边一列
328export function dashSvg(d: DashData, opts: { width?: number; compact?: boolean } = {}): string {
329 const W = Math.round(opts.width ?? 640)
330 const uid = `uhud${W}${opts.compact ? 'c' : 'f'}`
331 const lh = 22
332 const y = (i: number) => 15 + i * lh
333 const H = opts.compact ? 22 : lh * 3
334 const gap = 24
335 const cw = (W - gap * 2) / 3
336 const cx = [0, cw + gap, (cw + gap) * 2]
337 const LW = opts.compact ? 0 : 52 // 标签列宽: 三个汉字 + 间距
338 const meters = (row: number) =>
339 meterSvg(uid + 'a', cx[0], y(row), cw, '上下文', d.ctx, LW) +
340 meterSvg(uid + 'b', cx[1], y(row), cw, '5小时', d.five, LW) +
341 meterSvg(uid + 'c', cx[2], y(row), cw, '本周', d.week, LW)
342
343 // 每列一个遮罩: 在列的右端渐隐
344 const fade = 18 / cw
345 const defs = [0, 1, 2]
346 .map(
347 c =>
348 `<linearGradient id="${uid}f${c}" gradientUnits="userSpaceOnUse" x1="${cx[c].toFixed(1)}" y1="0" x2="${(cx[c] + cw).toFixed(1)}" y2="0"><stop offset="0" stop-color="#fff"/><stop offset="${(1 - fade).toFixed(3)}" stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>` +
349 `<mask id="${uid}m${c}" maskUnits="userSpaceOnUse" x="${cx[c].toFixed(1)}" y="0" width="${cw.toFixed(1)}" height="${H}"><rect x="${cx[c].toFixed(1)}" y="0" width="${cw.toFixed(1)}" height="${H}" fill="url(#${uid}f${c})"/></mask>`,
350 )
351 .join('')
352 // 一格: 标签 + 从标签后开始的值
353 const cell = (c: number, row: number, label: string, runs: Run[]) =>
354 `<g mask="url(#${uid}m${c})">${text(cx[c], y(row), label, T.muted, 12)}${flow(cx[c] + LW, y(row), runs)}</g>`
355
356 let body = ''
357 if (opts.compact) {
358 body = meters(0)
359 } else {
360 const effortIdx = EFFORTS.indexOf(d.effort)
361 const effortColor = EFFORT_COLOR[d.effort] ?? T.muted
362 const pips: Run[] = '▁▂▄▆█'.split('').map((ch, k) => [ch, k <= effortIdx ? effortColor : T.track])
363 const tone = d.status.tone === 'work' ? T.accent : d.status.tone === 'agents' ? T.violet : T.muted
364 // 第 1 行: 模型 | 项目 | 本会话
365 body += cell(0, 0, '模型', [[d.model, T.accent, 600], [' ', T.sep], ...pips, [' ' + (d.effort || '--'), effortColor, 600]])
366 body += cell(1, 0, '项目', [[d.project, T.value, 600], d.branch ? [' ' + d.branch, T.violet] : null])
367 body += cell(2, 0, '本会话', [[d.session, T.value, 600], d.cost ? [' ' + d.cost, T.muted] : null])
368 // 第 2 行: 三根用量条
369 body += meters(1)
370 // 第 3 行: 状态 | 工具 | token
371 body += cell(0, 2, '状态', [d.status.tone === 'idle' ? null : ['● ', tone], [d.status.text, tone, d.status.tone === 'idle' ? 0 : 600]])
372 body += cell(1, 2, '工具', [[d.tools || '还没有', d.tools ? T.value : T.muted]])
373 body += cell(2, 2, 'token', [[d.tokenTotal, T.value, 600], d.tokenOutput ? [' out ', T.muted] : null, d.tokenOutput ? [d.tokenOutput, T.value] : null])
374 }
375
376 return (
377 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="${FONT}" style="color-scheme:light dark">` +
378 `<defs>${defs}</defs>${body}</svg>`
379 )
380}
381hooks/sprites.ts 901 lines1// usage-hud: 螃蟹的像素画 (纯函数, 不碰 $), 给 hooks 模块 (register.tsx) 和散步道的 Client 模块 (walkway.tsx) 共用
2// - 面板那只大螃蟹 (scenePx / miniPx): 按工具做动作、道具区、情绪
3// - 散步道 (v0.14 起; v0.16 搬进 Client): 排队、走路、粒子、天空行; 像素 -> 格子 (laneCells), 最后一步由 walkway.tsx 画成 Text
4
5export const FRAME_MS = 150
6export const SPRITE_W = 15 // 12 列螃蟹 + 3 列道具
7export const MINI_W = 8
8export const JUMP_FRAMES = 4 // 发出消息那一跳: 蹲 / 腾空 / 腾空 / 落地
9export const DEF = 0x01000000 // 终端的默认颜色
10export const DIM = '#71717a'
11export const VALUE = '#d4d4d8'
12export const WARN = '#f87171' // 会用完时 百分比 和 "40m用完" 的颜色
13
14export type Mood = 'chill' | 'normal' | 'sweat' | 'panic'
15export type ToolKind = 'think' | 'read' | 'edit' | 'bash' | 'web' | 'agent' | 'other'
16
17// ---------------- 颜色 ----------------
18export const COL = {
19 body: 0xd97757,
20 hot: 0xe5484d,
21 eye: 0x1c1917,
22 sweat: 0x60a5fa,
23 spark: 0xfacc15,
24 bubble: 0x8b8b94,
25 paper: 0xd4d4d8,
26 ink: 0x52525b,
27 scan: 0x60a5fa,
28 term: 0x3f3f46,
29 cursor: 0x4ade80,
30 sea: 0x3b82f6,
31 land: 0x4ade80,
32 gear: 0xa1a1aa,
33 thought: 0xa1a1aa,
34 shades: 0x09090b, // 墨镜镜片
35 bridge: 0x52525b, // 墨镜鼻梁
36 alarm: 0xef4444, // 慌张的 "!"
37 kid: 0xf2a07b, // 小螃蟹用浅一号的颜色, 和大螃蟹分得开
38 kidEye: 0xf5f5f4, // 0.12 的小螃蟹眼睛 (会闪的浅色像素); 0.13 起不画, 留着给测试确认画面里没有它
39 kidLeg: 0xa4553d,
40}
41export function mix(a: number, b: number, t: number): number {
42 const ch = (s: number) => Math.round(((a >> s) & 255) * (1 - t) + ((b >> s) & 255) * t)
43 return (ch(16) << 16) | (ch(8) << 8) | ch(0)
44}
45
46// ---------------- 文字宽度 (中文/全角算 2 列) ----------------
47export function isWide(cp: number): boolean {
48 return (
49 (cp >= 0x1100 && cp <= 0x115f) ||
50 (cp >= 0x2e80 && cp <= 0xa4cf) ||
51 (cp >= 0xac00 && cp <= 0xd7a3) ||
52 (cp >= 0xf900 && cp <= 0xfaff) ||
53 (cp >= 0xfe30 && cp <= 0xfe4f) ||
54 (cp >= 0xff00 && cp <= 0xff60) ||
55 (cp >= 0xffe0 && cp <= 0xffe6)
56 )
57}
58export function dw(s: string): number {
59 let w = 0
60 for (const ch of s) w += isWide(ch.codePointAt(0) ?? 0) ? 2 : 1
61 return w
62}
63export function clip(s: string, max: number): string {
64 if (max <= 0) return ''
65 if (dw(s) <= max) return s
66 let out = ''
67 let w = 0
68 for (const ch of s) {
69 const cw = isWide(ch.codePointAt(0) ?? 0) ? 2 : 1
70 if (w + cw > max - 2) break
71 out += ch
72 w += cw
73 }
74 return out + '..'
75}
76
77// ---------------- 像素画 ----------------
78export type Px = number[][]
79export function canvas(w: number, h: number): Px {
80 const p: Px = []
81 for (let y = 0; y < h; y++) p.push(new Array(w).fill(-1))
82 return p
83}
84export function put(p: Px, x: number, y: number, c: number) {
85 if (y >= 0 && y < p.length && x >= 0 && x < p[y].length) p[y][x] = c
86}
87export function rect(p: Px, x0: number, y0: number, w: number, h: number, c: number) {
88 for (let y = y0; y < y0 + h; y++) for (let x = x0; x < x0 + w; x++) put(p, x, y, c)
89}
90export function encode(px: Px, cols: number, cellRows: number): string {
91 const words: number[] = []
92 for (let r = 0; r < cellRows; r++) {
93 for (let c = 0; c < cols; c++) {
94 const top = px[r * 2]?.[c] ?? -1
95 const bot = px[r * 2 + 1]?.[c] ?? -1
96 if (top < 0 && bot < 0) words.push(32, DEF, DEF)
97 else if (bot < 0) words.push(0x2580, top, DEF) // ▀
98 else if (top < 0) words.push(0x2584, bot, DEF) // ▄
99 else words.push(0x2580, top, bot)
100 }
101 }
102 return new Uint8Array(Uint32Array.from(words).buffer).toBase64()
103}
104
105// eyeDy: 眼睛在身体第几行 (默认 1, 散步道里低头看输入框时 2); legs < 0 = 腾空不画腿
106export type Pose = { bob: number; legs: number; eyes: 'open' | 'closed'; look: number; armL: 'out' | 'up'; armR: 'out' | 'up'; body: number; eyeDy?: number }
107
108// 12x5 像素的螃蟹 (第 6 行留给上下颠); ox = 往右挪几格 (面板里是 0, 散步道里是螃蟹的位置)
109export function drawCrab(p: Px, o: Pose, ox = 0) {
110 const y = o.bob
111 rect(p, ox + 2, y, 8, 4, o.body)
112 if (o.armL === 'out') rect(p, ox, y + 2, 2, 1, o.body)
113 else rect(p, ox, y, 1, 2, o.body)
114 if (o.armR === 'out') rect(p, ox + 10, y + 2, 2, 1, o.body)
115 else rect(p, ox + 11, y, 1, 2, o.body)
116 // 走路: 四条腿两两交替抬起
117 if (o.legs >= 0) {
118 const legs = [[2, 4, 7, 9], [2, 7], [2, 4, 7, 9], [4, 9]][o.legs % 4]
119 for (const x of legs) put(p, ox + x, y + 4, o.body)
120 }
121 const eye = o.eyes === 'open' ? COL.eye : mix(o.body, COL.eye, 0.45)
122 const ey = y + (o.eyeDy ?? 1)
123 put(p, ox + 4 + o.look, ey, eye)
124 put(p, ox + 7 + o.look, ey, eye)
125}
126
127// look / low / hop: 散步道用 (面板不给, 画法和以前一样):
128// look = 眼睛看哪边 (走路方向 / 东张西望); low = 打字时低头看输入框; hop = 发出消息那一跳的第几帧
129export type Scene = { working: boolean; kind: ToolKind; pct: number; celebrating: boolean; sleeping: boolean; agents: number; mood?: Mood; look?: number; low?: boolean; hop?: number }
130
131export function bodyColor(pct: number, f: number): number {
132 if (pct >= 95) return f % 4 < 2 ? COL.hot : COL.body
133 return pct >= 80 ? COL.hot : COL.body
134}
135
136// 按字符画盖像素: rows 里每个字符查 colors 得到颜色, 查不到 (比如 '.') 就空着
137export function paint(p: Px, x0: number, y0: number, rows: string[], colors: Record<string, number>) {
138 rows.forEach((row, dy) => {
139 for (let dx = 0; dx < row.length; dx++) {
140 const c = colors[row[dx]]
141 if (c !== undefined) put(p, x0 + dx, y0 + dy, c)
142 }
143 })
144}
145
146export function scenePx(s: Scene, f: number): Px {
147 const p = canvas(SPRITE_W, 6)
148 const t = Math.floor(f / 2)
149 const md = s.mood ?? 'normal'
150 // 0.14 起子代理小螃蟹搬到输入框上方的散步道, 面板右侧 3 列永远给道具
151 const zoneFree = true
152 const o: Pose = { bob: 0, legs: 0, eyes: 'open', look: 0, armL: 'out', armR: 'out', body: bodyColor(s.pct, f) }
153 let shades = false
154 let bigBang = false // 右侧的大 "!"
155
156 if (s.hop !== undefined && s.hop >= 0 && s.hop < JUMP_FRAMES) {
157 // 散步道: 发出消息那一跳 (画布只有 6 像素高, 没法整只往上移): 蹲 -> 腾空 (不画腿) -> 落地
158 o.bob = s.hop === 0 || s.hop >= JUMP_FRAMES - 1 ? 1 : 0
159 if (s.hop === 1 || s.hop === 2) {
160 o.legs = -1
161 o.armL = 'up'
162 o.armR = 'up'
163 }
164 } else if (s.low) {
165 // 散步道: 打字时低头看输入框 (身体放低, 眼睛往下)
166 o.bob = 1
167 o.eyeDy = 2
168 } else if (s.celebrating) {
169 o.armL = 'up'
170 o.armR = 'up'
171 o.bob = t % 2
172 const spots = [[12, 0], [14, 1], [13, 3], [12, 5], [14, 4], [13, 1], [1, 0], [10, 0]]
173 spots.forEach(([x, y], i) => {
174 if ((i + f) % 3 === 0 && (zoneFree || x < 12)) put(p, x, y, COL.spark)
175 })
176 } else if (s.working) {
177 o.legs = t % 4
178 o.bob = t % 2
179 switch (s.kind) {
180 case 'think': {
181 o.look = 1
182 const dots = [[12, 4], [13, 2], [14, 0]]
183 const n = Math.floor(f / 3) % 4
184 if (zoneFree) for (let i = 0; i < n; i++) put(p, dots[i][0], dots[i][1], COL.thought)
185 break
186 }
187 case 'read': {
188 o.look = 1
189 if (zoneFree) {
190 rect(p, 12, 1, 3, 4, COL.paper)
191 rect(p, 12, 1 + (t % 4), 3, 1, COL.scan)
192 }
193 break
194 }
195 case 'edit': {
196 o.look = 1
197 o.armR = f % 2 ? 'up' : 'out'
198 if (zoneFree) {
199 rect(p, 12, 1, 3, 4, COL.paper)
200 const k = t % 13
201 for (let i = 0; i < k; i++) put(p, 12 + (i % 3), 1 + Math.floor(i / 3), COL.ink)
202 }
203 break
204 }
205 case 'bash': {
206 o.armL = f % 2 ? 'up' : 'out'
207 o.armR = f % 2 ? 'out' : 'up'
208 if (zoneFree) {
209 rect(p, 12, 1, 3, 4, COL.term)
210 put(p, 12, 2, COL.cursor)
211 if (f % 4 < 2) put(p, 13, 4, COL.cursor)
212 }
213 break
214 }
215 case 'web': {
216 o.look = 1
217 if (zoneFree) {
218 const ring = [[13, 1], [14, 2], [13, 3], [12, 2]]
219 for (const [x, y] of ring) put(p, x, y, COL.sea)
220 put(p, 13, 2, COL.sea)
221 const [lx, ly] = ring[t % 4]
222 put(p, lx, ly, COL.land)
223 }
224 break
225 }
226 case 'agent':
227 break
228 default: {
229 if (zoneFree) {
230 const orbit = [[12, 1], [13, 1], [14, 1], [14, 2], [14, 3], [13, 3], [12, 3], [12, 2]]
231 put(p, 13, 2, COL.gear)
232 const [gx, gy] = orbit[f % 8]
233 put(p, gx, gy, COL.gear)
234 }
235 }
236 }
237 } else if (md === 'panic') {
238 // 闲置时慌张: 双钳举起, 左右发抖, 腿乱蹬; 右侧空着就竖一个大 "!"
239 o.armL = 'up'
240 o.armR = 'up'
241 o.look = f % 2 ? 1 : -1
242 o.legs = f % 4
243 bigBang = zoneFree
244 } else if (s.sleeping) {
245 o.eyes = 'closed'
246 o.bob = Math.floor(f / 8) % 2 // 慢慢呼吸
247 if (zoneFree) {
248 const z = Math.floor(f / 3)
249 put(p, 13, 5 - (z % 6), COL.bubble)
250 put(p, 14, 5 - ((z + 3) % 6), COL.bubble)
251 }
252 } else {
253 const cyc = f % 160
254 if (md === 'chill') shades = true // 戴墨镜: 不眨眼, 不张望
255 else {
256 if (f % 30 === 0) o.eyes = 'closed' // 眨眼
257 if (cyc >= 60 && cyc < 68) o.look = -1 // 左右张望
258 else if (cyc >= 68 && cyc < 76) o.look = 1
259 }
260 if (cyc >= 120 && cyc < 136) o.armR = Math.floor(f / 3) % 2 ? 'up' : 'out' // 挥手
261 }
262
263 if (s.look !== undefined && !s.low) o.look = s.look // 散步道: 眼睛看走的方向 / 东张西望
264 drawCrab(p, o)
265
266 if (shades) {
267 const y = o.bob + 1
268 rect(p, 3, y, 2, 1, COL.shades)
269 rect(p, 5, y, 2, 1, COL.bridge)
270 rect(p, 7, y, 2, 1, COL.shades)
271 }
272 if (bigBang) {
273 const c = f % 4 < 2 ? COL.alarm : COL.spark
274 put(p, 13, 0, c)
275 put(p, 13, 1, c)
276 put(p, 13, 3, c)
277 }
278 // 头边 (x=1, 第 0-1 行, 任何姿势下都空着): 上下文告急 / 冒汗 -> 汗滴; 慌张 -> 汗滴和红色 "!" 交替
279 if (!s.celebrating) {
280 const sweat = s.pct >= 80 || md === 'sweat' || md === 'panic'
281 if (md === 'panic' && !bigBang && t % 4 >= 2) {
282 put(p, 1, 0, COL.alarm)
283 put(p, 1, 1, COL.alarm)
284 } else if (sweat) {
285 const d = t % 4
286 if (d < 2) put(p, 1, d, COL.sweat)
287 }
288 }
289 return p
290}
291
292// 精简版的一行小螃蟹 (8 列 x 2 像素); 0.14 起子代理小螃蟹在散步道里, 这里不再画
293export function miniPx(s: Scene, f: number): Px {
294 const p = canvas(MINI_W, 2)
295 const t = Math.floor(f / 2)
296 const md = s.mood ?? 'normal'
297 const body = bodyColor(s.pct, f)
298 const bw = MINI_W
299 rect(p, 0, 0, bw, 1, body)
300 const idle = !s.working && !s.celebrating
301 const closed = s.sleeping || (idle && md !== 'chill' && f % 30 === 0)
302 const look = s.working ? 1 : 0
303 const ex = [2, 5]
304 let eye = closed ? mix(body, COL.eye, 0.45) : COL.eye
305 if (md === 'panic' && !s.celebrating && f % 4 < 2) eye = COL.spark // 慌张: 眼睛黄红闪
306 if (idle && !s.sleeping && md === 'chill') rect(p, ex[0], 0, ex[1] - ex[0] + 1, 1, COL.shades) // 墨镜
307 else {
308 put(p, ex[0] + look, 0, eye)
309 put(p, ex[1] + look, 0, eye)
310 }
311 const scramble = md === 'panic' && !s.celebrating
312 const wide = s.celebrating ? [0, 7] : (s.working || scramble) && t % 2 ? [0, 2, 5, 7] : [1, 3, 4, 6]
313 const legs = wide
314 for (const x of legs) put(p, x, 1, body)
315 // 冒汗: 左下角一颗蓝色汗滴闪
316 if (!s.celebrating && (s.pct >= 80 || md === 'sweat' || md === 'panic') && t % 4 < 2) put(p, 0, 1, COL.sweat)
317 return p
318}
319
320
321// ---------------- 螃蟹散步道 (v0.14 起: 输入框正上方的横栏, 只在终端) ----------------
322// 3 行版 (v0.15) = 6 像素高: 大螃蟹就是面板那只 (v0.16 直接用 scenePx: 按工具做动作 + 右边 3 列道具区 + 情绪), 占 15 格;
323// 小螃蟹 7x4 (v0.16: 比大螃蟹小一大截, 眼睛四周都有身体, 看前面时也只在身体里挪), 占下面两行
324// 天空行 (v0.16): 横栏放得下就在最上面空一行, 隔开上面的正文和螃蟹; 只有往上飘的粒子 (庆祝闪光 / 睡觉泡泡 / 思考点点) 能进去
325// 2 行版 = 4 像素高: 大螃蟹 9x4 (BIG2); 小螃蟹 3x2, 只占下面那行
326// 1 行版 = 2 像素高: 大螃蟹缩成 7x2 (占 9 格, 居中); 小螃蟹 3x2
327// 排队: 一维的路上谁也不能从谁身上穿过去, 所以左右顺序固定 [最新的小螃蟹 ... 最早的小螃蟹, 大螃蟹]
328// 往右走时小螃蟹跟在大螃蟹后面; 碰到一端整队一起掉头, 往左走时大螃蟹在后面赶着它们走
329// (按 "k 帧之前的位置" 跟着走的话, 掉头时每只都会和前面那只交叉重叠, 所以不用)
330// 大螃蟹什么时候走: 主会话在跑, 或者有子代理在跑 (速度跟心情); 全闲时每 20-40 秒溜达 3-8 格, 停下东张西望;
331// 5 分钟没有任何动静 (主会话 / 子代理 / 打字 / 发消息) 才睡; 打字时停下低头看输入框; 发出消息时跳一下
332// 粒子 (v0.15): 盲文点画在没有螃蟹的空格子里, 最多 24 个 (v0.16), 每个活 2-8 帧, 每帧最多挪 1 个点位, 只会慢慢变暗
333// 节奏: 每只每帧最多挪 1 格; 腿每 2-3 帧换一次; 身体颜色不闪
334export type Rows = 1 | 2 | 3
335// bw = 大螃蟹占的格数 (3 行版 = 螃蟹 12 + 右边道具区 3: 永远给道具留着地方, 不用镜像); cw = 螃蟹本身 (悬停区域); kw = 小螃蟹
336export const LANE_SIZE: Record<Rows, { bw: number; cw: number; kw: number }> = { 3: { bw: 15, cw: 12, kw: 7 }, 2: { bw: 9, cw: 9, kw: 3 }, 1: { bw: 9, cw: 9, kw: 3 } }
337export const TOOL_HOLD_FRAMES = 4 // 工具刚结束的这么多帧 (600ms) 里还算 "在用工具": 连着几个工具之间的空档不会走一格停一格
338const LANE_L = 5 // 放不下时左端留给暗色的 "+N"
339const LANE_ROAM = 6 // 小螃蟹再多也给整队留几格走动的地方
340export const SAY_FRAMES = 33 // 气泡显示多久 (约 5 秒)
341const WAVE_MS = 1500 // 子代理结束后挥手多久
342export const TYPE_FRAMES = 10 // 最后一次按键后多久恢复 (约 1.5 秒)
343const MOOD_STEP: Record<Mood, number> = { chill: 5, normal: 3, sweat: 2, panic: 1 } // 忙的时候每几帧走一格
344const STROLL_STEP = 3 // 溜达时每几帧走一格
345const STROLL_GAP = [133, 267] // 全闲时每隔多少帧溜达一次 (20-40 秒)
346const LOOK_FRAMES = 24 // 溜达完东张西望多久 (3.6 秒)
347const PT_MAX = 24 // 同时最多几个粒子
348const DUST = 0x8a7f72 // 尘土: 暖灰
349const PT_DARK = 0x27272a // 粒子变暗的方向
350
351// state: walk = 在队里; wave = 子代理结束, 原地挥手; hop = 3 行版挥完往上跳出横栏 (还占着队里的位置, 后面的不会滑到它身下);
352// exit = 2 行版挥完走上面那行出去 (在 gone 里, 画在最底下)
353export type LaneKid = { id: string; x: number; y: number; state: 'walk' | 'wave' | 'hop' | 'exit'; until: number; dir: 1 | -1 }
354// 粒子: 盲文点坐标 (每格 2 x 4 个点; y 从螃蟹区顶上算, 天空行是 -4..-1), 速度每轴 -1/0/1 (有两个轴时奇偶帧轮流动, 每帧只挪 1 个点位)
355// sky = 能不能进天空行 (只有往上飘的: 庆祝闪光 / 睡觉泡泡 / 思考点点)
356export type Particle = { x: number; y: number; vx: -1 | 0 | 1; vy: -1 | 0 | 1; age: number; life: number; color: number; sky: boolean; every: 1 | 2 }
357// greet = 鼠标停在大螃蟹上 (v0.16): 停下举钳打招呼, 队伍也停
358export type LanePose = 'greet' | 'celebrate' | 'jump' | 'type' | 'tool' | 'walk' | 'sleep' | 'idle'
359export type Lane = {
360 w: number // 宽度 (格)
361 rows: Rows // 螃蟹区几行 (不含天空行)
362 sky: boolean // 最上面有没有天空行
363 bw: number // 大螃蟹占几格 (3 行版含道具区)
364 cw: number // 大螃蟹本身几格
365 kw: number // 小螃蟹占几格
366 bx: number // 大螃蟹最左一格
367 dir: 1 | -1
368 stepAt: number // 大螃蟹上次挪动的帧
369 steps: number // 走了多少步 (尘土每隔几步扬一次)
370 kids: LaneKid[] // 队里的小螃蟹, 从右 (挨着大螃蟹) 到左
371 gone: LaneKid[] // 2 行版正在离场的
372 hidden: number // 放不下的子代理个数 (+N)
373 pt: Particle[]
374 strollLeft: number // 这次溜达还剩几格
375 strollAt: number // 下次溜达的帧 (-1 = 还没排)
376 lookUntil: number // 溜达完东张西望到哪一帧
377 celebAt: number // 这次庆祝从哪一帧开始 (-1 = 不在庆祝)
378 pose: LanePose
379}
380// 这一帧的处境: 散步道的时钟和渲染用同一份; tool = 主会话正在用的工具种类 ('' = 没有, 在想 / 在回复)
381// hold = 鼠标停在大螃蟹上 (或刚离开 0.5 秒内); lookAt = 指针在横栏别处时的列 (闲着时眼睛看过去), 没有就 undefined
382export type LaneAct = { working: boolean; agents: number; typing: boolean; celebrating: boolean; sleeping: boolean; jumpAge: number; md: Mood; tool: ToolKind | ''; hold: boolean; lookAt?: number }
383export function newLane(w: number, rows: Rows = 3, sky = false): Lane {
384 const { bw, cw, kw } = LANE_SIZE[rows]
385 const bx = Math.max(0, Math.floor((w - bw) / 2))
386 return { w, rows, sky, bw, cw, kw, bx, dir: 1, stepAt: 0, steps: 0, kids: [], gone: [], hidden: 0, pt: [], strollLeft: 0, strollAt: -1, lookUntil: -1, celebAt: -1, pose: 'idle' }
387}
388export const laneCap = (L: Lane) => Math.max(0, Math.floor((L.w - LANE_L - L.bw - LANE_ROAM) / (L.kw + 1)))
389const sgn = (n: number): -1 | 0 | 1 => (n > 0 ? 1 : n < 0 ? -1 : 0)
390// 帧号做种子的伪随机 (测试里可复现)
391const rnd = (f: number, salt: number) => (Math.imul(f + 1, 2654435761) ^ Math.imul(salt + 7, 40503)) >>> 0
392
393// 宽度或行数变了: 宽度变 -> 整队平移回横栏里; 行数变 -> 螃蟹换尺寸, 小螃蟹按新尺寸重新排在大螃蟹后面
394export function laneFit(L: Lane, w: number, rows: Rows, sky: boolean) {
395 if (sky !== L.sky) {
396 L.sky = sky
397 L.pt = []
398 }
399 if (rows !== L.rows) {
400 const { bw, cw, kw } = LANE_SIZE[rows]
401 L.rows = rows
402 L.bw = bw
403 L.cw = cw
404 L.kw = kw
405 L.gone = []
406 L.pt = []
407 L.kids = L.kids.filter(k => k.state !== 'hop')
408 L.kids.forEach((k, i) => (k.x = L.bx - (kw + 1) * (i + 1)))
409 }
410 if (w !== L.w || L.bx + L.bw > w) {
411 L.w = w
412 const shift = Math.min(0, w - L.bw - L.bx)
413 if (shift) {
414 L.bx += shift
415 for (const k of L.kids) k.x += shift
416 }
417 if (L.bx < 0) L.bx = 0
418 }
419 L.kids = L.kids.slice(0, laneCap(L))
420}
421
422// 进队 / 出队 (不走动): 子代理结束 -> 原地挥手 WAVE_MS -> 3 行版往上跳出去, 2 行版走上面那行出去; 新的排到队尾
423export function laneSync(L: Lane, now: number, running: string[]): boolean {
424 let changed = false
425 const run = new Set(running)
426 for (const k of L.kids) {
427 if (k.state === 'walk' && !run.has(k.id)) {
428 k.state = 'wave'
429 k.until = now + WAVE_MS
430 changed = true
431 } else if (k.state === 'wave' && run.has(k.id)) {
432 k.state = 'walk'
433 changed = true
434 } else if (k.state === 'wave' && now >= k.until) {
435 changed = true
436 if (L.rows === 3) {
437 k.state = 'hop'
438 k.y = 2
439 } else {
440 k.state = 'exit'
441 k.dir = k.x + L.kw / 2 < L.w / 2 ? -1 : 1
442 L.gone.push(k)
443 }
444 }
445 }
446 L.kids = L.kids.filter(k => k.state !== 'exit')
447 const cap = laneCap(L)
448 const slot = L.kw + 1
449 for (const id of running) {
450 if (L.kids.some(k => k.id === id)) continue
451 if (L.kids.length >= cap) break
452 const i = L.kids.length
453 const right = i ? L.kids[i - 1].x : L.bx // 右边邻居; 新来的放在队尾, 不和它挨着
454 L.kids.push({ id, x: Math.min(L.bx - slot * (i + 1), right - slot), y: 0, state: 'walk', until: 0, dir: 1 })
455 changed = true
456 }
457 const shown = L.kids.filter(k => k.state === 'walk').length
458 const hidden = Math.max(0, running.length - shown)
459 if (hidden !== L.hidden) {
460 L.hidden = hidden
461 changed = true
462 }
463 return changed
464}
465
466// 这一帧大螃蟹是什么姿势: 庆祝 > 跳 > 打字低头 > 用工具 (停下做动作) > 走 (在想 / 在回复 / 子代理在跑 / 溜达) > 睡 > 趴着
467export function lanePose(L: Lane, a: LaneAct): LanePose {
468 if (a.hold) return 'greet'
469 if (a.celebrating) return 'celebrate'
470 if (a.jumpAge >= 0 && a.jumpAge < JUMP_FRAMES) return 'jump'
471 if (a.typing) return 'type'
472 if (a.tool) return 'tool'
473 if (a.working || a.agents > 0 || L.strollLeft > 0) return 'walk'
474 if (a.sleeping) return 'sleep'
475 return 'idle'
476}
477
478// 粒子能到的最上面一行点: 能进天空行的到 -4, 其余到螃蟹区顶边 0
479const ptTop = (L: Lane, sky: boolean) => (sky && L.sky ? -4 : 0)
480function addPt(L: Lane, x: number, y: number, vx: -1 | 0 | 1, vy: -1 | 0 | 1, life: number, color: number, sky = false, every: 1 | 2 = 1) {
481 if (L.pt.length >= PT_MAX) return
482 if (x < 0 || y < ptTop(L, sky) || x >= L.w * 2 || y >= L.rows * 4) return
483 L.pt.push({ x, y, vx, vy, age: 0, life: Math.max(2, Math.min(8, life)), color, sky, every })
484}
485
486// 粒子: 先让活着的走一步 (到寿命就消失), 再按这一帧发生的事冒新的
487// 每粒的方向 (vx, vy) 和快慢 (every: 每 1 帧或每 2 帧挪一次) 略有不同, 散开飘, 不排成一条线
488function laneParticles(L: Lane, f: number, a: LaneAct, pose: LanePose, exits: LaneKid[]) {
489 for (const p of L.pt) {
490 p.age += 1
491 if (p.age % p.every) continue // 慢的那些隔一帧才挪
492 // 两个轴都有速度时轮流动: 每帧只挪 1 个点位
493 if (p.vx && p.vy) {
494 if ((p.age / p.every) % 2) p.x += p.vx
495 else p.y += p.vy
496 } else {
497 p.x += p.vx
498 p.y += p.vy
499 }
500 }
501 L.pt = L.pt.filter(p => p.age < p.life && p.x >= 0 && p.y >= ptTop(L, p.sky) && p.x < L.w * 2 && p.y < L.rows * 4)
502 const H = L.rows * 4 // 点的行数
503 const backCell = L.dir > 0 ? L.bx - 1 : L.bx + L.cw // 脚后那一格 (往右走: 和队里的小螃蟹之间的空隙; 往左走: 螃蟹右边)
504 const away = (L.dir > 0 ? -1 : 1) as -1 | 1 // 往后
505 const fast = a.md === 'sweat' || a.md === 'panic'
506 const busy = a.working || a.agents > 0
507 const trot = fast && busy
508 // 走路扬尘: 每 1-2 帧从脚后冒 1 粒, 每粒活 4-8 帧, 往后上方散开 -> 平时同时 3-5 粒; 小跑时每帧都冒, 活得久一点 -> 5-7 粒
509 if (pose === 'walk') {
510 const r = rnd(f, 1)
511 if (trot || f % 3 !== 2) {
512 const vy = (r % 3 === 0 ? 0 : -1) as -1 | 0 // 大多往上飘, 少数贴着地往后走
513 const vx = (r % 4 === 1 ? 0 : away) as -1 | 0 | 1 // 大多往后, 少数直着往上
514 const life = trot ? 6 + (r % 2) : 4 + (r % 2) + (f % 3 === 0 ? 1 : 0)
515 addPt(L, backCell * 2 + (f % 2), H - 1 - (r % 2), vx, vy, life, DUST, false, r % 5 === 0 ? 2 : 1)
516 }
517 // 小跑: 偶尔甩出一滴蓝色汗珠 (往后下方落)
518 if (trot && rnd(f, 4) % 12 === 0) addPt(L, backCell * 2, 2, away, 1, 5, mix(COL.sweat, PT_DARK, 0.25))
519 }
520 // 一轮结束庆祝: 螃蟹两边冒一小圈金色闪光, 往上飘进天空行消失 (分三波, 同时 6-10 粒)
521 if (pose === 'celebrate') {
522 if (L.celebAt < 0) L.celebAt = f
523 const age = f - L.celebAt
524 const wave = age === 0 ? 5 : age === 2 ? 3 : age === 4 ? 2 : 0
525 const left = L.bx * 2 - 1
526 const right = (L.bx + L.cw) * 2
527 for (let i = 0; i < wave; i++) {
528 const k = i + age * 3
529 const onRight = k % 2 === 1
530 const x = onRight ? right + ((k >> 1) % 3) : left - ((k >> 1) % 3)
531 const y = H - 2 - ((k * 3) % Math.max(1, H - 2))
532 addPt(L, x, y, (k >> 1) % 2 ? (onRight ? 1 : -1) : 0, -1, 5 + (rnd(f, 10 + i) % 3), mix(COL.spark, PT_DARK, 0.25), true, k % 3 === 0 ? 2 : 1)
533 }
534 } else L.celebAt = -1
535 // 发出消息那一跳落地: 两只脚边冒 5 粒, 往两边散
536 if (pose === 'jump' && a.jumpAge === JUMP_FRAMES - 1) {
537 const lx = (L.bx - 1) * 2 + 1
538 const rx = (L.bx + L.cw) * 2
539 addPt(L, lx, H - 1, -1, -1, 5, DUST)
540 addPt(L, lx, H - 2, -1, 0, 4, DUST, false, 2)
541 addPt(L, rx, H - 1, 1, -1, 5, DUST)
542 addPt(L, rx, H - 2, 1, 0, 4, DUST, false, 2)
543 addPt(L, rx + 1, H - 1, 1, -1, 6, DUST, false, 2)
544 }
545 // 睡着: 头边慢慢冒泡泡 (每 12 帧一个), 往上飘进天空行
546 if (pose === 'sleep' && f % 12 === 0) {
547 const x = L.bx + L.bw < L.w ? (L.bx + L.bw) * 2 : (L.bx - 1) * 2 + 1
548 addPt(L, x, Math.min(H - 1, 5), 0, -1, 5, mix(COL.bubble, PT_DARK, 0.3), true)
549 }
550 // 在想 / 在回复 (主会话在跑但没有工具): 头顶冒思考点点, 往上飘进天空行 (没有天空行就不冒, 头顶就是格子边)
551 if (pose === 'walk' && a.working && !a.tool && L.sky && f % 5 === 0) {
552 const head = L.bx + Math.floor(L.cw / 2) + (Math.floor(f / 5) % 3) - 1
553 addPt(L, head * 2 + (Math.floor(f / 5) % 2), -1, 0, -1, 4 + (rnd(f, 30) % 2), mix(COL.thought, PT_DARK, 0.2), true, 2)
554 }
555 // 小螃蟹离场: 出口处冒一小团 (5 粒, 往四周散)
556 for (const k of exits) {
557 const cx = L.rows === 3 ? (k.x + 4) * 2 : k.dir < 0 ? 1 : L.w * 2 - 2
558 const cy = L.rows === 3 ? 0 : 1
559 addPt(L, cx, cy, -1, 0, 4, DUST)
560 addPt(L, cx + 1, cy, 1, 0, 4, DUST)
561 addPt(L, cx, cy + 1, 0, 1, 4, DUST, false, 2)
562 addPt(L, cx + 1, cy + 1, 1, 1, 5, DUST, false, 2)
563 addPt(L, cx - 1, cy + 1, -1, 1, 5, DUST)
564 }
565}
566
567// 走一帧 (只是算位置和粒子, 不碰 $); 返回这一帧画面有没有变化
568export function laneStep(L: Lane, f: number, now: number, a: LaneAct, running: string[]): boolean {
569 let changed = laneSync(L, now, running)
570 if (L.kids.some(k => k.state === 'wave')) changed = true // 挥手的钳子在动
571 // 3 行版往上跳出去的: 每帧升 1 像素, 整只出了顶边才让出队里的位置
572 const exits: LaneKid[] = []
573 for (const k of L.kids) {
574 if (k.state !== 'hop') continue
575 k.y -= 1
576 changed = true
577 if (k.y + 4 <= 0) exits.push(k)
578 }
579 L.kids = L.kids.filter(k => !exits.includes(k))
580 // 2 行版走上面那行出去的
581 for (const k of L.gone) {
582 k.x += k.dir
583 changed = true
584 if (k.x + L.kw <= 0 || k.x >= L.w) exits.push(k)
585 }
586 L.gone = L.gone.filter(k => !exits.includes(k))
587 // 溜达的排程: 只在全闲时 (不忙、不打字、不跳、不庆祝、没睡) 才排; 一忙起来就清掉
588 const busy = a.working || a.agents > 0
589 const calm = !busy && !a.hold && !a.typing && !a.celebrating && !a.sleeping && !(a.jumpAge >= 0 && a.jumpAge < JUMP_FRAMES)
590 if (!calm) {
591 L.strollLeft = 0
592 L.strollAt = -1
593 } else {
594 if (L.strollAt < 0) L.strollAt = f + STROLL_GAP[0] + (rnd(f, 20) % (STROLL_GAP[1] - STROLL_GAP[0]))
595 if (L.strollLeft === 0 && f >= L.strollAt) {
596 L.strollLeft = 3 + (rnd(f, 21) % 6)
597 L.dir = rnd(f, 22) % 2 ? 1 : -1
598 L.strollAt = Number.MAX_SAFE_INTEGER // 走完再排下一次
599 }
600 }
601 const pose = lanePose(L, a)
602 if (pose !== L.pose) {
603 L.pose = pose
604 changed = true
605 }
606 if (pose === 'idle' && f < L.lookUntil) changed = true // 东张西望
607 const m = L.kids.length
608 const lo = L.hidden ? LANE_L : 0
609 const slot = L.kw + 1
610 let dBig: -1 | 0 | 1 = 0
611 if (pose === 'walk') {
612 const room = L.w - lo - L.bw - slot * m > 0
613 if (!room) {
614 // 没地方走: 这次溜达作罢, 过一阵再试 (不然会一直摆着走路的姿势)
615 if (L.strollLeft) L.strollAt = f + STROLL_GAP[0]
616 L.strollLeft = 0
617 } else if (f - L.stepAt >= (busy ? MOOD_STEP[a.md] : STROLL_STEP)) {
618 if (L.dir > 0 && L.bx + L.bw >= L.w) L.dir = -1
619 else if (L.dir < 0 && L.bx - slot * m <= lo) L.dir = 1
620 dBig = L.dir
621 }
622 }
623 // 一起挪: 往右的从最右一只开始依次挪, 往左的从最左一只开始依次挪, 前面被挡住就这一帧不动
624 // 这样任何时候两只之间至少空 1 格, 不会重叠, 也不会贴住
625 const base = L.bx + dBig - slot // 第 0 只小螃蟹的目标位置, 第 i 只再往左 i 个 slot
626 const xs = [L.bx, ...L.kids.map(k => k.x)]
627 const want = [dBig, ...L.kids.map((k, i) => (k.state === 'hop' ? 0 : sgn(base - slot * i - k.x)))]
628 for (let j = 0; j < xs.length; j++) {
629 if (want[j] !== 1) continue
630 const limit = j === 0 ? L.w - L.bw : xs[j - 1] - slot
631 if (xs[j] + 1 <= limit) xs[j] += 1
632 }
633 for (let j = xs.length - 1; j >= 0; j--) {
634 if (want[j] !== -1) continue
635 const limit = j === xs.length - 1 ? (j === 0 ? 0 : -Infinity) : xs[j + 1] + slot
636 if (xs[j] - 1 >= limit) xs[j] -= 1
637 }
638 const stepped = xs[0] !== L.bx
639 if (stepped) {
640 L.bx = xs[0]
641 L.stepAt = f
642 L.steps += 1
643 changed = true
644 if (!busy && L.strollLeft > 0) {
645 L.strollLeft -= 1
646 if (L.strollLeft === 0) {
647 L.lookUntil = f + LOOK_FRAMES
648 L.strollAt = f + STROLL_GAP[0] + (rnd(f, 23) % (STROLL_GAP[1] - STROLL_GAP[0]))
649 }
650 }
651 }
652 L.kids.forEach((k, i) => {
653 if (xs[i + 1] !== k.x) {
654 k.x = xs[i + 1]
655 changed = true
656 }
657 })
658 laneParticles(L, f, a, pose, exits)
659 if (L.pt.length) changed = true
660 return changed
661}
662
663// 2 行版大螃蟹的字符画: B = 身体 (钳子、腿同色), 眼睛另外点 (也是 3 行版小螃蟹的精灵)
664// 3 行版的小螃蟹 (7x4): 眼睛在第 1 行的 x=2 / x=4, 看前面时整体挪 1 格 (x 1-5), 四周都还是身体; 挥手 = 一边的钳子尖翘起来
665export const KID3 = {
666 walk: ['.BBBBB.', 'BBBBBBB', '.BBBBB.'],
667 legs: ['.B.B.B.', '..B.B..'],
668 waveA: ['BBBBBB.', 'BBBBBBB', '.BBBBB.'],
669 waveB: ['.BBBBBB', 'BBBBBBB', '.BBBBB.'],
670}
671export const BIG2 = {
672 walk: ['..BBBBB..', 'B.BBBBB.B', 'BBBBBBBBB'],
673 legs: ['..B.B.B..', '...B.B...'],
674 rest: ['.........', '..BBBBB..', '..BBBBB..', 'BBBBBBBBB'], // 趴着: 身体放低, 腿收起, 钳子平放在地上
675 jump: ['B.BBBBB.B', 'B.BBBBB.B', '..BBBBB..'], // 举钳
676 jumpLegs: ['..B...B..', '.........'], // 落地 / 腾空
677 hi: ['..BBBBB.B', 'B.BBBBB.B', 'BBBBBBB..', '..B.B.B..'], // 打招呼: 右钳举起
678}
679
680// 大螃蟹在散步道里的处境 -> 面板螃蟹 (scenePx / miniPx) 认的 Scene; 面板那套动作、道具、情绪原样拿来用
681export function laneScene(L: Lane, f: number, a: LaneAct, pct: number): Scene {
682 const base: Scene = { working: false, kind: 'think', pct, celebrating: false, sleeping: false, agents: 0, mood: a.md }
683 switch (L.pose) {
684 case 'celebrate':
685 return { ...base, celebrating: true }
686 case 'jump':
687 return { ...base, hop: a.jumpAge }
688 case 'type':
689 return { ...base, low: true }
690 case 'tool':
691 // 停下来原地做这个工具的动作, 道具画在右边 3 列 (和面板一样)
692 return { ...base, working: true, kind: a.tool || 'other' }
693 case 'walk':
694 // 走路: 腿交替、上下颠, 眼睛看走的方向; 不画道具 ("派子代理" 那种空手的样子)
695 return { ...base, working: true, kind: 'agent', look: L.dir }
696 case 'sleep':
697 return { ...base, sleeping: true }
698 default:
699 // 闲着: 眨眼 / 张望 / 偶尔挥手 / 悠闲戴墨镜 / 慌张举钳 "!" 都是面板那套; 溜达完东张西望
700 // 指针在横栏别处时眼睛看过去
701 if (a.lookAt !== undefined) return { ...base, look: a.lookAt < L.bx + L.cw / 2 - 1 ? -1 : a.lookAt > L.bx + L.cw / 2 ? 1 : 0 }
702 return { ...base, look: f < L.lookUntil ? (Math.floor((L.lookUntil - f) / 6) % 2 ? -1 : 1) : undefined }
703 }
704}
705
706// 画大螃蟹 (不含粒子)
707// 3 行版: 面板那只 (scenePx 15x6: 螃蟹 12 + 道具区 3), 逐像素拷到 bx; greet = 悬停时的举钳打招呼 (12x6, 静止的一张)
708// 2 行版: BIG2 字符画 (用工具时站着, 两只钳子轮流敲); 1 行版: 面板精简版那只 (miniPx 8x2, 带墨镜 / 汗滴 / 慌张眼睛)
709export function drawLaneBig(p: Px, L: Lane, f: number, a: LaneAct, pct: number, greetOnly = false, ox = L.bx) {
710 const greet = greetOnly || L.pose === 'greet'
711 const body = bodyColor(pct, f)
712 const blit = (src: Px, x0: number) => src.forEach((row, y) => row.forEach((c, x) => c >= 0 && put(p, x0 + x, y, c)))
713 if (L.rows === 3) {
714 if (greet) drawCrab(p, { bob: 0, legs: 0, eyes: 'open', look: 0, armL: 'out', armR: 'up', body }, ox)
715 else blit(scenePx(laneScene(L, f, a, pct), f), ox)
716 return
717 }
718 if (L.rows === 1) {
719 blit(miniPx(greet ? { working: false, kind: 'think', pct, celebrating: false, sleeping: false, agents: 0, mood: 'normal' } : laneScene(L, f, a, pct), f), ox + 1)
720 return
721 }
722 const bc = { B: body }
723 const pose = greet ? 'hi' : L.pose
724 const shut = (pose === 'sleep' || ((pose === 'idle' || pose === 'type') && f % 30 === 0)) && !greet
725 const eye = shut ? mix(body, COL.eye, 0.45) : COL.eye
726 const look = pose === 'walk' ? L.dir : pose === 'tool' ? 1 : pose === 'idle' && f < L.lookUntil ? (Math.floor((L.lookUntil - f) / 6) % 2 ? -1 : 1) : 0
727 const legStep = Math.floor(f / (a.md === 'panic' ? 2 : 3)) % 2
728 const hop = Math.floor(f / 3) % 2
729 let ey = 1
730 if (pose === 'hi') paint(p, ox, 0, BIG2.hi, bc)
731 else if (pose === 'walk') paint(p, ox, 0, [...BIG2.walk, BIG2.legs[legStep]], bc)
732 else if (pose === 'tool') paint(p, ox, 0, [...(hop ? BIG2.hi : BIG2.walk).slice(0, 3), BIG2.legs[0]], bc) // 站着, 右钳一起一落
733 else if (pose === 'celebrate') paint(p, ox, 0, [...BIG2.jump, BIG2.jumpLegs[hop]], bc)
734 else if (pose === 'jump') paint(p, ox, 0, [...BIG2.jump, BIG2.jumpLegs[a.jumpAge === 1 || a.jumpAge === 2 ? 1 : 0]], bc)
735 else {
736 paint(p, ox, 0, BIG2.rest, bc) // 趴着 / 睡着 / 打字时低头
737 ey = 2
738 }
739 put(p, ox + 3 + look, ey, eye)
740 put(p, ox + 5 + look, ey, eye)
741}
742
743// 螃蟹区的像素 (不含天空行)
744export function lanePx(L: Lane, f: number, a: LaneAct, pct: number): Px {
745 const rows = L.rows
746 const p = canvas(L.w, rows * 2)
747 const kc = { C: COL.kid, L: COL.kidLeg }
748 const kstep = Math.floor(f / 3) % 2
749 // 大螃蟹停下来用工具时, 小螃蟹也停下, 原地慢慢跳 (每 5 帧起落一次, 跳的时候腿不动)
750 const bounce = L.pose === 'tool' ? Math.floor(f / 5) % 2 : 0
751 // 2 行版离场的小螃蟹画在最底下 (从别的螃蟹背后过); 1 行版没有上面那行, 挥完手直接消失
752 if (rows === 2) for (const k of L.gone) paint(p, k.x, 0, ['CCC', Math.floor(f / 2) % 2 ? '.L.' : 'L.L'], kc)
753 for (const k of L.kids) {
754 const hopNow = k.state === 'walk' ? bounce : 0
755 if (rows === 3) {
756 // 7x4 的小螃蟹 (浅一号的颜色, 腿深一号), 眼睛看走的方向 (只在身体里挪); 挥手 = 两边的钳子尖轮流翘起
757 const y = (k.state === 'hop' ? k.y : 2) - hopNow
758 const arms = k.state === 'wave' ? (kstep ? KID3.waveA : KID3.waveB) : KID3.walk
759 paint(p, k.x, y, arms, { B: COL.kid })
760 paint(p, k.x, y + 3, [k.state === 'hop' ? '.......' : KID3.legs[L.pose === 'tool' || L.pose === 'greet' ? 0 : kstep]], { B: COL.kidLeg })
761 put(p, k.x + 2 + L.dir, y + 1, COL.eye)
762 put(p, k.x + 4 + L.dir, y + 1, COL.eye)
763 } else if (k.state === 'wave') {
764 // 挥手: 2 行版两只钳子轮流举起; 1 行版原地小跳
765 if (rows === 2) paint(p, k.x, 1, [kstep ? 'C..' : '..C', 'CCC', 'L.L'], kc)
766 else paint(p, k.x, 0, ['CCC', kstep ? '...' : 'L.L'], kc)
767 } else if (rows === 2) paint(p, k.x, 2 - hopNow, ['CCC', L.pose === 'tool' ? 'L.L' : kstep ? '.L.' : 'L.L'], kc)
768 else paint(p, k.x, 0, ['CCC', L.pose === 'tool' ? 'L.L' : kstep ? '.L.' : 'L.L'], kc)
769 }
770 drawLaneBig(p, L, f, a, pct)
771 return p
772}
773
774// 盲文点: (列, 行) -> 位
775const BRAILLE = [
776 [0x01, 0x02, 0x04, 0x40],
777 [0x08, 0x10, 0x20, 0x80],
778]
779// 编码: 有像素的格子照常用半格方块; 上下两半都空的格子才放粒子 (同一格几个点合成一个盲文字符, 颜色取最年轻的)
780// px 已经含天空行 (sky 时最上面 2 像素是空的); 粒子的 y 从螃蟹区顶上算, 所以往下挪 yOff 个点
781export function laneCells(px: Px, cols: number, rows: number, pts: Particle[], yOff = 0): number[] {
782 const dots = new Map<number, { bits: number; age: number; color: number }>()
783 for (const p of pts) {
784 const y = p.y + yOff
785 const cx = p.x >> 1
786 const r = y >> 2
787 if (y < 0 || cx >= cols || r >= rows) continue
788 const id = r * cols + cx
789 const d = dots.get(id) ?? { bits: 0, age: Infinity, color: 0 }
790 d.bits |= BRAILLE[p.x & 1][y & 3]
791 if (p.age / p.life < d.age) {
792 d.age = p.age / p.life
793 d.color = mix(p.color, PT_DARK, d.age * 0.6) // 只会慢慢变暗
794 }
795 dots.set(id, d)
796 }
797 const words: number[] = []
798 for (let r = 0; r < rows; r++) {
799 for (let c = 0; c < cols; c++) {
800 const top = px[r * 2]?.[c] ?? -1
801 const bot = px[r * 2 + 1]?.[c] ?? -1
802 const d = dots.get(r * cols + c)
803 if (top < 0 && bot < 0) {
804 if (d && d.bits) words.push(0x2800 + d.bits, d.color, DEF)
805 else words.push(32, DEF, DEF)
806 } else if (bot < 0) words.push(0x2580, top, DEF)
807 else if (top < 0) words.push(0x2584, bot, DEF)
808 else words.push(0x2580, top, bot)
809 }
810 }
811 return words
812}
813// 同上, 打包成 Raster 那种 base64 (测试和预览用)
814export function encodeLane(px: Px, cols: number, rows: number, pts: Particle[], yOff = 0): string {
815 return new Uint8Array(Uint32Array.from(laneCells(px, cols, rows, pts, yOff)).buffer).toBase64()
816}
817
818// 悬停气泡的内容挑法: 从长到短 [全文, 省掉时间, 只有摘要, 只有摘要且不带时间]; 放不下时先省掉时间,
819// 再把小贴士截短 (至少留得下 "小贴士 /hud" 和一个字, 见 tipMin), 再放不下才只留摘要 (截短)
820export function tipFit(v: string[], tipMin: number): (room: number) => string {
821 const [full, short, sumT, sum] = v
822 return room => (dw(full) <= room ? full : dw(short) <= room ? short : room >= tipMin ? clip(short, room) : dw(sumT) <= room ? sumT : clip(sum, room))
823}
824
825// 气泡放在大螃蟹旁边: 右边地方大就放右边, 否则放左边; 只放第 0 行 (2/3 行版的小螃蟹在下面); 放不下就截短, 绝不压到螃蟹
826// text: 一句话 (放不下就截短), 或者 "给定宽度, 返回放得下的写法" 的函数 (悬停气泡用)
827export function bubbleSpot(L: Lane, text: string | ((room: number) => string)): { x: number; text: string } | undefined {
828 const fit = typeof text === 'string' ? (room: number) => clip(text, room) : text
829 const rightX = L.bx + L.bw + 1
830 const rightRoom = L.w - rightX
831 const leftRoom = L.bx - 1
832 const useRight = rightRoom >= dw(fit(Infinity)) || rightRoom >= leftRoom
833 const room = useRight ? rightRoom : leftRoom
834 if (room < 4) return undefined
835 const t = fit(room)
836 return { x: useRight ? rightX : L.bx - 1 - dw(t), text: t }
837}
838
839// 测试用: 用一条全新的散步道跑 N 帧 (不碰会话里那条), 给出每帧的像素、粒子和位置
840type LanePreviewOpts = {
841 w?: number
842 rows?: Rows
843 sky?: boolean
844 frames?: number
845 working?: boolean | ((f: number) => boolean)
846 mood?: Mood
847 running?: (f: number) => string[]
848 typing?: (f: number) => boolean
849 jumpAt?: number
850 celebrating?: boolean | ((f: number) => boolean)
851 sleeping?: boolean
852 pct?: number
853 tool?: ToolKind | '' | ((f: number) => ToolKind | '')
854 hold?: (f: number) => boolean
855}
856export function previewLane(o: LanePreviewOpts = {}) {
857 const rows = o.rows ?? 3
858 const sky = !!o.sky
859 const L = newLane(o.w ?? 80, rows, sky)
860 const out: Array<{ px: Px; cells: string; pt: Particle[]; pose: LanePose; bx: number; dir: number; kids: Array<{ id: string; x: number; y: number; state: string }>; gone: Array<{ id: string; x: number }>; hidden: number }> = []
861 const at = (v: boolean | ((f: number) => boolean) | undefined, f: number, d: boolean) => (typeof v === 'function' ? v(f) : (v ?? d))
862 for (let f = 0; f < (o.frames ?? 60); f++) {
863 const running = o.running ? o.running(f) : []
864 const a: LaneAct = {
865 working: at(o.working, f, true),
866 agents: running.length,
867 typing: o.typing ? o.typing(f) : false,
868 celebrating: at(o.celebrating, f, false),
869 sleeping: !!o.sleeping,
870 jumpAge: o.jumpAt === undefined ? -999 : f - o.jumpAt,
871 md: o.mood ?? 'normal',
872 tool: typeof o.tool === 'function' ? o.tool(f) : (o.tool ?? ''),
873 hold: o.hold ? o.hold(f) : false,
874 }
875 laneStep(L, f, f * FRAME_MS, a, running)
876 // px 含天空行 (和真的横栏一样)
877 const px = [...(sky ? [new Array(L.w).fill(-1), new Array(L.w).fill(-1)] : []), ...lanePx(L, f, a, o.pct ?? 30)]
878 out.push({
879 px,
880 cells: encodeLane(px, L.w, rows + (sky ? 1 : 0), L.pt, sky ? 4 : 0),
881 pt: L.pt.map(p => ({ ...p })),
882 pose: L.pose,
883 bx: L.bx,
884 dir: L.dir,
885 kids: L.kids.map(k => ({ id: k.id, x: k.x, y: k.y, state: k.state })),
886 gone: L.gone.map(k => ({ id: k.id, x: k.x })),
887 hidden: L.hidden,
888 })
889 }
890 return {
891 frames: out,
892 colors: { body: COL.body, eye: COL.eye, kid: COL.kid, kidLeg: COL.kidLeg, spark: COL.spark, thought: COL.thought, paper: COL.paper, term: COL.term, sea: COL.sea, scan: COL.scan, cursor: COL.cursor },
893 bw: L.bw,
894 cw: L.cw,
895 kw: L.kw,
896 cap: laneCap(L),
897 max: PT_MAX,
898 dust: DUST,
899 }
900}
901hooks/walkway.tsx 279 lines1// usage-hud: 螃蟹散步道 (v0.16 起是一个 Client 模块, 跑在终端的绘制线程上)
2//
3// hooks (register.tsx) 只把处境当 props 传进来: 忙不忙、心情、当前工具、上下文 %、运行中的子代理、事件气泡、
4// 打字 / 发送 / 庆祝的序号、多久没动静了、悬停时的用量摘要和小贴士; 以及宽度、行数、有没有天空行
5// 模块自己用帧钟 (surface.every, 150ms 一帧) 推进: 走路、排队、粒子、气泡几秒后消失、5 分钟睡着、工具刚结束再撑 4 帧
6// (v0.16.3: 工具按序号认, 两帧之间就用完的工具也做满 4 帧; v0.16.4: 收到新序号的那一刻就画, 不等下一帧,
7// 从那一刻起至少 600ms: 先画的这一下 + 之后 4 帧)
8// 鼠标停在大螃蟹上 (只有全屏模式有指针事件): 大螃蟹立刻停下举钳, 队伍也停; 气泡在停下那一刻定好位置和全文;
9// 指针离开约 0.5 秒后接着走; 指针在横栏别处时, 闲着的螃蟹眼睛看过去. 点击不做任何事 (点一下 Client 会拿走键盘焦点)
10// 终端的 Client 里没有 Raster: 半格像素画成 Text (上像素当 color, 下像素当 backgroundColor), 同色的连续格子合成一段
11// 像素本身 (大螃蟹 / 小螃蟹 / 粒子) 和以前一样由 sprites.ts 生成
12
13import {
14 FRAME_MS,
15 SAY_FRAMES,
16 TYPE_FRAMES,
17 TOOL_HOLD_FRAMES,
18 DEF,
19 DIM,
20 VALUE,
21 newLane,
22 laneFit,
23 laneStep,
24 lanePose,
25 lanePx,
26 laneCells,
27 bubbleSpot,
28 tipFit,
29 isWide,
30 type Lane,
31 type LaneAct,
32 type Mood,
33 type ToolKind,
34 type Rows,
35} from './sprites'
36
37export type WalkProps = {
38 w: number // 宽度 (格)
39 rows: Rows // 螃蟹区几行 (不含天空行)
40 sky: boolean // 最上面有没有天空行
41 working: boolean // 主会话一轮在跑
42 agents: string[] // 运行中的子代理 (按开始时间)
43 mood: Mood
44 pct: number // 上下文 %
45 tool: ToolKind | '' // 主会话正在用的工具 ('' = 没有: 在想 / 在回复)
46 toolSeq: number // 主会话用工具的序号 (变了 = 刚用过一次工具, 哪怕已经用完了)
47 toolLast: ToolKind | '' // 最近一次工具的种类
48 typeSeq: number // 打字的序号 (变了 = 刚按了键)
49 jumpSeq: number // 发出消息的序号
50 celebSeq: number // 一轮结束的序号
51 celebFrames: number // 这次庆祝多少帧
52 say?: { seq: number; text: string; color: string } // 最近一条事件气泡
53 idleMs: number // 多久没动静了 (hooks 记的)
54 tip: { v: string[]; tipMin: number } // 悬停气泡: 从长到短 4 种写法 + 小贴士最少要留的宽度
55}
56
57type St = {
58 L: Lane
59 f: number // 帧号
60 typeSeq: number
61 typeUntil: number // 打字低头到哪一帧
62 jumpSeq: number
63 jumpAt: number // 跳的那一帧
64 celebSeq: number
65 celebUntil: number
66 saySeq: number
67 sayUntil: number // 事件气泡显示到哪一帧
68 lastBusyF: number // 最后一次有动静的帧 (5 分钟 = 2000 帧没动静就睡)
69 tool: ToolKind | ''
70 toolUntil: number // 工具刚结束时再撑到哪一帧
71 toolSeq: number
72 hover: boolean // 指针正停在大螃蟹上
73 holdUntil: number // 指针离开后再停到哪一帧
74 spot?: { x: number; text: string } // 悬停气泡 (停下那一刻定好的位置和全文)
75 ptrX?: number // 指针在横栏里的列 (不在就 undefined)
76 sig: string // 上一次画出来的样子 (没变就不重画)
77}
78
79const SLEEP_FRAMES = Math.round((5 * 60_000) / FRAME_MS)
80const HOLD_FRAMES = 3 // 指针离开后约 0.5 秒接着走
81const live = new WeakMap<object, WalkProps>() // 帧钟回调里拿最新的 props (回调是第一次画的时候建的)
82
83const hexOf = (c: number) => (c === DEF || c < 0 ? undefined : '#' + c.toString(16).padStart(6, '0'))
84
85function actOf(st: St, p: WalkProps): LaneAct {
86 const hold = st.hover || st.f < st.holdUntil
87 return {
88 working: p.working,
89 agents: p.agents.length,
90 typing: st.f < st.typeUntil,
91 celebrating: st.f < st.celebUntil,
92 sleeping: st.f - st.lastBusyF > SLEEP_FRAMES,
93 jumpAge: st.f - st.jumpAt,
94 md: p.mood,
95 tool: p.tool || (st.f < st.toolUntil ? st.tool : ''),
96 hold,
97 lookAt: hold ? undefined : st.ptrX,
98 }
99}
100
101type Cell = { ch: string; fg?: string; bg?: string }
102// 这一帧的样子: 每行一串格子 (含天空行、事件气泡、+N、悬停气泡)
103function view(st: St, p: WalkProps): Cell[][] {
104 const L = st.L
105 const a = actOf(st, p)
106 L.pose = lanePose(L, a)
107 const top = L.sky ? 1 : 0
108 const total = L.rows + top
109 const px = [...(L.sky ? [new Array(L.w).fill(-1), new Array(L.w).fill(-1)] : []), ...lanePx(L, st.f, a, p.pct)]
110 const words = laneCells(px, L.w, total, L.pt, top * 4)
111 const grid: Cell[][] = []
112 for (let r = 0; r < total; r++) {
113 const row: Cell[] = []
114 for (let c = 0; c < L.w; c++) {
115 const i = (r * L.w + c) * 3
116 const cp = words[i]
117 row.push(cp === 32 ? { ch: ' ' } : { ch: String.fromCodePoint(cp), fg: hexOf(words[i + 1]), bg: hexOf(words[i + 2]) })
118 }
119 grid.push(row)
120 }
121 // 盖在像素上面的字: 放不下的子代理 +N (最下面一行左端) / 事件气泡 / 悬停气泡 (都在螃蟹区第一行, 不进天空行)
122 const write = (r: number, x0: number, text: string, color: string) => {
123 let x = x0
124 for (const ch of text) {
125 const w = isWide(ch.codePointAt(0) ?? 0) ? 2 : 1
126 if (x < 0 || x + w > L.w) break
127 grid[r][x] = { ch, fg: color }
128 if (w === 2) grid[r][x + 1] = { ch: '' }
129 x += w
130 }
131 }
132 if (L.hidden) write(total - 1, 0, '+' + L.hidden, DIM)
133 if (p.say && st.f < st.sayUntil && !(a.hold && st.spot)) {
134 const s = bubbleSpot(L, p.say.text)
135 if (s) write(top, s.x, s.text, p.say.color)
136 }
137 if (a.hold && st.spot) write(top, st.spot.x, st.spot.text, VALUE)
138 return grid
139}
140
141// 一行格子 -> 同色连续的几段 Text
142// (Client 的一棵树最多 20,000 个节点: 最坏每格一段, 512 宽 x 4 行也只有约 2,050 个, 离上限很远)
143function segments(row: Cell[]): Cell[] {
144 const out: Cell[] = []
145 for (const c of row) {
146 const last = out[out.length - 1]
147 if (c.ch === '') {
148 if (last) last.ch += ''
149 continue
150 }
151 if (last && last.fg === c.fg && last.bg === c.bg) last.ch += c.ch
152 else out.push({ ...c })
153 }
154 return out
155}
156
157// 新的工具序号: 从这一刻起画工具动作和道具, 到第 TOOL_HOLD_FRAMES + 1 帧为止 (这一下 + 之后 4 帧 >= 600ms);
158// 画的时候 (props 一到) 和帧钟里都调: 工具只跑几十毫秒时, 不用等下一帧才画, 也不会先闪一下再空一帧
159function latchTool(st: St, p: WalkProps) {
160 if (p.toolSeq === st.toolSeq) return
161 st.toolSeq = p.toolSeq
162 if (!p.toolLast) return
163 st.tool = p.toolLast
164 st.toolUntil = Math.max(st.toolUntil, st.f + TOOL_HOLD_FRAMES + 1)
165}
166
167function start(p: WalkProps): St {
168 return {
169 L: newLane(p.w, p.rows, p.sky),
170 f: 0,
171 typeSeq: p.typeSeq,
172 typeUntil: -1,
173 jumpSeq: p.jumpSeq,
174 jumpAt: -999,
175 celebSeq: p.celebSeq,
176 celebUntil: -1,
177 saySeq: p.say?.seq ?? 0,
178 sayUntil: -1,
179 lastBusyF: -Math.round(p.idleMs / FRAME_MS),
180 tool: '',
181 toolUntil: -1,
182 toolSeq: p.toolSeq,
183 hover: false,
184 holdUntil: -1,
185 sig: '',
186 }
187}
188
189export default function Walkway(props: WalkProps, surface: any) {
190 const { Box, Text } = surface.elements
191 live.set(surface, props)
192 if (surface.state === undefined) {
193 surface.setState(start(props))
194 surface.every(FRAME_MS, () => {
195 const st: St = surface.state
196 const p = live.get(surface)
197 if (!st || !p) return
198 st.f += 1
199 // 新事件 (按序号认): 打字 -> 低头 1.5 秒; 发送 -> 跳; 一轮结束 -> 庆祝; 新气泡 -> 显示约 5 秒
200 if (p.typeSeq !== st.typeSeq) {
201 st.typeSeq = p.typeSeq
202 st.typeUntil = st.f + TYPE_FRAMES
203 st.lastBusyF = st.f
204 }
205 if (p.jumpSeq !== st.jumpSeq) {
206 st.jumpSeq = p.jumpSeq
207 st.jumpAt = st.f
208 st.lastBusyF = st.f
209 }
210 if (p.celebSeq !== st.celebSeq) {
211 st.celebSeq = p.celebSeq
212 st.celebUntil = st.f + p.celebFrames
213 }
214 if (p.say && p.say.seq !== st.saySeq) {
215 st.saySeq = p.say.seq
216 st.sayUntil = st.f + SAY_FRAMES
217 }
218 if (p.working || p.agents.length) st.lastBusyF = st.f
219 // 工具: 序号变了就画 (通常在下面画的时候已经认过了); 还在用就一直续上
220 latchTool(st, p)
221 if (p.tool) {
222 st.tool = p.tool
223 st.toolUntil = Math.max(st.toolUntil, st.f + TOOL_HOLD_FRAMES)
224 }
225 if (!st.hover && st.f >= st.holdUntil) st.spot = undefined
226 laneFit(st.L, p.w, p.rows, p.sky)
227 laneStep(st.L, st.f, st.f * FRAME_MS, actOf(st, p), p.agents)
228 // 画面没变就不重画 (闲着、没有子代理、也没有气泡时, 只在眨眼之类的时候画)
229 const sig = JSON.stringify(view(st, p).map(segments))
230 if (sig !== st.sig) {
231 st.sig = sig
232 surface.setState({ ...st })
233 }
234 })
235 // 鼠标: 停在大螃蟹的格子上 -> 停下举钳 + 气泡; 离开 -> 约 0.5 秒后接着走; 按下 / 松开不理 (不做点击)
236 surface.onPointer((e: any) => {
237 const st: St = surface.state
238 const p = live.get(surface)
239 if (!st || !p || e.type === 'down' || e.type === 'up') return
240 const L = st.L
241 const top = L.sky ? 1 : 0
242 if (e.type === 'leave') {
243 if (st.hover) st.holdUntil = st.f + HOLD_FRAMES
244 st.hover = false
245 st.ptrX = undefined
246 } else {
247 const hit = e.x >= L.bx && e.x < L.bx + L.cw && e.y >= top && e.y < top + L.rows
248 if (hit && !st.hover) {
249 st.hover = true
250 L.pose = 'greet'
251 st.spot = bubbleSpot(L, tipFit(p.tip.v, p.tip.tipMin))
252 } else if (!hit && st.hover) {
253 st.hover = false
254 st.holdUntil = st.f + HOLD_FRAMES
255 }
256 st.ptrX = e.x
257 }
258 surface.setState({ ...st })
259 })
260 }
261 const st: St = surface.state ?? start(props)
262 latchTool(st, props) // props 一到就认新的工具序号, 这一下就画出道具
263 laneFit(st.L, props.w, props.rows, props.sky)
264 const rows = view(st, props).map(segments)
265 return (
266 <Box key="walk" flexDirection="column" width={props.w} height={rows.length}>
267 {rows.map((segs, r) => (
268 <Box key={'walk-r' + r} flexDirection="row" height={1}>
269 {segs.map(s => (
270 <Text color={s.fg} backgroundColor={s.bg}>
271 {s.ch}
272 </Text>
273 ))}
274 </Box>
275 ))}
276 </Box>
277 )
278}
279