A card above the prompt: context, limits, cache and API cost, model and effort, a quote, and an animated pet (cat or dog) that grows with your context.

A Claude Code mod that puts a card above your prompt.
On the left: your session length, the weather, the model and its effort, what Claude is doing (thinking or working), and Rocky. Rocky is a cat or a dog who grows as your context fills up, naps when you're idle, types on a laptop while tools run, and reacts when you click him.
On the right: how full the context window is and what fills it, your 5-hour and weekly limits, how much the prompt cache saved you, what the session would cost at API prices, any subagents at work, and a fresh quote with every prompt.
Limit Coach: watches how fast your 5-hour window fills. If you'll run out before it resets, the 5H row says when ("⚠ full in ~25m") and a note at 80% and 95% suggests a lower /effort or a lighter model. When a full window resets, a Continue button on the card picks the work back up.
Buttons: one click sends a prompt you'd otherwise type: Recap (what are we doing, what's done, what's next), Update memory, Keep going, and Team update (a short status to paste to your team). Compact appears once the context passes 60% and runs /compact.
Why is the pet peach or pink? Its colour follows how full the context is: peach from 65%, pink from 85%. Click it then and it tells you ("I'm 88% full!", "Compact helps"); hover shows the share.
Click to change model and effort: click a cell of the effort meter under the weather to set that effort. Click the model name for a picker (Sonnet · Opus · Fable · Haiku).
Newest output: the newest image or video saved under your project this session, with Open and Folder buttons.
Render watch: while the GPU works (NVIDIA cards), a line shows its load, memory and how long it has been busy. When a job of a minute or more finishes, you get a note and the pet celebrates.
Wellness nudges: after 20 minutes of active work the pet closes its eyes and asks you to rest yours; after 60 it asks for a water break. Click the pet when you've done it. The pet chimes when a nudge or a finished GPU job needs you, and again every 5 minutes until you click; after a GPU job it keeps celebrating until then. /context-bar sounds off keeps it quiet. Time away counts as a break, so sessions that run for days don't matter. Change the minutes in /plugin configure, or turn them off with /context-bar nudges off.
The desk (wide windows, about 165 columns and up): a cozy night desk in fine pixel art beside the numbers, with a moonlit city window, a neon digital clock showing the real time, books, a plant, a cactus, a steaming mug and a lamp. Click the lamp to switch it off and on.
In Claude Code, type:
/plugin install context-bar --marketplace RenegadeRocks/claude-mods
Press y to add the marketplace, then pick the user scope so it shows in every session. It works right away.
| Command | What it does |
|---|---|
/context-bar off | Hides the card in every session until you turn it back on |
/context-bar on | Shows it again |
/context-bar pet dog | Rocky becomes a dog (remembered) |
/context-bar pet cat | Rocky becomes a cat (remembered) |
/context-bar sounds off | The pet stops chiming (remembered); sounds on brings it back |
/context-bar nudges off | No more eye and water nudges (remembered); nudges on brings them back |
/context-bar glow bright | The desk's clock and lamp glow at full strength (remembered); glow soft tones them down |
Run /plugin configure context-bar@renegaderocks to change these:
/cost total, split into input, output and cache.Made by RenegadeRocks with Claude Code.
hooks/register.tsx 1296 lines1// context-bar: a card above the prompt. On the left, the fun column (drawn by
2// ./fun-column.tsx on its own frame clock); on the right, the session's
3// numbers: context, rate limits, cache, API cost, model, subagents and a quote.
4
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register } from 'claude-code'
7
8import type {
9 AgentChip,
10 Ambient,
11 ContextSnap,
12 DeskMessage,
13 DeskProps,
14 FunMessage,
15 FunProps,
16 Limit,
17 Output,
18 Phase,
19 Pomodoro,
20 Spend,
21 Weather,
22} from '../types'
23import { BREAK_MS, COMPACT_DESK_WIDTH, DESK_WIDTH, FOCUS_MS } from './desk-art'
24import { NO_COACH, coachStep, coachTick, etaText, minutesToFull } from './coach'
25import { NO_TRACK, gpuStep, parseSmi } from './gpu'
26import { NO_WELLNESS, NUDGE_TOASTS, wellnessDone, wellnessTick } from './wellness'
27import {
28 allocate,
29 colorFor,
30 compact,
31 money,
32 prettyModel,
33 ratesFor,
34 resetsIn,
35 sessionLength,
36 shareCells,
37 shortName,
38} from './format'
39import { QUOTES, nextQuote } from './quotes'
40import {
41 BG,
42 BLUE,
43 BORDER,
44 GOLDS,
45 GREEN,
46 MAUVE,
47 MUTED,
48 PEACH,
49 TEAL,
50 TEXT,
51 TRACK,
52 YELLOW,
53 heatColor,
54 percentColor,
55 skyStyle,
56} from './theme'
57
58const NO_SPEND: Spend = {
59 input: 0,
60 output: 0,
61 cacheRead: 0,
62 cacheWrite: 0,
63 usdInput: 0,
64 usdOutput: 0,
65 usdRead: 0,
66 usdWrite: 0,
67 usdSaved: 0,
68}
69
70const snap = atom({ plugin: 'context-bar', key: 'snap' } as const, null)
71const isOn = atom({ plugin: 'context-bar', key: 'isOn' } as const, true)
72const phase = atom({ plugin: 'context-bar', key: 'phase' } as const, 'idle')
73const weather = atom({ plugin: 'context-bar', key: 'weather' } as const, null)
74const agents = atom({ plugin: 'context-bar', key: 'agents' } as const, [])
75const quote = atom({ plugin: 'context-bar', key: 'quote' } as const, '')
76const spend = atom({ plugin: 'context-bar', key: 'spend' } as const, NO_SPEND)
77const pet = atom({ plugin: 'context-bar', key: 'pet' } as const, 'cat')
78const coach = atom({ plugin: 'context-bar', key: 'coach' } as const, NO_COACH)
79const gpu = atom({ plugin: 'context-bar', key: 'gpu' } as const, null)
80const celebrate = atom({ plugin: 'context-bar', key: 'celebrate' } as const, null)
81const wellness = atom({ plugin: 'context-bar', key: 'wellness' } as const, NO_WELLNESS)
82const output = atom({ plugin: 'context-bar', key: 'output' } as const, null)
83const effortChoice = atom({ plugin: 'context-bar', key: 'effortChoice' } as const, null)
84const IDLE_POMODORO: Pomodoro = { phase: 'idle', endsAt: null, rounds: 0 }
85const goal = atom({ plugin: 'context-bar', key: 'goal' } as const, '')
86const pomodoro = atom({ plugin: 'context-bar', key: 'pomodoro' } as const, IDLE_POMODORO)
87const ambient = atom({ plugin: 'context-bar', key: 'ambient' } as const, 'off')
88
89// Layout, in terminal columns.
90const COLUMN_GAP = 3
91// below this the card drops the fun column and keeps the numbers
92const MIN_COLUMNS_FOR_FUN = 48
93// the pet sits beside its info rows from this terminal width, which saves rows
94const MIN_COLUMNS_FOR_SIDE = 140
95// the context label and numbers share the bar's row from this data width
96const MIN_DATA_FOR_ONE_CONTEXT_ROW = 60
97// the desk sits beside the rows under the context bar from this data width
98const MIN_DATA_FOR_DESK = 118
99// a narrower window (an editor with its sidebar open) gets a compact desk from here
100const MIN_DATA_FOR_COMPACT_DESK = 94
101const DESK_GAP = 2
102// the two limits share a row from this data width, and stack below it
103const MIN_DATA_FOR_ONE_LIMIT_ROW = 56
104const MIN_CELLS = 12
105const MAX_CELLS = 160
106// every meter's label takes this many columns, so the meters line up
107// the label column, wide enough for CONTEXT, so every row's value starts in line
108const LABEL = 8
109const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
110
111// Timing.
112const SPINNER_MS = 280
113const MINUTE_TICK_MS = 10_000
114const GPU_POLL_MS = 5000
115// an alert the pet is giving chimes again this often until it is clicked
116const REMIND_MS = 5 * 60_000
117// the order the desk's sound button steps through
118const AMBIENTS: Ambient[] = ['off', 'rain', 'storm', 'fire', 'focus']
119const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max']
120// newest-output search: these kinds of file, this deep, skipping these folders
121const MEDIA = /\.(png|jpe?g|webp|gif|mp4|mov|webm|mkv|avi)$/i
122const OUTPUT_DEPTH = 4
123const OUTPUT_MAX_DIRS = 300
124const SKIP_DIRS = new Set(['node_modules', '.git', 'venv', '.venv', '__pycache__', '.cache', '.next'])
125const WEATHER_REFRESH_MS = 15 * 60_000
126// the Compact button shows from this context share
127const COMPACT_FROM = 60
128
129// One-click prompts for what Satbir asks for most: [key, label, prompt].
130const ACTIONS: [key: string, label: string, prompt: string][] = [
131 [
132 'recap',
133 'Recap',
134 "Recap for me in a few short lines: what are we working on, what's done, what's in progress, " +
135 "what's next, and is anything blocked or broken? Plain words, no code.",
136 ],
137 [
138 'memory',
139 'Update memory',
140 'Update memory: save what future sessions should know from this one: decisions made, ' +
141 'where the work stands, and the next step. Keep each note short.',
142 ],
143 ['keep-going', 'Keep going', 'Keep going where you left off.'],
144 [
145 'team',
146 'Team update',
147 'Write a short status update about this work that I can paste to my team: what changed, ' +
148 "what's next, and anything they need to do. Plain words, 3 to 5 lines.",
149 ],
150]
151
152const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
153
154const AGENT_NAMES = [
155 'Pip', 'Mochi', 'Biscuit', 'Waffles', 'Noodle', 'Pixel', 'Nova', 'Zippy', 'Boba', 'Sprout',
156 'Maple', 'Cosmo', 'Tofu', 'Pogo', 'Comet', 'Ziggy', 'Fudge', 'Gizmo', 'Sunny', 'Bean',
157]
158
159/** '3m ago', '2h ago', 'just now'. */
160function ago(now: number, then: number): string {
161 const mins = Math.floor((now - then) / 60_000)
162 if (mins < 1) return 'just now'
163 return mins < 60 ? `${mins}m ago` : `${Math.floor(mins / 60)}h ago`
164}
165
166/**
167 * The fun column's width: from 140 columns wide enough for the pet beside its
168 * info rows; full from 100, compact from 70, smallest below, the pet stacked.
169 */
170function funColumns(columns: number): number {
171 return columns >= MIN_COLUMNS_FOR_SIDE ? 56 : columns >= 100 ? 34 : columns >= 70 ? 26 : 20
172}
173
174// What the timers need, mirrored here because a timer cannot read atoms.
175let isActive = false
176let inFlight = 0
177// numbers each phase change, so a write that lands late cannot undo a newer one
178let phaseSeq = 0
179let tick = 0
180let hasAgents = false
181let lastAgentsKey = ''
182// render watch: failed reads in a row (no nvidia-smi here after a few), a read
183// in flight, and the job being watched
184const SMI_GIVE_UP = 3
185let smiFailures = 0
186let isSmiBusy = false
187let gpuTrack = NO_TRACK
188// wellness: activity seen since the last tick, and when it last was
189let activitySeen = false
190let lastActivity = 0
191let isWindows: boolean | null = null
192// when the pet last chimed for an alert still waiting for a click
193let lastChime = 0
194// the ambient loop playing now: a stop for macOS, the player's stream on
195// Windows, and a count that moves on each change so an older loop ends itself
196let stopMacAmbient: AbortController | null = null
197let ambientStream: ReturnType<EngineInterface['process']['spawn']> | null = null
198let ambientSeq = 0
199const agentNames = new Map<string, string>()
200
201/** A fun name per subagent, kept for as long as the session lives. */
202function nameFor(id: string): string {
203 let name = agentNames.get(id)
204 if (!name) {
205 name = AGENT_NAMES[agentNames.size % AGENT_NAMES.length] ?? 'Pip'
206 agentNames.set(id, name)
207 }
208 return name
209}
210
211/** The model in use and its saved effort: this model's setting first, then the global one. */
212async function readModelInfo($: EngineInterface): Promise<{ model: string; effort: string | null }> {
213 let raw = ''
214 let settings: Record<string, unknown> = {}
215 try {
216 raw = await $.session.model()
217 } catch {
218 // keep going without a model name
219 }
220 try {
221 settings = (await $.settings.read()) as Record<string, unknown>
222 } catch {
223 // no settings, no effort
224 }
225 const model = prettyModel(raw)
226 const perModel = settings.modelSettings as Record<string, { effortLevel?: unknown }> | undefined
227 const own = perModel ? Object.entries(perModel).find(([k]) => prettyModel(k) === model)?.[1]?.effortLevel : undefined
228 const level = own ?? settings.effortLevel
229 return { model, effort: typeof level === 'string' ? level : null }
230}
231
232function limitOf(l: { percentUsed: number; resetsAt?: string } | undefined): Limit | null {
233 return l ? { percent: l.percentUsed, resetsAt: l.resetsAt } : null
234}
235
236async function refresh($: EngineInterface) {
237 try {
238 const { context, rateLimits, startedAt, cost } = await $.session.usage({ breakdown: 'summary' })
239 const b = context.breakdown
240 if (!b) return
241 const next: ContextSnap = {
242 percent: Math.round(b.percentage),
243 totalTokens: b.totalTokens,
244 maxTokens: b.rawMaxTokens,
245 rows: b.categories
246 .filter(c => c.kind !== 'deferred')
247 .map(c => ({ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind })),
248 fiveHour: limitOf(rateLimits.find(l => l.kind === 'five_hour')),
249 sevenDay: limitOf(rateLimits.find(l => l.kind === 'seven_day')),
250 startedAt,
251 costUsd: cost?.usd ?? null,
252 lastReply: b.apiUsage
253 ? {
254 input: b.apiUsage.input_tokens,
255 cacheRead: b.apiUsage.cache_read_input_tokens,
256 cacheWrite: b.apiUsage.cache_creation_input_tokens,
257 }
258 : null,
259 ...(await readModelInfo($)),
260 }
261 await update($, snap, () => next)
262 await coachReading($, next.fiveHour)
263 } catch {
264 // usage is unavailable before a session is bound; the card stays as it was
265 }
266}
267
268/** One turn's tokens, priced at its model's rates, added to the session's tally. */
269async function addSpend(
270 $: EngineInterface,
271 u: { model: string; input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number },
272) {
273 const [inRate, outRate, shortWriteRate, readRate] = ratesFor(u.model)
274 try {
275 // Claude Code keeps a subscription's cache for an hour, and an hour-long
276 // write costs 2x input; with an API key it keeps 5 minutes, at 1.25x. A
277 // reported 5-hour limit means a subscription.
278 const isSubscription = (await read($, snap))?.fiveHour != null
279 const writeRate = isSubscription ? inRate * 2 : shortWriteRate
280 await update($, spend, s => ({
281 input: s.input + u.input_tokens,
282 output: s.output + u.output_tokens,
283 cacheRead: s.cacheRead + u.cache_read_input_tokens,
284 cacheWrite: s.cacheWrite + u.cache_creation_input_tokens,
285 usdInput: s.usdInput + (u.input_tokens * inRate) / 1e6,
286 usdOutput: s.usdOutput + (u.output_tokens * outRate) / 1e6,
287 usdRead: s.usdRead + (u.cache_read_input_tokens * readRate) / 1e6,
288 usdWrite: s.usdWrite + (u.cache_creation_input_tokens * writeRate) / 1e6,
289 usdSaved: s.usdSaved + (u.cache_read_input_tokens * (inRate - readRate)) / 1e6,
290 }))
291 } catch {
292 // the tally is cosmetic
293 }
294}
295
296/** Limit Coach takes a new reading of the 5-hour window, and warns when one is due. */
297async function coachReading($: EngineInterface, lim: Limit | null) {
298 try {
299 const now = await $.clock.now()
300 let toast: string | null = null
301 await update($, coach, c => {
302 const step = coachStep(c, now, lim)
303 toast = step.toast
304 return step.next
305 })
306 if (toast) $.ui.toast(toast, { timeoutMs: 8000 })
307 } catch {
308 // the coach is advice; never let it break a refresh
309 }
310}
311
312/** On the clock: notices when a full 5-hour window has reset. */
313async function coachClock($: EngineInterface) {
314 try {
315 const now = await $.clock.now()
316 // decided inside the update, so a reading that lands meanwhile isn't lost
317 let isReset = false
318 await update($, coach, c => {
319 const ready = coachTick(c, now)
320 isReset = ready !== null
321 return ready ?? c
322 })
323 if (!isReset) return
324 $.ui.toast('Your 5-hour limit has reset. Press Continue on the card to pick up where you left off.', {
325 timeoutMs: 10_000,
326 })
327 } catch {
328 // try again on the next tick
329 }
330}
331
332/** Render watch: one GPU reading; a job that just ended gets a toast and a cheer from the pet. */
333async function pollGpu($: EngineInterface) {
334 if (smiFailures >= SMI_GIVE_UP || isSmiBusy) return
335 isSmiBusy = true
336 try {
337 let result: { exitCode: number | null; stdout: string }
338 try {
339 result = await $.process.run(
340 ['nvidia-smi', '--query-gpu=utilization.gpu,memory.used,memory.total', '--format=csv,noheader,nounits'],
341 { timeoutMs: 4000 },
342 )
343 } catch {
344 // no NVIDIA GPU or no nvidia-smi gives up after a few tries; a slow
345 // reading under a heavy render just waits for the next one
346 smiFailures++
347 return
348 }
349 smiFailures = 0
350 const reading = result.exitCode === 0 ? parseSmi(result.stdout) : null
351 if (!reading) return
352 const step = gpuStep(gpuTrack, await $.clock.now(), reading)
353 gpuTrack = step.track
354 await update($, gpu, () => step.gpu)
355 if (step.finishedMinutes !== null) {
356 const minutes = step.finishedMinutes
357 await update($, celebrate, () => minutes)
358 $.ui.toast(`GPU job finished after ${minutes}m. Rocky is celebrating until you click him!`, { timeoutMs: 10_000 })
359 lastChime = await $.clock.now()
360 void playSound($, 'cheer')
361 }
362 } catch {
363 // try again on the next reading
364 } finally {
365 isSmiBusy = false
366 }
367}
368
369/** Wellness clock: counts active time and lets the pet nudge when a break is due. */
370async function wellnessClock($: EngineInterface, eyesMs: number, waterMs: number) {
371 try {
372 const now = await $.clock.now()
373 if (activitySeen || lastActivity === 0) lastActivity = now
374 activitySeen = false
375 // the reminder chime also covers a finished GPU job, so it runs with nudges off too
376 if ((await $.store.get('nudges')) === false) return void remind($, now)
377 let nudged: 'eyes' | 'water' | null = null
378 await update($, wellness, w => {
379 const step = wellnessTick(w, now - lastActivity, MINUTE_TICK_MS, eyesMs, waterMs)
380 nudged = step.nudged
381 return step.next
382 })
383 if (nudged) {
384 $.ui.toast(NUDGE_TOASTS[nudged], { timeoutMs: 10_000 })
385 lastChime = now
386 void playSound($, 'nudge')
387 }
388 void remind($, now)
389 } catch {
390 // a missed tick changes little
391 }
392}
393
394/**
395 * Plays one of the pet's clips without holding anything up. The engine's own
396 * player only sounds on macOS, so on Windows PowerShell plays the file.
397 */
398async function playSound($: EngineInterface, clip: 'nudge' | 'cheer' | 'bell') {
399 try {
400 if ((await $.store.get('sounds')) === false) return
401 isWindows ??= (await $.env.get('OS')) === 'Windows_NT'
402 if (isWindows) {
403 const file = `${$.plugin.root}\\sounds\\${clip}.wav`.replace(/['‘’]/g, q => q + q)
404 await $.process.run(
405 ['powershell', '-NoProfile', '-NonInteractive', '-Command', `(New-Object Media.SoundPlayer '${file}').PlaySync()`],
406 { timeoutMs: 10_000 },
407 )
408 } else {
409 await $.audio.play({ asset: `sounds/${clip}.wav` })
410 }
411 } catch {
412 // a sound that cannot play is skipped
413 }
414}
415
416/** An alert still waiting for a click chimes again every few minutes, so it isn't missed. */
417async function remind($: EngineInterface, now: number) {
418 try {
419 if (now - lastChime < REMIND_MS) return
420 const [party, w] = await Promise.all([read($, celebrate), read($, wellness)])
421 if (party === null && w.due === null) return
422 lastChime = now
423 await playSound($, party !== null ? 'cheer' : 'nudge')
424 } catch {
425 // try again next tick
426 }
427}
428
429/** A click in the fun column: an effort or model change, or a nudge or a party answered. */
430async function funClicked($: EngineInterface, message: FunMessage) {
431 try {
432 if (message.type === 'nudge-done') {
433 await update($, wellness, wellnessDone)
434 return
435 }
436 if (message.type === 'celebrate-done') {
437 await update($, celebrate, () => null)
438 return
439 }
440 if (message.type === 'effort' && EFFORT_LEVELS.includes(message.level)) {
441 const model = (await read($, snap))?.model ?? ''
442 await update($, effortChoice, () => ({ model, level: message.level }))
443 $.clock.after(0, () => void $.command.run({ command: 'effort', args: message.level } as never).catch(() => {}))
444 return
445 }
446 if (message.type === 'model') {
447 $.clock.after(0, () => void $.command.run({ command: 'model', args: message.alias } as never).catch(() => {}))
448 // read the new model's name once the switch has landed
449 $.clock.after(1500, () => void refresh($))
450 }
451 } catch {
452 // a missed click is fine
453 }
454}
455
456/** The newest image or video saved under the project since the session began. */
457async function scanOutputs($: EngineInterface) {
458 try {
459 const root = await $.session.root()
460 const since = (await read($, snap))?.startedAt ?? 0
461 const sep = root.includes('\\') ? '\\' : '/'
462 let newest: Output | null = null
463 let queue: [string, number][] = [[root, 0]]
464 let seen = 0
465 while (queue.length > 0 && seen < OUTPUT_MAX_DIRS) {
466 const [dir, depth] = queue.shift() ?? [root, 0]
467 seen += 1
468 const entries = await $.fs.list(dir).catch(() => [])
469 for (const entry of entries) {
470 const path = `${dir.replace(/[\\/]+$/, '')}${sep}${entry.name}`
471 if (entry.kind === 'dir') {
472 if (depth < OUTPUT_DEPTH && !SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) queue.push([path, depth + 1])
473 } else if (MEDIA.test(entry.name) && entry.mtimeMs >= since && entry.mtimeMs > (newest?.mtimeMs ?? 0)) {
474 newest = { path, name: entry.name, mtimeMs: entry.mtimeMs }
475 }
476 }
477 }
478 if (newest) await update($, output, () => newest)
479 } catch {
480 // no output row this time
481 }
482}
483
484/** Opens a file in its default app, or shows it in its folder. */
485async function openPath($: EngineInterface, path: string, isReveal: boolean) {
486 try {
487 isWindows ??= (await $.env.get('OS')) === 'Windows_NT'
488 const argv = isWindows
489 ? isReveal
490 ? ['explorer', `/select,${path}`]
491 : // Explorer opens a file in its default app; cmd's start would read & and ^ in a name
492 ['explorer', path]
493 : isReveal
494 ? ['open', '-R', path]
495 : ['open', path]
496 await $.process.run(argv, { timeoutMs: 10_000 })
497 } catch {
498 // the file may have moved
499 }
500}
501
502/** The project key the desk's goal is filed under: the session's root folder. */
503async function projectKey($: EngineInterface): Promise<string> {
504 return (await $.session.root()).replace(/[\\/]+$/, '').toLowerCase()
505}
506
507/** Reads this project's goal into the desk's notebook. */
508async function loadGoal($: EngineInterface) {
509 try {
510 const goals = ((await $.store.get('goals')) ?? {}) as Record<string, string>
511 const mine = goals[await projectKey($)] ?? ''
512 await update($, goal, () => mine)
513 } catch {
514 // an empty notebook is fine
515 }
516}
517
518/**
519 * Stops whatever ambient loop is playing. Ending a spawned player's stream
520 * ends the player; a loop also notices the count has moved and leaves.
521 */
522function stopAmbient() {
523 ambientSeq++
524 stopMacAmbient?.abort()
525 stopMacAmbient = null
526 const stream = ambientStream
527 ambientStream = null
528 try {
529 // the value is what the loop would have ended with; nothing reads it
530 void stream?.return(undefined as never).catch(() => undefined)
531 } catch {
532 // it may have ended on its own
533 }
534}
535
536/**
537 * Loops an ambient sound until stopped. On Windows a hidden PowerShell plays
538 * it and ticks every half second; the loop reading those ticks is the
539 * player's life, so leaving it (on a change, or with the session) ends the
540 * sound. On macOS the engine plays it.
541 */
542async function playAmbient($: EngineInterface, kind: Ambient) {
543 stopAmbient()
544 const mine = ambientSeq
545 if (kind === 'off') return
546 try {
547 isWindows ??= (await $.env.get('OS')) === 'Windows_NT'
548 // a quicker click may have changed the sound while this one waited
549 if (mine !== ambientSeq) return
550 if (!isWindows) {
551 const stop = new AbortController()
552 stopMacAmbient = stop
553 await $.audio.play({ asset: `sounds/ambient-${kind}.wav` }, { shouldLoop: true, signal: stop.signal })
554 return
555 }
556 const file = `${$.plugin.root}\\sounds\\ambient-${kind}.wav`.replace(/['‘’]/g, q => q + q)
557 // it also ends itself when nobody reads its ticks or Claude Code is gone,
558 // so a hard-killed session can't leave a sound looping
559 const script =
560 `$parent = (Get-CimInstance Win32_Process -Filter "ProcessId=$PID").ParentProcessId; ` +
561 `$p = New-Object Media.SoundPlayer '${file}'; $p.PlayLooping(); ` +
562 `try { while (Get-Process -Id $parent -ErrorAction SilentlyContinue) { ` +
563 `[Console]::Out.WriteLine('.'); [Console]::Out.Flush(); Start-Sleep -Milliseconds 500 } } catch { }`
564 const stream = $.process.spawn({ argv: ['powershell', '-NoProfile', '-NonInteractive', '-Command', script] })
565 ambientStream = stream
566 for await (const _tick of stream) {
567 if (mine !== ambientSeq) break
568 }
569 } catch {
570 // no sound this time
571 }
572}
573
574/** The focus timer's clock: a finished focus starts a break, a finished break rests. */
575async function pomodoroClock($: EngineInterface, now: number) {
576 try {
577 // decided inside the update, so a click on the timer meanwhile wins
578 let ended: Pomodoro['phase'] = 'idle'
579 await update($, pomodoro, (p): Pomodoro => {
580 if (p.phase === 'idle' || p.endsAt === null || now < p.endsAt) return p
581 ended = p.phase
582 return p.phase === 'focus'
583 ? { phase: 'break', endsAt: now + BREAK_MS, rounds: p.rounds + 1 }
584 : { ...IDLE_POMODORO, rounds: p.rounds }
585 })
586 if (ended === 'idle') return
587 $.ui.toast(
588 ended === 'focus' ? 'Focus round done! Take a 5-minute break.' : 'Break over. Click the timer on the desk for another round.',
589 { timeoutMs: 10_000 },
590 )
591 void playSound($, 'bell')
592 } catch {
593 // check again next tick
594 }
595}
596
597/** A click or a typed goal on the desk. */
598async function deskClicked($: EngineInterface, message: DeskMessage) {
599 try {
600 if (message.type === 'goal') {
601 const text = message.text.trim().slice(0, 120)
602 await update($, goal, () => text)
603 const goals = ((await $.store.get('goals')) ?? {}) as Record<string, string>
604 await $.store.set('goals', { ...goals, [await projectKey($)]: text })
605 return
606 }
607 if (message.type === 'pomodoro') {
608 const now = await $.clock.now()
609 await update($, pomodoro, (p): Pomodoro =>
610 p.phase === 'idle' ? { phase: 'focus', endsAt: now + FOCUS_MS, rounds: p.rounds } : { ...IDLE_POMODORO, rounds: p.rounds },
611 )
612 return
613 }
614 const current = await read($, ambient)
615 const next = AMBIENTS[(AMBIENTS.indexOf(current) + 1) % AMBIENTS.length] ?? 'off'
616 await update($, ambient, () => next)
617 await $.store.set('ambient', next)
618 void playAmbient($, next)
619 } catch {
620 // a missed click is fine
621 }
622}
623
624async function sendPrompt($: EngineInterface, text: string) {
625 try {
626 await $.prompt.submit({ text } as never)
627 } catch {
628 // a missed press is fine; the prompt can still be typed
629 }
630}
631
632/** The Compact button: the same as typing /compact. */
633async function compactNow($: EngineInterface) {
634 try {
635 await $.command.run({ command: 'compact' } as never)
636 } catch {
637 // compaction can wait for the next press
638 }
639}
640
641async function continueWork($: EngineInterface) {
642 try {
643 await update($, coach, c => ({ ...c, isReset: false }))
644 await $.prompt.submit({ text: 'My 5-hour limit has reset. Please continue where you left off.' } as never)
645 } catch {
646 // a missed press is fine; typing continue still works
647 }
648}
649
650async function rollQuote($: EngineInterface) {
651 try {
652 await update($, quote, nextQuote)
653 } catch {
654 // keep the quote that is showing
655 }
656}
657
658async function refreshAgents($: EngineInterface) {
659 try {
660 const list = await $.agent.list()
661 const live: AgentChip[] = list
662 .filter(a => a.status === 'running' || a.status === 'waiting')
663 .map(a => ({ id: a.id, name: nameFor(a.id), type: a.type }))
664 hasAgents = live.length > 0
665 const key = live.map(a => a.id).join(',')
666 if (key === lastAgentsKey) return
667 lastAgentsKey = key
668 await update($, agents, () => live)
669 } catch {
670 // the roster is cosmetic
671 }
672}
673
674/** Moves the phase without holding up the turn: the write lands in the background. */
675async function setPhase($: EngineInterface, next: Phase) {
676 isActive = next !== 'idle'
677 const seq = ++phaseSeq
678 try {
679 await update($, phase, shown => (seq === phaseSeq ? next : shown))
680 } catch {
681 // the animation is cosmetic; never let it break a turn
682 }
683}
684
685async function fetchWeather($: EngineInterface, city: string, unit: string) {
686 try {
687 let lat: number
688 let lon: number
689 let place: string
690 if (city) {
691 const g = await $.http.fetch(
692 `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`,
693 )
694 const hit = g.ok ? JSON.parse(g.text).results?.[0] : undefined
695 if (!hit) return
696 lat = Number(hit.latitude)
697 lon = Number(hit.longitude)
698 place = String(hit.name)
699 } else {
700 const g = await $.http.fetch('https://get.geojs.io/v1/ip/geo.json')
701 if (!g.ok) return
702 const here = JSON.parse(g.text)
703 lat = Number(here.latitude)
704 lon = Number(here.longitude)
705 place = String(here.city || here.region || '')
706 }
707 const isF = unit === 'fahrenheit'
708 const w = await $.http.fetch(
709 `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}` +
710 `¤t=temperature_2m,weather_code,is_day&timezone=auto${isF ? '&temperature_unit=fahrenheit' : ''}`,
711 )
712 if (!w.ok) return
713 const body = JSON.parse(w.text)
714 const reading: Weather = {
715 place,
716 temp: Math.round(body.current.temperature_2m),
717 unit: isF ? 'F' : 'C',
718 code: Number(body.current.weather_code),
719 isDay: body.current.is_day === 1,
720 }
721 await update($, weather, () => reading)
722 } catch {
723 // offline or a service hiccup: keep the last reading
724 }
725}
726
727export const register: Register = (on, options) => {
728 const city = String(options.city ?? 'Ludhiana').trim()
729 const unit = String(options.unit ?? 'celsius')
730 const petOption = options.pet === 'dog' ? 'dog' : 'cat'
731 // wellness nudges, in minutes of active work; 0 turns one off
732 const eyesMs = Math.max(0, Number(options.eyesMinutes ?? 20)) * 60_000
733 const waterMs = Math.max(0, Number(options.waterMinutes ?? 60)) * 60_000
734
735 on('session.start', async ($, e, next) => {
736 await $.command.register({
737 name: 'context-bar',
738 description: 'Show or hide the context bar, or swap the pet: /context-bar [on|off|toggle|pet dog|pet cat]',
739 })
740 // `/context-bar off` sticks across sessions until it is turned back on
741 if ((await $.store.get('isOn')) === false) await update($, isOn, () => false)
742 // the pet: what /context-bar pet last chose, else the plugin's setting
743 const chosen = await $.store.get('pet')
744 const kind = chosen === 'dog' || chosen === 'cat' ? chosen : petOption
745 await update($, pet, () => kind)
746 // a `-p` run or the SDK draws no card: no timers, polling or ambient sound
747 if (!e.isInteractive) return next(e)
748 await refresh($)
749 if (!(await read($, quote))) await rollQuote($)
750 void fetchWeather($, city, unit)
751 void refreshAgents($)
752 // the cat animates itself; the band redraws for the agents' spinners,
753 // and once in a while for the session length and the reset countdowns
754 $.clock.every(SPINNER_MS, () => {
755 tick += 1
756 if (hasAgents) $.ui.invalidate('ui.render')
757 if (isActive && tick % 4 === 0) void refreshAgents($)
758 })
759 $.clock.every(MINUTE_TICK_MS, () => {
760 void refreshAgents($)
761 void coachClock($)
762 void wellnessClock($, eyesMs, waterMs)
763 void $.clock.now().then(now => pomodoroClock($, now))
764 $.ui.invalidate('ui.render')
765 })
766 $.clock.every(GPU_POLL_MS, () => void pollGpu($))
767 void pollGpu($)
768 void scanOutputs($)
769 void loadGoal($)
770 const sound = await $.store.get('ambient')
771 if (sound === 'rain' || sound === 'storm' || sound === 'fire' || sound === 'focus') {
772 await update($, ambient, () => sound)
773 void playAmbient($, sound)
774 }
775 $.clock.every(WEATHER_REFRESH_MS, () => void fetchWeather($, city, unit))
776 return next(e)
777 })
778
779 // None of these hold up the turn: the band's own work runs beside it.
780
781 on('prompt.submit', ($, e, next) => {
782 activitySeen = true
783 void rollQuote($)
784 void update($, coach, c => (c.isReset ? { ...c, isReset: false } : c)).catch(() => {})
785 void refresh($)
786 return next(e)
787 })
788
789 on('turn.start', ($, e, next) => {
790 activitySeen = true
791 inFlight = 0
792 void refreshAgents($)
793 void setPhase($, 'thinking')
794 return next(e)
795 })
796
797 on('tool.call', async ($, e, next) => {
798 // a subagent's calls run under the main loop's Agent call, which already
799 // reads as working; counting them would outlive the turn for one in the background
800 if (e.agentId) return next(e)
801 activitySeen = true
802 inFlight += 1
803 void setPhase($, 'working')
804 try {
805 return await next(e)
806 } finally {
807 inFlight = Math.max(0, inFlight - 1)
808 if (inFlight === 0 && isActive) void setPhase($, 'thinking')
809 }
810 })
811
812 on('turn.complete', ($, e, next) => {
813 // every loop's turn counts toward the bill, subagents' included
814 if (e.usage) void addSpend($, e.usage)
815 if (!e.agentId) {
816 activitySeen = true
817 inFlight = 0
818 void setPhase($, 'idle')
819 void refresh($)
820 void scanOutputs($)
821 }
822 return next(e)
823 })
824
825 // `/clear` starts a new conversation in the same process, with no
826 // session.start: the tally and the names start over with it
827 on('session.end', async ($, e, next) => {
828 if (e.reason !== 'clear') return next(e)
829 agentNames.clear()
830 lastAgentsKey = ''
831 void update($, spend, () => NO_SPEND).catch(() => {})
832 void update($, agents, () => []).catch(() => {})
833 void update($, output, () => null).catch(() => {})
834 // read the fresh session once the clear has gone through, not the old one
835 const ended = await next(e)
836 void refresh($)
837 return ended
838 })
839
840 on('ui.message', { element: 'fun' }, async ($, e, next) => {
841 const message = e.data as FunMessage | undefined
842 if (message && typeof message === 'object' && 'type' in message) void funClicked($, message)
843 return next(e)
844 })
845
846 on('ui.message', { element: 'desk' }, async ($, e, next) => {
847 const message = e.data as DeskMessage | undefined
848 if (message && typeof message === 'object' && 'type' in message) void deskClicked($, message)
849 return next(e)
850 })
851
852 // an effort typed by hand shows on the card too, for the model it was set on
853 on('command.run', { command: 'effort' }, async ($, e, next) => {
854 const result = await next(e)
855 const level = e.args.trim().toLowerCase()
856 if (EFFORT_LEVELS.includes(level)) {
857 const model = (await read($, snap))?.model ?? ''
858 void update($, effortChoice, () => ({ model, level })).catch(() => {})
859 }
860 return result
861 })
862
863 on('session.compact', async ($, e, next) => {
864 const result = await next(e)
865 void refresh($)
866 return result
867 })
868
869 on('command.run', { command: 'context-bar' }, async ($, e) => {
870 const arg = e.args.trim().toLowerCase()
871
872 // `/context-bar pet dog`, `/context-bar pet cat`, or `/context-bar pet` to swap
873 const words = arg.split(/\s+/)
874 if (words[0] === 'sounds') {
875 const isOn = words[1] !== 'off'
876 await $.store.set('sounds', isOn)
877 return { text: isOn ? "Rocky's sounds are on." : "Rocky is quiet now, until /context-bar sounds on." }
878 }
879
880 // the desk's clock and lamp glow: soft to try, bright to go back
881 if (words[0] === 'glow') {
882 const level = words[1] === 'bright' ? 'bright' : 'soft'
883 await $.store.set('glow', level)
884 $.ui.invalidate('ui.render')
885 return {
886 text: level === 'bright' ? 'The desk glows bright again.' : 'The desk glows softer now. /context-bar glow bright brings the full glow back.',
887 }
888 }
889
890 if (words[0] === 'nudges') {
891 const isOn = words[1] !== 'off'
892 await $.store.set('nudges', isOn)
893 if (!isOn) await update($, wellness, w => ({ ...w, due: null }))
894 return { text: isOn ? 'Rocky will remind you to rest your eyes and drink water.' : 'No more wellness nudges, until /context-bar nudges on.' }
895 }
896
897 if (words[0] === 'pet' || words[0] === 'dog' || words[0] === 'cat') {
898 const asked = words[0] === 'pet' ? words[1] : words[0]
899 const current = await read($, pet)
900 const kind = asked === 'dog' || asked === 'cat' ? asked : current === 'cat' ? 'dog' : 'cat'
901 await update($, pet, () => kind)
902 await $.store.set('pet', kind)
903 return { text: kind === 'dog' ? 'Rocky is a dog now. Woof!' : 'Rocky is a cat now. Meow.' }
904 }
905
906 // a word it doesn't know explains the command rather than flipping the bar
907 if (arg !== '' && arg !== 'on' && arg !== 'off' && arg !== 'toggle') {
908 return {
909 text: 'Usage: /context-bar [on|off|toggle] · pet dog|cat · sounds on|off · nudges on|off · glow soft|bright. On its own it switches the bar off or on.',
910 }
911 }
912 const was = await read($, isOn)
913 const now = arg === 'on' ? true : arg === 'off' ? false : !was
914 await update($, isOn, () => now)
915 await $.store.set('isOn', now)
916 if (now) await refresh($)
917
918 return { text: now ? 'Context bar on.' : 'Context bar off, in every session until /context-bar on.' }
919 })
920
921 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
922 const s = await read($, snap)
923 // the band is raised on these two alone, both of which draw a Client
924 const isCardSurface = e.surface === 'terminal' || e.surface === 'desktop'
925 if (s === null || !isCardSurface || e.props.hasSurvey || !(await read($, isOn))) {
926 return next(e)
927 }
928
929 const { Box, Button, Client, Text } = $.ui.resolve(e)
930 const bg = { backgroundColor: BG } as const
931 const t = (color: string, text: string, extra: { bold?: boolean; italic?: boolean } = {}) => (
932 <Text {...bg} color={color} {...extra}>
933 {text}
934 </Text>
935 )
936 /** `share` of `cells` filled in `color`, the rest an empty track. */
937 const meter = (share: number, color: string, cells: number) => {
938 const n = Math.round(Math.min(1, Math.max(0, share)) * cells)
939 return (
940 <Text {...bg}>
941 {t(color, '▰'.repeat(n))}
942 {t(TRACK, '▱'.repeat(cells - n))}
943 </Text>
944 )
945 }
946 const label = (text: string) => t(MUTED, text.padEnd(LABEL))
947
948 // Layout: the card's border and padding take two columns each side.
949 const columns = e.props.bodyColumns
950 const inner = columns - 4
951 const showFun = columns >= MIN_COLUMNS_FOR_FUN
952 const funWidth = funColumns(columns)
953 const dataWidth = showFun ? inner - funWidth - COLUMN_GAP : inner
954 // the desk takes the right of the rows under the context bar when it fits
955 // the full desk, a compact one (window, clock, books and plant), or none
956 const deskWidth =
957 dataWidth >= MIN_DATA_FOR_DESK ? DESK_WIDTH : dataWidth >= MIN_DATA_FOR_COMPACT_DESK ? COMPACT_DESK_WIDTH : 0
958 const showDesk = deskWidth > 0
959 const rowsWidth = showDesk ? dataWidth - deskWidth - DESK_GAP : dataWidth
960 const meterCells = rowsWidth >= 70 ? 10 : 6
961
962 const now = await $.clock.now()
963 const [wx, live, shownQuote, stored, sp] = await Promise.all([
964 read($, weather),
965 read($, agents),
966 read($, quote),
967 read($, phase),
968 read($, spend),
969 ])
970 const current: Phase = e.props.isWorking ? (stored === 'idle' ? 'thinking' : stored) : 'idle'
971 const mood = percentColor(s.percent)
972 const desk: DeskProps = {
973 goal: await read($, goal),
974 pomodoro: await read($, pomodoro),
975 ambient: await read($, ambient),
976 isCompact: deskWidth === COMPACT_DESK_WIDTH,
977 glow: (await $.store.get('glow')) === 'bright' ? 'bright' : 'soft',
978 now,
979 }
980 const [gpuNow, party, wellnessNow, chosenEffort, newest] = await Promise.all([
981 read($, gpu),
982 read($, celebrate),
983 read($, wellness),
984 read($, effortChoice),
985 read($, output),
986 ])
987
988 // The fun column's data; it draws and animates itself.
989 const sky = wx ? skyStyle(wx.code, wx.isDay) : null
990 // an effort set from the card or typed wins, while the same model is in use
991 const shownEffort = chosenEffort && chosenEffort.model === s.model ? chosenEffort.level : s.effort
992 const fun: FunProps = {
993 showTagline: funWidth >= 26,
994 layout: columns >= MIN_COLUMNS_FOR_SIDE ? 'side' : 'stack',
995 pet: await read($, pet),
996 model: s.model,
997 effort: shownEffort,
998 session: sessionLength(now, s.startedAt),
999 weather:
1000 wx && sky
1001 ? {
1002 ...sky,
1003 temp: `${wx.temp}°${wx.unit}`,
1004 tempColor: heatColor(wx.temp, wx.unit),
1005 place: funWidth >= 26 ? wx.place.slice(0, 12) : '',
1006 }
1007 : null,
1008 phase: current,
1009 percent: s.percent,
1010 gpu: gpuNow,
1011 nudge: wellnessNow.due,
1012 celebrate: party,
1013 }
1014
1015 // Context: used rows, then the compaction buffer, then free space.
1016 const used = s.rows.filter(r => r.kind === 'used' && r.tokens > 0)
1017 const bar = [...used, ...s.rows.filter(r => r.kind === 'buffer'), ...s.rows.filter(r => r.kind === 'free')]
1018 // one row when there is room: the label and share, the bar, the token counts
1019 const contextHead = `${'CONTEXT'.padEnd(LABEL)}${s.percent}% `
1020 const contextTail = ` ${compact(s.totalTokens)} / ${compact(s.maxTokens)}`
1021 // beside the desk the bar ends where the desk begins, its totals right after it
1022 const contextWidth = showDesk ? rowsWidth : dataWidth
1023 const isOneContextRow = contextWidth >= MIN_DATA_FOR_ONE_CONTEXT_ROW
1024 const barWidth = isOneContextRow ? contextWidth - contextHead.length - contextTail.length : contextWidth
1025 const cells = allocate(bar, s.maxTokens, Math.min(MAX_CELLS, Math.max(MIN_CELLS, barWidth)))
1026
1027 // Cache: the share of the input reused from the prompt cache, which is
1028 // billed at a fraction of the input rate. The session's turns when the
1029 // mod has seen some, else the last reply's (say, just after a restart).
1030 const inputSeen = sp.cacheRead + sp.cacheWrite + sp.input
1031 const last = s.lastReply
1032 const lastSeen = last ? last.cacheRead + last.cacheWrite + last.input : 0
1033 const hit = inputSeen > 0 ? sp.cacheRead / inputSeen : last && lastSeen > 0 ? last.cacheRead / lastSeen : null
1034
1035 // Cost at API rates: /cost's total, itemized from the turns seen here;
1036 // 'other' is what /cost counts beyond them (background calls, turns before
1037 // the mod loaded).
1038 const itemized = sp.usdInput + sp.usdOutput + sp.usdRead + sp.usdWrite
1039 const total = Math.max(s.costUsd ?? 0, itemized)
1040 const other = total - itemized
1041 const costParts: [string, number, string][] = [
1042 ['in', sp.usdInput, GOLDS[0]],
1043 ['out', sp.usdOutput, GOLDS[1]],
1044 ['read', sp.usdRead, GOLDS[2]],
1045 ['write', sp.usdWrite, GOLDS[3]],
1046 ...(other >= 0.01 ? [['other', other, GOLDS[4]] as [string, number, string]] : []),
1047 ]
1048 const costCells = shareCells(
1049 costParts.map(([, usd]) => usd),
1050 meterCells,
1051 )
1052 // the parts so far, named in their bar colours; beside the total when they fit
1053 const shownParts = costParts.filter(([, usd]) => usd > 0)
1054 const legendWidth = shownParts.reduce((w, [name, usd]) => w + 5 + name.length + 1 + money(usd).length, 0)
1055 const legendBeside = LABEL + meterCells + 1 + money(total).length + legendWidth <= rowsWidth
1056 const costLegend = (
1057 <Text {...bg}>
1058 {shownParts.map(([name, usd, color]) => (
1059 <Text key={`v${name}`} {...bg}>
1060 {t(color, ' ● ')}
1061 {t(MUTED, `${name} ${money(usd)}`)}
1062 </Text>
1063 ))}
1064 </Text>
1065 )
1066
1067 const effortLevel = EFFORTS.indexOf(shownEffort ?? '') + 1
1068 const spinFrame = Math.floor(now / SPINNER_MS)
1069 const shownAgents = live.slice(0, 4)
1070
1071 // Limit Coach: the pace the 5-hour window fills at, when it would run out first
1072 const coached = await read($, coach)
1073 const eta = minutesToFull(coached.samples, now, s.fiveHour)
1074
1075 const barCells = bar.map((r, i) => (
1076 <Text key={`c${i}`} {...bg} color={colorFor(r)}>
1077 {(r.kind === 'free' ? '░' : r.kind === 'buffer' ? '▒' : '█').repeat(cells[i] ?? 0)}
1078 </Text>
1079 ))
1080
1081 const limit = (name: string, lim: Limit | null, warning: string | null = null) => (
1082 <Text {...bg}>
1083 {label(name)}
1084 {lim === null ? (
1085 t(MUTED, 'shows after the next reply')
1086 ) : (
1087 <Text {...bg}>
1088 {meter(lim.percent / 100, percentColor(lim.percent), meterCells)}
1089 {t(percentColor(lim.percent), ` ${Math.round(lim.percent)}%`, { bold: true })}
1090 {t(MUTED, resetsIn(now, lim.resetsAt) ? ` ↻ ${resetsIn(now, lim.resetsAt)}` : '')}
1091 {warning ? t(PEACH, ` ⚠ ${warning}`, { bold: true }) : null}
1092 </Text>
1093 )}
1094 </Text>
1095 )
1096 const fiveWarning = eta === null ? null : etaText(eta)
1097 // how wide a limit's row runs, so the two share a row only when both fit
1098 const limitWidth = (name: string, lim: Limit | null, warning: string | null = null) => {
1099 if (lim === null) return LABEL + 'shows after the next reply'.length
1100 const reset = resetsIn(now, lim.resetsAt)
1101 return (
1102 LABEL +
1103 meterCells +
1104 ` ${Math.round(lim.percent)}%`.length +
1105 (reset ? ` ↻ ${reset}`.length : 0) +
1106 // the warning sign can draw two cells wide
1107 (warning ? ` ⚠ ${warning}`.length + 1 : 0)
1108 )
1109 }
1110
1111 const modelLine = (
1112 <Text {...bg} wrap="truncate-end">
1113 {t(BLUE, '◆ ')}
1114 {t(TEXT, s.model || 'Claude', { bold: true })}
1115 {shownEffort && (
1116 <Text {...bg}>
1117 {t(MUTED, ' effort ')}
1118 {meter(effortLevel / EFFORTS.length, MAUVE, EFFORTS.length)}
1119 {t(TEXT, ` ${shownEffort}`)}
1120 </Text>
1121 )}
1122 </Text>
1123 )
1124
1125 return (
1126 <Box
1127 {...bg}
1128 width={columns}
1129 flexDirection="row"
1130 alignItems="center"
1131 paddingX={1}
1132 borderStyle="round"
1133 borderColor={BORDER}
1134 >
1135 {showFun && (
1136 <Box {...bg} width={funWidth} flexShrink={0} marginRight={COLUMN_GAP}>
1137 <Client key="fun" module="./fun-column.tsx" props={fun} width={funWidth} />
1138 </Box>
1139 )}
1140 <Box {...bg} flexDirection="column" flexGrow={1}>
1141 <Box {...bg} flexDirection="row">
1142 {/* a set width, so a long row truncates instead of pushing the desk off the edge */}
1143 <Box {...bg} flexDirection="column" width={rowsWidth} flexShrink={0}>
1144 {isOneContextRow ? (
1145 <Text {...bg} wrap="truncate-end">
1146 {label('CONTEXT')}
1147 {t(mood, `${s.percent}%`, { bold: true })}
1148 {t(MUTED, ' ')}
1149 {barCells}
1150 {t(MUTED, contextTail)}
1151 </Text>
1152 ) : (
1153 [
1154 <Text key="head" {...bg} wrap="truncate-end">
1155 {label('CONTEXT')}
1156 {t(mood, `${s.percent}%`, { bold: true })}
1157 {t(MUTED, ` ${compact(s.totalTokens)} / ${compact(s.maxTokens)}`)}
1158 </Text>,
1159 <Text key="bar" {...bg}>
1160 {barCells}
1161 </Text>,
1162 ]
1163 )}
1164 <Text {...bg} wrap="truncate-end">
1165 {t(MUTED, ' '.repeat(LABEL))}
1166 {used.map((r, i) => (
1167 <Text key={`l${i}`} {...bg}>
1168 {t(colorFor(r), '● ')}
1169 {t(MUTED, `${shortName(r.name)} ${compact(r.tokens)}${i < used.length - 1 ? ' ' : ''}`)}
1170 </Text>
1171 ))}
1172 </Text>
1173 {rowsWidth >= MIN_DATA_FOR_ONE_LIMIT_ROW && limitWidth('5H', s.fiveHour, fiveWarning) + 5 + limitWidth('WEEK', s.sevenDay) <= rowsWidth ? (
1174 <Text {...bg} wrap="truncate-end">
1175 {limit('5H', s.fiveHour, fiveWarning)}
1176 {t(MUTED, ' ')}
1177 {limit('WEEK', s.sevenDay)}
1178 </Text>
1179 ) : (
1180 [
1181 <Text key="five" {...bg} wrap="truncate-end">
1182 {limit('5H', s.fiveHour, fiveWarning)}
1183 </Text>,
1184 <Text key="week" {...bg} wrap="truncate-end">
1185 {limit('WEEK', s.sevenDay)}
1186 </Text>,
1187 ]
1188 )}
1189 {coached.isReset && (
1190 <Box {...bg} flexDirection="row" alignItems="center">
1191 {label('5H')}
1192 {t(GREEN, 'reset! ', { bold: true })}
1193 <Button
1194 key="continue"
1195 variant="primary"
1196 label="Continue where we left off"
1197 onPress={() => void continueWork($)}
1198 />
1199 </Box>
1200 )}hooks/desk-art.ts 301 lines1// The desk scene, painted as pixels: each character cell shows two stacked
2// pixels with the half block '▀' (top pixel as its colour, bottom as its
3// background), which doubles the height's detail. The wall clock is a small
4// orange neon clock in a thin double-line frame, drawn as text so it stays sharp. Pure: the same inputs paint the same
5// scene, so the surface module just lays out what this returns.
6
7import type { Ambient, Glow, Pomodoro } from '../types'
8
9export type DeskCell = { ch: string; fg: string; bg: string; bold?: boolean }
10
11export const DESK_WIDTH = 44
12// the compact desk shows the scene's left part: the window, the clock, the books and the plant
13export const COMPACT_DESK_WIDTH = 30
14// six rows of wall and one of desk top (14 pixels), then the notebook strip and the drawer
15export const SCENE_ROWS = 7
16export const NOTE_ROW = 7
17export const DRAWER_ROW = 8
18
19const PX_HEIGHT = SCENE_ROWS * 2
20// the wall clock: a small rounded case (cells x0..x1, pixels y0..y1), its face one row of text
21// the wall clock, in cells: a thin double-line frame around one row of orange neon digits
22const CLOCK = { x0: 15, x1: 23, top: 0, bottom: 2 }
23const CLOCK_FRAME = '#ffa04a'
24const CLOCK_FACE = '#1f0f0a'
25const CLOCK_DIGITS = '#ffc472'
26const CLOCK_COLON_DIM = '#b8662a'
27const CLOCK_GLOW = '#ff7a1a'
28// the lamp's bulb, in pixels; its warm light falls around it
29const BULB = { x: 36, y: 6 }
30
31// the focus timer's rounds; the window's sky follows them
32export const FOCUS_MS = 25 * 60_000
33export const BREAK_MS = 5 * 60_000
34// the break's last stretch, when the morning sets back into night
35const DUSK_MS = 90_000
36
37// the sky's colours, top and bottom of the window, from night (0) through sunrise (1) to morning (1.3)
38const SKY_STOPS: [number, string, string][] = [
39 [0, '#0d1430', '#22345e'],
40 [0.4, '#1c2252', '#4e3f78'],
41 [0.7, '#34407e', '#d98aa0'],
42 [1, '#5b86c9', '#ffc58a'],
43 [1.3, '#86b3e3', '#ffe0a6'],
44]
45const MORNING = 1.3
46
47/**
48 * How far the day has come, from the focus timer: night while idle, night
49 * to sunrise over a focus round, golden morning on the break, and back to
50 * night as the break ends.
51 */
52export function skyFor(pomodoro: Pomodoro, now: number): number {
53 const { phase, endsAt } = pomodoro
54 if (phase === 'idle' || endsAt === null) return 0
55 const left = Math.max(0, endsAt - now)
56 if (phase === 'focus') return Math.min(1, Math.max(0, 1 - left / FOCUS_MS))
57 return left < DUSK_MS ? (MORNING * left) / DUSK_MS : MORNING
58}
59
60function skyAt(sky: number, y: number): string {
61 const i = SKY_STOPS.findIndex(([at]) => at >= sky)
62 const hi = SKY_STOPS[Math.max(0, i === -1 ? SKY_STOPS.length - 1 : i)] ?? SKY_STOPS[0]!
63 const lo = SKY_STOPS[Math.max(0, (i === -1 ? SKY_STOPS.length : i) - 1)] ?? hi
64 const t = hi[0] === lo[0] ? 0 : (sky - lo[0]) / (hi[0] - lo[0])
65 return mix(mix(lo[1], hi[1], t), mix(lo[2], hi[2], t), y)
66}
67
68/**
69 * How bright the lightning is on this frame, 0 to 1: a strike every 18 s or
70 * so with a fainter flicker after it, and a smaller one between them.
71 */
72function lightningAt(frame: number): number {
73 const big = frame % 73
74 if (big === 0) return 0.8
75 if (big === 2) return 0.45
76 const small = frame % 131
77 return small === 0 ? 0.4 : 0
78}
79
80type Rgb = [number, number, number]
81
82function rgb(hex: string): Rgb {
83 return [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16)) as Rgb
84}
85
86function hex([r, g, b]: Rgb): string {
87 return `#${[r, g, b].map(v => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('')}`
88}
89
90function mix(a: string, b: string, t: number): string {
91 const [x, y] = [rgb(a), rgb(b)]
92 return hex([0, 1, 2].map(i => (x[i] ?? 0) + ((y[i] ?? 0) - (x[i] ?? 0)) * Math.max(0, Math.min(1, t))) as Rgb)
93}
94
95/** How strongly the lamp lights the point (x, y), 0 to 1. */
96function glowAt(x: number, y: number, power: number): number {
97 const d = Math.hypot(x - BULB.x, (y - BULB.y) * 1.2)
98 return power * Math.max(0, 1 - d / 17) ** 2.2
99}
100
101/**
102 * Paints the wall, window, plant, mug and lamp into a pixel grid
103 * [x][y] of DESK_WIDTH × PX_HEIGHT colours.
104 */
105function paintPixels(frame: number, ambient: Ambient, isLampOn: boolean, sky: number, glow: Glow): string[][] {
106 const isSoft = glow === 'soft'
107 // the lamp breathes a little; by the fire it flickers
108 const flicker = ambient === 'fire' ? 0.85 + 0.15 * Math.sin(frame * 1.7) * Math.sin(frame * 0.6) : 1
109 // by morning the lamp matters less, and daylight warms the wall
110 const daylight = Math.min(1, Math.max(0, (sky - 0.5) / 0.8))
111 const power = isLampOn ? (isSoft ? 0.38 : 0.5) * flicker * (1 - 0.5 * daylight) : 0
112 const px: string[][] = []
113 for (let x = 0; x < DESK_WIDTH; x++) {
114 const column: string[] = []
115 for (let y = 0; y < PX_HEIGHT; y++) {
116 let c: string
117 if (y < 12) c = mix(mix('#1b1828', '#2a2340', y / 11), '#5a4560', 0.4 * daylight)
118 else if (y === 12) c = '#a8724c'
119 else c = '#7d5136'
120 // a soft grain along the desk top
121 if (y === 13 && (x * 7 + 3) % 11 === 0) c = '#6e4630'
122 column.push(mix(c, '#f6c177', glowAt(x, y, power)))
123 }
124 px.push(column)
125 }
126 const put = (x: number, y: number, c: string, lit = true) => {
127 const column = px[x]
128 if (column && y >= 0 && y < PX_HEIGHT) column[y] = lit ? mix(c, '#f6c177', glowAt(x, y, power) * 0.6) : c
129 }
130
131 // the neon clock's orange glow, spilling onto the wall around it
132 for (let x = CLOCK.x0 - 6; x <= CLOCK.x1 + 6; x++) {
133 for (let y = 0; y <= 9; y++) {
134 const d = Math.hypot((x - (CLOCK.x0 + CLOCK.x1) / 2) / (isSoft ? 7 : 10), (y - 2.5) / (isSoft ? 3.5 : 5))
135 const column = px[x]
136 if (column && d < 1) column[y] = mix(column[y] ?? '#1b1828', CLOCK_GLOW, (isSoft ? 0.32 : 0.55) * (1 - d) ** 1.3)
137 }
138 }
139
140 // the window: a frame, night sky, the moon, stars or rain, and a sill
141 const raining = ambient === 'rain' || ambient === 'storm'
142 for (let x = 1; x <= 12; x++) {
143 for (let y = 1; y <= 10; y++) {
144 const isFrame = x === 1 || x === 12 || y === 1 || y === 10 || x === 6 || y === 5
145 const clear = skyAt(sky, (y - 2) / 7)
146 put(x, y, isFrame ? '#3d3453' : raining ? mix(clear, '#2a3146', 0.6) : clear, false)
147 }
148 }
149 const isGlass = (x: number, y: number) => x >= 2 && x <= 11 && y >= 2 && y <= 9 && x !== 6 && y !== 5
150 const glassAt = (x: number, y: number) => px[x]?.[y] ?? '#0d1430'
151 if (!raining) {
152 // the moon sinks and fades as the sky lightens; the stars go first
153 const moonFade = Math.min(1, sky / 0.7)
154 const moonDrop = Math.round(sky * 3)
155 const moon: [number, number, string][] = [[9, 2, '#f9e2af'], [10, 2, '#f9e2af'], [9, 3, '#f4d58d'], [10, 3, '#fbe7b5']]
156 for (const [x, y, c] of moon) {
157 const my = y + moonDrop
158 if (moonFade < 1 && isGlass(x, my)) put(x, my, mix(c, glassAt(x, my), moonFade), false)
159 }
160 const stars: [number, number][] = [[3, 2], [4, 4], [8, 7], [3, 7], [10, 8], [7, 3], [4, 9]]
161 stars.forEach(([x, y], i) => {
162 const twinkle = (Math.floor(frame / 5) + i * 2) % 5
163 if (twinkle !== 0 && sky < 0.5) put(x, y, mix(twinkle === 2 ? '#ffffff' : '#9aa6d6', glassAt(x, y), sky / 0.5), false)
164 })
165 // the sun climbs from behind the sill, with a soft halo
166 if (sky > 0.55) {
167 const sunY = 10.5 - ((sky - 0.55) / (MORNING - 0.55)) * 7
168 const sunX = 4.5
169 for (let x = 2; x <= 11; x++) {
170 for (let y = 2; y <= 9; y++) {
171 if (!isGlass(x, y)) continue
172 const d = Math.hypot(x - sunX, y - sunY)
173 if (d <= 1.1) put(x, y, '#fff1c4', false)
174 else if (d <= 3.2) put(x, y, mix(glassAt(x, y), '#ffd27a', 0.45 * (1 - (d - 1.1) / 2.1)), false)
175 }
176 }
177 }
178 } else {
179 for (let x = 2; x <= 11; x++) {
180 if (x === 6) continue
181 const drop = (frame + x * 5) % 9
182 const y = 2 + drop
183 if (y <= 9 && y !== 5) put(x, y, '#7aa2d8', false)
184 if (y - 1 >= 2 && y - 1 <= 9 && y - 1 !== 5) put(x, y - 1, '#3c5a8c', false)
185 }
186 // in a storm, lightning: a flash with a flicker after it, a bolt on the brightest frame
187 const flash = ambient === 'storm' ? lightningAt(frame) : 0
188 if (flash > 0) {
189 for (let x = 2; x <= 11; x++) for (let y = 2; y <= 9; y++) if (isGlass(x, y)) put(x, y, mix(glassAt(x, y), '#dfe8ff', flash), false)
190 if (flash > 0.6) for (const [x, y] of [[9, 2], [8, 3], [9, 4], [8, 6], [7, 7]] as [number, number][]) put(x, y, '#ffffff', false)
191 // the room lights up for an instant too
192 px.forEach((column, x) => {
193 for (let y = 0; y < 12; y++) if (!isGlass(x, y)) column[y] = mix(column[y] ?? '#1b1828', '#c8d4ff', 0.18 * flash)
194 })
195 }
196 }
197 for (let x = 0; x <= 13; x++) put(x, 11, '#4b405f')
198
199 // a short stack of books, spines out
200 const books: [number, number, number, string, string][] = [
201 [15, 22, 11, '#5b7fbf', '#89b4fa'],
202 [16, 21, 10, '#b5527a', '#f5c2e7'],
203 [15, 20, 9, '#4f8f6a', '#a6e3a1'],
204 ]
205 for (const [from, to, y, spine, edge] of books) for (let x = from; x <= to; x++) put(x, y, x === from || x === to ? edge : spine)
206
207 // a little plant in a terracotta pot
208 for (let x = 25; x <= 28; x++) put(x, 11, '#c46f4b')
209 for (let x = 25; x <= 28; x++) put(x, 10, '#e08a62')
210 const leaves: [number, number, string][] = [
211 [26, 9, '#7fbf7a'], [27, 9, '#a6e3a1'], [25, 8, '#a6e3a1'], [28, 8, '#7fbf7a'],
212 [26, 7, '#a6e3a1'], [27, 6, '#7fbf7a'], [24, 7, '#7fbf7a'], [29, 6, '#a6e3a1'], [27, 8, '#5f9e64'],
213 ]
214 leaves.forEach(([x, y, c]) => put(x, y, c))
215
216 // a mug of something warm, steam curling up
217 for (let x = 30; x <= 32; x++) for (let y = 9; y <= 11; y++) put(x, y, y === 9 ? '#e0c8ff' : '#cba6f7')
218 put(33, 10, '#b48ef0')
219 for (let y = 5; y <= 8; y++) {
220 if ((y + Math.floor(frame / 3)) % 2 === 0) continue
221 const x = 31 + Math.round(Math.sin(frame / 4 + y * 1.3))
222 const base = px[x]?.[y] ?? '#2a2340'
223 put(x, y, mix(base, '#e6e3ee', 0.06 + (8 - y) * 0.02), false)
224 }
225
226 // the lamp: a weighted base, a bent arm, a brass shade and, when on, its bulb
227 for (let x = 37; x <= 41; x++) put(x, 11, '#6c7086')
228 put(39, 10, '#8087a2')
229 put(39, 9, '#8087a2')
230 put(40, 8, '#8087a2')
231 put(40, 7, '#8087a2')
232 put(39, 6, '#8087a2')
233 const shade: [number, number, number][] = [[3, 35, 37], [4, 34, 38], [5, 33, 39]]
234 for (const [y, from, to] of shade) for (let x = from; x <= to; x++) put(x, y, y === 3 ? '#c98f2e' : '#e8b04b', false)
235 if (isLampOn) {
236 put(35, 6, '#fff3c4', false)
237 put(36, 6, '#fffaf0', false)
238 put(37, 6, '#fff3c4', false)
239 }
240 return px
241}
242
243/** The clock's face: the time in 12-hour digits, centred in the frame. */
244function clockFace(now: Date): string {
245 const hours = now.getHours() % 12 || 12
246 const time = `${hours}:${String(now.getMinutes()).padStart(2, '0')}`
247 const room = CLOCK.x1 - CLOCK.x0 - 1
248 const left = Math.floor((room - time.length) / 2)
249 return ' '.repeat(left) + time + ' '.repeat(room - left - time.length)
250}
251
252/** The clock's cell at (cx, cy), or null where the scene shows through. */
253function clockCell(cx: number, cy: number, now: Date, wall: string): DeskCell | null {
254 if (cx < CLOCK.x0 || cx > CLOCK.x1 || cy < CLOCK.top || cy > CLOCK.bottom) return null
255 const isLeft = cx === CLOCK.x0
256 const isRight = cx === CLOCK.x1
257 if (cy === CLOCK.top) return { ch: isLeft ? '╔' : isRight ? '╗' : '═', fg: CLOCK_FRAME, bg: wall }
258 if (cy === CLOCK.bottom) return { ch: isLeft ? '╚' : isRight ? '╝' : '═', fg: CLOCK_FRAME, bg: wall }
259 if (isLeft || isRight) return { ch: '║', fg: CLOCK_FRAME, bg: wall }
260 // the digits glow; the colon dims every other second
261 const ch = clockFace(now)[cx - CLOCK.x0 - 1] ?? ' '
262 const isDim = ch === ':' && now.getSeconds() % 2 === 1
263 return { ch, fg: isDim ? CLOCK_COLON_DIM : CLOCK_DIGITS, bg: CLOCK_FACE, bold: true }
264}
265
266/** The scene's cells, top to bottom: six rows of wall, one of desk top. */
267export function paintScene(
268 frame: number,
269 now: Date,
270 ambient: Ambient,
271 isLampOn: boolean,
272 sky = 0,
273 glow: Glow = 'bright',
274): DeskCell[][] {
275 const px = paintPixels(frame, ambient, isLampOn, Number.isFinite(sky) ? Math.min(1.3, Math.max(0, sky)) : 0, glow)
276 const rows: DeskCell[][] = []
277 for (let cy = 0; cy < SCENE_ROWS; cy++) {
278 const row: DeskCell[] = []
279 for (let cx = 0; cx < DESK_WIDTH; cx++) {
280 const top = px[cx]?.[cy * 2] ?? '#1b1828'
281 const bottom = px[cx]?.[cy * 2 + 1] ?? '#1b1828'
282 const clock = clockCell(cx, cy, now, mix(top, bottom, 0.5))
283 if (clock) row.push(clock)
284 else row.push(top === bottom ? { ch: ' ', fg: top, bg: bottom } : { ch: '▀', fg: top, bg: bottom })
285 }
286 rows.push(row)
287 }
288 return rows
289}
290
291/** Runs of one look, so the row draws as a handful of texts rather than one per cell. */
292export function runs(row: DeskCell[]): DeskCell[] {
293 const out: DeskCell[] = []
294 for (const cell of row) {
295 const last = out[out.length - 1]
296 if (last && last.fg === cell.fg && last.bg === cell.bg && last.bold === cell.bold) last.ch += cell.ch
297 else out.push({ ...cell })
298 }
299 return out
300}
301hooks/coach.ts 73 lines1// Limit Coach: watches how fast the 5-hour window fills, warns before it runs
2// out, and notices when a full window resets so the work can pick back up.
3
4import type { Coach, Limit } from '../types'
5
6export const NO_COACH: Coach = { samples: [], warned: 0, waitingFor: null, isReset: false }
7
8// the pace is read over the last hour of readings
9const WINDOW_MS = 60 * 60_000
10// fewer minutes than this between readings is too little to read a pace from
11const MIN_SPAN_MINUTES = 5
12// a full window is one at or past this share
13const FULL = 99
14
15function minutes(text: number): string {
16 return text >= 60 ? `${Math.floor(text / 60)}h ${text % 60}m` : `${text}m`
17}
18
19/**
20 * Minutes until the 5-hour window fills at the last hour's pace, or null when
21 * it will reset first (or there is no pace to read yet).
22 */
23export function minutesToFull(samples: [number, number][], now: number, lim: Limit | null): number | null {
24 const first = samples[0]
25 const last = samples[samples.length - 1]
26 if (!lim || !first || !last) return null
27 if (lim.percent >= 100) return 0
28 const span = (last[0] - first[0]) / 60_000
29 const rise = last[1] - first[1]
30 if (span < MIN_SPAN_MINUTES || rise <= 0) return null
31 const eta = (100 - lim.percent) / (rise / span)
32 const untilReset = lim.resetsAt ? (Date.parse(lim.resetsAt) - now) / 60_000 : Number.POSITIVE_INFINITY
33 return eta < untilReset ? Math.max(1, Math.round(eta)) : null
34}
35
36export function etaText(eta: number): string {
37 return eta <= 0 ? 'full: waits for the reset' : `full in ~${minutes(eta)}`
38}
39
40/** One new reading of the 5-hour window: the coach's next state, and a toast when one is due. */
41export function coachStep(c: Coach, now: number, lim: Limit | null): { next: Coach; toast: string | null } {
42 if (!lim) return { next: c, toast: null }
43
44 // a fresh window shows as a big drop: the old pace no longer applies
45 const prior = c.samples[c.samples.length - 1]
46 const isNewWindow = prior !== undefined && lim.percent < prior[1] - 5
47 const samples: [number, number][] = [...(isNewWindow ? [] : c.samples), [now, lim.percent] as [number, number]].filter(
48 ([t]) => now - t <= WINDOW_MS,
49 )
50
51 let warned = isNewWindow || lim.percent < 50 ? 0 : c.warned
52 let toast: string | null = null
53 const eta = minutesToFull(samples, now, lim)
54 for (const level of [95, 80]) {
55 if (lim.percent >= level && warned < level) {
56 warned = level
57 toast =
58 `5-hour limit at ${Math.round(lim.percent)}%` +
59 (eta !== null && eta > 0 ? `, ${etaText(eta)} at this pace` : '') +
60 '. /effort medium or a lighter /model makes it last longer.'
61 break
62 }
63 }
64
65 const waitingFor = lim.percent >= FULL && lim.resetsAt ? lim.resetsAt : c.waitingFor
66 return { next: { samples, warned, waitingFor, isReset: c.isReset }, toast }
67}
68
69/** On the clock: a full window whose reset time has passed is ready to go again. */
70export function coachTick(c: Coach, now: number): Coach | null {
71 return c.waitingFor && Date.parse(c.waitingFor) <= now ? { ...c, waitingFor: null, isReset: true } : null
72}
73hooks/gpu.ts 60 lines1// Render watch: reads the GPU's load from nvidia-smi and tells a job's start
2// and end apart from a passing spike.
3
4import type { Gpu } from '../types'
5
6// the GPU counts as working from this load, and as quiet below the next
7const BUSY = 40
8const QUIET = 15
9// a job is over once the GPU has been quiet this long
10const QUIET_MS = 20_000
11// and was a job worth mentioning if it ran at least this long
12const JOB_MS = 60_000
13
14export type GpuTrack = { busySince: number | null; quietSince: number | null }
15export const NO_TRACK: GpuTrack = { busySince: null, quietSince: null }
16
17/** `nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total --format=csv,noheader,nounits`. */
18export function parseSmi(stdout: string): { util: number; memUsed: number; memTotal: number } | null {
19 const [util, used, total] = (stdout.split(/\r?\n/)[0] ?? '').split(',').map(v => Number(v.trim()))
20 if (![util, used, total].every(v => Number.isFinite(v))) return null
21 return { util: util ?? 0, memUsed: (used ?? 0) / 1024, memTotal: (total ?? 0) / 1024 }
22}
23
24/**
25 * One reading: the line to show (null while the GPU idles), and, when a job
26 * just ended, how many minutes it ran.
27 */
28export function gpuStep(
29 track: GpuTrack,
30 now: number,
31 reading: { util: number; memUsed: number; memTotal: number },
32): { track: GpuTrack; gpu: Gpu | null; finishedMinutes: number | null } {
33 let { busySince, quietSince } = track
34 let finishedMinutes: number | null = null
35 if (reading.util >= BUSY) {
36 busySince ??= now
37 quietSince = null
38 } else if (reading.util >= QUIET) {
39 // between quiet and busy: still working, so the quiet stretch starts over
40 quietSince = null
41 } else if (busySince !== null) {
42 quietSince ??= now
43 if (now - quietSince >= QUIET_MS) {
44 if (quietSince - busySince >= JOB_MS) finishedMinutes = Math.max(1, Math.round((quietSince - busySince) / 60_000))
45 busySince = null
46 quietSince = null
47 }
48 }
49 const isShown = reading.util >= QUIET || busySince !== null
50 const gpu = isShown
51 ? {
52 util: Math.round(reading.util),
53 memUsed: Math.round(reading.memUsed),
54 memTotal: Math.round(reading.memTotal),
55 busyMinutes: busySince === null ? 0 : Math.floor((now - busySince) / 60_000),
56 }
57 : null
58 return { track: { busySince, quietSince }, gpu, finishedMinutes }
59}
60hooks/wellness.ts 50 lines1// Wellness nudges: Rocky asks you to rest your eyes and drink water, counted
2// in time you actually work, so sessions that run for days don't matter.
3
4import type { Nudge, Wellness } from '../types'
5
6export const NO_WELLNESS: Wellness = { activeMs: 0, eyesAt: 0, waterAt: 0, due: null }
7
8// you count as working within this long of your last prompt or Claude's last step
9const ACTIVE_WITHIN_MS = 5 * 60_000
10// and as having taken a break once you have been away this long
11const BREAK_MS = 15 * 60_000
12
13export const NUDGE_TOASTS: Record<Nudge, string> = {
14 eyes: 'Rocky says: rest your eyes. Look at something far away for 20 seconds, then click him.',
15 water: 'Rocky says: water break! Have a glass, then click him.',
16}
17
18/**
19 * One clock tick of `tickMs`: adds active time, resets after a real break, and
20 * makes a nudge due when its time comes (eyes first). `nudged` is a nudge that
21 * just became due, for a toast; intervals of 0 turn that nudge off.
22 */
23export function wellnessTick(
24 w: Wellness,
25 awayMs: number,
26 tickMs: number,
27 eyesMs: number,
28 waterMs: number,
29): { next: Wellness; nudged: Nudge | null } {
30 if (awayMs >= BREAK_MS) {
31 return { next: { ...w, eyesAt: w.activeMs, waterAt: w.activeMs, due: null }, nudged: null }
32 }
33 const activeMs = awayMs < ACTIVE_WITHIN_MS ? w.activeMs + tickMs : w.activeMs
34 let due = w.due
35 let nudged: Nudge | null = null
36 if (due === null) {
37 if (eyesMs > 0 && activeMs - w.eyesAt >= eyesMs) due = 'eyes'
38 else if (waterMs > 0 && activeMs - w.waterAt >= waterMs) due = 'water'
39 nudged = due
40 }
41 return { next: { ...w, activeMs, due }, nudged }
42}
43
44/** The pet was clicked: the nudge showing is done, and its clock starts over. */
45export function wellnessDone(w: Wellness): Wellness {
46 if (w.due === 'eyes') return { ...w, eyesAt: w.activeMs, due: null }
47 if (w.due === 'water') return { ...w, waterAt: w.activeMs, due: null }
48 return w
49}
50hooks/format.ts 129 lines1// Numbers, names and shares as the band writes them.
2
3import type { ContextRow } from '../types'
4import { BLUE, BUFFER, FREE, GREEN, MAUVE, PEACH, PINK, TEAL, YELLOW } from './theme'
5
6// Context categories by what their name contains: [match, short label, colour].
7const CATEGORIES: [match: string, short: string, color: string][] = [
8 ['system prompt', 'sys', BLUE],
9 ['system tools', 'tools', MAUVE],
10 ['mcp', 'mcp', PINK],
11 ['agent', 'agents', PEACH],
12 ['memory', 'memory', TEAL],
13 ['skill', 'skills', YELLOW],
14 ['message', 'msgs', GREEN],
15]
16
17export function shortName(name: string): string {
18 const lower = name.toLowerCase()
19 return CATEGORIES.find(([m]) => lower.includes(m))?.[1] ?? lower
20}
21
22export function colorFor(row: ContextRow): string {
23 if (row.kind === 'free') return FREE
24 if (row.kind === 'buffer') return BUFFER
25 const lower = row.name.toLowerCase()
26 return CATEGORIES.find(([m]) => lower.includes(m))?.[2] ?? row.color
27}
28
29/** 812, 8.0k, 84k, 1.2M. */
30export function compact(tokens: number): string {
31 // thresholds sit where rounding would carry: 999.5k shows as 1.0M, 9,950 as 10k
32 if (tokens >= 999_500) return `${(tokens / 1_000_000).toFixed(1)}M`
33 if (tokens >= 9_950) return `${Math.round(tokens / 1000)}k`
34 if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`
35 return `${tokens}`
36}
37
38/** $0.10, <$0.01, $123. */
39export function money(usd: number): string {
40 if (usd > 0 && usd < 0.01) return '<$0.01'
41 return `$${usd < 99.995 ? usd.toFixed(2) : usd.toFixed(0)}`
42}
43
44/** '2h 14m' since `startedAt`; hours always shown, as a session is measured in them. */
45export function sessionLength(now: number, startedAt: number): string {
46 const mins = Math.max(0, Math.floor((now - startedAt) / 60_000))
47 return `${Math.floor(mins / 60)}h ${String(mins % 60).padStart(2, '0')}m`
48}
49
50/** '4d 3h', '2h 10m' or '12m' until `resetsAt`; '' once it has passed or when unknown. */
51export function resetsIn(now: number, resetsAt: string | undefined): string {
52 const ms = resetsAt ? Date.parse(resetsAt) - now : Number.NaN
53 if (!Number.isFinite(ms) || ms <= 0) return ''
54 const mins = Math.round(ms / 60_000)
55 if (mins < 1) return '<1m'
56 if (mins >= 1440) return `${Math.floor(mins / 1440)}d ${Math.floor((mins % 1440) / 60)}h`
57 return mins >= 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`
58}
59
60/** `cells` split over `values` by share, largest remainder first. */
61export function shareCells(values: number[], cells: number): number[] {
62 const sum = values.reduce((a, b) => a + b, 0)
63 if (sum <= 0) return values.map(() => 0)
64 const exact = values.map(v => (v / sum) * cells)
65 const out = exact.map(Math.floor)
66 let left = cells - out.reduce((a, b) => a + b, 0)
67 for (const [, i] of exact.map((x, k) => [x - Math.floor(x), k] as const).sort((a, b) => b[0] - a[0])) {
68 if (left <= 0) break
69 out[i] = (out[i] ?? 0) + 1
70 left -= 1
71 }
72 return out
73}
74
75/** The context bar's cells per row, out of the window; a non-empty used row keeps at least one. */
76export function allocate(rows: ContextRow[], max: number, cells: number): number[] {
77 const out = rows.map(r => (max > 0 ? Math.floor((r.tokens / max) * cells) : 0))
78 const exact = rows.map(r => (max > 0 ? (r.tokens / max) * cells : 0))
79 let left = cells - out.reduce((a, b) => a + b, 0)
80 for (const [, i] of exact.map((x, k) => [x - Math.floor(x), k] as const).sort((a, b) => b[0] - a[0])) {
81 if (left <= 0) break
82 out[i] = (out[i] ?? 0) + 1
83 left -= 1
84 }
85 rows.forEach((r, i) => {
86 if (r.kind === 'used' && r.tokens > 0 && out[i] === 0) {
87 const donor = out.indexOf(Math.max(...out))
88 out[donor] = (out[donor] ?? 1) - 1
89 out[i] = 1
90 }
91 })
92 // past a full window the rows add up to more than the bar: trim the widest,
93 // a whole bar's worth at most so a wild reading can't spin here
94 for (let guard = 0; out.reduce((a, b) => a + b, 0) > cells && guard < cells * 4; guard++) {
95 const widest = out.indexOf(Math.max(...out))
96 out[widest] = (out[widest] ?? 1) - 1
97 }
98 return out
99}
100
101/** 'claude-sonnet-5-5', 'Sonnet 5.5' or the older 'claude-3-5-sonnet-20241022' -> 'Sonnet 5.5' / 'Sonnet 3.5'. */
102export function prettyModel(raw: string): string {
103 // the version after the name, never the start of a date such as 20241022
104 const after = /(opus|sonnet|haiku|fable)[-\s]?(\d{1,2})(?!\d)(?:[-.](\d)(?!\d))?/i.exec(raw)
105 // older ids put the version first
106 const before = /(\d)(?:[-.](\d))?[-.](opus|sonnet|haiku)/i.exec(raw)
107 const [word, major, minor] = after ? [after[1], after[2], after[3]] : before ? [before[3], before[1], before[2]] : []
108 if (!word || !major) return raw.replace(/^claude-/i, '')
109 const name = word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
110 return minor ? `${name} ${major}.${minor}` : `${name} ${major}`
111}
112
113// First-party API rates in dollars per million tokens: input, output,
114// 5-minute cache write (1.25x input) and cache read. Checked 2026-09. An
115// hour-long cache write, what Claude Code uses on a subscription, is 2x input.
116const PRICES: [match: RegExp, rates: [number, number, number, number]][] = [
117 [/fable-5-1|mythos-5-1/, [10, 50, 12.5, 0.25]],
118 [/fable|mythos/, [10, 50, 12.5, 1]],
119 [/opus-5-5/, [4, 20, 5, 0.2]],
120 [/opus/, [5, 25, 6.25, 0.5]],
121 [/sonnet-5/, [2, 10, 2.5, 0.2]],
122 [/sonnet/, [3, 15, 3.75, 0.3]],
123 [/haiku/, [1, 5, 1.25, 0.1]],
124]
125
126export function ratesFor(model: string): [number, number, number, number] {
127 return PRICES.find(([match]) => match.test(model.toLowerCase()))?.[1] ?? [2, 10, 2.5, 0.2]
128}
129hooks/quotes.ts 41 lines1// A fresh one shows with every prompt.
2export const QUOTES = [
3 'Today is a great day to build something remarkable.',
4 "You're one focused hour away from a breakthrough.",
5 'Small steps, big momentum. Keep shipping!',
6 'Done is powerful. Ship it, then make it better.',
7 'Energy flows where focus goes. Lock in!',
8 'Every line you write is a step toward the thing you imagined.',
9 "You've solved harder problems than this one. Go get it!",
10 'Progress, not perfection. Keep the streak alive.',
11 "Be the builder you'd be proud to meet.",
12 'The best time to start was yesterday. The second best is right now!',
13 'Hard things become easy once you begin.',
14 'Make it work, make it right, make it fast. In that order.',
15 'Your future self is cheering for what you do right now.',
16 'Focus is a superpower. Use it well!',
17 "One more commit. One more win. Let's go!",
18 'Great things are built one stubborn hour at a time.',
19 'Curiosity is your edge. Ask the bold question.',
20 "You don't need more time, just more momentum.",
21 'Turn the idea into something real. Right now.',
22 'Discipline today, freedom tomorrow. Keep pushing!',
23 'Bugs are just puzzles in disguise. You love puzzles!',
24 'Build boldly. Learn loudly. Repeat.',
25 "The work you do when it's hard is the work that counts.",
26 'Dream big, ship small, iterate fast.',
27 "You are closer than you think. Don't stop now!",
28 "Make today's version better than yesterday's. That's the whole game.",
29 'Win the hour. The day takes care of itself.',
30 "Believe in the next step. It's always enough.",
31 'Create something that makes you proud. Then do it again!',
32 'Fuel up on focus. The finish line is closer than it looks.',
33]
34
35/** Any quote but the one showing. */
36export function nextQuote(shown: string): string {
37 let next = shown
38 while (next === shown) next = QUOTES[Math.floor(Math.random() * QUOTES.length)] ?? ''
39 return next
40}
41hooks/theme.ts 78 lines1// Colours and colour rules, shared by the band and the fun column.
2// A dark slate card in a soft pastel palette that reads on any terminal theme.
3
4export const BG = '#1a1d29'
5export const BORDER = '#3b4261'
6export const TEXT = '#cdd6f4'
7export const MUTED = '#8087a2'
8export const TRACK = '#444b6a'
9export const FREE = '#353b58'
10export const BUFFER = '#6c4a5a'
11
12export const BLUE = '#89b4fa'
13export const MAUVE = '#cba6f7'
14export const PINK = '#f5c2e7'
15export const PEACH = '#fab387'
16export const YELLOW = '#f9e2af'
17export const GREEN = '#a6e3a1'
18export const TEAL = '#94e2d5'
19export const SKY = '#74c7ec'
20export const LAVENDER = '#b4befe'
21export const RED = '#f38ba8'
22
23export const SIGNATURE_STOPS = [PINK, MAUVE, BLUE]
24
25// the API $ bar's parts, light to dark gold: in, out, cache read, cache write, other
26export const GOLDS = ['#fbe7b5', '#f0c674', '#d19a4e', '#a87642', '#6c6450'] as const
27
28/** Green, then peach, then red as a share fills up. */
29export function percentColor(percent: number): string {
30 if (percent >= 85) return RED
31 if (percent >= 65) return PEACH
32 return GREEN
33}
34
35/** Cool blues to hot reds, by the temperature in Celsius. */
36export function heatColor(temp: number, unit: 'C' | 'F'): string {
37 const c = unit === 'F' ? ((temp - 32) * 5) / 9 : temp
38 if (c < 10) return BLUE
39 if (c < 20) return TEAL
40 if (c < 30) return GREEN
41 if (c < 36) return YELLOW
42 if (c < 40) return PEACH
43 return RED
44}
45
46export type Sky = { glyph: string; word: string; color: string }
47
48/** An Open-Meteo weather code as a one-column glyph, a word and a colour. */
49export function skyStyle(code: number, isDay: boolean): Sky {
50 const cloudy = { glyph: '◐', word: 'Cloudy', color: '#a6adc8' }
51 if (code === 0) return isDay ? { glyph: '☼', word: 'Clear', color: YELLOW } : { glyph: '☾', word: 'Clear', color: LAVENDER }
52 if (code <= 3) return cloudy
53 if (code === 45 || code === 48) return { glyph: '≡', word: 'Fog', color: '#9399b2' }
54 if ((code >= 51 && code <= 67) || (code >= 80 && code <= 82)) return { glyph: '∴', word: 'Rain', color: SKY }
55 if ((code >= 71 && code <= 77) || code === 85 || code === 86) return { glyph: '*', word: 'Snow', color: '#89dceb' }
56 if (code >= 95) return { glyph: 'ϟ', word: 'Storm', color: PEACH }
57 return cloudy
58}
59
60function lerpHex(a: string, b: string, t: number): string {
61 const channels = (hex: string) => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16))
62 const from = channels(a)
63 const to = channels(b)
64 return `#${from.map((v, k) => Math.round(v + ((to[k] ?? v) - v) * t).toString(16).padStart(2, '0')).join('')}`
65}
66
67export type Cell = { ch: string; color: string }
68
69/** Each character of `text` coloured along the stops, left to right. */
70export function gradient(text: string, stops: string[]): Cell[] {
71 const span = Math.max(1, text.length - 1)
72 return [...text].map((ch, i) => {
73 const at = (i / span) * (stops.length - 1)
74 const k = Math.min(stops.length - 2, Math.floor(at))
75 return { ch, color: lerpHex(stops[k] ?? '#ffffff', stops[k + 1] ?? '#ffffff', at - k) }
76 })
77}
78hooks/fun-column.tsx 285 lines1// The fun column's surface module: signature, session, weather, the model and
2// its effort (both clickable), the GPU while it works, the phase animation and
3// the pet. It runs on the surface's own frame clock, so the pet moves without
4// the band redrawing, and it takes clicks itself: no Button, so nothing lights
5// up under the pointer. What a click changes outside the column it posts to
6// the hooks module.
7
8import type { ClientModule } from 'claude-code'
9
10import type { FunMessage, FunProps } from '../types'
11import { FRAME_MS, PET_NAME, REACTION_FRAMES, REACTIONS, petStage, petView, thinkingCells, workingCells } from './pet'
12import {
13 BG,
14 BLUE,
15 LAVENDER,
16 MAUVE,
17 MUTED,
18 PINK,
19 SIGNATURE_STOPS,
20 TEAL,
21 TEXT,
22 TRACK,
23 YELLOW,
24 gradient,
25 percentColor,
26} from './theme'
27
28const SIGNATURE = 'RenegadeRocks'
29const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
30// the picker under the model name: [label, the /model alias it sends]
31const MODELS: [string, string][] = [
32 ['Sonnet', 'sonnet'],
33 ['Opus', 'opus'],
34 ['Fable', 'fable'],
35 ['Haiku', 'haiku'],
36]
37// the gap between the pet and its info rows, side by side
38const SIDE_GAP = 3
39// a full-ish pet explains itself this many frames (5 s), then not again for a minute
40const EXPLAIN_FRAMES = 36
41const EXPLAIN_REST_FRAMES = 430
42// the pet's colour turns peach from this context share, pink from the next
43const WARM = 65
44const FULL = 85
45
46type Local = {
47 frame: number
48 reaction: { kind: string; at: number } | null
49 last: string
50 isHovered: boolean
51 isPicking: boolean
52 /** The frame the pet last said why it is peach or pink; null before. */
53 explainedAt: number | null
54}
55
56const FRESH: Local = { frame: 0, reaction: null, last: '', isHovered: false, isPicking: false, explainedAt: null }
57
58const FunColumn: ClientModule<FunProps, Local> = (props, surface) => {
59 const { Box, Text } = surface.elements
60 if (surface.state === undefined) {
61 surface.setState({ ...FRESH })
62 surface.every(FRAME_MS, () => {
63 const s = surface.state
64 if (s) surface.setState({ ...s, frame: s.frame + 1 })
65 })
66 }
67 const local = surface.state ?? { ...FRESH }
68 const { frame } = local
69 const isPartying = props.celebrate !== null
70
71 const mood = percentColor(props.percent)
72 const stage = petStage(props.pet, props.percent)
73 const reaction = local.reaction ? { kind: local.reaction.kind, t: frame - local.reaction.at } : null
74 const plate = isPartying
75 ? { text: 'job done! click', color: PINK }
76 : props.nudge
77 ? { text: 'click when done', color: PINK }
78 : local.isHovered
79 ? { text: props.percent >= WARM ? `${props.percent}% full · click` : `♥ pet ${PET_NAME}`, color: PINK }
80 : { text: `${PET_NAME} · ${stage.title}`, color: mood }
81 const view = petView(stage.lines, props.pet, props.phase, frame, props.percent >= 85, plate, reaction, props.nudge)
82 // saying why it is peach or pink: two short lines, a few seconds each
83 const isExplaining = local.explainedAt !== null && frame - local.explainedAt < EXPLAIN_FRAMES
84 if (isExplaining) {
85 const width = view.pet[0]?.length ?? 16
86 const said =
87 Math.floor((frame - (local.explainedAt ?? frame)) / 12) % 2 === 0
88 ? props.percent >= FULL
89 ? `I'm ${props.percent}% full!`
90 : `getting full ${props.percent}%`
91 : props.percent >= FULL
92 ? 'Compact helps'
93 : 'Compact soon'
94 const left = Math.max(0, Math.floor((width - said.length) / 2))
95 view.top = [{ text: (' '.repeat(left) + said).padEnd(width).slice(0, width), color: PINK }]
96 }
97 const effortLevel = EFFORTS.indexOf(props.effort ?? '') + 1
98 const anim = props.phase === 'thinking' ? thinkingCells(frame) : props.phase === 'working' ? workingCells(frame) : []
99 const labelColor = props.phase === 'thinking' ? MAUVE : props.phase === 'working' ? YELLOW : MUTED
100 const model = props.model || 'Claude'
101 const showMeter = Boolean(props.effort) && props.showTagline
102
103 // Where things sit, for the pointer. Side by side the info rows start right
104 // of the pet at the top; stacked they start at the column's top-left and the
105 // pet sits under them.
106 const isSide = props.layout === 'side'
107 const petWidth = view.pet[0]?.length ?? 16
108 const infoLeft = isSide ? petWidth + SIDE_GAP : 0
109 const modelRow = props.weather ? 3 : 2
110 const infoRows = modelRow + 1 + (local.isPicking ? 1 : 0) + (props.gpu ? 1 : 0) + 1
111 const nameStart = infoLeft + 2
112 const meterStart = nameStart + model.length + 1
113 const pickerSpots = MODELS.map(([name, alias], i) => {
114 const start = nameStart + MODELS.slice(0, i).reduce((w, [n]) => w + n.length + 2, 0)
115 return { start, end: start + name.length, alias }
116 })
117
118 const post = (message: FunMessage) => surface.post(message)
119
120 surface.onPointer(e => {
121 const s = surface.state
122 if (!s) return
123 const isOverPet = e.type !== 'leave' && (isSide ? e.x < petWidth : e.y >= infoRows)
124 if (e.type === 'down' && e.button === 'left') {
125 if (isOverPet) {
126 if (isPartying) {
127 post({ type: 'celebrate-done' })
128 surface.setState({ ...s, reaction: { kind: 'hearts', at: s.frame }, last: 'hearts' })
129 return
130 }
131 if (props.nudge) {
132 post({ type: 'nudge-done' })
133 surface.setState({ ...s, reaction: { kind: 'hearts', at: s.frame }, last: 'hearts' })
134 return
135 }
136 // peach or pink: the first click says why, in its speech row
137 const isWarm = props.percent >= WARM
138 if (isWarm && (s.explainedAt === null || s.frame - s.explainedAt > EXPLAIN_REST_FRAMES)) {
139 surface.setState({ ...s, explainedAt: s.frame, reaction: null })
140 return
141 }
142 let kind = s.last
143 while (kind === s.last) kind = REACTIONS[Math.floor(Math.random() * REACTIONS.length)] ?? 'hearts'
144 surface.setState({ ...s, reaction: { kind, at: s.frame }, last: kind })
145 return
146 }
147 if (e.y === modelRow && e.x >= nameStart && e.x < nameStart + model.length) {
148 surface.setState({ ...s, isPicking: !s.isPicking })
149 return
150 }
151 if (e.y === modelRow && showMeter && e.x >= meterStart && e.x < meterStart + EFFORTS.length) {
152 post({ type: 'effort', level: EFFORTS[e.x - meterStart] ?? 'high' })
153 return
154 }
155 if (e.y === modelRow && props.effort && !showMeter && e.x >= meterStart) {
156 // no meter in a narrow column: the effort word steps to the next level
157 post({ type: 'effort', level: EFFORTS[(EFFORTS.indexOf(props.effort) + 1) % EFFORTS.length] ?? 'high' })
158 return
159 }
160 const spot = s.isPicking && e.y === modelRow + 1 ? pickerSpots.find(p => e.x >= p.start && e.x < p.end) : undefined
161 if (spot) {
162 post({ type: 'model', alias: spot.alias })
163 surface.setState({ ...s, isPicking: false })
164 }
165 return
166 }
167 if (isOverPet !== s.isHovered) surface.setState({ ...s, isHovered: isOverPet })
168 })
169 // a finished reaction is dropped, so the next click starts clean; while a
170 // GPU job's party waits for a click, one dance or sparkle follows another
171 const isReactionOver = !local.reaction || frame - local.reaction.at >= REACTION_FRAMES
172 if (isPartying && isReactionOver) {
173 const kind = local.last === 'sparkle' ? 'dance' : 'sparkle'
174 surface.setState({ ...local, reaction: { kind, at: frame }, last: kind })
175 } else if (local.reaction && isReactionOver) {
176 surface.setState({ ...local, reaction: null })
177 }
178
179 const t = (color: string, text: string, extra: { bold?: boolean; italic?: boolean } = {}) => (
180 <Text backgroundColor={BG} color={color} {...extra}>
181 {text}
182 </Text>
183 )
184 const isCurrent = (alias: string) => model.toLowerCase().startsWith(alias)
185
186 const info = (
187 <Box flexDirection="column" backgroundColor={BG}>
188 <Text backgroundColor={BG} wrap="truncate-end">
189 {gradient(SIGNATURE, SIGNATURE_STOPS).map((c, i) => (
190 <Text key={`s${i}`} backgroundColor={BG} color={c.color} bold>
191 {c.ch}
192 </Text>
193 ))}
194 {props.showTagline && t(MUTED, ' claude code', { italic: true })}
195 </Text>
196 <Text backgroundColor={BG} wrap="truncate-end">
197 {t(TEAL, '◷ ')}
198 {t(TEXT, props.session, { bold: true })}
199 {t(MUTED, ' session')}
200 </Text>
201 {props.weather && (
202 <Text backgroundColor={BG} wrap="truncate-end">
203 {t(props.weather.color, `${props.weather.glyph} ${props.weather.word}`)}
204 {t(props.weather.tempColor, ` ${props.weather.temp}`, { bold: true })}
205 {props.weather.place ? t(LAVENDER, ` ${props.weather.place}`, { italic: true }) : null}
206 </Text>
207 )}
208 <Text backgroundColor={BG} wrap="truncate-end">
209 {t(BLUE, '◆ ')}
210 {t(TEXT, model, { bold: true })}
211 {showMeter && (
212 <Text backgroundColor={BG}>
213 {t(MAUVE, ` ${'▰'.repeat(effortLevel)}`)}
214 {t(TRACK, '▱'.repeat(EFFORTS.length - effortLevel))}
215 </Text>
216 )}
217 {props.effort ? t(MUTED, ` ${props.effort}`) : null}
218 </Text>
219 {local.isPicking && (
220 <Text backgroundColor={BG} wrap="truncate-end">
221 {t(MUTED, ' ')}
222 {MODELS.map(([name, alias], i) => (
223 <Text key={alias} backgroundColor={BG} color={isCurrent(alias) ? BLUE : MUTED} bold={isCurrent(alias)}>
224 {name}
225 {i < MODELS.length - 1 ? ' ' : ''}
226 </Text>
227 ))}
228 </Text>
229 )}
230 {props.gpu && (
231 <Text backgroundColor={BG} wrap="truncate-end">
232 {t(YELLOW, '▦ GPU ')}
233 {t(percentColor(props.gpu.util), `${props.gpu.util}%`, { bold: true })}
234 {t(MUTED, ` ${props.gpu.memUsed}/${props.gpu.memTotal} GB`)}
235 {props.gpu.busyMinutes > 0 ? t(MUTED, ` · busy ${props.gpu.busyMinutes}m`) : null}
236 </Text>
237 )}
238 <Text backgroundColor={BG}>
239 {anim.map((c, i) => (
240 <Text key={`a${i}`} backgroundColor={BG} color={c.color}>
241 {c.ch}
242 </Text>
243 ))}
244 {t(labelColor, `${anim.length > 0 ? ' ' : ''}${props.phase}`)}
245 </Text>
246 </Box>
247 )
248
249 const pet = (
250 <Box key="pet" flexDirection="column" alignSelf={isSide ? 'flex-start' : 'center'} backgroundColor={BG}>
251 {view.top.map((r, i) => (
252 <Text key={`pt${i}`} backgroundColor={BG} color={r.color}>
253 {r.text}
254 </Text>
255 ))}
256 {view.pet.map((line, i) => (
257 <Text key={`pet-${i}`} backgroundColor={BG} color={mood}>
258 {line}
259 </Text>
260 ))}
261 {view.bottom.map((r, i) => (
262 <Text key={`pb${i}`} backgroundColor={BG} color={r.color}>
263 {r.text}
264 </Text>
265 ))}
266 </Box>
267 )
268
269 return isSide ? (
270 <Box flexDirection="row" alignItems="flex-start" backgroundColor={BG}>
271 {pet}
272 <Box marginLeft={SIDE_GAP} backgroundColor={BG}>
273 {info}
274 </Box>
275 </Box>
276 ) : (
277 <Box flexDirection="column" backgroundColor={BG}>
278 {info}
279 {pet}
280 </Box>
281 )
282}
283
284export default FunColumn
285hooks/desk.tsx 228 lines1// The desk's surface module: a cozy night desk drawn in pixels (see
2// ./desk-art.ts), a notebook strip that holds this project's goal, and a
3// drawer with the focus timer and the ambient sound. It animates on its own
4// clock. Typing and clicks that change something outside it are posted to
5// the hooks module.
6
7import type { ClientModule } from 'claude-code'
8
9import type { Ambient, DeskMessage, DeskProps } from '../types'
10import { COMPACT_DESK_WIDTH, DESK_WIDTH, DRAWER_ROW, NOTE_ROW, paintScene, runs, skyFor } from './desk-art'
11
12const FRAME_MS = 250
13// the lamp, in cells: a click on it switches it off and on (the compact desk leaves it out)
14const LAMP = { x0: 32, x1: 42, y0: 1, y1: 5 }
15// the notebook strip: its width inside the wood, the longest goal, and the hint shown while typing
16const MAX_GOAL = 120
17const SAVE_HINT = ' enter saves '
18const COMPACT_SAVE_HINT = ' ↵ save '
19const PENCIL = ' ✎ '
20// an open note with no typing for this many frames (30 s) saves itself: Escape
21// hands the keyboard back without telling the desk
22const IDLE_SAVE_FRAMES = 120
23
24const WOOD = '#5e3b28'
25const PAPER = '#ddd2ba'
26const INK = '#2a2340'
27const FADED_INK = '#7a7286'
28const BRASS = '#f9e2af'
29const TEAL = '#94e2d5'
30
31const SOUND_NAMES: Record<Ambient, string> = { off: 'sounds off', rain: 'rain', storm: 'thunderstorm', fire: 'fireplace', focus: 'deep focus' }
32const SHORT_SOUND_NAMES: Record<Ambient, string> = { off: 'off', rain: 'rain', storm: 'storm', fire: 'fire', focus: 'focus' }
33
34type Local = {
35 frame: number
36 baseNow: number
37 baseFrame: number
38 isEditing: boolean
39 draft: string
40 /** The frame of the last key typed into the note. */
41 typedAt: number
42 isLampOn: boolean
43}
44
45// keys that arrive by name rather than as the character they type
46const NAMED_KEYS = new Set([
47 'up', 'down', 'left', 'right', 'tab', 'delete', 'insert', 'pageup', 'pagedown',
48 'home', 'end', 'escape', 'return', 'backspace', 'clear', 'capslock',
49])
50
51/**
52 * What a key types into the note: a character, a space, or a pasted run of
53 * text; null for a named key such as 'pagedown' or 'f5'.
54 */
55function typedText(key: string): string | null {
56 if (key === 'space') return ' '
57 if (NAMED_KEYS.has(key) || /^f\d{1,2}$/.test(key)) return null
58 return key.replace(/[\r\n\t]+/g, ' ')
59}
60
61/** How many terminal cells a character takes: two for wide East Asian scripts and emoji. */
62function cellsOf(ch: string): number {
63 const code = ch.codePointAt(0) ?? 0
64 const isWide =
65 (code >= 0x1100 && code <= 0x115f) ||
66 (code >= 0x2e80 && code <= 0xa4cf) ||
67 (code >= 0xac00 && code <= 0xd7a3) ||
68 (code >= 0xf900 && code <= 0xfaff) ||
69 (code >= 0xfe30 && code <= 0xfe4f) ||
70 (code >= 0xff00 && code <= 0xff60) ||
71 (code >= 0xffe0 && code <= 0xffe6) ||
72 (code >= 0x1f300 && code <= 0x1faff) ||
73 (code >= 0x20000 && code <= 0x3fffd)
74 return isWide ? 2 : 1
75}
76
77/** As much of the text as fits in `width` cells, from its start or (`fromEnd`) its end, and the cells it takes. */
78function fitCells(text: string, width: number, fromEnd = false): { text: string; cells: number } {
79 const chars = [...text]
80 if (fromEnd) chars.reverse()
81 let used = 0
82 const kept: string[] = []
83 for (const ch of chars) {
84 const w = cellsOf(ch)
85 if (used + w > width) break
86 kept.push(ch)
87 used += w
88 }
89 if (fromEnd) kept.reverse()
90 return { text: kept.join(''), cells: used }
91}
92
93function clockText(ms: number): string {
94 const total = Math.max(0, Math.ceil(ms / 1000))
95 return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
96}
97
98const Desk: ClientModule<DeskProps, Local> = (props, surface) => {
99 const { Box, Text } = surface.elements
100 // the full desk, or the compact one: the scene's left part and a shorter drawer
101 const width = props.isCompact ? COMPACT_DESK_WIDTH : DESK_WIDTH
102 const timerEnd = props.isCompact ? 18 : 26
103 const soundStart = width - (props.isCompact ? 10 : 16)
104 const noteWidth = width - 2
105 const saveHint = props.isCompact ? COMPACT_SAVE_HINT : SAVE_HINT
106 const fresh: Local = { frame: 0, baseNow: props.now, baseFrame: 0, isEditing: false, draft: '', typedAt: 0, isLampOn: true }
107 if (surface.state === undefined) {
108 surface.setState(fresh)
109 surface.every(FRAME_MS, () => {
110 const s = surface.state
111 if (s) surface.setState({ ...s, frame: s.frame + 1 })
112 })
113 }
114 const local = surface.state ?? fresh
115 // the hooks hand a fresh time every few seconds; between them, count frames
116 const isNewTime = props.now !== local.baseNow
117 if (isNewTime) surface.setState({ ...local, baseNow: props.now, baseFrame: local.frame })
118 const now = isNewTime ? props.now : props.now + (local.frame - local.baseFrame) * FRAME_MS
119
120 const post = (message: DeskMessage) => surface.post(message)
121 const saveGoal = (s: Local) => {
122 post({ type: 'goal', text: s.draft })
123 surface.setState({ ...s, isEditing: false })
124 }
125
126 surface.onPointer(e => {
127 const s = surface.state
128 if (!s || e.type !== 'down' || e.button !== 'left') return
129 if (e.y === NOTE_ROW) {
130 // the click gives the desk the keyboard; a second click saves
131 if (s.isEditing) saveGoal(s)
132 else surface.setState({ ...s, isEditing: true, draft: props.goal.trim(), typedAt: s.frame })
133 } else if (e.y === DRAWER_ROW && e.x < timerEnd) {
134 post({ type: 'pomodoro' })
135 } else if (e.y === DRAWER_ROW && e.x >= soundStart) {
136 post({ type: 'ambient' })
137 } else if (!props.isCompact && e.x >= LAMP.x0 && e.x <= LAMP.x1 && e.y >= LAMP.y0 && e.y <= LAMP.y1) {
138 surface.setState({ ...s, isLampOn: !s.isLampOn })
139 }
140 })
141
142 // while the note is open, typing goes into it: Enter saves, Backspace erases
143 surface.onKey(e => {
144 const s = surface.state
145 if (!s?.isEditing || e.ctrl || e.meta) return
146 if (e.key === 'return') saveGoal(s)
147 // by characters, not UTF-16 units, so an emoji is erased whole
148 else if (e.key === 'backspace') surface.setState({ ...s, draft: [...s.draft].slice(0, -1).join(''), typedAt: s.frame })
149 else {
150 const typed = typedText(e.key)
151 if (typed !== null) {
152 surface.setState({ ...s, draft: [...(s.draft + typed)].slice(0, MAX_GOAL).join(''), typedAt: s.frame })
153 }
154 }
155 })
156
157 const latest = surface.state ?? local
158 if (latest.isEditing && latest.frame - latest.typedAt > IDLE_SAVE_FRAMES) saveGoal(latest)
159
160 // the window's sky follows the focus timer: dawn as you focus, morning on the break
161 const full = paintScene(local.frame, new Date(now), props.ambient, local.isLampOn, skyFor(props.pomodoro, now), props.glow)
162 const scene = props.isCompact ? full.map(row => row.slice(0, width)) : full
163
164 // the drawer: the focus timer on the left, the ambient sound on the right
165 const { phase, endsAt, rounds } = props.pomodoro
166 const timer =
167 phase === 'idle' || endsAt === null
168 ? props.isCompact
169 ? '◷ 25-min focus'
170 : '◷ start a 25-min focus'
171 : `${phase === 'focus' ? '◷' : '◌'} ${clockText(endsAt - now)} ${phase === 'focus' ? 'focus' : 'break'}${props.isCompact ? ' ' : ' '}${'●'.repeat(rounds % 4)}${'○'.repeat(4 - (rounds % 4))}`
172 const sound = `♪ ${(props.isCompact ? SHORT_SOUND_NAMES : SOUND_NAMES)[props.ambient]}`
173 const drawer = ` ${timer}`.padEnd(soundStart).slice(0, soundStart) + sound.padStart(width - soundStart - 1) + ' '
174
175 const goal = props.goal.trim()
176 // while typing, the end of the draft stays in view, with a blinking caret;
177 // widths count terminal cells, so wide characters can't push the row out
178 // the pencil draws two cells wide in some terminals, so two spaces follow it
179 const room = noteWidth - PENCIL.length - 1 - saveHint.length
180 const caret = local.frame % 4 < 2 ? '▏' : ' '
181 const tail = fitCells(local.draft, room, true)
182 const typing = `${PENCIL}${tail.text}${caret}${' '.repeat(room - tail.cells)}`
183 const head = fitCells(`${PENCIL}${goal || (props.isCompact ? "today's goal" : "click to write today's goal")}`, noteWidth)
184 const note = head.text + ' '.repeat(noteWidth - head.cells)
185
186 return (
187 <Box flexDirection="column" backgroundColor={WOOD}>
188 {scene.map((row, y) => (
189 <Text key={`r${y}`} backgroundColor={WOOD}>
190 {runs(row).map((cell, i) => (
191 <Text key={`c${i}`} color={cell.fg} backgroundColor={cell.bg} bold={cell.bold}>
192 {cell.ch}
193 </Text>
194 ))}
195 </Text>
196 ))}
197 <Text backgroundColor={WOOD}>
198 <Text backgroundColor={WOOD}> </Text>
199 {local.isEditing ? (
200 <Text backgroundColor={PAPER}>
201 <Text backgroundColor={PAPER} color={INK}>
202 {typing}
203 </Text>
204 <Text backgroundColor={PAPER} color={FADED_INK} italic>
205 {saveHint}
206 </Text>
207 </Text>
208 ) : (
209 <Text backgroundColor={PAPER} color={goal ? INK : FADED_INK} italic={!goal}>
210 {note}
211 </Text>
212 )}
213 <Text backgroundColor={WOOD}> </Text>
214 </Text>
215 <Text backgroundColor={WOOD}>
216 <Text backgroundColor={WOOD} color={BRASS}>
217 {drawer.slice(0, soundStart)}
218 </Text>
219 <Text backgroundColor={WOOD} color={TEAL}>
220 {drawer.slice(soundStart)}
221 </Text>
222 </Text>
223 </Box>
224 )
225}
226
227export default Desk
228hooks/pet.ts 225 lines1// Rocky the cat and the phase animations: pure functions of a frame number,
2// drawn by the fun column's surface module on its own frame clock.
3
4import type { Phase } from '../types'
5import { GREEN, LAVENDER, MAUVE, MUTED, PINK, SKY, TEAL, TRACK, YELLOW } from './theme'
6import type { Cell } from './theme'
7
8export const FRAME_MS = 140
9export const PET_NAME = 'Rocky'
10
11export type PetKind = 'cat' | 'dog'
12
13// One entry per growth stage: [context percent it appears from, its title,
14// lines]. {E} is its three-character face and ¤ its tail; every line of a
15// stage has the same width, and both pets grow one row per stage.
16const CAT: [number, string, string[]][] = [
17 [0, 'tiny', ['=({E})=']],
18 [10, 'kitten', [' /\\_/\\ ', '=( {E} )=']],
19 [25, 'pouncer', [' /\\_/\\ ', '=( {E} )=', ' > ^ < ']],
20 [40, 'prowler', [' /\\_/\\ ', '=( {E} )=', ' > ^ < ', '(______)¤']],
21 [55, 'hunter', [' /\\_/\\ ', ' =( {E} )= ', ' > ^ < ', ' /| |\\ ', '(_|_____|_)¤']],
22 [70, 'alpha', [' /\\___/\\ ', ' ( {E} ) ', ' =( ^ )= ', ' /| |\\ ', ' / | | \\ ', '(__|_______|__)¤']],
23 [85, 'legend', [" ' ! ' ", ' /\\___/\\ ', ' ( {E} ) ', ' =( ^ )= ', ' /| |\\ ', ' / | | \\ ', '(__|_______|__)¤']],
24]
25
26// The dog: floppy U ears, a snout, and a tail that wags.
27const DOG: [number, string, string[]][] = [
28 [0, 'tiny', ['U({E})U']],
29 [10, 'puppy', [' .-----. ', 'U( {E} )U']],
30 [25, 'pup', [' .-----. ', 'U( {E} )U', ' \\_w_/ ']],
31 [40, 'buddy', [' .-----. ', 'U( {E} )U', ' \\_w_/ ', ' (_| |_)¤']],
32 [55, 'good dog', [' .-------. ', 'U( {E} )U', ' \\__w__/ ', ' / \\¤ ', ' (_| |_) ']],
33 [70, 'alpha', [' .-------. ', ' U( {E} )U ', ' \\__w__/ ', ' / \\ ', ' /| |\\¤ ', ' (_|_____|_) ']],
34 [85, 'legend', [' ! ! ! ', ' .-------. ', ' U( {E} )U ', ' \\__w__/ ', ' / \\ ', ' /| |\\¤ ', ' (_|_____|_) ']],
35]
36
37export function petStage(kind: PetKind, percent: number): { title: string; lines: string[] } {
38 const stages = kind === 'dog' ? DOG : CAT
39 const stage = [...stages].reverse().find(([from]) => percent >= from) ?? stages[0]
40 return { title: stage?.[1] ?? 'tiny', lines: stage?.[2] ?? [] }
41}
42
43/** The dots that ripple while the model thinks. */
44export function thinkingCells(frame: number): Cell[] {
45 const glyphs = ['·', '∙', '•', '●']
46 const colors = ['#5b4a7a', '#8a6bbf', '#b48ef0', '#e0c8ff']
47 return Array.from({ length: 8 }, (_, i) => {
48 const level = Math.round(((Math.sin(frame * 0.55 - i * 0.8) + 1) / 2) * 3)
49 return { ch: glyphs[level] ?? '·', color: colors[level] ?? '#5b4a7a' }
50 })
51}
52
53/** The scanner that sweeps while a tool runs. */
54export function workingCells(frame: number): Cell[] {
55 const width = 8
56 const period = 2 * (width - 1)
57 const step = frame % period
58 const pos = step < width ? step : period - step
59 const looks: [string, string][] = [
60 ['█', YELLOW],
61 ['▓', '#c9b27f'],
62 ['▒', '#7d6f4a'],
63 ]
64 return Array.from({ length: width }, (_, i) => {
65 const [ch, color] = looks[Math.abs(i - pos)] ?? ['░', '#3a3f58']
66 return { ch, color }
67 })
68}
69
70/** What the cat is doing this frame: its face, the row above it, its tail, a lean and what it sits on. */
71type Pose = { face: string; top: string; topColor: string; tail: string; shift: number; prop: string; propColor: string }
72
73// idle poses move on every third frame, a calm pace
74const BEAT = 3
75const GROUND = '‾‾‾‾‾‾‾‾‾‾'
76const CODE_FEED = '{ } </> (); => [ ] && :: ++ '
77const NAP = ['z', 'z Z', 'z Z z', 'Z z', ' z']
78
79function poseFor(phase: Phase, frame: number, stressed: boolean): Pose {
80 const beat = Math.floor(frame / BEAT)
81 let pose: Pose
82 if (phase === 'working') {
83 // typing on its laptop under a stream of code
84 const bob = Math.floor(frame / 2) % 2
85 const at = frame % CODE_FEED.length
86 pose = {
87 face: frame % 17 === 0 ? '-.-' : 'O.O',
88 top: CODE_FEED.repeat(3).slice(at, at + 12),
89 topColor: GREEN,
90 tail: bob ? '≈' : '~',
91 shift: bob,
92 prop: bob ? '[▪▫▪▫▪▫▪]' : '[▫▪▫▪▫▪▫]',
93 propColor: YELLOW,
94 }
95 } else if (phase === 'thinking') {
96 // head tilting under a thought bubble
97 pose = {
98 face: ['o.o', 'o.O', 'o.o', 'O.o'][beat % 4] ?? 'o.o',
99 top: ['( . )', '( . . )', '( . . . )', '( ? )'][beat % 4] ?? '',
100 topColor: MAUVE,
101 tail: beat % 2 ? '≈' : '~',
102 shift: beat % 4 >= 2 ? 1 : 0,
103 prop: GROUND,
104 propColor: TRACK,
105 }
106 } else {
107 // idle: sits and swishes, blinks, looks around, grooms, yawns, naps
108 const i = beat % 48
109 let face = '^.^'
110 let top = ''
111 let topColor = MUTED
112 if (i === 12 || i === 27) face = '-.-'
113 else if (i === 13) face = '<.<'
114 else if (i === 14) face = '>.>'
115 else if (i >= 15 && i <= 18) {
116 face = '-.-'
117 top = i % 2 ? 'lick lick' : 'lick'
118 topColor = PINK
119 } else if (i >= 19 && i <= 22) {
120 face = '=O='
121 top = 'yaaawn~'
122 topColor = LAVENDER
123 } else if (i >= 28) {
124 face = 'u_u'
125 top = NAP[(i - 28) % NAP.length] ?? 'z'
126 topColor = LAVENDER
127 }
128 pose = {
129 face,
130 top,
131 topColor,
132 tail: i >= 28 ? '~' : Math.floor(i / 2) % 2 ? '≈' : '~',
133 shift: 0,
134 prop: GROUND,
135 propColor: TRACK,
136 }
137 }
138 if (stressed && pose.face !== 'u_u') pose.face = '>.<'
139 return pose
140}
141
142export const REACTIONS = ['hearts', 'jump', 'dance', 'purr', 'sparkle', 'spin']
143export const REACTION_FRAMES = 18
144
145export type Row = { text: string; color: string }
146export type PetView = { top: Row[]; pet: string[]; bottom: Row[] }
147
148function centreIn(text: string, width: number): string {
149 const left = Math.max(0, Math.floor((width - text.length) / 2))
150 return (' '.repeat(left) + text).padEnd(width).slice(0, width)
151}
152
153function sprinkle(width: number, chars: string[], t: number, count: number, step: number): string {
154 const row = Array.from({ length: width }, () => ' ')
155 for (let i = 0; i < count; i++) {
156 row[(t * step + i * 5) % width] = chars[(t + i) % chars.length] ?? ' '
157 }
158 return row.join('')
159}
160
161function shifted(line: string, by: number): string {
162 if (by === 0) return line
163 return by > 0 ? ' '.repeat(by) + line.slice(0, line.length - by) : line.slice(-by) + ' '.repeat(-by)
164}
165
166/**
167 * The cat this frame: a row above it, the cat, then what it sits on and its
168 * nameplate. A reaction `t` frames after a click takes over the face and the
169 * row above. Every frame of a stage has the same rows, so nothing jumps.
170 */
171export function petView(
172 template: string[],
173 kind: PetKind,
174 phase: Phase,
175 frame: number,
176 stressed: boolean,
177 plate: Row,
178 reaction: { kind: string; t: number } | null,
179 nudge: 'eyes' | 'water' | null = null,
180): PetView {
181 const pose = poseFor(phase, frame, stressed)
182 // a wellness nudge takes over the pose until the pet is clicked
183 if (nudge === 'eyes') Object.assign(pose, { face: '-.-', top: 'rest your eyes', topColor: LAVENDER })
184 if (nudge === 'water') {
185 const sip = Math.floor(frame / BEAT) % 2
186 Object.assign(pose, { face: sip ? '^o^' : '^.^', top: 'water break?', topColor: SKY, prop: 'c[_]', propColor: SKY })
187 }
188 const width = Math.max(14, ...template.map(l => l.length)) + 2
189 const fit = (l: string) => centreIn(l, width)
190 const row = (text: string, color: string): Row => ({ text: fit(text), color })
191 const dress = (face: string, by: number) =>
192 template.map(l => shifted(fit(l.replace('{E}', face).replace('¤', pose.tail)), by))
193 const ground = row(pose.prop, pose.propColor)
194 const name = row(plate.text, plate.color)
195 const plain: PetView = { top: [row(pose.top, pose.topColor)], pet: dress(pose.face, pose.shift), bottom: [ground, name] }
196 if (!reaction || reaction.t < 0 || reaction.t >= REACTION_FRAMES) return plain
197
198 const t = reaction.t
199 switch (reaction.kind) {
200 case 'hearts':
201 return { ...plain, top: [{ text: sprinkle(width, ['♥', '♡'], t, 3, 2), color: PINK }], pet: dress('^w^', 0) }
202 case 'jump':
203 // up in the air, its shadow left on the ground below
204 return t % 4 < 2
205 ? { top: [], pet: dress('^o^', 0), bottom: [row('', MUTED), row(GROUND, TRACK), name] }
206 : { ...plain, top: [row('', MUTED)], pet: dress('^o^', 0) }
207 case 'dance':
208 return { ...plain, top: [row(t % 2 ? '♪ ♫' : '♫ ♪', LAVENDER)], pet: dress('^v^', [0, 1, 0, -1][t % 4] ?? 0) }
209 case 'purr':
210 return {
211 ...plain,
212 top: [row((kind === 'dog' ? ['woof!', 'wag wag', 'woof woof!'] : ['~ purr ~', '~~ purrr ~~', '~ purrrr ~'])[t % 3] ?? '', PINK)],
213 pet: dress('-w-', 0),
214 }
215 case 'sparkle':
216 return { ...plain, top: [{ text: sprinkle(width, ['✦', '✧', '·'], t, 4, 3), color: YELLOW }], pet: dress('*.*', 0) }
217 default:
218 return {
219 ...plain,
220 top: [row(['◜ ◝', '◝ ◞', '◞ ◟', '◟ ◜'][t % 4] ?? '', TEAL)],
221 pet: dress(['@.@', 'o.<', 'o.o', '>.o'][t % 4] ?? 'o.o', 0),
222 }
223 }
224}
225types/index.d.ts 160 lines1export type ContextRow = {
2 name: string
3 tokens: number
4 color: string
5 kind: 'used' | 'free' | 'buffer' | 'deferred'
6}
7
8/** One rate-limit window: how much of it is used, and when it resets. */
9export type Limit = { percent: number; resetsAt?: string }
10
11/** Everything the band reads off the session, refreshed at each prompt and turn end. */
12export type ContextSnap = {
13 percent: number
14 totalTokens: number
15 maxTokens: number
16 rows: ContextRow[]
17 /** The five-hour window, null until a response has reported it. */
18 fiveHour: Limit | null
19 /** The seven-day window, null until a response has reported it. */
20 sevenDay: Limit | null
21 /** When the session began, in clock milliseconds. */
22 startedAt: number
23 /** The model in use, as '/model' names it: 'Sonnet 5.5'. */
24 model: string
25 /** The model's saved effort level, if it has one. */
26 effort: string | null
27 /** What the session has cost at API rates, as /cost totals it; null where unknown. */
28 costUsd: number | null
29 /** The last reply's input, split by how the prompt cache handled it; null before any reply. */
30 lastReply: { input: number; cacheRead: number; cacheWrite: number } | null
31}
32
33/** Tokens and their API-rate cost, summed over every turn the mod has seen. */
34export type Spend = {
35 input: number
36 output: number
37 cacheRead: number
38 cacheWrite: number
39 usdInput: number
40 usdOutput: number
41 usdRead: number
42 usdWrite: number
43 /** What the cache reads would have cost at the full input rate, less what they did cost. */
44 usdSaved: number
45}
46
47/** Limit Coach's memory of the 5-hour window. */
48export type Coach = {
49 /** [clock ms, percent] readings from the last hour, oldest first. */
50 samples: [number, number][]
51 /** The highest warning given in this window: 0, 80 or 95. */
52 warned: number
53 /** When a full window resets (its resetsAt), while waiting for it; else null. */
54 waitingFor: string | null
55 /** True once a full window has reset, until Continue or the next prompt. */
56 isReset: boolean
57}
58
59/** A live subagent, under the fun name it was given. */
60export type AgentChip = { id: string; name: string; type: string }
61
62export type Phase = 'idle' | 'thinking' | 'working'
63
64export type Weather = {
65 place: string
66 temp: number
67 unit: 'C' | 'F'
68 code: number
69 isDay: boolean
70}
71
72/** The GPU while it works: load, memory in GB, and minutes busy (0 when only warm). */
73export type Gpu = { util: number; memUsed: number; memTotal: number; busyMinutes: number }
74
75/** A wellness nudge Rocky is giving: rest your eyes, or drink some water. */
76export type Nudge = 'eyes' | 'water'
77
78/** Active time and when each nudge was last answered, in active milliseconds. */
79export type Wellness = { activeMs: number; eyesAt: number; waterAt: number; due: Nudge | null }
80
81/** The newest image or video saved under the project this session. */
82export type Output = { path: string; name: string; mtimeMs: number }
83
84/** What the fun column's surface module draws from; plain data. */
85export type FunProps = {
86 showTagline: boolean
87 /** 'side': the pet beside its info rows (wide terminals); 'stack': the info rows above it. */
88 layout: 'side' | 'stack'
89 pet: 'cat' | 'dog'
90 /** The model and its effort, shown under the weather: 'Opus 5.5', 'high' (null when none). */
91 model: string
92 effort: string | null
93 session: string
94 weather: { glyph: string; word: string; color: string; temp: string; tempColor: string; place: string } | null
95 phase: Phase
96 percent: number
97 /** The GPU's line, while it works; null when idle or absent. */
98 gpu: Gpu | null
99 /** The nudge to show, until the pet is clicked. */
100 nudge: Nudge | null
101 /** Minutes the GPU job that just finished ran; the pet celebrates until clicked. */
102 celebrate: number | null
103}
104
105/** The desk's focus timer: 25 minutes of focus, then a 5-minute break. */
106export type Pomodoro = { phase: 'idle' | 'focus' | 'break'; endsAt: number | null; rounds: number }
107
108/** The desk's ambient sound. */
109export type Ambient = 'off' | 'rain' | 'storm' | 'fire' | 'focus'
110
111/** How strongly the desk's clock and lamp glow: `/context-bar glow soft|bright`. */
112export type Glow = 'soft' | 'bright'
113
114/** What the desk's surface module draws from; plain data. */
115export type DeskProps = {
116 goal: string
117 pomodoro: Pomodoro
118 ambient: Ambient
119 glow: Glow
120 /** The narrow desk: the scene's left part, and a shorter drawer. */
121 isCompact: boolean
122 now: number
123}
124
125/** What the desk posts to the hooks module when it is clicked or typed into. */
126export type DeskMessage = { type: 'goal'; text: string } | { type: 'pomodoro' } | { type: 'ambient' }
127
128/** What the fun column posts to the hooks module when it is clicked. */
129export type FunMessage =
130 | { type: 'effort'; level: string }
131 | { type: 'model'; alias: string }
132 | { type: 'nudge-done' }
133 | { type: 'celebrate-done' }
134
135declare module 'claude-code' {
136 interface PluginState {
137 'context-bar': {
138 snap: ContextSnap | null
139 isOn: boolean
140 phase: Phase
141 weather: Weather | null
142 agents: AgentChip[]
143 quote: string
144 spend: Spend
145 pet: 'cat' | 'dog'
146 coach: Coach
147 gpu: Gpu | null
148 celebrate: number | null
149 /** This project's goal, written in the desk's notebook. */
150 goal: string
151 pomodoro: Pomodoro
152 ambient: Ambient
153 wellness: Wellness
154 output: Output | null
155 /** An effort chosen from the card or typed, for the model it was set on. */
156 effortChoice: { model: string; level: string } | null
157 }
158 }
159}
160