A dock above the prompt: plan usage windows, main and subagent context fill, and the shells running now with what each one is doing.

A live dashboard that lives right above your Claude Code prompt.
Your plan's limits, this chat's context, the shells you have running, the model and its effort, all in one band that updates as you work. Plus a small companion who has opinions.
<img src="assets/reveal.gif" alt="Claude Dock appearing above the Claude Code prompt right after install, with the mascot saying hello" width="900">
This is the dock exactly as it draws in a 210-column terminal, mid-session:
────────────────────────────────────────────────────────────────────────────────────────────────────────
Max 20x · Running hot · pace exceeds Sunday's reset
PLAN ▾ usage windows on your subscription
Session █████████░░░░░░░░░░░░░░░░░░░ 32% resets 5:20 PM PDT
Week █████████████████░░░░░░░░░░░ 60% resets Sun 10:00 PM PDT
THIS CHAT ▾ context window of this conversation
Context ▸ █░░░░░░░░░░░░░░░░░░░░░░░░░░░ 5% 54k of 1000k tokens used · $0.30 spent
Model claude-opus-5-5 [ low ] [ medium ] [ high ] [ xhigh ] [ max ] [ ultracode ]
▐█▜██▛█ SHELLS ▸ 1 running · 4 just finished
▝▜██████▀
▝▝ ▝▝ [ Settings ] [ MCP ] [ Fast ] [ Compact ] [ Slim ]
<img src="assets/hero-dock.png" alt="The dock: plan usage bars, this chat's context and cost, the model with effort chips, shells, and the button row" width="900">
/model switch shows within a second.<img src="assets/shells.gif" alt="The shells view: a dev server and a test watcher running in the background, each opened to show its latest output" width="900">
Press SHELLS to see every background shell and task this session is running, with how long each has been going. Open one to read its latest output, like a dev server's request log or a test watcher's results, without leaving your prompt.
Turn on Settings → Dock → SHELLS → Background servers and watchers, and commands that never finish on their own run in the background automatically, so Claude keeps answering while they run. That covers dev servers, watch modes, tail -f and local web servers. Everything else runs exactly as Claude asked. It's off by default.
<img src="assets/effort.gif" alt="Clicking the effort chips from low to max, then switching ultracode on" width="900">
The chips run from low to max, each in its own colour, and the lit one is the level the engine is really using. Pressing one runs /effort for you, waiting for the current turn to finish if one is running. Ultracode is a chip too, and when it's on your companion holds the sparkler.
<img src="assets/settings.gif" alt="The Settings pane's Dock tab: changing the top line's colour, style, shimmer and alternating colours" width="900">
Settings → Dock holds the dock's own look, every row stepped with the arrow keys and saved for every session:
The other tabs mirror Claude Code itself, to read. Config lists every /config setting and its value; change them with /config. Status, Usage and Stats show what their commands show. File shows your settings.json as a tree.
The dock can chime when Claude finishes, when Claude needs you, and when a subagent finishes. "Needs you" covers a permission prompt or a question. Each moment has its own sound, picked from ten, or off. Success, Ping and Bubble were made for the dock, and the other seven are from Kenney's public-domain Interface Sounds:
| Sound | Character |
|---|---|
| Success | a rising three-note jingle |
| Ping | a crisp UI ping |
| Bubble | a round bloop |
| Chime | a bright two-tone confirmation |
| Glass | a clean glass tap |
| Confirm | a soft confirming blip |
| Question | a curious rising query |
| Rise | an upward sweep |
| Drop | a low drop |
| Pluck | a short pluck |
Everything lives under Settings → Dock → SOUND:
The finished sound waits until no background work is still running, so you hear it once, when it's really done.
<img src="assets/slim.gif" alt="Slim folds the dock to a single strip of buttons, and Bulk brings it back" width="900">
One click folds the dock to a single strip, with the companion and the buttons. Bulk brings the whole dock back, every section open.
He climbs. He gets rained on. He has other moods too, which we'll leave for you to find.
<img src="assets/ladder.gif" alt="The mascot climbs a ladder and parachutes back down" width="900">
<img src="assets/storm.gif" alt="Tapping the mascot a few times brings a storm cloud, and he pulls out an umbrella" width="900">
Everything stays on your machine except one request to Anthropic, described below. The full privacy policy says the same in one place.
Network. The dock contacts one host, api.anthropic.com. About every 10 seconds while the session is open, and right after each model response, it sends a GET request to https://api.anthropic.com/api/oauth/usage, the endpoint Claude Code's /usage command uses, to read your plan's usage windows and plan tier. The request carries your Claude Code login as a bearer token and sends nothing else. There is no other network destination.
Your login. That request needs the Claude Code login already on this machine, so the dock reads it from the macOS keychain entry Claude Code-credentials, or from ~/.claude/.credentials.json where there is no keychain. Asking you to paste the token into a setting instead would mean copying a secret by hand. The token is held in memory for that one request and is never logged, drawn or stored. Without a login, the dock draws the limits Claude Code itself reports.
Programs it runs. Two fixed commands, nothing else:
security find-generic-password -s "Claude Code-credentials" -w, on macOS, to read the login above.id -u, once per session, to find the folder where Claude Code keeps background shell output (/private/tmp/claude-<uid>/…/tasks), so the shells view can show each shell's latest line.Claude Code commands it runs. Only when you press the matching control:
/effort <level>, including /effort ultracode on|off, when you press an effort chip. Claude Code saves the level as it normally does./fast when you press Fast./compact when you confirm Compact.It runs no tools and starts no agents.
Files it reads. ~/.claude/settings.json, ~/.claude/stats-cache.json, ~/.claude/sessions/*.json, the account block of ~/.claude.json, and background shell output files, to draw settings, stats, session names, your account's display name, and shell output. It never draws or stores tokens.
Files it writes. None. Its own settings (look, sounds, folded sections, the last plan reading) are kept in Claude Code's plugin store on your machine. It changes no configuration and sets no environment variables.
What each hook does:
tool.call on Bash: lists each shell command in the dock and returns the tool's own result unchanged. With Background servers and watchers switched on (off by default), it changes one input: it sets run_in_background to true for a command that never ends on its own, such as a dev server or a watch mode. It changes nothing else.turn.step: reads the model and effort of each request. After you press an effort chip, and only until /effort has run, it sets that effort on the main conversation's requests so the chip takes effect at once.command.run on /model, /effort and /fast: passes each command on unchanged and reads its reply to keep the dock current. On /ladder, /snake and /chatter, the dock's own commands, it plays that animation.classic.Stop, classic.SubagentStop and classic.Notification: play the chime you picked and update the shells list. They decide nothing and pass every event on unchanged.ui.render: draws the dock above the prompt (AbovePrompt) and its Settings and MCP panes (Pane). On the footer (PromptHint) it only reads the permission mode, the shell count and whether a turn is running, and draws it unchanged.session.start, session.measure, turn.complete and ui.message: read session figures and your clicks in the dock. They change nothing.Sound. It plays its bundled sounds through Claude Code's audio player, which is afplay on macOS. Other platforms stay silent.
Claude Code in the terminal only, version 2.1.292 or later. It draws nothing in the desktop app, VS Code, claude.ai chat or Cowork.
hooks/register.tsx 2270 lines1import type { MenuRow, Seg } from './menu'
2import { VOICE_FRAMES, VOICE_STEP_MS } from './voice-envelope'
3import type { Elements, EngineInterface, Register, ModelEffort, SessionContextUsage, SessionCost, SessionRateLimit, SessionContextBreakdown, ConfigRow } from 'claude-code'
4
5type Shell = {
6 toolUseId: string
7 description: string
8 command: string
9 startedAt: number
10 taskId?: string
11 agentId?: string
12 endedAt?: number
13 failed?: boolean
14}
15const LINGER_MS = 20_000
16type Limit = { kind: string; label: string; percent: number; resetsAt?: string; dollars?: { used: number; limit: number } }
17type Plan = { tier: string; limits: Limit[]; codeShare?: number; fetchedAt: number }
18
19// Module state resets on reload; the next measurement, fetch and tool call refill it.
20let context: SessionContextUsage | null = null
21let rateLimits: SessionRateLimit[] = []
22let cost: SessionCost | undefined
23let plan: Plan | null = null
24let planError = ''
25let frame = 0
26let tasksDir = ''
27const expanded = new Set<string>()
28let view: 'dash' | 'shells' | 'context' | 'fast' = 'dash'
29let planOpen = true
30let slim = false // the ultra-compact band: the mascot and the buttons, nothing else
31// The dock's own look, chosen on the Dock tab and kept in the store.
32const COLORS: readonly { id: string; name: string }[] = [
33 { id: 'claude', name: 'orange' }, { id: 'warning', name: 'yellow' }, { id: 'success', name: 'green' }, { id: 'ide', name: 'blue' },
34 { id: 'permission', name: 'lavender' }, { id: '#827dbd', name: 'purple' }, { id: 'error', name: 'red' }, { id: 'text', name: 'white' }, { id: 'inactive', name: 'grey' },
35]
36const RULE_STYLES: readonly { id: string; name: string }[] = [
37 { id: '─', name: 'thin' }, { id: '━', name: 'thick' }, { id: '═', name: 'double' }, { id: '-', name: 'dashes' }, { id: '=', name: 'equals' }, { id: '+', name: 'plus' }, { id: '·', name: 'dots' }, { id: '╌', name: 'broken' },
38]
39type DockLook = { ruleColor: number; ruleAltColor: number; ruleStyle: number; ruleShimmer: boolean; rulePattern: boolean; uiColor: number; avatar: boolean; header: boolean }
40let look: DockLook = { ruleColor: 0, ruleAltColor: 0, ruleStyle: 0, ruleShimmer: false, rulePattern: false, uiColor: 0, avatar: true, header: true }
41let ruleFrame = 0
42const colorOf = (i: number) => COLORS[i]?.id ?? 'text'
43let chatOpen = true
44let usageNote = ''
45let breakdown: SessionContextBreakdown | null = null
46let breakdownNote = ''
47let breakdownBusy = false
48let compactArmedAt = 0 // the Compact button asks "Yes?" until this plus CONFIRM_MS
49// The session's permission mode, read from the footer line the engine draws under the prompt
50// (`⏵⏵ bypass permissions on (shift+tab to cycle)`), so Shift+Tab changes show up too.
51let permissionMode = ''
52const MODE_WORDS: readonly [RegExp, string][] = [
53 [/bypass permissions on/i, 'bypass'],
54 [/manual mode on/i, 'manual'],
55 [/accept edits on/i, 'accept edits'],
56 [/plan mode on/i, 'plan'],
57 [/auto mode on/i, 'auto'],
58 [/don'?t ask on/i, "don't ask"],
59]
60const CONFIRM_MS = 6_000
61let compactions = 0
62let compactWhenIdle = false // Compact pressed mid-turn: runs the moment the turn ends, no prompt queued
63let model = ''
64let effort = ''
65let fastMode = false
66let ultracode = false // the session's ultracode mode, as /effort last reported it
67let ultraPending: 'on' | 'off' | '' = '' // a switch to hand /effort once idle
68let fastNote = ''
69// The mascot's speech bubble: who it is talking to and what it is saying right now.
70let userName = ''
71let bubble = ''
72const QUIPS = {
73 hello: ['Hi {name}!', 'Yo {name}.', '{name}!', 'Hey.'],
74 thinking: ['Hmm.', 'On it.', 'Brb.', 'Shh.'],
75 done: ['Done.', 'Ta-da.', 'Easy.', 'Next?'],
76 idle: ['Boop.', 'Tea?', 'Psst.', 'Hi.', 'Zzz.', 'Nice.'],
77 compact: ['Tidy.', 'Roomy.'],
78 fast: ['Zoom.', 'Whee.'],
79 full: ['Full-ish.', 'Compact?'],
80}
81let fastQuipDue = false
82// A burst of clicks on the mascot rolls a reaction: a dizzy shake, pacman, a chef's hat, a storm, a spider
83// dropping from a web, a starry night with fireflies, or the snake chase on the band. The
84// shirt colour is chosen on the Dock tab and remembered across sessions.
85const SHIRTS = ['claude', 'warning', 'success', 'ide', 'permission', '#827dbd', 'error', 'text']
86
87// Notification sounds, files in sounds/: one choice per moment by id, '' meaning off, kept in the store.
88// Chime, Glass, Confirm, Question, Rise, Drop and Pluck are Kenney's CC0 Interface Sounds (KENNEY-LICENSE.txt).
89const SOUNDS = [
90 { id: 'success', name: 'Success' },
91 { id: 'ping', name: 'Ping' },
92 { id: 'bubble', name: 'Bubble' },
93 { id: 'chime', name: 'Chime' },
94 { id: 'glass', name: 'Glass' },
95 { id: 'confirm', name: 'Confirm' },
96 { id: 'question', name: 'Question' },
97 { id: 'rise', name: 'Rise' },
98 { id: 'drop', name: 'Drop' },
99 { id: 'pluck', name: 'Pluck' },
100] as const
101const VOLUMES = [0.25, 0.5, 0.75, 1] as const
102type SoundMoment = 'done' | 'needs' | 'agent'
103// `notify` mutes every chime at once; `voice` is the idle chatter.
104type SoundPrefs = Record<SoundMoment, string> & { volume: number; notify: boolean; voice: boolean }
105let sounds: SoundPrefs = { done: 'success', needs: 'ping', agent: '', volume: 1, notify: true, voice: true }
106
107// Commands that never finish on their own. With autoBackground on, the dock runs them in the background so the
108// main agent stays free to answer; anything else runs as Claude asked.
109const NEVER_ENDS: readonly RegExp[] = [
110 /\b(npm|pnpm|yarn|bun)\s+(run\s+)?(dev|start|serve|watch)\b/,
111 /\b(next|nuxt|astro|remix|vite)\s+dev\b/,
112 /\bnodemon\b/,
113 /--watch\b/,
114 /\btsc\b.*\s-w\b/,
115 /\btail\s+-[a-zA-Z]*f/,
116 /\bpython3?\s+-m\s+http\.server\b/,
117 /\b(uvicorn|gunicorn)\b|\bflask\s+run\b|\brails\s+s(erver)?\b|\bartisan\s+serve\b/,
118 /\bdocker[-\s]compose\s+up\b(?![^|;&]*\s-d\b)/,
119]
120const neverEnds = (command: string): boolean => NEVER_ENDS.some(re => re.test(command))
121let autoBackground = false
122
123// Idle chatter: after ten quiet minutes he talks, once, in the voice of sounds/idle-voice.mp3, his head
124// lifting off his body in time with it. Any turn, typing or click starts a new quiet stretch.
125const IDLE_VOICE_MS = 10 * 60_000
126let talk: { id: number; frames: readonly number[]; stepMs: number } | null = null
127let talkId = 0
128let lastActivityAt = 0
129let chattedThisIdle = false
130
131function startChatter($: EngineInterface): void {
132 if (talk) return
133 talkId += 1
134 talk = { id: talkId, frames: VOICE_FRAMES, stepMs: VOICE_STEP_MS }
135 // The clip is far quieter than the chimes already; it plays a touch under their volume setting.
136 const gain = Math.min(1, (VOLUMES[sounds.volume] ?? 0.5) * 0.9)
137 void $.audio.play({ asset: 'sounds/idle-voice.mp3' }, { gain }).catch(() => {})
138 redraw($)
139}
140
141async function noteActivity($: EngineInterface): Promise<void> {
142 lastActivityAt = await $.clock.now()
143 chattedThisIdle = false
144}
145let lastChimeAt = 0
146const soundName = (id: string): string => SOUNDS.find(x => x.id === id)?.name ?? 'off'
147// Off, then each sound in order, wrapping both ways.
148function stepSound(id: string, step: number): string {
149 const order = ['', ...SOUNDS.map(x => x.id)]
150 const at = Math.max(0, order.indexOf(id))
151 return order[(at + step + order.length) % order.length] ?? ''
152}
153
154function playSound($: EngineInterface, id: string): void {
155 const gain = VOLUMES[sounds.volume] ?? 0.5
156 let played: Promise<unknown> | undefined
157 switch (id) {
158 case 'success': played = $.audio.play({ asset: 'sounds/success.mp3' }, { gain }); break
159 case 'ping': played = $.audio.play({ asset: 'sounds/ping.mp3' }, { gain }); break
160 case 'bubble': played = $.audio.play({ asset: 'sounds/bubble.mp3' }, { gain }); break
161 case 'chime': played = $.audio.play({ asset: 'sounds/chime.mp3' }, { gain }); break
162 case 'glass': played = $.audio.play({ asset: 'sounds/glass.mp3' }, { gain }); break
163 case 'confirm': played = $.audio.play({ asset: 'sounds/confirm.mp3' }, { gain }); break
164 case 'question': played = $.audio.play({ asset: 'sounds/question.mp3' }, { gain }); break
165 case 'rise': played = $.audio.play({ asset: 'sounds/rise.mp3' }, { gain }); break
166 case 'drop': played = $.audio.play({ asset: 'sounds/drop.mp3' }, { gain }); break
167 case 'pluck': played = $.audio.play({ asset: 'sounds/pluck.mp3' }, { gain }); break
168 }
169 void played?.catch(() => {})
170}
171
172// A burst (a workflow's agents finishing together) chimes once.
173async function chime($: EngineInterface, moment: SoundMoment): Promise<void> {
174 const id = sounds[moment]
175 if (!id || !sounds.notify) return
176 const now = await $.clock.now()
177 if (now - lastChimeAt < 1_200) return
178 lastChimeAt = now
179 playSound($, id)
180}
181let shirt = 0
182type ReactionKind = 'shake' | 'pacman' | 'chef' | 'storm' | 'spider' | 'night'
183let react: { kind: ReactionKind; id: number } | null = null
184let reactId = 0
185let taps: number[] = [] // recent clicks on the mascot; a burst of them rolls a reaction
186const TAP_BURST = 4
187const TAP_WINDOW_MS = 1_800
188let lastTurnEndAt = 0
189// A shuffle bag, the way games deal pieces: every item comes out once, in a random order, before any
190// repeats, and the first of a fresh bag is never the last of the one before, so nothing plays twice in a row.
191class Bag<T> {
192 private left: T[] = []
193 private last: T | undefined
194 constructor(private readonly items: readonly T[]) {}
195 draw(): T {
196 if (this.left.length === 0) {
197 const order = [...this.items]
198 for (let i = order.length - 1; i > 0; i -= 1) {
199 const j = Math.floor(Math.random() * (i + 1))
200 ;[order[i], order[j]] = [order[j]!, order[i]!]
201 }
202 if (order.length > 1 && order[0] === this.last) [order[0], order[order.length - 1]] = [order[order.length - 1]!, order[0]!]
203 this.left = order
204 }
205 const pick = this.left.shift()!
206 this.last = pick
207 return pick
208 }
209}
210type RollKind = ReactionKind | 'snake'
211const reactionBag = new Bag<RollKind>(['shake', 'pacman', 'chef', 'storm', 'spider', 'night', 'snake'])
212// One of the six reactions from the bag: five in the column, the snake chase on the band.
213const rollReaction = (): void => {
214 const kind = reactionBag.draw()
215 if (kind === 'snake') scene = { t: 0 }
216 else {
217 reactId += 1
218 react = { kind, id: reactId }
219 }
220}
221let climb = 0 // bumped to send the mascot up the ladder
222let lastQuipKind = ''
223let bubbleSince = 0 // when the quip went up (clock ms)
224let bubbleHold = 0 // how long it stays before it fades
225// How often each kind of moment earns a word at all: rare, so a bubble is an easter egg.
226const CHANCE: Record<keyof typeof QUIPS, number> = { hello: 1, thinking: 0.15, done: 0.2, idle: 0.2, compact: 0.6, fast: 0.8, full: 0.5 }
227const HOLD: Record<keyof typeof QUIPS, number> = { hello: 6_000, thinking: 4_000, done: 4_000, idle: 4_000, compact: 4_000, fast: 4_000, full: 5_000 }
228const quipBags = new Map<string, Bag<string>>()
229// Maybe says something: by chance, the next line from that kind's shuffle bag, only when the bubble is empty.
230const say = (kind: keyof typeof QUIPS, now: number): void => {
231 if (slim || bubble || Math.random() > CHANCE[kind]) return
232 let bag = quipBags.get(kind)
233 if (!bag) {
234 bag = new Bag(QUIPS[kind])
235 quipBags.set(kind, bag)
236 }
237 const pick = bag.draw()
238 bubble = pick.replace('{name}', userName || 'you')
239 lastQuipKind = kind
240 bubbleHold = HOLD[kind]
241 bubbleSince = now
242}
243let fastReply = '' // what /fast last answered, shown on the Fast page
244let effortOverride: ModelEffort | '' = '' // a level pressed on a chip, forced on each request only until /effort has run
245const EFFORTS: readonly ModelEffort[] = ['low', 'medium', 'high', 'xhigh', 'max']
246const EFFORT_COLOR: Record<ModelEffort, string> = { low: 'success', medium: 'ide', high: 'warning', xhigh: 'claude', max: 'error' }
247let configNote = ''
248let turnRunning = false
249let spinnerWord = ''
250const CONFIG_PANE = 'cc-dock-config'
251// Client instances live on across reloads while their key holds, old module code and all; a key stamped
252// per load makes every reload mount fresh ones.
253const LOAD = Date.now().toString(36)
254const MENU_ID = `config-menu-${LOAD}`
255const MCP_MENU_ID = `mcp-menu-${LOAD}`
256const ULTRA_ID = `ultracode-chip-${LOAD}`
257const FAST_CHIP_ID = `fast-chip-${LOAD}`
258const MASCOT_ID = `mascot-${LOAD}`
259const MCP_PANE = 'cc-dock-mcp'
260const shells = new Map<string, Shell>()
261
262const REFRESH_MS = 10_000 // idle poll
263const AFTER_TURN_MS = 1_000 // a response just landed: refresh unless one did within this window
264const BACKOFF_MS = 20_000 // after a 429: keep retrying quietly
265let tier = ''
266let lastFetchAt = 0
267let backoffUntil = 0
268let fetching = false
269
270const TIER_LABEL: Record<string, string> = {
271 default_claude_max_20x: 'Max 20x',
272 default_claude_max_5x: 'Max 5x',
273 default_claude_pro: 'Pro',
274}
275
276// The mascot frames Claude Code itself ships (its front pose, then its turn-left and turn-right
277// sprites), cycled so it looks side to side.
278const DANCE: readonly (readonly string[])[] = [
279 [' ▐▛███▜▌ ', '▝▜█████▛▘', ' ▘▘ ▝▝ '],
280 [' ▐▛███▜█ ', '▝▜██████▀', ' ▝▝ ▝▝ '],
281 [' ▛██▛█▌ ', ' ▝█████▛ ', ' ▘▘ ▘▘ '],
282 [' ▐▛███▜█ ', '▝▜██████▀', ' ▝▝ ▝▝ '],
283 [' ▐▛███▜▌ ', '▝▜█████▛▘', ' ▘▘ ▝▝ '],
284 [' ▐█▜██▛█ ', '▝▜██████▀', ' ▝▝ ▝▝ '],
285 [' █▛██▛▌ ', ' ▝█████▛ ', ' ▘▘ ▘▘ '],
286 [' ▐█▜██▛█ ', '▝▜██████▀', ' ▝▝ ▝▝ '],
287]
288
289// The snake chase: a rare scene on the empty stage right of the sections. Act one, the mascot runs
290// after a wriggling snake and gains on it; act two, it stops and opens an alien mouth far too big for
291// its head; act three, the snake is gone in three gulps and the mascot looks pleased.
292let scene: { t: number } | null = null
293const MOUTH: readonly (readonly string[])[] = [
294 [' ▐▛███▜▌ ', '▝▜▙▄▄▄▟▛▘', ' ▘▘ ▝▝ '],
295 ['▐▛▀▀▀▀▀▜▌', '▐▌ᴠᴠᴠᴠᴠ▐▌', '▝▜▄▄▄▄▄▛▘'],
296 ['▐▛▀▀▀▀▀▜▌', '▐▌ᴠᴠᴠᴠᴠ▐▌', '▝▜▄▄▄▄▄▛▘'],
297]
298const STAGE_H = 9
299const PINK = '#ff5fa2'
300// A coloured cell canvas: art is blitted at (x, y) with one colour per art row; spaces are transparent.
301type Cell = { ch: string; c: string }
302const canvas = (width: number): Cell[][] => Array.from({ length: STAGE_H }, () => Array.from({ length: width }, () => ({ ch: ' ', c: 'text' })))
303const blit = (c: Cell[][], art: readonly string[], x: number, y: number, colors: string | readonly string[]): void => {
304 art.forEach((row, i) => {
305 const line = c[y + i]
306 if (!line) return
307 const color = typeof colors === 'string' ? colors : colors[i] ?? colors[colors.length - 1] ?? 'text'
308 ;[...row].forEach((ch, j) => {
309 const col = x + j
310 if (ch !== ' ' && col >= 0 && col < line.length) line[col] = { ch, c: color }
311 })
312 })
313}
314// A row of cells as runs of one colour, for drawing.
315const runs = (row: Cell[]): { t: string; c: string }[] => {
316 const out: { t: string; c: string }[] = []
317 for (const cell of row) {
318 const last = out[out.length - 1]
319 if (last && last.c === cell.c) last.t += cell.ch
320 else out.push({ t: cell.ch, c: cell.c })
321 }
322 return out
323}
324const ULTRA = '#a78bfa' // the banner mascot's purple while ultracode is on (idle only)
325const SPARK = '#e8c547' // the sparkler's gold
326// The mascot's colours by row on the stage: head and legs orange, the shirt on the body.
327const mascotColors = (shirtColor: string): string[] => ['claude', shirtColor, 'claude']
328// The snake: two rows, a short skinny wave with an eye, two wriggle frames.
329const SNAKE: readonly (readonly string[])[] = [
330 ['▄▀▄ ▄▀▄ ▄▀▖', ' ▀▀▀ ▀▀▀ ◉▘'],
331 [' ▄▀▄ ▄▀▄ ▄▖', '▀▀▀ ▀▀▀ ▀ ◉▘'],
332]
333const SNAKE_W = 17
334
335// A long zig-zag: the runner climbs and dives across the stage's rows, one row every two frames.
336const zig = (t: number): number => {
337 const span = STAGE_H - 4
338 const k = Math.floor(t / 2) % (span * 2)
339 return k <= span ? k : span * 2 - k
340}
341// The chase across the whole band: the mascot leaves its spot at the left edge and runs right; the snake,
342// lower down, flees almost as fast, so the two travel a long way before the gap closes or the snake is
343// cornered at the right edge. Then: stop, an arm reaches out and lifts the snake, the top of the head
344// unscrews over a row of teeth, the snake goes down in bites, the head screws back, `gulp.`, a pink heart.
345const snakeFrame = (t: number, width: number, shirtColor: string): { t: string; c: string }[][] | null => {
346 const c = canvas(width)
347 const SPEED = 2
348 const FLEE = 1.85
349 const snakeX = (f: number) => Math.min(width - SNAKE_W - 1, 18 + Math.floor(f * FLEE))
350 let chaseFrames = 0
351 while (chaseFrames * SPEED + 9 < snakeX(chaseFrames) && chaseFrames < 900) chaseFrames += 1
352 const mc = mascotColors(shirtColor)
353 const snakeY = 7
354 if (t < chaseFrames) {
355 blit(c, DANCE[t % 2 === 0 ? 0 : 1]!, t * SPEED, zig(t), mc)
356 blit(c, SNAKE[t % 2]!, snakeX(t), snakeY - (Math.floor(t / 3) % 2), shirtColor)
357 return c.map(runs)
358 }
359 const u = t - chaseFrames
360 const sx = snakeX(chaseFrames)
361 const mx = Math.max(1, sx - 9) // nose to tail
362 const y = 4 // the head's row once he stops
363 const body = DANCE[0]![1]!
364 const legs = DANCE[0]![2]!
365 const lower = () => blit(c, [body, legs], mx, y + 1, [shirtColor, 'claude'])
366 const still = () => blit(c, DANCE[0]!, mx, y, mc)
367 // The lid is the head, hinged at its back-left corner; it cracks, stands half open on a diagonal,
368 // then swings right back and grows into a big block with teeth along its underside.
369 const lidCrack = () => {
370 blit(c, ['▗▟▌'], mx + 6, y - 1, 'claude')
371 blit(c, [' ▐▛██▀▀ '], mx, y, 'claude')
372 }
373 const lidHalf = () => {
374 blit(c, ['▗▟█▌'], mx + 5, y - 2, 'claude')
375 blit(c, ['▗▟█▀'], mx + 3, y - 1, 'claude')
376 blit(c, [' ▐▀▘'], mx, y, 'claude')
377 blit(c, ['▐'], mx + 7, y, 'claude')
378 }
379 const lidOpen = () => {
380 // A low lid: one row of head swung back, the open cavity beneath it.
381 blit(c, ['▗▟█████▙▖'], mx - 1, y - 1, 'claude')
382 blit(c, ['▌'], mx + 1, y, 'claude')
383 blit(c, ['▐'], mx + 7, y, 'claude')
384 }
385 // The snake held at mouth level to the right of the head; `into` columns of it have gone in past the
386 // right cheek, so only what is still outside is drawn.
387 const held = (f: number, x: number, into: number) => {
388 const art = SNAKE[f % 2]!.map(row => row.slice(into))
389 blit(c, art, x, y - 1, shirtColor)
390 }
391 if (u < 2) {
392 still()
393 blit(c, SNAKE[0]!, sx, snakeY, shirtColor)
394 } else if (u < 4) {
395 // A short, thick arm out of the body.
396 still()
397 blit(c, [u === 2 ? '▆' : '▆▆'], mx + 9, y + 1, shirtColor)
398 blit(c, SNAKE[u % 2]!, sx, snakeY, shirtColor)
399 } else if (u < 8) {
400 // Lifted straight up on the arm to mouth level, just right of the head.
401 const lift = u - 4 // 0..3
402 const sy = snakeY - lift
403 still()
404 blit(c, ['▆▆'], mx + 9, y + 1, shirtColor)
405 for (let r = sy + 2; r <= y + 1; r += 1) blit(c, ['▐'], mx + 10, r, shirtColor)
406 blit(c, SNAKE[u % 2]!, mx + 10, Math.max(0, sy), shirtColor)
407 } else if (u === 8) {
408 lower()
409 lidCrack()
410 blit(c, SNAKE[0]!, mx + 10, y - 1, shirtColor)
411 } else if (u === 9) {
412 lower()
413 lidHalf()
414 blit(c, SNAKE[1]!, mx + 10, y - 1, shirtColor)
415 } else if (u < 16) {
416 // Wide open; the snake slides in tail first, four columns a frame, and what passes the cheek is gone.
417 lower()
418 lidOpen()
419 const into = (u - 10) * 4
420 if (into < SNAKE_W) held(u, mx + 8, into)
421 } else if (u === 16) {
422 lower()
423 lidHalf()
424 } else if (u === 17) {
425 lower()
426 lidCrack()
427 } else if (u < 22) {
428 still()
429 blit(c, ['gulp.'], mx + 2, y - 1, 'text')
430 } else if (u < 30) {
431 still()
432 blit(c, ['♥'], mx + 4, y - 1, PINK)
433 } else return null
434 return c.map(runs)
435}
436
437
438const bar = (percent: number, width: number): string => {
439 const filled = Math.max(0, Math.min(width, Math.round((percent / 100) * width)))
440 return '█'.repeat(filled) + '░'.repeat(width - filled)
441}
442const barColor = (percent: number): 'error' | 'warning' | 'success' =>
443 percent >= 90 ? 'error' : percent >= 70 ? 'warning' : 'success'
444
445// The short zone name the system runs in (PDT, PST, CET), from the formatter's own parts.
446const zoneName = (d: Date): string => {
447 try {
448 const part = new Intl.DateTimeFormat([], { timeZoneName: 'short' }).formatToParts(d).find(x => x.type === 'timeZoneName')
449 return part?.value ?? ''
450 } catch {
451 return ''
452 }
453}
454const fmtTime = (d: Date): string => {
455 const zone = zoneName(d)
456 return `${d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}${zone ? ` ${zone}` : ''}`
457}
458const resetText = (iso: string | undefined, now: number): string => {
459 if (!iso) return ''
460 try {
461 const d = new Date(iso)
462 const days = (d.getTime() - now) / 86_400_000
463 if (days < 1 && d.getDate() === new Date(now).getDate()) return `resets ${fmtTime(d)}`
464 if (days < 7) return `resets ${d.toLocaleDateString([], { weekday: 'short' })} ${fmtTime(d)}`
465 return `expires ${d.toLocaleDateString([], { month: 'short', day: 'numeric' })}`
466 } catch {
467 return ''
468 }
469}
470const kTokens = (n: number): string => (n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
471const elapsed = (startedAt: number, now: number): string => {
472 const s = Math.max(0, Math.round((now - startedAt) / 1000))
473 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`
474}
475
476// "On track" the way the desktop usage page says it: used share vs. elapsed share of the weekly window.
477// The plan windows: the usage endpoint's reading when it answered, else the headers the last response carried.
478const currentLimits = (): Limit[] =>
479 plan
480 ? plan.limits
481 : rateLimits.map(w => ({
482 kind: w.kind,
483 label: w.kind === 'five_hour' ? 'Session' : w.kind === 'seven_day' ? 'Week' : w.kind,
484 percent: w.percentUsed,
485 resetsAt: w.resetsAt,
486 }))
487
488type ConfigTab = 'dock' | 'settings' | 'status' | 'config' | 'usage' | 'stats'
489const CONFIG_TABS: readonly { id: ConfigTab; label: string }[] = [
490 { id: 'dock', label: 'Dock' },
491 { id: 'config', label: 'Config' },
492 { id: 'status', label: 'Status' },
493 { id: 'usage', label: 'Usage' },
494 { id: 'stats', label: 'Stats' },
495 { id: 'settings', label: 'File' },
496]
497let configTab: ConfigTab = 'dock'
498let sessionStartedAt = 0
499
500type Els = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
501type BracketOpts = {
502 key: string
503 label: string
504 color?: string // bracket colour when lit; dim when not
505 lit?: boolean // default true
506 width?: number // pad the label to this width, equally each side
507 onPress: () => void
508}
509// The one button shape the dock draws: `[ label ]`, brackets coloured, the whole inside pressable.
510// Padding rides inside the label as no-break spaces, which the Button keeps, so the click lands anywhere
511// between the brackets.
512const bracket = (els: Els, o: BracketOpts) => {
513 const { Box, Text, Button } = els
514 const lit = o.lit !== false
515 const side = o.width ? Math.floor(Math.max(0, o.width - o.label.length) / 2) : 0
516 const label = `\u00a0${'\u00a0'.repeat(side)}${o.label}${'\u00a0'.repeat(side)}\u00a0`
517 return (
518 <Box key={`${o.key}-wrap`} flexDirection="row">
519 <Text bold={lit} color={lit ? o.color ?? colorOf(look.uiColor) : undefined} dimColor={!lit}>[</Text>
520 <Button key={o.key} label={label} plain dimColor={!lit} onPress={o.onPress} />
521 <Text bold={lit} color={lit ? o.color ?? colorOf(look.uiColor) : undefined} dimColor={!lit}>]</Text>
522 </Box>
523 )
524}
525
526// ~/.claude/stats-cache.json: what the engine's own /stats screen draws from.
527type StatsCache = {
528 dailyActivity?: { date: string; messageCount: number; sessionCount: number; toolCallCount: number }[]
529 dailyModelTokens?: { date: string; tokensByModel: Record<string, number> }[]
530 modelUsage?: Record<string, { inputTokens: number; outputTokens: number; cacheReadInputTokens: number; cacheCreationInputTokens: number }>
531 totalSessions?: number
532 totalMessages?: number
533 longestSession?: { duration: number; timestamp: string }
534 firstSessionDate?: string
535}
536type StatsPeriod = 'all' | '30' | '7'
537let statsPeriod: StatsPeriod = 'all'
538const PERIOD_LABEL: Record<StatsPeriod, string> = { all: 'All time', '30': 'Last 30 days', '7': 'Last 7 days' }
539
540const bigNumber = (n: number): string =>
541 n >= 1e9 ? `${(n / 1e9).toFixed(1)}b` : n >= 1e6 ? `${(n / 1e6).toFixed(1)}m` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n)
542const duration = (ms: number): string => {
543 const m = Math.floor(ms / 60000)
544 const d = Math.floor(m / 1440)
545 const hours = Math.floor((m % 1440) / 60)
546 return [d ? `${d}d` : '', hours ? `${hours}h` : '', `${m % 60}m`].filter(Boolean).join(' ')
547}
548const dayKey = (t: number): string => new Date(t).toISOString().slice(0, 10)
549const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
550const DAY_MS = 86_400_000
551
552// The /stats overview as header lines: the year's activity grid, its legend, the period line and the figures.
553const statsHeader = (cache: StatsCache, now: number, period: StatsPeriod, accent: string): Seg[][] => {
554 const activity = cache.dailyActivity ?? []
555 const byDay = new Map(activity.map(a => [a.date, a]))
556 const cutoff = period === 'all' ? 0 : now - Number(period) * DAY_MS
557 const inPeriod = (date: string) => new Date(`${date}T12:00:00Z`).getTime() >= cutoff
558 const lines: Seg[][] = []
559
560 // Grid: 53 week columns ending this week, rows Monday to Sunday, one cell per day.
561 const today = new Date(now)
562 const todayUtc = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate())
563 const weekday = (new Date(todayUtc).getUTCDay() + 6) % 7 // Monday 0
564 const thisMonday = todayUtc - weekday * DAY_MS
565 const WEEKS = 53
566 const firstMonday = thisMonday - (WEEKS - 1) * 7 * DAY_MS
567 const counts = activity.map(a => a.messageCount).filter(n => n > 0).sort((a, b) => a - b)
568 const q = (i: number) => counts[Math.min(counts.length - 1, Math.floor(counts.length * i))] ?? 0
569 const level = (n: number) => (n <= 0 ? 0 : n <= q(0.25) ? 1 : n <= q(0.5) ? 2 : n <= q(0.75) ? 3 : 4)
570 const GLYPH = ['·', '░', '▒', '▓', '█']
571 const months: Seg[] = [{ t: ' ' }]
572 let lastMonth = -1
573 for (let w = 0; w < WEEKS; w += 1) {
574 const monday = new Date(firstMonday + w * 7 * DAY_MS)
575 const m = monday.getUTCMonth()
576 if (m !== lastMonth && monday.getUTCDate() <= 7) {
577 months.push({ t: MONTHS[m]!.slice(0, 1), c: 'text' })
578 lastMonth = m
579 } else months.push({ t: ' ' })
580 }
581 lines.push(months)
582 const labels = ['Mon', '', 'Wed', '', 'Fri', '', '']
583 for (let d = 0; d < 7; d += 1) {
584 const row: Seg[] = [{ t: `${labels[d]!.padEnd(3)} `, c: 'text' }]
585 for (let w = 0; w < WEEKS; w += 1) {
586 const t = firstMonday + w * 7 * DAY_MS + d * DAY_MS
587 if (t > todayUtc) {
588 row.push({ t: ' ' })
589 continue
590 }
591 const a = byDay.get(dayKey(t))
592 const lv = level(a?.messageCount ?? 0)
593 row.push(lv === 0 ? { t: GLYPH[0]!, d: true } : { t: GLYPH[lv]!, c: accent })
594 }
595 lines.push(row)
596 }
597 lines.push([{ t: ' Less ', c: 'text' }, { t: '░▒▓█', c: accent }, { t: ' More', c: 'text' }])
598 lines.push([])
599
600 // Figures for the chosen period.
601 const days = activity.filter(a => inPeriod(a.date))
602 const tokenDays = (cache.dailyModelTokens ?? []).filter(a => inPeriod(a.date))
603 const perModel = new Map<string, number>()
604 for (const day of tokenDays) for (const [m, n] of Object.entries(day.tokensByModel)) perModel.set(m, (perModel.get(m) ?? 0) + n)
605 const favourite = [...perModel.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? '—'
606 const totalTokens = [...perModel.values()].reduce((a, b) => a + b, 0)
607 const sessions = period === 'all' ? cache.totalSessions ?? days.reduce((a, d) => a + d.sessionCount, 0) : days.reduce((a, d) => a + d.sessionCount, 0)
608 const first = cache.firstSessionDate ? new Date(cache.firstSessionDate).getTime() : now
609 const span = Math.max(1, Math.floor((todayUtc - Math.max(first, cutoff)) / DAY_MS) + 1)
610 const activeDays = days.length
611 const busiest = [...days].sort((a, b) => b.messageCount - a.messageCount)[0]
612 const dateLabel = (date: string) => {
613 const dt = new Date(`${date}T12:00:00Z`)
614 return `${MONTHS[dt.getUTCMonth()]} ${dt.getUTCDate()}`
615 }
616 // Streaks over the active dates.
617 const active = new Set(activity.map(a => a.date))
618 let longest = 0
619 let current = 0
620 for (let t = todayUtc; t >= first - DAY_MS; t -= DAY_MS) {
621 if (active.has(dayKey(t))) current += 1
622 else break
623 }
624 let run = 0
625 for (let t = first; t <= todayUtc; t += DAY_MS) {
626 run = active.has(dayKey(t)) ? run + 1 : 0
627 longest = Math.max(longest, run)
628 }
629 const usage = Object.values(cache.modelUsage ?? {})
630 const sum = (k: 'inputTokens' | 'outputTokens' | 'cacheReadInputTokens' | 'cacheCreationInputTokens') => usage.reduce((a, u) => a + (u[k] ?? 0), 0)
631 const periodLine: Seg[] = []
632 for (const p of ['all', '30', '7'] as const) {
633 if (periodLine.length) periodLine.push({ t: ' · ', d: true })
634 periodLine.push(p === period ? { t: PERIOD_LABEL[p], c: accent, b: true } : { t: PERIOD_LABEL[p], d: true })
635 }
636 periodLine.push({ t: ' (←→ on the Period row switches)', d: true })
637 lines.push(periodLine)
638 lines.push([])
639 const COL = Math.max(28, `Favorite model: ${favourite}`.length + 3)
640 const pair = (l1: string, v1: string, l2: string, v2: string): Seg[] => [
641 { t: `${l1}: `, c: 'text' }, { t: v1.padEnd(Math.max(1, COL - l1.length - 2)), c: accent, b: true },
642 { t: `${l2}: `, c: 'text' }, { t: v2, c: accent, b: true },
643 ]
644 lines.push(pair('Favorite model', favourite, 'Total tokens', bigNumber(totalTokens)))
645 lines.push([])
646 lines.push(pair('Sessions', String(sessions), 'Longest session', cache.longestSession ? duration(cache.longestSession.duration) : '—'))
647 lines.push([
648 { t: 'Active days: ', c: 'text' }, { t: `${activeDays}`, c: accent, b: true }, { t: `/${span}`.padEnd(Math.max(1, COL - 13 - String(activeDays).length)), d: true },
649 { t: 'Longest streak: ', c: 'text' }, { t: `${longest} days`, c: accent, b: true },
650 ])
651 lines.push(pair('Most active day', busiest ? dateLabel(busiest.date) : '—', 'Current streak', `${current} days`))
652 if (period === 'all') {
653 lines.push([{ t: `Input ${bigNumber(sum('inputTokens'))} · Output ${bigNumber(sum('outputTokens'))} · Cache read ${bigNumber(sum('cacheReadInputTokens'))} · Cache write ${bigNumber(sum('cacheCreationInputTokens'))}`, d: true }])
654 } else {
655 lines.push([{ t: 'token split by kind is kept for all time only', d: true }])
656 }
657 return lines
658}
659
660const headline = (limits: Limit[], now: number): string => {
661 const week = limits.find(l => l.kind === 'weekly_all' || l.kind === 'seven_day')
662 if (!week?.resetsAt) return ''
663 const resets = new Date(week.resetsAt).getTime()
664 const elapsedShare = Math.max(0, Math.min(1, 1 - (resets - now) / (7 * 86_400_000)))
665 const day = new Date(resets).toLocaleDateString([], { weekday: 'long' })
666 if (week.percent >= 95) return `Weekly limit nearly used · resets ${day}`
667 if (week.percent <= elapsedShare * 100 + 15) return `On track · room to spare before ${day}'s reset`
668 return `Running hot · pace exceeds ${day}'s reset`
669}
670
671function redraw($: EngineInterface): void {
672 $.ui.invalidate('ui.render')
673}
674
675// The login record holds the plan tier and the token the usage endpoint wants. The token stays in
676// this function's scope and is never logged, drawn or stored.
677async function readLogin($: EngineInterface): Promise<{ token: string; tier: string } | null> {
678 let raw = ''
679 try {
680 const r = await $.process.run(['security', 'find-generic-password', '-s', 'Claude Code-credentials', '-w'])
681 if (r.exitCode === 0) raw = r.stdout.trim()
682 } catch {
683 raw = ''
684 }
685 if (!raw) {
686 try {
687 const home = (await $.env.get('HOME')) ?? ''
688 raw = await $.fs.read(`${home}/.claude/.credentials.json`)
689 } catch {
690 return null
691 }
692 }
693 try {
694 const o = JSON.parse(raw) as { claudeAiOauth?: { accessToken?: string; rateLimitTier?: string; subscriptionType?: string } }
695 const auth = o.claudeAiOauth
696 if (!auth?.accessToken) return null
697 return { token: auth.accessToken, tier: auth.rateLimitTier ?? auth.subscriptionType ?? '' }
698 } catch {
699 return null
700 }
701}
702
703type UsageJson = {
704 limits?: { kind: string; percent: number; resets_at?: string; scope?: { model?: { display_name?: string } } | null }[]
705 seven_day_breakdown?: { rows?: { key: string; percent: number }[] }
706} & Record<string, unknown>
707
708function parseUsage(json: UsageJson, tier: string, now: number): Plan {
709 const limits: Limit[] = []
710 for (const l of json.limits ?? []) {
711 const scoped = l.kind === 'weekly_scoped' ? l.scope?.model?.display_name : undefined
712 const label = l.kind === 'session' ? 'Session' : l.kind === 'weekly_all' ? 'Week' : scoped ? scoped : l.kind
713 limits.push({ kind: l.kind, label, percent: l.percent, resetsAt: l.resets_at })
714 }
715 const code = json.seven_day_breakdown?.rows?.find(r => r.key === 'claude_code')
716 return { tier: TIER_LABEL[tier] ?? tier, limits, codeShare: code?.percent, fetchedAt: now }
717}
718
719// Panes open at once, mid-turn or not; a slash command would wait in the prompt queue.
720const DOCK_PANES = [CONFIG_PANE, MCP_PANE]
721async function openPane($: EngineInterface, id: string, title: string): Promise<void> {
722 try {
723 // One dock pane at a time, so the engine draws no tab row above it; the dock's own buttons switch.
724 for (const other of DOCK_PANES) {
725 if (other === id) continue
726 try {
727 await $.ui.close({ id: other })
728 } catch {
729 // not open
730 }
731 }
732 const opened = await $.ui.open({ id, title, focus: true, closeOnEscape: true })
733 usageNote = opened.isPlaced ? '' : `widen the terminal to see ${title}`
734 } catch (err) {
735 usageNote = err instanceof Error ? err.message : `could not open ${title}`
736 }
737 redraw($)
738}
739
740async function loadBreakdown($: EngineInterface, columns: number): Promise<void> {
741 if (breakdownBusy) return
742 breakdownBusy = true
743 breakdownNote = 'counting…'
744 redraw($)
745 try {
746 const usage = await $.session.usage({ breakdown: 'full', columns })
747 context = usage.context
748 breakdown = usage.context.breakdown ?? null
749 breakdownNote = breakdown ? '' : 'no breakdown available'
750 } catch (err) {
751 breakdownNote = err instanceof Error ? err.message : 'could not count the context'
752 } finally {
753 breakdownBusy = false
754 redraw($)
755 }
756}
757
758// First press arms the button (it reads "Yes?"), the second within CONFIRM_MS compacts; a stray click does nothing.
759async function compactPress($: EngineInterface): Promise<void> {
760 const now = await $.clock.now()
761 if (compactArmedAt && now - compactArmedAt < CONFIRM_MS) {
762 compactArmedAt = 0
763 await compactNow($)
764 return
765 }
766 compactArmedAt = now
767 redraw($)
768}
769
770// The engine compacts only between turns; mid-turn the request is queued so it runs the moment the turn ends.
771async function compactNow($: EngineInterface): Promise<void> {
772 try {
773 const { skip } = await $.session.compact()
774 if (!skip) {
775 compactions += 1
776 say('compact', await $.clock.now())
777 }
778 usageNote = skip ? `compaction skipped: ${skip}` : ''
779 breakdown = null
780 } catch {
781 compactWhenIdle = true
782 usageNote = 'turn running · compacts when it ends'
783 }
784 redraw($)
785}
786
787async function syncModel($: EngineInterface): Promise<void> {
788 try {
789 const now = await $.session.model()
790 if (now && now !== model) {
791 model = now
792 redraw($)
793 }
794 } catch {
795 // keep the last reading
796 }
797}
798
799// The model, its reasoning level and fast mode, as /model and /config show them.
800async function loadModelInfo($: EngineInterface): Promise<void> {
801 try {
802 model = await $.session.model()
803 } catch {
804 // keep the last reading
805 }
806}
807
808// A chip press: the lit level again clears the override, another sets it; the store keeps it across sessions.
809let effortPending: ModelEffort | '' = ''
810// Usage by model, summed from every turn.complete since the dock loaded (the engine's own totals since
811// session start are not exposed).
812type ModelTally = { input: number; output: number; cacheRead: number; cacheWrite: number; turns: number }
813const tally = new Map<string, ModelTally>()
814let requests = 0
815let apiMs = 0 // a level to hand /effort once the session is idle
816
817// Runs /effort <level> as typed: the only way the running session's own level changes. Idle it runs at
818// once; mid-turn it waits for turn.complete, so nothing queues behind a prompt.
819async function applyEffortLive($: EngineInterface): Promise<void> {
820 const level = effortPending
821 if (!level || turnRunning) return
822 effortPending = ''
823 try {
824 const result = await $.command.run({ command: 'effort', args: level })
825 readEffortText(result.text)
826 if (!result.text) effort = level
827 // The engine now holds the level itself; the dock's bridge is no longer needed and must not shadow it.
828 effortOverride = ''
829 configNote = ''
830 } catch (err) {
831 configNote = `effort: ${err instanceof Error ? err.message : '/effort did not run'}`
832 }
833 redraw($)
834}
835
836// /effort's own words carry the state: "Ultracode on (this session only) … Effort stays high.",
837// "Ultracode off. Effort stays high.", "Effort set to xhigh". A level the person chose there is the
838// real one, so it also clears any level the dock had been forcing.
839function readEffortText(text: string | undefined): void {
840 if (!text) return
841 if (/ultracode\s+on/i.test(text)) ultracode = true
842 else if (/ultracode\s+off/i.test(text)) ultracode = false
843 // "Effort stays high", "Set effort level to low (…)", "Effort set to xhigh", "effort: medium".
844 const m = /effort(?:\s+level)?\s*(?:stays|set to|is now|is|to|:)?\s*(low|medium|high|xhigh|max)\b/i.exec(text)
845 if (m) {
846 effort = m[1]!.toLowerCase() as ModelEffort
847 effortOverride = ''
848 }
849}
850
851// Ultracode is session state the engine switches only through `/effort ultracode on|off`; run it idle, or
852// after the running turn ends, never queued behind a prompt.
853async function applyUltracodeLive($: EngineInterface): Promise<void> {
854 const want = ultraPending
855 if (!want || turnRunning) return
856 ultraPending = ''
857 try {
858 const result = await $.command.run({ command: 'effort', args: `ultracode ${want}` })
859 readEffortText(result.text)
860 if (!result.text) ultracode = want === 'on'
861 configNote = ''
862 } catch (err) {
863 configNote = `ultracode: ${err instanceof Error ? err.message : '/effort did not run'}`
864 }
865 redraw($)
866}
867
868// The mascot's word of warning when bypass comes on: five seconds, over anything else he was saying.
869const careful = (now: number): void => {
870 if (slim) return
871 bubble = 'Careful!'
872 bubbleHold = 5_000
873 bubbleSince = now
874}
875
876async function toggleUltracode($: EngineInterface): Promise<void> {
877 // A second click while one is waiting cancels it.
878 if (ultraPending) {
879 ultraPending = ''
880 configNote = ''
881 redraw($)
882 return
883 }
884 ultraPending = ultracode ? 'off' : 'on'
885 configNote = turnRunning ? `ultracode ${ultraPending} after this turn` : ''
886 redraw($)
887 await applyUltracodeLive($)
888}
889
890// Whatever the dock still means to switch, done the moment the session is idle; called after each turn and
891// again on the idle checks, so a switch pressed during back-to-back turns is never left hanging.
892async function flushPending($: EngineInterface): Promise<void> {
893 if (turnRunning) return
894 if (effortPending) await applyEffortLive($)
895 if (ultraPending) await applyUltracodeLive($)
896 if (fastPending) await toggleFast($)
897}
898
899async function setEffort($: EngineInterface, level: ModelEffort): Promise<void> {
900 // The lit chip is already this level: nothing to do.
901 if (level === (effortOverride || effort)) return
902 // Until /effort has run, the dock bridges the gap by rewriting each request's effort itself.
903 effortOverride = level
904 effort = level
905 effortPending = effortOverride
906 configNote = turnRunning ? `${effortOverride} now · /effort after this turn` : configNote
907 redraw($)
908 await applyEffortLive($)
909}
910
911// /fast's own words decide the state; without a readable answer the press is taken at its word.
912function readFastText(text: string | undefined): void {
913 if (!text) return
914 fastReply = text.trim()
915 if (/disabled|turned off|\boff\b/i.test(text)) fastMode = false
916 else if (/enabled|turned on|\bon\b/i.test(text)) {
917 fastMode = true
918 fastQuipDue = true
919 }
920}
921
922// The Fast button runs /fast like a typed command: at once when idle, after the turn when one runs.
923let fastPending = false // a /fast to run once the turn ends
924async function toggleFast($: EngineInterface): Promise<void> {
925 if (turnRunning) {
926 fastPending = true
927 fastNote = 'after this turn'
928 redraw($)
929 return
930 }
931 fastPending = false
932 fastNote = ''
933 try {
934 const result = await $.command.run({ command: 'fast' })
935 readFastText(result.text)
936 fastNote = ''
937 } catch (err) {
938 fastNote = err instanceof Error ? err.message : 'could not run /fast'
939 }
940 redraw($)
941}
942
943async function refreshPlan($: EngineInterface, minAge = REFRESH_MS): Promise<void> {
944 const now = await $.clock.now()
945 if (fetching || now < backoffUntil || now - lastFetchAt < minAge) return
946 fetching = true
947 lastFetchAt = now
948 try {
949 const login = await readLogin($)
950 if (!login) {
951 planError = 'not signed in'
952 return
953 }
954 tier = TIER_LABEL[login.tier] ?? login.tier
955 const res = await $.http.fetch('https://api.anthropic.com/api/oauth/usage', {
956 headers: { Authorization: `Bearer ${login.token}`, 'anthropic-beta': 'oauth-2025-04-20' },
957 })
958 if (res.status === 429) {
959 backoffUntil = now + BACKOFF_MS
960 planError = 'usage endpoint busy, retrying later'
961 return
962 }
963 if (!res.ok) {
964 planError = `usage endpoint ${res.status}`
965 return
966 }
967 plan = parseUsage(JSON.parse(res.text) as UsageJson, login.tier, now)
968 planError = ''
969 try {
970 await $.store.set('plan', plan)
971 } catch {
972 // the store is a convenience; the reading is still on screen
973 }
974 } catch {
975 planError = 'usage endpoint unreachable'
976 } finally {
977 fetching = false
978 redraw($)
979 }
980}
981
982// A reload starts from the last reading saved, so the header never blanks while the endpoint is asked again.
983async function loadSavedPlan($: EngineInterface): Promise<void> {
984 try {
985 const folded = (await $.store.get('folded')) as { plan?: boolean; chat?: boolean } | undefined
986 planOpen = folded?.plan !== true
987 chatOpen = folded?.chat !== true
988 try {
989 const home = (await $.env.get('HOME')) ?? ''
990 const settings = JSON.parse(await $.fs.read(`${home}/.claude/settings.json`)) as { ultracode?: unknown }
991 ultracode = settings.ultracode === true
992 } catch {
993 ultracode = false
994 }
995 slim = (await $.store.get('slim')) === true
996 const savedLook = (await $.store.get('look')) as Partial<DockLook> | undefined
997 if (savedLook && typeof savedLook === 'object') look = { ...look, ...savedLook }
998 autoBackground = (await $.store.get('autoBackground')) === true
999 const savedSounds = (await $.store.get('sounds')) as Record<string, unknown> | undefined
1000 if (savedSounds && typeof savedSounds === 'object') {
1001 // A pick names a sound that still exists; anything else (an older numbered pick) keeps the default.
1002 const pick = (v: unknown, d: string) => (v === '' || (typeof v === 'string' && SOUNDS.some(x => x.id === v)) ? v : d)
1003 sounds = {
1004 done: pick(savedSounds.done, sounds.done),
1005 needs: pick(savedSounds.needs, sounds.needs),
1006 agent: pick(savedSounds.agent, sounds.agent),
1007 volume: typeof savedSounds.volume === 'number' ? savedSounds.volume : sounds.volume,
1008 notify: savedSounds.notify !== false,
1009 voice: savedSounds.voice !== false,
1010 }
1011 }
1012 const shirtSaved = (await $.store.get('shirt')) as number | undefined
1013 shirt = typeof shirtSaved === 'number' && shirtSaved >= 0 && shirtSaved < SHIRTS.length ? shirtSaved : 0
1014 // A level forced in an earlier session is not carried over: the engine's own setting rules at start.
1015 effortOverride = ''
1016 } catch {
1017 // both sections open
1018 }
1019 try {
1020 const saved = (await $.store.get('plan')) as Plan | undefined
1021 if (saved && Array.isArray(saved.limits)) {
1022 plan = saved
1023 tier = saved.tier
1024 const now = await $.clock.now()
1025 lastFetchAt = now < saved.fetchedAt ? 0 : saved.fetchedAt
1026 }
1027 } catch {
1028 // nothing saved yet
1029 }
1030}
1031
1032// Background shells stream to /private/tmp/claude-<uid>/<cwd slug>/<session>/tasks/<task>.output.
1033async function locateTasksDir($: EngineInterface): Promise<void> {
1034 try {
1035 const [uid, cwd, id] = await Promise.all([
1036 $.process.run(['id', '-u']).then(r => r.stdout.trim()),
1037 $.session.cwd(),
1038 $.session.id(),
1039 ])
1040 tasksDir = `/private/tmp/claude-${uid}/${cwd.replace(/[^A-Za-z0-9]/g, '-')}/${id}/tasks`
1041 } catch {
1042 tasksDir = ''
1043 }
1044}
1045
1046async function tailOutput($: EngineInterface, taskId: string, lines: number, width: number): Promise<string[]> {
1047 if (!tasksDir) return []
1048 try {
1049 const text = await $.fs.read(`${tasksDir}/${taskId}.output`)
1050 const all = text.replace(/\r/g, '').split('\n').filter(l => l.trim() !== '')
1051 return all.slice(-lines).map(l => (l.length > width ? `${l.slice(0, width - 1)}…` : l))
1052 } catch {
1053 return []
1054 }
1055}
1056
1057// Backgrounded shells end out of band: the Stop hooks carry what is still in flight.
1058// The engine's list of background work: ends the shells it no longer has, and adopts running ones the dock never
1059// saw start (begun before a load or reload), so their output can be opened too.
1060type InFlight = { id: string; type?: string; status?: string; description?: string; command?: string }
1061async function pruneBackground($: EngineInterface, inFlight: readonly InFlight[] | undefined): Promise<void> {
1062 if (!inFlight) return
1063 const live = new Set(inFlight.map(t => t.id))
1064 const now = await $.clock.now()
1065 for (const shell of shells.values()) {
1066 if (shell.taskId && !live.has(shell.taskId) && shell.endedAt === undefined) shell.endedAt = now
1067 }
1068 const known = new Set([...shells.values()].map(x => x.taskId).filter(Boolean))
1069 for (const t of inFlight) {
1070 const isShell = t.type === 'shell' || t.command !== undefined
1071 if (!isShell || known.has(t.id) || /done|complete|fail|kill|stop/i.test(t.status ?? '')) continue
1072 shells.set(`bg-${t.id}`, {
1073 toolUseId: `bg-${t.id}`,
1074 description: t.description || (t.command ?? '').slice(0, 60) || 'background shell',
1075 command: t.command ?? '',
1076 startedAt: now,
1077 taskId: t.id,
1078 })
1079 }
1080 redraw($)
1081}
1082
1083// The footer's own count of background shells ("· 1 shell ·"), the engine's word even for shells the dock missed.
1084let engineShells = 0
1085
1086function dropLingered(now: number): void {
1087 for (const [key, shell] of shells) {
1088 if (shell.endedAt !== undefined && now - shell.endedAt > LINGER_MS) {
1089 shells.delete(key)
1090 expanded.delete(key)
1091 }
1092 }
1093}
1094
1095// Bulk brings back the whole dock, every section open whatever was folded before, and the mascot climbs
1096// the ladder its full height gives him.
1097async function toggleSlim($: EngineInterface): Promise<void> {
1098 slim = !slim
1099 if (slim) {
1100 bubble = ''
1101 scene = null
1102 react = null
1103 } else {
1104 planOpen = true
1105 chatOpen = true
1106 climb += 1
1107 }
1108 redraw($)
1109 try {
1110 await $.store.set('slim', slim)
1111 if (!slim) await $.store.set('folded', { plan: false, chat: false })
1112 } catch {
1113 // remembered for this session only
1114 }
1115}
1116
1117async function toggleSection($: EngineInterface, which: 'plan' | 'chat'): Promise<void> {
1118 if (which === 'plan') planOpen = !planOpen
1119 else chatOpen = !chatOpen
1120 redraw($)
1121 try {
1122 await $.store.set('folded', { plan: !planOpen, chat: !chatOpen })
1123 } catch {
1124 // remembered for this session only
1125 }
1126}
1127
1128export const register: Register = (on) => {
1129
1130 on('session.start', async ($, e, next) => {
1131 try {
1132 const usage = await $.session.usage()
1133 context = usage.context
1134 rateLimits = usage.rateLimits
1135 cost = usage.cost
1136 sessionStartedAt = usage.startedAt
1137 } catch {
1138 // session.measure fills it after the first turn
1139 }
1140 for (const id of DOCK_PANES) {
1141 try {
1142 await $.ui.close({ id })
1143 } catch {
1144 // not open
1145 }
1146 }
1147 try {
1148 const home = (await $.env.get('HOME')) ?? ''
1149 const root = JSON.parse(await $.fs.read(`${home}/.claude.json`)) as { oauthAccount?: { displayName?: string; fullName?: string } }
1150 const full = root.oauthAccount?.displayName || root.oauthAccount?.fullName || ''
1151 userName = full.split(/\s+/)[0] ?? ''
1152 } catch {
1153 userName = ''
1154 }
1155 say('hello', await $.clock.now())
1156 // Quiet time counts from when the dock loads, so a resumed old session does not chatter at once.
1157 await noteActivity($)
1158 await loadSavedPlan($)
1159 await loadModelInfo($)
1160 void refreshPlan($, 0)
1161 void locateTasksDir($)
1162 try {
1163 await $.command.register({ name: 'snake', description: 'The dock mascot chases a snake.' })
1164 await $.command.register({ name: 'ladder', description: 'The dock mascot climbs a ladder.' })
1165 await $.command.register({ name: 'chatter', description: 'The dock mascot says what he says after ten idle minutes.' })
1166 } catch {
1167 // another plugin may own the name
1168 }
1169 $.clock.every(5_000, () => {
1170 void refreshPlan($)
1171 void flushPending($)
1172 void $.clock.now().then(now => {
1173 const quietSince = Math.max(lastActivityAt, lastTurnEndAt)
1174 if (!sounds.voice || chattedThisIdle || turnRunning || !look.avatar || talk || scene || react) return
1175 if (now - quietSince < IDLE_VOICE_MS) return
1176 chattedThisIdle = true
1177 startChatter($)
1178 })
1179 })
1180 $.clock.every(1_000, () => {
1181 void syncModel($)
1182 })
1183 $.clock.every(90_000, () => {
1184 if (turnRunning) return
1185 void $.clock.now().then(now => {
1186 const idleFor = now - Math.max(lastTurnEndAt, sessionStartedAt)
1187 if (idleFor >= 3 * 60_000 && !scene && !react && !slim && look.avatar && Math.random() < 0.12) {
1188 rollReaction()
1189 redraw($)
1190 }
1191 })
1192 const pct = context?.percent ?? 0
1193 void $.clock.now().then(now => {
1194 say(pct >= 80 && lastQuipKind !== 'full' ? 'full' : 'idle', now)
1195 redraw($)
1196 })
1197 })
1198 $.clock.every(80, () => {
1199 if (!scene && !look.ruleShimmer) return
1200 if (scene) scene = { ...scene, t: scene.t + 1 }hooks/menu.tsx 239 lines1import type { ClientModule } from 'claude-code'
2
3// A row the menu draws: `action` says what Enter does (`toggle`, `cycle`, `expand`, or nothing).
4export type MenuRow = {
5 id: string
6 label: string
7 value: string
8 action: 'toggle' | 'cycle' | 'expand' | 'none'
9 note?: string
10 details?: string[]
11 heading?: boolean // a bold section title the cursor skips
12 muted?: boolean // drawn dim: a value nothing can change
13}
14
15export type MenuTab = { id: string; label: string }
16
17// One coloured piece of a header line.
18export type Seg = { t: string; c?: string; d?: boolean; b?: boolean }
19
20export type MenuProps = {
21 rows: MenuRow[]
22 labelWidth: number
23 tabs?: MenuTab[]
24 activeTab?: string
25 hint?: string
26 header?: Seg[][] // free-form coloured lines drawn between the hint and the rows
27 accent?: string // the colour of ‹ ›, ▸ ▾, the tab brackets and the animated selector
28}
29
30type MenuState = {
31 zone: 'list' | 'tabs'
32 cursor: number
33 tab: number
34 top: number
35 open: string[]
36 frame: number
37}
38
39// The listeners are set once per instance; they read the newest props and layout through this ref
40// instead of the closure of the render that set them.
41type Latest = { props: MenuProps; rows: MenuRow[]; tabs: MenuTab[]; count: number; room: number; headerLines: number; lines: { row: MenuRow; detail?: string; index: number }[] }
42const latestBySurface = new WeakMap<object, Latest>()
43
44// The engine's own spinner frames, turning on whatever has the keyboard.
45const SPIN = ['·', '✢', '✳', '✶', '✻', '✽']
46const fresh = (): MenuState => ({ zone: 'list', cursor: 0, tab: 0, top: 0, open: [], frame: 0 })
47
48// Click the list to give it the keyboard. ↑↓ move through the rows; ↑ past the first row reaches the tab
49// bar, where ←→ pick a tab and Enter opens it, ↓ returns to the list. On a row, Enter/→ go forward and ←
50// back. A change posts `{ select, dir }` or `{ tab }` to the hooks module, which performs it.
51const Menu: ClientModule<MenuProps, MenuState> = (props, surface) => {
52 const { Box, Text } = surface.elements
53 const state = surface.state ?? fresh()
54 const rows = props.rows
55 const tabs = props.tabs ?? []
56 const count = rows.length
57 const header = props.header ?? []
58 const headerLines = (tabs.length > 0 ? 2 : 0) + (props.hint ? 2 : 0) + header.length
59
60 const lines: { row: MenuRow; detail?: string; index: number }[] = []
61 rows.forEach((row, index) => {
62 lines.push({ row, index })
63 if (state.open.includes(row.id)) for (const d of row.details ?? []) lines.push({ row, detail: d, index })
64 })
65 const room = Math.max(3, (surface.rows || lines.length + headerLines) - headerLines)
66 if (count > 0 && state.cursor > count - 1 && surface.state !== undefined) surface.setState({ ...state, cursor: count - 1 })
67 if (surface.state !== undefined && rows[state.cursor]?.heading) {
68 const firstRow = rows.findIndex((r, i) => i > state.cursor && !r.heading)
69 if (firstRow >= 0) surface.setState({ ...state, cursor: firstRow })
70 }
71 const cursorLine = lines.findIndex(l => l.index === Math.min(state.cursor, Math.max(0, count - 1)) && l.detail === undefined)
72 let top = state.top
73 if (cursorLine < top) top = cursorLine
74 if (cursorLine >= top + room) top = cursorLine - room + 1
75 top = Math.max(0, Math.min(top, Math.max(0, lines.length - room)))
76
77 latestBySurface.set(surface, { props, rows, tabs, count, room, headerLines, lines })
78 const now = (): Latest => latestBySurface.get(surface) ?? { props, rows, tabs, count, room, headerLines, lines }
79 const tabAt = (x: number): number => {
80 const { tabs: ts } = now()
81 let col = 0
82 for (let i = 0; i < ts.length; i += 1) {
83 const w = ts[i]!.label.length + 4
84 if (x >= col && x < col + w) return i
85 col += w + 2
86 }
87 return -1
88 }
89 const openRow = (s: MenuState, row: MenuRow, back: boolean) => {
90 if (row.action === 'expand') {
91 const isOpen = s.open.includes(row.id)
92 if (back && !isOpen) return
93 if (!back && isOpen) return
94 surface.setState({ ...s, open: isOpen ? s.open.filter(id => id !== row.id) : [...s.open, row.id] })
95 } else if (row.action === 'cycle') surface.post({ select: row.id, dir: back ? 'prev' : 'next' })
96 else if (row.action === 'toggle') surface.post({ select: row.id })
97 }
98
99 if (surface.state === undefined) {
100 surface.setState(fresh())
101 surface.every(150, () => {
102 const s = surface.state ?? fresh()
103 surface.setState({ ...s, frame: (s.frame + 1) % SPIN.length })
104 })
105 surface.onKey(event => {
106 const s = surface.state ?? fresh()
107 const { props, rows, tabs, count, room } = now()
108 if (s.zone === 'tabs') {
109 if (event.key === 'left') surface.setState({ ...s, tab: Math.max(0, s.tab - 1) })
110 else if (event.key === 'right') surface.setState({ ...s, tab: Math.min(tabs.length - 1, s.tab + 1) })
111 else if (event.key === 'down') surface.setState({ ...s, zone: 'list' })
112 else if (event.key === 'return' || event.key === ' ') {
113 const t = tabs[s.tab]
114 if (t) surface.post({ tab: t.id })
115 }
116 return
117 }
118 // Headings are skipped in whichever direction the cursor moves.
119 const skip = (from: number, dir: 1 | -1): number => {
120 let i = from
121 while (i >= 0 && i < count && rows[i]?.heading) i += dir
122 return i < 0 || i >= count ? from - dir : i
123 }
124 if (event.key === 'up') {
125 const first = rows.findIndex(r => !r.heading)
126 if (s.cursor <= Math.max(0, first) && tabs.length > 0) {
127 const active = Math.max(0, tabs.findIndex(t => t.id === props.activeTab))
128 surface.setState({ ...s, zone: 'tabs', tab: active })
129 } else surface.setState({ ...s, cursor: skip(Math.max(0, s.cursor - 1), -1) })
130 } else if (event.key === 'down') surface.setState({ ...s, cursor: skip(Math.min(count - 1, s.cursor + 1), 1) })
131 else if (event.key === 'pageup') surface.setState({ ...s, cursor: Math.max(0, s.cursor - room) })
132 else if (event.key === 'pagedown') surface.setState({ ...s, cursor: Math.min(count - 1, s.cursor + room) })
133 else if (event.key === 'home') surface.setState({ ...s, cursor: 0 })
134 else if (event.key === 'end') surface.setState({ ...s, cursor: count - 1 })
135 else if (event.key === 'return' || event.key === ' ' || event.key === 'right' || event.key === 'left') {
136 const row = rows[s.cursor]
137 if (row) openRow(s, row, event.key === 'left')
138 }
139 })
140 surface.onPointer(event => {
141 if (event.type !== 'down') return
142 const s = surface.state ?? fresh()
143 const { tabs, headerLines, lines } = now()
144 if (tabs.length > 0 && event.y === 0) {
145 const i = tabAt(event.x)
146 if (i >= 0) {
147 surface.setState({ ...s, zone: 'tabs', tab: i })
148 surface.post({ tab: tabs[i]!.id })
149 }
150 return
151 }
152 const y = event.y - headerLines
153 if (y < 0) return
154 const hit = lines[s.top + y]
155 if (!hit) return
156 if (hit.index !== s.cursor || s.zone !== 'list') {
157 surface.setState({ ...s, zone: 'list', cursor: hit.index })
158 return
159 }
160 openRow(s, hit.row, false)
161 })
162 } else if (top !== state.top) {
163 surface.setState({ ...state, top })
164 }
165
166 const glyph = SPIN[state.frame] ?? '·'
167 const accent = props.accent ?? 'claude'
168 const shown = lines.slice(top, top + room)
169 return (
170 <Box flexDirection="column">
171 {tabs.length > 0 ? (
172 <Box flexDirection="row" columnGap={2}>
173 {tabs.map((t, i) => {
174 const active = t.id === props.activeTab
175 const focused = state.zone === 'tabs' && state.tab === i
176 // `[ Name ]`: the active page's name bold white in orange brackets, the rest dim; the focused
177 // one alternates its word with the spinner glyph.
178 const showGlyph = focused && state.frame % 4 >= 2
179 const inner = t.label.length
180 const left = Math.floor((inner - 1) / 2)
181 const word = showGlyph ? ' '.repeat(left) + glyph + ' '.repeat(inner - 1 - left) : t.label
182 const lit = active || focused
183 return (
184 <Box key={`tab-${t.id}`} flexDirection="row">
185 <Text bold={lit} color={lit ? accent : undefined} dimColor={!lit}>[ </Text>
186 <Text bold={lit} color={showGlyph ? accent : lit ? 'text' : undefined} dimColor={!lit}>{word}</Text>
187 <Text bold={lit} color={lit ? accent : undefined} dimColor={!lit}> ]</Text>
188 </Box>
189 )
190 })}
191 </Box>
192 ) : null}
193 {tabs.length > 0 ? <Text> </Text> : null}
194 {props.hint ? <Text dimColor>{props.hint}</Text> : null}
195 {props.hint ? <Text> </Text> : null}
196 {header.map((segs, i) => (
197 <Box key={`h-${i}`} flexDirection="row">
198 {segs.length === 0 ? <Text> </Text> : segs.map((sg, j) => <Text key={`h-${i}-${j}`} color={sg.c} dimColor={sg.d} bold={sg.b}>{sg.t}</Text>)}
199 </Box>
200 ))}
201 {shown.map((line, i) => {
202 const here = state.zone === 'list' && line.index === state.cursor && line.detail === undefined
203 if (line.detail !== undefined) {
204 return (
205 <Box key={`d-${line.row.id}-${i}`} flexDirection="row">
206 <Text dimColor>{' · '}{line.detail}</Text>
207 </Box>
208 )
209 }
210 const row = line.row
211 if (row.heading) {
212 return (
213 <Box key={`r-${row.id}`} flexDirection="row" paddingLeft={1}>
214 <Text> </Text>
215 <Text bold color={row.label.trim() ? 'text' : undefined}>{row.label ? ` ${row.label}` : ' '}</Text>
216 </Box>
217 )
218 }
219 const steps = row.action === 'cycle' || row.action === 'toggle'
220 const mark = row.action === 'expand' ? (state.open.includes(row.id) ? '▾' : '▸') : ''
221 return (
222 <Box key={`r-${row.id}`} flexDirection="row" columnGap={1} paddingLeft={1}>
223 <Text bold={here} color={here ? accent : undefined}>{here ? glyph : ' '}</Text>
224 <Text bold={here} color="text" dimColor={row.muted && !here} wrap="truncate-end">{row.label.padEnd(props.labelWidth)}</Text>
225 {steps ? <Text color={accent}>‹</Text> : null}
226 <Text bold={here} color={row.muted ? undefined : 'text'} dimColor={row.muted}>{row.value}</Text>
227 {steps ? <Text color={accent}>›</Text> : null}
228 {mark ? <Text color={accent}>{mark}</Text> : null}
229 {row.note ? <Text dimColor wrap="truncate-end">{row.note}</Text> : null}
230 </Box>
231 )
232 })}
233 {lines.length > room ? <Text dimColor>{` ${top + shown.length} of ${lines.length} · ↑↓ scroll`}</Text> : null}
234 </Box>
235 )
236}
237
238export default Menu
239hooks/voice-envelope.ts 5 lines1// The mouth for sounds/idle-voice.mp3: one value per 70 ms (0 closed, 1 open, 2 wide), from its loudness.
2// Regenerate with the launch project's tools/voice-envelope.mjs when the clip changes.
3export const VOICE_STEP_MS = 70
4export const VOICE_FRAMES: readonly number[] = [0,0,0,0,0,0,2,2,1,1,2,2,1,1,1,0,0,0,0,1,1,1,2,1,2,2,2,1,1,1,0,0,1,2,1,1,1,1,2,2,1,1,1,1,1,1,2,2,2,0,0,0,0,0,1,2,2,1,1,1,1,1,1,2,1,1,1,2,2,1,1,2,2,2,2,2,2,1,2,1,1,1,2,2,2,2,0,0,1,2,2,1,1,1,1,1,1,2,2,2,1,1,1,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,2,1,1,0,0,0,1,1,1,0,0,0,1,2,2,2,1,2,1,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,1,2,2,2,1,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,2,1,2,2,1,1,1,0,0,0,0,0,1,1,2,1,1,2,2,1,1,1,1,0,0,1,1,1,1,1,1,2,2,1,1,1,1,1,2,2,1,1,0,0,1,1,1,1,0,0,0,0,1,1,1,2,1,0,0,0,0,0,0,1,1,1,1,1,1,1,0,0,1,2,2,2,1,2,0,0,0,0,1,1,1,0,0,2,2,1,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,1,2,1,1,1,1,0,0,0,1,2,1,0,0,1,1,1,2,2,2,1,1,1,1,1,1,1,2,1,0,0,0,0,1,0,0,0]
5hooks/chip.tsx 46 lines1import type { ClientModule } from 'claude-code'
2
3// A coloured `[ label ]` chip whose word and brackets share one colour (a Button cannot). While lit, a
4// highlight sweeps across the letters, the shimmer Claude Code gives the word "ultracode". A click
5// posts `{ press: true }`.
6export type ChipProps = { label: string; color: string; glow?: string; lit: boolean; width?: number; bracketColor?: string }
7type ChipState = { frame: number }
8
9const Chip: ClientModule<ChipProps, ChipState> = (props, surface) => {
10 const { Box, Text } = surface.elements
11 const state = surface.state ?? { frame: 0 }
12 const span = props.label.length + 6 // the sweep runs off both ends before it comes round
13 if (surface.state === undefined) {
14 surface.setState({ frame: 0 })
15 surface.every(90, () => {
16 const s = surface.state ?? { frame: 0 }
17 surface.setState({ frame: (s.frame + 1) % span })
18 })
19 surface.onPointer(event => {
20 if (event.type === 'down') surface.post({ press: true })
21 })
22 }
23 const glow = props.glow ?? props.color
24 const head = state.frame - 3
25 // Centred to `width` when given, so the chip lines up with bracket buttons beside it.
26 const pad = Math.max(0, (props.width ?? props.label.length) - props.label.length)
27 const padL = ' '.repeat(Math.floor(pad / 2))
28 const padR = ' '.repeat(pad - Math.floor(pad / 2))
29 const letters = props.lit
30 ? [...props.label].map((ch, i) => {
31 const d = Math.abs(i - head)
32 // The letter under the sweep glows, its two neighbours a little, the rest hold the base colour.
33 return <Text key={`c${i}`} bold color={d === 0 ? glow : props.color} dimColor={false}>{ch}</Text>
34 })
35 : [<Text key="w" dimColor>{props.label}</Text>]
36 return (
37 <Box flexDirection="row">
38 <Text bold={props.lit} color={props.lit ? props.bracketColor ?? props.color : undefined} dimColor={!props.lit}>{`[ ${padL}`}</Text>
39 {letters}
40 <Text bold={props.lit} color={props.lit ? props.bracketColor ?? props.color : undefined} dimColor={!props.lit}>{`${padR} ]`}</Text>
41 </Box>
42 )
43}
44
45export default Chip
46hooks/mascot.tsx 404 lines1import type { ClientModule } from 'claude-code'
2
3// The mascot column: his floating words, the mascot in his shirt colour, and whatever reaction he is
4// in the middle of. A click posts `{ tap: true }` and the hooks roll a reaction, handed back as `react`
5// (a new `id` starts it): `shake` (a dizzy WHOOAH), `pacman`, `chef`, `storm`, or `spider` (one spins a web
6// in the top-left corner and drops down on a thread while he looks up), or `night` (a scarf, a dark sky with a
7// moon, twinkling stars and fireflies); the snake chase plays on the band. `climb` is a counter:
8// each new value starts the ladder gag. During a storm, a click pops the umbrella early.
9export type Reaction = { kind: 'shake' | 'pacman' | 'chef' | 'storm' | 'spider' | 'night'; id: number }
10export type MascotProps = {
11 pose: string[]
12 bubble: string[]
13 bubbleColor?: string
14 color: string
15 climb: number
16 rows: number
17 leanPoses: string[][]
18 dancePoses: string[][]
19 react: Reaction | null
20 ultra?: boolean // ultracode is on: he is the purple sparkler mascot from the engine's own banner
21 // A voice clip playing now: each new id starts it, and his head lifts off his body by frames[i] rows every stepMs.
22 talk: { id: number; frames: readonly number[]; stepMs: number } | null
23}
24type MascotState = {
25 seenClimb: number
26 climbTick: number
27 climbing: boolean
28 seenReact: number
29 tick: number
30 reacting: Reaction['kind'] | null
31 umbrellaAt: number
32 seenTalk: number
33 talkTick: number
34 talking: boolean
35 talkStartedAt: number
36}
37
38const latestBySurface = new WeakMap<object, MascotProps>()
39const TICK_MS = 140
40const PINK = '#ff5fa2'
41const ULTRA = '#a78bfa' // the banner mascot's purple while ultracode is on
42const SPLASH = ['·', '✢', '✳', '✶', '✻', '✽'] // the menus' spinner frames, as rain splashing on the umbrella
43const SPARK = '#e8c547' // the sparkler's gold
44const fresh = (p: MascotProps): MascotState => ({ seenClimb: p.climb, climbTick: 0, climbing: false, seenReact: p.react?.id ?? 0, tick: 0, reacting: null, umbrellaAt: -1, seenTalk: p.talk?.id ?? 0, talkTick: 0, talking: false, talkStartedAt: 0 })
45const REACT_TICKS: Record<Reaction['kind'], number> = { shake: 18, pacman: 26, chef: 30, storm: 96, spider: 70, night: 80 }
46
47const Mascot: ClientModule<MascotProps, MascotState> = (props, surface) => {
48 const { Box, Text } = surface.elements
49 latestBySurface.set(surface, props)
50 const state = surface.state ?? fresh(props)
51 const rows = props.rows
52 const roomAbove = Math.max(0, rows - props.pose.length) // rows free above him at rest
53
54 // Ladder gag timeline, in ticks.
55 const H = roomAbove
56 const APPEAR = 3
57 const TOP = Math.max(1, H - 1)
58 const CLIMB = APPEAR + TOP * 2
59 const HOLD = CLIMB + 2
60 const GONE = HOLD + 4
61 const FLOAT = GONE + TOP * 2
62 const LAND = FLOAT + 3
63 const END = LAND + 1
64
65 if (surface.state === undefined) {
66 surface.setState(fresh(props))
67 surface.every(TICK_MS, () => {
68 const s = surface.state ?? fresh(props)
69 const p = latestBySurface.get(surface) ?? props
70 let n = { ...s }
71 // Start a ladder when a new climb arrives and nothing else is playing.
72 if (p.climb !== s.seenClimb) {
73 n = { ...n, seenClimb: p.climb, climbTick: 0, climbing: H >= 3 && !s.reacting }
74 } else if (s.climbing) {
75 const t = s.climbTick + 1
76 n = { ...n, climbTick: t, climbing: t < END }
77 }
78 // Start a reaction when a new one arrives and nothing is playing; one in progress always plays out.
79 const id = p.react?.id ?? 0
80 if (s.reacting) {
81 const t = s.tick + 1
82 const still = t < REACT_TICKS[s.reacting]
83 n = { ...n, tick: t, reacting: still ? s.reacting : null }
84 if (!still) surface.post({ done: true })
85 } else if (id !== s.seenReact) {
86 n = { ...n, seenReact: id, tick: 0, reacting: !n.climbing && p.react ? p.react.kind : null, umbrellaAt: -1 }
87 if (!n.reacting) surface.post({ done: true })
88 }
89 surface.setState(n)
90 })
91 // The mouth follows the clip by elapsed time, not by ticks, so a timer running late never lets it drift.
92 surface.every(50, () => {
93 const s = surface.state ?? fresh(props)
94 const p = latestBySurface.get(surface) ?? props
95 const id = p.talk?.id ?? 0
96 if (s.talking && p.talk) {
97 const t = Math.floor((Date.now() - s.talkStartedAt) / p.talk.stepMs)
98 const still = t < p.talk.frames.length
99 if (t !== s.talkTick || !still) surface.setState({ ...s, talkTick: t, talking: still })
100 if (!still) surface.post({ talked: true })
101 } else if (id !== s.seenTalk) {
102 surface.setState({ ...s, seenTalk: id, talkTick: 0, talking: id !== 0 && !s.reacting && !s.climbing, talkStartedAt: Date.now() })
103 }
104 })
105 surface.onPointer(event => {
106 if (event.type !== 'down') return
107 const s = surface.state ?? fresh(props)
108 // In the rain, a click pops the umbrella; otherwise the hooks roll a reaction.
109 if (s.reacting === 'storm' && s.tick >= 12 && s.umbrellaAt < 0) {
110 surface.setState({ ...s, umbrellaAt: s.tick })
111 return
112 }
113 surface.post({ tap: true })
114 })
115 }
116
117 const pad = (s: string) => {
118 const gap = Math.max(0, surface.columns - s.length)
119 const left = Math.floor(gap / 2)
120 return ' '.repeat(left) + s + ' '.repeat(gap - left)
121 }
122 const nudge = (l: string, dir: number) => (dir === 0 ? l : dir < 0 ? `${l.slice(1)} ` : ` ${l}`.slice(0, l.length))
123
124 // ---- the ladder gag: where he stands and what shows
125 let lift = 0
126 let ladder = false
127 let startled = false
128 let chute: 'open' | 'crumpled' | null = null
129 let sway = 0
130 let pose = props.pose
131 if (state.climbing) {
132 const t = state.climbTick
133 if (t < APPEAR) ladder = true
134 else if (t < CLIMB) {
135 ladder = true
136 lift = Math.min(TOP, Math.floor((t - APPEAR) / 2) + 1)
137 const step = Math.floor((t - APPEAR) / 2)
138 const lean = props.leanPoses[step % Math.max(1, props.leanPoses.length)] ?? props.pose
139 pose = lean.map(l => nudge(l, step % 2 === 0 ? 1 : -1))
140 } else if (t < HOLD) {
141 ladder = true
142 lift = TOP
143 } else if (t < GONE) {
144 lift = TOP
145 startled = true
146 } else if (t < FLOAT) {
147 chute = 'open'
148 lift = Math.max(0, TOP - Math.floor((t - GONE) / 2) - 1)
149 sway = Math.floor((t - GONE) / 2) % 2 === 0 ? -1 : 1
150 } else if (t < LAND) chute = 'crumpled'
151 }
152 if (sway !== 0) pose = pose.map(l => nudge(l, sway))
153
154 // A row of cells with a colour each, as runs of one colour.
155 const runsOf = (chars: string[], cols: string[], dims?: boolean[]): { t: string; c: string; d?: boolean }[] => {
156 const out: { t: string; c: string; d?: boolean }[] = []
157 chars.forEach((ch, x) => {
158 const c = cols[x] ?? 'text'
159 const d = dims?.[x] ?? false
160 const last = out[out.length - 1]
161 if (last && last.c === c && (last.d ?? false) === d) last.t += ch
162 else out.push({ t: ch, c, d })
163 })
164 return out
165 }
166 // ---- reactions: lines drawn above him, and his own pose while it plays
167 type Line = { t: string; c?: string; d?: boolean; b?: boolean; segs?: { t: string; c: string; d?: boolean }[] }
168 let overhead: Line[] = []
169 let poseColors: (string | undefined)[] = [undefined, undefined, undefined] // per row; undefined = usual
170 let words: Line | null = null
171 const k = state.tick
172 if (state.reacting === 'shake') {
173 // Shaken, not tickled: a fast run through the poses, a column each way, and a dizzy WHOOAH.
174 pose = (props.dancePoses[k % Math.max(1, props.dancePoses.length)] ?? pose).map(l => nudge(l, k % 2 === 0 ? 1 : -1))
175 words = { t: k < 12 ? 'WHOOAH' : '@_@', c: 'text', b: true }
176 } else if (state.reacting === 'pacman') {
177 // He turns into a pacman and chomps.
178 const open = k % 2 === 0
179 pose = open ? ['▗▟█████▙▖', '▐█████▀▘ ', '▝▜█████▛▘'] : ['▗▟█████▙▖', '▐███████▌', '▝▜█████▛▘']
180 poseColors = ['warning', 'warning', 'warning']
181 } else if (state.reacting === 'chef') {
182 // A chef's hat, and three ingredients tossed up and caught in turn: a red tomato, a green pepper, a die.
183 const hatRows: Line[] = [{ t: '▗▄▄▄▄▄▖', c: 'text' }, { t: '▐█████▌', c: 'text' }, { t: ' ▀▀▀▀▀ ', c: 'text', d: true }]
184 const items: { ch: string; c: string; x: number; phase: number }[] = [
185 { ch: '●', c: 'error', x: 2, phase: 0 },
186 { ch: '▲', c: 'success', x: 5, phase: 3 },
187 { ch: '⚄', c: 'text', x: 8, phase: 6 },
188 ]
189 const flight = Math.max(1, Math.min(4, roomAbove - hatRows.length))
190 const sky: string[][] = Array.from({ length: flight }, () => Array.from({ length: 11 }, () => ' '))
191 const skyColors: string[][] = Array.from({ length: flight }, () => Array.from({ length: 11 }, () => 'text'))
192 for (const it of items) {
193 const u = (k + it.phase) % 9 // 0..8: up 4, down 4
194 const height = u <= 4 ? u : 8 - u
195 const y = flight - 1 - Math.min(flight - 1, height)
196 if (u === 0) continue // in the hand
197 const row = sky[y]
198 if (row) {
199 row[it.x] = it.ch
200 skyColors[y]![it.x] = it.c
201 }
202 }
203 overhead = [...sky.map((row, i) => ({ t: row.join(''), c: skyColors[i]!.find(c => c !== 'text') ?? 'text' })), ...hatRows]
204 } else if (state.reacting === 'spider') {
205 // A web is spun stroke by stroke in the top-left corner, then the spider, the spinner glyph, drops
206 // on a thin thread to just above his head, dangles, and climbs back up. He looks up the whole time.
207 const SPIN = ['·', '✢', '✳', '✶', '✻', '✽']
208 const W = 11
209 const blank = () => Array.from({ length: W }, () => ' ')
210 const grid: string[][] = Array.from({ length: Math.max(0, roomAbove) }, blank)
211 const colors: string[][] = Array.from({ length: Math.max(0, roomAbove) }, () => Array.from({ length: W }, () => 'inactive'))
212 const put = (x: number, y: number, ch: string, c = 'inactive') => {
213 if (y >= 0 && y < grid.length && x >= 0 && x < W) {
214 grid[y]![x] = ch
215 colors[y]![x] = c
216 }
217 }
218 // The web: nine strokes around a hub at (1, 1), one stroke a tick.
219 const strokes: [number, number, string][] = [[1, 1, '┼'], [0, 0, '╲'], [2, 0, '╱'], [0, 1, '─'], [2, 1, '─'], [1, 0, '│'], [0, 2, '╱'], [2, 2, '╲'], [1, 2, '│']]
220 const SPIN_END = strokes.length
221 const dropRows = Math.max(1, roomAbove - 2) // from under the hub to just above his head
222 const DROP_END = SPIN_END + dropRows * 2
223 const DANGLE_END = DROP_END + 10
224 const CLIMB_END = DANGLE_END + dropRows * 2
225 const fade = k >= CLIMB_END
226 const drawn = Math.min(strokes.length, k + 1)
227 if (!fade) for (const [x, y, ch] of strokes.slice(0, drawn)) put(x, y, ch)
228 let spiderRow = -1
229 if (k >= SPIN_END && k < DROP_END) spiderRow = 1 + Math.floor((k - SPIN_END) / 2) + 1
230 else if (k >= DROP_END && k < DANGLE_END) spiderRow = 1 + dropRows - ((k - DROP_END) % 6 >= 3 ? 1 : 0)
231 else if (k >= DANGLE_END && k < CLIMB_END) spiderRow = 1 + dropRows - Math.floor((k - DANGLE_END) / 2) - 1
232 if (spiderRow >= 2) {
233 for (let r = 2; r < spiderRow; r += 1) put(1, r, '│')
234 put(1, spiderRow, SPIN[k % SPIN.length] ?? '·', 'text')
235 }
236 overhead = grid.map((row, i) => ({ t: row.join(''), c: colors[i]!.includes('text') ? undefined : 'inactive' }))
237 // Per-cell colour is not available on a line, so the spider's row is drawn in the plain text colour.
238 overhead = overhead.map((line, i) => (colors[i]!.includes('text') ? { ...line, c: 'text' } : line))
239 // Looking up while the web is spun and the spider comes down; once it reaches the bottom his look
240 // returns to normal.
241 const still = props.dancePoses[0] ?? props.pose
242 pose = k < DROP_END ? still.map((l, i) => (i === 0 ? l.replace('▛', '▄').replace('▜', '▄') : l)) : still
243 } else if (state.reacting === 'night') {
244 // Night falls: a thin scarf, a moon in the top-left corner, stars twinkling across the sky, and three
245 // fireflies drifting about as pulsing dots.
246 const W = 11
247 const rows = Math.max(0, roomAbove)
248 const chars: string[][] = Array.from({ length: rows }, () => Array.from({ length: W }, () => ' '))
249 const cols: string[][] = Array.from({ length: rows }, () => Array.from({ length: W }, () => 'text'))
250 const dims: boolean[][] = Array.from({ length: rows }, () => Array.from({ length: W }, () => false))
251 const dusk = Math.min(1, k / 8) // the sky fills in over the first beats
252 for (let y = 0; y < rows; y += 1) {
253 for (let x = 0; x < W; x += 1) {
254 const seed = (x * 13 + y * 7) % 17
255 // Stars come out a few at a time as dusk falls, each in a fixed place.
256 if (seed < 4 && dusk >= (seed + 1) / 4) {
257 // Each star keeps its own twinkle rhythm.
258 const twinkle = (k + seed * 3) % 9
259 chars[y]![x] = twinkle < 2 ? '✦' : twinkle < 5 ? '·' : ' '
260 cols[y]![x] = 'text'
261 dims[y]![x] = twinkle >= 2
262 }
263 }
264 }
265 if (rows > 0 && dusk >= 0.5) {
266 chars[0]![1] = '☾'
267 cols[0]![1] = 'text'
268 dims[0]![1] = false
269 }
270 // Fireflies wander on slow loops and pulse through the spinner frames.
271 const SPINF = ['·', '✢', '✳', '✶', '✻', '✽']
272 const flies = [
273 { phase: 0, cx: 5, cy: Math.max(1, rows - 2) },
274 { phase: 2.1, cx: 8, cy: Math.max(1, Math.floor(rows / 2)) },
275 { phase: 4.2, cx: 3, cy: Math.max(1, rows - 3) },
276 ]
277 if (rows > 1 && k >= 6) {
278 flies.forEach((f, i) => {
279 const x = Math.round(f.cx + 2.5 * Math.sin(k / 6 + f.phase))
280 const y = Math.round(f.cy + 1.2 * Math.cos(k / 9 + f.phase))
281 if (y >= 0 && y < rows && x >= 0 && x < W) {
282 chars[y]![x] = SPINF[(k + i * 2) % SPINF.length] ?? '·'
283 cols[y]![x] = i === 1 ? 'success' : 'warning'
284 dims[y]![x] = false
285 }
286 })
287 }
288 overhead = chars.map((row, y) => ({ t: row.join(''), segs: runsOf(row, cols[y]!, dims[y]!) }))
289 // The scarf: a thin knitted row between head and body, with a tail off to one side.
290 const still = props.dancePoses[0] ?? props.pose
291 pose = [still[0] ?? '', ' ≈≈≈≈≈≈≈▖', still[1] ?? '', still[2] ?? '']
292 poseColors = ['claude', 'error', undefined, 'claude']
293 } else if (state.reacting === 'storm') {
294 // A white cloud darkens to grey, then rains blue drops on him; later, or on a click, an umbrella
295 // opens over his head and the drops bounce off to the sides.
296 const grey = k >= 8
297 const raining = k >= 12
298 // The umbrella always comes out: a dozen ticks into the rain, or at once on a click.
299 const umbrellaAt = state.umbrellaAt >= 0 ? state.umbrellaAt : 30
300 const umbrella = k >= umbrellaAt
301 // He glances up at the sky for a few beats before the umbrella comes out, then looks down again.
302 const lookingUp = k >= umbrellaAt - 4 && k < umbrellaAt
303 // A blank row above the cloud keeps it a little lower in the column.
304 const cloud: Line[] = [{ t: ' ' }, { t: ' ▗▄▄▟▙▄▖ ', c: grey ? 'inactive' : 'text' }, { t: '▐███████▌', c: grey ? 'inactive' : 'text' }]
305 const brollyRows = umbrella ? 2 : 0
306 const rainRows = Math.max(1, roomAbove - cloud.length - brollyRows)
307 // Rain falls across the whole width down to the umbrella. On the row just above the canopy, a few
308 // white dots flicker where drops splash on it.
309 const rain: Line[] = Array.from({ length: rainRows }, (_, r) => {
310 if (!raining) return { t: ' ' }
311 const lastRow = r === rainRows - 1
312 const cells = Array.from({ length: 11 }, (_, x) => {
313 if (umbrella && lastRow && (x * 3 + k) % 5 === 0 && x > 0 && x < 10) return SPLASH[(x + k) % SPLASH.length] ?? '·'
314 const falling = (x * 7 + r * 3 + k) % 4 === 0
315 return falling ? '╎' : ' '
316 })
317 const splashy = umbrella && lastRow && cells.some(ch => SPLASH.includes(ch))
318 if (!splashy) return { t: cells.join(''), c: 'ide' }
319 return { t: cells.join(''), segs: runsOf(cells, cells.map(ch => (SPLASH.includes(ch) ? 'text' : 'ide'))) }
320 })
321 const brolly: Line[] = umbrella ? [{ t: '▗▄▟█████▙▄▖', c: 'error' }, { t: ' ┃ ', c: 'text' }] : []
322 const stack = [...cloud, ...rain, ...brolly]
323 overhead = stack.length > roomAbove ? stack.slice(stack.length - roomAbove) : stack
324 // He stands dead still in the weather, looking up just before the umbrella.
325 const still = props.dancePoses[0] ?? props.pose
326 pose = lookingUp ? still.map((l, i) => (i === 0 ? l.replace('▛', '▄').replace('▜', '▄') : l)) : still
327 }
328
329 // ---- talking: his head lifts one row off his body while the voice is loud enough, an empty gap between
330 let mouthRows = 0
331 if (state.talking && props.talk) {
332 const still = props.dancePoses[0] ?? props.pose
333 mouthRows = rows > still.length && (props.talk.frames[state.talkTick] ?? 0) > 0 ? 1 : 0
334 pose = still
335 }
336
337 // ---- the parachute canopy sized to the room above him
338 const roomNow = Math.max(0, rows - props.pose.length - lift)
339 const canopyRows = chute === 'open' ? Math.max(1, Math.min(4, roomNow)) : 0
340 const CANOPY = [' ▄▟█████▙▄ ', '▐█▓▒░▒▓█▌', ' ╲ ╲ │ ╱ ╱ ', ' ╲ ╲│╱ ╱ ']
341 const CANOPY_COLORS = ['error', 'text', 'text', 'text']
342 const canopy = CANOPY.slice(4 - canopyRows)
343 const canopyColors = CANOPY_COLORS.slice(4 - canopyRows)
344 if (startled && roomNow < 1) startled = false
345
346 // Only the idle mascot wears ultracode's purple and sparkler; reactions and the ladder use his usual colours.
347 const ultraIdle = props.ultra === true && !state.reacting && !state.climbing && !state.talking
348 // ---- what sits above him this frame, newest reason first
349 const bubbleLines: Line[] = words ? [words, { t: ' ' }] : props.bubble.map(l => ({ t: l, c: props.bubbleColor ?? 'text', b: true }))
350 const aboveLines: Line[] = chute === 'open'
351 ? canopy.map((t, i) => ({ t, c: canopyColors[i], d: i >= canopy.length - 2 && canopy.length > 2 }))
352 : chute === 'crumpled'
353 ? [{ t: '▁▂▃▂▁▂▃▂▁', c: 'error', d: true }]
354 : overhead.length > 0
355 ? overhead.slice(-roomAbove)
356 : bubbleLines
357 const stackAbove = aboveLines.length + (startled ? 1 : 0)
358 // Filler rows are emitted only while the ladder plays, so at rest the column takes the row's height.
359 const above = state.climbing ? Math.max(0, rows - stackAbove - props.pose.length - lift) : 0
360 const below = lift
361 const belowStart = above + stackAbove + props.pose.length
362 const rung = (row: number) => (row % 2 === 0 ? '┣━┫' : '┃ ┃')
363 return (
364 <Box flexDirection="column" justifyContent="flex-end" width="100%" height="100%">
365 {Array.from({ length: above }, (_, i) => (
366 <Text key={`a${i}`} color="claude" dimColor>{pad(ladder ? rung(i) : ' ')}</Text>
367 ))}
368 {startled ? <Text color="error" bold>{pad('!')}</Text> : null}
369 {aboveLines.map((line, i) =>
370 line.segs ? (
371 <Box key={`o${i}`} flexDirection="row">
372 {line.segs.map((sg, j) => <Text key={`o${i}-${j}`} color={sg.c} dimColor={sg.d}>{sg.t}</Text>)}
373 </Box>
374 ) : (
375 <Text key={`o${i}`} color={line.c} dimColor={line.d} bold={line.b}>{pad(line.t)}</Text>
376 ),
377 )}
378 {pose.map((line, i) =>
379 i === 1 && mouthRows > 0 ? (
380 <Box key={`m${i}`} flexDirection="column">
381 {Array.from({ length: mouthRows }, (_, j) => (
382 <Text key={`mouth${j}`}>{pad('')}</Text>
383 ))}
384 <Text color={poseColors[i] ?? (i === pose.length - 2 ? props.color : 'claude')}>{pad(line)}</Text>
385 </Box>
386 ) : i === 0 && ultraIdle ? (
387 // The head stays where it is; the gold sparkler sits in the two cells after it.
388 <Box key={`m${i}`} flexDirection="row">
389 <Text color={ULTRA}>{pad(line).slice(0, pad(line).length - 2)}</Text>
390 <Text color={SPARK}>/✦</Text>
391 </Box>
392 ) : (
393 <Text key={`m${i}`} color={poseColors[i] ?? (ultraIdle ? ULTRA : i === pose.length - 2 ? props.color : 'claude')} bold={state.reacting === 'shake'}>{pad(line)}</Text>
394 ),
395 )}
396 {Array.from({ length: below }, (_, i) => (
397 <Text key={`u${i}`} color="claude" dimColor>{pad(ladder ? rung(belowStart + i) : ' ')}</Text>
398 ))}
399 </Box>
400 )
401}
402
403export default Mascot
404