SLOPSHOPPER

cc-hud

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…

newpanebandspinnerguardcommand
v1.3.1MITupdated 2026-10-08kimyong6366/cc-hud/cc-hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-hud
│ ┃ hud-agents ✕ › fix the failing auth test and add an audit log call │ ┃ Subagents running 0 · ended 0 │ ┃ No subagents in this session yet. Esc to clo ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked… · 3 files changed +14 -1 · 9 tool calls │ │ › /hud │ ⎿ cc-hud: Usage panel: compact │ │ ▣ client module ./walkway.tsx ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › [settings] [handoff] Context ━━━━━━━━ 17% 5h ━━━━━━━━ 11% Week ━━━━━━━━ -- Opus 5.5 ▁▂▃▅▇ -- ✓ Ready Tokens 0 ⟨Claude Code's own drawing⟩

Draws

Band
▣ client module ./walkway.tsx
Pane · hud-agents
Subagents running 0 · ended 0 No subagents in this session yet. Esc to close
Pane · hud-handoffs
No handoffs for this project yet. Press [handoff] or type /hud handoff to write one
Prompt hint
[settings] [handoff] Context ━━━━━━━━ 17% 5h ━━━━━━━━ 11% Week ━━━━━━━━ -- Opus 5.5 ▁▂▃▅▇ ⟨Claude Code's own drawing⟩
Turn line
✻ Worked… · 3 files changed +14 -1 · 9 tool calls
README

cc-hud

English | 简体中文

See your Claude Code usage at a glance, hand off long sessions in one click, and keep a pixel crab company

License: MIT Claude Code Platform mods

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

