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…

<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:
12 actions · 1m12s).mcp__crabhud__progress tool, and subagents report their own steps through mcp__crabhud__step. At 100% the crab hops and throws pixel confetti.+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./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, ○ ◔ ◑ ◕ ●.
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.
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%
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:
/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.
<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.
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.
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.
/effort the moment it closes, and start from the effort saved for your model.~/.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.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.
git clone https://github.com/vinicius-sandes/crabhud ~/.claude/mods/crabhud
~/.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.
To update: git -C ~/.claude/mods/crabhud pull, then open a new chat.
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" } } }
/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.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).
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.
claude plugin validate ~/.claude/mods/crabhud
claude plugin test ~/.claude/mods/crabhud
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.
hooks/register.tsx 3176 lines1import { 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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 lines1// 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, '&').replace(/</g, '<')}</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}
363hooks/hud-svg.ts 236 lines1// 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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}
236hooks/hud-words.ts 119 lines1// 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}
119hooks/portraits.ts 117 lines1// 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}
117hooks/hud-client.tsx 420 lines1import 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
420types/index.d.ts 185 lines1// ---- 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