SLOPSHOPPER

hudcade

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.

newbandspinnerguardcommandtoast
v0.1.0MITupdated 2026-10-08g761007/hudcade/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hudcade
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /hud ⎿ hudcade: hudcade status ⎿ hudcade: modules: hud on · status line on · spinner on (random) · notify on ⎿ hudcade: context thresholds: warn 60% · critical 80% ⎿ hudcade: config sources: userConfig ⎿ hudcade: telegram: not configured · language auto ⎿ hudcade: hud data: project=app · model=claude-opus-5-5 · effort=— · turn=1 (idle) ▄▀▄▄▄▄▀▀▀▀▀▄▄ Thinking ▄▀▄ ▀▀▀▄▄▄ 0:00 · default · thinking ▀▀▀▀▀▀▀▀▀▄▄▄▄▄▄▄▄▄▄▄▄▄▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ▸ … ████████████████████████████████████████▀▀▀█████▀▀▀█████ 9 tools ◆ Opus 5.5 app $0.42 · ◷ 30m ctx ██████░░░░░░ 49% · 103k left 5h █░░░ 31% 20350041d15h ○ idle · turn 1 08:53:20–08:53:20 (0:42) tools 9 ✗1 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ hudcade: Opus 5.5 │ ctx 49% │ $0.42 │ 5h 31%

Draws

Band
◆ Opus 5.5 app $0.42 · ◷ 30m ctx ██████░░░░░░ 49% · 103k left 5h █░░░ 31% 20350041d15h ○ idle · turn 1 08:53:20–08:53:20 (0:42) tools 9 ✗1
Spinner
▄▀▄▄▄▄▀▀▀▀▀▄▄ Thinking ▄▀▄ ▀▀▀▄▄▄ 0:00 · default · thinking ▀▀▀▀▀▀▀▀▀▄▄▄▄▄▄▄▄▄▄▄▄▄▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ▸ … ████████████████████████████████████████▀▀▀█████▀▀▀█████ 9 tools
README

🕹️ hudcade

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.

License: MIT Claude Code Built as a mod Platform

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%">

Why hudcade

Claude Code shows you the conversation. hudcade shows you everything happening around it.

  • 💸 Know what a turn costs while it runs. Session cost, what this turn has added so far, and how close you are to the 5-hour and 7-day limits, with reset countdowns.
  • 🧠 See context filling up before auto-compact hits. A colored context bar with tokens left, how often the session has compacted, and, once you pass the warning line, what is eating the window.
  • ⚡ Catch a broken prompt cache. Hit ratio for the session. The last request is flagged when it missed, and a resumed session whose cache has expired says so, with the cost of rebuilding it.
  • 🤖 Watch every worker at once. Subagents with their model and current tool, agent-team teammates, background shells and scheduled loops, each on its own row.
  • 🕹️ Make waiting fun. The spinner becomes one of ten little 8-bit scenes that react to what Claude is doing.
  • 📱 Walk away. A Telegram message when a turn finishes, when Claude needs your approval, or when a turn fails.

Install

In a Claude Code terminal session:

/plugin install hudcade --marketplace g761007/hudcade
  1. Answer y to add the marketplace.
  2. Pick the user scope.
  3. Fill in the options you want. Leave the Telegram fields empty if you don't want notifications.

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

What you get

The HUD band

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.

RowWhat it tells you
IdentityModel 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
CapacityContext bar, tokens left and compactions · prompt-cache hit ratio · 5-hour and 7-day limits with reset countdowns
ActivityWorking, 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 breakdownThe largest parts of the window. Only appears once context passes the warning threshold.
WorkersSubagents, 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.

The status line

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%

The pixel spinner

<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:

