SLOPSHOPPER

clawd-hud

A pixel spark above the prompt with usage batteries (personal or enterprise), a prompt-cache timer and a context meter by the model picker

newbandspinnerguardstatustimer
★ 2v0.6.2MITupdated 2026-10-24Amitro123/claudemod/plugins/clawd-hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-hud
› 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 ✦\(^o^)/ 5h [▰▰▰▰▱] 69% left resets in – 7d [▱▱▱▱▱] – no reading yet ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ clawd-hud: ✦ 5h 69% left · 7d – · cache 60:00 · ctx 49% ϟ cache 60:00 · ctx 49% · 97k/200k ⟨Claude Code's own drawing⟩

Draws

Band
✦\(^o^)/ 5h [▰▰▰▰▱] 69% left resets in – 7d [▱▱▱▱▱] – no reading yet ⟨Claude Code's own drawing⟩
README

claudemod: mods for Claude Code

Seven mods, each installed on its own. They also work side by side: each draws its part of the band above the prompt, and the others' parts stay.

ModWhat it does
clawd-hudA pixel spark above the prompt, usage batteries, a prompt-cache timer and a context meter
task-hudA to-do progress bar, and a pane with a pixel creature for each sub-agent
followupsClaude flags what it notices, asks whether to track it, and keeps a to-do you can export
rtl-toggleFixes the reading order of lines that mix English and Hebrew
goal-runAsks for a goal when a session starts, has Claude plan it, and runs a pixel Claude to 100% as the tasks get done
brain-hudAgent memory with its skill built in: mistake-brain logs and routes lessons, a pixel Claude with a brain reacts to each entry, and a pane draws the memory as a graph
factory/factory <feature>: a spec, your approval of its plan, a build on its own branch, checks, parallel reviews and an approver, then Claude opens the PR

Add the marketplace once, then install the ones you want:

/plugin marketplace add Amitro123/claudemod
/plugin install <mod>@claudemod

The installer may say some options are "not yet set". That's fine: every option has a default, and the mods run on those until you change one with /plugin configure <mod>@claudemod.

If you added this marketplace before it was renamed from clawd-hud, remove it with /plugin marketplace remove clawd-hud, add it again, and reinstall your mods as <mod>@claudemod.

clawd-hud: a pixel spark

clawd-hud demo

A Claude Code plugin that puts a little animated spark above the prompt, with a pixel battery for each of your usage limits, and adds a prompt-cache timer and a context meter beside the model picker. It works on personal plans and on enterprise seats.

What it shows

Above the prompt (the AbovePrompt band)

  • The spark on the left. What it does follows what Claude is doing:
MoodWhenScenes
idlewaiting for youdrifting with a firefly, juggling, watering a sprout
workinga turn is runningat the terminal, stacking blocks, pondering, sending out sub-agents
working > 90sa hard taska smoke break on a crate joins the rotation
loopthe same tool with the same input 3× in one turnriding a coaster loop it can't get out of
donethe turn answeredfireworks and a hop (a lightbulb if the task was hard)
relax4 min after donestargazing on a hill, a pool float, cocoa by a snowy window
errorAPI error or refusalgoes gray under a little storm cloud, then idle
  • A battery per usage window, showing what is left: green above 30%, amber above 10%, red below. Each has a "resets in …" countdown.
Personal (Pro / Max)Enterprise
Batteries5h window, 7d weekly7d weekly, mo monthly spend cap ("over by …" past 100%)
Costnot shown$ spent this session
"resets in"the API's reset timethe API's reset time; for the spend cap without one, the 1st of next month
Cache timer (auto)1 hour5 minutes

Beside the model picker (the SessionMode slot)

  • Cache bolt: counts down the prompt-cache TTL from the last response. Green while warm, amber in the last minute, gray once cold.
  • Context capsule: how full the context window is, e.g. ctx 34% · 68k/200k.

