A pixel-art HUD for Claude Code: context, cost, cache, agents and rate limits at a glance, little 8-bit scenes while Claude thinks, and Telegram notifications.

A pixel-art HUD for Claude Code.
Context, cost, cache, agents and rate limits at a glance,<br> and little 8-bit scenes that play while Claude thinks.
English · 繁體中文
<img src="assets/hud.svg" alt="hudcade inside a Claude Code session: a pixel-scene spinner, the HUD band and the status line" width="100%">
Claude Code shows you the conversation. hudcade shows you everything happening around it.
In a Claude Code terminal session:
/plugin install hudcade --marketplace g761007/hudcade
y to add the marketplace.The HUD shows up right away, in every session from then on, including those the desktop app starts on the same machine.
If you used a statusLine command before, you can remove it from ~/.claude/settings.json. hudcade draws its own status line.
claude plugin marketplace add g761007/hudcade
claude plugin install hudcade@hudcade
Above the prompt, in the terminal and in the desktop app. Each row has a left and a right side. On a narrow terminal the least important parts drop out first.
| Row | What it tells you |
|---|---|
| Identity | Model and effort · project and branch (dirty, ahead/behind) · lines added and removed and files edited this session · session cost, what this turn added, session time |
| Capacity | Context bar, tokens left and compactions · prompt-cache hit ratio · 5-hour and 7-day limits with reset countdowns |
| Activity | Working, waiting for your approval, or idle · turn number, start time (and end time once done) · the tool running now · tokens in and out this turn · output speed · tool calls and failures · task progress |
| Context breakdown | The largest parts of the window. Only appears once context passes the warning threshold. |
| Workers | Subagents, teammates, background tasks and loops, while there are any |
Toasts warn you when context crosses its thresholds, and before a model switch that would throw away a warm prompt cache. The toast includes what rebuilding it will cost.
A compact line under the prompt, for when you only want the essentials:
Opus 5.5 · xhigh │ ctx 41% │ $3.21 │ 5h 13% │ 7d 38%
<img src="assets/demo.gif" alt="The ten spinner scenes, switching between thinking, running a tool and replying" width="828">
While Claude works, the spinner turns into a little 8-bit scene, a different one each turn. Every scene reacts to what Claude is doing:
| Scene | Thinking | Running a tool | Replying |
|---|---|---|---|
| Robot coder | The antenna blinks, a question forms | A progress bar fills, gears turn, sparks fly | It types; bits stream out of the screen |
| Coffee conveyor | Mugs roll under a slow drip | A kettle pours, the drip speeds up | The belt speeds up, every mug steams |
| Little garden | Plants sprout and sway | A watering can flies over | Everything blooms, butterflies arrive |
| Rocket voyage | Cruising among the stars | Collecting energy crystals | Warp speed |
| Cat and yarn | The cat sits, tail swishing | It pounces, the yarn unrolls | A full-speed chase |
| Fishing | The bobber bobs, fish swim below | A bite and a splash | Fish leap out of the water |
| Steam train | Puffing through the trees | The cars load cargo | Full steam ahead |
| Campfire | Small flames under the stars | A log goes in, the fire flares | A marshmallow toasts |
| Weather | Sun and drifting clouds | Storm clouds, rain and lightning | A rainbow and birds |
| Equalizer | A low, gentle wave | Pulses on the beat | Peaks in the red |
Next to the scene are the spinner word, the elapsed time, the effort, the current activity, and what the turn has done so far: tool calls, output tokens, speed and cost (2 tools · ↓900 · 50 tok/s · +$0.42). Pin one scene with the spinnerScene option.
The spinner is terminal only; the desktop app keeps its own.
✅ Claude Code is done
Project: my-app (feat/retry)
Time: 2026-10-08 14:34:19 CST
Took 2m14s · Opus 5.5 · xhigh · $3.21
5h:13%(2h02m) wk:81%(5d16h) ctx:65%(opus)
Summary: Added a retry policy with exponential backoff and covered it with 6 tests.
Session: 11fa1a35
Messages are sent in three cases:
Messages come in English or Traditional Chinese. By default they follow Claude Code's own language setting. The bot token stays in Claude Code's secure storage and never appears in a log.
Everything lives in /config, or run claude plugin configure hudcade:
| Option | Default | Meaning |
|---|---|---|
hud | true | The HUD band |
statusLine | true | The line under the prompt |
spinner | true | The pixel spinner (terminal) |
spinnerScene | random | random, or one of robot, coffee, garden, rocket, cat, fishing, train, campfire, weather, equalizer |
ctxWarnPercent / ctxCriticalPercent | 60 / 80 | Context thresholds for colors, toasts and the breakdown row |
notify | true | Telegram notifications |
notifyMinTurnSeconds | 0 | Skip completion messages for shorter turns; 0 sends every turn |
notifyBlocklist | empty | Project folder names that never notify, separated by spaces |
notifyLanguage | auto | auto follows Claude Code's language setting (Chinese there means Traditional Chinese, anything else English); or en, zh-TW |
telegramBotToken / telegramChatId | — | Your bot and chat. When unset, ~/.claude/hooks/.telegram.env (TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID) is read if it exists |
Per project, .claude/claude-mods.json (shared with the team) and .claude/claude-mods.local.json (just you) override any option except the Telegram credentials:
{ "notify": false, "spinnerScene": "campfire" }
Commands:
/hud: what is switched on, where the settings came from, and what the HUD has read./hud test-notify: send a test Telegram message.hudcade is a mod: a plugin of function hooks that runs inside Claude Code itself.
$.session.usage(). Tool calls, subagents and tasks come from tool.call and the session events. Tokens, cache reads and output speed come from the model's own response stream.Raster repainted about twelve times a second while a turn runs, and only then.git and date on PATH./hud to see what the HUD has read and any reading that failed. For more detail, start claude --debug and look for lines starting with hudcade:./hud tells you whether Telegram is configured. Then try /hud test-notify..claude-plugin/marketplace.json the marketplace
plugin/ the mod
.claude-plugin/plugin.json manifest and options
hooks/register.tsx every hook
src/core/ pure logic: HUD model, band layout, config, messages
src/core/scenes/ one file per spinner scene
types/index.d.ts the session-state contract
tests/ unit and drawing tests
scripts/render-assets.ts renders assets/ from the mod's own code
tsc -p plugin # type-check (TypeScript 5.4+)
claude plugin validate plugin --strict # what the engine would refuse
claude plugin test plugin # tests, terminal and desktop surfaces
claude --plugin-dir ./plugin # run your working copy; saving a file reloads it
bun scripts/render-assets.ts # regenerate the README images (Bun + ImageMagick)
All hooks live in one file because of two engine rules:
$ only into functions declared in the same file.The editor and tsc read the API declarations from plugin/.claude-plugin/types/. The engine writes that folder whenever it loads the mod. Before the first load, copy it from the plugin-authoring skill's types/claude-code.d.ts.
Issues and pull requests are welcome, especially new scenes, new HUD rows and notification channels beyond Telegram.
A scene is one file in plugin/src/core/scenes/: a draw(canvas, mode, t) function that paints an 8-pixel-tall frame for the think, tool and reply moods. Register it in scenes/index.ts, add its name to SceneName and to the spinnerScene options, and the tests check that it draws in every mood and at every width.
Please run the three checks above before opening a PR.
hudcade is an independent community project. It is not affiliated with, endorsed by or sponsored by Anthropic. Claude and Claude Code are trademarks of Anthropic, PBC.
hooks/register.tsx 723 lines1// hudcade: a HUD band with session and agent progress, a status line, a
2// pixel-scene spinner (terminal), and Telegram notifications.
3//
4// The engine admits one hook per event in a module and follows `$` only into
5// functions of the same file, so every hook lives here and dispatches to the
6// enabled features; the rules themselves are pure functions in src/core.
7
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register, Timer } from 'claude-code'
10
11import type { Hud, SceneName } from '../types'
12import { buildBand, statusLine, type Seg } from '../src/core/band'
13import { createRuntime, parseEnvFile, resolveConfig, type Layer, type Runtime } from '../src/core/config'
14import { basename, clock, modelLabel, parseTzOffset, tokens, toolDetail, toolLabel, truncate, usd } from '../src/core/format'
15import { SCENE_ROWS, SCENES, modeLabel, modeOf, pickScene, renderScene, type SpinnerMode } from '../src/core/scenes'
16import { turnFacts } from '../src/core/spinner'
17import {
18 addDiff,
19 addRequestUsage,
20 agentResult,
21 applyResume,
22 applyUsage,
23 completeTurn,
24 emptyHud,
25 endTool,
26 finishAgent,
27 launchAgent,
28 linkAgent,
29 noteCompaction,
30 parseGitStatus,
31 patchCounts,
32 setCacheTtl,
33 setCtxTop,
34 setAgentModel,
35 setTeammates,
36 setWaiting,
37 setWorkers,
38 startTool,
39 startTurn,
40 taskCreated,
41 taskUpdated,
42 todosWritten,
43 withDefaults,
44 type UsageInput,
45} from '../src/core/hud-model'
46import { buildMessage, resolveLanguage, summarize, type NotifyKind } from '../src/core/telegram'
47
48// The one session value the drawings read: a write redraws the band and the spinner.
49const hudAtom = atom({ plugin: 'hudcade', key: 'hud' } as const, emptyHud(''))
50
51const PROJECT_FILES = ['claude-mods.json', 'claude-mods.local.json'] as const
52const GIT_THROTTLE_MS = 3_000
53const GIT_TOOLS = new Set(['Bash', 'Edit', 'Write', 'NotebookEdit'])
54const FRAME_MS = 80
55const SPINNER_TEXT_COLUMNS = 36
56
57type NotifyExtra = { durationMs?: number; summary?: string; detail?: string }
58
59// Every write and read sees a complete state, even one an older version of
60// the mod left in this session.
61function patch($: EngineInterface, change: (h: Hud) => Hud): Promise<Hud> {
62 return update($, hudAtom, h => change(withDefaults(h)))
63}
64
65async function snapshot($: EngineInterface): Promise<Hud> {
66 return withDefaults(await read($, hudAtom))
67}
68
69// Module-local working state (starts over on a hot reload); the values the
70// drawings read live in hudAtom.
71let runtime: Runtime = createRuntime({})
72let values: Record<string, unknown> = {}
73let lastGitAt = 0
74let gitNote = 'not read yet'
75let lastStatus: string | undefined
76let ticker: Timer | undefined
77let warned: 'none' | 'warn' | 'critical' = 'none'
78let frameTimer: Timer | undefined
79let isTicking = false
80let turnStartedAt = 0
81let toolsAtStart = 0
82let scene: SceneName = 'robot'
83let lastScene: SceneName | undefined
84let spinnerMode: SpinnerMode | undefined
85const spinners = new Map<string, number>() // mounted spinner requestId → scene columns
86
87// ── Configuration ─────────────────────────────────────────────────────────
88
89// Project overrides and the legacy Telegram env file are read through `$`,
90// so the configuration is completed when the session starts.
91async function loadRuntime($: EngineInterface): Promise<void> {
92 const root = await $.session.root()
93 const layers: Layer[] = []
94 const readWarnings: string[] = []
95
96 for (const name of PROJECT_FILES) {
97 const path = `${root}/.claude/${name}`
98 if (!(await $.fs.exists(path))) continue
99 try {
100 const parsed: unknown = JSON.parse(await $.fs.read(path))
101 if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
102 layers.push({ source: `.claude/${name}`, values: parsed as Record<string, unknown> })
103 } else {
104 readWarnings.push(`.claude/${name}: expected a JSON object`)
105 }
106 } catch (error) {
107 readWarnings.push(`.claude/${name}: ${error instanceof Error ? error.message : String(error)}`)
108 }
109 }
110
111 const { config, warnings } = resolveConfig(values, layers)
112 runtime.config = config
113 runtime.warnings = [...readWarnings, ...warnings]
114 runtime.sources = ['userConfig', ...layers.map(layer => layer.source)]
115 runtime.telegramSource = config.telegramBotToken && config.telegramChatId ? 'userConfig' : undefined
116
117 if (!runtime.telegramSource) {
118 const home = await $.env.get('HOME')
119 const path = `${home}/.claude/hooks/.telegram.env`
120 if (home && (await $.fs.exists(path))) {
121 const env = parseEnvFile(await $.fs.read(path))
122 config.telegramBotToken ||= env.TELEGRAM_BOT_TOKEN
123 config.telegramChatId ||= env.TELEGRAM_CHAT_ID
124 if (config.telegramBotToken && config.telegramChatId) runtime.telegramSource = '~/.claude/hooks/.telegram.env'
125 }
126 }
127}
128
129// ── HUD tracking ──────────────────────────────────────────────────────────
130
131async function refreshGit($: EngineInterface, force = false): Promise<void> {
132 const now = await $.clock.now()
133 if (!force && now - lastGitAt < GIT_THROTTLE_MS) return
134 lastGitAt = now
135 try {
136 const { exitCode, stdout } = await $.process.run(
137 ['git', '--no-optional-locks', 'status', '--porcelain=v2', '--branch', '-z'],
138 { timeoutMs: 2_000 },
139 )
140 const git = exitCode === 0 ? parseGitStatus(stdout) : undefined
141 gitNote = git ? 'ok' : `exit ${exitCode}`
142 await patch($, h => ({ ...h, git }))
143 } catch (error) {
144 // Not a repository, or no git: the band shows no branch.
145 gitNote = error instanceof Error ? error.message : String(error)
146 }
147}
148
149async function applyMeasure($: EngineInterface, usage: UsageInput): Promise<void> {
150 await patch($, h => applyUsage(h, usage))
151 const percent = usage.context?.percent
152 if (percent === undefined || percent <= 0 || !runtime.config.hud) return
153 const { ctxWarnPercent, ctxCriticalPercent } = runtime.config
154 if (percent >= ctxCriticalPercent && warned !== 'critical') {
155 warned = 'critical'
156 $.ui.toast(`Context ${Math.round(percent)}% — consider /compact soon`, { timeoutMs: 8_000 })
157 } else if (percent >= ctxWarnPercent && warned === 'none') {
158 warned = 'warn'
159 $.ui.toast(`Context ${Math.round(percent)}% used`)
160 } else if (percent < ctxWarnPercent) {
161 warned = 'none'
162 }
163}
164
165async function refreshTeammates($: EngineInterface): Promise<void> {
166 try {
167 const agents = await $.agent.list()
168 await patch($, h => setTeammates(h, agents))
169 } catch {
170 // Agent teams off, or the roster unavailable: no teammate rows.
171 }
172}
173
174// The window's largest parts, worth showing once context passes the warning threshold.
175async function refreshContextBreakdown($: EngineInterface): Promise<void> {
176 try {
177 const usage = await $.session.usage({ breakdown: 'summary' })
178 const categories = usage.context.breakdown?.categories
179 if (categories) await patch($, h => setCtxTop(h, categories))
180 } catch {
181 // The band shows no breakdown row.
182 }
183}
184
185// Pins the compact line under the prompt; only a changed text is sent.
186async function syncStatus($: EngineInterface): Promise<void> {
187 if (!runtime.config.statusLine) {
188 if (lastStatus !== undefined) $.ui.status(undefined)
189 lastStatus = undefined
190 return
191 }
192 const text = statusLine(await snapshot($))
193 if (text === lastStatus) return
194 lastStatus = text
195 $.ui.status(text)
196}
197
198// Redraws once a second while something runs, for the elapsed clocks.
199function syncTicker($: EngineInterface, isBusy: boolean): void {
200 const wantsTicks = isBusy && (runtime.config.hud || runtime.config.spinner)
201 if (wantsTicks && !ticker) {
202 ticker = $.clock.every(1_000, () => $.ui.invalidate('ui.render'))
203 } else if (!wantsTicks && ticker) {
204 ticker.cancel()
205 ticker = undefined
206 }
207}
208
209// ── Spinner frames ────────────────────────────────────────────────────────
210
211function stopFrames(): void {
212 frameTimer?.cancel()
213 frameTimer = undefined
214 spinners.clear()
215}
216
217async function paintFrame($: EngineInterface): Promise<void> {
218 if (isTicking || spinners.size === 0) return
219 isTicking = true
220 try {
221 const t = (await $.clock.now()) - turnStartedAt
222 for (const [requestId, columns] of spinners) {
223 const cells = renderScene(scene, modeOf(spinnerMode), t, columns)
224 const painted = await $.ui.blit({ requestId, key: 'scene', cells, columns, rows: SCENE_ROWS })
225 if ('deny' in painted && painted.deny) spinners.delete(requestId)
226 }
227 } catch {
228 // A missed frame is painted on the next tick.
229 } finally {
230 isTicking = false
231 }
232}
233
234// ── Telegram ──────────────────────────────────────────────────────────────
235
236async function notify($: EngineInterface, kind: NotifyKind, extra: NotifyExtra): Promise<string> {
237 const { config } = runtime
238 if (!config.telegramBotToken || !config.telegramChatId) return 'telegram is not configured'
239 const project = basename(await $.session.root())
240 if (kind !== 'test' && config.notifyBlocklist.includes(project)) return `project ${project} is blocklisted`
241
242 const [usage, h, sessionId, now, time] = await Promise.all([
243 $.session.usage(),
244 read($, hudAtom),
245 $.session.id(),
246 $.clock.now(),
247 localTime($),
248 ])
249 let settingsLanguage: unknown
250 if (config.notifyLanguage === 'auto') {
251 try {
252 settingsLanguage = ((await $.settings.read()) as Record<string, unknown>).language
253 } catch {
254 // Unreadable settings: English.
255 }
256 }
257 const text = buildMessage({
258 kind,
259 language: resolveLanguage(config.notifyLanguage, settingsLanguage),
260 project,
261 branch: h.git?.branch,
262 time,
263 now,
264 durationMs: extra.durationMs,
265 model: h.model,
266 effort: h.effort,
267 costUsd: usage.cost?.usd,
268 ctxPercent: usage.context.percent,
269 rates: usage.rateLimits.map(r => ({ kind: r.kind, percent: r.percentUsed, resetsAt: r.resetsAt })),
270 summary: extra.summary,
271 detail: extra.detail,
272 sessionId,
273 sessionTitle: h.sessionTitle,
274 })
275
276 try {
277 const response = await $.http.fetch(`https://api.telegram.org/bot${config.telegramBotToken}/sendMessage`, {
278 method: 'POST',
279 headers: { 'content-type': 'application/json' },
280 body: JSON.stringify({ chat_id: config.telegramChatId, text, parse_mode: 'HTML', disable_web_page_preview: true }),
281 })
282 $.ui.log(`hudcade: telegram ${kind} http=${response.status} project=${project}`, { to: 'debug' })
283 return `http ${response.status}`
284 } catch (error) {
285 // The error's text may carry the request URL, and with it the token.
286 const name = error instanceof Error ? error.name : 'error'
287 $.ui.log(`hudcade: telegram ${kind} failed (${name}) project=${project}`, { to: 'debug' })
288 return `failed (${name})`
289 }
290}
291
292function notifyLater($: EngineInterface, kind: NotifyKind, extra: NotifyExtra): void {
293 if (!runtime.config.notify) return
294 void notify($, kind, extra).catch(() => undefined)
295}
296
297// Local wall-clock time with the zone name, as the old hook's `date` printed it.
298async function localTime($: EngineInterface): Promise<string> {
299 try {
300 const { exitCode, stdout } = await $.process.run(['date', '+%Y-%m-%d %H:%M:%S %Z'], { timeoutMs: 2_000 })
301 if (exitCode === 0 && stdout.trim()) return stdout.trim()
302 } catch {
303 // Fall back to UTC.
304 }
305 return `${new Date().toISOString().replace('T', ' ').slice(0, 19)} UTC`
306}
307
308
309export const register: Register = (on, options) => {
310 values = options as Record<string, unknown>
311 runtime = createRuntime(values)
312
313 // ── Session ───────────────────────────────────────────────────────────────
314
315 on('session.start', async ($, e, next) => {
316 try {
317 await loadRuntime($)
318 } catch (error) {
319 runtime.warnings.push(`config: ${error instanceof Error ? error.message : String(error)}`)
320 }
321 await $.command.register({
322 name: 'hud',
323 description: 'hudcade status; `/hud test-notify` sends a test Telegram message',
324 argumentHint: '[test-notify]',
325 })
326 // Each fact is read on its own: one that fails must not blank the others,
327 // and its reason shows in /hud.
328 const failed: string[] = []
329 const note = (what: string, error: unknown) =>
330 failed.push(`${what}: ${error instanceof Error ? error.message : String(error)}`)
331 let project = ''
332 let model: string | undefined
333 let effort: string | undefined
334 let turns: number | undefined
335 try {
336 project = basename(await $.session.root())
337 } catch (error) {
338 note('session.root', error)
339 }
340 try {
341 model = await $.session.model()
342 } catch (error) {
343 note('session.model', error)
344 }
345 try {
346 const settings = (await $.settings.read()) as Record<string, unknown>
347 if (typeof settings.effortLevel === 'string') effort = settings.effortLevel
348 } catch (error) {
349 note('settings.read', error)
350 }
351 try {
352 turns = await $.session.turns()
353 } catch (error) {
354 note('session.turns', error)
355 }
356 let tzOffsetMinutes: number | undefined
357 try {
358 const { exitCode, stdout } = await $.process.run(['date', '+%z'], { timeoutMs: 2_000 })
359 if (exitCode === 0) tzOffsetMinutes = parseTzOffset(stdout)
360 } catch (error) {
361 note('date +%z', error)
362 }
363 try {
364 // Session state outlives a reload, so merge: what classic.SessionStart
365 // already noted (a resumed context, a cold cache) stays.
366 await patch($, h => ({
367 ...h,
368 project: project || h.project,
369 model: model ?? h.model,
370 effort: effort ?? h.effort,
371 tzOffsetMinutes: tzOffsetMinutes ?? h.tzOffsetMinutes,
372 turn: { ...h.turn, count: Math.max(h.turn.count, turns ?? 0) },
373 }))
374 } catch (error) {
375 note('state', error)
376 }
377 try {
378 const usage = await $.session.usage()
379 await applyMeasure($, { context: usage.context, rateLimits: usage.rateLimits, cost: usage.cost, startedAt: usage.startedAt })
380 } catch (error) {
381 note('session.usage', error)
382 }
383 await refreshGit($, true)
384 await syncStatus($)
385 runtime.warnings.push(...failed)
386 return next(e)
387 })
388
389 on('session.measure', async ($, e, next) => {
390 await applyMeasure($, { context: e.context, rateLimits: e.rateLimits, cost: e.cost })
391 await syncStatus($)
392 return next(e)
393 })
394
395 on('command.run', { command: 'hud' }, async ($, e) => {
396 if (e.args.trim() === 'test-notify') {
397 return { text: `hudcade test notification: ${await notify($, 'test', {})}` }
398 }
399 return { text: statusText(runtime, await snapshot($)) }
400 })
401
402 // ── Turns ─────────────────────────────────────────────────────────────────
403
404 on('turn.start', async ($, e, next) => {
405 const now = await $.clock.now()
406 const h = await patch($, h => startTurn(h, now))
407 syncTicker($, true)
408
409 turnStartedAt = now
410 toolsAtStart = h.tools.total
411 scene = pickScene(runtime.config.spinnerScene, lastScene, Math.random())
412 lastScene = scene
413 spinnerMode = undefined
414 stopFrames()
415 if (runtime.config.spinner) frameTimer = $.clock.every(FRAME_MS, () => void paintFrame($))
416 return next(e)
417 })
418
419 // Every model request of a turn passes here: write only what changed, so
420 // the drawings are not redrawn for nothing.
421 on('turn.step', async function* ($, e, next) {
422 const effort = e.effort === undefined ? undefined : String(e.effort)
423 const hud = await snapshot($)
424 const isChanged = e.agentId
425 ? hud.agents.some(a => a.agentId === e.agentId && a.model !== e.model)
426 : hud.model !== e.model || (effort !== undefined && hud.effort !== effort)
427 if (isChanged) {
428 await patch($, h =>
429 e.agentId ? setAgentModel(h, e.agentId, e.model) : { ...h, model: e.model, effort: effort ?? h.effort },
430 )
431 if (!e.agentId) await syncStatus($)
432 }
433
434 // Read the request's stream through unchanged; its stop chunk carries the
435 // tokens, cache reads and writes included.
436 const stream = next(e)
437 let firstChunkAt: number | undefined
438 for await (const chunk of stream) {
439 if (!e.agentId) {
440 firstChunkAt ??= await $.clock.now()
441 if (chunk.kind === 'stop' && chunk.usage) {
442 const usage = chunk.usage
443 const streamMs = (await $.clock.now()) - firstChunkAt
444 try {
445 await patch($, h => addRequestUsage(h, usage, streamMs))
446 } catch {
447 // A missed reading only skews the ratio and the speed.
448 }
449 }
450 }
451 yield chunk
452 }
453 return await stream.result
454 })
455
456 on('turn.complete', async ($, e, next) => {
457 if (!e.agentId) {
458 const now = await $.clock.now()
459 const h = await patch($, h => completeTurn(h, e.durationMs, now))
460 syncTicker($, h.agents.some(a => a.status === 'running'))
461 stopFrames()
462 void refreshGit($)
463 void refreshTeammates($)
464 const percent = h.ctx?.percent
465 if (percent !== undefined && percent >= runtime.config.ctxWarnPercent) void refreshContextBreakdown($)
466 await syncStatus($)
467
468 const isLongEnough = e.durationMs >= runtime.config.notifyMinTurnSeconds * 1_000
469 if (!e.isAborted && isLongEnough) {
470 notifyLater($, e.reason === 'error' ? 'failure' : 'done', {
471 durationMs: e.durationMs,
472 summary: summarize(e.answer),
473 })
474 }
475 }
476 return next(e)
477 })
478
479 // ── Tools, subagents, tasks ───────────────────────────────────────────────
480
481 on('tool.call', async ($, e, next) => {
482 const now = await $.clock.now()
483 const input = e as unknown as Record<string, unknown>
484 const id = e.tool_use_id ?? `${e.tool}:${now}`
485 await patch($, h => {
486 let state = startTool(h, { id, tool: e.tool, detail: toolDetail(e.tool, input), startedAt: now, agentId: e.agentId })
487 if (e.tool === 'Agent') {
488 state = launchAgent(state, {
489 toolUseId: id,
490 type: typeof input.subagent_type === 'string' ? input.subagent_type : 'general-purpose',
491 description: toolDetail('Agent', input),
492 model: typeof input.model === 'string' ? input.model : undefined,
493 isBackground: input.run_in_background === true,
494 now,
495 })
496 }
497 return state
498 })
499
500 const ran = await next(e)
501
502 try {
503 const end = await $.clock.now()
504 const isError = ran.deny !== undefined || ran.isError === true
505 const result = (ran.result ?? {}) as Record<string, unknown>
506 await patch($, h => {
507 let state = endTool(h, id, isError)
508 if (e.tool === 'Agent') {
509 state = agentResult(
510 state,
511 id,
512 {
513 agentId: typeof result.agentId === 'string' ? result.agentId : undefined,
514 model: typeof result.resolvedModel === 'string' ? result.resolvedModel : undefined,
515 isLaunchOnly: result.status === 'async_launched',
516 isError,
517 },
518 end,
519 )
520 } else if (e.tool === 'TaskCreate' && !isError) {
521 const task = result.task as { id?: unknown } | undefined
522 state = taskCreated(state, String(input.subject ?? ''), typeof task?.id === 'string' ? task.id : undefined)
523 } else if (e.tool === 'TaskUpdate' && !isError && typeof input.taskId === 'string') {
524 state = taskUpdated(state, input.taskId, {
525 status: input.status as 'pending' | 'in_progress' | 'completed' | 'deleted' | undefined,
526 subject: typeof input.subject === 'string' ? input.subject : undefined,
527 })
528 } else if ((e.tool === 'Edit' || e.tool === 'Write') && !isError && typeof input.file_path === 'string') {
529 const counts = patchCounts(ran.result)
530 if (counts) state = addDiff(state, input.file_path, counts)
531 } else if (e.tool === 'TodoWrite' && !isError && Array.isArray(input.todos)) {
532 state = todosWritten(state, input.todos as { content: string; status: 'pending' | 'in_progress' | 'completed' }[])
533 }
534 return state
535 })
536 if (GIT_TOOLS.has(e.tool)) void refreshGit($)
537 } catch {
538 // Bookkeeping never changes what the tool returned.
539 }
540 return ran
541 }).catch(($, e, next) => next(e))
542
543 on('classic.SubagentStart', async ($, e, next) => {
544 const now = await $.clock.now()
545 await patch($, h => linkAgent(h, e.agent_id, e.agent_type, now))
546 syncTicker($, true)
547 void refreshTeammates($)
548 return next(e)
549 })
550
551 on('classic.SubagentStop', async ($, e, next) => {
552 const now = await $.clock.now()
553 const h = await patch($, h => (e.agent_id ? finishAgent(h, e.agent_id, now) : h))
554 syncTicker($, h.turn.status !== 'idle' || h.agents.some(a => a.status === 'running'))
555 return next(e)
556 })
557
558 on('classic.SessionStart', async ($, e, next) => {
559 if (e.source === 'resume' || e.source === 'fork') {
560 await patch($, h =>
561 applyResume(h, {
562 contextTokens: e.context_tokens,
563 isCacheCold: e.prompt_cache_likely_expired,
564 rebuildUsd: e.estimated_cache_write_usd,
565 }),
566 )
567 } else if (e.source === 'clear') {
568 await patch($, h => ({ ...h, cache: undefined }))
569 }
570 return next(e)
571 })
572
573 on('classic.Stop', async ($, e, next) => {
574 if (!e.agent_id) {
575 const finished = new Set(['completed', 'failed', 'killed', 'stopped'])
576 const background = (e.background_tasks ?? [])
577 .filter(t => !finished.has(t.status))
578 .map(t => ({ id: t.id, type: t.type, status: t.status, description: t.description || t.command || t.name || t.type }))
579 const crons = (e.session_crons ?? []).map(c => ({ id: c.id, schedule: c.schedule, isRecurring: c.recurring, prompt: c.prompt }))
580 await patch($, h => setWorkers(h, background, crons))
581 }
582 return next(e)
583 })
584
585 on('classic.PostCompact', async ($, e, next) => {
586 await patch($, h => noteCompaction(h))
587 return next(e)
588 })
589
590 on('classic.TeammateIdle', async ($, e, next) => {
591 void refreshTeammates($)
592 return next(e)
593 })
594
595 // A switch forfeits a warm prompt cache: say what rebuilding it costs.
596 on('classic.PreModelSwitch', async ($, e, next) => {
597 await patch($, h => setCacheTtl(h, e.cache_ttl))
598 if (runtime.config.hud && e.prompt_cache_warm && e.estimated_cache_write_usd >= 0.01) {
599 const target = modelLabel(e.to_model) ?? e.to_model
600 $.ui.toast(
601 `Switching to ${target} rebuilds the prompt cache: ~${usd(e.estimated_cache_write_usd)} for ${tokens(e.context_tokens)} tokens`,
602 { timeoutMs: 8_000 },
603 )
604 }
605 return next(e)
606 })
607
608 on('classic.PostModelSwitch', async ($, e, next) => {
609 await patch($, h => setCacheTtl(h, e.cache_ttl))
610 return next(e)
611 })
612
613 on('classic.UserPromptSubmit', async ($, e, next) => {
614 if (e.session_title) await patch($, h => ({ ...h, sessionTitle: e.session_title }))
615 return next(e)
616 })
617
618 on('classic.Notification', async ($, e, next) => {
619 if (!e.agent_id && e.notification_type === 'permission_prompt') {
620 await patch($, h => setWaiting(h, true))
621 notifyLater($, 'permission', { detail: summarize(e.message, 200) })
622 }
623 return next(e)
624 })
625
626 on('classic.StopFailure', async ($, e, next) => {
627 if (!e.agent_id) {
628 const detail = [String(e.error), e.error_details].filter(Boolean).join(' — ')
629 notifyLater($, 'failure', { detail: summarize(detail, 200) })
630 }
631 return next(e)
632 })
633
634 // ── Drawing ───────────────────────────────────────────────────────────────
635
636 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
637 if (!runtime.config.hud || e.props.hasSurvey) return next(e)
638 const hud = await snapshot($)
639 const now = await $.clock.now()
640 const rows = buildBand(hud, now, Math.max(20, e.props.bodyColumns), {
641 warnPercent: runtime.config.ctxWarnPercent,
642 criticalPercent: runtime.config.ctxCriticalPercent,
643 }).slice(0, Math.max(1, e.props.maxRows))
644 const { Box, Text } = $.ui.resolve(e)
645 const texts = (segs: Seg[]) =>
646 segs.map(seg => (
647 <Text color={seg.color} dimColor={seg.dim} bold={seg.bold} wrap="truncate">
648 {seg.text}
649 </Text>
650 ))
651 return (
652 <Box flexDirection="column">
653 {rows.map(row => (
654 <Box flexDirection="row" justifyContent="space-between">
655 <Box flexDirection="row">{texts(row.left)}</Box>
656 {row.right.length > 0 && <Box flexDirection="row">{texts(row.right)}</Box>}
657 </Box>
658 ))}
659 </Box>
660 )
661 })
662
663 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
664 if (!runtime.config.spinner || e.surface !== 'terminal') return next(e)
665
666 spinnerMode = e.props.mode
667 const columns = Math.max(16, Math.min(56, (e.viewport?.columns ?? 100) - SPINNER_TEXT_COLUMNS))
668 spinners.set(e.requestId, columns)
669
670 const hud = await snapshot($)
671 const now = await $.clock.now()
672 const t = turnStartedAt ? now - turnStartedAt : 0
673 const elapsed = clock(hud.turn.startedAt !== undefined ? now - hud.turn.startedAt : t)
674 const main = hud.tools.running.filter(run => !run.agentId)
675 const current = main[main.length - 1]
676 const agents = hud.agents.filter(a => a.status === 'running').length
677 const activity = current
678 ? `▸ ${toolLabel(current.tool)} ${truncate(current.detail, 24)}`
679 : agents > 0
680 ? `▸ ${agents} agent${agents > 1 ? 's' : ''} running`
681 : '▸ …'
682
683 const { Box, Text, Raster } = $.ui.resolve(e)
684 return (
685 <Box flexDirection="row">
686 <Raster key="scene" columns={columns} rows={SCENE_ROWS} cells={renderScene(scene, modeOf(spinnerMode), t, columns)} />
687 <Box flexDirection="column" marginLeft={1}>
688 <Text color={SCENES[scene].accent} bold wrap="truncate">
689 {e.props.message ?? e.props.word}
690 {e.props.suffix}
691 </Text>
692 <Text dimColor wrap="truncate">
693 {elapsed} · {hud.effort ?? 'default'} · {modeLabel(spinnerMode)}
694 </Text>
695 <Text color="suggestion" wrap="truncate">
696 {activity}
697 </Text>
698 <Text dimColor wrap="truncate">
699 {turnFacts(hud, toolsAtStart)}
700 </Text>
701 </Box>
702 </Box>
703 )
704 })
705}
706
707function statusText(runtime: Runtime, hud: Hud): string {
708 const { config } = runtime
709 const onOff = (flag: boolean) => (flag ? 'on' : 'off')
710 const lines = [
711 'hudcade status',
712 `modules: hud ${onOff(config.hud)} · status line ${onOff(config.statusLine)} · spinner ${onOff(config.spinner)} (${config.spinnerScene}) · notify ${onOff(config.notify)}`,
713 `context thresholds: warn ${config.ctxWarnPercent}% · critical ${config.ctxCriticalPercent}%`,
714 `config sources: ${runtime.sources.join(', ')}`,
715 `telegram: ${runtime.telegramSource ? `configured (${runtime.telegramSource})` : 'not configured'} · language ${config.notifyLanguage}`,
716 `hud data: project=${hud.project || '—'} · model=${hud.model ?? '—'} · effort=${hud.effort ?? '—'} · turn=${hud.turn.count} (${hud.turn.status})`,
717 ` ctx=${hud.ctx ? `${hud.ctx.percent ?? '?'}% of ${hud.ctx.window}` : '— (no reading yet)'} · rate limits=${hud.rates.length} · cost=${hud.costUsd !== undefined ? usd(hud.costUsd) : '—'}`,
718 ` git=${hud.git ? hud.git.branch : `— (${gitNote})`} · tools=${hud.tools.total} · agents=${hud.agents.length} · tasks=${hud.tasks.length}`,
719 ]
720 if (runtime.warnings.length > 0) lines.push('warnings:', ...runtime.warnings.map(w => ` - ${w}`))
721 return lines.join('\n')
722}
723src/core/band.ts 405 lines1// Lays the HUD snapshot out as rows of styled segments that fit a width.
2// Pure: the mod maps each row to a left and a right group of <Text>.
3//
4// identity ◆ model · effort project ⎇ branch +added −removed · files cost +turn · ◷ time
5// capacity ctx bar % · left · ⟲compactions cache % │ 5h bar % reset │ 7d bar % reset
6// activity ● status clock · turn n since start / start–end ◑ tool ↑in ↓out · tok/s │ tasks d/n ▸ active
7// (context) ↳ the largest parts of the window, once context passes the warning threshold
8// workers agent / mate / bg / loop rows, while there are any
9
10import type { AgentRun, Hud } from '../../types'
11import {
12 bar,
13 clock,
14 wallClock,
15 compactDuration,
16 modelLabel,
17 msUntil,
18 rateLabel,
19 tokens,
20 toolLabel,
21 truncate,
22 usd,
23} from './format'
24import { cacheHitPercent, withDefaults } from './hud-model'
25
26export type Seg = { text: string; color?: string; dim?: boolean; bold?: boolean }
27export type Row = { left: Seg[]; right: Seg[] }
28
29export type BandOptions = { warnPercent: number; criticalPercent: number }
30
31// Lower numbers are dropped last when a row is too wide.
32type Group = { priority: number; side: 'left' | 'right'; segs: Seg[] }
33
34const LEFT_GAP: Seg = { text: ' ' }
35const RIGHT_SEP: Seg = { text: ' │ ', dim: true }
36const MIN_GAP = 2
37const SPIN = ['◐', '◓', '◑', '◒']
38const MAX_WORKER_ROWS = 4
39
40export function buildBand(raw: Hud, now: number, columns: number, options: BandOptions): Row[] {
41 const h = withDefaults(raw)
42 const rows = [identityRow(h, now), capacityRow(h, now, options), activityRow(h, now)].map(groups => fit(groups, columns))
43 const percent = ctxPercent(h)
44 if (percent !== undefined && percent >= options.warnPercent && h.ctxTop?.length) {
45 rows.push(fit([contextGroup(h)], columns))
46 }
47 for (const row of workerRows(h, now)) rows.push(fit([{ priority: 0, side: 'left', segs: row }], columns))
48 return rows.filter(row => row.left.length + row.right.length > 0)
49}
50
51export function rowText(row: Row): string {
52 return [...row.left, ...row.right].map(s => s.text).join('')
53}
54
55// ── Rows ────────────────────────────────────────────────────────────────────
56
57function identityRow(h: Hud, now: number): Group[] {
58 const groups: Group[] = []
59 const model = modelLabel(h.model) ?? 'Claude'
60 groups.push({
61 priority: 0,
62 side: 'left',
63 segs: [{ text: `◆ ${model}`, color: 'claude', bold: true }, ...(h.effort ? [{ text: ` · ${h.effort}`, dim: true }] : [])],
64 })
65
66 if (h.project) {
67 const place: Seg[] = [{ text: h.project, color: 'suggestion' }]
68 if (h.git) {
69 const marks = `${h.git.isDirty ? '*' : ''}${h.git.ahead ? ` ↑${h.git.ahead}` : ''}${h.git.behind ? ` ↓${h.git.behind}` : ''}`
70 place.push({ text: ` ⎇ ${h.git.branch}${marks}`, color: h.git.isDirty ? 'warning' : 'success' })
71 }
72 groups.push({ priority: 2, side: 'left', segs: place })
73 }
74
75 if (h.diff.files.length > 0) {
76 groups.push({
77 priority: 4,
78 side: 'left',
79 segs: [
80 { text: `+${h.diff.added}`, color: 'success' },
81 { text: ` −${h.diff.removed}`, color: 'error' },
82 { text: ` · ${h.diff.files.length} file${h.diff.files.length > 1 ? 's' : ''}`, dim: true },
83 ],
84 })
85 }
86
87 const money: Seg[] = []
88 if (h.costUsd !== undefined) {
89 money.push({ text: usd(h.costUsd), bold: true })
90 const delta = h.turn.status !== 'idle' && h.turn.costAtStart !== undefined ? h.costUsd - h.turn.costAtStart : 0
91 if (delta >= 0.005) money.push({ text: ` +${usd(delta).slice(1)}`, dim: true })
92 }
93 if (h.sessionStartedAt !== undefined) {
94 money.push({ text: `${money.length ? ' · ' : ''}◷ ${compactDuration(now - h.sessionStartedAt)}`, dim: true })
95 }
96 if (money.length) groups.push({ priority: 1, side: 'right', segs: money })
97 return groups
98}
99
100function capacityRow(h: Hud, now: number, options: BandOptions): Group[] {
101 const groups: Group[] = []
102 const percent = ctxPercent(h)
103 const compacted: Seg[] = h.compactions > 0 ? [{ text: ` · ⟲${h.compactions}`, dim: true }] : []
104 if (percent === undefined) {
105 // No response of this session has reported a fill yet (a new or just resumed session).
106 const window = h.ctx?.window ? ` · ${tokens(h.ctx.window)} window` : ''
107 groups.push({
108 priority: 0,
109 side: 'left',
110 segs: [{ text: `ctx ${bar(0, 12)} —${window} · waiting for first reply`, dim: true }, ...compacted],
111 })
112 } else {
113 const color = percent >= options.criticalPercent ? 'error' : percent >= options.warnPercent ? 'warning' : 'success'
114 const left = h.ctx?.tokens !== undefined && h.ctx.window > 0 ? ` · ${tokens(Math.max(0, h.ctx.window - h.ctx.tokens))} left` : ''
115 groups.push({
116 priority: 0,
117 side: 'left',
118 segs: [
119 { text: 'ctx ', dim: true },
120 { text: bar(percent, 12), color },
121 { text: ` ${Math.round(percent)}%`, color, bold: percent >= options.criticalPercent },
122 { text: left, dim: true },
123 ...compacted,
124 ],
125 })
126 }
127
128 const cache = cacheGroup(h)
129 if (cache) groups.push(cache)
130
131 h.rates.forEach((rate, i) => {
132 const color = rate.percent >= 90 ? 'error' : rate.percent >= 75 ? 'warning' : 'success'
133 const wait = msUntil(rate.resetsAt, now)
134 groups.push({
135 priority: 1 + i,
136 side: 'right',
137 segs: [
138 { text: `${rateLabel(rate.kind)} `, dim: true },
139 { text: bar(rate.percent, 4), color },
140 { text: ` ${Math.round(rate.percent)}%`, color: rate.percent >= 75 ? color : undefined },
141 ...(wait !== undefined ? [{ text: ` ${compactDuration(wait)}`, dim: true }] : []),
142 ],
143 })
144 })
145 return groups
146}
147
148function cacheGroup(h: Hud): Group | undefined {
149 const hit = cacheHitPercent(h.cache)
150 if (h.cache?.isCold) {
151 const cost = h.cache.rebuildUsd ? ` ~${usd(h.cache.rebuildUsd)}` : ''
152 return { priority: 4, side: 'right', segs: [{ text: `cache cold${cost}`, color: 'warning' }] }
153 }
154 if (hit === undefined) return undefined
155 const last = h.cache?.lastHitPercent
156 const isMiss = last !== undefined && last < 50 && (h.cache?.requests ?? 0) > 1
157 return {
158 priority: 4,
159 side: 'right',
160 segs: [
161 { text: 'cache ', dim: true },
162 { text: `${Math.round(hit)}%`, color: hit >= 70 ? 'success' : hit < 40 ? 'warning' : undefined },
163 ...(isMiss ? [{ text: ` last ${Math.round(last)}%`, color: 'warning' }] : []),
164 ],
165 }
166}
167
168function activityRow(h: Hud, now: number): Group[] {
169 const groups: Group[] = []
170 const { turn } = h
171 const at = (ms: number) => wallClock(ms, h.tzOffsetMinutes)
172 if (turn.status === 'idle') {
173 groups.push({ priority: 0, side: 'left', segs: [{ text: `○ idle · turn ${turn.count}`, dim: true }] })
174 if (turn.startedAt !== undefined && turn.endedAt !== undefined) {
175 const took = turn.lastDurationMs !== undefined ? ` (${clock(turn.lastDurationMs)})` : ''
176 groups.push({ priority: 2, side: 'left', segs: [{ text: `${at(turn.startedAt)}–${at(turn.endedAt)}${took}`, dim: true }] })
177 }
178 } else {
179 const elapsed = turn.startedAt !== undefined ? ` ${clock(now - turn.startedAt)}` : ''
180 const status: Seg =
181 turn.status === 'waiting'
182 ? { text: `⚠ waiting for you${elapsed}`, color: 'warning', bold: true }
183 : { text: `● working${elapsed}`, color: 'claude', bold: true }
184 groups.push({ priority: 0, side: 'left', segs: [status, { text: ` · turn ${turn.count}`, dim: true }] })
185 if (turn.startedAt !== undefined) {
186 groups.push({ priority: 2, side: 'left', segs: [{ text: `since ${at(turn.startedAt)}`, dim: true }] })
187 }
188 }
189
190 const main = h.tools.running.filter(t => !t.agentId)
191 const current = main[main.length - 1]
192 if (current && turn.status !== 'idle') {
193 groups.push({
194 priority: 1,
195 side: 'left',
196 segs: [
197 { text: `${spin(now)} ${toolLabel(current.tool)}`, color: 'suggestion' },
198 { text: ` ${clock(now - current.startedAt)}`, dim: true },
199 ...(current.detail ? [{ text: ` ${truncate(current.detail, 40)}` }] : []),
200 ],
201 })
202 }
203
204 const io: Seg[] = []
205 if (h.turnTokens && h.turnTokens.input + h.turnTokens.output > 0) {
206 io.push({ text: `↑${tokens(h.turnTokens.input)} ↓${tokens(h.turnTokens.output)}`, dim: true })
207 }
208 if (h.tokensPerSecond !== undefined) io.push({ text: `${io.length ? ' · ' : ''}${Math.round(h.tokensPerSecond)} tok/s`, dim: true })
209 if (h.tools.total > 0) {
210 io.push({ text: `${io.length ? ' · ' : ''}tools ${h.tools.total}`, dim: true })
211 if (h.tools.failed > 0) io.push({ text: ` ✗${h.tools.failed}`, color: 'error' })
212 }
213 if (io.length) groups.push({ priority: 3, side: 'right', segs: io })
214
215 if (h.tasks.length > 0) {
216 const done = h.tasks.filter(t => t.status === 'completed').length
217 const active = h.tasks.find(t => t.status === 'in_progress')
218 groups.push({
219 priority: 2,
220 side: 'right',
221 segs: [
222 { text: `tasks ${done}/${h.tasks.length}`, color: done === h.tasks.length ? 'success' : undefined },
223 ...(active ? [{ text: ` ▸ ${truncate(active.subject, 28)}`, dim: true }] : []),
224 ],
225 })
226 }
227 return groups
228}
229
230function contextGroup(h: Hud): Group {
231 const total = h.ctx?.tokens ?? h.ctxTop!.reduce((sum, c) => sum + c.tokens, 0)
232 const parts = h.ctxTop!.map(c => `${c.name.toLowerCase()} ${Math.round((c.tokens / Math.max(1, total)) * 100)}%`)
233 return { priority: 0, side: 'left', segs: [{ text: ' ↳ ', dim: true }, { text: parts.join(' · '), dim: true }] }
234}
235
236// ── Workers ─────────────────────────────────────────────────────────────────
237
238function workerRows(h: Hud, now: number): Seg[][] {
239 const rows: Seg[][] = []
240 const agents = [...h.agents].sort((a, b) => rankAgent(a) - rankAgent(b) || a.startedAt - b.startedAt)
241 for (const agent of agents) rows.push(agentRow(agent, now))
242 for (const mate of h.teammates) {
243 const isBusy = mate.status === 'running' || mate.status === 'pending'
244 rows.push([
245 tag('mate'),
246 { text: `◇ ${mate.name}`, color: isBusy ? 'suggestion' : undefined, bold: isBusy },
247 { text: ` · ${mate.status}`, dim: !isBusy },
248 ...(mate.description ? [{ text: ` · ${mate.description}`, dim: true }] : []),
249 ])
250 }
251 for (const task of h.background) {
252 rows.push([
253 tag('bg'),
254 { text: `» ${task.description || task.type}`, color: task.status === 'running' ? 'suggestion' : undefined },
255 { text: ` · ${task.status}`, dim: true },
256 ])
257 }
258 for (const cron of h.crons) {
259 rows.push([
260 tag('loop'),
261 { text: `↻ ${cron.schedule}`, color: 'suggestion' },
262 { text: `${cron.isRecurring ? '' : ' · once'} · ${truncate(cron.prompt.replace(/\s+/g, ' '), 60)}`, dim: true },
263 ])
264 }
265 if (rows.length <= MAX_WORKER_ROWS) return rows
266 return [...rows.slice(0, MAX_WORKER_ROWS - 1), [{ text: ` +${rows.length - MAX_WORKER_ROWS + 1} more`, dim: true }]]
267}
268
269function rankAgent(a: AgentRun): number {
270 return a.status === 'running' ? 0 : 1
271}
272
273function agentRow(a: AgentRun, now: number): Seg[] {
274 const isRunning = a.status === 'running'
275 const glyph = isRunning ? spin(now) : a.status === 'done' ? '✓' : '✗'
276 const color = isRunning ? 'suggestion' : a.status === 'done' ? 'success' : 'error'
277 const model = modelLabel(a.model)
278 return [
279 tag('agent'),
280 { text: `${glyph} `, color },
281 { text: a.type, color, bold: isRunning },
282 ...(model ? [{ text: ` · ${model}`, dim: true }] : []),
283 ...(a.isBackground ? [{ text: ' · bg', dim: true }] : []),
284 { text: ` · ${clock((a.endedAt ?? now) - a.startedAt)} · ${a.tools} tools`, dim: true },
285 ...(a.currentTool ? [{ text: ` ▸ ${toolLabel(a.currentTool)}`, color: 'suggestion' }] : []),
286 ...(a.description ? [{ text: ` · ${a.description}`, dim: !isRunning }] : []),
287 ]
288}
289
290function tag(name: string): Seg {
291 return { text: ` ${name.padEnd(6)}`, dim: true }
292}
293
294function spin(now: number): string {
295 return SPIN[Math.floor(now / 1000) % SPIN.length]!
296}
297
298// ── Fitting ─────────────────────────────────────────────────────────────────
299
300// Drops the lowest-priority groups until left, gap and right fit, then cuts
301// the left side's tail.
302function fit(groups: Group[], columns: number): Row {
303 const kept = [...groups]
304 const widthOf = () => {
305 const left = width(joinSide(kept, 'left'))
306 const right = width(joinSide(kept, 'right'))
307 return left + right + (left > 0 && right > 0 ? MIN_GAP : 0)
308 }
309 while (kept.length > 1 && widthOf() > columns) {
310 let drop = 0
311 for (let i = 1; i < kept.length; i++) if (kept[i]!.priority >= kept[drop]!.priority) drop = i
312 kept.splice(drop, 1)
313 }
314 const right = joinSide(kept, 'right')
315 const rightWidth = width(right)
316 const room = columns - (rightWidth > 0 ? rightWidth + MIN_GAP : 0)
317 return { left: fitSegs(joinSide(kept, 'left'), Math.max(0, room)), right: rightWidth > columns ? fitSegs(right, columns) : right }
318}
319
320function joinSide(groups: Group[], side: 'left' | 'right'): Seg[] {
321 const out: Seg[] = []
322 for (const g of groups.filter(g => g.side === side)) {
323 if (out.length > 0) out.push(side === 'left' ? LEFT_GAP : RIGHT_SEP)
324 out.push(...g.segs)
325 }
326 return out
327}
328
329function fitSegs(segs: Seg[], columns: number): Seg[] {
330 const out: Seg[] = []
331 let used = 0
332 for (const seg of segs) {
333 const w = displayWidth(seg.text)
334 if (used + w <= columns) {
335 out.push(seg)
336 used += w
337 continue
338 }
339 const room = columns - used
340 if (room > 1) out.push({ ...seg, text: cutToWidth(seg.text, room - 1) + '…' })
341 break
342 }
343 return out
344}
345
346function width(segs: Seg[]): number {
347 return segs.reduce((sum, seg) => sum + displayWidth(seg.text), 0)
348}
349
350// Context fill: the engine's percent, else tokens over a known window.
351function ctxPercent(h: Hud): number | undefined {
352 if (!h.ctx) return undefined
353 if (h.ctx.percent !== undefined && h.ctx.percent > 0) return h.ctx.percent
354 if (h.ctx.tokens !== undefined && h.ctx.window > 0) return (h.ctx.tokens / h.ctx.window) * 100
355 return h.ctx.percent
356}
357
358// Terminal columns: East Asian wide characters and most emoji take two.
359export function displayWidth(text: string): number {
360 let w = 0
361 for (const ch of text) w += isWide(ch.codePointAt(0)!) ? 2 : 1
362 return w
363}
364
365export function cutToWidth(text: string, max: number): string {
366 let w = 0
367 let out = ''
368 for (const ch of text) {
369 const cw = isWide(ch.codePointAt(0)!) ? 2 : 1
370 if (w + cw > max) break
371 out += ch
372 w += cw
373 }
374 return out
375}
376
377function isWide(cp: number): boolean {
378 return (
379 (cp >= 0x1100 && cp <= 0x115f) ||
380 (cp >= 0x2e80 && cp <= 0xa4cf) ||
381 (cp >= 0xac00 && cp <= 0xd7a3) ||
382 (cp >= 0xf900 && cp <= 0xfaff) ||
383 (cp >= 0xfe30 && cp <= 0xfe4f) ||
384 (cp >= 0xff00 && cp <= 0xff60) ||
385 (cp >= 0xffe0 && cp <= 0xffe6) ||
386 (cp >= 0x1f300 && cp <= 0x1faff) ||
387 (cp >= 0x20000 && cp <= 0x3fffd)
388 )
389}
390
391// The compact line pinned under the prompt: model · effort │ ctx │ cost │ 5h │ 7d.
392export function statusLine(raw: Hud): string {
393 const h = withDefaults(raw)
394 const model = modelLabel(h.model) ?? 'Claude'
395 const parts = [h.effort ? `${model} · ${h.effort}` : model]
396 const percent = ctxPercent(h)
397 parts.push(percent !== undefined ? `ctx ${Math.round(percent)}%` : 'ctx —')
398 if (h.costUsd !== undefined) parts.push(usd(h.costUsd))
399 for (const [kind, label] of [['five_hour', '5h'], ['seven_day', '7d']] as const) {
400 const rate = h.rates.find(r => r.kind === kind)
401 if (rate) parts.push(`${label} ${Math.round(rate.percent)}%`)
402 }
403 return parts.join(' │ ')
404}
405src/core/config.ts 157 lines1// Effective configuration: the plugin's userConfig options, overridden per
2// project by .claude/claude-mods.json and .claude/claude-mods.local.json.
3
4import type { SceneName } from '../../types'
5import { SCENE_NAMES } from './scenes'
6import type { LanguageChoice } from './telegram'
7
8export type SceneChoice = 'random' | SceneName
9
10export type ModsConfig = {
11 hud: boolean
12 statusLine: boolean
13 spinner: boolean
14 spinnerScene: SceneChoice
15 ctxWarnPercent: number
16 ctxCriticalPercent: number
17 notify: boolean
18 notifyMinTurnSeconds: number
19 notifyBlocklist: string[]
20 notifyLanguage: LanguageChoice
21 telegramBotToken?: string
22 telegramChatId?: string
23}
24
25export const DEFAULTS: ModsConfig = {
26 hud: true,
27 statusLine: true,
28 spinner: true,
29 spinnerScene: 'random',
30 ctxWarnPercent: 60,
31 ctxCriticalPercent: 80,
32 notify: true,
33 notifyMinTurnSeconds: 0,
34 notifyBlocklist: [],
35 notifyLanguage: 'auto',
36}
37
38const SCENE_CHOICES: readonly SceneChoice[] = ['random', ...SCENE_NAMES]
39
40// Secrets stay out of project files: only these keys may be overridden there.
41const PROJECT_KEYS = [
42 'hud',
43 'statusLine',
44 'spinner',
45 'spinnerScene',
46 'ctxWarnPercent',
47 'ctxCriticalPercent',
48 'notify',
49 'notifyMinTurnSeconds',
50 'notifyBlocklist',
51 'notifyLanguage',
52] as const
53
54// What every module reads. Created from userConfig when the module loads and
55// completed at session.start, once project files can be read through `$`.
56export type Runtime = {
57 config: ModsConfig
58 warnings: string[]
59 sources: string[]
60 telegramSource?: string
61}
62
63export type Layer = { source: string; values: Record<string, unknown> }
64
65export type ConfigResult = { config: ModsConfig; warnings: string[] }
66
67export function createRuntime(options: Record<string, unknown>): Runtime {
68 const { config, warnings } = resolveConfig(options, [])
69 return { config, warnings, sources: ['userConfig'] }
70}
71
72export function resolveConfig(options: Record<string, unknown>, projectLayers: Layer[]): ConfigResult {
73 const warnings: string[] = []
74 const config: ModsConfig = { ...DEFAULTS, notifyBlocklist: [...DEFAULTS.notifyBlocklist] }
75
76 apply(config, options, 'userConfig', Object.keys(options), warnings)
77 for (const layer of projectLayers) {
78 apply(config, layer.values, layer.source, PROJECT_KEYS, warnings)
79 }
80 return { config, warnings }
81}
82
83function apply(
84 config: ModsConfig,
85 values: Record<string, unknown>,
86 source: string,
87 allowed: readonly string[],
88 warnings: string[],
89): void {
90 for (const [key, value] of Object.entries(values)) {
91 if (value === undefined || value === null || value === '') continue
92 if (!allowed.includes(key)) {
93 warnings.push(`${source}: unknown or not overridable key "${key}"`)
94 continue
95 }
96 const error = set(config, key, value)
97 if (error) warnings.push(`${source}: ${error}`)
98 }
99}
100
101function set(config: ModsConfig, key: string, value: unknown): string | undefined {
102 switch (key) {
103 case 'hud':
104 case 'statusLine':
105 case 'spinner':
106 case 'notify':
107 if (typeof value !== 'boolean') return `"${key}" must be a boolean`
108 config[key] = value
109 return
110 case 'ctxWarnPercent':
111 case 'ctxCriticalPercent':
112 case 'notifyMinTurnSeconds':
113 if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
114 return `"${key}" must be a non-negative number`
115 }
116 config[key] = value
117 return
118 case 'spinnerScene':
119 if (!SCENE_CHOICES.includes(value as SceneChoice)) return `"spinnerScene" must be one of ${SCENE_CHOICES.join(', ')}`
120 config.spinnerScene = value as SceneChoice
121 return
122 case 'notifyLanguage':
123 if (value !== 'auto' && value !== 'en' && value !== 'zh-TW') return '"notifyLanguage" must be one of auto, en, zh-TW'
124 config.notifyLanguage = value
125 return
126 case 'notifyBlocklist':
127 if (typeof value === 'string') config.notifyBlocklist = value.split(/\s+/).filter(Boolean)
128 else if (Array.isArray(value) && value.every(v => typeof v === 'string')) config.notifyBlocklist = value
129 else return '"notifyBlocklist" must be a space-separated string or a string array'
130 return
131 case 'telegramBotToken':
132 case 'telegramChatId':
133 if (typeof value !== 'string') return `"${key}" must be a string`
134 config[key] = value.trim()
135 return
136 default:
137 return `unknown key "${key}"`
138 }
139}
140
141// Parses KEY="value" lines of a shell env file (the old notify hook's format).
142export function parseEnvFile(text: string): Record<string, string> {
143 const out: Record<string, string> = {}
144 for (const raw of text.split('\n')) {
145 const line = raw.trim()
146 if (!line || line.startsWith('#')) continue
147 const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line)
148 if (!match) continue
149 let value = match[2]!.trim()
150 if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
151 value = value.slice(1, -1)
152 }
153 out[match[1]!] = value
154 }
155 return out
156}
157src/core/format.ts 163 lines1// Small formatters shared by the HUD band, the spinner and Telegram messages.
2
3// 0:42, 12:05, 1:02:03
4export function clock(ms: number): string {
5 const total = Math.max(0, Math.floor(ms / 1000))
6 const h = Math.floor(total / 3600)
7 const m = Math.floor((total % 3600) / 60)
8 const s = total % 60
9 const ss = String(s).padStart(2, '0')
10 return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
11}
12
13// 14:32:05 local time; the offset comes from the machine's `date +%z`, since
14// the mod's own environment may not know the local zone.
15export function wallClock(epochMs: number, offsetMinutes?: number): string {
16 const pad = (n: number) => String(n).padStart(2, '0')
17 if (offsetMinutes === undefined) {
18 const d = new Date(epochMs)
19 return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
20 }
21 const d = new Date(epochMs + offsetMinutes * 60_000)
22 return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`
23}
24
25// "+0800" → 480, "-0330" → -210
26export function parseTzOffset(text: string): number | undefined {
27 const match = /^([+-])(\d{2})(\d{2})$/.exec(text.trim())
28 if (!match) return undefined
29 const minutes = Number(match[2]) * 60 + Number(match[3])
30 return match[1] === '-' ? -minutes : minutes
31}
32
33// 42s, 12m, 1h05m, 2d3h
34export function compactDuration(ms: number): string {
35 const total = Math.max(0, Math.floor(ms / 1000))
36 const d = Math.floor(total / 86400)
37 const h = Math.floor((total % 86400) / 3600)
38 const m = Math.floor((total % 3600) / 60)
39 if (d > 0) return `${d}d${h}h`
40 if (h > 0) return `${h}h${String(m).padStart(2, '0')}m`
41 if (m > 0) return `${m}m`
42 return `${total}s`
43}
44
45// 2m14s, 45s, 1h02m
46export function preciseDuration(ms: number): string {
47 const total = Math.max(0, Math.round(ms / 1000))
48 const h = Math.floor(total / 3600)
49 const m = Math.floor((total % 3600) / 60)
50 const s = total % 60
51 if (h > 0) return `${h}h${String(m).padStart(2, '0')}m`
52 if (m > 0) return `${m}m${String(s).padStart(2, '0')}s`
53 return `${s}s`
54}
55
56// 850, 12k, 913k, 1.2M
57export function tokens(n: number): string {
58 if (n >= 1_000_000) return `${trim(n / 1_000_000)}M`
59 if (n >= 1_000) return `${Math.round(n / 1_000)}k`
60 return String(Math.round(n))
61}
62
63function trim(n: number): string {
64 return n >= 10 ? String(Math.round(n)) : n.toFixed(1).replace(/\.0$/, '')
65}
66
67export function usd(n: number): string {
68 return n >= 100 ? `$${Math.round(n)}` : `$${n.toFixed(2)}`
69}
70
71// ██▌░░░ — one cell per 1/width, with a half cell for the remainder.
72export function bar(percent: number, width: number): string {
73 const clamped = Math.min(100, Math.max(0, percent))
74 const halves = Math.round((clamped / 100) * width * 2)
75 const full = Math.floor(halves / 2)
76 const half = halves % 2
77 return '█'.repeat(full) + (half ? '▌' : '') + '░'.repeat(Math.max(0, width - full - half))
78}
79
80// claude-opus-5-5 → Opus 5.5; claude-haiku-4-5-20251001 → Haiku 4.5
81export function modelLabel(id: string | undefined): string | undefined {
82 if (!id) return undefined
83 const base = id.replace(/\[.*\]$/, '').replace(/-\d{8}$/, '')
84 const match = /^(?:claude-)?([a-z]+)-(\d+)(?:-(\d+))?$/.exec(base)
85 if (!match) return id
86 const family = match[1]!.charAt(0).toUpperCase() + match[1]!.slice(1)
87 return match[3] ? `${family} ${match[2]}.${match[3]}` : `${family} ${match[2]}`
88}
89
90export function rateLabel(kind: string): string {
91 if (kind === 'five_hour') return '5h'
92 if (kind === 'seven_day') return '7d'
93 return kind.replace(/_/g, ' ')
94}
95
96// resetsAt arrives as an ISO time or as Unix seconds in a string.
97export function msUntil(resetsAt: string | undefined, now: number): number | undefined {
98 if (!resetsAt) return undefined
99 const at = /^\d+$/.test(resetsAt) ? Number(resetsAt) * 1000 : Date.parse(resetsAt)
100 return Number.isFinite(at) ? Math.max(0, at - now) : undefined
101}
102
103// Drops terminal control sequences and control characters from text that
104// came from outside (commands, descriptions, branch names).
105export function sanitize(text: string): string {
106 return text
107 .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
108 .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, '')
109 .replace(/[\u0000-\u001f\u007f-\u009f--]/g, ' ')
110 .replace(/\s+/g, ' ')
111 .trim()
112}
113
114export function truncate(text: string, max: number): string {
115 if (max <= 0) return ''
116 return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`
117}
118
119export function basename(path: string): string {
120 const parts = path.split('/').filter(Boolean)
121 return parts[parts.length - 1] ?? path
122}
123
124// A short description of a tool call for the HUD: the command, the file, the pattern.
125export function toolDetail(tool: string, input: Record<string, unknown>): string {
126 const str = (key: string) => (typeof input[key] === 'string' ? sanitize(input[key] as string) : '')
127 switch (tool) {
128 case 'Bash':
129 return str('description') || str('command')
130 case 'Read':
131 case 'Edit':
132 case 'Write':
133 case 'NotebookEdit':
134 return basename(str('file_path') || str('notebook_path'))
135 case 'Grep':
136 case 'Glob':
137 return str('pattern')
138 case 'WebFetch':
139 return hostOf(str('url'))
140 case 'WebSearch':
141 return str('query')
142 case 'Agent':
143 return str('description')
144 case 'Skill':
145 return str('skill')
146 case 'TaskCreate':
147 return str('subject')
148 default:
149 return ''
150 }
151}
152
153// mcp__server__tool → server:tool
154export function toolLabel(tool: string): string {
155 const match = /^mcp__(.+?)__(.+)$/.exec(tool)
156 return match ? `${match[1]}:${match[2]}` : tool
157}
158
159function hostOf(url: string): string {
160 const match = /^[a-z]+:\/\/([^/]+)/i.exec(url)
161 return match ? match[1]! : url
162}
163src/core/scenes/index.ts 37 lines1import type { SceneName } from '../../../types'
2import { campfire } from './campfire'
3import { Canvas } from './canvas'
4import { cat } from './cat'
5import { coffee } from './coffee'
6import { equalizer } from './equalizer'
7import { fishing } from './fishing'
8import { garden } from './garden'
9import type { Scene } from './kit'
10import type { Mode } from './mode'
11import { robot } from './robot'
12import { rocket } from './rocket'
13import { train } from './train'
14import { weather } from './weather'
15
16export { modeLabel, modeOf, type Mode, type SpinnerMode } from './mode'
17
18export const SCENES: Record<SceneName, Scene> = { robot, coffee, garden, rocket, cat, fishing, train, campfire, weather, equalizer }
19
20export const SCENE_NAMES = Object.keys(SCENES) as SceneName[]
21
22export const SCENE_ROWS = 4 // terminal rows: 8 pixels tall
23
24// One frame of `name` as Raster cells, `columns` wide and SCENE_ROWS tall.
25export function renderScene(name: SceneName, mode: Mode, t: number, columns: number): string {
26 const canvas = new Canvas(columns, SCENE_ROWS * 2)
27 SCENES[name].draw(canvas, mode, t)
28 return canvas.toCells()
29}
30
31// A fixed choice, or a different scene than last turn's for `random`.
32export function pickScene(choice: string, last: SceneName | undefined, roll: number): SceneName {
33 if ((SCENE_NAMES as readonly string[]).includes(choice)) return choice as SceneName
34 const pool = SCENE_NAMES.filter(name => name !== last)
35 return pool[Math.floor(Math.abs(roll) * pool.length) % pool.length]!
36}
37src/core/spinner.ts 15 lines1// The facts line beside the spinner scene: what this turn has done so far.
2
3import type { Hud } from '../../types'
4import { tokens, usd } from './format'
5
6export function turnFacts(h: Hud, toolsAtStart: number): string {
7 const calls = Math.max(0, h.tools.total - toolsAtStart)
8 const parts = [`${calls} tool${calls === 1 ? '' : 's'}`]
9 if (h.turnTokens && h.turnTokens.output > 0) parts.push(`↓${tokens(h.turnTokens.output)}`)
10 if (h.tokensPerSecond !== undefined) parts.push(`${Math.round(h.tokensPerSecond)} tok/s`)
11 const spent = h.costUsd !== undefined && h.turn.costAtStart !== undefined ? h.costUsd - h.turn.costAtStart : 0
12 if (spent >= 0.005) parts.push(`+${usd(spent)}`)
13 return parts.join(' · ')
14}
15src/core/hud-model.ts 368 lines1// Pure state transitions of the HUD snapshot. Hooks feed events in through
2// `update($, hud, fn)`; nothing here touches `$`, so every rule is testable.
3
4import type { AgentRun, BackgroundTask, CacheStats, Cron, CtxSlice, Hud, TaskItem, Teammate, ToolRun } from '../../types'
5
6const DONE_AGENT_TTL_MS = 60_000
7const MAX_RUNNING_TOOLS = 50
8
9export function emptyHud(project: string): Hud {
10 return {
11 project,
12 rates: [],
13 turn: { count: 0, status: 'idle' },
14 tools: { total: 0, failed: 0, running: [] },
15 agents: [],
16 tasks: [],
17 compactions: 0,
18 diff: { added: 0, removed: 0, files: [] },
19 background: [],
20 crons: [],
21 teammates: [],
22 }
23}
24
25// A state written by an older version of the mod (the session outlives a
26// reload) gets the fields it lacks.
27export function withDefaults(h: Partial<Hud> | undefined): Hud {
28 const base = emptyHud(h?.project ?? '')
29 return { ...base, ...h, diff: { ...base.diff, ...h?.diff } } as Hud
30}
31
32export type UsageInput = {
33 context?: { tokens?: number; window: number; percent?: number }
34 rateLimits?: readonly { kind: string; percentUsed: number; resetsAt?: string }[]
35 cost?: { usd: number }
36 startedAt?: number
37}
38
39// A context reading of 0 or none (start of session, mid-compaction) keeps the
40// last good one instead of flashing 0%.
41export function applyUsage(h: Hud, usage: UsageInput): Hud {
42 const next = { ...h }
43 if (usage.context) {
44 const hasReading = (usage.context.percent ?? 0) > 0 || (usage.context.tokens ?? 0) > 0
45 if (hasReading || !h.ctx) next.ctx = { ...usage.context }
46 else next.ctx = { ...h.ctx, window: usage.context.window }
47 }
48 if (usage.rateLimits) {
49 next.rates = usage.rateLimits.map(r => ({ kind: r.kind, percent: r.percentUsed, resetsAt: r.resetsAt }))
50 }
51 if (usage.cost) next.costUsd = usage.cost.usd
52 if (usage.startedAt !== undefined) next.sessionStartedAt = usage.startedAt
53 return next
54}
55
56export type RequestUsage = {
57 input_tokens: number
58 output_tokens?: number
59 cache_read_input_tokens: number
60 cache_creation_input_tokens: number
61}
62
63// One main-thread model request: the session's prompt-cache totals, the
64// turn's tokens, and the request's output speed when its duration is known.
65export function addRequestUsage(h: Hud, usage: RequestUsage, streamMs?: number): Hud {
66 const prev = h.cache ?? { read: 0, written: 0, uncached: 0, requests: 0 }
67 const read = usage.cache_read_input_tokens
68 const total = usage.input_tokens + read + usage.cache_creation_input_tokens
69 const output = usage.output_tokens ?? 0
70 const turn = h.turnTokens ?? { input: 0, output: 0 }
71 const isTimed = streamMs !== undefined && streamMs >= 250 && output > 0
72 return {
73 ...h,
74 cache: {
75 ...prev,
76 read: prev.read + read,
77 written: prev.written + usage.cache_creation_input_tokens,
78 uncached: prev.uncached + usage.input_tokens,
79 requests: prev.requests + 1,
80 lastHitPercent: total > 0 ? (read / total) * 100 : prev.lastHitPercent,
81 isCold: false,
82 },
83 turnTokens: { input: turn.input + total, output: turn.output + output },
84 tokensPerSecond: isTimed ? output / (streamMs / 1000) : h.tokensPerSecond,
85 }
86}
87
88export function noteCompaction(h: Hud): Hud {
89 return { ...h, compactions: h.compactions + 1, ctxTop: undefined }
90}
91
92// Lines a successful Edit or Write changed, from its structured patch.
93export function patchCounts(result: unknown): { added: number; removed: number } | undefined {
94 if (!result || typeof result !== 'object') return undefined
95 const r = result as { structuredPatch?: unknown; type?: unknown; content?: unknown }
96 let added = 0
97 let removed = 0
98 if (Array.isArray(r.structuredPatch)) {
99 for (const hunk of r.structuredPatch as { lines?: unknown }[]) {
100 if (!Array.isArray(hunk.lines)) continue
101 for (const line of hunk.lines) {
102 if (typeof line !== 'string') continue
103 if (line.startsWith('+')) added += 1
104 else if (line.startsWith('-')) removed += 1
105 }
106 }
107 }
108 // A new file's patch can be empty: count its content.
109 if (added === 0 && removed === 0 && r.type === 'create' && typeof r.content === 'string') {
110 added = r.content.length === 0 ? 0 : r.content.replace(/\n$/, '').split('\n').length
111 }
112 return { added, removed }
113}
114
115export function addDiff(h: Hud, file: string, counts: { added: number; removed: number }): Hud {
116 const files = h.diff.files.includes(file) ? h.diff.files : [...h.diff.files, file]
117 return { ...h, diff: { added: h.diff.added + counts.added, removed: h.diff.removed + counts.removed, files } }
118}
119
120// What a Stop reports: the session's background tasks and scheduled prompts.
121export function setWorkers(h: Hud, background: readonly BackgroundTask[], crons: readonly Cron[]): Hud {
122 return { ...h, background: [...background], crons: [...crons] }
123}
124
125export function setTeammates(h: Hud, agents: readonly { id: string; teammateId?: string; name?: string; status: string; description: string }[]): Hud {
126 const teammates: Teammate[] = agents
127 .filter(a => a.teammateId)
128 .map(a => ({ id: a.id, name: a.name ?? a.teammateId!.split('@')[0]!, status: a.status, description: a.description }))
129 return { ...h, teammates }
130}
131
132// The largest parts of the window, from /context's breakdown (used rows only).
133export function setCtxTop(h: Hud, categories: readonly { name: string; tokens: number; kind: string }[]): Hud {
134 const ctxTop: CtxSlice[] = categories
135 .filter(c => c.kind === 'used' && c.tokens > 0)
136 .sort((a, b) => b.tokens - a.tokens)
137 .slice(0, 4)
138 .map(c => ({ name: c.name, tokens: c.tokens }))
139 return { ...h, ctxTop }
140}
141
142export function setCacheTtl(h: Hud, ttl: '5m' | '1h'): Hud {
143 return { ...h, cache: { ...(h.cache ?? { read: 0, written: 0, uncached: 0, requests: 0 }), ttl } }
144}
145
146// Share of the session's input tokens served from the prompt cache.
147export function cacheHitPercent(cache: CacheStats | undefined): number | undefined {
148 if (!cache) return undefined
149 const total = cache.read + cache.written + cache.uncached
150 return total > 0 ? (cache.read / total) * 100 : undefined
151}
152
153// What SessionStart says of a resumed transcript: its context size, and
154// whether the prompt cache has outlived its TTL since the last response.
155export function applyResume(h: Hud, facts: { contextTokens?: number; isCacheCold?: boolean; rebuildUsd?: number }): Hud {
156 const hasReading = (h.ctx?.percent ?? 0) > 0 || (h.ctx?.tokens ?? 0) > 0
157 const ctx =
158 !hasReading && facts.contextTokens !== undefined
159 ? { window: h.ctx?.window ?? 0, tokens: facts.contextTokens }
160 : h.ctx
161 const cache = facts.isCacheCold
162 ? { ...(h.cache ?? { read: 0, written: 0, uncached: 0, requests: 0 }), isCold: true, rebuildUsd: facts.rebuildUsd }
163 : h.cache
164 return { ...h, ctx, cache }
165}
166
167export function startTurn(h: Hud, now: number): Hud {
168 return {
169 ...h,
170 turn: {
171 ...h.turn,
172 count: h.turn.count + 1,
173 status: 'working',
174 startedAt: now,
175 endedAt: undefined,
176 costAtStart: h.costUsd,
177 },
178 turnTokens: { input: 0, output: 0 },
179 agents: pruneAgents(h.agents, now),
180 }
181}
182
183export function completeTurn(h: Hud, durationMs: number, now: number): Hud {
184 return {
185 ...h,
186 turn: { ...h.turn, status: 'idle', startedAt: h.turn.startedAt ?? now - durationMs, endedAt: now, lastDurationMs: durationMs },
187 // A main-loop tool still listed when the turn ends was interrupted.
188 tools: { ...h.tools, running: h.tools.running.filter(t => t.agentId && isAgentRunning(h, t.agentId)) },
189 agents: pruneAgents(h.agents, now),
190 }
191}
192
193export function setWaiting(h: Hud, isWaiting: boolean): Hud {
194 if (h.turn.status === 'idle') return h
195 return { ...h, turn: { ...h.turn, status: isWaiting ? 'waiting' : 'working' } }
196}
197
198export function startTool(h: Hud, run: ToolRun): Hud {
199 let agents = h.agents
200 if (run.agentId) {
201 agents = ensureAgent(agents, run.agentId, run.startedAt).map(a =>
202 a.agentId === run.agentId ? { ...a, tools: a.tools + 1, currentTool: run.tool } : a,
203 )
204 }
205 const running = [...h.tools.running, run].slice(-MAX_RUNNING_TOOLS)
206 return {
207 ...setWaiting(h, false),
208 tools: { ...h.tools, total: h.tools.total + 1, running },
209 agents,
210 }
211}
212
213export function endTool(h: Hud, id: string, isError: boolean): Hud {
214 const run = h.tools.running.find(t => t.id === id)
215 const running = h.tools.running.filter(t => t.id !== id)
216 const agents = run?.agentId
217 ? h.agents.map(a => {
218 if (a.agentId !== run.agentId) return a
219 const still = running.filter(t => t.agentId === a.agentId)
220 return { ...a, currentTool: still[still.length - 1]?.tool }
221 })
222 : h.agents
223 return {
224 ...h,
225 tools: { ...h.tools, failed: h.tools.failed + (isError ? 1 : 0), running },
226 agents,
227 }
228}
229
230export type AgentLaunch = {
231 toolUseId: string
232 type: string
233 description: string
234 model?: string
235 isBackground: boolean
236 now: number
237}
238
239export function launchAgent(h: Hud, launch: AgentLaunch): Hud {
240 const run: AgentRun = {
241 key: `tu:${launch.toolUseId}`,
242 toolUseId: launch.toolUseId,
243 type: launch.type,
244 description: launch.description,
245 model: launch.model,
246 isBackground: launch.isBackground,
247 startedAt: launch.now,
248 tools: 0,
249 status: 'running',
250 }
251 return { ...h, agents: [...pruneAgents(h.agents, launch.now), run] }
252}
253
254// SubagentStart names the loop's id: attach it to the oldest launched agent of
255// that type still without one, or list the agent on its own.
256export function linkAgent(h: Hud, agentId: string, agentType: string, now: number): Hud {
257 if (h.agents.some(a => a.agentId === agentId)) return h
258 const index = h.agents.findIndex(a => !a.agentId && a.status === 'running' && a.type === agentType)
259 const fallback = index >= 0 ? index : h.agents.findIndex(a => !a.agentId && a.status === 'running')
260 if (fallback < 0) return { ...h, agents: ensureAgent(h.agents, agentId, now, agentType) }
261 return { ...h, agents: h.agents.map((a, i) => (i === fallback ? { ...a, agentId } : a)) }
262}
263
264// The Agent tool's result: a foreground agent is finished; a background one
265// only reports its id.
266export function agentResult(
267 h: Hud,
268 toolUseId: string,
269 result: { agentId?: string; model?: string; isLaunchOnly: boolean; isError: boolean },
270 now: number,
271): Hud {
272 return {
273 ...h,
274 agents: h.agents.map(a => {
275 if (a.toolUseId !== toolUseId) return a
276 const linked = { ...a, agentId: a.agentId ?? result.agentId, model: result.model ?? a.model }
277 if (result.isLaunchOnly && !result.isError) return linked
278 return { ...linked, status: result.isError ? 'failed' : 'done', endedAt: now, currentTool: undefined }
279 }),
280 }
281}
282
283export function finishAgent(h: Hud, agentId: string, now: number): Hud {
284 return {
285 ...h,
286 agents: h.agents.map(a =>
287 a.agentId === agentId && a.status === 'running' ? { ...a, status: 'done', endedAt: now, currentTool: undefined } : a,
288 ),
289 tools: { ...h.tools, running: h.tools.running.filter(t => t.agentId !== agentId) },
290 }
291}
292
293export function setAgentModel(h: Hud, agentId: string, model: string): Hud {
294 if (!h.agents.some(a => a.agentId === agentId && a.model !== model)) return h
295 return { ...h, agents: h.agents.map(a => (a.agentId === agentId ? { ...a, model } : a)) }
296}
297
298export function pruneAgents(agents: AgentRun[], now: number): AgentRun[] {
299 return agents.filter(a => a.status === 'running' || now - (a.endedAt ?? now) < DONE_AGENT_TTL_MS)
300}
301
302function isAgentRunning(h: Hud, agentId: string): boolean {
303 return h.agents.some(a => a.agentId === agentId && a.status === 'running')
304}
305
306function ensureAgent(agents: AgentRun[], agentId: string, now: number, type = 'agent'): AgentRun[] {
307 if (agents.some(a => a.agentId === agentId)) return agents
308 return [
309 ...agents,
310 { key: `ag:${agentId}`, agentId, type, description: '', isBackground: false, startedAt: now, tools: 0, status: 'running' },
311 ]
312}
313
314// TaskCreate adds one; its result carries the id.
315export function taskCreated(h: Hud, subject: string, id: string | undefined): Hud {
316 return { ...h, tasks: [...h.tasks, { id, subject, status: 'pending' }] }
317}
318
319// TaskUpdate names the task by id; tasks created before the id was known are
320// matched by their 1-based position, as Claude Code numbers them.
321export function taskUpdated(
322 h: Hud,
323 taskId: string,
324 change: { status?: 'pending' | 'in_progress' | 'completed' | 'deleted'; subject?: string },
325): Hud {
326 let index = h.tasks.findIndex(t => t.id === taskId)
327 if (index < 0 && /^\d+$/.test(taskId)) {
328 const position = Number(taskId) - 1
329 if (position >= 0 && position < h.tasks.length && !h.tasks[position]!.id) index = position
330 }
331 if (index < 0) return h
332 const status = change.status
333 if (status === 'deleted') return { ...h, tasks: h.tasks.filter((_, i) => i !== index) }
334 const tasks = h.tasks.map((t, i): TaskItem =>
335 i === index ? { ...t, id: taskId, subject: change.subject ?? t.subject, status: status ?? t.status } : t,
336 )
337 return { ...h, tasks }
338}
339
340// TodoWrite replaces the whole list.
341export function todosWritten(h: Hud, todos: readonly { content: string; status: TaskItem['status'] }[]): Hud {
342 return { ...h, tasks: todos.map(t => ({ subject: t.content, status: t.status })) }
343}
344
345// porcelain v2 `# branch.*` headers and change lines.
346export function parseGitStatus(stdout: string): { branch: string; isDirty: boolean; ahead: number; behind: number } | undefined {
347 const records = stdout.split('\0').filter(Boolean)
348 let branch: string | undefined
349 let oid: string | undefined
350 let ahead = 0
351 let behind = 0
352 let isDirty = false
353 for (const record of records) {
354 if (record.startsWith('# branch.head ')) branch = record.slice('# branch.head '.length)
355 else if (record.startsWith('# branch.oid ')) oid = record.slice('# branch.oid '.length)
356 else if (record.startsWith('# branch.ab ')) {
357 const match = /\+(\d+) -(\d+)/.exec(record)
358 if (match) {
359 ahead = Number(match[1])
360 behind = Number(match[2])
361 }
362 } else if (!record.startsWith('#')) isDirty = true
363 }
364 if (!branch) return undefined
365 if (branch === '(detached)') branch = oid && oid !== '(initial)' ? oid.slice(0, 7) : 'detached'
366 return { branch, isDirty, ahead, behind }
367}
368src/core/telegram.ts 141 lines1// Builds the Telegram message (HTML parse mode), in English or Traditional
2// Chinese: project, branch, time, duration, model, effort, cost, limits,
3// context, a summary of the reply and the session id.
4
5import { compactDuration, modelLabel, msUntil, preciseDuration, usd } from './format'
6
7export type NotifyKind = 'done' | 'permission' | 'failure' | 'test'
8
9export type NotifyLanguage = 'en' | 'zh-TW'
10
11export type LanguageChoice = 'auto' | NotifyLanguage
12
13export type NotifyFacts = {
14 kind: NotifyKind
15 language: NotifyLanguage
16 project: string
17 branch?: string
18 time: string
19 now: number
20 durationMs?: number
21 model?: string
22 effort?: string
23 costUsd?: number
24 ctxPercent?: number
25 rates: readonly { kind: string; percent: number; resetsAt?: string }[]
26 summary?: string
27 detail?: string
28 sessionId?: string
29 sessionTitle?: string
30}
31
32type Strings = {
33 titles: Record<NotifyKind, string>
34 project: string
35 topic: string
36 time: string
37 took: string
38 error: string
39 details: string
40 summary: string
41 session: string
42}
43
44const STRINGS: Record<NotifyLanguage, Strings> = {
45 en: {
46 titles: {
47 done: '✅ Claude Code is done',
48 permission: '⚠️ Claude Code needs your approval',
49 failure: '❌ Claude Code turn failed',
50 test: '🧪 hudcade test notification',
51 },
52 project: 'Project: ',
53 topic: 'Topic: ',
54 time: 'Time: ',
55 took: 'Took',
56 error: 'Error: ',
57 details: 'Details: ',
58 summary: 'Summary: ',
59 session: 'Session: ',
60 },
61 'zh-TW': {
62 titles: {
63 done: '✅ Claude Code 完成回覆',
64 permission: '⚠️ Claude Code 需要你的確認',
65 failure: '❌ Claude Code 回覆失敗',
66 test: '🧪 hudcade 測試通知',
67 },
68 project: '專案:',
69 topic: '主題:',
70 time: '時間:',
71 took: '耗時',
72 error: '錯誤:',
73 details: '內容:',
74 summary: '摘要:',
75 session: 'Session:',
76 },
77}
78
79// `auto` follows Claude Code's own `language` setting: Chinese there means
80// Traditional Chinese messages, anything else English.
81export function resolveLanguage(choice: LanguageChoice, settingsLanguage: unknown): NotifyLanguage {
82 if (choice !== 'auto') return choice
83 return typeof settingsLanguage === 'string' && /中文|chinese|^zh/i.test(settingsLanguage.trim()) ? 'zh-TW' : 'en'
84}
85
86export function buildMessage(f: NotifyFacts): string {
87 const s = STRINGS[f.language]
88 const lines = [`<b>${s.titles[f.kind]}</b>`]
89 lines.push(`${s.project}<code>${esc(f.project)}</code>${f.branch ? ` (${esc(f.branch)})` : ''}`)
90 if (f.sessionTitle) lines.push(`${s.topic}${esc(f.sessionTitle)}`)
91 lines.push(`${s.time}${esc(f.time)}`)
92
93 const run = [
94 f.durationMs !== undefined ? `${s.took} ${preciseDuration(f.durationMs)}` : undefined,
95 modelLabel(f.model),
96 f.effort,
97 f.costUsd !== undefined ? usd(f.costUsd) : undefined,
98 ].filter(Boolean)
99 if (run.length > 0) lines.push(esc(run.join(' · ')))
100
101 const stats = statsLine(f)
102 if (stats) lines.push(esc(stats))
103
104 if (f.detail) lines.push(`${f.kind === 'failure' ? s.error : s.details}${esc(f.detail)}`)
105 if (f.summary) lines.push(`${s.summary}${esc(f.summary)}`)
106 if (f.sessionId) lines.push(`${s.session}<code>${esc(f.sessionId.slice(0, 8))}</code>`)
107 return lines.join('\n')
108}
109
110// 5h:13%(2h15m) wk:38%(5d18h) ctx:9%(opus) — the old hook's stats line.
111function statsLine(f: NotifyFacts): string {
112 const parts: string[] = []
113 for (const rate of f.rates) {
114 const label = rate.kind === 'five_hour' ? '5h' : rate.kind === 'seven_day' ? 'wk' : rate.kind
115 const wait = msUntil(rate.resetsAt, f.now)
116 parts.push(`${label}:${Math.round(rate.percent)}%${wait !== undefined ? `(${compactDuration(wait)})` : ''}`)
117 }
118 if (f.ctxPercent !== undefined) {
119 const family = /opus|sonnet|haiku|fable/i.exec(f.model ?? '')?.[0]?.toLowerCase()
120 parts.push(`ctx:${Math.round(f.ctxPercent)}%${family ? `(${family})` : ''}`)
121 }
122 return parts.join(' ')
123}
124
125// The reply's gist: no code blocks or markdown marks, one line, ~max chars.
126export function summarize(text: string, max = 120): string {
127 const plain = text
128 .replace(/```[\s\S]*?```/g, ' ')
129 .replace(/[*_`#>]/g, '')
130 .replace(/\s+/g, ' ')
131 .trim()
132 if (plain.length <= max) return plain
133 const cut = plain.slice(0, max)
134 const space = cut.lastIndexOf(' ')
135 return `${space > max - 24 ? cut.slice(0, space) : cut}…`
136}
137
138export function esc(text: string): string {
139 return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
140}
141src/core/scenes/campfire.ts 46 lines1// Campfire.
2// A campfire under the stars. Thinking: small flames, a few sparks. A tool:
3// a log goes in and the fire flares. Replying: a marshmallow toasts from
4// white to brown.
5
6import type { Canvas } from './canvas'
7import { C, hash, wrap, type Scene } from './kit'
8import type { Mode } from './mode'
9
10function draw(c: Canvas, mode: Mode, t: number) {
11 for (let k = 0; k < 10; k++) if (Math.floor(t / 400 + k * 3) % 5 !== 0) c.set(Math.floor(hash(k) * c.width), Math.floor(hash(k + 9) * 3), C.white)
12 c.sprite(['.YY', 'Y..', 'Y..', '.YY'], 42, 0, { Y: C.cream })
13 c.sprite(['...T...', '..TTT..', '.TTdTT.', 'TTTddTT'], 2, 4, { T: C.orange, d: C.dbrown })
14 const fx = 20
15 c.hline(fx - 3, fx + 3, 7, C.brown)
16 c.set(fx - 2, 6, C.dbrown)
17 c.set(fx + 2, 6, C.dbrown)
18 const big = mode === 'tool' ? 1 + Math.max(0, 2 - wrap(t / 400, 4)) : 0
19 for (let dx = -2; dx <= 2; dx++) {
20 const h = Math.max(1, Math.round(3 + big + hash(Math.floor(t / 90) * 7 + dx) * 2 - Math.abs(dx)))
21 for (let i = 0; i < h; i++) {
22 const y = 6 - i
23 c.set(fx + dx, y, i >= h - 1 ? C.yellow : i >= h - 2 ? C.orange : C.red)
24 }
25 }
26 const sparks = mode === 'tool' ? 6 : 2
27 for (let s = 0; s < sparks; s++) {
28 const age = wrap(t / 120 + s * 2.3, 6)
29 c.set(fx + Math.round(Math.sin(s * 5 + t / 300) * 2), 3 - age, age < 3 ? C.yellow : C.orange)
30 }
31 if (mode === 'tool') {
32 const p = wrap(t / 1600, 1)
33 if (p < 0.3) {
34 const x = 36 - p * 50
35 c.hline(x, x + 2, 4 - Math.sin((p / 0.3) * Math.PI) * 3, C.brown)
36 }
37 }
38 if (mode === 'reply') {
39 for (let x = 25; x < 32; x++) c.set(x, 3 + Math.floor((x - 25) / 4), C.tan)
40 const toast = Math.floor(t / 700) % 3
41 c.sprite(['mm', 'mm'], 23, 3, { m: [C.white, C.tan, C.brown][toast]! })
42 }
43}
44
45export const campfire: Scene = { name: 'campfire', accent: '#ffd23f', draw }
46src/core/scenes/canvas.ts 109 lines1// A tiny pixel canvas packed into Raster cells: each terminal cell shows two
2// vertical pixels with the half-block characters, foreground on top.
3
4export const CLEAR = 0x01000000 // the terminal's default color (transparent)
5
6const SPACE = 0x20
7const UPPER = 0x2580 // ▀
8const LOWER = 0x2584 // ▄
9const FULL = 0x2588 // █
10
11export type Palette = Record<string, number>
12
13export class Canvas {
14 readonly px: Uint32Array
15
16 constructor(
17 readonly width: number,
18 readonly height: number,
19 ) {
20 this.px = new Uint32Array(width * height).fill(CLEAR)
21 }
22
23 set(x: number, y: number, color: number): void {
24 const xi = Math.round(x)
25 const yi = Math.round(y)
26 if (xi < 0 || yi < 0 || xi >= this.width || yi >= this.height) return
27 this.px[yi * this.width + xi] = color
28 }
29
30 get(x: number, y: number): number {
31 if (x < 0 || y < 0 || x >= this.width || y >= this.height) return CLEAR
32 return this.px[y * this.width + x]!
33 }
34
35 // Draws a sprite given as rows of palette keys; '.' is transparent.
36 sprite(rows: readonly string[], x: number, y: number, palette: Palette): void {
37 const left = Math.round(x)
38 const top = Math.round(y)
39 rows.forEach((row, dy) => {
40 for (let dx = 0; dx < row.length; dx++) {
41 const key = row[dx]!
42 if (key === '.') continue
43 const color = palette[key]
44 if (color !== undefined) this.set(left + dx, top + dy, color)
45 }
46 })
47 }
48
49 hline(x0: number, x1: number, y: number, color: number): void {
50 for (let x = x0; x <= x1; x++) this.set(x, y, color)
51 }
52
53 // Raster cells: `width` columns by `height / 2` rows of [codePoint, fg, bg].
54 toCells(): string {
55 const rows = Math.ceil(this.height / 2)
56 const words = new Uint32Array(this.width * rows * 3)
57 let i = 0
58 for (let r = 0; r < rows; r++) {
59 for (let x = 0; x < this.width; x++) {
60 const top = this.get(x, r * 2)
61 const bottom = this.get(x, r * 2 + 1)
62 let code = SPACE
63 let fg = CLEAR
64 let bg = CLEAR
65 if (top !== CLEAR && bottom !== CLEAR) {
66 if (top === bottom) {
67 code = FULL
68 fg = top
69 } else {
70 code = UPPER
71 fg = top
72 bg = bottom
73 }
74 } else if (top !== CLEAR) {
75 code = UPPER
76 fg = top
77 } else if (bottom !== CLEAR) {
78 code = LOWER
79 fg = bottom
80 }
81 words[i++] = code
82 words[i++] = fg
83 words[i++] = bg
84 }
85 }
86 return base64(words)
87 }
88}
89
90const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
91
92// Little-endian u32 words as standard padded base64 (RasterProps.cells).
93export function base64(words: Uint32Array): string {
94 const bytes = new Uint8Array(words.length * 4)
95 const view = new DataView(bytes.buffer)
96 words.forEach((word, i) => view.setUint32(i * 4, word, true))
97 let out = ''
98 for (let i = 0; i < bytes.length; i += 3) {
99 const a = bytes[i]!
100 const b = i + 1 < bytes.length ? bytes[i + 1]! : 0
101 const c = i + 2 < bytes.length ? bytes[i + 2]! : 0
102 const n = (a << 16) | (b << 8) | c
103 out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]!
104 out += i + 1 < bytes.length ? ALPHABET[(n >> 6) & 63]! : '='
105 out += i + 2 < bytes.length ? ALPHABET[n & 63]! : '='
106 }
107 return out
108}
109src/core/scenes/cat.ts 37 lines1// Cat and yarn.
2// A cat and a ball of yarn. Thinking: the cat sits, tail swishing. A tool:
3// it pounces and the yarn unrolls. Replying: it chases the yarn at full
4// speed.
5
6import type { Canvas } from './canvas'
7import { C, wrap, type Scene } from './kit'
8import type { Mode } from './mode'
9
10const CAT_SIT = ['k.k....', 'kkk....', 'kek....', 'kkkk...', '.kkkk..', '.kkkkT.', '.k.k..T']
11const CAT_RUN = [
12 ['......k.k', '......kkk', 'T.....kek', '.Tkkkkkk.', '..kkkkkk.', '..k....k.'],
13 ['......k.k', '......kkk', '......kek', 'TTkkkkkk.', '..kkkkkk.', '...k..k..'],
14]
15function draw(c: Canvas, mode: Mode, t: number) {
16 c.hline(0, c.width - 1, 7, C.tan)
17 if (mode === 'think') {
18 const tail = Math.floor(t / 300) % 2
19 const sit = CAT_SIT.map((row, i) => (i >= 5 && tail ? row.replace(/T/g, '.') + (i === 5 ? '' : '') : row))
20 c.sprite(sit, 8, 0, { k: C.orange, e: C.dgreen, T: C.orange })
21 if (tail) c.set(14, 4, C.orange)
22 const wobble = Math.round(Math.sin(t / 250))
23 c.sprite(['.pp.', 'pPpp', 'ppPp', '.pp.'], 18 + wobble, 3, { p: C.pink, P: 0xd9467c })
24 return
25 }
26 const speed = mode === 'reply' ? 30 : 14
27 const lap = c.width + 20
28 const yarnX = wrap((t / 1000) * speed, lap) - 4
29 const catX = yarnX - (mode === 'reply' ? 13 : 16)
30 for (let x = Math.max(0, catX + 9); x < yarnX; x++) c.set(x, 6, C.pink)
31 const spin = Math.floor(t / 100) % 2
32 c.sprite(spin ? ['.pp.', 'pPpp', 'ppPp', '.pp.'] : ['.pp.', 'ppPp', 'pPpp', '.pp.'], yarnX, 3, { p: C.pink, P: 0xd9467c })
33 c.sprite(CAT_RUN[Math.floor(t / (mode === 'reply' ? 70 : 120)) % 2]!, catX, 1, { k: C.orange, e: C.dgreen, T: C.orange })
34}
35
36export const cat: Scene = { name: 'cat', accent: '#ff77a8', draw }
37src/core/scenes/coffee.ts 40 lines1// Coffee conveyor.
2// Mugs ride a conveyor under a pour-over. Thinking: slow drips. A tool: a
3// kettle pours and the drip speeds up. Replying: the belt speeds up and
4// every mug steams.
5
6import type { Canvas } from './canvas'
7import { C, wrap, type Scene } from './kit'
8import type { Mode } from './mode'
9
10const MUG = ['WKKKW.', 'WKKKWh', 'WWWWWh', '.WWW..']
11function draw(c: Canvas, mode: Mode, t: number) {
12 const speed = mode === 'reply' ? 14 : mode === 'tool' ? 6 : 4
13 const dripX = 20
14 c.sprite(['DDDDDDD', '.DDDDD.', '..DDD..'], dripX - 3, 0, { D: C.tan })
15 if (mode === 'tool') {
16 c.sprite(['.KKKK', 'KKKKK', '.KKK.'], dripX - 11, 0, { K: C.steel })
17 for (let x = dripX - 6; x <= dripX - 4; x++) c.set(x, 0, C.cyan)
18 }
19 const dripEvery = mode === 'think' ? 260 : 120
20 c.set(dripX, 3 + ((t % dripEvery) / dripEvery > 0.5 ? 1 : 0), C.dbrown)
21 const spacing = 11
22 const offset = (t / 1000) * speed
23 for (let k = -1; k < c.width / spacing + 1; k++) {
24 const x = Math.round(k * spacing + wrap(offset, spacing)) - 2
25 const center = x + 2
26 const isFull = center > dripX + 1
27 const isFilling = Math.abs(center - dripX) <= 1
28 const inside = isFull ? C.dbrown : isFilling ? C.brown : 0x1f2937
29 c.sprite(MUG, x, 3, { W: C.white, h: C.white, K: inside })
30 if (isFull && (mode !== 'think' || k % 2 === 0)) {
31 const s = Math.floor(t / 160 + k) % 3
32 c.set(x + 1 + s, 2 - (s % 2), 0xcbd5e1)
33 if (mode === 'reply') c.set(x + 3 - s, 1 + (s % 2), 0xcbd5e1)
34 }
35 }
36 for (let x = 0; x < c.width; x++) c.set(x, 7, wrap(x - Math.floor(offset), 3) === 0 ? C.dgray : C.gray)
37}
38
39export const coffee: Scene = { name: 'coffee', accent: '#d8a46a', draw }
40