SLOPSHOPPER

crabhud

Above the prompt: a crab that shows what Claude is doing, a progress bar for long jobs (also on its own from Claude's task list or an approved plan), and a HUD…

newpanebandguardcommandtoast
v2.6.1MITupdated 2026-10-15vinicius-sandes/crabhud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · crabhud
│ ┃ crabhud-agents ✕ › fix the failing auth test and add an audit log call │ ┃ Recolher │ ┃ ≈$0.00 · 0 tokens · 0:00 ⏺ Read(src/auth.ts) │ ┃ Esta resposta: 9 ações · 0s ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Nenhum subagente rodando agora. ⎿ 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 for 42s · done 4:20 PM │ │ › /crabhud │ ⎿ crabhud: HUD hidden. Use /crabhud on to show it again. │ │ ▣ dormindo · esperando a próxima tarefa ──────────────────────────────────────────────────────────────────────────────────────────────────── ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ dormindo · esperando a próxima tarefa ──────────────────────────────────────────────────────────────────────────────────────────────────── ⟨Claude Code's own drawing⟩
Pane · crabhud-agents
Recolher ≈$0.00 · 0 tokens · 0:00 Esta resposta: 9 ações · 0s Nenhum subagente rodando agora.
Pane · crabhud-style
1: Pixel ▣ client module ./hud-client.tsx a rounded tr 2: Segments ▣ client module ./hud-client.tsx a block per 3: Line ▣ client module ./hud-client.tsx a thin line 4: Solid ▣ client module ./hud-client.tsx a filled bar 5: Dots ▣ client module ./hud-client.tsx a dot per st 6: Spark ▣ client module ./hud-client.tsx a meter that 7: Classic ▣ client module ./hud-client.tsx solid blocks Press 1/2/3/4/5/6/7 to choose · Esc to close
README

<img src="assets/hero-light.svg" alt="crabhud: its five crabs, one per mood, and the band above the Claude Code prompt in the desktop app: the crab row taking turns through working, thinking, analyzing, coordinating and sleeping, and the HUD under it with the model, effort, context, the 5-hour and 7-day limits and the cost" width="100%">

A Claude Code mod that sits right above the prompt and beside the chat:

  • The crab shows what Claude is doing right now (sleeping, thinking, analyzing, working, coordinating). In the desktop app, while Claude works it walks sideways through the free part of the row, a puff of smoke behind it, and stops to act out the task; asleep it keeps still, and beside the progress bar it sits in the corner. At the right of the row, small: how many actions this answer took so far, and for how long (12 actions · 1m12s).
  • Waiting on you: when Claude asks for a permission or asks you a question, the row turns amber until you answer.
  • A progress bar for long jobs: a title, the step in progress, stretches with their own colors, and a checklist in the panel. It shows up on its own when Claude writes its task list or you approve a plan, the task at hand on its tag; skills and workflows feed it through the mcp__crabhud__progress tool, and subagents report their own steps through mcp__crabhud__step. At 100% the crab hops and throws pixel confetti.
  • The HUD under them: the selected model and effort, the chat's context and cost, and the account's 5-hour and 7-day limits with their reset times and how fast they fill (+12%/h, +8%/day), shared across every chat. Compact, each reading is a small ring; expanded, a bar. The pace turns amber, and the expanded HUD says when the limit runs out, when that comes before its reset.
  • The agents panel (/agents-info): running, finished and planned subagents with model, effort, progress, context, cost and time, each with its own crab; and what you spent today and this month, summed over every chat.
  • /savvy-flow: an orchestrated delivery flow. The session model plans, owns the design and reviews; five tiered worker agents (savvy-fable, savvy-heavy, savvy-careful, savvy-medium, savvy-light) implement. The flow drives the progress bar, and each worker reports its own steps to the panel.

<img src="assets/moments-light.svg" alt="Five moments in the desktop app: the crab walking while Claude works, counting the answer's actions and time; a progress bar for a long job; the bar from Claude's own task list, with the crab celebrating at 100%; the row turning amber when Claude waits for your permission; and the expanded HUD with the limits and how fast they fill" width="880">

<img src="assets/hud-light.svg" alt="The HUD compact, in one line with a ring per reading, and expanded, one row per reading with its bar; the 5-hour limit fills fast enough to run out before it resets, so its pace is amber" width="880">

Switch the HUD between compact (one line, a ring per reading) and expanded (one row per reading, with its bar, token counts and full reset times): in the desktop app with the ▾/▴ button beside it, in the terminal by clicking anywhere on it. Your choice is remembered. In the app the rings breathe and a glint runs along them, quicker while Claude works; in the terminal they fill in steps, ○ ◔ ◑ ◕ ●.

The bar, on its own

You don't need a skill to get a progress bar. When Claude writes its own task list, the bar shows one stretch per task, and its tag names the task at hand, cut to a few words (Adding the dark palette · 2/5). When you approve a plan, the bar takes its title and its steps until a task list takes over, and the answer that closes the turn completes it. A skill's own bar (/savvy-flow, or any skill calling mcp__crabhud__progress) always wins over these.

When a bar reaches 100% the crab hops and pixel confetti bursts out in the bar's colours; with sounds on, a short fanfare plays. A bar from the task list or a plan then leaves on its own after 10 seconds; a skill's bar stays until you close it.

Styles

Seven styles for every bar crabhud draws: the progress bar, the expanded HUD's gauges and the bars in the agents panel. Pick one with /crabhud style: a picker with a live preview of each (keys 1–7). The desktop app and the terminal each keep their own pick; /crabhud style <name> sets both at once.

<img src="assets/styles-light.svg" alt="The seven bar styles on the same progress bar: Pixel, Segments, Line, Solid, Dots, Spark and Classic" width="880">

In the desktop app the bars are drawn shapes: a rounded track with the fill inside it and, on the bigger bars, a tag with the count or the percentage. In the terminal they are text (the expanded HUD's, here):

Pixel     ctx ⣦⣿⣀⣀⣀⣀⣀⣀ 31%  │  5h ⣦⣿⣀⣀⣀⣀⣀⣀ 20%  │  7d ⣤⣦⣶⣿⣿⣀⣀⣀ 58%
Segments  ctx ▰▰▱▱▱▱▱▱ 31%  │  5h ▰▰▱▱▱▱▱▱ 20%  │  7d ▰▰▰▰▰▱▱▱ 58%
Line      ctx ━━╸───── 31%  │  5h ━━╸───── 20%  │  7d ━━━━━╸── 58%
Solid     ctx ██░░░░░░ 31%  │  5h ██░░░░░░ 20%  │  7d █████░░░ 58%
Dots      ctx ● ○ ○ ○  31%  │  5h ◐ ○ ○ ○  20%  │  7d ● ● ○ ○  58%
Spark     ctx ▅█▁▁▁▁▁▁ 31%  │  5h ▅█▁▁▁▁▁▁ 20%  │  7d ▃▄▅▆█▁▁▁ 58%
Classic   ctx █▒······ 31%  │  5h █▒······ 20%  │  7d ███▓▒··· 58%
  • Pixel: pixels that thicken toward the head and twinkle (braille dots in the terminal).
  • Segments: a block per step. Dots: a dot per step. Both count the real steps of the progress bar when they fit.
  • Line: a thin line with a glowing head. Solid: a filled bar with a light that sweeps along it while Claude works (drawn above with █ and ░). Spark: a meter that rises as it fills.
  • Classic: solid blocks, █ ▓ ▒ and dots after.

The defaults are Pixel in the desktop app and Classic in the terminal. While Claude works the heads pulse. The Remote Control row draws the same style, without the animation.

The loudest settings get their own look, following each surface's own effort picker:

  • Desktop app: Ultracode switches palettes: the bars flow through a purple-to-pink gradient and a soft pixel wave drifts behind everything.
  • Terminal: Low is yellow, Medium green, High purple. xhigh turns the HUD purple, with a purple wave behind it; max turns it every colour, with a rainbow wave. Ultracode (Tab in /effort, /effort ultracode on, or "ultracode": true in the settings) shows next to the level, as in Max · Ultracode; "Dynamic workflows" in /config only allows it, so it does not turn it on.

When the context or a limit crosses 90%, you get a toast.

The agents panel

<img src="assets/panel-light.svg" alt="The agents panel: the flow with its cost, tokens and time; this answer's actions and time, today's and this month's spend; and three subagents, each with its own crab, model, effort, steps, context and cost" width="440">

/agents-info opens it beside the chat. Each subagent gets a card with its crab, its model and effort, the steps it reports, its context, cost and time; the cards stay after they finish, with a check, and the header adds them up. Under the header, one line: this answer's actions and time at the left, and at the right what you spent today and this month, summed over every chat on the machine (only there, not in the HUD). The sums start counting the day crabhud 2.6 is installed, and keep about two months.

Portraits of your own agents

In the agents panel the savvy workers and Explore wear costumes of their own; any other agent is the plain crab. To dress your team's agents, put a crabhud-portraits.json in a folder under ~/.claude/mods/ (a repository of your team's, say). crabhud reads every one it finds there when a chat opens:

{
  "crabhud": 1,
  "groups": { "prep": "#E46FB0" },
  "portraits": {
    "barista": {
      "agents": ["barista-team"],
      "color": "#8B5A2B",
      "pixels": [
        [7, 10, 16, 12, "#D97757"],
        [7, 22, 2, 4, "#D97757", "la"],
        [22, 14, 4, 5, "role", "fr cup"]
      ],
      "css": [".la,.lb{animation:none}", ".cup{animation:blink 1s steps(1) infinite}"]
    }
  }
}
  • agents: the agent types it dresses, as the Agent tool names them. color: the agent's colour in the panel.
  • pixels: the crab on its 30×26 grid, one rectangle each (x, y, width, height, colour, class). role paints in the agent's colour. The classes la and lb are the legs (they step while it runs), fr draws in front of them, and any other class names a part for css to move.
  • css: its motion while it runs. Each rule is scoped to the portrait (.cup{…} moves only its cup), and @keyframes of its own are welcome; crabhud's blink, c1–c3 and peek are there to use.
  • groups: a progress bar group's colour by its name, kept whichever groups a skill calls.

A name the mod already uses (the tiers, explore) can't be replaced, and what doesn't hold (a colour that isn't one, a rule that could reach outside the drawing) is left out; crabhud says so once when the chat opens.

Remote Control

Following a chat through Remote Control (from the phone or another computer), the HUD shows a still version of the compact row, marked ⇄ Remote Control: ON: the same model, effort, context, limits and cost, in the same colours, refreshed with every new reading, without the animations (each frame would have to cross the connection). The computer running the chat keeps the full HUD. The mod only needs to be installed on that computer.

How it stays live

  • Context and cost refresh after every model response, not only when a turn ends.
  • Model and effort follow the picker under the chat the moment you change it (desktop app). In the terminal they follow /effort the moment it closes, and start from the effort saved for your model.
  • Limits are the account's, not the chat's: every chat running the HUD shares its latest reading through a small file (~/.claude/crabhud-limits.json), and every HUD shows the freshest one. Use one chat to 97% and the others show 97% within half a second.
  • A window whose reset time has passed drops back to 0% until the next reading.

Install

Works in the Claude desktop app (Code tab) and in the Claude Code terminal, on Windows, macOS, and Linux. On Linux the HUD reads the app's records from $XDG_CONFIG_HOME (or ~/.config), and in the terminal it works the same as anywhere else.

  1. Clone the repository into your mods folder:
   git clone https://github.com/vinicius-sandes/crabhud ~/.claude/mods/crabhud
  1. Add this to ~/.claude/settings.json (inside the existing top-level object):
   "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/crabhud" }

If your settings.json already has an "env" block, add just the CLAUDE_CODE_PLUGIN_DIRS key to it.

  1. Open a new chat. The HUD appears above the prompt, in every chat from now on, and asks which language you want.

To update: git -C ~/.claude/mods/crabhud pull, then open a new chat.

Language

The HUD speaks English by default and also Brazilian Portuguese. The first time it appears, it asks which one you want, once. Your pick is kept for every chat on that machine, and the progress bar and the agents panel follow it too. Change it anytime with /crabhud language.

To skip the question (for example, when setting up a machine from a script), set it in ~/.claude/settings.json:

"pluginConfigs": { "crabhud": { "options": { "language": "pt-BR" } } }

Commands

  • /crabhud off hides the HUD, /crabhud on shows it again, /crabhud toggles it.
  • /crabhud language asks for the language again; /crabhud language en or /crabhud language pt-BR sets it directly.
  • /crabhud style opens the style picker; /crabhud style pixel|segments|line|solid|dots|spark|classic sets it directly.
  • /crabhud status sums it all up as text: model and effort, context, the limits with their reset and pace, this chat's, today's and this month's spend, the bar, the agents and your settings.
  • /crabhud demo plays 20 seconds of sample data (a bar walking to its celebration, two agents, the amber wait, sample limits when there's no reading yet) and then puts everything back as it was; /crabhud demo off stops it.
  • /crabhud sound on plays a short chime when Claude waits for you and when it finishes, and a little fanfare when a bar reaches 100%; /crabhud sound off silences it (the default). The clips are the mod's own (sounds/); macOS plays them, a Windows or Linux terminal has no player and stays quiet.
  • /agents-info opens or closes the agents panel.
  • /savvy-flow <task> (/crabhud:savvy-flow when another skill has the same name) runs the orchestrated flow.

Cost

The HUD never calls the model and adds nothing to what is sent to Claude: it spends no tokens and does not touch your limits. It only uses a little of your own machine: a file check every quarter second (the chat's settings and the shared limits), a local usage read every second, redraws while Claude is working or Ultracode is on, and in the desktop app a redraw every 30 seconds for the reset countdowns (its animations run in the drawing itself).

Privacy

Everything stays on your machine. The HUD reads the desktop app's local record of the current chat (only its model and effort fields) and writes one small file with your limit percentages and reset times; the day-by-day spend sits in the mod's own local store. Nothing is sent anywhere.

Notes

  • Claude Code's mod system (function hooks) is in early access and may change between releases.
  • The 5-hour and 7-day limits appear after the first response in any chat running the HUD.

Tests

claude plugin validate ~/.claude/mods/crabhud
claude plugin test ~/.claude/mods/crabhud

License

MIT. The progress bar, the crab, the agents panel and /savvy-flow start from JohnnyVizz's claude-kit, and the bar styles follow nvr0x5's claude-deck (both MIT); see THIRD_PARTY_NOTICES.md.

Source 7 files
hooks/register.tsx 3176 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderInput, SessionRateLimit, SessionUsage, ToolCallInput, TurnStepResult } from 'claude-code'
3
4import type { Activity, AgentRun, Flow, HudChat, HudLang, HudLimit, HudLimits, HudProps, HudStyle, HudStyles, Panel, Phase, PlannedTask, StepGroup, TurnStats, Waiting } from '../types'
5import { BAR_CSS, GLIDE_MS, STYLE_HINTS, STYLE_NAMES, STYLES, asStyle, barCells, barSvg, mergeCells } from './bar-styles'
6import type { Cell } from './bar-styles'
7import { hudSvg } from './hud-svg'
8import { CYCLE, CYCLE_S, WORDS, pace } from './hud-words'
9import { NO_PORTRAITS, mergePortraits, portraitFor, readPortraits } from './portraits'
10import type { PortraitSet } from './portraits'
11
12// crabhud: the crab and the progress bar on top of the band above the prompt, the HUD
13// under them, and the agents panel beside the chat. The engine follows $ only into
14// functions of this same file, so every hook lives here; drawing that needs no $ may
15// move to files of its own.
16
17// ===========================================================================
18// Progress bar, crab and agents panel
19// ===========================================================================
20
21const flow = atom({ plugin: 'crabhud', key: 'flow' } as const, null)
22const agents = atom({ plugin: 'crabhud', key: 'agents' } as const, [])
23const panel = atom({ plugin: 'crabhud', key: 'panel' } as const, {
24  isCompact: false,
25  isDoneCollapsed: false,
26  autoOpenedFor: '',
27})
28const now = atom({ plugin: 'crabhud', key: 'now' } as const, 0)
29// The main agent's tool in flight, for the crab's mood; null between tools.
30const activity = atom({ plugin: 'crabhud', key: 'activity' } as const, null)
31// Claude waiting on the person (a permission, its own question); null otherwise.
32const waitingA = atom({ plugin: 'crabhud', key: 'waiting' } as const, null)
33// Sounds, off until /crabhud sound on.
34const soundA = atom({ plugin: 'crabhud', key: 'sound' } as const, false)
35// The main agent's turn: since when and how many tools it called.
36const turnA = atom({ plugin: 'crabhud', key: 'turn' } as const, null)
37// What the chats on this machine spent, by day; kept in $.store, read again for the panel.
38const spendA = atom({ plugin: 'crabhud', key: 'spend' } as const, {})
39// `/crabhud demo`: sample data on screen until this time (ms).
40const demoA = atom({ plugin: 'crabhud', key: 'demo' } as const, 0)
41// Claude's task tools (TaskCreate, TaskUpdate), by id in the order they came: the bar's steps.
42const taskItems = new Map<string, TodoItem>()
43// The calls in flight: an ask still pending after ASK_DELAY_MS is a prompt on screen.
44const inFlight = new Set<string>()
45// An ask the mode settles at once (rules, auto mode) never shows: wait this long first.
46const ASK_DELAY_MS = 600
47
48// The day a moment falls on, on this machine's calendar: "2026-10-15".
49const dayOf = (t: number): string => {
50  const d = new Date(t)
51  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
52}
53
54// Today's and this month's spend, summed over the days kept.
55function spendNow(days: Record<string, number>, t: number): { today: number; month: number } {
56  const today = dayOf(t)
57  const month = today.slice(0, 8)
58  let sum = 0
59  for (const [day, usd] of Object.entries(days)) if (day.startsWith(month)) sum += usd
60  return { today: days[today] ?? 0, month: sum }
61}
62
63// The spend every chat adds to, in $.store: the dollars of each day, and each
64// chat's last cost (a chat's cost only grows, so what it adds is the step up).
65type SpendBook = { days: Record<string, number>; last: Record<string, number> }
66const SPEND_DAYS = 62
67const SPEND_CHATS = 50
68
69async function spendBook($: EngineInterface): Promise<SpendBook> {
70  const v = (await $.store.get('spend').catch(() => null)) as Partial<SpendBook> | null
71  return { days: { ...(v?.days ?? {}) }, last: { ...(v?.last ?? {}) } }
72}
73
74// This chat's cost moved: add the step to today's sum. The first reading of a
75// chat only marks where it stands (a resumed chat's earlier cost is already
76// in, or came before the mod counted).
77async function addSpend($: EngineInterface, cost: number | null): Promise<void> {
78  if (cost === null) return
79  const id = await $.session.id()
80  const book = await spendBook($)
81  const before = book.last[id]
82  if (before !== undefined && Math.abs(cost - before) < 0.0001) return
83  const t = await $.clock.now()
84  if (before !== undefined && cost > before) {
85    const day = dayOf(t)
86    book.days[day] = (book.days[day] ?? 0) + (cost - before)
87  }
88  delete book.last[id]
89  book.last[id] = cost
90  const chats = Object.keys(book.last)
91  for (const old of chats.slice(0, Math.max(0, chats.length - SPEND_CHATS))) delete book.last[old]
92  const oldest = dayOf(t - SPEND_DAYS * 86400000)
93  for (const day of Object.keys(book.days)) if (day < oldest) delete book.days[day]
94  await $.store.set('spend', book)
95  await update($, spendA, () => book.days)
96}
97
98// Another chat may have added to the book: read it again.
99async function pullSpend($: EngineInterface): Promise<void> {
100  const { days } = await spendBook($)
101  if (JSON.stringify(days) !== JSON.stringify(await read($, spendA))) await update($, spendA, () => days)
102}
103
104// A short clip of the mod's own (sounds/wait.wav, done.wav, finish.wav), when sounds are on.
105async function chime($: EngineInterface, name: 'wait' | 'done' | 'finish') {
106  if (!(await read($, soundA))) return
107  void $.audio.play({ asset: `sounds/${name}.wav` }, { gain: 0.6 }).catch(() => undefined)
108}
109
110async function startWaiting($: EngineInterface, w: Waiting) {
111  const before = await read($, waitingA)
112  await update($, waitingA, () => w)
113  if (before === null) await chime($, 'wait')
114}
115
116// The scene the walking crab plays: its mood and when it began, so a redraw
117// picks the walk up where it was.
118let scene = { mood: '', at: 0 }
119
120const TOOL = 'mcp__crabhud__progress'
121const STEP_TOOL = 'mcp__crabhud__step'
122const PANE = 'crabhud-agents'
123const PHASES: readonly Phase[] = ['plan', 'design', 'delegate', 'review', 'close']
124const ACCENT = '#8f8cf4'
125const DONE = '#5fbf8f'
126
127type ProgressInput = {
128  title?: string
129  total?: number
130  done?: number
131  phase?: Phase
132  finished?: boolean
133  tasks?: { title?: string; tier?: string; after?: number[] }[]
134  step?: string
135  groups?: { name?: string; steps?: (string | { name?: string; short?: string })[] }[]
136}
137
138// ---------------------------------------------------------------------------
139// Language: the `language` option, else Claude Code's `language` setting, else the
140// process locale; English when nothing says Russian or Portuguese.
141
142type Lang = 'en' | 'ru' | 'pt'
143
144const STRINGS = {
145  en: {
146    pane: 'Agents',
147    cost: 'Cost',
148    tokens: 'Tokens',
149    time: 'Time',
150    collapse: 'Collapse',
151    expand: 'Expand',
152    running: 'Running',
153    finished: 'Finished',
154    planned: 'Planned',
155    empty: 'No subagents yet.',
156    round: 'round',
157    failed: 'error',
158    after: 'after',
159    tokensWord: 'tokens',
160    agentsCount: 'agents',
161    isRunning: 'running',
162    isFinished: 'finished',
163    isPlanned: 'planned',
164    opened: 'Agents panel opened.',
165    closed: 'Agents panel closed.',
166    done: 'Done',
167    plan: 'Plan',
168    design: 'Design',
169    tasks: 'Tasks',
170    review: 'Review',
171    busy: 'running',
172    sleep: 'sleeping',
173    sleepHint: 'waiting for the next task',
174    waitPermission: 'waiting for your permission',
175    waitAnswer: 'waiting for your answer',
176    think: 'thinking',
177    analyze: 'analyzing',
178    work: 'working',
179    delegate: 'coordinating',
180    agentsN: (n: number) => `${n} ${n === 1 ? 'agent' : 'agents'}`,
181    reading: 'reading',
182    searching: 'searching',
183    editing: 'editing',
184    running_: 'running',
185    browsing: 'browsing',
186    actionsN: (n: number) => `${n} ${n === 1 ? 'action' : 'actions'}`,
187    thisTurn: 'This answer',
188    today: 'Today',
189    month: 'This month',
190  },
191  ru: {
192    pane: 'Агенты',
193    cost: 'Стоимость',
194    tokens: 'Токены',
195    time: 'Время',
196    collapse: 'Свернуть',
197    expand: 'Развернуть',
198    running: 'Работают',
199    finished: 'Завершены',
200    planned: 'Запланированы',
201    empty: 'Субагентов пока нет.',
202    round: 'раунд',
203    failed: 'ошибка',
204    after: 'после',
205    tokensWord: 'токенов',
206    agentsCount: 'агентов',
207    isRunning: 'работает',
208    isFinished: 'завершён',
209    isPlanned: 'запланирована',
210    opened: 'Панель агентов открыта.',
211    closed: 'Панель агентов закрыта.',
212    done: 'Готово',
213    plan: 'План',
214    design: 'Дизайн',
215    tasks: 'Задачи',
216    review: 'Ревью',
217    busy: 'в работе',
218    sleep: 'спит',
219    sleepHint: 'ждёт следующую задачу',
220    waitPermission: 'ждёт вашего разрешения',
221    waitAnswer: 'ждёт вашего ответа',
222    think: 'думает',
223    analyze: 'анализирует',
224    work: 'работает',
225    delegate: 'координирует',
226    agentsN: (n: number) => `${n} агент.`,
227    reading: 'читает',
228    searching: 'ищет',
229    editing: 'правит',
230    running_: 'запускает',
231    browsing: 'смотрит',
232    actionsN: (n: number) => `${n} действ.`,
233    thisTurn: 'Этот ответ',
234    today: 'Сегодня',
235    month: 'Этот месяц',
236  },
237  pt: {
238    pane: 'Agentes',
239    cost: 'Custo',
240    tokens: 'Tokens',
241    time: 'Tempo',
242    collapse: 'Recolher',
243    expand: 'Expandir',
244    running: 'Rodando',
245    finished: 'Concluídos',
246    planned: 'Planejados',
247    empty: 'Nenhum subagente rodando agora.',
248    round: 'rodada',
249    failed: 'erro',
250    after: 'depois de',
251    tokensWord: 'tokens',
252    agentsCount: 'agentes',
253    isRunning: 'rodando',
254    isFinished: 'concluído',
255    isPlanned: 'planejada',
256    opened: 'Painel de agentes aberto.',
257    closed: 'Painel de agentes fechado.',
258    done: 'Pronto',
259    plan: 'Plano',
260    design: 'Design',
261    tasks: 'Tarefas',
262    review: 'Revisão',
263    busy: 'rodando',
264    sleep: 'dormindo',
265    sleepHint: 'esperando a próxima tarefa',
266    waitPermission: 'esperando sua permissão',
267    waitAnswer: 'esperando sua resposta',
268    think: 'pensando',
269    analyze: 'analisando',
270    work: 'trabalhando',
271    delegate: 'coordenando',
272    agentsN: (n: number) => `${n} ${n === 1 ? 'agente' : 'agentes'}`,
273    reading: 'lendo',
274    searching: 'buscando',
275    editing: 'editando',
276    running_: 'rodando',
277    browsing: 'pesquisando',
278    actionsN: (n: number) => `${n} ${n === 1 ? 'ação' : 'ações'}`,
279    thisTurn: 'Esta resposta',
280    today: 'Hoje',
281    month: 'Este mês',
282  },
283} as const
284
285// Module scope is fine here: session.start sets it again on every (re)load.
286let lang: Lang = 'en'
287const tr = () => STRINGS[lang]
288
289const langOf = (v: unknown): Lang | null =>
290  typeof v !== 'string' ? null : /^(ru|russian|рус)/i.test(v.trim()) ? 'ru' : /^(pt|portug)/i.test(v.trim()) ? 'pt' : null
291
292async function detectLang($: EngineInterface, option: unknown): Promise<Lang> {
293  // The language picked with /crabhud language comes first: the whole mod speaks it.
294  const picked = await $.store.get('language').catch(() => null)
295  if (picked === 'en') return 'en'
296  if (picked === 'pt-BR') return 'pt'
297  if (option === 'en' || option === 'ru' || option === 'pt') return option
298  try {
299    const settings = (await $.settings.read()) as Record<string, unknown>
300    if (typeof settings.language === 'string' && settings.language.trim()) return langOf(settings.language) ?? 'en'
301  } catch {
302    // No settings: fall through to the locale.
303  }
304  const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
305  return langOf(locale) ?? 'en'
306}
307
308const blank = (): Flow => ({
309  title: 'savvy-flow',
310  total: 0,
311  done: 0,
312  running: 0,
313  phase: 'plan',
314  isFinished: false,
315  tasks: [],
316  step: '',
317  groups: [],
318})
319
320const isNewFlow = (prev: Flow | null, input: ProgressInput): boolean =>
321  !prev || prev.isFinished || (input.title !== undefined && input.title.trim() !== prev.title)
322
323const cleanTasks = (tasks: ProgressInput['tasks']): PlannedTask[] | undefined =>
324  tasks
325    ?.filter(t => t.title?.trim())
326    .map(t => ({
327      title: (t.title ?? '').trim(),
328      tier: (t.tier ?? '').replace(/^savvy-/, '').trim().toLowerCase(),
329      after: (t.after ?? []).filter(n => Number.isInteger(n) && n > 0),
330    }))
331
332// Step names start with a capital, on the bar and in the panel.
333const capital = (s: string): string => s.replace(/\p{L}/u, c => c.toLocaleUpperCase())
334
335const cleanGroups = (groups: ProgressInput['groups']): StepGroup[] | undefined =>
336  groups
337    ?.map(g => {
338      const steps = (g.steps ?? [])
339        .map(t => (typeof t === 'string' ? { name: t, short: '' } : { name: t?.name ?? '', short: t?.short ?? '' }))
340        .map(t => ({ name: capital(t.name.trim()), short: capital(t.short.trim()) }))
341        .filter(t => t.name)
342      return { name: (g.name ?? '').trim(), steps: steps.map(t => t.name), short: steps.map(t => t.short) }
343    })
344    .filter(g => g.steps.length)
345
346const stepsOf = (f: Flow): string[] => f.groups.flatMap(g => g.steps)
347const shortsOf = (f: Flow): string[] => f.groups.flatMap(g => g.steps.map((name, i) => g.short?.[i] || name))
348
349const merge = (prev: Flow | null, input: ProgressInput): Flow => {
350  // A new title means a new flow: never carry counters over from an earlier one.
351  const base = isNewFlow(prev, input) || !prev ? blank() : { ...blank(), ...prev }
352  const tasks = cleanTasks(input.tasks) ?? base.tasks
353  const groups = cleanGroups(input.groups) ?? base.groups
354  const counted = input.groups ? groups.reduce((n, g) => n + g.steps.length, 0) : input.tasks ? tasks.length : base.total
355  const total = Math.max(0, Math.round(input.total ?? counted))
356  const done = Math.min(total || Infinity, Math.max(0, Math.round(input.done ?? base.done)))
357  const phase = input.phase && PHASES.includes(input.phase) ? input.phase : base.phase
358  // A named step lasts until it is renamed or the count moves on.
359  const step = input.step !== undefined ? input.step.trim() : done !== base.done ? '' : base.step
360  return {
361    ...base,
362    title: input.title?.trim() || base.title,
363    total,
364    done,
365    phase: input.finished ? 'close' : phase,
366    isFinished: input.finished === true,
367    tasks,
368    step,
369    groups,
370  }
371}
372
373// The step in progress, as short as the bar's pill wants it: the one the caller named, else
374// the next one in `groups`.
375const currentStep = (f: Flow): string => {
376  if (f.isFinished) return ''
377  const steps = shortsOf(f)
378  return capital(f.step) || steps[Math.min(f.done, steps.length - 1)] || ''
379}
380
381const label = (f: Flow): string => {
382  const s = tr()
383  if (f.isFinished) return s.done
384  const step = currentStep(f)
385  const count = f.total ? `${f.done}/${f.total}` : `${f.running} ${s.busy}`
386  if (step) return `${clip(step, f.source === 'todo' || f.source === 'plan' ? 20 : 16)} · ${count}`
387  if (f.phase === 'plan') return s.plan
388  if (f.phase === 'design') return s.design
389  return `${f.phase === 'review' ? s.review : s.tasks} ${count}`
390}
391
392// ---------------------------------------------------------------------------
393// The automatic bar: Claude's own task list (TodoWrite, or the task tools) and a
394// plan the person approved draw the bar too, when no skill drives it.
395
396type TodoItem = { content: string; status: string; activeForm?: string }
397
398// Words a step name may end on only if it says nothing ("Add the", "Ler o").
399const DANGLING = new Set(['a', 'an', 'the', 'of', 'to', 'for', 'and', 'in', 'on', 'with', 'o', 'os', 'as', 'um', 'uma', 'de', 'do', 'da', 'dos', 'das', 'para', 'e', 'em', 'no', 'na', 'com'])
400
401// A step name short enough for the bar's tag: whole words up to `max` letters,
402// no markup, no trailing filler; one word too long is cut.
403const shortStep = (raw: string, max = 20): string => {
404  const plain = raw
405    .replace(/[*_`~]/g, '')
406    .replace(/\([^)]*\)/g, '')
407    .replace(/[.:;,]+$/, '')
408    .replace(/\s+/g, ' ')
409    .trim()
410  if (plain.length <= max) return capital(plain)
411  const words = plain.split(' ')
412  let out = ''
413  for (const w of words) {
414    const next = out ? out + ' ' + w : w
415    if (next.length > max) break
416    out = next
417  }
418  while (out.includes(' ') && DANGLING.has(out.split(' ').pop()!.toLowerCase())) out = out.split(' ').slice(0, -1).join(' ')
419  return capital(out || clip(plain, max))
420}
421
422// A bar drawn from a task list: one unnamed stretch, a step per item, the tag on
423// the item in progress (its "-ing" form, made short).
424const listFlow = (title: string, items: TodoItem[], source: 'todo' | 'plan'): Flow => {
425  const total = items.length
426  const done = items.filter(t => t.status === 'completed').length
427  const now_ = items.find(t => t.status === 'in_progress')
428  const isFinished = total > 0 && done === total
429  return {
430    ...blank(),
431    title,
432    total,
433    done,
434    phase: isFinished ? 'close' : 'delegate',
435    isFinished,
436    step: now_ ? shortStep(now_.activeForm || now_.content) : '',
437    groups: [{ name: '', steps: items.map(t => capital(t.content.trim())), short: items.map(t => shortStep(t.activeForm || t.content)) }],
438    source,
439  }
440}
441
442const unmark = (s: string): string => s.replace(/[*_`]/g, '').replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').trim()
443
444// An approved plan's title and steps: its first heading, and its top-level
445// numbered items (else its top-level bullets, else its second-level headings).
446const planOf = (md: string): { title: string; steps: string[] } => {
447  const lines = md.split(/\r?\n/)
448  const heading = lines.find(l => /^#\s+\S/.test(l))
449  const title = heading ? unmark(heading.replace(/^#\s+/, '').replace(/^(plan|plano|план)\s*[:—–-]\s*/i, '')) : ''
450  const pick = (re: RegExp) => lines.map(l => re.exec(l)?.[1]).filter((t): t is string => !!t && !!unmark(t)).map(unmark)
451  let steps = pick(/^\s?\d+[.)]\s+(.+)/)
452  if (steps.length < 2) steps = pick(/^[-*]\s+(.+)/)
453  if (steps.length < 2) steps = pick(/^###?\s+(?:\d+[.)]?\s*)?(.+)/).filter(t => t !== title)
454  return { title, steps: steps.map(t => t.split(/\s[—–-]\s|:\s/)[0] ?? t).slice(0, 20) }
455}
456
457// The crab in the corner acts out what Claude is doing right now.
458type Mood = 'sleep' | 'think' | 'analyze' | 'work' | 'delegate'
459
460const READS = new Set(['Read', 'Grep', 'Glob', 'LS', 'NotebookRead'])
461const WEB = new Set(['WebFetch', 'WebSearch'])
462const WRITES = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
463const RUNS = new Set(['Bash', 'PowerShell'])
464
465const kindOf = (tool: string): Activity['kind'] =>
466  READS.has(tool) ? (tool === 'Read' || tool === 'NotebookRead' ? 'read' : 'search') : WEB.has(tool) ? 'web' : WRITES.has(tool) ? 'write' : RUNS.has(tool) ? 'run' : 'other'
467
468const baseName = (p: string): string => p.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? p
469
470// The one thing about a call worth naming: the file, the pattern, the command's first word.
471const targetOf = (input: Record<string, unknown>): string => {
472  const str = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
473  const path = str('file_path') || str('notebook_path') || str('path')
474  if (path) return baseName(path)
475  if (str('command')) return str('command').trim().split(/\s+/).slice(0, 2).join(' ')
476  return str('pattern') || str('query') || str('url').replace(/^https?:\/\//, '').split('/')[0] || ''
477}
478
479const moodOf = (isWorking: boolean, running: number, act: Activity | null): Mood =>
480  running > 0 ? 'delegate' : !isWorking ? 'sleep' : !act ? 'think' : act.kind === 'write' || act.kind === 'run' || act.kind === 'other' ? 'work' : 'analyze'
481
482const moodText = (mood: Mood, running: number, act: Activity | null): string => {
483  const s = tr()
484  if (mood === 'sleep') return `${s.sleep} · ${s.sleepHint}`
485  if (mood === 'delegate') return `${s.delegate} ${s.agentsN(running)}`
486  if (mood === 'think' || !act) return `${s.think}…`
487  const verb = { read: s.reading, search: s.searching, web: s.browsing, write: s.editing, run: s.running_, other: s.running_ }[act.kind]
488  return `${mood === 'work' ? s.work : s.analyze} · ${verb}${act.target ? ' ' + act.target : ''}`
489}
490
491const ratio = (f: Flow): number => (f.isFinished ? 1 : f.total ? f.done / f.total : 0)
492
493// Deterministic noise so the dither does not shimmer between redraws.
494const noise = (x: number, y: number): number => {
495  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
496  return s - Math.floor(s)
497}
498
499// The whole row is one SVG: the desktop wraps sibling elements onto new lines,
500// so title, bar, percent and the crab live in one drawing; only the count and
501// the dismiss are Buttons beside it.
502const H = 26
503const BAR_H = 16
504const CRAB_W = 28
505const CELL = 3
506const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
507
508const xml = (s: string): string =>
509  s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
510
511const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s)
512
513// Rough advance of system UI text, in em; good enough to size the title's slot.
514const charEm = (ch: string): number =>
515  /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-ZА-ЯЁmwшщжюМШЩЖЮ@%]/.test(ch) ? 0.72 : 0.56
516
517const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
518
519// Cuts `s` to fit `maxW` pixels, with an ellipsis when it had to cut.
520const fitText = (s: string, size: number, maxW: number): string => {
521  if (textWidth(s, size) <= maxW) return s
522  let out = ''
523  for (const ch of s) {
524    if (textWidth(out + ch + '…', size) > maxW) break
525    out += ch
526  }
527  return out + '…'
528}
529
530// A stretch of the bar per group: a group the data files name keeps its colour
531// whichever groups are called; any other takes the next colour of the palette.
532const GROUP_PALETTE = ['#3B7FF5', '#A259E6', '#F08C2E', '#D9649B', '#3FA7B5']
533const groupColor = (name: string, i: number): string => portraits.groups[name.toLowerCase()] ?? GROUP_PALETTE[i % GROUP_PALETTE.length] ?? ACCENT
534
535// The row is taller when the bar carries the groups' names under it.
536const named = (f: Flow): boolean => f.groups.some(g => g.name)
537const rowHeight = (f: Flow): number => (named(f) ? 40 : H)
538
539// The bar just reached 100%: the crab in the corner hops and pixel confetti in
540// the bar's colours bursts out of it, once (`age`, ms since then, keeps a redraw
541// from starting it over).
542const PARTY_MS = 2600
543const partySvg = (cx: number, cy: number, age: number, colors: string[]): string => {
544  // The row is short: the burst opens sideways, back over the bar, and falls within it.
545  const n = 28
546  const delay = (extra: number) => `animation-delay:${(extra - age / 1000).toFixed(2)}s`
547  const css: string[] = []
548  const bits: string[] = []
549  for (let i = 0; i < n; i++) {
550    const a = Math.PI * (0.08 + (0.84 * i) / (n - 1)) + (noise(i, 7) - 0.5) * 0.3
551    const reach = 24 + noise(i, 3) * 130
552    const dx = -Math.abs(Math.cos(a)) * reach - 8
553    const dy = (noise(i, 5) - 0.6) * 16
554    css.push(`@keyframes cf${i}{0%{transform:translate(0,0);opacity:1}55%{transform:translate(${dx.toFixed(1)}px,${dy.toFixed(1)}px);opacity:1}100%{transform:translate(${(dx * 1.12).toFixed(1)}px,${Math.min(10, dy + 9).toFixed(1)}px);opacity:0}}.cf${i}{animation:cf${i} 1.5s cubic-bezier(.2,.7,.4,1) both;${delay(0.04 * (i % 4))}}`)
555    const size = i % 3 === 0 ? 3 : 2
556    bits.push(`<rect class="cf${i}" x="${cx}" y="${cy}" width="${size}" height="${size}" fill="${colors[i % colors.length]}"/>`)
557  }
558  css.push(`@keyframes jp{0%,36%,72%,100%{transform:translateY(0)}18%,54%{transform:translateY(-3px)}}.jp{animation:jp 1.2s ease-out both;${delay(0)}}`)
559  css.push('@media (prefers-reduced-motion: reduce){[class^="cf"]{animation:none;opacity:0}.jp{animation:none}}')
560  return `<style>${css.join('')}</style>${bits.join('')}`
561}
562
563const rowSvg = (f: Flow, W: number, mood: Mood, style: HudStyle, from?: { p: number; age: number }, party?: number): string => {
564  const RH = rowHeight(f)
565  // The title takes what it needs, up to 40% of the row; the bar takes the rest.
566  const title = fitText(f.title, 13, Math.max(60, W * 0.4))
567  const BAR_X = Math.round(16 + textWidth(title, 13) + 12)
568  const BAR_W = Math.max(60, W - BAR_X - 46 - CRAB_W)
569  const y0 = named(f) ? 3 : (H - BAR_H) / 2
570  const mid = y0 + BAR_H / 2
571
572  // Where each group starts and ends on the bar (0-1), and which one is in progress.
573  const spans: { name: string; color: string; f0: number; f1: number; done: number; size: number }[] = []
574  let before = 0
575  f.groups.forEach((g, i) => {
576    const size = g.steps.length
577    spans.push({
578      name: g.name,
579      color: f.isFinished ? DONE : groupColor(g.name, i),
580      f0: f.total ? before / f.total : 0,
581      f1: f.total ? (before + size) / f.total : 1,
582      done: Math.max(0, Math.min(size, f.done - before)),
583      size,
584    })
585    before += size
586  })
587  const live = spans.find(sp => sp.done < sp.size) ?? spans[spans.length - 1]
588  const color = f.isFinished ? DONE : (live?.color ?? ACCENT)
589  // Each stretch of the fill takes its group's color.
590  const colorAt = (x: number): string => (spans.length ? (spans.find(sp => x < sp.f1) ?? spans[spans.length - 1])?.color ?? color : color)
591
592  const bar = barSvg({
593    style,
594    x: BAR_X,
595    y: y0,
596    w: BAR_W,
597    h: BAR_H,
598    p: ratio(f) * 100,
599    colorAt,
600    head: color,
601    live: !f.isFinished,
602    uid: 'p',
603    label: label(f),
604    from,
605    steps: f.total,
606    gaps: spans.slice(1).map(sp => sp.f0),
607  })
608  // Each group's name and count under its stretch.
609  const names = spans.filter(sp => sp.name).map(sp => {
610    const isLive = sp === live && !f.isFinished
611    const x0 = BAR_X + Math.round(BAR_W * sp.f0)
612    const x1 = BAR_X + Math.round(BAR_W * sp.f1)
613    const text = fitText(`${sp.name} ${sp.done}/${sp.size}`, 10.5, Math.max(10, x1 - x0 - 6))
614    return `<rect x="${x0 + 1}" y="${y0 + BAR_H + 4}" width="${Math.max(2, x1 - x0 - 3)}" height="2" rx="1" fill="${sp.color}" opacity="${isLive ? 0.9 : 0.4}"/>
615<text x="${x0 + 2}" y="${y0 + BAR_H + 17}" font-family="${FONT}" font-size="10.5" font-weight="${isLive ? 600 : 400}" fill="${sp.color}" >${xml(text)}</text>`
616  })
617  const percent = `${Math.round(ratio(f) * 100)}%`
618
619  return `<svg xmlns="http://www.w3.org/2000/svg" data-style="${style}" width="${W}" height="${RH}" viewBox="0 0 ${W} ${RH}">
620<style>
621.t{fill:#1f1f1f}.m{fill:#6f6d67}
622@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}}
623${BAR_CSS}
624</style>
625<circle cx="5" cy="${mid}" r="4" fill="${color}"/>
626<text class="t" x="16" y="${mid + 4.5}" font-family="${FONT}" font-size="13" font-weight="500">${xml(title)}</text>
627${bar}
628${names.join('')}
629<text class="m" x="${W - CRAB_W - 6}" y="${mid + 4.5}" text-anchor="end" font-family="${FONT}" font-size="12.5" font-variant-numeric="tabular-nums">${percent}</text>
630${CRAB_CSS}${party !== undefined ? `<g class="jp">${crab(W - CRAB_W + 1, 0, 'm-' + mood, false, true, 0.9)}</g>${partySvg(W - CRAB_W / 2, 12, party, [...spans.map(sp => groupColor(sp.name, spans.indexOf(sp))), DONE, '#F2C14E', '#E46FB0'])}` : crab(W - CRAB_W + 1, 0, 'm-' + mood, false, true, 0.9)}
631</svg>`
632}
633
634// The line between this bar and the mods drawn below it in the band.
635const hairline = (W: number): string =>
636  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="13" viewBox="0 0 ${W} 13"><style>.l{stroke:#bdbbb5}@media (prefers-color-scheme: dark){.l{stroke:#5c5b57}}</style><line class="l" x1="0" y1="6.5" x2="${W}" y2="6.5" stroke-width="1"/></svg>`
637
638// No flow running: the crab and what Claude is doing, in one drawing.
639const moodSvg = (W: number, mood: Mood, text: string): string => `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
640<style>.t{fill:#1f1f1f}.m{fill:#6f6d67}@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}}</style>
641${CRAB_CSS}${crab(0, 0, 'm-' + mood, false, true, 0.9)}
642<text x="${CRAB_W + 8}" y="${H / 2 + 4.5}" font-family="${FONT}" font-size="13"><tspan class="t" font-weight="600">${xml(text.split(' · ')[0] ?? text)}</tspan><tspan class="m">${xml(text.includes(' · ') ? ' · ' + fitText(text.split(' · ').slice(1).join(' · '), 13, W - CRAB_W - 120) : '')}</tspan></text>
643</svg>`
644
645// No flow running and Claude at work: the text stays at the left and the crab
646// walks sideways through the free part of the row, stops to do what Claude is
647// doing, and walks on. One loop of WALK_S seconds; `age` (ms since the scene
648// began) sets a negative delay, so a redraw picks the loop up where it was
649// instead of starting it over. Asleep, the crab keeps still (moodSvg).
650const WALK_S = 16
651const walkSvg = (W: number, mood: Mood, text: string, age: number, note = ''): string => {
652  const [head = text, ...rest] = text.split(' · ')
653  const tail = rest.length ? ' · ' + fitText(rest.join(' · '), 13, Math.max(40, W * 0.45)) : ''
654  const lo = Math.round(textWidth(head + tail, 13) * 1.04 + 26)
655  // The note only when the row has room for it and for the walk.
656  const noteW = note ? Math.round(textWidth(note, 11.5)) + 12 : 0
657  const side = noteW && W - noteW - CRAB_W - lo > 80 ? note : ''
658  const hi = W - CRAB_W - 2 - (side ? noteW : 0)
659  // Where it stops: fixed per mood, so the same scene walks the same way.
660  const seed = [...mood].reduce((n, ch) => n + ch.charCodeAt(0), 0)
661  const xs = [0.12, 0.6, 0.34].map((f, i) => Math.round(lo + Math.max(0, hi - lo) * ((f + noise(seed, i) * 0.3) % 1)))
662  const at = (pct: number, x: number | undefined) => `${pct}%{transform:translateX(${x ?? lo}px)}`
663  const key = [at(0, xs[0]), at(14, xs[1]), at(50, xs[1]), at(62, xs[2]), at(86, xs[2]), at(100, xs[0])].join('')
664  const delay = -((age / 1000) % WALK_S)
665  const run = (name: string, timing: string) => `animation:${name} ${WALK_S}s ${timing} infinite;animation-delay:${delay.toFixed(2)}s`
666  const css = `<style>.mv{${run('mv', 'ease-in-out')}}@keyframes mv{${key}}
667.wk{${run('wk', 'steps(1)')}}.st{${run('sto', 'steps(1)')}}
668@keyframes wk{0%{opacity:1}14%{opacity:0}50%{opacity:1}62%{opacity:0}86%{opacity:1}}@keyframes sto{0%{opacity:0}14%{opacity:1}50%{opacity:0}62%{opacity:1}86%{opacity:0}}
669/* walking: the same costume, its props still and only the legs stepping */
670.walk .la{animation:st .25s steps(1) infinite}.walk .lb{animation:st .25s steps(1) infinite -.125s}.walk .bd{animation:bob .25s steps(1) infinite -.06s}.walk g{transform-box:fill-box}
671/* the smoke behind it: one trail per stretch of the walk, shown only on that stretch */
672.smk{fill:#aeaaa1}@media (prefers-color-scheme: dark){.smk{fill:#9a978f}}
673.sm0{${run('sm0', 'steps(1)')}}.sm1{${run('sm1', 'steps(1)')}}.sm2{${run('sm2', 'steps(1)')}}
674@keyframes sm0{0%{opacity:1}14%{opacity:0}}@keyframes sm1{0%{opacity:0}50%{opacity:1}62%{opacity:0}}@keyframes sm2{0%{opacity:0}86%{opacity:1}}
675.pf{transform-box:fill-box;transform-origin:center;animation:.8s ease-out infinite}.pf1{animation-delay:-.2s}.pf2{animation-delay:-.4s}.pf3{animation-delay:-.6s}
676.pr .pf{animation-name:pfr}.pl .pf{animation-name:pfl}
677@keyframes pfr{from{transform:translate(0,0) scale(.6);opacity:1}to{transform:translate(-14px,-5px) scale(1.8);opacity:0}}
678@keyframes pfl{from{transform:translate(0,0) scale(.6);opacity:1}to{transform:translate(14px,-5px) scale(1.8);opacity:0}}
679@media (prefers-reduced-motion: reduce){.mv,.wk,.st,.walk .la,.walk .lb,.walk .bd{animation:none}.wk,.sm0,.sm1,.sm2{opacity:0}}</style>`
680  const costume = 'm-' + mood
681  // Walking it wears what it wears when it stops, so nothing comes off on the way.
682  const walking = crab(0, 0, costume, false, false, 0.9).replace(`class="c-${costume}"`, `class="c-${costume} walk"`)
683  // Little pixel puffs at its feet on the side it walks away from; they grow and fade.
684  const CW = Math.round((CRAB_W * 0.9) / CRAB_SCALE)
685  const puff = (i: number) => `<g class="pf pf${i}"><rect class="smk" x="1" y="${i % 2}" width="4" height="4"/><rect class="smk" x="0" y="${2 + (i % 2)}" width="3" height="3"/><rect class="smk" x="4" y="${3 + (i % 2)}" width="2" height="2"/></g>`
686  const trail = (i: number, from: number, to: number) => {
687    const right = to >= from
688    return `<g class="sm${i} ${right ? 'pr' : 'pl'}" transform="translate(${right ? -3 : CW - 3}, ${H - 10})">${[0, 1, 2, 3].map(puff).join('')}</g>`
689  }
690  const [x0 = lo, x1 = lo, x2 = lo] = xs
691  const smoke = `${trail(0, x0, x1)}${trail(1, x1, x2)}${trail(2, x2, x0)}`
692  return `<svg xmlns="http://www.w3.org/2000/svg" data-walk="${mood}" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
693<style>.t{fill:#1f1f1f}.m{fill:#6f6d67}@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}}</style>${CRAB_CSS}${css}
694<text x="0" y="${H / 2 + 4.5}" font-family="${FONT}" font-size="13"><tspan class="t" font-weight="600">${xml(head)}</tspan><tspan class="m">${xml(tail)}</tspan></text>
695${side ? `<text class="m" x="${W}" y="${H / 2 + 4}" text-anchor="end" font-family="${FONT}" font-size="11.5" font-variant-numeric="tabular-nums">${xml(side)}</text>` : ''}
696<g class="mv">${smoke}<g class="wk">${walking}</g><g class="st">${crab(0, 0, costume, false, true, 0.9)}</g></g></svg>`
697}
698
699// Claude waiting on the person (a permission, a question): the row turns
700// amber, the crab holds up a sign that pulses, beside the text.
701const WAIT = '#E9A23B'
702const waitSvg = (W: number, text: string): string => {
703  const [head = text, ...rest] = text.split(' · ')
704  const tail = rest.length ? ' · ' + fitText(rest.join(' · '), 13, W - CRAB_W - 160) : ''
705  return `<svg xmlns="http://www.w3.org/2000/svg" data-wait="1" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
706<style>.m{fill:#6f6d67}@media (prefers-color-scheme: dark){.m{fill:#9a9a9a}}.wp{animation:wp 1.2s ease-in-out infinite}@keyframes wp{50%{opacity:.25}}@media (prefers-reduced-motion: reduce){.wp{animation:none}}</style>${CRAB_CSS}
707${crab(0, 0, 'm-think', false, false, 0.9)}
708<g class="wp"><rect x="${CRAB_W - 4}" y="1" width="9" height="11" rx="2" fill="${WAIT}"/><rect x="${CRAB_W - 0.5}" y="3" width="2" height="4.5" fill="#fff"/><rect x="${CRAB_W - 0.5}" y="8.5" width="2" height="2" fill="#fff"/></g>
709<text x="${CRAB_W + 12}" y="${H / 2 + 4.5}" font-family="${FONT}" font-size="13"><tspan font-weight="600" fill="${WAIT}">${xml(head)}</tspan><tspan class="m">${xml(tail)}</tspan></text>
710</svg>`
711}
712
713// The terminal's bar: the style's cells, each in its group's color.
714const barText = (f: Flow, width: number, style: HudStyle): Cell[] => {
715  const at = (x: number): string => {
716    if (f.isFinished) return DONE
717    let before = 0
718    for (const [i, g] of f.groups.entries()) {
719      before += g.steps.length
720      if (f.total && x < before / f.total) return groupColor(g.name, i)
721    }
722    return f.groups.length ? groupColor(f.groups[f.groups.length - 1]?.name ?? '', f.groups.length - 1) : ACCENT
723  }
724  return barCells(style, ratio(f) * 100, width, i => at((i + 0.5) / width), 0, false)
725}
726
727// A text bar's cells as Texts, neighbours of one look joined.
728const cellTexts = (Text: any, cells: Cell[]) =>
729  mergeCells(cells).map((c, i) => (
730    <Text key={i} color={c.c} dimColor={c.dim} backgroundColor={c.bg}>
731      {c.t}
732    </Text>
733  ))
734
735// ---------------------------------------------------------------------------
736// Agents panel: every subagent of the session, plus the tasks the flow planned.
737
738const TIER_COLOR: Record<string, string> = {
739  fable: '#7F77DD',
740  heavy: '#D85A30',
741  careful: '#BA7517',
742  medium: '#378ADD',
743  light: '#1D9E75',
744  other: '#888780',
745}
746
747// A bar that shows the context rather than progress.
748const GREY = '#9a9a96'
749
750// A tier's colour, or a portrait's from the data files.
751const colorOf = (tier: string): string => TIER_COLOR[tier] ?? portraits.portraits[tier]?.color ?? '#888780'
752
753// What each savvy tier runs on, for planned tasks that have no run yet.
754const TIER_MODEL: Record<string, string> = {
755  fable: 'Fable · high',
756  heavy: 'Opus · xhigh',
757  careful: 'Opus · high',
758  medium: 'Opus · medium',
759  light: 'Opus · low',
760}
761
762// USD per million tokens: input, output, cache read, cache write (5-minute TTL).
763// The engine reports tokens, not money, so the panel's cost is an estimate.
764const PRICES: [RegExp, [number, number, number, number]][] = [
765  [/fable|mythos/, [10, 50, 0.25, 12.5]],
766  [/opus-5-5/, [4, 20, 0.2, 5]],
767  [/opus/, [5, 25, 0.5, 6.25]],
768  [/sonnet/, [2, 10, 0.2, 2.5]],
769  [/haiku/, [1, 5, 0.1, 1.25]],
770]
771
772type Usage = {
773  input_tokens: number
774  output_tokens: number
775  cache_read_input_tokens: number
776  cache_creation_input_tokens: number
777}
778
779const priceOf = (model: string): [number, number, number, number] =>
780  PRICES.find(([re]) => re.test(model.toLowerCase()))?.[1] ?? [4, 20, 0.2, 5]
781
782const costOf = (model: string, u: Usage): number => {
783  const [i, o, r, w] = priceOf(model)
784  return (
785    ((u.input_tokens || 0) * i +
786      (u.output_tokens || 0) * o +
787      (u.cache_read_input_tokens || 0) * r +
788      (u.cache_creation_input_tokens || 0) * w) /
789    1e6
790  )
791}
792
793const windowOf = (model: string): number => (/haiku/i.test(model) ? 200_000 : 1_000_000)
794
795// `savvy-careful`, or `savvy-flow:savvy-careful` when the agents ship in a plugin.
796const tierOf = (type: string): string => {
797  const bare = type.replace(/^[^:]*:/, '')
798  const t = bare.replace(/^savvy-/, '').toLowerCase()
799  return t in TIER_COLOR && bare.startsWith('savvy-') ? t : 'other'
800}
801
802const modelName = (id: string): string => {
803  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
804  const [, family = '', major = '', minor] = m ?? []
805  if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
806  return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
807}
808
809const norm = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
810
811const fmtTokens = (n: number): string =>
812  n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
813
814const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
815
816// A turn's time, short: 45s, 1m12s, 1h05m.
817const fmtSpan = (ms: number): string => {
818  const s = Math.max(0, Math.floor(ms / 1000))
819  if (s < 60) return `${s}s`
820  if (s < 3600) return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
821  return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
822}
823
824const fmtTime = (ms: number): string => {
825  const s = Math.max(0, Math.round(ms / 1000))
826  const h = Math.floor(s / 3600)
827  const m = Math.floor((s % 3600) / 60)
828  const ss = String(s % 60).padStart(2, '0')
829  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
830}
831
832const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
833
834type Planned = PlannedTask & { n: number }
835
836const plannedOf = (f: Flow | null, list: AgentRun[]): Planned[] => {
837  if (!f || f.isFinished) return []
838  const started = new Set(list.map(a => norm(a.description)))
839  return (f.tasks ?? []).map((t, i) => ({ ...t, n: i + 1 })).filter(t => !started.has(norm(t.title)))
840}
841
842const totals = (list: AgentRun[], at: number) => {
843  const cost = list.reduce((s, a) => s + a.costUsd, 0)
844  const tokens = list.reduce((s, a) => s + a.tokens, 0)
845  const start = Math.min(...list.map(a => a.startedAt))
846  const end = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
847  return { cost, tokens, time: list.length ? end - start : 0 }
848}
849
850// --- desktop drawings: each row is one SVG, as the band above the prompt is.
851
852const PANE_CSS = `<style>
853.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#6f6d67}.k{fill:#ecebe8}.ln{stroke:#e4e4e1}.tile{fill:#f4f3f0}
854@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.k{fill:#2c2c2b}.ln{stroke:#333331}.tile{fill:#262625}}
855.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
856@media (prefers-reduced-motion: reduce){.live{animation:none}}
857</style>`
858
859// Pixel Clawd from DockCrab (Clawdy): a 24×18 crab on a 30×28 grid, one costume per tier.
860// The body keeps the brand clay; the tier's color lives in the costume's accent.
861const CLAY = '#D97757'
862const INK = '#1F1E1D'
863
864// `cls` puts a pixel in a named group: `bd` (the default) is the body and its
865// costume, `la`/`lb` the leg pairs, anything else a prop with its own motion.
866type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
867
868const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
869  rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
870
871// `armCls` lets a raised claw travel with the prop it holds.
872const crabBody = (f: Fill, armFront = 0, armCls?: string): void => {
873  f(7, 10, 16, 12, CLAY)
874  f(3, 14, 4, 4, CLAY)
875  f(23, 14 + armFront, 4, 4, CLAY, armCls)
876  f(9, 12, 2, 2, INK)
877  f(19, 12, 2, 2, INK)
878  f(7, 22, 2, 4, CLAY, 'la')
879  f(17, 22, 2, 4, CLAY, 'la')
880  f(11, 22, 2, 4, CLAY, 'lb')
881  f(21, 22, 2, 4, CLAY, 'lb')
882}
883
884// The body and legs alone, for a costume that places its own claws.
885const crabTrunk = (f: Fill): void => {
886  f(7, 10, 16, 12, CLAY)
887  f(9, 12, 2, 2, INK)
888  f(19, 12, 2, 2, INK)
889  f(7, 22, 2, 4, CLAY, 'la')
890  f(17, 22, 2, 4, CLAY, 'la')
891  f(11, 22, 2, 4, CLAY, 'lb')
892  f(21, 22, 2, 4, CLAY, 'lb')
893}
894
895// A big magnifier held in front of the right eye, which shows through it zoomed in (a 4×4 eye
896// on paler, magnified skin); `ey`/`el` let both eyes glance and blink. Draw it over a trunk.
897const magnifier = (f: Fill): void => {
898  f(9, 12, 2, 2, CLAY); f(19, 12, 2, 2, CLAY)
899  f(9, 12, 2, 2, INK, 'el')
900  stamp(f, 14, 7, ['..kkkkkk..', '.k......k.', 'k........k', 'k........k', 'k........k', 'k........k', 'k........k', 'k........k', '.k......k.', '..kkkkkk..'], { k: '#3A3A3C' })
901  stamp(f, 15, 8, ['.pppppp.', 'pppppppp', 'pppppppp', 'pppppppp', 'pppppppp', 'pppppppp', 'pppppppp', '.pppppp.'], { p: '#F2C2AE' })
902  f(16, 9, 2, 1, '#fff'); f(16, 10, 1, 1, '#fff')
903  f(17, 10, 4, 4, INK, 'ey'); f(19, 11, 1, 1, '#fff', 'ey')
904  f(23, 15, 1, 1, '#7A4A26'); f(23, 16, 2, 1, '#7A4A26')
905  f(23, 17, 4, 4, CLAY)
906}
907
908// Pure CSS, run by the compositor: no redraws. Periods divide one second, so the
909// once-a-second redraw of a running row restarts them in phase. Every crab walks;
910// each costume adds its prop's own motion on top.
911const CRAB_CSS = `<style>
912.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
913.run .bd{animation:bob .5s steps(1) infinite -.125s}
914.run g{transform-box:fill-box}
915@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
916.c-fable.run{animation:float 1s ease-in-out infinite}
917.c-fable.run .la,.c-fable.run .lb,.c-fable.run .bd{animation:none}
918.c-fable.run .ant{animation:blink 1s steps(1) infinite}
919.c-fable.run .star{animation:blink .5s steps(1) infinite -.25s}
920@keyframes float{50%{transform:translateY(-2px)}}@keyframes blink{50%{opacity:.15}}
921.c-heavy.run .it{animation:scan 1s steps(1) infinite}
922.c-heavy.run .gl{animation:blink 1s steps(1) infinite -.5s}
923@keyframes scan{25%{transform:translate(-1px,1px)}50%{transform:translate(-2px,2px)}75%{transform:translate(-1px,1px)}}
924.c-careful.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite}
925@keyframes twist{50%{transform:rotate(-35deg)}}
926.c-medium.run .pan{transform-origin:0 50%;animation:tilt 1s ease-in-out infinite}
927.c-medium.run .egg{animation:flip 1s ease-in-out infinite}
928@keyframes tilt{20%,40%{transform:rotate(-12deg)}}@keyframes flip{30%{transform:translateY(-5px) scaleY(-1)}60%{transform:translateY(0)}}
929.c-light.run .la{animation-duration:.25s}.c-light.run .lb{animation-duration:.25s;animation-delay:-.125s}
930.c-light.run .flag{transform-origin:0 50%;animation:wave .25s steps(1) infinite}
931@keyframes wave{50%{transform:skewY(-12deg) scaleX(.85)}}
932.c-explore.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite}
933@keyframes fence{50%{transform:rotate(25deg)}}
934@keyframes c1{0%,24%{opacity:0}25%,100%{opacity:1}}@keyframes c2{0%,49%{opacity:0}50%,100%{opacity:1}}@keyframes c3{0%,74%{opacity:0}75%,100%{opacity:1}}
935.c-m-sleep.run .la,.c-m-sleep.run .lb,.c-m-sleep.run .bd,.c-m-think.run .la,.c-m-think.run .lb,.c-m-analyze.run .la,.c-m-analyze.run .lb,.c-m-delegate.run .la,.c-m-delegate.run .lb{animation:none}
936.c-m-sleep.run .bd{animation:breathe 2s ease-in-out infinite}@keyframes breathe{50%{transform:translateY(1px)}}
937.c-m-sleep.run .z1{animation:zz 2s ease-in infinite}.c-m-sleep.run .z2{animation:zz 2s ease-in infinite -1s}
938@keyframes zz{0%{transform:translate(0,3px);opacity:0}30%{opacity:1}100%{transform:translate(2px,-3px);opacity:0}}
939.c-m-think.run .d1{animation:c1 1s steps(1) infinite}.c-m-think.run .d2{animation:c2 1s steps(1) infinite}.c-m-think.run .d3{animation:c3 1s steps(1) infinite}
940.c-m-analyze.run .ey,.c-m-analyze.run .el{transform-origin:center;animation:peek 2s steps(1) infinite}
941@keyframes peek{0%{transform:none}25%{transform:translateX(-1px)}50%{transform:translateX(1px)}75%{transform:none}85%{transform:scaleY(.25)}90%{transform:none}}
942.c-m-work.run .hm{transform-origin:0 100%;animation:hammer .4s ease-in infinite}@keyframes hammer{50%{transform:rotate(-40deg)}}
943.c-m-work.run .sp{animation:blink .4s steps(1) infinite}
944.c-m-delegate.run .w1{animation:blink .5s steps(1) infinite}.c-m-delegate.run .w2{animation:blink .5s steps(1) infinite -.25s}
945@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
946</style>`
947
948// Portraits from data: each folder under ~/.claude/mods/ may hold a
949// crabhud-portraits.json (see hooks/portraits.ts), read once per session.
950const PORTRAITS_FILE = 'crabhud-portraits.json'
951let portraits: PortraitSet = NO_PORTRAITS
952const portraitCss = (): string => {
953  const css = Object.values(portraits.portraits).map(p => p.css).filter(Boolean).join('\n')
954  return css ? `<style>${css}</style>` : ''
955}
956
957async function loadPortraits($: EngineInterface): Promise<void> {
958  const [configDir, home, profile] = await Promise.all([$.env.get('CLAUDE_CONFIG_DIR'), $.env.get('HOME'), $.env.get('USERPROFILE')])
959  const base = (home || profile) && `${home || profile}/.claude`
960  const roots = [...new Set([configDir, base].filter((d): d is string => Boolean(d)).map(d => `${d.replace(/[\\/]+$/, '')}/mods`))]
961  let set = NO_PORTRAITS
962  const problems: string[] = []
963  for (const root of roots) {
964    const entries = await $.fs.list(root).catch(() => [])
965    for (const name of entries.filter(en => en.kind !== 'file').map(en => en.name).sort()) {
966      const path = `${root}/${name}/${PORTRAITS_FILE}`
967      const text = await $.fs.read(path).catch(() => null)
968      if (text === null) continue
969      const read = readPortraits(text, costume => costume in COSTUMES || costume in set.portraits)
970      set = mergePortraits(set, read.set)
971      problems.push(...read.problems.map(p => `${name}/${PORTRAITS_FILE}: ${p}`))
972    }
973  }
974  portraits = set
975  if (problems.length) $.ui.toast(`crabhud: ${problems.slice(0, 3).join('; ')}${problems.length > 3 ? ` (+${problems.length - 3})` : ''}`)
976}
977
978const COSTUMES: Record<string, (f: Fill, t: string) => void> = {
979  // Fable: astronaut in a glass dome; floats instead of walking, the antenna and the star blink.
980  fable: (f, t) => {
981    crabBody(f)
982    f(6, 7, 18, 1, '#E6E8EE'); f(5, 8, 1, 14, '#E6E8EE'); f(24, 8, 1, 14, '#E6E8EE'); f(6, 22, 18, 1, '#C9CCD2')
983    f(6, 8, 18, 14, 'rgba(169,214,245,.32)'); f(8, 9, 2, 1, '#fff'); f(8, 10, 1, 2, '#fff')
984    f(14, 4, 2, 3, '#C9CCD2'); f(14, 2, 2, 2, t, 'ant'); f(13, 18, 4, 2, t)
985    f(27, 3, 1, 3, '#F5C542', 'star'); f(26, 4, 3, 1, '#F5C542', 'star')
986  },
987  // Heavy: detective with a deerstalker; the magnifier sweeps and glints.
988  heavy: (f, t) => {
989    crabBody(f, -4, 'it')
990    stamp(f, 6, 3, ['......bbbbbb......', '....bbcbbcbbbb....', '...bbbbbbbbbbbb...', '..bcbbcbbcbbcbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: '#7A4A26', c: '#A0703F', d: '#5A3519' })
991    f(6, 9, 18, 1, t)
992    stamp(f, 23, 1, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'it')
993    f(24, 2, 3, 3, 'rgba(169,214,245,.7)', 'it'); f(25, 6, 1, 4, '#7A4A26', 'it'); f(24, 2, 1, 1, '#fff', 'gl')
994  },
995  // Careful: engineer in a hard hat; the wrench turns a bolt.
996  careful: (f, t) => {
997    crabBody(f)
998    stamp(f, 6, 4, ['.....yyyyyyyy.....', '...yyyyyhhyyyyy...', '..yyyyyyhhyyyyyy..', '..yyyyyyhhyyyyyy..', '.yyyyyyyhhyyyyyyy.', 'dddddddddddddddddd'], { y: '#F5C542', h: '#FBE08A', d: '#C99A1E' })
999    f(13, 5, 4, 2, t)
1000    stamp(f, 0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it')
1001  },
1002  // Medium: chef, the toque traced from DockCrab's Sprites.chefHat; tosses the omelette.
1003  medium: (f, t) => {
1004    crabBody(f, -4, 'pan')
1005    stamp(f, 6, 0, ['........lll.......', '.......lllll......', '.wwwwgwwwwwwgwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwgwwwwwggwwwww', '.wwwwgwwwwwggwwwww', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.'], { w: '#F4F3EE', l: '#F7F6F2', g: '#D2D1C8', b: t, d: '#B45F43' })
1006    f(22, 8, 7, 2, '#4A4A48', 'pan'); f(26, 10, 1, 1, '#4A4A48', 'pan'); f(24, 7, 3, 1, '#F5B731', 'egg')
1007  },
1008  // Light: racer in a helmet; runs at double pace, the checkered flag flutters.
1009  light: (f, t) => {
1010    crabBody(f, -4)
1011    stamp(f, 6, 5, ['....rrrrrrrrrr....', '..rrrrrrwwrrrrrr..', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.kkkkkkkkkkkkkkkkr'], { r: t, w: '#F8F6F1', k: INK })
1012    f(25, 1, 1, 9, '#8E929A')
1013    stamp(f, 26, 1, ['wkwk', 'kwkw', 'wkwk'], { w: '#F8F6F1', k: INK }, 'flag')
1014  },
1015  // Explore: pirate scouting the code; the cutlass fences.
1016  explore: f => {
1017    crabBody(f)
1018    stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: '#F5C542' })
1019    f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
1020    f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
1021  },
1022  // The corner crab's moods. Sleeping: eyes shut, claws down, Zs drifting up.
1023  'm-sleep': f => {
1024    f(7, 10, 16, 12, CLAY); f(3, 17, 4, 4, CLAY); f(23, 17, 4, 4, CLAY)
1025    f(9, 13, 2, 1, INK); f(19, 13, 2, 1, INK)
1026    f(7, 22, 2, 4, CLAY, 'la'); f(17, 22, 2, 4, CLAY, 'la'); f(11, 22, 2, 4, CLAY, 'lb'); f(21, 22, 2, 4, CLAY, 'lb')
1027    stamp(f, 22, 3, ['zzzz', '..z.', '.z..', 'zzzz'], { z: '#9a9a96' }, 'z1')
1028    stamp(f, 26, 0, ['zzz', '.z.', 'zzz'], { z: '#9a9a96' }, 'z2')
1029  },
1030  // Thinking: eyes up, a claw at the chin, and a white thought bubble with a grey rim (it reads
1031  // on light and dark) whose three dots fill in one by one.
1032  'm-think': f => {
1033    f(7, 10, 16, 12, CLAY); f(3, 14, 4, 4, CLAY); f(23, 16, 4, 4, CLAY); f(21, 18, 3, 2, CLAY)
1034    f(9, 11, 2, 2, INK); f(19, 11, 2, 2, INK)
1035    f(7, 22, 2, 4, CLAY, 'la'); f(17, 22, 2, 4, CLAY, 'la'); f(11, 22, 2, 4, CLAY, 'lb'); f(21, 22, 2, 4, CLAY, 'lb')
1036    const bubble = { k: '#8A8984', w: '#FFFFFF' }
1037    stamp(f, 13, 7, ['kk', 'kk'], bubble)
1038    stamp(f, 15, 4, ['.k.', 'kwk', '.k.'], bubble)
1039    stamp(f, 17, 0, ['.kkkkkkkkkkk.', 'kwwwwwwwwwwwk', 'kwwwwwwwwwwwk', 'kwwwwwwwwwwwk', 'kwwwwwwwwwwwk', '.kkkkkkkkkkk.'], bubble)
1040    f(19, 2, 2, 2, '#3A3A3C', 'd1'); f(22, 2, 2, 2, '#3A3A3C', 'd2'); f(25, 2, 2, 2, '#3A3A3C', 'd3')
1041  },
1042  // Analyzing: the big magnifier at the eye; the eyes glance left and right, then blink.
1043  'm-analyze': f => {
1044    crabTrunk(f)
1045    f(3, 14, 4, 4, CLAY)
1046    magnifier(f)
1047  },
1048  // Working: a hard hat; the hammer comes down, sparks fly.
1049  'm-work': f => {
1050    crabBody(f, -4)
1051    stamp(f, 6, 5, ['.....yyyyyyyy.....', '...yyyyyyyyyyyy...', '..yyyyyyyyyyyyyy..', 'dddddddddddddddddd'], { y: '#F5C542', d: '#C99A1E' })
1052    f(26, 5, 1, 6, '#7A4A26', 'hm'); f(24, 3, 5, 3, '#8E929A', 'hm')
1053    f(28, 12, 1, 1, '#F5C542', 'sp'); f(26, 14, 1, 1, '#F5C542', 'sp'); f(29, 15, 1, 1, '#F5C542', 'sp')
1054  },
1055  // Coordinating: a megaphone, calling the subagents to work.
1056  'm-delegate': f => {
1057    crabBody(f, -4)
1058    f(23, 8, 3, 3, '#D97757'); f(25, 7, 2, 5, '#E6E8EE'); f(27, 5, 2, 9, '#E6E8EE')
1059    f(29, 6, 1, 1, '#8f8cf4', 'w1'); f(29, 9, 1, 1, '#8f8cf4', 'w2'); f(29, 12, 1, 1, '#8f8cf4', 'w1')
1060  },
1061  other: f => crabBody(f),
1062}
1063
1064const costumeOf = (type: string): string =>
1065  type === 'Explore' ? 'explore' : (portraitFor(portraits, type) ?? tierOf(type))
1066
1067const CRAB_SCALE = 1.1
1068
1069// Body and props nest inside `bd` so a prop rides the bob and adds its own motion;
1070// legs stay outside it and step on their own.
1071const crab = (x: number, y: number, costume: string, dim = false, isWalking = false, scale = CRAB_SCALE): string => {
1072  const groups = new Map<string, string[]>([['bd', []]])
1073  const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
1074    if (!groups.has(cls)) groups.set(cls, [])
1075    groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
1076  }
1077  const own = portraits.portraits[costume]
1078  const draw =
1079    COSTUMES[costume] ??
1080    (own ? (g: Fill, t: string) => own.pixels.forEach(([px, py, w, h, c, cls]) => g(px, py, w, h, c === 'role' ? t : c, cls)) : (g: Fill) => crabBody(g))
1081  draw(f, colorOf(costume))
1082  const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
1083  const isFront = (k: string) => k.split(' ').includes('fr')
1084  const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb' && !isFront(k))
1085  const front = [...groups.keys()].filter(isFront)
1086  const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
1087  return `<g transform="translate(${x},${y}) scale(${scale})" opacity="${dim ? 0.45 : 1}" shape-rendering="crispEdges"><g class="c-${costume}${isWalking ? ' run' : ''}">${body}${group('la')}${group('lb')}${front.map(group).join('')}</g></g>`
1088}
1089
1090const statusMark = (x: number, y: number, status: string, color: string): string => {
1091  if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
1092  if (status === 'done') return `<path d="M${x - 5} ${y}l3.5 3.5 6.5-7" fill="none" stroke="#3B9C5F" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`
1093  if (status === 'failed') return `<path d="M${x - 4} ${y - 4}l8 8M${x + 4} ${y - 4}l-8 8" stroke="#D0453F" stroke-width="1.8" stroke-linecap="round"/>`
1094  return `<circle cx="${x}" cy="${y}" r="5" fill="none" stroke="#9a9a96" stroke-width="1.4"/><path d="M${x} ${y - 2.5}v2.8l1.8 1.2" fill="none" stroke="#9a9a96" stroke-width="1.4" stroke-linecap="round"/>`
1095}
1096
1097const svg = (W: number, H: number, body: string, attrs = ''): string =>
1098  `<svg xmlns="http://www.w3.org/2000/svg"${attrs} width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}<style>${BAR_CSS}</style>${CRAB_CSS}${portraitCss()}${body}</svg>`
1099
1100// The pane's own title already says "Agents": the header names the flow, if any.
1101const headerSvg = (W: number, title: string, t: ReturnType<typeof totals>): string => {
1102  const s = tr()
1103  const gap = 6
1104  const tw = (W - gap * 2) / 3
1105  const top = title ? 28 : 0
1106  const tile = (i: number, k: string, v: string) =>
1107    `<rect class="tile" x="${i * (tw + gap)}" y="${top}" width="${tw}" height="40" rx="8"/>
1108<text class="s" x="${i * (tw + gap) + 9}" y="${top + 16}" font-family="${FONT}" font-size="11">${k}</text>
1109<text class="t" x="${i * (tw + gap) + 9}" y="${top + 33}" font-family="${FONT}" font-size="15" font-weight="600" font-variant-numeric="tabular-nums">${v}</text>`
1110  return svg(
1111    W,
1112    headerHeight(title),
1113    `${title ? `<text class="t" x="0" y="15" font-family="${FONT}" font-size="14" font-weight="600">${xml(fitText(title, 14, W))}</text>` : ''}
1114${tile(0, s.cost, '≈' + fmtCost(t.cost))}${tile(1, s.tokens, fmtTokens(t.tokens))}${tile(2, s.time, fmtTime(t.time))}`,
1115  )
1116}
1117
1118const headerHeight = (title: string): number => (title ? 72 : 44)
1119
1120// The panel's line of the moment: this answer (actions, time) at the left,
1121// today's and this month's spend (every chat on this machine) at the right.
1122type UsageLine = { turn: string; spend: string }
1123function usageLine(turn: TurnStats | null, days: Record<string, number>, t: number): UsageLine {
1124  const s = tr()
1125  const { today, month } = spendNow(days, t)
1126  return {
1127    turn: turn ? `${s.thisTurn}: ${s.actionsN(turn.actions)} · ${fmtSpan((turn.endedAt ?? t) - turn.startedAt)}` : '',
1128    spend: month > 0 ? `${s.today} ≈${fmtCost(today)} · ${s.month} ≈${fmtCost(month)}` : '',
1129  }
1130}
1131
1132const usageSvg = (W: number, u: UsageLine): string => {
1133  const right = u.spend ? textWidth(u.spend, 12) + 12 : 0
1134  return svg(
1135    W,
1136    22,
1137    `${u.turn ? `<text class="s" x="0" y="15" font-family="${FONT}" font-size="12" font-variant-numeric="tabular-nums">${xml(fitText(u.turn, 12, W - right))}</text>` : ''}
1138${u.spend ? `<text class="s" x="${W}" y="15" text-anchor="end" font-family="${FONT}" font-size="12" font-variant-numeric="tabular-nums">${xml(u.spend)}</text>` : ''}`,
1139  )
1140}
1141
1142// The flow's steps, group by group: done, in progress, still to come. Two columns when wide.
1143const STEP_ROW = 18
1144const stepColumns = (W: number): number => (W >= 380 ? 2 : 1)
1145const stepsHeight = (f: Flow, W: number): number =>
1146  f.groups.reduce((h, g) => h + 22 + Math.ceil(g.steps.length / stepColumns(W)) * STEP_ROW, 4)
1147
1148const stepsSvg = (W: number, f: Flow): string => {
1149  const n = stepColumns(W)
1150  const colW = W / n
1151  // Each step carries its number in the whole flow (1 to the bar's total), in a
1152  // column as wide as the longest number.
1153  const numW = Math.ceil(String(stepsOf(f).length).length * 6.6) + 5
1154  const out: string[] = []
1155  let y = 0
1156  let index = 0
1157  f.groups.forEach((g, gi) => {
1158    const color = groupColor(g.name, gi)
1159    const gDone = Math.max(0, Math.min(g.steps.length, f.done - index))
1160    out.push(`<text x="0" y="${y + 15}" font-family="${FONT}" font-size="12" font-weight="600" fill="${color}">${xml(fitText(g.name, 12, W - 60))}</text>
1161<text class="m" x="${W}" y="${y + 15}" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${f.isFinished ? g.steps.length : gDone}/${g.steps.length}</text>`)
1162    y += 22
1163    // Down the first column, then the second: the list reads in order.
1164    const rows = Math.ceil(g.steps.length / n)
1165    g.steps.forEach((name, i) => {
1166      const at = index + i
1167      const state = f.isFinished || at < f.done ? 'done' : at === f.done ? 'now' : 'next'
1168      const x = Math.floor(i / rows) * colW
1169      const ry = y + (i % rows) * STEP_ROW
1170      const mark =
1171        state === 'done'
1172          ? `<path d="M${x + 1} ${ry + 9}l3 3 6-6.5" fill="none" stroke="#3B9C5F" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>`
1173          : state === 'now'
1174            ? `<circle class="live" cx="${x + 5}" cy="${ry + 9}" r="3.5" fill="${color}"/>`
1175            : `<circle cx="${x + 5}" cy="${ry + 9}" r="3.5" fill="none" stroke="#9a9a96" stroke-width="1.2"/>`
1176      out.push(`${mark}<text class="m" x="${x + 14 + numW}" y="${ry + 13}" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${at + 1}.</text>
1177<text class="${state === 'next' ? 'm' : 't'}" x="${x + 18 + numW}" y="${ry + 13}" font-family="${FONT}" font-size="12" font-weight="${state === 'now' ? 600 : 400}">${xml(fitText(name, 12, colW - 24 - numW))}</text>`)
1178    })
1179    y += Math.ceil(g.steps.length / n) * STEP_ROW
1180    index += g.steps.length
1181  })
1182  return svg(W, stepsHeight(f, W), out.join('\n'))
1183}
1184
1185// The task's own progress when the worker reports steps; a finished run is full.
1186const progressOf = (a: AgentRun): number | null => {
1187  if (a.status === 'done') return 1
1188  if (a.stepTotal) return Math.min(1, (a.stepDone ?? 0) / a.stepTotal)
1189  return null
1190}
1191
1192const ctxOf = (a: AgentRun): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0)
1193
1194const agentSvg = (W: number, a: AgentRun, at: number, style: HudStyle): string => {
1195  const s = tr()
1196  const tier = tierOf(a.type)
1197  const color = colorOf(costumeOf(a.type))
1198  const ctx = ctxOf(a)
1199  const textW = W - 42 - 22
1200  const meta = [a.effort ? `${modelName(a.model)} · ${a.effort}` : modelName(a.model)]
hooks/bar-styles.ts 363 lines
1// The bar styles (/crabhud style; credits in THIRD_PARTY_NOTICES.md), for every
2// bar crabhud draws: the progress bar, the HUD's gauges and the agents panel.
3// `barSvg` draws one as SVG (the desktop and the remote surfaces), `barCells`
4// as text cells (the terminal). Pure, no `$`.
5
6import type { HudStyle } from '../types'
7
8export type BarStyle = HudStyle
9
10/** One cell of a text bar: its glyph, colour, and background (solid's fill). */
11export type Cell = { t: string; c?: string; bg?: string; dim?: boolean }
12
13export const STYLES: readonly BarStyle[] = ['pixel', 'segments', 'line', 'solid', 'dots', 'spark', 'classic']
14
15export const STYLE_NAMES: Record<BarStyle, string> = {
16  pixel: 'Pixel',
17  segments: 'Segments',
18  line: 'Line',
19  solid: 'Solid',
20  dots: 'Dots',
21  spark: 'Spark',
22  classic: 'Classic',
23}
24
25// What each looks like, for /crabhud style and the picker, per language.
26export const STYLE_HINTS: Record<'en' | 'pt-BR', Record<BarStyle, string>> = {
27  en: {
28    pixel: 'a rounded track, twinkling pixels, the % on a tag',
29    segments: 'a block per step',
30    line: 'a thin line with a glowing head',
31    solid: 'a filled bar with a light sweep',
32    dots: 'a dot per step',
33    spark: 'a meter that rises as it fills',
34    classic: 'solid blocks: █ ▓ ▒ then dots',
35  },
36  'pt-BR': {
37    pixel: 'régua arredondada, pontinhos que brilham, a % numa etiqueta',
38    segments: 'um bloco por etapa',
39    line: 'linha fina com a ponta brilhando',
40    solid: 'barra cheia com um brilho que passa',
41    dots: 'um ponto por etapa',
42    spark: 'medidor que sobe conforme enche',
43    classic: 'blocos cheios: █ ▓ ▒ e pontos depois',
44  },
45}
46
47export const asStyle = (v: unknown): BarStyle | null =>
48  typeof v === 'string' && (STYLES as readonly string[]).includes(v.toLowerCase()) ? (v.toLowerCase() as BarStyle) : null
49
50const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
51const SHINE = '#F2F0EA'
52
53// Deterministic noise so the dither does not shimmer between redraws.
54const noise = (x: number, y: number): number => {
55  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
56  return s - Math.floor(s)
57}
58const charEm = (ch: string): number => (/[\s.,:;'|!il1()[\]/]/.test(ch) ? 0.3 : /[A-Zm%]/.test(ch) ? 0.72 : 0.56)
59const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
60const r1 = (v: number) => Math.round(v * 10) / 10
61
62/**
63 * The classes the bars use: the track (bk, its border bkb), what is still empty
64 * (be, bes), the step marks (btk), the twinkle (bt0-bt3) and a live head's pulse
65 * (bpu), prefixed so they keep clear of the panel's own classes. Goes in the drawing's <style> once.
66 */
67export const BAR_CSS = `.bk{fill:#f7f6f2}.bkb{stroke:#d9d7d0}.be{fill:#d6d4cd}.bes{stroke:#c9c7c0}.btk{fill:#c4c2bb}
68@media (prefers-color-scheme: dark){.bk{fill:#2c2c2c}.bkb{stroke:none}.be{fill:#454542}.bes{stroke:#5a5a56}.btk{fill:#5a5a5a}}
69.bt0,.bt1,.bt2,.bt3{animation:btw 2.2s ease-in-out infinite}.bt1{animation-duration:2.8s;animation-delay:-.7s}.bt2{animation-duration:1.9s;animation-delay:-1.3s}.bt3{animation-duration:3.3s;animation-delay:-.4s}
70@keyframes btw{0%,100%{opacity:1}50%{opacity:.3}}
71.bpu{animation:bpu 1.1s ease-in-out infinite}@keyframes bpu{0%,100%{opacity:1}50%{opacity:.35}}
72@media (prefers-reduced-motion: reduce){.bt0,.bt1,.bt2,.bt3,.bpu,.bsw{animation:none}}`
73
74export type BarSvgInput = {
75  style: BarStyle
76  x: number
77  y: number
78  w: number
79  h: number
80  /** Percent filled, 0-100. */
81  p: number
82  /** The colour of the fill at `f` (0-1 along the bar): a group's, a theme's. */
83  colorAt: (f: number) => string
84  /** The head's colour (the tag's, the pulse's); defaults to the colour at the fill's end. */
85  head?: string
86  /** Claude working: heads pulse, solid's light sweeps. */
87  live: boolean
88  /** Unique within the drawing: names the clip and the sweep. */
89  uid: string
90  /** Text on a tag at the fill's end (the progress bar's 5/14, the HUD's 31%). */
91  label?: string
92  /** The steps the bar counts: segments and dots draw one each when they fit. */
93  steps?: number
94  /** Where one group ends and the next begins (0-1): a gap in the track. */
95  gaps?: number[]
96  /** The percent before the last step and how long ago it moved (ms): the tag
97   *  glides from there, picking up where it was if the drawing is redrawn. */
98  from?: { p: number; age: number }
99}
100
101/** How long the tag takes to glide to its new place. */
102export const GLIDE_MS = 450
103
104/** One bar, as an SVG group at (x, y); its classes come from BAR_CSS. */
105export function barSvg(b: BarSvgInput): string {
106  const { style, w, h, uid } = b
107  const p = Math.max(0, Math.min(100, b.p))
108  const fillW = Math.round((w * p) / 100)
109  const head = b.head ?? b.colorAt(Math.max(0, p / 100 - 0.001))
110  const live = b.live && p < 100
111  const pulse = live ? ' class="bpu"' : ''
112  const rounded = style !== 'classic'
113  const out: string[] = []
114  let ticks = ''
115  // Segments and dots count the steps when there are few enough to draw.
116  const unit = h >= 12 ? 14 : h >= 8 ? 9 : 7
117  const fits = (count: number) => count >= 2 && w / count >= (style === 'dots' ? Math.max(5, h * 0.75) : 5)
118  const count = b.steps && fits(b.steps) ? b.steps : Math.max(4, Math.min(40, Math.round(w / unit)))
119
120  switch (style) {
121    case 'segments': {
122      const gap = h >= 10 ? 2 : 1.5
123      const sw = (w - gap * (count - 1)) / count
124      const filled = Math.round((p / 100) * count)
125      const rx = Math.min(2.5, h / 3)
126      for (let i = 0; i < count; i++) {
127        const x = r1(i * (sw + gap))
128        const isHead = i === filled - 1 && live
129        out.push(
130          i < filled
131            ? `<rect${isHead ? pulse : ''} x="${x}" width="${r1(sw)}" height="${h}" rx="${rx}" fill="${b.colorAt((i + 0.5) / count)}"/>`
132            : `<rect class="be" x="${x}" width="${r1(sw)}" height="${h}" rx="${rx}"/>`,
133        )
134      }
135      break
136    }
137    case 'line': {
138      const lh = Math.max(2, Math.round(h * 0.24))
139      const ly = (h - lh) / 2
140      out.push(`<rect class="be" y="${ly}" width="${w}" height="${lh}" rx="${lh / 2}"/>`)
141      out.push(...runs(fillW, b.colorAt, w).map(r => `<rect x="${r.x}" y="${ly}" width="${r.w}" height="${lh}" fill="${r.c}"/>`))
142      if (fillW > 0 && !b.label) {
143        const r = Math.max(1.6, Math.min(h / 2 - 0.5, lh * 1.4))
144        const cx = Math.min(w - r, Math.max(r, fillW))
145        out.push(`<g${pulse}><circle cx="${cx}" cy="${h / 2}" r="${r1(r * 1.9)}" fill="${head}" opacity=".25"/><circle cx="${cx}" cy="${h / 2}" r="${r1(r)}" fill="${head}"/></g>`)
146      }
147      ticks = stepTicks(b.steps, w, fillW + 6, h)
148      break
149    }
150    case 'solid': {
151      out.push(...runs(fillW, b.colorAt, w).map(r => `<rect x="${r.x}" width="${r.w}" height="${h}" fill="${r.c}"/>`))
152      if (live && fillW > 8) {
153        const sweep = Math.round(Math.max(16, Math.min(60, fillW * 0.35)))
154        out.push(`<defs><linearGradient id="g${uid}"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".5" stop-color="#fff" stop-opacity=".55"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><clipPath id="f${uid}"><rect width="${fillW}" height="${h}"/></clipPath></defs>
155<style>.sw${uid}{animation:sw${uid} 2.4s linear infinite}@keyframes sw${uid}{from{transform:translateX(${-sweep}px)}to{transform:translateX(${fillW}px)}}</style>
156<g clip-path="url(#f${uid})"><rect class="bsw sw${uid}" width="${sweep}" height="${h}" fill="url(#g${uid})"/></g>`)
157      }
158      ticks = stepTicks(b.steps, w, fillW + 4, h)
159      break
160    }
161    case 'dots': {
162      const pitch = w / count
163      const r = r1(Math.max(1.4, Math.min(h / 2 - (h >= 10 ? 2 : 0.6), pitch * 0.34)))
164      const v = (p / 100) * count
165      const ring = (cx: number) => `<circle class="bes" cx="${cx}" cy="${h / 2}" r="${r1(r - 0.5)}" fill="none" stroke-width="1"/>`
166      for (let i = 0; i < count; i++) {
167        const cx = r1(pitch * (i + 0.5))
168        const c = b.colorAt((i + 0.5) / count)
169        if (v - i >= 1) out.push(`<circle cx="${cx}" cy="${h / 2}" r="${r}" fill="${c}"/>`)
170        else if (v - i > 0.25) out.push(`${ring(cx)}<path${pulse} d="M${cx} ${h / 2 - r}a${r} ${r} 0 0 0 0 ${2 * r}z" fill="${c}"/>`)
171        else if (live && i === Math.floor(v)) out.push(`${ring(cx)}<circle class="bpu" cx="${cx}" cy="${h / 2}" r="${r1(r * 0.55)}" fill="${head}"/>`)
172        else out.push(ring(cx))
173      }
174      break
175    }
176    case 'spark': {
177      const pitch = h >= 12 ? 5 : 4
178      const n = Math.max(4, Math.floor(w / pitch))
179      const bw = Math.max(1.5, pitch - 1.5)
180      const pad = h >= 10 ? 2 : 0.5
181      const filled = Math.round((p / 100) * n)
182      for (let i = 0; i < n; i++) {
183        const x = r1(i * pitch + (w - n * pitch) / 2 + 0.75)
184        if (i < filled) {
185          // Taller toward the head: the meter rises along what is filled.
186          const bh = r1((h - pad * 2) * (0.25 + 0.75 * ((i + 1) / filled)))
187          out.push(`<rect${i === filled - 1 && live ? pulse : ''} x="${x}" y="${r1(h - pad - bh)}" width="${bw}" height="${bh}" rx="${Math.min(1, bw / 2)}" fill="${b.colorAt((i + 0.5) / n)}"/>`)
188        } else out.push(`<rect class="be" x="${x}" y="${r1(h - pad - 1.5)}" width="${bw}" height="1.5" rx=".75"/>`)
189      }
190      break
191    }
192    case 'classic': {
193      // Blocks: solid up to the fill, a dithered edge (▓ ▒), a dot per empty cell.
194      const cw = Math.max(3, Math.round(h * 0.55))
195      const n = Math.max(4, Math.floor(w / cw))
196      const filled = Math.round((p / 100) * n)
197      const fade = filled < n ? (filled >= 4 ? 2 : filled >= 2 ? 1 : 0) : 0
198      for (let i = 0; i < n; i++) {
199        const x = i * cw
200        if (i >= filled) out.push(`<rect class="be" x="${r1(x + cw / 2 - 0.75)}" y="${r1(h / 2 - 0.75)}" width="1.5" height="1.5"/>`)
201        else {
202          const op = fade >= 1 && i === filled - 1 ? 0.4 : fade === 2 && i === filled - 2 ? 0.7 : 1
203          out.push(`<rect${op < 1 && live && i === filled - 1 ? pulse : ''} x="${x}" width="${cw - 0.5}" height="${h}" fill="${b.colorAt((i + 0.5) / n)}" opacity="${op}"/>`)
204        }
205      }
206      break
207    }
208    default: {
209      // Pixel: a dithered fill, sparse at the start and dense toward the head, twinkling.
210      const C = h >= 14 ? 3 : h >= 8 ? 2.5 : 2
211      const D = h >= 14 ? 2 : h >= 8 ? 1.6 : 1.4
212      const cols = Math.floor(fillW / C)
213      const rows = Math.max(1, Math.floor(h / C))
214      const oy = (h - rows * C) / 2
215      for (let c = 0; c < cols; c++) {
216        const density = 0.35 + 0.6 * Math.pow(c / Math.max(1, cols), 1.2)
217        const color = b.colorAt((c * C) / w)
218        for (let r = 0; r < rows; r++)
219          if (noise(c + b.x, r) < density)
220            out.push(`<rect class="bt${Math.floor(noise(r, c + b.x) * 4)}" x="${r1(c * C + 0.6)}" y="${r1(r * C + oy + 0.4)}" width="${D}" height="${D}" fill="${color}"/>`)
221      }
222      if (fillW > 0 && !b.label) out.push(`<rect x="${Math.max(0, fillW - 3)}" width="3" height="${h}" rx="1.5" fill="${head}"/>`)
223      ticks = stepTicks(b.steps, w, fillW + 4, h)
224    }
225  }
226
227  const gaps = style === 'segments' || style === 'dots' ? [] : (b.gaps ?? []).map(f => `<rect class="bk" x="${Math.round(w * f) - 1}" width="2.5" height="${h}"/>`)
228  let tag = ''
229  if (b.label) {
230    const size = h >= 14 ? 11 : 10
231    const tw = Math.min(w, Math.round(16 + textWidth(b.label, size) * 1.08))
232    const tx = Math.max(0, Math.min(w - tw, fillW - tw))
233    const fromTx = b.from ? Math.max(0, Math.min(w - tw, Math.round((w * Math.max(0, Math.min(100, b.from.p))) / 100) - tw)) : tx
234    const glide = b.from && b.from.age >= 0 && b.from.age < GLIDE_MS && Math.abs(fromTx - tx) > 0.5
235    tag = `<rect x="${tx}" width="${tw}" height="${h}" rx="${h / 2}" fill="${head}"/><text x="${tx + tw / 2}" y="${r1(h / 2 + size * 0.36)}" text-anchor="middle" font-family="${FONT}" font-size="${size}" font-weight="600" fill="#fff" font-variant-numeric="tabular-nums">${b.label.replace(/&/g, '&amp;').replace(/</g, '&lt;')}</text>`
236    // The tag glides from where it was; a negative delay by the step's age keeps a redraw mid-glide from starting it over.
237    if (glide && b.from)
238      tag = `<style>@keyframes bg${uid}{from{transform:translateX(${fromTx - tx}px)}}.bg${uid}{animation:bg${uid} ${GLIDE_MS}ms cubic-bezier(.22,1,.36,1) both;animation-delay:-${Math.round(b.from.age)}ms}@media (prefers-reduced-motion: reduce){.bg${uid}{animation:none}}</style><g class="bg${uid}">${tag}</g>`
239  }
240  // Pixel and solid sit in the rounded track; the others draw their own empties.
241  const tracked = style === 'pixel' || style === 'solid'
242  const clip = rounded ? `<defs><clipPath id="c${uid}"><rect width="${w}" height="${h}" rx="${h / 2}"/></clipPath></defs>` : ''
243  return `<g transform="translate(${b.x},${b.y})">${clip}${tracked ? `<rect class="bk" width="${w}" height="${h}" rx="${h / 2}"/>` : ''}<g${rounded ? ` clip-path="url(#c${uid})"` : ''}>${out.join('')}${ticks}${gaps.join('')}</g>${tracked ? `<rect class="bkb" x=".5" y=".5" width="${w - 1}" height="${h - 1}" rx="${(h - 1) / 2}" fill="none"/>` : ''}${tag}</g>`
244}
245
246// The fill as runs of one colour, 2px at a time, so a group's or a theme's colours follow along.
247function runs(fillW: number, colorAt: (f: number) => string, w: number): { x: number; w: number; c: string }[] {
248  const out: { x: number; w: number; c: string }[] = []
249  for (let x = 0; x < fillW; x += 2) {
250    const c = colorAt(x / w)
251    const last = out[out.length - 1]
252    if (last && last.c === c) last.w = Math.min(fillW, x + 2) - last.x
253    else out.push({ x, w: Math.min(2, fillW - x), c })
254  }
255  return out
256}
257
258// A small mark per step still to come.
259function stepTicks(steps: number | undefined, w: number, from: number, h: number): string {
260  if (!steps || steps < 2 || w / steps < 6 || h < 10) return ''
261  const out: string[] = []
262  for (let i = 1; i < steps; i++) {
263    const x = Math.round((w * i) / steps)
264    if (x > from) out.push(`<rect x="${x}" y="${h / 2 - 4}" width="1.5" height="8" rx=".75"/>`)
265  }
266  return `<g class="btk">${out.join('')}</g>`
267}
268
269// ---- the terminal: the same bars in text
270
271const RAMP = '▁▂▃▄▅▆▇█'
272// Pixel in text: braille dots, low at the start and full toward the head; an empty cell is the floor (⣀).
273const BRAILLE = ['⣀', '⣄', '⣤', '⣦', '⣶', '⣷', '⣿']
274
275const filledOf = (p: number | null, n: number) => Math.min(n, Math.max(0, Math.round(((p ?? 0) / 100) * n)))
276
277/**
278 * A text bar at `p` percent, `n` cells long. `colorAt(i)` is the colour of
279 * the filled cell `i`; `frame` moves the heads and sweeps while `live`.
280 */
281export function barCells(style: BarStyle, p: number | null, n: number, colorAt: (i: number) => string, frame: number, live: boolean): Cell[] {
282  const filled = filledOf(p, n)
283  const blink = live && frame % 4 < 2
284  const out: Cell[] = []
285  switch (style) {
286    case 'segments':
287      for (let i = 0; i < n; i++) {
288        const head = live && filled < n && i === filled - 1
289        out.push(i < filled ? { t: '▰', c: head && blink ? SHINE : colorAt(i) } : { t: '▱', dim: true })
290      }
291      return out
292    case 'line':
293      for (let i = 0; i < n; i++) {
294        if (i < filled) out.push({ t: '━', c: colorAt(i) })
295        else if (i === filled && filled > 0) out.push({ t: '╸', c: blink ? SHINE : colorAt(i - 1) })
296        else out.push({ t: '─', dim: true })
297      }
298      return out
299    case 'solid': {
300      // A light sweeps along the fill while live.
301      const sweep = live ? ((frame * 2) % (n + 8)) - 4 : -99
302      for (let i = 0; i < n; i++) out.push(i < filled ? { t: ' ', bg: Math.abs(i - sweep) < 1 ? SHINE : colorAt(i) } : { t: ' ', bg: '#80808040' })
303      return out
304    }
305    case 'dots': {
306      // A dot every other cell; a half dot where the fill ends partway.
307      const count = Math.ceil(n / 2)
308      const v = ((p ?? 0) / 100) * count
309      for (let i = 0; i < n; i++) {
310        if (i % 2 === 1) {
311          out.push({ t: ' ' })
312          continue
313        }
314        const k = i / 2
315        const head = live && k === Math.floor(v) && v < count
316        out.push(v - k >= 1 ? { t: '●', c: colorAt(i) } : v - k > 0.35 ? { t: '◐', c: colorAt(i) } : head ? { t: blink ? '◉' : '○', c: colorAt(i) } : { t: '○', dim: true })
317      }
318      return out
319    }
320    case 'spark':
321      // Each filled cell a step taller than the last, as a level meter that peaks at the head.
322      for (let i = 0; i < n; i++) {
323        const level = Math.min(RAMP.length - 1, 1 + Math.floor(((i + 1) / Math.max(1, filled)) * (RAMP.length - 2)))
324        const head = live && i === filled - 1 && filled < n
325        out.push(i < filled ? { t: RAMP[level] ?? '█', c: head && blink ? SHINE : colorAt(i) } : { t: '▁', dim: true })
326      }
327      return out
328    case 'classic': {
329      // Blocks: solid up to the fill, its edge dithered (two steps once
330      // the fill is long enough, one on a short one), dots after.
331      const fade = filled < n ? (filled >= 4 ? 2 : filled >= 2 ? 1 : 0) : 0
332      for (let i = 0; i < n; i++) {
333        if (i >= filled) out.push({ t: '·', dim: true })
334        else out.push({ t: fade >= 1 && i === filled - 1 ? '▒' : fade === 2 && i === filled - 2 ? '▓' : '█', c: colorAt(i) })
335      }
336      return out
337    }
338    default:
339      // Pixel: braille dots growing denser toward the head, which twinkles while live.
340      for (let i = 0; i < n; i++) {
341        if (i >= filled) {
342          out.push({ t: '⣀', dim: true })
343          continue
344        }
345        const d = Math.min(BRAILLE.length - 1, Math.floor((0.25 + 0.75 * Math.pow((i + 1) / Math.max(1, filled), 1.2)) * (BRAILLE.length - 1) + noise(i, 3) * 1.5))
346        const head = live && i === filled - 1 && filled < n
347        out.push({ t: BRAILLE[d] ?? '⣿', c: head && blink ? SHINE : colorAt(i) })
348      }
349      return out
350  }
351}
352
353/** Neighbouring cells that look the same, joined: one Text each. */
354export function mergeCells(cells: Cell[]): Cell[] {
355  const out: Cell[] = []
356  for (const c of cells) {
357    const last = out[out.length - 1]
358    if (last && last.c === c.c && last.bg === c.bg && last.dim === c.dim) last.t += c.t
359    else out.push({ ...c })
360  }
361  return out
362}
363
hooks/hud-svg.ts 236 lines
1// The HUD in the app: one SVG the hooks module draws (the desktop's Client
2// draws text only), closed in one row or open in four or more. The bars come
3// from bar-styles; the spinner's turn and Ultracode's wave are CSS
4// animations, so the drawing moves without redrawing. Pure, no `$`.
5
6import type { HudLimit, HudProps } from '../types'
7import { BAR_CSS, barSvg } from './bar-styles'
8import { AMBER, EFFORT_COLOR, GREEN, ORANGE, PURPLE, RED, WORDS, fmtLeft, fmtTok, pace, pctText, tone } from './hud-words'
9
10const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
11// Ultracode's palette, purple to pink, along the bars and its label.
12const ULTRA = ['#8B83E6', '#A58BEB', '#C86DD7', '#E46FB0', '#F25F8B']
13const ultraAt = (f: number): string => ULTRA[Math.min(ULTRA.length - 1, Math.max(0, Math.floor(f * ULTRA.length)))] ?? PURPLE
14
15const xml = (s: string): string => s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
16const charEm = (ch: string): number => (/[\s.,:;'|!il1()[\]/↻]/.test(ch) ? 0.3 : /[A-ZmMw%$—]/.test(ch) ? 0.72 : 0.56)
17const textWidth = (s: string, size: number, bold = false): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0) * (bold ? 1.06 : 1)
18
19const CSS = `.t{fill:#1f1f1f}.m{fill:#6f6d67}.sep{fill:#c9c7c0}.rt{stroke:#dedbd2}.rg{stroke:#fff}
20@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}.sep{fill:#4a4a47}.rt{stroke:#3d3c39}.rg{stroke:#fff}}
21.rb{animation:rb 2.6s ease-in-out infinite}@keyframes rb{50%{opacity:.72}}
22@media (prefers-reduced-motion: reduce){.rb,.rs{animation:none}.rs{opacity:0}}
23.spin{transform-box:fill-box;transform-origin:center;animation:hs 1.4s linear infinite}@keyframes hs{to{transform:rotate(360deg)}}
24.uw{animation:uw 7s linear infinite}@keyframes uw{from{transform:translateX(-50%)}to{transform:translateX(0)}}
25@media (prefers-reduced-motion: reduce){.spin,.uw{animation:none}}`
26
27type Lim = (HudLimit & { isReset: boolean }) | undefined
28
29/** The HUD as one SVG `W` px wide: its markup, height and what it says. */
30export function hudSvg(p: HudProps, W: number, open: boolean): { svg: string; height: number; alt: string } {
31  const words = WORDS[p.lang] ?? WORDS.en
32  const now = p.now
33  const limits = p.limits.map(l => (l.resetsAt !== null && l.resetsAt <= now ? { ...l, percent: 0, resetsAt: null, isReset: true } : { ...l, isReset: false }))
34  const five: Lim = limits.find(l => l.kind === 'five_hour')
35  const week: Lim = limits.find(l => l.kind === 'seven_day')
36  const others = limits.filter(l => l.kind !== 'five_hour' && l.kind !== 'seven_day')
37  const ctxP = p.chat?.ctxPercent ?? null
38  const alert = [ctxP ?? 0, ...limits.map(l => l.percent)].some(v => v >= 90)
39  const cost = p.chat?.costUsd != null ? `$${p.chat.costUsd.toFixed(2)}` : '$—'
40  const ultra = p.ultracode
41  // The app's picker sets Ultracode on top of Extra: then it reads just "Ultracode".
42  const level = ultra && (p.effort === 'xhigh' || p.effort === null) ? null : p.effort
43  const levelName = level === null ? null : (words.effortNames[level] ?? level)
44  const effortText = [levelName, ultra ? 'Ultracode' : null].filter(Boolean).join(' · ') || '—'
45  let n = 0
46  const uid = () => `h${n++}`
47
48  const T = (x: number, y: number, s: string, o: { cls?: string; size?: number; bold?: boolean; fill?: string; anchor?: string } = {}) =>
49    `<text${o.cls ? ` class="${o.cls}"` : ''} x="${Math.round(x * 10) / 10}" y="${y}"${o.anchor ? ` text-anchor="${o.anchor}"` : ''} font-family="${FONT}" font-size="${o.size ?? 12.5}"${o.bold ? ' font-weight="600"' : ''}${o.fill ? ` fill="${o.fill}"` : ''} font-variant-numeric="tabular-nums">${xml(s)}</text>`
50  // Working: the ✻ turns; an alert: a red !; else a green check.
51  const status = (x: number, cy: number) =>
52    p.working
53      ? `<text class="spin" x="${x + 6}" y="${cy + 4.5}" text-anchor="middle" font-family="${FONT}" font-size="14" fill="${ORANGE}">✻</text>`
54      : alert
55        ? T(x + 6, cy + 4.5, '!', { size: 14, bold: true, fill: RED, anchor: 'middle' })
56        : `<path d="M${x + 1} ${cy}l3 3 6-6.5" fill="none" stroke="${GREEN}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`
57  // The effort, its level in its colour and Ultracode in its gradient.
58  const effort = (x: number, y: number): string => {
59    const out: string[] = []
60    if (levelName !== null && level !== null) {
61      out.push(T(x, y, levelName, { bold: level === 'xhigh' || level === 'max', fill: EFFORT_COLOR[level] ?? PURPLE }))
62      x += textWidth(levelName, 12.5, true) + 4
63    }
64    if (ultra) {
65      if (levelName !== null) {
66        out.push(T(x, y, '·', { cls: 'm' }))
67        x += 9
68      }
69      const id = uid()
70      const w = textWidth('Ultracode', 12.5, true)
71      out.push(`<defs><linearGradient id="${id}" gradientUnits="userSpaceOnUse" x1="${x}" x2="${x + w}">${ULTRA.map((c, i) => `<stop offset="${i / (ULTRA.length - 1)}" stop-color="${c}"/>`).join('')}</linearGradient></defs>`)
72      out.push(T(x, y, 'Ultracode', { bold: true, fill: `url(#${id})` }))
73    }
74    if (!out.length) out.push(T(x, y, '—', { cls: 'm' }))
75    return out.join('')
76  }
77  const effortW = levelName === null && !ultra ? 10 : textWidth(effortText, 12.5, true) + 6
78  // A bar's colours: the tone of its reading, or Ultracode's palette below 90%.
79  const colors = (v: number | null, base: string) => {
80    const c = tone(v ?? 0, base)
81    return ultra && (v ?? 0) < 90 ? { colorAt: ultraAt, head: ULTRA[ULTRA.length - 1] ?? c } : { colorAt: () => c, head: c }
82  }
83  // A reading as a ring: the track, the arc of the reading in its colour (or
84  // Ultracode's gradient) that breathes, and a glint running along the arc,
85  // quicker while Claude works.
86  const RING = 16
87  const ring = (x: number, v: number | null, base: string): string => {
88    const id = uid()
89    const R = 6
90    const C = 2 * Math.PI * R
91    const frac = Math.max(0, Math.min(1, (v ?? 0) / 100))
92    const len = frac * C
93    const c = colors(v, base)
94    const cx = x + RING / 2
95    const cy = 13
96    let stroke = c.head
97    let defs = ''
98    if (ultra && (v ?? 0) < 90) {
99      defs = `<defs><linearGradient id="g${id}" x1="0" x2="1" y1="0" y2="1">${ULTRA.map((u, i) => `<stop offset="${i / (ULTRA.length - 1)}" stop-color="${u}"/>`).join('')}</linearGradient></defs>`
100      stroke = `url(#g${id})`
101    }
102    const speed = p.working ? 1.3 : 3.2
103    const arc = len > 0.5
104      ? `<circle class="rb" cx="${cx}" cy="${cy}" r="${R}" fill="none" stroke="${stroke}" stroke-width="2.6" stroke-linecap="round" stroke-dasharray="${len.toFixed(2)} ${C.toFixed(2)}" transform="rotate(-90 ${cx} ${cy})"/>
105<style>.rs${id}{animation:rs${id} ${speed}s linear infinite}@keyframes rs${id}{from{stroke-dashoffset:0}to{stroke-dashoffset:${(-Math.max(0, len - 1.6)).toFixed(2)}}}</style>
106<circle class="rg rs rs${id}" cx="${cx}" cy="${cy}" r="${R}" fill="none" stroke-width="2.6" stroke-linecap="round" stroke-opacity=".75" stroke-dasharray="1.6 ${C.toFixed(2)}" transform="rotate(-90 ${cx} ${cy})"/>`
107      : ''
108    return `${defs}<circle class="rt" cx="${cx}" cy="${cy}" r="${R}" fill="none" stroke-width="2.6"/>${arc}`
109  }
110  // Ultracode's wave: a dot grid behind everything, lit by a band of light that drifts across.
111  const wave = (H: number) => {
112    if (!ultra) return ''
113    const id = uid()
114    return `<defs><pattern id="d${id}" width="6" height="6" patternUnits="userSpaceOnUse"><rect x="2" y="2" width="1.6" height="1.6" fill="${PURPLE}"/></pattern>
115<linearGradient id="l${id}" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".25" stop-color="#fff" stop-opacity=".9"/><stop offset=".5" stop-color="#fff" stop-opacity="0"/><stop offset=".75" stop-color="#fff" stop-opacity=".9"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>
116<mask id="m${id}"><rect class="uw" width="${W * 2}" height="${H}" fill="url(#l${id})"/></mask></defs>
117<rect width="${W}" height="${H}" fill="url(#d${id})" mask="url(#m${id})" opacity=".55"/>`
118  }
119  // How fast a window fills (+N%/h for 5h, +N%/day for 7d), and when it runs
120  // out at that pace, only when that comes before its reset.
121  const rateText = (l: Lim) => pace(l, now, words, true).rate
122  const runsOut = (l: Lim, tight: boolean) => pace(l, now, words, tight).out
123  const left = (l: Lim, tight: boolean) => (l?.resetsAt != null ? fmtLeft(l.resetsAt - now, tight, words.now) : '')
124  const svg = (H: number, body: string) =>
125    `<svg xmlns="http://www.w3.org/2000/svg" data-hud="1" data-style="${p.style}" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}"><style>${CSS}${BAR_CSS}</style>${wave(H)}${body}</svg>`
126
127  if (!open) {
128    // Closed: a ring per reading (they take less room than bars); drop the
129    // reset times, the effort, the rings and at last the model, so the cost at
130    // the right never gets cut.
131    const H = 26
132    const y = 17
133    const layout = (rings: boolean, resets: boolean, head: boolean, model = true) => {
134      const parts: string[] = []
135      let x = 0
136      const put = (s: string, o: { cls?: string; bold?: boolean; fill?: string } = {}) => {
137        parts.push(T(x, y, s, o))
138        x += textWidth(s, 12.5, o.bold) + 7
139      }
140      const sep = () => {
141        x += 5
142        parts.push(`<rect class="sep" x="${Math.round(x)}" y="7" width="1" height="12"/>`)
143        x += 12
144      }
145      parts.push(status(x, 13))
146      x += 18
147      if (model) put(p.model, { cls: 't', bold: true })
148      if (head) {
149        parts.push(effort(x, y))
150        x += effortW + 4
151      }
152      const mini = (label: string, v: number | null, base: string, reset: string, l?: Lim) => {
153        put(label, { cls: 'm' })
154        if (rings) {
155          parts.push(ring(x - 2, v, base))
156          x += RING + 3
157        }
158        put(pctText(v), { cls: 't', bold: true })
159        if (resets && reset) put(`↻ ${reset}`, { cls: 'm' })
160        if (resets && rateText(l)) x += reset ? 2 : 0
161        if (resets && rateText(l)) put(rateText(l), runsOut(l, true) ? { fill: AMBER, bold: true } : { cls: 'm' })
162      }
163      sep()
164      mini('ctx', ctxP, PURPLE, '')
165      sep()
166      mini('5h', five?.percent ?? null, GREEN, left(five, true), five)
167      sep()
168      mini('7d', week?.percent ?? null, GREEN, left(week, true), week)
169      sep()
170      put(cost, { cls: 't', bold: true })
171      return { parts, width: x - 7 }
172    }
173    let best = layout(false, false, false)
174    if (best.width > W) best = layout(false, false, false, false)
175    for (const [rings, resets, head] of [[true, true, true], [true, false, true], [true, false, false]] as const) {
176      const l = layout(rings, resets, head)
177      if (l.width <= W) {
178        best = l
179        break
180      }
181    }
182    const alt = [
183      p.working ? words.working : alert ? words.alert : words.ready,
184      `${p.model} ${effortText}`,
185      `ctx ${pctText(ctxP)}`,
186      `5h ${pctText(five?.percent ?? null)}${left(five, true) ? ` ↻ ${left(five, true)}` : ''}${rateText(five) ? ` ${rateText(five)}` : ''}${runsOut(five, true) ? ` (${runsOut(five, true)})` : ''}`,
187      `7d ${pctText(week?.percent ?? null)}${left(week, true) ? ` ↻ ${left(week, true)}` : ''}${rateText(week) ? ` ${rateText(week)}` : ''}${runsOut(week, true) ? ` (${runsOut(week, true)})` : ''}`,
188      cost,
189    ].join(' · ')
190    return { svg: svg(H, best.parts.join('')), height: H, alt }
191  }
192
193  // Open: a row per reading, every bar on the same columns.
194  const RH = 28
195  const rows = 3 + others.length
196  const H = RH * (rows + 1) + 6
197  const L = 22
198  const BX = 116
199  const parts: string[] = []
200  const stateWord = p.working ? { t: words.working, c: ORANGE } : alert ? { t: words.alert, c: RED } : { t: words.ready, c: GREEN }
201  parts.push(status(0, 14), T(L, 18, stateWord.t, { bold: true, fill: stateWord.c }), T(BX, 18, p.model, { cls: 't', bold: true }))
202  const ex = BX + textWidth(p.model, 12.5, true) + 16
203  parts.push(T(ex, 18, words.effort, { cls: 'm' }), effort(ex + textWidth(words.effort, 12.5) + 7, 18))
204  parts.push(`<text x="${W - 2}" y="18" text-anchor="end" font-family="${FONT}" font-size="12.5"><tspan class="m">${xml(words.chat)} </tspan><tspan class="t" font-weight="600">${xml(cost)}</tspan></text>`)
205  const lines: string[] = [`${stateWord.t} · ${p.model} · ${words.effort} ${effortText} · ${words.chat} ${cost}`]
206  const row = (i: number, label: string, v: number | null, base: string, detail: string, warn = '') => {
207    const y = RH * i + 6
208    const c = tone(v ?? 0, base)
209    parts.push(
210      `<circle cx="6" cy="${y + 11}" r="4" fill="${ultra && (v ?? 0) < 90 ? ultraAt(i / (rows + 1)) : c}"/>`,
211      T(L, y + 15, label, { cls: 'm' }),
212      barSvg({ style: p.style, x: BX, y: y + 3, w: BW, h: 16, p: v ?? 0, ...colors(v, base), live: true, uid: uid(), label: pctText(v) }),
213      warn
214        ? `<text x="${BX + BW + 14}" y="${y + 15}" font-family="${FONT}" font-size="12.5" font-variant-numeric="tabular-nums"><tspan class="m">${xml(detail)} · </tspan><tspan fill="${AMBER}" font-weight="600">${xml(warn)}</tspan></text>`
215        : T(BX + BW + 14, y + 15, detail, { cls: 'm' }),
216    )
217    lines.push(`${label} ${pctText(v)}${detail ? ` · ${detail}` : ''}${warn ? ` · ${warn}` : ''}`)
218  }
219  const u = p.chat
220  const limitDetail = (l: Lim) =>
221    l?.isReset ? `${words.reset} · ${words.account}` : l?.resetsAt != null ? `${words.resetsIn(left(l, false))} · ${rateText(l) || words.account}` : l ? rateText(l) || words.account : words.noReading
222  const ctxDetail = u?.ctxTokens != null ? `${fmtTok(u.ctxTokens)} / ${fmtTok(u.ctxWindow)}` : u ? `— / ${fmtTok(u.ctxWindow)}` : ''
223  // The details column takes what its longest line needs (the amber run-out included), within reason.
224  const need = Math.max(
225    textWidth(ctxDetail, 12.5),
226    ...[five, week, ...others].map(l => textWidth(limitDetail(l), 12.5) + (runsOut(l, false) ? textWidth(` · ${runsOut(l, false)}`, 12.5, true) : 0)),
227  )
228  const DW = Math.round(Math.min(W * 0.46, Math.max(120, Math.min(210, W * 0.28), need + 8)))
229  const BW = Math.max(60, W - BX - DW - 14)
230  row(1, words.context, ctxP, PURPLE, ctxDetail)
231  row(2, words.fiveHour, five?.percent ?? null, GREEN, limitDetail(five), runsOut(five, false))
232  row(3, words.sevenDay, week?.percent ?? null, GREEN, limitDetail(week), runsOut(week, false))
233  others.forEach((l, i) => row(4 + i, l.kind === 'spend_limit' ? words.spend : l.kind, l.percent, GREEN, limitDetail(l)))
234  return { svg: svg(H, parts.join('')), height: H, alt: lines.join('; ') }
235}
236
hooks/hud-words.ts 119 lines
1// What the HUD says and its colours, shared by the drawing in the app (hud-svg)
2// and the Client (hud-client). Pure, no `$`.
3
4export const GREEN = '#3FB68B'
5export const PURPLE = '#8B83E6'
6export const AMBER = '#E9A23B'
7export const RED = '#E5534B'
8export const ORANGE = '#D97757'
9// Effort from calm to loud on the desktop: grey, sky, the HUD's purple,
10// magenta, hot pink.
11export const EFFORT_COLOR: Record<string, string> = {
12  low: '#9AA0A8',
13  medium: '#5AA9E6',
14  high: PURPLE,
15  xhigh: '#C86DD7',
16  max: '#F25F8B',
17}
18// Every word the HUD draws, per language. The effort names are the app's
19// picker's; Ultracode is its own switch on top of `xhigh`, which the hooks
20// module reports apart, as `ultracode`.
21export const WORDS = {
22  en: {
23    effortNames: { low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra', max: 'Max' } as Record<string, string>,
24    ready: 'ready',
25    working: 'working',
26    alert: 'alert',
27    effort: 'effort',
28    chat: 'chat',
29    context: 'context',
30    fiveHour: '5h limit',
31    sevenDay: '7d limit',
32    spend: 'spend',
33    resetsIn: (t: string) => `↻ resets in ${t}`,
34    reset: '↻ reset',
35    noReading: 'no reading yet',
36    account: 'account',
37    now: 'now',
38    perHour: '/h',
39    perDay: '/day',
40    runsOut: (t: string) => `runs out in ${t}`,
41  },
42  'pt-BR': {
43    effortNames: { low: 'Baixo', medium: 'Médio', high: 'Alto', xhigh: 'Extra', max: 'Máx' } as Record<string, string>,
44    ready: 'pronto',
45    working: 'rodando',
46    alert: 'atenção',
47    effort: 'esforço',
48    chat: 'chat',
49    context: 'contexto',
50    fiveHour: 'limite 5h',
51    sevenDay: 'limite 7d',
52    spend: 'gasto',
53    resetsIn: (t: string) => `↻ reseta em ${t}`,
54    reset: '↻ resetou',
55    noReading: 'sem leitura ainda',
56    account: 'conta',
57    now: 'agora',
58    perHour: '/h',
59    perDay: '/dia',
60    runsOut: (t: string) => `acaba em ${t}`,
61  },
62}
63export function fmtLeft(ms: number, tight: boolean, now: string): string {
64  if (ms <= 0) return now
65  const min = Math.floor(ms / 60000)
66  const d = Math.floor(min / 1440)
67  const h = Math.floor((min % 1440) / 60)
68  const m = min % 60
69  const sp = tight ? '' : ' '
70  if (d > 0) return `${d}d${sp}${h}h`
71  if (h > 0) return `${h}h${sp}${m}m`
72  return `${m}m`
73}
74
75export function fmtTok(n: number): string {
76  if (n >= 1e6) return `${+(n / 1e6).toFixed(1)}M`
77  if (n >= 1e3) return `${Math.round(n / 1e3)}k`
78  return String(n)
79}
80
81type Words = (typeof WORDS)[keyof typeof WORDS]
82type Paced = { kind: string; percent: number; resetsAt: number | null; rate?: number | null; isReset?: boolean }
83
84/** How fast a window fills (+N%/h for 5h, +N%/day for 7d) and, only when that
85 *  comes before its reset, when it runs out at that pace. */
86export function pace(l: Paced | undefined, now: number, w: Words, tight: boolean): { rate: string; out: string } {
87  if (l?.rate == null || l.rate < 0.05 || l.isReset) return { rate: '', out: '' }
88  const day = l.kind === 'seven_day'
89  const v = day ? l.rate * 24 : l.rate
90  const rate = `+${v < 10 ? Math.round(v * 10) / 10 : Math.round(v)}%${day ? w.perDay : w.perHour}`
91  const ms = ((100 - l.percent) / l.rate) * 3600000
92  return { rate, out: l.resetsAt != null && now + ms < l.resetsAt ? w.runsOut(fmtLeft(ms, tight, w.now)) : '' }
93}
94
95export const pctText = (p: number | null) => (p === null ? '—' : `${Math.round(p)}%`)
96
97export const tone = (p: number, base: string) => (p >= 90 ? RED : p >= 70 ? AMBER : base)
98
99// The style picker's samples drift through the mod's own colours, one into
100// the next, once around the colour wheel: the context's purple, the base
101// group's blue, a teal, the limits' green, the low effort's yellow (so green
102// does not turn muddy on its way to orange), the final group's orange, a pink,
103// the intermediate group's violet.
104export const CYCLE = ['#8B83E6', '#3B7FF5', '#3FA7B5', '#3FB68B', '#E9C46A', '#F08C2E', '#E46FB0', '#A259E6']
105/** Seconds the samples take through the whole palette. */
106export const CYCLE_S = 14
107
108const hex = (c: string) => [1, 3, 5].map(i => parseInt(c.slice(i, i + 2), 16))
109/** The palette's colour at `f` (0-1 around the loop), blended between its neighbours. */
110export function cycleAt(f: number): string {
111  const x = (((f % 1) + 1) % 1) * CYCLE.length
112  const i = Math.floor(x)
113  const a = hex(CYCLE[i] ?? '#8B83E6')
114  const b = hex(CYCLE[(i + 1) % CYCLE.length] ?? '#8B83E6')
115  // Eased, so each colour lingers a little before it gives way.
116  const t = (1 - Math.cos((x - i) * Math.PI)) / 2
117  return '#' + a.map((v, k) => Math.round(v + ((b[k] ?? v) - v) * t).toString(16).padStart(2, '0')).join('')
118}
119
hooks/portraits.ts 117 lines
1// Portraits of one's own agents, from data: a `crabhud-portraits.json` in a
2// folder under ~/.claude/mods/ gives an agent type a costume (its pixels, a
3// colour, its motion) for the agents panel, and a bar group its colour. Pure,
4// no `$`: register.tsx reads the files and draws with what this returns.
5
6/** One rectangle of the crab, on its 30×26 grid: x, y, width, height, colour, and its class (legs `la`/`lb`, `fr` in front). */
7export type PortraitPixel = [number, number, number, number, string, string?]
8
9export type Portrait = {
10  /** The agent types it dresses, as the Agent tool names them (`barista-team`). */
11  agents: string[]
12  color: string
13  pixels: PortraitPixel[]
14  /** Its motion, its rules already scoped to it. */
15  css: string
16}
17
18export type PortraitSet = {
19  portraits: Record<string, Portrait>
20  /** A bar group's colour by its name, lowercased. */
21  groups: Record<string, string>
22}
23
24export const NO_PORTRAITS: PortraitSet = { portraits: {}, groups: {} }
25
26const NAME = /^[a-z][a-z0-9-]{0,31}$/
27const COLOR = /^(#[0-9a-fA-F]{3,8}|rgba?\(\s*[\d.]+%?\s*,\s*[\d.]+%?\s*,\s*[\d.]+%?\s*(,\s*[\d.]+\s*)?\))$/
28const CLS = /^[a-z][a-z0-9]*( [a-z][a-z0-9]*)*$/
29const RULE = /^([^{}@]+)\{([^{}]*)\}$/
30const KEYFRAMES = /^@keyframes [a-zA-Z][\w-]*\{(\s*[\d%,.\sa-z]+\{[^{}]*\})*\s*\}$/
31
32const isNum = (v: unknown, lo: number, hi: number): v is number => typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi
33
34// A rule of the portrait's motion, scoped to its costume while it runs
35// (`.kl{...}` reads `.c-NAME.run .kl{...}`), or a @keyframes as it is.
36function scopeRule(name: string, rule: unknown): string | null {
37  if (typeof rule !== 'string') return null
38  const r = rule.trim()
39  if (r.includes('<') || /@import|url\s*\(/i.test(r)) return null
40  if (r.startsWith('@')) return KEYFRAMES.test(r) ? r : null
41  const m = RULE.exec(r)
42  if (!m) return null
43  const selectors = (m[1] ?? '').split(',').map(s => s.trim())
44  if (selectors.some(s => !s)) return null
45  return `${selectors.map(s => `.c-${name}.run ${s}`).join(',')}{${m[2]}}`
46}
47
48/**
49 * The portraits and group colours of one file. `taken` names the costumes
50 * already in use (the mod's own, an earlier file's), which a file cannot
51 * replace; what does not hold is left out and said in `problems`.
52 */
53export function readPortraits(text: string, taken: (name: string) => boolean): { set: PortraitSet; problems: string[] } {
54  const problems: string[] = []
55  const set: PortraitSet = { portraits: {}, groups: {} }
56  let data: unknown
57  try {
58    data = JSON.parse(text)
59  } catch {
60    return { set, problems: ['not JSON'] }
61  }
62  if (!data || typeof data !== 'object') return { set, problems: ['not an object'] }
63  const d = data as { portraits?: unknown; groups?: unknown }
64
65  if (d.groups && typeof d.groups === 'object') {
66    for (const [group, color] of Object.entries(d.groups as Record<string, unknown>)) {
67      if (typeof color === 'string' && COLOR.test(color)) set.groups[group.toLowerCase()] = color
68      else problems.push(`group ${group}: not a colour`)
69    }
70  }
71
72  if (d.portraits && typeof d.portraits === 'object') {
73    for (const [name, raw] of Object.entries(d.portraits as Record<string, unknown>)) {
74      if (!NAME.test(name)) {
75        problems.push(`${name}: a name is lowercase letters, digits and dashes`)
76        continue
77      }
78      if (taken(name)) {
79        problems.push(`${name}: already taken`)
80        continue
81      }
82      const p = (raw ?? {}) as { agents?: unknown; color?: unknown; pixels?: unknown; css?: unknown }
83      const agents = Array.isArray(p.agents) ? p.agents.filter((a): a is string => typeof a === 'string' && a.length > 0) : []
84      if (!agents.length || typeof p.color !== 'string' || !COLOR.test(p.color) || !Array.isArray(p.pixels)) {
85        problems.push(`${name}: needs agents, a colour and pixels`)
86        continue
87      }
88      const pixels: PortraitPixel[] = []
89      for (const px of p.pixels) {
90        const [x, y, w, h, c, cls] = Array.isArray(px) ? px : []
91        const ok =
92          isNum(x, -10, 40) && isNum(y, -10, 40) && isNum(w, 0, 40) && isNum(h, 0, 40) &&
93          typeof c === 'string' && (c === 'role' || COLOR.test(c)) && (cls === undefined || (typeof cls === 'string' && CLS.test(cls)))
94        if (ok) pixels.push(cls === undefined ? [x, y, w, h, c] : [x, y, w, h, c, cls])
95      }
96      if (pixels.length < p.pixels.length) problems.push(`${name}: ${p.pixels.length - pixels.length} pixels left out`)
97      const rules = Array.isArray(p.css) ? p.css : []
98      const css = rules.map(r => scopeRule(name, r))
99      if (css.some(r => r === null)) problems.push(`${name}: ${css.filter(r => r === null).length} css rules left out`)
100      set.portraits[name] = { agents, color: p.color, pixels, css: css.filter((r): r is string => r !== null).join('\n') }
101    }
102  }
103  return { set, problems }
104}
105
106/** Two sets as one: the first keeps a name or a group both give. */
107export function mergePortraits(a: PortraitSet, b: PortraitSet): PortraitSet {
108  return { portraits: { ...b.portraits, ...a.portraits }, groups: { ...b.groups, ...a.groups } }
109}
110
111/** The costume dressing an agent type, if a portrait names it. */
112export function portraitFor(set: PortraitSet, type: string): string | null {
113  const bare = type.replace(/^[^:]*:/, '')
114  for (const [name, p] of Object.entries(set.portraits)) if (p.agents.includes(bare) || p.agents.includes(type)) return name
115  return null
116}
117
hooks/hud-client.tsx 420 lines
1import type { ClientModule, RenderElement } from 'claude-code'
2
3import type { HudLimit, HudProps } from '../types'
4import { barCells, mergeCells } from './bar-styles'
5import { AMBER, EFFORT_COLOR, GREEN, ORANGE, PURPLE, RED, WORDS, fmtLeft, fmtTok, pace, pctText, tone, CYCLE_S, cycleAt } from './hud-words'
6
7type State = { open: boolean; frame: number; refresh: number }
8type Seg = { t: string; c?: string; dim?: boolean; bold?: boolean }
9// One piece of the compact row: text, a bar `n` cells long, or a drawn
10// element `w` cells wide.
11type Piece =
12  | { seg: Seg }
13  | { bar: { p: number | null; n: number; base: string } }
14  | { el: RenderElement; w: number }
15
16const SPIN = ['✻', '✶', '✷', '✸', '✹', '✺']
17// The terminal's own effort picker: low yellow, medium green, high purple
18// (xhigh and max wear a theme, below).
19const TERMINAL_EFFORT_COLOR: Record<string, string> = {
20  low: '#E9C46A',
21  medium: GREEN,
22  high: PURPLE,
23  xhigh: PURPLE,
24  max: '#F25F8B',
25}
26// Ultracode's label shimmers through these, letter by letter.
27const ULTRA = ['#8B83E6', '#A58BEB', '#C86DD7', '#E46FB0', '#F25F8B', '#E46FB0', '#C86DD7', '#A58BEB']
28// The desktop draws Text in a proportional font, where █ is wide and · thin;
29// there each pixel is a one-cell Box, so the bar's length never depends on
30// the glyphs. Its text runs about 0.8 cell a character.
31
32// The frame clock's callback reads the newest props through this (the HUD's;
33// the picker's previews keep their own clock).
34let latest: HudProps | null = null
35// The sample the picker's previews draw, in percent.
36const SAMPLE = 64
37
38function nowMs(props: HudProps): number {
39  return typeof Date === 'function' ? Date.now() : props.now
40}
41
42// Ultracode's backdrop: a soft dither wave drifting across the band. 0 is
43// blank, 1-3 the ·, ░ and ▒ steps.
44function shimmer(row: number, cols: number, t: number): number[] {
45  return Array.from({ length: cols }, (_, i) => {
46    const wave = 0.5 + 0.5 * Math.sin(i * 0.45 - t * 0.5 + row * 1.7)
47    const sweep = 0.5 + 0.5 * Math.sin(i * 0.09 - t * 0.12 + row)
48    const v = wave * sweep
49    return v > 0.7 ? 3 : v > 0.45 ? 2 : v > 0.28 ? 1 : 0
50  })
51}
52
53// A loud look: a palette that flows along the bars and the labels, and the
54// colour of each step (1-3) of the wave behind the band at column `i`.
55type Theme = { flow: readonly string[]; wave: (step: number, i: number, t: number, desktop: boolean) => string }
56
57const PURPLE_WAVE = (step: number, _i: number, _t: number, desktop: boolean) =>
58  desktop ? PURPLE + ['62', '3D', '52'][step - 1] : ['#544D80', '#433D66', '#544D80'][step - 1]
59const RAINBOW = ['#F25F8B', '#F08A4B', '#E9C46A', '#3FB68B', '#5AA9E6', '#8B83E6', '#C86DD7']
60const RAINBOW_DIM = ['#5C2A3A', '#5A3A24', '#57502C', '#1F4A3A', '#24405A', '#3A3560', '#4E2D55']
61
62// The desktop's Ultracode; under the terminal, xhigh (purple) and max (every
63// colour), as its own picker draws them.
64const THEMES = {
65  ultracode: { flow: ULTRA, wave: PURPLE_WAVE },
66  xhigh: { flow: ['#6E64D8', '#8B83E6', '#A58BEB', '#BBA8F2', '#A58BEB', '#8B83E6'], wave: PURPLE_WAVE },
67  max: {
68    flow: RAINBOW,
69    wave: (_step: number, i: number, t: number, desktop: boolean) => {
70      const k = Math.floor(i / 4 + t / 3) % RAINBOW.length
71      return desktop ? RAINBOW[k] + '55' : RAINBOW_DIM[k]
72    },
73  },
74} satisfies Record<string, Theme>
75
76function themeOf(p: HudProps): Theme | null {
77  if (p.surface === 'desktop') return p.ultracode ? THEMES.ultracode : null
78  return p.effort === 'max' ? THEMES.max : p.effort === 'xhigh' ? THEMES.xhigh : null
79}
80
81// The theme's palette flowing along a bar, a step every two frames; a bar
82// past 90% keeps its warning in red and pink.
83function flowAt(theme: Theme, p: number, i: number, frame: number): string {
84  const k = Math.floor(i / 2 + frame / 2)
85  return p >= 90 ? (k % 2 === 0 ? '#F25F8B' : RED) : theme.flow[k % theme.flow.length]
86}
87
88const Hud: ClientModule<HudProps, State> = (props, surface) => {
89  const preview = props.preview
90  if (preview === undefined) latest = props
91  const { Box, Text } = surface.elements
92  const desktop = props.surface === 'desktop'
93  const init: State = { open: props.expanded, frame: 0, refresh: 0 }
94  const st = surface.state ?? init
95  const theme = preview === undefined ? themeOf(props) : null
96
97  if (surface.state === undefined && preview !== undefined) {
98    // A preview is always alive, so the picker shows each style moving.
99    surface.every(200, () => {
100      const cur = surface.state ?? init
101      surface.setState({ ...cur, frame: cur.frame + 1 })
102    })
103  } else if (surface.state === undefined) {
104    let ticks = 0
105    surface.every(200, () => {
106      ticks++
107      const cur = surface.state ?? init
108      // Animate while Claude works or a theme is on; otherwise repaint every
109      // 30s so the reset countdowns stay current.
110      if (latest && (latest.working || themeOf(latest))) surface.setState({ ...cur, frame: cur.frame + 1 })
111      else if (ticks % 150 === 0) surface.setState({ ...cur, refresh: cur.refresh + 1 })
112    })
113    const toggle = () => {
114      const cur = surface.state ?? init
115      const open = !cur.open
116      surface.setState({ ...cur, open })
117      surface.post({ expanded: open })
118    }
119    surface.onPointer(ev => {
120      if (ev.type === 'down' && (ev.button === undefined || ev.button === 'left')) toggle()
121    })
122    surface.onKey(ev => {
123      if (ev.key === 'return' || ev.key === ' ') toggle()
124    })
125  }
126
127  const text = (s: Seg): RenderElement => (
128    <Text color={s.c} dimColor={s.dim} bold={s.bold} wrap="truncate">
129      {s.t}
130    </Text>
131  )
132  const bar = (p: number | null, n: number, base: string): RenderElement => {
133    const col = tone(p ?? 0, base)
134    const colorAt = (i: number) => (theme ? flowAt(theme, p ?? 0, i, st.frame) : col)
135    const cells = barCells(props.style, p, n, colorAt, st.frame, preview !== undefined || props.working)
136    if (!desktop) {
137      return (
138        <Box flexDirection="row" flexShrink={0}>
139          {mergeCells(cells).map(c => (
140            <Text color={c.c} dimColor={c.dim} backgroundColor={c.bg}>
141              {c.t}
142            </Text>
143          ))}
144        </Box>
145      )
146    }
147    // Each cell is its own glyph clipped to one cell: the texture stays, and
148    // the bar's length is the cells', not the font's.
149    return (
150      <Box flexDirection="row" flexShrink={0}>
151        {cells.map(c => (
152          <Box width={1} height={1} flexShrink={0} overflow="hidden" justifyContent="center" backgroundColor={c.bg}>
153            <Text color={c.c} dimColor={c.dim}>
154              {c.t}
155            </Text>
156          </Box>
157        ))}
158      </Box>
159    )
160  }
161
162  if (preview !== undefined) {
163    return (
164      <Box flexDirection="row" columnGap={1} alignItems="center">
165        {text({ t: 'ctx', dim: true })}
166        {bar(SAMPLE, preview, cycleAt((st.frame * 0.2) / CYCLE_S))}
167        {text({ t: `${SAMPLE}%`, bold: true })}
168      </Box>
169    )
170  }
171
172  const W = WORDS[props.lang] ?? WORDS.en
173  const u = props.chat
174  const now = nowMs(props)
175  // A window whose reset time has passed starts over at zero until the next
176  // reading says otherwise.
177  const limits = props.limits.map(l =>
178    l.resetsAt !== null && l.resetsAt <= now ? { ...l, percent: 0, resetsAt: null, isReset: true } : { ...l, isReset: false },
179  )
180  const five = limits.find(l => l.kind === 'five_hour')
181  const week = limits.find(l => l.kind === 'seven_day')
182  const others = limits.filter(l => l.kind !== 'five_hour' && l.kind !== 'seven_day')
183  const ctxP = u?.ctxPercent ?? null
184  const alert = [ctxP ?? 0, ...limits.map(l => l.percent)].some(p => p >= 90)
185  const cost: Seg = { t: u?.costUsd != null ? `$${u.costUsd.toFixed(2)}` : '$—', bold: true }
186  const effortName = (e: string | null) => (e === null ? '—' : (W.effortNames[e] ?? e))
187  const cols = surface.columns > 0 ? surface.columns : 100
188  const status: Seg = props.working
189    ? { t: SPIN[st.frame % SPIN.length], c: ORANGE }
190    : alert
191      ? { t: '!', c: RED, bold: true }
192      : { t: '✓', c: theme ? theme.flow[Math.floor(st.frame / 2) % theme.flow.length] : GREEN }
193  // Ultracode is a switch over an effort level: the app's picker sets it on
194  // top of Extra (shown as just "Ultracode"), the terminal's Tab on any level
195  // (shown as "Max · Ultracode"). A themed level shimmers in its palette.
196  const ultra = props.ultracode
197  const level = ultra && desktop && (props.effort === 'xhigh' || props.effort === null) ? null : props.effort
198  const shimmerWord = (word: string, palette: readonly string[]) =>
199    [...word].map((ch, i) => (
200      <Text bold color={palette[(i + st.frame) % palette.length]}>
201        {ch}
202      </Text>
203    ))
204  const levelName = level === null ? null : effortName(level)
205  const levelEl: RenderElement[] =
206    level === null || levelName === null
207      ? []
208      : !desktop && theme
209        ? shimmerWord(levelName, theme.flow)
210        : [text({ t: levelName, c: (desktop ? EFFORT_COLOR : TERMINAL_EFFORT_COLOR)[level] || PURPLE, bold: level === 'xhigh' || level === 'max' })]
211  const effortText = [levelName, ultra ? 'Ultracode' : null].filter(Boolean).join(' · ') || '—'
212  const effort: RenderElement = (
213    <Box flexDirection="row" flexShrink={0}>
214      {levelEl}
215      {ultra && levelEl.length > 0 ? text({ t: ' · ', dim: true }) : []}
216      {ultra ? shimmerWord('Ultracode', theme?.flow ?? ULTRA) : []}
217      {levelEl.length === 0 && !ultra ? text({ t: '—', dim: true }) : []}
218    </Box>
219  )
220  const est = (s: string) => (desktop ? Math.ceil(s.length * 0.8) : s.length)
221
222  // A theme puts its dither wave behind everything, drawn first so the
223  // readings paint over it. The desktop's shades are translucent, so they sit
224  // on a light theme and a dark one alike.
225  const backdrop = (rows: number, body: RenderElement): RenderElement => {
226    if (!theme) return body
227    const glyphs = ['·', '░', '▒']
228    const lines = Array.from({ length: rows }, (_, r) => {
229      const v = shimmer(r, cols, st.frame)
230      const color = (i: number) => (v[i] === 0 ? '' : theme.wave(v[i], i, st.frame, desktop))
231      const out: RenderElement[] = []
232      for (let i = 0; i < v.length; ) {
233        let j = i
234        while (j < v.length && v[j] === v[i] && color(j) === color(i) && (desktop ? v[i] === 0 : true)) j++
235        if (v[i] === 0) {
236          out.push(desktop ? <Box width={j - i} height={1} flexShrink={0} /> : <Text>{' '.repeat(j - i)}</Text>)
237        } else if (desktop) {
238          j = i + 1
239          out.push(
240            <Box width={1} height={1} flexShrink={0} overflow="hidden" justifyContent="center">
241              <Text color={color(i)}>{glyphs[v[i] - 1]}</Text>
242            </Box>,
243          )
244        } else {
245          out.push(<Text color={color(i)}>{glyphs[v[i] - 1].repeat(j - i)}</Text>)
246        }
247        i = j
248      }
249      return (
250        <Box flexDirection="row" height={1} overflow="hidden">
251          {out}
252        </Box>
253      )
254    })
255    return (
256      <Box width="100%" position="relative">
257        <Box position="absolute" top={0} left={0} width="100%" height={rows} overflow="hidden" flexDirection="column">
258          {lines}
259        </Box>
260        {body}
261      </Box>
262    )
263  }
264
265  if (!st.open) {
266    // Compact: groups spaced by margins (the desktop collapses spaces). A ring
267    // per reading (○ ◔ ◑ ◕ ●) in its colour; drop the reset times, then the
268    // effort, then the rings, so the cost at the right never gets cut.
269    const pie = (v: number | null) => (v === null || v < 12.5 ? '○' : v < 37.5 ? '◔' : v < 62.5 ? '◑' : v < 87.5 ? '◕' : '●')
270    const ring = (v: number | null, base: string): Piece => ({ seg: { t: pie(v), c: theme ? flowAt(theme, v ?? 0, 0, st.frame) : tone(v ?? 0, base) } })
271    const limit = (label: string, l: (HudLimit & { isReset?: boolean }) | undefined, n: number, withReset: boolean): Piece[] => [
272      { seg: { t: label, dim: true } },
273      ...(n > 0 ? [ring(l?.percent ?? null, GREEN)] : []),
274      { seg: { t: pctText(l?.percent ?? null), bold: true } },
275      ...(withReset && l?.resetsAt != null ? [{ seg: { t: `↻ ${fmtLeft(l.resetsAt - now, true, W.now)}`, dim: true } }] : []),
276      ...(withReset && pace(l, now, W, true).rate
277        ? [{ seg: pace(l, now, W, true).out ? { t: pace(l, now, W, true).rate, c: AMBER, bold: true } : { t: pace(l, now, W, true).rate, dim: true } }]
278        : []),
279    ]
280    // The status glyph sits in a box of fixed width: in the desktop's
281    // proportional font each spinner frame (and the ✓) is a different width,
282    // and a bare glyph would nudge the whole row on every frame.
283    const statusW = desktop ? 2 : 1
284    const statusEl = (
285      <Box key="status" width={statusW} flexShrink={0}>
286        {text(status)}
287      </Box>
288    )
289    const build = (n: number, resets: boolean, head: boolean): Piece[][] => [
290      [{ el: statusEl, w: statusW }, { seg: { t: props.model, bold: true } }, ...(head ? [{ el: effort, w: ultra || (theme && !desktop) ? effortText.length + 1 : est(effortText) }] : [])],
291      [
292        { seg: { t: 'ctx', dim: true } },
293        ...(n > 0 ? [ring(ctxP, PURPLE)] : []),
294        { seg: { t: pctText(ctxP), bold: true } },
295      ],
296      limit('5h', five, n, resets),
297      limit('7d', week, n, resets),
298      [{ seg: cost }],
299    ]
300    const SEP = 5 // "  │  "
301    const width = (groups: Piece[][]) =>
302      groups.reduce(
303        (sum, g) =>
304          sum + g.reduce((w, p) => w + ('seg' in p ? est(p.seg.t) : 'bar' in p ? p.bar.n : p.w), 0) + g.length - 1,
305        SEP * (groups.length - 1),
306      )
307    const room = cols - 3 // the ▾ at the right edge
308    let groups = build(0, false, false)
309    for (const [resets, head, n] of [[true, true, 1], [false, true, 1], [false, false, 1], [false, false, 0]] as const) {
310      const candidate = build(n, resets, head)
311      if (width(candidate) <= room) {
312        groups = candidate
313        break
314      }
315    }
316    const draw = (p: Piece) =>
317      'seg' in p ? text(p.seg) : 'bar' in p ? bar(p.bar.p, p.bar.n, p.bar.base) : p.el
318    const sep = () => <Box marginX={2} flexShrink={0}>{text({ t: '│', dim: true })}</Box>
319    const group = (g: Piece[]) => (
320      <Box flexDirection="row" columnGap={1} alignItems="center" flexShrink={0}>
321        {g.map(draw)}
322      </Box>
323    )
324    // The cost (the last group) sits outside the part that may be clipped:
325    // if the estimate runs short, the 7d reset gives way, never the cost.
326    const head = groups.slice(0, -1)
327    const row: RenderElement[] = []
328    head.forEach((g, i) => {
329      if (i > 0) row.push(sep())
330      row.push(group(g))
331    })
332
333    return backdrop(
334      1,
335      <Box flexDirection="row" width="100%" justifyContent="space-between" alignItems="center">
336        <Box flexDirection="row" flexShrink={1} minWidth={0} alignItems="center">
337          <Box flexDirection="row" flexShrink={1} minWidth={0} overflow="hidden" alignItems="center">
338            {row}
339          </Box>
340          {sep()}
341          {group(groups[groups.length - 1])}
342        </Box>
343        <Box flexShrink={0} marginLeft={2}>
344          {text({ t: '▾', dim: true })}
345        </Box>
346      </Box>,
347    )
348  }
349
350  // Expanded: fixed-width columns (icon, label, bar, percent, detail), so
351  // every bar starts and ends on the same column.
352  const n = Math.max(10, Math.min(desktop ? 28 : 40, cols - 2 - 12 - 2 - 6 - 2 - 18))
353  const row = (icon: Seg, label: string, b: RenderElement, pct: number | null, detail: string, warn = '') => (
354    <Box flexDirection="row" alignItems="center">
355      <Box width={2} flexShrink={0}>{text(icon)}</Box>
356      <Box width={12} flexShrink={0}>{text({ t: label, dim: true })}</Box>
357      {b}
358      <Box width={6} flexShrink={0} justifyContent="flex-end">{text({ t: pctText(pct), bold: true })}</Box>
359      <Box marginLeft={2} flexShrink={1} overflow="hidden">
360        {text({ t: detail, dim: true })}
361        {warn ? text({ t: ` · ${warn}`, c: AMBER, bold: true }) : null}
362      </Box>
363    </Box>
364  )
365  const limitRow = (icon: string, label: string, l: (HudLimit & { isReset?: boolean }) | undefined) =>
366    row(
367      { t: icon, c: theme ? flowAt(theme, l?.percent ?? 0, 3, st.frame) : tone(l?.percent ?? 0, GREEN) },
368      label,
369      bar(l?.percent ?? null, n, GREEN),
370      l?.percent ?? null,
371      l?.isReset
372        ? `${W.reset} · ${W.account}`
373        : l?.resetsAt != null
374          ? `${W.resetsIn(fmtLeft(l.resetsAt - now, false, W.now))} · ${pace(l, now, W, false).rate || W.account}`
375          : l
376            ? pace(l, now, W, false).rate || W.account
377            : W.noReading,
378      pace(l, now, W, false).out,
379    )
380  const stateWord: Seg = props.working
381    ? { t: W.working, c: ORANGE }
382    : alert
383      ? { t: W.alert, c: RED }
384      : { t: W.ready, c: theme ? theme.flow[Math.floor(st.frame / 2) % theme.flow.length] : GREEN }
385
386  return backdrop(
387    4 + others.length,
388    <Box flexDirection="column" width="100%" rowGap={0}>
389      <Box flexDirection="row" justifyContent="space-between" alignItems="center">
390        <Box flexDirection="row" flexShrink={1} overflow="hidden" alignItems="center">
391          <Box width={2} flexShrink={0}>{text(status)}</Box>
392          <Box width={12} flexShrink={0}>{text(stateWord)}</Box>
393          {text({ t: props.model, bold: true })}
394          <Box marginLeft={3} flexDirection="row" columnGap={1}>
395            {text({ t: W.effort, dim: true })}
396            {effort}
397          </Box>
398        </Box>
399        <Box flexDirection="row" flexShrink={0} marginLeft={2} columnGap={1}>
400          {text({ t: W.chat, dim: true })}
401          {text(cost)}
402          {text({ t: '▴', dim: true })}
403        </Box>
404      </Box>
405      {row(
406        { t: '■', c: theme ? flowAt(theme, ctxP ?? 0, 0, st.frame) : tone(ctxP ?? 0, PURPLE) },
407        W.context,
408        bar(ctxP, n, PURPLE),
409        ctxP,
410        u?.ctxTokens != null ? `${fmtTok(u.ctxTokens)} / ${fmtTok(u.ctxWindow)}` : u ? `— / ${fmtTok(u.ctxWindow)}` : '',
411      )}
412      {limitRow('●', W.fiveHour, five)}
413      {limitRow('◆', W.sevenDay, week)}
414      {others.map(l => limitRow('$', l.kind === 'spend_limit' ? W.spend : l.kind, l))}
415    </Box>,
416  )
417}
418
419export default Hud
420
types/index.d.ts 185 lines
1// ---- HUD (model, effort, context, limits, cost) ----
2
3/** One rate-limit window: `five_hour`, `seven_day`, or a gateway's `spend_limit`. */
4export type HudLimit = {
5  kind: string
6  percent: number
7  /** Epoch milliseconds, or null when the engine gave no reset time. */
8  resetsAt: number | null
9  /** How fast the window fills, in points per hour, from the readings so far; absent until there are two. */
10  rate?: number | null
11}
12
13/** What belongs to this chat alone: its context window and its cost. */
14export type HudChat = {
15  ctxTokens: number | null
16  /** The autocompact window when the engine names one, else the model's. */
17  ctxWindow: number
18  ctxPercent: number | null
19  costUsd: number | null
20}
21
22/**
23 * The account's limits, shared by every chat: the freshest reading any chat
24 * running the HUD got, and when it got it (epoch ms).
25 */
26export type HudLimits = {
27  limits: HudLimit[]
28  at: number
29}
30
31export type HudLang = 'en' | 'pt-BR'
32
33/** How the bars look (`/crabhud style`): six drawn styles and the classic text blocks. */
34export type HudStyle = 'pixel' | 'segments' | 'line' | 'solid' | 'dots' | 'spark' | 'classic'
35
36/** The style picked on each surface: the desktop app and the terminal keep their own. */
37export type HudStyles = { terminal: HudStyle; desktop: HudStyle }
38
39/** What the hooks module hands the drawing (plain JSON). */
40export type HudProps = {
41  surface: 'terminal' | 'desktop'
42  lang: HudLang
43  style: HudStyle
44  /**
45   * The style picker's preview: draw one sample bar this many cells long,
46   * alive, instead of the HUD.
47   */
48  preview?: number
49  chat: HudChat | null
50  limits: HudLimit[]
51  model: string
52  effort: string | null
53  /** Ultracode: the app's picker's top step, or the terminal's Tab switch. */
54  ultracode: boolean
55  working: boolean
56  expanded: boolean
57  now: number
58}
59
60// ---- Progress bar, crab and agents panel ----
61
62export type Phase = 'plan' | 'design' | 'delegate' | 'review' | 'close'
63
64export type PlannedTask = {
65  title: string
66  tier: string
67  after: number[]
68}
69
70/** A named stretch of the bar (one skill, say) and the steps it holds, in order. */
71export type StepGroup = {
72  name: string
73  steps: string[]
74  /** A short name per step for the bar's pill ('' = use the step's name). */
75  short: string[]
76}
77
78export type Flow = {
79  title: string
80  total: number
81  done: number
82  running: number
83  phase: Phase
84  isFinished: boolean
85  tasks: PlannedTask[]
86  /** The step in progress, when the caller names it; else read from `groups`. */
87  step: string
88  groups: StepGroup[]
89  /** The last step forward: the percent before it and when it came, so the tag glides from there. */
90  moved?: { from: number; at: number }
91  /** Who drives the bar: the progress tool (a skill, /savvy-flow), Claude's own task list, or an approved plan. */
92  source?: 'tool' | 'todo' | 'plan'
93  /** When it reached 100%, for the crab's celebration. */
94  doneAt?: number
95}
96
97/** The main agent's current turn: since when, and how many tools it has called. */
98export type TurnStats = {
99  startedAt: number
100  actions: number
101  /** Set when the turn ended. */
102  endedAt?: number
103}
104
105export type AgentStatus = 'running' | 'done' | 'failed'
106
107export type AgentRun = {
108  id: string
109  agentId?: string
110  type: string
111  description: string
112  model: string
113  effort?: string
114  status: AgentStatus
115  startedAt: number
116  endedAt?: number
117  contextTokens: number
118  contextMax: number
119  tokens: number
120  costUsd: number
121  steps: number
122  round: number
123  /** Self-reported by the worker through the `step` tool. */
124  stepDone?: number
125  stepTotal?: number
126  stepNote?: string
127}
128
129/** The main agent's tool in flight, for the crab's mood. */
130export type Activity = {
131  id: string
132  kind: 'read' | 'search' | 'web' | 'write' | 'run' | 'other'
133  target: string
134}
135
136/** Claude waiting on the person: a permission prompt or its own question. */
137export type Waiting = {
138  kind: 'permission' | 'answer'
139  /** The tool asking, and what it acts on (a command, a file, the question). */
140  tool: string
141  detail: string
142  /** The call it waits on; cleared when that call ends. */
143  id: string
144}
145
146export type Panel = {
147  isCompact: boolean
148  isDoneCollapsed: boolean
149  autoOpenedFor: string
150}
151
152declare module 'claude-code' {
153  interface PluginState {
154    crabhud: {
155      // HUD
156      chat: HudChat | null
157      limits: HudLimits | null
158      model: string
159      effort: string | null
160      ultracode: boolean
161      expanded: boolean
162      hidden: boolean
163      alerted: string[]
164      language: HudLang | null
165      style: HudStyles
166      /** Bumped every 30 s so the app's HUD redraws its reset countdowns. */
167      tick: number
168      // Progress bar and agents panel
169      flow: Flow | null
170      agents: AgentRun[]
171      panel: Panel
172      now: number
173      activity: Activity | null
174      waiting: Waiting | null
175      /** Sounds on (`/crabhud sound on`); off by default. */
176      sound: boolean
177      turn: TurnStats | null
178      /** What the chats on this machine spent, by day (YYYY-MM-DD, local). */
179      spend: Record<string, number>
180      /** `/crabhud demo` on until this time (ms), else 0. */
181      demo: number
182    }
183  }
184}
185