In a terminal (which can't draw SVG) the same readings show up as a text spark ✦(•_•) and ▰▰▰▱▱ batteries. A one-line summary also goes to the status bar.

The spark shares both places with the other mods: what they draw in the band shows under its row, and whatever sits beside the model picker stays next to the bolt and capsule.

Install

/plugin install clawd-hud@claudemod

Options

Set with claude plugin configure clawd-hud@claudemod, or in the plugin settings:

OptionValuesDefaultWhat it does
planauto, personal, enterpriseautoWhich limits to show. auto switches to enterprise as soon as the account reports a monthly spend limit (spend_limit).
cacheTtlauto, 5m, 1hautoThe cache timer's length. auto is 1h on personal plans and 5m on enterprise.

How it works

  • hooks/register.tsx hooks session.start, session.measure, turn.start, tool.call, turn.complete and ui.render, keeps its state in atoms, and redraws the spark 8 times a second while it moves (working, the loop, fireworks, the storm) and less often while it idles or rests: 4 times a second for the SVG scenes, once a second for the terminal's text face.
  • hooks/scene.ts paints one frame of a mood on an 88×20 pixel stage. Every motion is a plain function of the time since the mood began, so a redraw lands on the same frame and never restarts an animation.
  • hooks/gauges.ts draws the batteries, the bolt and the capsule as plain SVG, so the demo renderer uses the exact same art.
  • tests/plan.test.tsx checks the personal and enterprise views, the spend-cap overflow and both cache timers. Run it with claude plugin test plugins/clawd-hud.

Changelog

0.6.2: the idle and resting SVG scenes redraw 4 times a second instead of once, so they move smoothly; the terminal stays at once a second. The terminal face's idle blink shows again (every 4 seconds).

0.6.1: plays well with the other mods: their bands, and other changes beside the model picker, are no longer hidden. Idle redraws drop from 8 a second to 1. No "clawd-hud loaded" toast on every start.

0.6.0: a new look. The spark mascot, its scenes, the stage and the batteries, bolt and capsule are all new, drawn by a new animation engine. Everything else carries over: plans, timers, loop detection, the hard-task and eureka moments.

0.5.0: renamed from pixel-buddy to clawd-hud (install again; old settings don't carry over). Enterprise mode. The loop detector's tool.call hook got a registration .catch, so it can never block a tool call.

0.4.1: only a real answer celebrates (a refusal gets the error mood). The cache countdown flips to "cold" even after the machine slept. Timers no longer stack up when session.start fires again. No more "resets in NaNm".

0.4.0: the hard-task smoke break, the eureka lightbulb and the loop coaster. 0.3.0: the error mood.

task-hud: to-dos and sub-agents

task-hud: the to-do bar above the prompt and the Sub-agents pane

<sub>The creatures, the task bar and the progress lines are drawn by the mod's own art.ts; the window around them is a mock-up of the desktop app.</sub>

/plugin install task-hud@claudemod
  • A progress bar above the prompt once Claude writes a to-do list (TodoWrite, or TaskCreate/TaskUpdate): it's titled with the prompt that started the work, fills as each to-do gets ticked off (Tasks 4/5 · 80%), and shows the one in progress (Now: Write the script). The title survives a reload of the mod. When it shares the band with clawd-hud, it draws above the spark. A finished list clears on your next prompt.
  • A Sub-agents pane that opens the first time Claude sends out a sub-agent, and reopens with /task-hud. Each agent gets a pixel creature in its own color that walks while it works. Its card shows the task's description, the agent type and model (general-purpose · Opus 5.5), its tokens and run time, the current step (Step 3 · Reading pre-outline.md), the steps before it and a progress line. Cards are grouped into Running and Finished. A finished agent closes its eyes and lists what it did (Wrote script.md). A failed one turns gray. An agent sent back to work gets its card back, or a new one if its old card was already dropped. Clear drops the finished cards.

Nobody knows ahead of time how many steps a sub-agent will take, so a running agent's line creeps toward 90% as it works and fills only when it finishes. The token count is the size of the agent's context on its latest request.

Tests: claude plugin test plugins/task-hud.

followups: don't lose what Claude notices

followups: Claude asks about a bug it noticed in the question dialog; the To-do pane shows open, in-progress and done items with Start, Done and Export to

<sub>A mock-up of version 0.5.0, not a screenshot. The dialog is Claude Code's own question dialog, so it looks like the one in your app rather than exactly like this. The pane follows the mod's drawing: orange open, blue in progress, green done, 🐞 for a bug, and an item offered from a sub-agent's open questions. A dialog closed without an answer leaves the item in a band above the prompt (Add to to-do / Skip / Add all), which isn't shown here.</sub>

While Claude works, it often notices things outside the task: a flaky test, a stale doc, a bug in nearby code. Those get lost unless somebody writes them down. This mod writes them down.

/plugin install followups@claudemod
  • Claude flags what it notices. The mod gives Claude a flag tool and a short system-prompt section on when to use it: things outside the current task that need attention later. Claude doesn't fix them unless you ask.
  • A second look after each answer. Claude doesn't always remember to flag. After each of its answers (in the main conversation, 200 characters or longer), a small model (haiku) reads the answer, together with the reports any sub-agents handed back since the last answer (where the work, and its loose ends, often is), for problems mentioned but left open: something flaky, unverified, skipped or put off, or an open question or decision that wasn't settled. If it can't run (haiku not allowed on the account, an API error), one line in the transcript says so instead of it failing quietly. Up to 3 of those wait in the band above the prompt. Anything already tracked, or that you said isn't needed (remembered per project, across sessions), isn't offered again. It costs one small call per answer and never delays the end of a turn. Turn it off with the autoDetect option (claude plugin configure followups@claudemod).
  • A sub-agent's open questions, read directly. When a sub-agent's report has a section for what it left open (Unsure-about, Open questions or Open items, as the model router's routed-worker writes), each line in it is offered in the band as soon as the report comes back. This needs no model, so it works even where the after-answer look can't run. A section that says none offers nothing, and the turn's end-of-turn check doesn't stop Claude again for items already offered.
  • A check when the turn ends. If Claude's final reply says something is unverified, untested or left open (a short English and Hebrew phrase list: not verified, untested, known gap, open questions, לא נבדק, פער, שאלות פתוחות, ...; Open questions: none doesn't count; not follow-up, this mod's own word) and it never called flag that turn, the turn is stopped once with a nudge to flag each item or say there are none. It reads the reply whatever its source, so findings relayed from a subagent's report count; phrases in code blocks and inline code don't. It stops at most once per turn, never when flag was called (even if you turned the item down), and only in interactive sessions (not -p or the SDK). Limits: it is a phrase match, so a reply that leaves something open in other words is missed (the second look above still reads it), and a phrase used in passing can stop a turn needlessly. Add phrases to STOP_PHRASES in hooks/register.tsx; turn it off with the stopCheck option.
  • Claude stops and asks you about each one in the question dialog: Claude noticed: "Fix the flaky upload test" (Fails 1 in 5 runs · tests/upload.test.ts:42). Add it to your follow-ups? You answer Add to to-do or Not needed, or type your own wording, which is kept as the title. Claude gets your answer back and carries on. If you close the dialog without answering, the item waits in a band above the prompt with Add to to-do (a), Skip (s) and Add all, so it isn't lost.
  • A To-do pane lists what you kept, colored by status: orange ○ open, blue ◐ in progress, green struck-through ✓ done. A red 🐞 marks bugs (Claude passes kind: "bug" to flag for broken behavior; /followup adds a plain task). ✓ Done / ↺ Reopen toggles an item, ✕ removes it, and /followup <text> adds one by hand. The header counts open, in progress and done. The list is saved per project and comes back in the next session (lists saved by older versions are migrated). Two sessions open in the same project don't overwrite each other: each save merges with what the other saved, so items added, changed or removed in either one are kept that way. When the project has open or in-progress items, the pane opens by itself as the session starts; the openOnStart option turns that off. The list is saved on the machine running Claude Code, so it carries over between local sessions but not between cloud sessions, whose container is gone when they end: use Export to → Memory for that.
  • ▶ Start (per open item, or Start all open) marks it in progress and submits a message to Claude with the item's id, title, detail and location, asking it to do the work. When it finishes, Claude calls the done tool with that id and the item turns green. done is idempotent, rejects unknown ids, and the prompt tells Claude not to use it for unrelated work. If the message can't be sent, the items go back to open, so you can start them again. Pressing Add to to-do twice keeps the item once.
  • Export to:
ButtonWrites toWhy
MemoryCLAUDE.mdClaude reads it at the start of every session
Docsdocs/FOLLOW-UPS.mda page of its own
READMEREADME.md, under Known issues & follow-upsvisible to everyone

The export is a markdown checklist ([ ] open, [x] done, in-progress items tagged _(in progress)_, bugs _(bug)_) between <!-- followups:start --> and <!-- followups:end --> markers. Exporting again replaces that section in place, and the rest of the file is left alone.

Tests: claude plugin test plugins/followups.

rtl-toggle: Hebrew and English on one line

Toggle offToggle on
Toggle off: embedded Hebrew renders out of orderToggle on: embedded Hebrew renders in correct order

A line that starts in English but is mostly Hebrew (or Arabic, or another right-to-left script) can render out of order, with the Hebrew words shifted. This mod fixes those lines in your messages and Claude's.

/plugin install rtl-toggle@claudemod
  • A button above the prompt (עברית: פעיל / עברית: כבוי) turns the fix on or off for the session. /rtl does the same on every surface, and /rtl on, /rtl off and /rtl status set or report it.
  • What it changes: a line that starts with an English letter but has at least as many RTL words as LTR words gets one invisible leading RLM (U+200F). That gives the line a right-to-left base direction and right alignment, like a line that starts in Hebrew. Nothing else in the text changes.
  • What it leaves alone: code blocks (fenced and indented), lines that already start in Hebrew, lines with no RTL text, and inline code, links and URLs, which aren't counted. Table rows are fixed cell by cell, except in the terminal.
  • Limits: the mod API has no dir or alignment prop, so alignment comes only from the RLM. That works where the surface picks the direction per block (the desktop app). In a terminal it depends on the terminal's own bidi support. Messages already on screen keep their layout until they're drawn again.

It also lives in its own repo, Amitro123/RTL-claude-mod, which has the full rules and the performance notes.

Tests: claude plugin test plugins/rtl-toggle.

goal-run: a goal for the session

goal-run: the question a session opens with, and the Goal pane at 40%, with each task's priority and tokens

<sub>Mock-ups, not screenshots: the track and the runner are drawn by the mod's own art.ts, and the band, the pane and the dialogs follow its layout. The dialogs are Claude Code's own, so yours will look like your app's.</sub>

/plugin install goal-run@claudemod

How a goal runs

goal-run in four steps: the start-of-session question; the plan by priority in the approval dialog, with the pane waiting at 0%; the runner at 40% with each task's tokens and a to-do item offered to the goal; the runner cheering at 100%

  1. The question. When a session starts, a band above the prompt asks Is there a goal you want to work on this session?, with a field to type it. No goal this time dismisses it. If an earlier session in this project left a goal unfinished, the band offers Continue, New goal or Not now instead.
  2. The plan, by priority. Your goal goes to Claude, which breaks it into 3 to 8 tasks with the plan tool. Each task has a priority (high, medium or low) and the tasks it depends on. The plan is ordered highest priority first, except that a task comes after what it needs: tests come after the code they test, and say so (needs 1).
  3. Your approval, and the pace. The plan is put to you in a dialog, each task with its priority. Pick Approve, run through, Approve, ask between tasks, or Change it (Claude revises and asks again). Nothing starts before you approve. If you close the dialog, the plan waits in the pane with both approve buttons.
  4. Run through: Claude goes from task to task on its own. If it tries to stop with tasks left, it's sent on once; it can still stop when it's blocked or needs your decision.
  5. Ask between tasks: after each task, a dialog asks "Add tests" is done (40%). Go on to "Document the endpoint"?, with Continue or Stop here.
  6. A button in the pane switches between the two while you work.
  7. The run. The Goal pane shows the goal, a track from 0 to 100%, and a pixel Claude in blue sneakers. It stands still between tasks, runs while one is in progress, and cheers at the flag at 100%. Claude marks each task in progress and done with the update tool, and takes the first pending task in the list's current order. Each of your prompts while the goal is open carries the plan's state, so Claude keeps track of where it is.
  8. What each task cost. Every model request, sub-agents' included, is counted against the task in progress, and the pane shows each task's tokens. Planning and work between tasks have a line of their own. The count is the new tokens of each request (output, input written to the cache, uncached input). Cache reads are left out: they are the context earlier requests already counted, and counting them again on every step would grow the number far past what the task added. It is not a price.

Steering it while it runs

goal-run while it runs: the plan by priority with the pace choice; a task added mid-goal, placed by priority; the question between tasks; running through, sent on to the next task

  • Reorder. ↑ ↓ in the pane move tasks that haven't started. A task moved up takes the tasks it needs along with it, and one moved down takes the tasks that need it, so dependencies stay first. Claude works the list as you leave it.
  • Add a task. Ask Claude for more work during the goal, and it uses the add tool with a priority and what the task needs. The task goes in among the tasks that haven't started, by priority and after anything it needs. A dialog shows where it lands and what that does to the percentage (It goes in as #4, before "Document the endpoint". Progress 50% → 40%), and you choose Add it or Not now. Done and in-progress tasks stay where they are. The goal's text doesn't change, but the plan grows, so the percentage drops to match the work added.
  • Re-plan. A new plan in the middle of a goal keeps the done tasks and their tokens.
  • Work from followups' to-do list. Pressing ▶ Start on a to-do item while a goal is open is a side job. Claude gets no goal reminder with it, so the two don't compete, and its tokens show on a line of their own, Outside the goal. The band above the prompt asks whether to Add to goal, showing what that does to the plan (grows to 6 tasks: 40% → 33%), or Keep it separate. Added, the item becomes a task in progress, carrying the tokens it used so far, and a bug comes in as high priority. It turns done when followups marks the item done. The side job ends when you send a prompt of your own.

Commands and options

  • /session-goal <text> sets a goal at any time, /session-goal shows it, and /session-goal clear drops it. Claude Code has its own /goal, so the mod's command has a different name.
  • The goal is saved per project on the machine running Claude Code, like followups' list, so it carries over between local sessions in the same folder. If two sessions in the folder each set a goal, the one set later is the one saved. The other session is told once, with a toast, that its progress isn't saved.
  • askOnStart (on / off) turns the start-of-session question off.
  • A plan from factory. When the factory's spec is put to you as the goal's plan, the dialog is titled Factory plan and offers Approve, start building or Change it. Approved, the factory's builder does the tasks and marks them, and the main Claude is told not to: there's no goal reminder on your prompts, no nudge at a stop and no question between tasks. Changes go back to the factory's spec-writer. Approving later in the pane runs /factory plan-approved. If you already have a goal of your own that isn't done, it stays: the factory's plan isn't set as the goal, and the factory asks for approval in its own band. A factory plan starts fresh, without an earlier goal's done tasks.

Tests: claude plugin test plugins/goal-run.

brain-hud: the agent's memory, made visible

brain-hud: three of the scenes above the prompt, an offer from followups, and the Brain pane with the memory drawn as a knowledge graph

<sub>The scenes and the brain map are the mod's own SVG output; the windows around them are mock-ups. The data is a real project's memory, with one new lesson just routed into Projects (✦).</sub>

brain-hud gives Claude a memory that lasts between sessions, and shows it. The mistake-brain skill ships inside it, with its hooks. Claude logs what happened to MISTAKES.md (a mistake, a success, a decision or a handoff). It routes those entries into PARA folders under .agent-brain/shared/ (Projects, Areas, Resources, Archives), promotes a lesson that repeats into a rule (with your approval), and now and then reorganizes the memory ("dream"). The mod shows that memory as it grows. Nothing else needs installing.

/plugin install brain-hud@claudemod

Then, in each project that should remember: /brain-hud:mistake-brain init, or Set up memory in the Brain pane (/brain). It creates MISTAKES.md, the PARA folders, the index .agent-brain/shared/MEMORY.md and the standing rules. A CLAUDE.md tracked in git is left as it is; the instruction goes into .claude/rules/mistake-brain.md.

The skill, built in

  • The command is /brain-hud:mistake-brain w
Source 4 files
hooks/register.tsx 401 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Ctx, Limit, Mood } from '../types'
5import { BATTERY, BOLT, LOW, OK, PILL, batterySvg, boltSvg, cellsText, charge, pillSvg } from './gauges'
6import { DONE_SECONDS, ERROR_SECONDS, HARD_SECONDS, SCENE_HEIGHT, SCENE_WIDTH, sceneSvg } from './scene'
7
8// ---------- state ----------
9
10const mood = atom({ plugin: 'clawd-hud', key: 'mood' } as const, 'idle' as Mood)
11const moodAt = atom({ plugin: 'clawd-hud', key: 'moodAt' } as const, 0)
12const frame = atom({ plugin: 'clawd-hud', key: 'frame' } as const, 0)
13// Whether the band last drew SVG scenes (desktop) rather than the terminal's text face. A render
14// can't write state, and after a reload the next render sets it again.
15let svgBand = false
16const cacheAt = atom({ plugin: 'clawd-hud', key: 'cacheAt' } as const, null as number | null)
17const now = atom({ plugin: 'clawd-hud', key: 'now' } as const, 0)
18const minute = atom({ plugin: 'clawd-hud', key: 'minute' } as const, 0)
19const ctx = atom({ plugin: 'clawd-hud', key: 'ctx' } as const, null as Ctx | null)
20const limits = atom({ plugin: 'clawd-hud', key: 'limits' } as const, [] as Limit[])
21const eureka = atom({ plugin: 'clawd-hud', key: 'eureka' } as const, false)
22const loopSince = atom({ plugin: 'clawd-hud', key: 'loopSince' } as const, null as number | null)
23const toolCounts = atom({ plugin: 'clawd-hud', key: 'toolCounts' } as const, {} as Record<string, number>)
24const cost = atom({ plugin: 'clawd-hud', key: 'cost' } as const, null as number | null)
25
26const MIN = 60_000
27// The mascot is redrawn as still frames at this interval (8 a second).
28const FRAME_MS = 125
29// Idle and resting: the terminal's face needs a frame a second; the SVG scenes look smooth at 4.
30const IDLE_FRAME_MS = 1_000
31const SVG_IDLE_FRAME_MS = 250
32// After the fireworks the spark rests this long before idling.
33const RELAX_MS = 4 * MIN
34// The same tool with the same input this many times in one turn means Claude is going in circles.
35const LOOP_REPEATS = 3
36const RESERVED = new Set(['tool', 'tool_use_id', 'agentId', 'consent'])
37
38// The intervals armed by session.start, kept so a later session.start can cancel them.
39const timers: { cancel: () => void }[] = []
40
41// ---------- plans ----------
42
43// Personal plans meter a 5-hour and a weekly window; enterprise seats meter the week and a
44// monthly spend cap (a gateway's `spend_limit`, which can run past 100%).
45type Cfg = { plan: string; cacheTtl: string }
46type Win = { kind: string; label: string }
47const PERSONAL: Win[] = [
48  { kind: 'five_hour', label: '5h' },
49  { kind: 'seven_day', label: '7d' },
50]
51const ENTERPRISE: Win[] = [
52  { kind: 'seven_day', label: '7d' },
53  { kind: 'spend_limit', label: 'mo' },
54]
55
56const isEnterprise = (cfg: Cfg, rl: Limit[]) =>
57  cfg.plan === 'enterprise' || (cfg.plan !== 'personal' && rl.some(l => l.kind === 'spend_limit'))
58
59const ttlOf = (cfg: Cfg, enterprise: boolean) =>
60  cfg.cacheTtl === '5m' ? 5 * MIN : cfg.cacheTtl === '1h' ? 60 * MIN : enterprise ? 5 * MIN : 60 * MIN
61
62// When the window resets: the API's own time, or for a spend cap without one, the 1st of next month.
63function resetAt(win: Win, lim: Limit | undefined, t: number): number | null {
64  if (lim?.resetsAt) return Date.parse(lim.resetsAt)
65  if (win.kind !== 'spend_limit') return null
66  const d = new Date(t)
67  return new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime()
68}
69
70async function planOf($: EngineInterface, cfg: Cfg) {
71  const rl = await read($, limits)
72  const enterprise = isEnterprise(cfg, rl)
73  return { rl, enterprise, wins: enterprise ? ENTERPRISE : PERSONAL, ttlMs: ttlOf(cfg, enterprise) }
74}
75
76// ---------- text ----------
77
78// "2d 4h", "3h 12m", "45m".
79function until(ms: number): string {
80  if (!Number.isFinite(ms)) return '–'
81  const mins = Math.max(0, Math.round(ms / MIN))
82  const d = Math.floor(mins / 1440)
83  const h = Math.floor(mins / 60) % 24
84  return d ? `${d}d ${h}h` : h ? `${h}h ${mins % 60}m` : `${mins % 60}m`
85}
86
87const mmss = (ms: number) => {
88  const s = Math.max(0, Math.ceil(ms / 1000))
89  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
90}
91
92const usd = (n: number) => `$${n.toFixed(2)}`
93const tok = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : `${Math.round(n / 1e3)}k`)
94
95// A window as what is left of it: "66% left", or past a spend cap, "over by 12%".
96function leftOf(lim: Limit | undefined) {
97  if (!lim) return { left: 0, over: false, text: '–' }
98  const used = lim.percentUsed
99  if (used > 100) return { left: 0, over: true, text: `over by ${Math.round(used - 100)}%` }
100  const left = Math.max(0, 100 - used)
101  return { left, over: false, text: `${Math.round(left)}% left` }
102}
103
104function cacheOf(at: number | null, tNow: number, ttlMs: number) {
105  const left = at === null ? 0 : ttlMs - (tNow - at)
106  const warm = at !== null && left > 0
107  return {
108    warm,
109    label: at === null ? 'cache –' : warm ? `cache ${mmss(left)}` : 'cache cold',
110    color: !warm ? '#59618a' : left < MIN ? LOW : OK,
111  }
112}
113
114// ---------- moods ----------
115
116async function setMood($: EngineInterface, m: Mood) {
117  const t = await $.clock.now()
118  await update($, moodAt, () => t)
119  await update($, mood, () => m)
120}
121
122// What plays `s` seconds into a stored mood: fireworks, a rest, then idle; or a short storm, then idle.
123function sceneFor(m: Mood, s: number): { mood: Mood; t: number } {
124  if (m === 'done') return s < DONE_SECONDS ? { mood: 'done', t: s } : sceneFor('relax', s - DONE_SECONDS)
125  if (m === 'error') return s < ERROR_SECONDS ? { mood: 'error', t: s } : { mood: 'idle', t: s - ERROR_SECONDS }
126  if (m === 'relax' && s >= RELAX_MS / 1000) return { mood: 'idle', t: s - RELAX_MS / 1000 }
127  return { mood: m, t: s }
128}
129
130const ALT: Record<Mood, string> = {
131  idle: 'drifting',
132  working: 'working',
133  loop: 'stuck riding a coaster loop',
134  done: 'setting off fireworks',
135  relax: 'taking it easy',
136  error: 'gone gray under a storm cloud',
137}
138
139// The terminal's spark, as text.
140const SPIN = ['◐', '◓', '◑', '◒']
141
142export function faceFor(m: Mood, t: number, idea: boolean): string {
143  const f = Math.floor((t * 1000) / FRAME_MS)
144  if (m === 'working') return `✦(•_•) ${SPIN[f % SPIN.length]}`
145  if (m === 'loop') return '✦(@_@) ↻'
146  if (m === 'done') return `✦\\(^o^)/${idea ? ' (!)' : ''}`
147  if (m === 'relax') return '✦(˘_˘) ♪'
148  if (m === 'error') return '✦(;_;) ϟ'
149  // On whole seconds, so the once-a-second idle redraw always lands on it.
150  return Math.floor(t) % 4 === 0 ? '✦(-_-)' : '✦(•_•)'
151}
152
153// ---------- loop detector ----------
154
155function hash(s: string): string {
156  let h = 5381
157  for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0
158  return (h >>> 0).toString(36)
159}
160
161async function countCall($: EngineInterface, e: { tool: string; agentId?: string }) {
162  if (e.agentId || (await read($, loopSince)) !== null) return
163  const args = Object.fromEntries(Object.entries(e).filter(([k]) => !RESERVED.has(k)))
164  const sig = `${e.tool}:${hash(JSON.stringify(args))}`
165  await update($, toolCounts, c => ({ ...c, [sig]: (c[sig] ?? 0) + 1 }))
166  if (((await read($, toolCounts))[sig] ?? 0) >= LOOP_REPEATS) {
167    const t = await $.clock.now()
168    await update($, loopSince, () => t)
169  }
170}
171
172// One line in the status bar with every reading, for surfaces that draw neither band.
173async function refreshStatus($: EngineInterface, cfg: Cfg) {
174  const { rl, enterprise, wins, ttlMs } = await planOf($, cfg)
175  const c = await read($, ctx)
176  const spent = await read($, cost)
177  const cache = cacheOf(await read($, cacheAt), await $.clock.now(), ttlMs)
178  const parts = [
179    ...wins.map(w => `${w.label} ${leftOf(rl.find(l => l.kind === w.kind)).text}`),
180    ...(enterprise && spent !== null ? [usd(spent)] : []),
181    cache.label,
182    c?.percent === undefined ? 'ctx –' : `ctx ${c.percent}%`,
183  ]
184  $.ui.status(`✦ ${parts.join(' · ')}`)
185}
186
187export const register: Register = (on, options) => {
188  const cfg: Cfg = { plan: String(options.plan ?? 'auto'), cacheTtl: String(options.cacheTtl ?? 'auto') }
189
190  on('session.start', async ($, e, next) => {
191    // session.start can fire again for the same module (an enable, a worker respawn):
192    // drop the previous timers so the intervals never stack up.
193    for (const timer of timers.splice(0)) timer.cancel()
194
195    const u = await $.session.usage()
196    await update($, ctx, () => u.context)
197    await update($, limits, () => u.rateLimits)
198    await update($, cost, () => u.cost?.usd ?? null)
199    const t0 = await $.clock.now()
200    await update($, minute, () => t0)
201    await update($, moodAt, () => t0)
202
203    // Keep "resets in" fresh.
204    timers.push($.clock.every(MIN, async () => {
205      const t = await $.clock.now()
206      await update($, minute, () => t)
207      await refreshStatus($, cfg)
208    }))
209    // Every motion is a function of elapsed time, so an idle or resting spark can redraw
210    // less often; working, loop, done and error scenes get every frame.
211    timers.push($.clock.every(FRAME_MS, async () => {
212      const t = await $.clock.now()
213      const scene = sceneFor(await read($, mood), (t - (await read($, moodAt))) / 1000).mood
214      const still = scene === 'idle' || scene === 'relax'
215      const every = svgBand ? SVG_IDLE_FRAME_MS : IDLE_FRAME_MS
216      if (still && t - (await read($, frame)) < every) return
217      await update($, frame, () => t)
218    }))
219    // Tick the cache countdown while it is warm, and once more after expiry: a late tick
220    // (the machine slept) must still flip the label to "cold" instead of freezing it.
221    timers.push($.clock.every(1_000, async () => {
222      const at = await read($, cacheAt)
223      if (at === null) return
224      const t = await $.clock.now()
225      const { ttlMs } = await planOf($, cfg)
226      const shownWarm = (await read($, now)) - at < ttlMs
227      if (t - at < ttlMs + 2_000 || shownWarm) {
228        await update($, now, () => t)
229        await refreshStatus($, cfg)
230      }
231    }))
232
233    await refreshStatus($, cfg)
234    return next(e)
235  })
236
237  // Each response refreshes the prompt cache and reports the live usage.
238  on('session.measure', async ($, e, next) => {
239    await update($, ctx, () => e.context)
240    if (e.rateLimits.length) await update($, limits, () => e.rateLimits)
241    if (e.cost) await update($, cost, () => e.cost?.usd ?? null)
242    if (e.changed.includes('context')) {
243      const t = await $.clock.now()
244      await update($, cacheAt, () => t)
245      await update($, now, () => t)
246    }
247    await refreshStatus($, cfg)
248    return next(e)
249  })
250
251  on('turn.start', async ($, e, next) => {
252    await update($, toolCounts, () => ({}))
253    await update($, loopSince, () => null)
254    await setMood($, 'working')
255    return next(e)
256  })
257
258  // tool.call gates every tool, so the loop detector is best-effort: its .catch lets the
259  // call through if counting fails or overruns the hook's budget.
260  on('tool.call', async ($, e, next) => {
261    await countCall($, e)
262    return next(e)
263  }).catch(($, e, next) => next(e))
264
265  // A real answer sets off the fireworks (with a bulb after a hard task); anything else but an
266  // interrupt gets the storm. The hand-offs are worked out from the clock on each frame.
267  on('turn.complete', async ($, e, next) => {
268    if (e.agentId) return next(e)
269    const t = await $.clock.now()
270    const startedAt = (await read($, mood)) === 'working' ? await read($, moodAt) : t
271    const solved = !e.isAborted && e.reason === 'answer'
272    await update($, eureka, () => solved && t - startedAt >= HARD_SECONDS * 1000)
273    await update($, loopSince, () => null)
274    await setMood($, e.isAborted ? 'idle' : solved ? 'done' : 'error')
275    return next(e)
276  })
277
278  // The band above the prompt: the spark on the left, a battery per usage window beside it.
279  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
280    if (e.props.hasSurvey) return next(e)
281    svgBand = e.surface !== 'terminal'
282    const { Box, Text } = $.ui.resolve(e)
283    // What the mods beneath draw stays, under the spark's row: this band is shared.
284    const below = await next(e)
285    const { rl, enterprise, wins } = await planOf($, cfg)
286    const spent = enterprise ? await read($, cost) : null
287    const t = await read($, minute)
288    const stored = await read($, mood)
289    const m: Mood = e.props.isWorking ? 'working' : stored === 'working' ? 'idle' : stored
290    const tFrame = await read($, frame)
291    // When the band's mood is not the stored one (the UI says "working" before turn.start
292    // lands, or the turn ended before turn.complete ran) there is no start time: run a
293    // free clock that stays under HARD_SECONDS rather than counting from the epoch.
294    const elapsed = m === stored ? (tFrame - (await read($, moodAt))) / 1000 : (tFrame / 1000) % HARD_SECONDS
295    const loopAt = await read($, loopSince)
296    const { mood: shown, t: sceneT } =
297      e.props.isWorking && loopAt !== null
298        ? { mood: 'loop' as Mood, t: Math.max(0, (tFrame - loopAt) / 1000) }
299        : sceneFor(m, Math.max(0, elapsed))
300    const idea = shown === 'done' && (await read($, eureka))
301
302    const meters = wins.map(w => {
303      const lim = rl.find(l => l.kind === w.kind)
304      const at = t ? resetAt(w, lim, t) : null
305      return { ...w, ...leftOf(lim), has: !!lim, resets: lim && at !== null && t ? `resets in ${until(at - t)}` : 'no reading yet' }
306    })
307
308    if (e.surface !== 'desktop' && e.surface !== 'vscode') {
309      const row = (
310        <Box flexDirection="row" flexWrap="wrap" gap={3}>
311          <Text bold color="#ffb547">{faceFor(shown, sceneT, idea)}</Text>
312          {meters.map(w => (
313            <Box key={w.kind} flexDirection="row" gap={1}>
314              <Text bold>{w.label}</Text>
315              <Text color={w.has ? (w.over ? '#ff5a5a' : charge(w.left)) : undefined} dimColor={!w.has}>{`[${cellsText(w.left, w.over)}]`}</Text>
316              <Text bold>{w.text}</Text>
317              <Text dimColor>{w.resets}</Text>
318            </Box>
319          ))}
320          {spent !== null ? <Text bold>{`${usd(spent)} this session`}</Text> : null}
321        </Box>
322      )
323      return (
324        <Box flexDirection="column">
325          {row}
326          {below}
327        </Box>
328      )
329    }
330    const { Svg } = $.ui.resolve(e as typeof e & { surface: 'desktop' })
331
332    const row = (
333      <Box flexDirection="row" alignItems="center" gap={2} paddingX={1}>
334        <Svg
335          source={sceneSvg(shown, sceneT, idea)}
336          alt={`The spark is ${idea ? 'having a eureka moment' : ALT[shown]}`}
337          width={SCENE_WIDTH}
338          height={SCENE_HEIGHT}
339        />
340        <Box flexDirection="row" alignItems="center" flexWrap="wrap" gap={3}>
341          {meters.map(w => (
342            <Box key={w.kind} flexDirection="row" alignItems="center" gap={1}>
343              <Svg source={batterySvg(w.left, w.over)} alt={`${w.label}: ${w.text}`} width={BATTERY.width} height={BATTERY.height} />
344              <Text bold>{`${w.label} ${w.text}`}</Text>
345              <Text dimColor>{w.resets}</Text>
346            </Box>
347          ))}
348          {spent !== null ? (
349            <Box flexDirection="row" alignItems="center" gap={1}>
350              <Text bold>{usd(spent)}</Text>
351              <Text dimColor>this session</Text>
352            </Box>
353          ) : null}
354        </Box>
355      </Box>
356    )
357    return (
358      <Box flexDirection="column">
359        {row}
360        {below}
361      </Box>
362    )
363  })
364
365  // Beside the model picker: the cache bolt and the context capsule.
366  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
367    const { Box, Text } = $.ui.resolve(e)
368    const { ttlMs } = await planOf($, cfg)
369    const cache = cacheOf(await read($, cacheAt), await read($, now), ttlMs)
370    const c = await read($, ctx)
371    const pct = c?.percent
372    const ctxLabel = pct === undefined ? 'ctx –' : `ctx ${pct}%${c?.tokens ? ` · ${tok(c.tokens)}/${tok(c.window)}` : ''}`
373    // The engine's own drawing here (the mode labels, or another mod's take on them) stays beside ours.
374    const below = await next(e)
375
376    // SessionMode is raised on the terminal and desktop alone.
377    if (e.surface !== 'desktop') {
378      return (
379        <Box flexDirection="row" gap={1}>
380          <Text color={cache.warm ? cache.color : undefined} dimColor={!cache.warm}>{`ϟ ${cache.label}`}</Text>
381          <Text dimColor>·</Text>
382          <Text dimColor>{ctxLabel}</Text>
383          {below}
384        </Box>
385      )
386    }
387    const { Svg } = $.ui.resolve(e as typeof e & { surface: 'desktop' })
388
389    return (
390      <Box flexDirection="row" alignItems="center" gap={1}>
391        <Svg source={boltSvg(cache.color)} alt="prompt cache" width={BOLT.width} height={BOLT.height} />
392        <Text color={cache.warm ? cache.color : undefined} dimColor={!cache.warm}>{cache.label}</Text>
393        <Text dimColor>·</Text>
394        <Svg source={pillSvg(pct ?? 0)} alt="context used" width={PILL.width} height={PILL.height} />
395        <Text dimColor>{ctxLabel}</Text>
396        {below}
397      </Box>
398    )
399  })
400}
401
hooks/gauges.ts 60 lines
1// The small pixel gauges beside the mascot, as plain SVG strings (no engine imports),
2// so the demo renderer can draw exactly what the plugin draws.
3
4export const OK = '#6be38b'
5export const LOW = '#ffb547'
6export const OUT = '#ff5a5a'
7const DIM = '#2c3354'
8const RIM = '#a7b0d6'
9const HOLE = '#11152a'
10
11// What is left of a window, as a color: plenty, running low, nearly out.
12export const charge = (left: number) => (left > 30 ? OK : left > 10 ? LOW : OUT)
13
14const cell = (x: number, y: number, w: number, h: number, c: string) =>
15  w > 0 && h > 0 ? `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${c}"/>` : ''
16
17const pixels = (w: number, h: number, scale: number, body: string) =>
18  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w * scale}" height="${h * scale}" shape-rendering="crispEdges">${body}</svg>`
19
20const lit = (left: number) => Math.min(5, Math.max(0, Math.ceil(left / 20)))
21
22export const BATTERY = { width: 48, height: 22 }
23
24// Five cells for what is left of a window. `over` is a spend cap run past 100%:
25// an empty shell with a red bolt in it.
26export function batterySvg(left: number, over = false): string {
27  const n = over ? 0 : lit(left)
28  let body = cell(0, 0, 22, 11, RIM) + cell(1, 1, 20, 9, HOLE) + cell(22, 3, 2, 5, RIM)
29  for (let i = 0; i < 5; i++) body += cell(2 + i * 4, 2, 3, 7, i < n ? charge(left) : DIM)
30  if (over) body += cell(12, 2, 2, 3, OUT) + cell(10, 5, 4, 1, OUT) + cell(10, 6, 2, 3, OUT)
31  return pixels(24, 11, 2, body)
32}
33
34export const BOLT = { width: 10, height: 14 }
35
36const BOLT_ROWS = ['..xxx', '.xxx.', 'xxxxx', '..xx.', '.xx..', 'xx...', 'x....']
37
38// The prompt cache: a lit bolt while warm, amber in its last minute, gray once cold.
39export function boltSvg(color: string): string {
40  let body = ''
41  BOLT_ROWS.forEach((row, y) => {
42    for (let x = 0; x < row.length; x++) if (row[x] === 'x') body += cell(x, y, 1, 1, color)
43  })
44  return pixels(5, 7, 2, body)
45}
46
47export const PILL = { width: 40, height: 6 }
48
49// How full the context window is, as a capsule that fills left to right.
50export function pillSvg(percent: number): string {
51  const p = Math.min(100, Math.max(0, percent))
52  return pixels(40, 6, 1, cell(0, 0, 40, 6, DIM) + cell(1, 1, Math.round((p / 100) * 38), 4, charge(100 - p)))
53}
54
55// The same battery for a terminal, which cannot draw SVG.
56export const cellsText = (left: number, over = false) => {
57  const n = over ? 0 : lit(left)
58  return '▰'.repeat(n) + '▱'.repeat(5 - n)
59}
60
hooks/scene.ts 448 lines
1// Scenes for the spark mascot. `sceneSvg` paints ONE frame of a mood, `t` seconds into it,
2// on an 88x20 pixel stage drawn at 3x. Every motion is a plain function of `t`, so a redraw
3// lands on the same frame and never restarts an animation.
4
5import type { Mood } from '../types'
6
7const W = 88
8const H = 20
9const SCALE = 3
10const FLOOR = 17
11
12export const SCENE_WIDTH = W * SCALE
13export const SCENE_HEIGHT = H * SCALE
14export const DONE_SECONDS = 6.5
15export const ERROR_SECONDS = 3.2
16// A turn that runs this long is a hard task: smoke breaks while on it, a lightbulb when it lands.
17export const HARD_SECONDS = 90
18
19const SKY = '#11152a'
20const INK: Record<string, string> = {
21  A: '#ffb547', // spark
22  a: '#d9822b', // spark, shaded
23  H: '#fff1c2', // spark, lit
24  s: '#8a90a8', // spark, gone gray
25  S: '#5d637c',
26  e: '#2b1606', // eyes
27  k: '#0a0c18',
28  N: '#1c2240',
29  d: '#2c3354',
30  L: '#59618a',
31  l: '#a7b0d6',
32  w: '#f4f6ff',
33  y: '#ffe066',
34  o: '#ff8a3d',
35  r: '#ff5a5a',
36  m: '#ff6fae',
37  c: '#5ad1ff',
38  C: '#2b7cb3',
39  g: '#6be38b',
40  G: '#2f8f55',
41  v: '#9d7bff',
42  b: '#8a5a33',
43  B: '#5c3a20',
44}
45
46// ---------- drawing ----------
47
48class Canvas {
49  private parts: string[] = []
50
51  rect(x: number, y: number, w: number, h: number, c: string) {
52    if (w <= 0 || h <= 0) return
53    this.parts.push(`<rect x="${Math.round(x)}" y="${Math.round(y)}" width="${w}" height="${h}" fill="${INK[c] ?? c}"/>`)
54  }
55
56  dot(x: number, y: number, c: string) {
57    this.rect(x, y, 1, 1, c)
58  }
59
60  // ASCII art, one char per pixel ('.' is empty); `swap` recolors chars as it draws.
61  art(rows: readonly string[], x: number, y: number, swap: Record<string, string> = {}) {
62    rows.forEach((row, j) => {
63      for (let i = 0; i < row.length; ) {
64        const ch = row[i] ?? '.'
65        let k = i + 1
66        while (k < row.length && row[k] === ch) k++
67        if (ch !== '.') this.rect(x + i, y + j, k - i, 1, swap[ch] ?? ch)
68        i = k
69      }
70    })
71  }
72
73  toString() {
74    return this.parts.join('')
75  }
76}
77
78// ---------- motion ----------
79
80const TAU = Math.PI * 2
81const osc = (t: number, period: number, amp: number, phase = 0) => Math.round(Math.sin((t / period + phase) * TAU) * amp)
82const tick = (t: number, fps: number, n: number) => ((Math.floor(t * fps) % n) + n) % n
83
84// A stable pseudo-random number in [0, 1) for an integer seed.
85function noise(n: number): number {
86  let x = Math.imul((n | 0) ^ 0x9e3779b9, 0x85ebca6b)
87  x ^= x >>> 13
88  x = Math.imul(x, 0xc2b2ae35)
89  x ^= x >>> 16
90  return (x >>> 0) / 4294967296
91}
92
93// Plays each scene for `each` seconds, in turn.
94function rotate(t: number, each: number, scenes: ((t: number) => void)[]) {
95  scenes[Math.floor(Math.max(0, t) / each) % scenes.length]?.(t)
96}
97
98// ---------- the spark ----------
99
100// 15x11: a four-point star; its core (rows 4-8) carries the face.
101const PLUS = [
102  '.......A.......',
103  '.......A.......',
104  '......AHA......',
105  '......AAA......',
106  '.....HAAAA.....',
107  '...AHAAAAAAA...',
108  'AAAAAAAAAAAAAAa',
109  '...AAAAAAAAa...',
110  '.....AAAAa.....',
111  '......aAa......',
112  '.......a.......',
113]
114// The same star turned 45 degrees, for twinkles and spins.
115const CROSS = [
116  '...............',
117  '..A.........A..',
118  '...A.......A...',
119  '....A.....A....',
120  '.....HAAAA.....',
121  '...AHAAAAAAA...',
122  '...AAAAAAAAA...',
123  '...AAAAAAAAa...',
124  '.....AAAAa.....',
125  '....a.....a....',
126  '...a.......a...',
127]
128const GRAY = { A: 's', a: 'S', H: 'l' }
129
130type Face = 'calm' | 'happy' | 'focus' | 'sleepy' | 'dizzy' | 'sad' | 'wow' | 'shades'
131
132function face(cv: Canvas, x: number, y: number, f: Face, t: number) {
133  switch (f) {
134    case 'calm':
135      if (t % 3.7 < 0.16) cv.art(['e...e'], x + 5, y + 6)
136      else cv.art(['e...e', 'e...e'], x + 5, y + 5)
137      return
138    case 'happy':
139      cv.art(['.e...e.', 'e.e.e.e'], x + 4, y + 5)
140      cv.dot(x + 3, y + 7, 'm')
141      cv.dot(x + 11, y + 7, 'm')
142      return
143    case 'focus':
144      cv.art(['aa...aa', 'ee...ee'], x + 4, y + 5)
145      return
146    case 'sleepy':
147      cv.art(['ee...ee'], x + 4, y + 6)
148      return
149    case 'dizzy':
150      cv.art(['e.e.e.e', '.e...e.', 'e.e.e.e'], x + 4, y + 5)
151      return
152    case 'sad':
153      cv.art(['.e...e.', '.e...e.', '...e...', '..e.e..'], x + 4, y + 5)
154      return
155    case 'wow':
156      cv.art(['.e...e.', '.e...e.', '...e...'], x + 4, y + 5)
157      return
158    case 'shades':
159      cv.art(['kwkk.kwkk', 'kkkkkkkkk'], x + 3, y + 5)
160      return
161  }
162}
163
164type SparkOpts = { face?: Face; turned?: boolean; gray?: boolean }
165
166function spark(cv: Canvas, x: number, y: number, t: number, o: SparkOpts = {}) {
167  cv.art(o.turned ? CROSS : PLUS, x, y, o.gray ? GRAY : {})
168  face(cv, x, y, o.face ?? 'calm', t)
169}
170
171// 7x7 helper sparks: sub-agents, the coaster rider.
172const MINI = ['...A...', '..AAA..', '.AeAeA.', 'AAAAAAa', '.AAAAa.', '..AAa..', '...a...']
173
174// ---------- stage ----------
175
176// Night sky with a few far stars, and a horizon floor whose lines run off to a vanishing point.
177function stage(cv: Canvas, t: number) {
178  for (let i = 0; i < 16; i++) {
179    const x = Math.floor(noise(i) * W)
180    const y = Math.floor(noise(i + 40) * 11)
181    cv.dot(x, y, tick(t + noise(i + 80) * 9, 1.5, 6) === 0 ? 'd' : 'L')
182  }
183  cv.rect(0, FLOOR, W, 1, 'L')
184  for (let k = -7; k <= 7; k++) {
185    cv.dot(W / 2 + k * 6, FLOOR + 1, 'd')
186    cv.dot(W / 2 + k * 9, FLOOR + 2, 'd')
187  }
188}
189
190function monitor(cv: Canvas, x: number, y: number, t: number, on = true) {
191  cv.rect(x, y, 14, 9, 'L')
192  cv.rect(x + 1, y + 1, 12, 7, 'k')
193  cv.rect(x + 6, y + 9, 2, 1, 'L')
194  cv.rect(x + 4, y + 10, 6, 1, 'L')
195  if (!on) {
196    cv.art(['www', '..w', '.w.', 'www'], x + 5, y + 2)
197    return
198  }
199  const scroll = Math.floor(t * 3)
200  for (let r = 0; r < 6; r++) {
201    const line = scroll + r
202    const indent = Math.floor(noise(line + 7) * 3)
203    const len = Math.min(10 - indent, 2 + Math.floor(noise(line) * 9))
204    cv.rect(x + 2 + indent, y + 2 + r, len, 1, ['g', 'c', 'v', 'y'][Math.floor(noise(line + 3) * 4)] ?? 'g')
205  }
206}
207
208// ---------- idle: drifting, juggling, gardening ----------
209
210function idle(cv: Canvas, t: number) {
211  rotate(t, 12, [
212    t => {
213      spark(cv, 36, 5 + osc(t, 2.6, 1), t, { turned: t % 5 > 4.7 })
214      const a = (t / 3) * TAU
215      cv.dot(43 + Math.round(Math.cos(a) * 13), 9 + Math.round(Math.sin(a) * 4), tick(t, 4, 2) ? 'y' : 'o')
216    },
217    t => {
218      spark(cv, 36, 6, t, { face: 'happy' })
219      for (let i = 0; i < 3; i++) {
220        const run = t * 0.8 + i / 3
221        const p = run % 1
222        const dir = Math.floor(run) % 2 === 0 ? 1 : -1
223        const x = 43 + Math.round((p - 0.5) * 16 * dir)
224        cv.art(['.x.', 'xxx', '.x.'], x - 1, 3 - Math.round(Math.sin(p * Math.PI) * 3), { x: ['c', 'm', 'g'][i] ?? 'c' })
225      }
226    },
227    t => {
228      spark(cv, 28, 6, t)
229      cv.art(['.lll..', 'llllll', 'llll..'], 43, 8)
230      const grow = Math.min(5, Math.floor((t % 12) / 2))
231      for (let i = 0; i < grow; i++) cv.dot(53, 13 - i, 'g')
232      if (grow > 1) cv.art(['g.g'], 52, 14 - grow)
233      cv.art(['bbbbb', '.bBb.', '.bBb.'], 51, 14)
234      for (let i = 0; i < 2; i++) cv.dot(50, 10 + ((tick(t, 6, 4) + i * 2) % 4), 'c')
235    },
236  ])
237}
238
239// ---------- working: terminal, stacking, pondering, delegating; smoke breaks once it's hard ----------
240
241function smokeBreak(cv: Canvas, t: number) {
242  monitor(cv, 18, 6, t, false)
243  cv.art(['bbbbbbbbb', 'bBBBBBBBb', 'bbbbbbbbb'], 38, 14)
244  const x = 35
245  const y = 3
246  spark(cv, x, y, t, { face: 'sleepy' })
247  cv.rect(x + 15, y + 6, 3, 1, 'w')
248  cv.dot(x + 18, y + 6, tick(t, 3, 2) ? 'o' : 'r')
249  for (let i = 0; i < 4; i++) {
250    const p = (t / 2.4 + i / 4) % 1
251    const px = x + 19 + Math.round(p * 6 + Math.sin(p * TAU * 1.5))
252    const py = y + 5 - Math.round(p * 8)
253    cv.rect(px, py, p > 0.6 ? 2 : 1, 1, p < 0.5 ? 'l' : 'L')
254  }
255  if (t % 3 < 2) cv.art(['.c.', 'ccc'], x + 2, y + 1)
256}
257
258function working(cv: Canvas, t: number) {
259  if (t >= HARD_SECONDS && Math.floor((t - HARD_SECONDS) / 8) % 4 === 0) return smokeBreak(cv, t)
260  rotate(t, 6, [
261    t => {
262      monitor(cv, 22, 6, t)
263      spark(cv, 40, 5 + tick(t, 6, 2), t, { face: 'focus' })
264      if (tick(t, 8, 3) === 0) cv.dot(38, 12, 'y')
265    },
266    t => {
267      const p = t % 6
268      const placed = Math.min(6, Math.floor(p / 0.9))
269      const colors = ['c', 'm', 'g', 'v', 'y', 'o']
270      for (let i = 0; i < placed; i++) cv.rect(58, FLOOR - 2 - i * 2, 6, 2, colors[i] ?? 'c')
271      const lift = Math.sin(((p % 0.9) / 0.9) * Math.PI)
272      const x = 34 + Math.round(lift * 6)
273      const y = 6 - Math.round(lift * 2)
274      spark(cv, x, y, t, { face: 'happy' })
275      cv.rect(x + 5, y - 2, 5, 2, colors[placed % colors.length] ?? 'c')
276    },
277    t => {
278      spark(cv, 36, 6, t, { face: 'wow', turned: tick(t, 7, 2) === 1 })
279      cv.art(['.wwwwwww.', 'wwwwwwwww', '.wwwwwww.'], 53, 1)
280      cv.dot(51, 5, 'w')
281      cv.dot(52, 4, 'w')
282      for (let i = 0; i < tick(t, 2, 4); i++) cv.dot(55 + i * 2, 2, 'k')
283      const a = t * 4
284      cv.dot(43 + Math.round(Math.cos(a) * 9), 11 + Math.round(Math.sin(a) * 3), 'y')
285    },
286    t => {
287      spark(cv, 26, 6, t, { face: 'focus' })
288      for (let i = 0; i < 3; i++) {
289        const p = (t / 2.4 + i / 3) % 1
290        const x = 42 + Math.round(p * 44)
291        const y = 9 + osc(t, 0.6, 1, i / 3)
292        cv.art(MINI, x, y)
293        cv.art(['www', 'wlw'], x + 2, y - 2)
294      }
295    },
296  ])
297}
298
299// ---------- loop: the same call again and again, as a coaster loop with no way out ----------
300
301function coaster(cv: Canvas, t: number) {
302  const cx = 52
303  const cy = 9
304  for (let k = 0; k < 40; k++) {
305    const a = (k / 40) * TAU
306    cv.dot(cx + Math.round(6 * Math.sin(a)), cy + Math.round(6 * Math.cos(a)), 'l')
307  }
308  cv.rect(26, cy + 6, 56, 1, 'l')
309  for (const x of [30, 38, 66, 74]) cv.rect(x, cy + 7, 1, FLOOR - cy - 7, 'L')
310  for (let back = 2; back >= 0; back--) {
311    const a = ((t - back * 0.08) / 1.4) * TAU
312    const x = cx + Math.round(4 * Math.sin(a)) - 3
313    const y = cy + Math.round(4 * Math.cos(a)) - 3
314    if (back) cv.dot(x + 3, y + 3, back === 1 ? 'o' : 'r')
315    else cv.art(MINI, x, y, tick(t, 6, 2) ? { e: 'w' } : {})
316  }
317  for (let i = 0; i < 3; i++) {
318    const a = t * 3 + (i * TAU) / 3
319    cv.dot(cx + Math.round(Math.cos(a) * 11), 2 + Math.round(Math.sin(a) * 1), 'y')
320  }
321}
322
323// ---------- done: fireworks and a hop; a lightbulb if the task was hard ----------
324
325const BURSTS = [
326  { at: 0.1, x: 18, y: 4, c: 'm' },
327  { at: 1.2, x: 68, y: 3, c: 'c' },
328  { at: 2.4, x: 58, y: 2, c: 'y' },
329  { at: 3.6, x: 78, y: 5, c: 'g' },
330  { at: 4.8, x: 10, y: 3, c: 'v' },
331]
332
333function fireworks(cv: Canvas, t: number) {
334  for (const b of BURSTS) {
335    const dt = t - b.at
336    if (dt < 0 || dt > 1.8) continue
337    if (dt < 0.5) {
338      const y = FLOOR - Math.round(((FLOOR - b.y) * dt) / 0.5)
339      cv.dot(b.x, y, 'w')
340      cv.dot(b.x, y + 1, 'o')
341      continue
342    }
343    const r = (dt - 0.5) * 9
344    for (let k = 0; k < 10; k++) {
345      const a = (k / 10) * TAU
346      cv.dot(b.x + Math.round(Math.cos(a) * r), b.y + Math.round(Math.sin(a) * r * 0.6), dt > 1.4 ? 'L' : b.c)
347    }
348  }
349}
350
351const BULB = ['.yyy.', 'yHHHy', 'yHHyy', '.yyy.', '.lLl.']
352
353function done(cv: Canvas, t: number, eureka: boolean) {
354  fireworks(cv, t)
355  const x = 36
356  // With the bulb on, the spark stays put so bulb and rays fit above it.
357  const y = eureka ? 6 : 6 - Math.round(Math.abs(Math.sin((t * Math.PI) / 0.45)) * 3)
358  spark(cv, x, y, t, { face: 'happy', turned: tick(t, 3, 2) === 1 })
359  if (!eureka) return
360  cv.art(BULB, x + 5, 0)
361  if (tick(t, 3, 2)) {
362    for (const [dx, dy] of [[3, 1], [11, 1], [4, 0], [10, 0]] as const) cv.dot(x + dx, dy, 'y')
363  }
364}
365
366// ---------- relax: stargazing, a pool float, cocoa by the window ----------
367
368const SUN = ['.yyy.', 'yyyyy', 'yyyyy', '.yyy.']
369
370function relax(cv: Canvas, t: number) {
371  rotate(t, 16, [
372    t => {
373      for (let x = 18; x <= 62; x++) {
374        const h = Math.round(3 - ((x - 40) / 22) ** 2 * 3)
375        cv.rect(x, FLOOR - h, 1, h, 'G')
376        if (h > 0) cv.dot(x, FLOOR - h, 'g')
377      }
378      spark(cv, 33, 3, t, { face: 'sleepy' })
379      cv.art(['.www', 'ww..', 'ww..', '.www'], 74, 1)
380      const p = (t % 5) / 0.8
381      if (p < 1) {
382        const sx = 82 - Math.round(p * 30)
383        const sy = 1 + Math.round(p * 6)
384        cv.dot(sx, sy, 'w')
385        cv.dot(sx + 1, sy, 'l')
386        cv.dot(sx + 2, sy - 1, 'L')
387      }
388    },
389    t => {
390      cv.art(SUN, 76, 1)
391      spark(cv, 33, 4 + osc(t, 3, 1), t, { face: 'shades' })
392      cv.rect(0, 13, W, H - 13, 'C')
393      for (let x = 0; x < W; x += 5) cv.rect(x + tick(t, 2, 5), 13, 2, 1, 'c')
394      cv.art(['.mwmwmwmwmwmwm.', 'mm...........mm', '.mwmwmwmwmwmwm.'], 33, 13 + osc(t, 3, 1))
395    },
396    t => {
397      cv.rect(56, 2, 18, 11, 'N')
398      for (let i = 0; i < 8; i++) {
399        cv.dot(57 + Math.floor(noise(i) * 16), 3 + (Math.floor(t * 3 + noise(i + 9) * 10) % 9), 'w')
400      }
401      cv.rect(56, 2, 18, 1, 'L')
402      cv.rect(56, 12, 18, 1, 'L')
403      cv.rect(56, 2, 1, 11, 'L')
404      cv.rect(73, 2, 1, 11, 'L')
405      cv.rect(64, 2, 1, 11, 'L')
406      cv.rect(56, 7, 18, 1, 'L')
407      spark(cv, 28, 6, t, { face: 'sleepy' })
408      cv.art(['wwww.', 'wbbww', 'wwww.', 'wwww.'], 44, 12)
409      for (let i = 0; i < 2; i++) {
410        const p = (t / 1.6 + i / 2) % 1
411        cv.dot(45 + i * 2 + osc(p, 1, 1), 11 - Math.round(p * 5), 'l')
412      }
413    },
414  ])
415}
416
417// ---------- error: gone gray under a little storm cloud ----------
418
419function storm(cv: Canvas, t: number) {
420  const glitch = t < 0.6
421  if (glitch) {
422    for (let i = 0; i < 12; i++) {
423      const seed = i + tick(t, 20, 100) * 13
424      cv.dot(Math.floor(noise(seed) * W), Math.floor(noise(seed + 300) * FLOOR), 'L')
425    }
426  }
427  spark(cv, 36 + (glitch ? tick(t, 20, 3) - 1 : 0), 6, t, { face: 'sad', gray: true })
428  cv.art(['..lll...', '.lllll.l', 'llllllll', '.LLLLLL.'], 39, 0)
429  if (tick(t, 3, 3) === 0) cv.art(['.y', 'y.', '.y'], 42, 4)
430  for (let i = 0; i < 3; i++) cv.dot(40 + i * 3, 4 + ((tick(t, 8, 3) + i) % 3), 'c')
431}
432
433// One frame of `mood`, `t` seconds after it began.
434export function sceneSvg(mood: Mood, t: number, eureka = false): string {
435  const cv = new Canvas()
436  stage(cv, t)
437  if (mood === 'working') working(cv, t)
438  else if (mood === 'loop') coaster(cv, t)
439  else if (mood === 'done') done(cv, t, eureka)
440  else if (mood === 'relax') relax(cv, t)
441  else if (mood === 'error') storm(cv, t)
442  else idle(cv, t)
443  return (
444    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${SCENE_WIDTH}" height="${SCENE_HEIGHT}" shape-rendering="crispEdges">` +
445    `<rect width="${W}" height="${H}" fill="${SKY}"/>${cv}</svg>`
446  )
447}
448
types/index.d.ts 23 lines
1export type Mood = 'idle' | 'working' | 'done' | 'relax' | 'error' | 'loop'
2export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
3export type Ctx = { tokens?: number; window: number; percent?: number }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'clawd-hud': {
8      mood: Mood
9      moodAt: number
10      frame: number
11      cacheAt: number | null
12      now: number
13      minute: number
14      ctx: Ctx | null
15      limits: Limit[]
16      eureka: boolean
17      loopSince: number | null
18      toolCounts: Record<string, number>
19      cost: number | null
20    }
21  }
22}
23