What is this

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:

  • A usage panel under the prompt. It shows:
  • model and effort, project and git branch, session time and cost
  • context, 5-hour and weekly quota bars, with a time tick and an "out in 40m" warning when you're burning quota too fast
  • what Claude is doing right now, your most-used tools, and total tokens
  • One-click handoff prompts. When a session gets long, click [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.
  • A subagent board. It shows every running subagent, workflow agents included, and what each one is doing right now (Bash npm test, Read src/app.ts, …), grouped by workflow.
  • A pixel crab. It walks above the prompt and acts out what Claude is doing: reading, typing, running commands, browsing. Your subagents walk behind it as baby crabs. It sweats or panics as your quota runs low, and celebrates when a turn is done. It moves in half-cell steps at 13 frames a second, with in-between poses, so it looks smooth even in a terminal.
  • English and Chinese UI, switchable from the toolbar or with /hud lang.

Quick start

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.

How to use

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 toClick (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 conversationcompact in the Context cell (shows up at 75%)/compact
Change model or effortthe model name or the effort word/model, /effort
See the quota detailsthe 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.

cc-hud: an animated usage panel

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 1Column 2Column 3
Row 1Model + effort levelProject + git branchSession time + cost
Row 2Context usage5-hour usageWeekly usage
Row 3StatusMost-used toolsTotal tokens + output (out)

The 5-hour and weekly bars each carry a bright │ tick that marks how much of the window has passed, and a short countdown to the reset follows the bar, such as 1h54m. When the colored part runs past the tick, you are using quota faster than the clock.

The crab above the prompt (terminal only) lives in a strip right above the input box: one blank row that keeps it apart from the conversation, then three rows of crab. It follows what Claude is doing:

Claude isThe crab
Thinking or replyingWalks sideways, eyes on where it's going; thinking dots rise while it thinks
Reading or searchingStops and reads a page while a scan line moves
Writing or editing a fileStops and taps with its right claw as the page fills with text
Running a commandStops and types with both claws, terminal cursor blinking
On the webStops beside a spinning globe
Running subagentsEach running subagent, 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 turnHops twice with claws up, really leaving the ground, then keeps its claws up; gold sparkles
Idle / idle for 5 minutesStrolls a few steps now and then, blinks and looks around / sleeps, blowing bubbles
You type / you sendStops and looks down at the input box / crouches, jumps up into the blank row above, lands with a squash and a puff of dust
At ≥ 80% contextFades 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:

  • Claws pass through a half-raised pose when tapping, typing, waving or greeting you.
  • Blinks go half-closed first.
  • Walking eases in when it starts and eases out at the end of a stroll.
  • At either end of the strip the crab pauses with its eyes already turned before walking back.
  • Sleep breathing is slow and even.
  • A finished subagent waves through a neutral pose and hops out along a little arc.

The crab also has moods that follow your quota pace (pace = actual usage ÷ the usage expected for the time elapsed):

QuotaThe crabThe panel
Using less than the clock (pace < 0.8)Wears sunglasses, strollsNormal
Clearly ahead of pace, on track to run out before the resetSweats, walks fasterThe percentage and the countdown turn red: out 40m
Runs out within 30 minutes, or ≥ 95% usedPanics: 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 walkingSame as above
  • Small particles keep it lively: dust behind its feet, a drop of sweat when it hurries, gold sparkles when a turn finishes, bubbles while it sleeps.
  • Speech bubbles appear beside the crab for a few seconds: a finished turn (「Done 3m12s」), a finished compaction (「Compacted」), a quota reset (「Quota's back」), a red pace warning (「Slow down! out in 40m」), context filling up (「Context almost full」, then 「Handoff?」 at 85%), and a permission prompt waiting for you (「Your call」).
  • In fullscreen mode (/tui fullscreen), point at the crab: it stops, waves, and holds a bubble with your usage and a tip until you move away. Point elsewhere on the strip and its eyes follow the pointer. No click needed; a click moves the keyboard focus to the strip, and Esc gives it back.
  • The row between the strip and the input box belongs to Claude Code itself (notices such as "copied 4 chars to clipboard" appear there), so the strip can't sit any lower.
  • The engine draws [-] at the strip's right end: click it or press ctrl+x ctrl+a to fold the strip away. /hud crab off turns it off for good. In a short terminal the strip drops its blank row first, then shrinks to two rows, one row, or hides.

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
  • Lang: English or Chinese, for the whole panel, crab bubbles, toasts and the subagent board. Until you pick one, it follows Claude Code's language setting (Chinese if that's Chinese, English otherwise).
  • Crab: the walking crab above the prompt.
  • Panel: full grid, one-line compact, or hidden. The one-line layout has no room for the toolbar, and hiding the panel hides it too; /hud brings the full panel back.
  • Clicking needs fullscreen rendering (/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:

  • It's written in a side request ("fork") that sees the full conversation but adds nothing to your transcript and reuses the prompt cache. It costs one short reply, which shows up in the session cost.
  • While Claude writes, the chip reads [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.
  • The next session fills it in for you. Open a new 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.
  • This happens once, within 2 hours of writing the handoff.
  • It doesn't happen when you resume an old session (--resume), fork one, or after a compaction.
  • If the file path contains a space, which would break the @ reference, the handoff text itself is filled in.
  • The desktop app draws its own prompt box, so there you use Open handoff file instead.
  • [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.
  • Handoff history: [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.
  • The prompt starts with a header (project, branch, time, and 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.
  • At 85% context the [handoff] brackets turn orange and the crab asks 「Handoff?」 once. It asks again after the context drops below 70%, for example after a compaction.
  • Handoff + clear or /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

  • Turn receipt: the line that ends each turn gets a tail such as · $0.42 · 2 files changed +5 -1 · 3 tool calls (terminal only).
  • One-click compact: at 75% context a "compact" button appears in the context cell and runs /compact.
  • Quota-reset reminder: after the 5-hour or weekly quota passed 30% and then resets, a toast says it's back.
  • Subagent board: /hud agents, or click "+N agents" in the status cell, opens a side pane. It shows how long each subagent has run, how long since it last did something, and 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.
  • Workflows: agents started by a workflow are listed too, grouped under the workflow's name (── 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.
  • When a workflow starts, the board opens by itself, once per run. Claude Code only seats a pane nobody asked for in a terminal at least 144 columns wide; in a narrower one nothing pops up, and "+N agents" still opens it.

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.

CommandWhat it does
/hudCycles full → compact (one line) → hidden
/hud agentsOpens the subagent board; Esc closes it
/hud crab, /hud crab on, /hud crab offToggles the crab above the prompt (on by default)
/hud lang en, /hud lang zh, /hud lang autoSets the UI language; auto follows Claude Code's language setting. /hud lang alone switches between the two
/hud handoffWrites a handoff prompt, copies it and saves it (same as the [handoff] chip); the next new session or /clear fills it in
/hud historyOpens 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:

  • moves glide instead of stepping a pixel at a time
  • claws pass through a half-raised pose, and blinks go half-closed first
  • a finished turn makes it really hop twice, and a sent message makes it jump
  • red fades in over a second, and pulses smoothly at 95%

Buttons in the desktop app. Above the card sit the app's own native buttons:

  • Settings opens three dropdowns: Lang, Crab and Panel.
  • Handoff writes a handoff prompt. It turns into the app's primary button once context reaches 85%, and shows "Writing handoff…" while Claude writes.
  • The desktop app can't copy to the clipboard from a mod yet, so the handoff is saved to a file and an Open handoff file button opens it in your default editor.
  • Handoff history opens the same history pane as in the terminal. The app probably won't let a mod fill its prompt box, so use Open there.

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" is the API list-price equivalent, the same figure /cost shows. Subscribers aren't charged this amount.
  • Tokens come from the session transcript, subagents included. Cache reads count too, so the number is usually large.

Requirements

ItemNotes
Claude CodeTerminal 2.1.287 or later, desktop app 2.1.286 or later (the official requirement for mods). Tested on 2.1.289 (Windows) and 2.1.290 (WSL)
OSWindows: tested on Windows 11 + Windows Terminal. Linux: plugin tests and the open commands verified in WSL (Ubuntu 22.04). macOS: covered by mocked tests only; issues are welcome
Opening filesWindows: explorer.exe / cmd start; macOS: open; Linux: xdg-open, then gio open; in WSL, Windows' default apps first (wslview, else explorer.exe via wslpath)
RenderingClicking and hovering need fullscreen rendering (/tui fullscreen)
DependenciesNode.js on your PATH (cc-hud counts tokens with a small script), git (for the branch); on Linux outside WSL, xdg-utils to open files
  • In the macOS desktop app, Node.js installed with Homebrew or nvm may not be on the app's PATH. The token cell then falls back to counting from this launch only; everything else works.
  • If you set CLAUDE_CONFIG_DIR, cc-hud looks for session transcripts there.

Uninstall

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.

Development

Check and test a mod:

claude plugin validate ./cc-hud
claude plugin test ./cc-hud
  • Terminal sessions watch the mod folders and reload on save. Desktop app sessions don't; run /reload-plugins there.
  • Bump version in plugin.json after each change: cc-hud reloads itself in the desktop app when the version changes, and marketplace users only receive a new version.
  • All UI text lives in cc-hud/hooks/strings.ts, one table per language with identical keys (a test checks this). Add a string to both tables.
  • In the terminal, use only ASCII, CJK characters, │ ─ ━ ✓ and block characters. Glyphs such as • ⏱ ↻ ✦ have an ambiguous width in some terminals and overlap their neighbours.
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

Changelog

See CHANGELOG.md.

License

MIT © 2026 kimyong6366

Source 5 files
hooks/register.tsx 3132 lines
1import 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 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
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}
433
hooks/sprites.ts 1208 lines
1// 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 lines
1// 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}
263
hooks/strings.ts 399 lines
1// 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