A usage panel under the prompt (model, project, context, 5-hour and weekly quota with pace warnings) with a settings toolbar, English/Chinese UI and one-click…

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