SLOPSHOPPER

Claude Dock

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

newpanebandspinnerguardcommand
v0.1.0MITupdated 2026-10-08New-Retr0/claude-dock/cc-dock
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-dock
│ ┃ cc-dock-mcp ✕ › fix the failing auth test and add an audit log call │ ┃ MCP 0 servers · 0 tools │ ┃ ⏺ Read(src/auth.ts) │ ┃ click the list · ↑↓ move · → opens a ⎿ Read 6 lines │ ┃ server, ← closes it · esc leaves ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ no MCP tools listed ⏺ Bash(bun test) │ ┃ ▣ client module ./menu.tsx ⎿ 3 pass, 1 fail │ ┃ to connect, sign in or disable a server, │ ┃ type /mcp ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ Close ] │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /snake │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ─────────────────────────────────────────────────────────────────────────────────────────────────── ▐▛███▜▌ ▝▜█████▛▘ ▘▘ ▝▝ ▄▀▄ ▄▀▄ ▄▀▖ ▀▀▀ ▀▀▀ ◉▘ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
─────────────────────────────────────────────────────────────────────────────────────────────────── ▐▛███▜▌ ▝▜█████▛▘ ▘▘ ▝▝ ▄▀▄ ▄▀▄ ▄▀▖ ▀▀▀ ▀▀▀ ◉▘
Pane · cc-dock-mcp
MCP 0 servers · 0 tools click the list · ↑↓ move · → opens a server, ← closes it · esc leaves no MCP tools listed ▣ client module ./menu.tsx to connect, sign in or disable a server, type /mcp [ Close ]
Pane · cc-dock-config
▣ client module ./menu.tsx [ Close ]
README

Claude Dock

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

At a glance

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   ]

What it shows

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

  • PLAN shows your subscription's five-hour and weekly windows, each with its reset time. If your plan also has a weekly limit for one model, that model gets its own row under the week. A headline sums up your pace against the next reset.
  • THIS CHAT shows how full this conversation's context is, how many tokens that is, and what the chat has cost so far. Subagents get their own rows while they run.
  • Model names the model actually answering, read live from the session, so a /model switch shows within a second.
  • SHELLS lists the background shells and tasks running now, each with its latest line of output. Shells started before the dock loaded count too.
  • The button row opens Settings and your MCP servers, switches Fast mode, runs Compact after a confirm, and folds the dock to Slim.

Every shell, live

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

Effort in one click

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

Make it yours

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

  • Header: show or hide the plan headline.
  • Top line: colour, style (thin, double, heavy and more), a wave shimmer, and alternating colours.
  • Avatar: show or hide him, and pick his shirt.
  • UI: the theme colour for every bracket, button and menu accent.
  • Shells: run servers and watchers in the background, covered above.
  • Sound: covered below.

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.

Sounds

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:

SoundCharacter
Successa rising three-note jingle
Pinga crisp UI ping
Bubblea round bloop
Chimea bright two-tone confirmation
Glassa clean glass tap
Confirma soft confirming blip
Questiona curious rising query
Risean upward sweep
Dropa low drop
Plucka short pluck

Everything lives under Settings → Dock → SOUND:

  • Notifications switches every chime on or off at once.
  • The three moments each pick their own sound, and every choice plays as you cycle to it.
  • Volume sets how loud the chimes are.
  • Robot voice switches one of the dock's surprises on or off. We'll let you find out which.

The finished sound waits until no background work is still running, so you hear it once, when it's really done.

Slim when you need the room

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

A few surprises

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

What it reads, writes and sends

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.

Where it runs

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.

Source 5 files
hooks/register.tsx 2270 lines
1import 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 lines
1import 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
239
hooks/voice-envelope.ts 5 lines
1// 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]
5
hooks/chip.tsx 46 lines
1import 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
46
hooks/mascot.tsx 404 lines
1import 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