SLOPSHOPPER

usage-hud

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

newpanebandspinnerguardcommand
★ 6v0.16.4MITupdated 2026-10-07ronanworks/claude-code-mods/usage-hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-hud
│ ┃ hud-agents ✕ › fix the failing auth test and add an audit log call │ ┃ 子代理 运行中 0 · 已结束 0 │ ┃ 这个会话还没有子代理,Esc 关闭 ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked… · 改 3 个文件 +14 -1 · 工具 9 次 │ │ › /hud │ ⎿ usage-hud: 用量面板:精简 │ │ ▣ client module ./walkway.tsx ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › 上下文 ━━━━━━━━ 17% 5小时 ━━━━━━━━ 11% 本周 ━━━━━━━━ -- Opus 5.5 ▁▂▃▅▇ -- ✓ 待命 token 0 ⟨Claude Code's own drawing⟩

Draws

Band
▣ client module ./walkway.tsx
Pane · hud-agents
子代理 运行中 0 · 已结束 0 这个会话还没有子代理,Esc 关闭
Prompt hint
上下文 ━━━━━━━━ 17% 5小时 ━━━━━━━━ 11% 本周 ━━━━━━━━ -- Opus 5.5 ▁▂▃▅▇ -- ✓ 待命 ⟨Claude Code's own drawing⟩
Turn line
✻ Worked… · 改 3 个文件 +14 -1 · 工具 9 次
README

claude-code-mods

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

License: MIT Claude Code Platform mods

<img src="assets/usage-hud.gif" alt="usage-hud: a pixel-crab usage panel under the prompt" width="900">

What is this

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:

ModWhat it does
usage-hudA 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-shelfFile 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.

Quick start

<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 fullscreen inside 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.

usage-hud: an animated usage panel

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 1Column 2Column 3
Row 1Model + effort levelProject + git branchSession time + cost
Row 2Context usage5-hour usageWeekly usage
Row 3StatusMost-used toolsTotal 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 isThe crab
Thinking or replyingWalks sideways, eyes on where it's going; thinking dots rise while it thinks
Reading or searchingStops and reads a page while a scan line moves
Writing or editing a fileStops and taps with its right claw as the page fills with text
Running a commandStops and types with both claws, terminal cursor blinking
On the webStops beside a spinning globe
Running subagentsEach 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 turnJumps with claws up, gold sparkles
Idle / idle for 5 minutesStrolls a few steps now and then, blinks and looks around / sleeps, blowing bubbles
You type / you sendStops and looks down at the input box / jumps
At ≥ 80% contextTurns 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):

QuotaThe crabThe panel
Using less than the clock (pace < 0.8)Wears sunglasses, strollsNormal
Clearly ahead of pace, on track to run out before the resetSweats, walks fasterThe percentage and the countdown turn red: 40m用完 (empty in 40m)
Runs out within 30 minutes, or ≥ 95% usedPanics with claws up and a "!", scurriesSame as above
  • Small particles keep it lively: dust behind its feet, a drop of sweat when it hurries, gold sparkles when a turn finishes, bubbles while it sleeps.
  • Speech bubbles appear beside the crab for a few seconds: a finished turn (「搞定 3m12s」, done), a finished compaction, a quota reset, a red pace warning (「慢点!40m用完」, slow down, empty in 40m), and a permission prompt waiting for you (「等你点头」).
  • In fullscreen mode (/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.
  • The row between the strip and the input box belongs to Claude Code itself (notices such as "copied 4 chars to clipboard" appear there), so the strip can't sit any lower.
  • The engine draws [-] 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

  • Turn receipt: the line that ends each turn gets a tail such as · $0.42 · 2 files changed +5 -1 · 3 tool calls (terminal only).
  • One-click compact: at 75% context a "compact" button appears in the context cell and runs /compact.
  • Quota-reset reminder: after the 5-hour or weekly quota passed 30% and then resets, a toast says it's back.
  • Subagent board: /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.

CommandWhat it does
/hudCycles full → compact (one line) → hidden
/hud agentsOpens the subagent board; Esc closes it
/hud crab, /hud crab on, /hud crab offToggles 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" is the API list-price equivalent, the same figure /cost shows. Subscribers aren't charged this amount.
  • Tokens come from the session transcript, subagents included. Cache reads count too, so the number is usually large.

html-shelf: clickable links and code cards

<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">

  • File paths become links: paths in a reply (bare paths, ` code , or text`) open in their default app with one click. Only files that exist become links.
  • Supported: HTML, Markdown, PDF, images (png / jpg / gif / svg …), audio and video (mp4 / mov / mp3 …), Office files (docx / xlsx / pptx), csv, txt.
  • Folders open in your file manager.
  • Scripts and executables (bat, ps1, sh, py, exe …) are never run; a click only selects them in their folder.
  • "Open" on tool rows: when Claude writes a file, its 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.
  • Code cards: each code block is drawn as a card, with the language on the left of the title bar and buttons on the right.
  • "Copy": copies the exact text with no trailing newline, so pasting into a shell doesn't run it.
  • "Insert": single-line shell commands get this extra button, including an untagged one-line command such as one that starts with !. 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.
  • Commands written inline in the text (inside backticks, such as ` 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.
  • Hover the card and the title bar lights up while the buttons turn orange; after a click you see "Copied ✓".
  • Card colors come from Claude Code's current theme, so they suit both dark and light themes.
  • Copy the whole reply: hover a reply and "Copy all" appears at its top right. It copies that block's text with common indentation removed.
  • Code cards, the buttons and non-HTML links appear in the terminal only; the desktop app already has its own.

Requirements

ItemNotes
Claude CodeTerminal 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)
OSWindows: 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 filesWindows: explorer.exe / cmd start; macOS: open; Linux: xdg-open, then gio open; in WSL, Windows' default apps first (wslview, else explorer.exe via wslpath)
RenderingClicking and hovering need fullscreen rendering (/tui fullscreen)
DependenciesNode.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
  • In the macOS desktop app, Node.js installed with Homebrew or nvm may not be on the app's PATH. The token cell then falls back to counting from this launch only; everything else works.
  • If you set CLAUDE_CONFIG_DIR, usage-hud looks for session transcripts there.

Uninstall

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.

Development

Check and test a mod:

claude plugin validate ./usage-hud
claude plugin test ./usage-hud
  • Terminal sessions watch the mod folders and reload on save. Desktop app sessions don't; run /reload-plugins there.
  • Bump 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.
  • In the terminal, use only ASCII, CJK characters, │ ─ ━ ✓ 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

License

MIT © 2026 Ronan

Source 4 files
hooks/register.tsx 2454 lines
1import 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 !== undefined
hooks/desktop.ts 381 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
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}
381
hooks/sprites.ts 901 lines
1// 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}
901
hooks/walkway.tsx 279 lines
1// 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