SLOPSHOPPER

context-bar

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.

newbandguardcommandtoastprompt
v0.1.0no licenseupdated 2026-10-09RenegadeRocks/claude-mods/context-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-bar
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /context-bar ⎿ context-bar: Context bar off, in every session until /context-bar on. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

context-bar

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.

  • Notebook: click the paper strip and type this project's goal; it's there again next time you open the project.
  • Focus timer: click "start a 25-min focus" for a Pomodoro: 25 minutes of focus, a bell, then a 5-minute break. The dots count your rounds.
  • Ambient sound: click the sound on the right to step through rain, thunderstorm, fireplace, deep focus and off. Rain falls on the window, lightning flashes in the storm, and the lamp flickers by the fire. Your choice carries over to the next session.

Install

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.

Commands

CommandWhat it does
/context-bar offHides the card in every session until you turn it back on
/context-bar onShows it again
/context-bar pet dogRocky becomes a dog (remembered)
/context-bar pet catRocky becomes a cat (remembered)
/context-bar sounds offThe pet stops chiming (remembered); sounds on brings it back
/context-bar nudges offNo more eye and water nudges (remembered); nudges on brings them back
/context-bar glow brightThe desk's clock and lamp glow at full strength (remembered); glow soft tones them down

Settings

Run /plugin configure context-bar@renegaderocks to change these:

  • Weather city: Ludhiana by default. Leave it empty to use your approximate location from your IP address.
  • Temperature unit: Celsius or Fahrenheit.
  • Pet: cat or dog.
  • Rest-your-eyes reminder and Water reminder: minutes of active work between nudges; 0 turns one off.

Good to know

  • The weather comes from Open-Meteo, refreshed every 15 minutes.
  • The API cost is what this session would cost at Anthropic's published API rates. It comes from Claude Code's own /cost total, split into input, output and cache.
  • Below about 48 columns the card drops the left side and keeps the numbers.

Made by RenegadeRocks with Claude Code.

Source 12 files
hooks/register.tsx 1296 lines
1// 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        `&current=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 lines
1// 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}
301
hooks/coach.ts 73 lines
1// 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}
73
hooks/gpu.ts 60 lines
1// 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}
60
hooks/wellness.ts 50 lines
1// 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}
50
hooks/format.ts 129 lines
1// 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}
129
hooks/quotes.ts 41 lines
1// 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}
41
hooks/theme.ts 78 lines
1// 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}
78
hooks/fun-column.tsx 285 lines
1// 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
285
hooks/desk.tsx 228 lines
1// 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
228
hooks/pet.ts 225 lines
1// 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}
225
types/index.d.ts 160 lines
1export 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