SceneThinkingRunning a toolReplying
Robot coderThe antenna blinks, a question formsA progress bar fills, gears turn, sparks flyIt types; bits stream out of the screen
Coffee conveyorMugs roll under a slow dripA kettle pours, the drip speeds upThe belt speeds up, every mug steams
Little gardenPlants sprout and swayA watering can flies overEverything blooms, butterflies arrive
Rocket voyageCruising among the starsCollecting energy crystalsWarp speed
Cat and yarnThe cat sits, tail swishingIt pounces, the yarn unrollsA full-speed chase
FishingThe bobber bobs, fish swim belowA bite and a splashFish leap out of the water
Steam trainPuffing through the treesThe cars load cargoFull steam ahead
CampfireSmall flames under the starsA log goes in, the fire flaresA marshmallow toasts
WeatherSun and drifting cloudsStorm clouds, rain and lightningA rainbow and birds
EqualizerA low, gentle wavePulses on the beatPeaks 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.

Telegram notifications

✅ 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:

  • A turn completes. Turns you interrupt and subagent turns are skipped.
  • A permission prompt is waiting.
  • A turn fails, for example on a rate limit.

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.

Configuration

Everything lives in /config, or run claude plugin configure hudcade:

OptionDefaultMeaning
hudtrueThe HUD band
statusLinetrueThe line under the prompt
spinnertrueThe pixel spinner (terminal)
spinnerScenerandomrandom, or one of robot, coffee, garden, rocket, cat, fishing, train, campfire, weather, equalizer
ctxWarnPercent / ctxCriticalPercent60 / 80Context thresholds for colors, toasts and the breakdown row
notifytrueTelegram notifications
notifyMinTurnSeconds0Skip completion messages for shorter turns; 0 sends every turn
notifyBlocklistemptyProject folder names that never notify, separated by spaces
notifyLanguageautoauto 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.

How it works

hudcade is a mod: a plugin of function hooks that runs inside Claude Code itself.

  • No statusline script. Nothing is spawned on every refresh, and no transcript files are parsed.
  • It reads the engine directly. Usage, cost and rate limits come from $.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.
  • The drawing goes through Claude Code's UI. The band and the status line are drawn with the UI's elements. The scenes are a Raster repainted about twelve times a second while a turn runs, and only then.
  • The logic is plain TypeScript, separate from the hooks. The HUD model, the layout, the scenes and the messages are pure functions, covered by tests that also mount the band on the terminal and desktop surfaces.

Requirements

  • Claude Code 2.1.290 or newer. Mods are on by default from 2.1.287; their API is still marked early access.
  • macOS or Linux, with git and date on PATH.
  • A Telegram bot, only if you want notifications.

Known limits

  • Background tasks and loops refresh when a turn ends. One that finishes while you are idle stays listed until the next turn.
  • Lines added and removed count Edit and Write changes, not files changed through shell commands.
  • Output speed counts from the first streamed chunk, thinking included.
  • Before the first reply of a session, Claude Code has no context or rate-limit readings yet. The band says so instead of showing zeros.

Troubleshooting

  • Nothing shows. Run /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:.
  • No Telegram message. /hud tells you whether Telegram is configured. Then try /hud test-notify.

Development

.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:

  • A module registers one hook per event.
  • The engine follows $ 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.

Contributing

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.

Disclaimer

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.

License

MIT

Source 22 files
hooks/register.tsx 723 lines
1// 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}
723
src/core/band.ts 405 lines
1// 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}
405
src/core/config.ts 157 lines
1// 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}
157
src/core/format.ts 163 lines
1// 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}
163
src/core/scenes/index.ts 37 lines
1import 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}
37
src/core/spinner.ts 15 lines
1// 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}
15
src/core/hud-model.ts 368 lines
1// 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}
368
src/core/telegram.ts 141 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
140}
141
src/core/scenes/campfire.ts 46 lines
1// 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 }
46
src/core/scenes/canvas.ts 109 lines
1// 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}
109
src/core/scenes/cat.ts 37 lines
1// 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 }
37
src/core/scenes/coffee.ts 40 lines
1// 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