SLOPSHOPPER

nukey

Nukey the microwave sits above the prompt and acts out what Claude is doing: reading, writing code, thinking, searching and more, with a DING! when the turn is…

newbandguardcommandtimeragents
v0.2.0MITupdated 2026-10-05arifamir/nukey-kit/plugins/nukey
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · nukey
› 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 › /nukey ⎿ nukey: Nukey is hidden. Type /nukey to bring it back. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

nukey

A Claude Code mod: Nukey, a small microwave with a face in its window, sits above the prompt and acts out what Claude is doing, in the terminal and in the desktop app. When a turn is done it goes DING! Beside it, a control panel shows how full the context window is and how much of your rate limits is used.

Optimised for the Claude Code desktop app.

Nukey cycling through thinking, reading, searching, writing code, running tests, git and done

Install

claude plugin marketplace add arifamir/nukey-kit
claude plugin install nukey@nukey-kit

Requires Claude Code v2.1.287 or later; tested with v2.1.289.

To get new versions, run claude plugin update nukey@nukey-kit, or enable auto-update for the marketplace under /plugin → Marketplaces.

Commands

| Command | What it does | | :- | :- | | /nukey | Hide or show Nukey | | /nukey demo | Play every scene, three seconds each | | /nukey meter | Hide or show the control-panel meter | | /nukey details | Show or hide the meter's readings in figures beside the control panel | | /nukey commands | Show commands as typed, or go back to the description of what they do | | /nukey theme | Switch to the next colour theme | | /nukey theme <name> | Switch to a theme by name: classic, retro, steel, midnight or bubblegum |

Themes

Nukey comes in five finishes. The theme colours Nukey, its helpers and the control panel in the desktop app, and Nukey and the meter in the terminal. Your pick is remembered across sessions.

Nukey in each theme: classic cream, retro mint, stainless steel, midnight and bubblegum

| Theme | Look | | :- | :- | | classic | Cream body, Claude-orange power bars (the default) | | retro | 1950s mint green with warm eyes | | steel | Stainless steel with ice-blue eyes | | midnight | Dark body with amber eyes, for dark setups | | bubblegum | Pink all over |

Scenes

The scene follows the tool Claude calls. A shell command is read for what it does, so cat shows reading and cd repo && git status shows git.

| Scene | Shown when | | :- | :- | | thinking, writing a reply | Claude reasons or writes text | | reading | Read, or cat, head, tail, sed -n, jq in a shell | | writing code | Edit, Write, or sed -i, cat > file in a shell | | searching | Grep, Glob, or grep, rg, find, ls in a shell | | browsing the web | WebFetch, WebSearch, browser tools, curl, wget | | running a command | any other shell command | | running tests | pytest, vitest, jest, npm test, cargo test and the like | | working with git | git and gh, also further on in a chain such as make build && git status | | installing packages | npm install, pip install, uv add, brew install and the like | | putting helpers to work | subagents and workflows, during the turn and while they run on after it; the line under it says how many are at work and what they are doing, such as 3 helpers: 2 reading, 1 running tests | | making a plan | plan mode and task lists | | loading a skill | Skill | | writing text | an edit to a text file: .md, .txt, .rst, README, CHANGELOG and the like | | designing | an edit to a stylesheet or an .svg, a design or Figma tool, a design or diagramming skill, the start of an artifact, and an edit to an .html page later in that same turn | | remembering something | an edit to CLAUDE.md, MEMORY.md or a memory file | | sharing something | artifacts and files sent to you | | a question for you, waiting for permission | Claude asks, or a permission dialog is open | | waiting on a background task | Claude polls a background command or timer, or the turn ended with a command still running | | using a tool | any other tool | | tidying up its memory | the conversation is compacted | | oops | a tool call failed, or an API error or refusal ended the turn | | DING!, resting | the turn ended |

Work inside a subagent is not shown; Nukey stays on "putting helpers to work" until the helper returns.

Meter

The control panel lights up as things are used:

  • Power bars: the context window.
  • Five buttons: the 5-hour rate limit.
  • Seven smaller buttons: the weekly rate limit.

The last two units of a gauge turn red above 85%. The buttons are left out on accounts without rate-limit windows.

The meter comes in two forms, and /nukey meter hides it altogether:

| Default | With details (/nukey details) | | :- | :- | | The meter filling up: the power bars with the context window, the buttons with the rate limits | The same meter with its readings in figures beside it |

Development

claude --plugin-dir ./plugins/nukey   # load from disk, reload on save
claude plugin test plugins/nukey      # run the tests
claude plugin validate plugins/nukey  # check the manifest and hooks
npx tsx plugins/nukey/scripts/make-demo.mts  # redraw the animations in assets/

The editor types under .claude-plugin/types/ are generated and not committed. Claude Code writes them for your version when it loads the mod with --plugin-dir.


An unofficial community project, not affiliated with or endorsed by Anthropic.

Source 6 files
hooks/register.tsx 932 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Activity, Meter, Scene, ThemeName } from '../types'
5import type { Helper, Lingering } from './activity'
6import {
7  activityOf,
8  activityOfStreaming,
9  helpersLine,
10  isHandback,
11  isToldByArgs,
12  opensDesign,
13  sceneOfLingering,
14  whole,
15} from './activity'
16import { meterAlt, meterLines, meterRow, meterSvg, meterWidth } from './meter'
17import { NUKEY_WIDTH, PROP_WIDTH, SCENE_HEIGHT, nukeySvg, propSvg, sceneRows } from './scenes'
18import type { Theme } from './themes'
19import { DEFAULT_THEME, THEMES, THEME_NAMES, isThemeName, nextTheme } from './themes'
20
21const COMMAND = 'nukey'
22const REST_AFTER_MS = 8000
23const TICK_MS = 600
24// The detail may run on to a second line: the layout cuts whatever is wider
25// than the room before the meter, so the limit only keeps it to two lines.
26const DETAIL_LENGTH = 140
27// A detail up to this long is taken to fit on one line.
28const ONE_LINE = 60
29// How long a tool's scene stays up at least, so a call that returns at once
30// is still seen; a failure stays longer.
31const HOLD_MS = 1200
32const ERROR_HOLD_MS = 2500
33const MAX_HELD = 3
34// How long the last tool's scene stays once the tool is through before the
35// Nukey goes back to thinking: the model's pause between two calls is mostly
36// shorter, and the work then reads as one stretch of reading or searching.
37const THINK_AFTER_MS = 5000
38const DEMO_MS = 3000
39const DEMO_DETAIL = 'demo'
40const DEMO: readonly Activity[] = [
41  'thinking',
42  'reading',
43  'coding',
44  'writing',
45  'designing',
46  'terminal',
47  'testing',
48  'git',
49  'installing',
50  'searching',
51  'web',
52  'delegating',
53  'planning',
54  'skill',
55  'memory',
56  'tool',
57  'sharing',
58  'waiting',
59  'permission',
60  'error',
61  'compacting',
62  'talking',
63  'asking',
64  'done',
65]
66
67const scene = atom({ plugin: 'nukey', key: 'scene' } as const, { activity: 'idle', detail: '' })
68const isHidden = atom({ plugin: 'nukey', key: 'isHidden' } as const, false)
69const frame = atom({ plugin: 'nukey', key: 'frame' } as const, 0)
70const meter = atom({ plugin: 'nukey', key: 'meter' } as const, { context: null, fiveHour: null, week: null })
71const isMetered = atom({ plugin: 'nukey', key: 'isMetered' } as const, true)
72const isDetailed = atom({ plugin: 'nukey', key: 'isDetailed' } as const, false)
73// Whether a command shows as typed rather than by its description.
74const isRaw = atom({ plugin: 'nukey', key: 'isRaw' } as const, false)
75// The colours Nukey is drawn in; kept in the store too, so it lasts across sessions.
76const theme = atom({ plugin: 'nukey', key: 'theme' } as const, DEFAULT_THEME as ThemeName)
77const THEME_KEY = 'theme'
78
79const LABELS: Record<Activity, string> = {
80  idle: 'Claude is resting',
81  thinking: 'Claude is thinking',
82  reading: 'Claude is reading',
83  coding: 'Claude is writing code',
84  writing: 'Claude is writing text',
85  designing: 'Claude is designing',
86  terminal: 'Claude is running a command',
87  searching: 'Claude is searching',
88  web: 'Claude is browsing the web',
89  delegating: 'Claude is putting helpers to work',
90  talking: 'Claude is writing a reply',
91  asking: 'Claude has a question for you',
92  planning: 'Claude is making a plan',
93  tool: 'Claude is using a tool',
94  done: 'Done!',
95  permission: 'Claude is waiting for your permission',
96  compacting: 'Claude is tidying up its memory',
97  error: 'Oops, that went wrong',
98  waiting: 'Claude is waiting on a background task',
99  testing: 'Claude is running tests',
100  git: 'Claude is working with git',
101  installing: 'Claude is installing packages',
102  skill: 'Claude is loading a skill',
103  memory: 'Claude is remembering something',
104  sharing: 'Claude is sharing something with you',
105}
106
107// A command comes with a description of what it does in plain words, the one
108// the app shows too: that goes before the command itself.
109const DETAIL_KEYS = ['file_path', 'notebook_path', 'description', 'command', 'pattern', 'query', 'url', 'skill'] as const
110// The same with the command itself first, for who wants to see what runs.
111const RAW_KEYS = ['file_path', 'notebook_path', 'command', 'pattern', 'query', 'url', 'description', 'skill'] as const
112
113// A detail cut to what the band has room for.
114function clip(text: string): string {
115  return text.length > DETAIL_LENGTH ? `${text.slice(0, DETAIL_LENGTH - 1)}…` : text
116}
117
118// What the call is about, in a few words: the file's name, what the command
119// does, the pattern.
120function detailOf(tool: string, args: Readonly<Record<string, unknown>>, raw = false): string {
121  for (const key of raw ? RAW_KEYS : DETAIL_KEYS) {
122    const value = args[key]
123
124    if (typeof value !== 'string' || value === '') {
125      continue
126    }
127
128    const isPath = key === 'file_path' || key === 'notebook_path'
129
130    return clip(isPath ? (value.split('/').pop() ?? value) : (value.split('\n')[0] ?? value))
131  }
132
133  return tool.startsWith('mcp__') ? (tool.split('__').pop() ?? '') : ''
134}
135
136// The arguments that say what a call is about before it is made, once all of
137// one has arrived: the file written to, the skill, what a command does.
138const EARLY_KEYS = ['file_path', 'skill', 'description'] as const
139const RAW_EARLY_KEYS = ['file_path', 'skill', 'command'] as const
140
141// What a call is about while its arguments are still arriving; nothing until
142// one of the telling ones is whole.
143function detailOfStreaming(tool: string, json: string, raw: boolean): string {
144  for (const key of raw ? RAW_EARLY_KEYS : EARLY_KEYS) {
145    const value = whole(json, key)
146
147    if (value !== undefined) {
148      return detailOf(tool, { [key]: value })
149    }
150  }
151
152  return ''
153}
154
155type Painted = { activity: Activity; detail: string; hold: number }
156
157type Call = { tool: string; activity: Activity; detail: string }
158
159let isWorking = false
160// Whether this turn has taken up design work, by a design skill or tool or
161// the start of an artifact: a page written after that is designed, not coded.
162let isDesigning = false
163// The main loop's calls still running, by tool_use_id, in the order begun.
164const calls = new Map<string, Call>()
165let pending: Promise<unknown> = Promise.resolve()
166let rest: Timer | undefined
167let ticker: Timer | undefined
168let demo: Timer | undefined
169let muse: Timer | undefined
170// The tool a permission dialog is open for, while one is.
171let asking: string | undefined
172// What the last stop of the main loop left running in the background.
173let lingering: readonly Lingering[] = []
174// The helpers at work, by agent id: subagents and a workflow's agents alike,
175// each from its first event until its own turn ends, during a turn and after.
176const helpers = new Map<string, Helper>()
177// Runs while the helpers' line was drawn a moment ago; what changes meanwhile
178// is drawn once it is through, so six helpers at once do not make it flicker.
179let helperBeat: Timer | undefined
180let isHelperLineStale = false
181// The scene on screen, the hold it is under, and what came in meanwhile.
182let current: Painted | undefined
183let busy: Timer | undefined
184let held: Painted[] = []
185let trailing: Painted | undefined
186
187// The next scene due: held ones in the order made, then the latest unheld one.
188function takeQueued(): Painted | undefined {
189  const [first, ...others] = held
190  held = others
191
192  if (first !== undefined) {
193    return first
194  }
195
196  const last = trailing
197  trailing = undefined
198
199  return last
200}
201
202// Draws a scene, or queues it while the one on screen is still held. Writes
203// run one after another and off the caller's path, so a stream of chunks
204// never waits on Nukey and two changes land in the order made.
205function paint($: EngineInterface, activity: Activity, detail: string, hold = 0): void {
206  const wanted: Painted = { activity, detail, hold }
207
208  if (busy !== undefined) {
209    if (hold > 0) {
210      held = [...held, wanted].slice(-MAX_HELD)
211      trailing = undefined
212    } else {
213      trailing = wanted
214    }
215
216    return
217  }
218
219  if (current?.activity !== activity || current.detail !== detail) {
220    const next: Scene = { activity, detail }
221    pending = pending.then(() => update($, scene, () => next)).catch(() => undefined)
222  }
223
224  current = wanted
225
226  if (hold > 0) {
227    busy = $.clock.after(hold, () => {
228      busy = undefined
229      const due = takeQueued()
230
231      if (due !== undefined) {
232        paint($, due.activity, due.detail, due.hold)
233      }
234    })
235  }
236}
237
238// Drops the hold on the scene on screen and what is queued behind it, so the
239// next scene shows at once and nothing from before it shows after.
240function flush(): void {
241  busy?.cancel()
242  busy = undefined
243  held = []
244  trailing = undefined
245}
246
247// What the session is doing; held back while the demo has Nukey. Helpers
248// at work show as how many there are and what they are doing, which says more
249// than the description of the call that started them.
250function show($: EngineInterface, activity: Activity, detail = '', hold = 0): void {
251  if (demo === undefined) {
252    const isCounted = activity === 'delegating' && helpers.size > 0
253    paint($, activity, isCounted ? clip(helpersLine([...helpers.values()])) : detail, hold)
254  }
255}
256
257function stopMusing(): void {
258  muse?.cancel()
259  muse = undefined
260}
261
262// Back to thinking once nothing new has come for a while.
263function museLater($: EngineInterface): void {
264  stopMusing()
265  muse = $.clock.after(THINK_AFTER_MS, () => {
266    muse = undefined
267
268    if (isWorking && calls.size === 0) {
269      show($, 'thinking')
270    }
271  })
272}
273
274function stopTicking(): void {
275  ticker?.cancel()
276  ticker = undefined
277}
278
279// The scene between turns: the background work still going, or rest.
280function settle($: EngineInterface): void {
281  if (helpers.size > 0) {
282    show($, 'delegating')
283
284    return
285  }
286
287  // A subagent's end is heard as it happens, so one the stop still listed is
288  // through by now; a workflow may be between two of its agents.
289  const scene = sceneOfLingering(lingering.filter(task => task.type !== 'subagent'))
290
291  if (scene === undefined) {
292    stopTicking()
293    show($, 'idle')
294  } else {
295    show($, scene.activity, clip(scene.detail))
296  }
297}
298
299// The scene of a turn under way: the call still running that began last, else
300// thinking.
301function resume($: EngineInterface): void {
302  const last = [...calls.values()].pop()
303
304  if (last === undefined) {
305    show($, 'thinking')
306  } else {
307    show($, last.activity, last.detail)
308  }
309}
310
311// Back to what the session is doing, after something else had Nukey.
312function restore($: EngineInterface): void {
313  if (isWorking) {
314    resume($)
315  } else {
316    settle($)
317  }
318}
319
320// Draws the helpers' line where Nukey is on helpers: during a turn only
321// while the scene is theirs already, between turns whenever Nukey is settled.
322function drawHelpers($: EngineInterface): void {
323  if (!isWorking) {
324    if (rest === undefined) {
325      settle($)
326    }
327  } else if (current?.activity === 'delegating') {
328    if (helpers.size > 0) {
329      show($, 'delegating')
330    } else {
331      resume($)
332    }
333  }
334}
335
336// A helper came, went or took up something else.
337function helpersChanged($: EngineInterface): void {
338  if (helperBeat !== undefined) {
339    isHelperLineStale = true
340
341    return
342  }
343
344  drawHelpers($)
345  helperBeat = $.clock.after(HOLD_MS, () => {
346    helperBeat = undefined
347
348    if (isHelperLineStale) {
349      isHelperLineStale = false
350      helpersChanged($)
351    }
352  })
353}
354
355// Takes note of a helper: that it is there, and what is new about it.
356function noteHelper($: EngineInterface, id: string, news: Partial<Helper> = {}): void {
357  const before = helpers.get(id)
358  const now: Helper = { activity: 'thinking', ...before, ...news }
359  helpers.set(id, now)
360
361  if (before?.activity !== now.activity || before.about !== now.about) {
362    helpersChanged($)
363  }
364}
365
366function dropHelper($: EngineInterface, id: string): void {
367  if (helpers.delete(id)) {
368    helpersChanged($)
369  }
370}
371
372// Drops the helpers that are through without their end having been heard, as
373// after a crash: those the engine lists as no longer running, and those it
374// does not list at all, a workflow's, once no workflow is left running.
375async function pruneHelpers($: EngineInterface): Promise<void> {
376  if (helpers.size === 0) {
377    return
378  }
379
380  const listed = await $.agent.list().catch(() => undefined)
381
382  if (listed === undefined) {
383    return
384  }
385
386  const hasWorkflow = lingering.some(task => task.type === 'workflow')
387
388  for (const id of [...helpers.keys()]) {
389    const info = listed.find(agent => agent.id === id)
390
391    if (info === undefined ? !hasWorkflow : info.status !== 'running') {
392      dropHelper($, id)
393    }
394  }
395}
396
397function stopTimers(): void {
398  stopMusing()
399  rest?.cancel()
400  rest = undefined
401}
402
403// Walks every scene in turn, so each animation can be seen without a task
404// that happens to raise it; the session's own scenes wait until it is through.
405function playDemo($: EngineInterface): void {
406  demo?.cancel()
407  flush()
408  let index = 0
409  paint($, DEMO[0] ?? 'idle', DEMO_DETAIL)
410
411  demo = $.clock.every(DEMO_MS, () => {
412    index += 1
413    const activity = DEMO[index]
414
415    if (activity === undefined) {
416      demo?.cancel()
417      demo = undefined
418      restore($)
419
420      return
421    }
422
423    paint($, activity, DEMO_DETAIL)
424  })
425}
426
427type Measured = {
428  context: { percent?: number }
429  rateLimits: readonly { kind: string; percentUsed: number }[]
430}
431
432// Takes a measurement of the session over into the meter, redrawing the band
433// only when a figure the meter shows has moved.
434async function gauge($: EngineInterface, measured: Measured): Promise<void> {
435  const used = (kind: string) => {
436    const limit = measured.rateLimits.find(one => one.kind === kind)
437
438    return limit === undefined ? null : Math.round(limit.percentUsed)
439  }
440  const now: Meter = { context: measured.context.percent ?? null, fiveHour: used('five_hour'), week: used('seven_day') }
441  const before = await read($, meter)
442
443  if (before.context !== now.context || before.fiveHour !== now.fiveHour || before.week !== now.week) {
444    await update($, meter, () => now)
445  }
446}
447
448// The meter's readings spelled out as a small table: the labels dim in one
449// column, the figures bold in the next, so the figures line up.
450function readings(table: Elements['terminal'] | Elements['desktop'], gauged: Meter) {
451  const { Box, Text } = table
452  const lines = meterLines(gauged)
453
454  return (
455    <Box flexDirection="row" gap={2} flexShrink={0}>
456      <Box flexDirection="column" flexShrink={0}>
457        {lines.map(line => (
458          <Text dimColor>{line.label}</Text>
459        ))}
460      </Box>
461      <Box flexDirection="column" alignItems="flex-end" flexShrink={0}>
462        {lines.map(line => (
463          <Text bold>{line.value}</Text>
464        ))}
465      </Box>
466    </Box>
467  )
468}
469
470// The line under the label: a short one on one line and cut at the edge, a
471// long one wrapped onto as many rows as it needs. No room is set aside for a
472// second row, which would stand empty under a long one that fits on one.
473function detailLine(table: Elements['terminal'] | Elements['desktop'], detail: string) {
474  const { Text } = table
475
476  return (
477    <Text dimColor wrap={detail.length <= ONE_LINE ? 'truncate-end' : 'wrap'}>
478      {detail === '' ? ' ' : detail}
479    </Text>
480  )
481}
482
483function terminalTree(
484  table: Elements['terminal'],
485  shown: Scene,
486  tick: number,
487  gauged: Meter | undefined,
488  detailed: boolean,
489  colours: Theme,
490) {
491  const { Box, Text } = table
492  const { activity, detail } = shown
493  const rows = sceneRows(activity, tick)
494
495  return (
496    <Box flexDirection="row" gap={2} width="100%">
497      <Box flexDirection="column" flexShrink={0}>
498        {rows.nukey.map(row => (
499          <Text color={colours.terminal}>{row}</Text>
500        ))}
501      </Box>
502      <Box flexDirection="column" flexShrink={0}>
503        {rows.prop.map(row => (
504          <Text dimColor>{row === '' ? ' ' : row}</Text>
505        ))}
506      </Box>
507      <Box flexDirection="column" flexGrow={1}>
508        {detail.length <= ONE_LINE && <Text> </Text>}
509        <Text bold wrap="truncate-end">
510          {LABELS[activity]}
511        </Text>
512        {detailLine(table, detail)}
513      </Box>
514      {gauged !== undefined && (
515        <Box flexDirection="column" flexShrink={0}>
516          <Text> </Text>
517          <Text color={colours.terminal}>{meterRow(gauged)}</Text>
518        </Box>
519      )}
520      {gauged !== undefined && detailed && readings(table, gauged)}
521    </Box>
522  )
523}
524
525function desktopTree(table: Elements['desktop'], shown: Scene, gauged: Meter | undefined, detailed: boolean, colours: Theme) {
526  const { Box, Svg, Text } = table
527  const { activity, detail } = shown
528  const label = LABELS[activity]
529
530  return (
531    <Box flexDirection="row" alignItems="center" gap={2} width="100%">
532      <Box flexDirection="row" gap={0} flexShrink={0}>
533        <Svg source={nukeySvg(activity, colours)} alt="Nukey the microwave" width={NUKEY_WIDTH} height={SCENE_HEIGHT} />
534        <Svg
535          source={propSvg(activity, colours)}
536          alt={detail === '' ? label : `${label}: ${detail}`}
537          width={PROP_WIDTH}
538          height={SCENE_HEIGHT}
539        />
540      </Box>
541      <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
542        <Text bold>{label}</Text>
543        {detail !== '' && detailLine(table, detail)}
544      </Box>
545      {gauged !== undefined && <Svg source={meterSvg(gauged, colours)} alt={meterAlt(gauged)} width={meterWidth(gauged)} height={SCENE_HEIGHT} />}
546      {gauged !== undefined && detailed && readings(table, gauged)}
547    </Box>
548  )
549}
550
551export const register: Register = on => {
552  on('session.start', async ($, e, next) => {
553    await $.command.register({
554      name: COMMAND,
555      description:
556        'Hide or show Nukey; "demo" plays every scene, "meter" shows or hides the control panel, "details" its readings in figures, "commands" shows commands as typed, "theme" changes its colours',
557      argumentHint: '[demo|meter|details|commands|theme [name]]',
558    })
559
560    // A reload in the middle of a turn finds the turn's scene in place and
561    // leaves it; only a scene that was on its way to rest is put to rest.
562    const stored = await read($, scene)
563
564    if (stored.activity === 'done' || stored.activity === 'error') {
565      show($, 'idle')
566    }
567
568    // The theme picked in an earlier session, if any.
569    const picked = await $.store.get(THEME_KEY).catch(() => undefined)
570
571    if (isThemeName(picked)) {
572      await update($, theme, () => picked)
573    }
574
575    // The figures the session already has; later ones come with session.measure.
576    void $.session
577      .usage()
578      .then(usage => gauge($, usage))
579      .catch(() => undefined)
580
581    return next(e)
582  })
583
584  on('command.run', { command: COMMAND }, async ($, e) => {
585    const argument = e.args.trim()
586
587    if (argument === 'demo') {
588      await update($, isHidden, () => false)
589      playDemo($)
590
591      return { text: `Nukey plays all ${DEMO.length} scenes, ${DEMO_MS / 1000} seconds each.` }
592    }
593
594    if (argument === 'meter') {
595      const metered = await update($, isMetered, value => !value)
596
597      return {
598        text: metered
599          ? 'The control panel is back at the right of the band. Everything lights up as it is used: the power bars with the context, the row of five buttons with the 5-hour limit, the seven smaller ones below it with the week.'
600          : 'The control panel is gone. /nukey meter puts it back.',
601      }
602    }
603
604    if (argument === 'details') {
605      const detailed = await update($, isDetailed, value => !value)
606
607      if (detailed) {
608        await update($, isMetered, () => true)
609      }
610
611      return {
612        text: detailed
613          ? 'The readings are spelled out beside the control panel. /nukey details hides them again.'
614          : 'The readings are gone; the control panel stays. /nukey details puts them back.',
615      }
616    }
617
618    if (argument === 'theme' || argument.startsWith('theme ')) {
619      const asked = argument.slice('theme'.length).trim().toLowerCase()
620      const list = THEME_NAMES.map(name => `${name} (${THEMES[name].label})`).join(', ')
621
622      if (asked !== '' && !isThemeName(asked)) {
623        return { text: `There is no theme "${asked}". Pick one of: ${list}.` }
624      }
625
626      const picked = await update($, theme, current => (asked === '' ? nextTheme(current) : (asked as ThemeName)))
627
628      // Kept for later sessions; this one has the theme either way.
629      await $.store.set(THEME_KEY, picked).catch(() => undefined)
630
631      return { text: `Nukey is now ${THEMES[picked].label}. Themes: ${list}.` }
632    }
633
634    if (argument === 'commands') {
635      const raw = await update($, isRaw, value => !value)
636
637      return {
638        text: raw
639          ? 'Commands now show as typed. /nukey commands goes back to describing what they do.'
640          : 'Commands are described by what they do again. /nukey commands shows them as typed.',
641      }
642    }
643
644    const hidden = await update($, isHidden, value => !value)
645
646    return { text: hidden ? 'Nukey is hidden. Type /nukey to bring it back.' : 'Nukey is back.' }
647  })
648
649  on('session.measure', async ($, e, next) => {
650    await gauge($, e).catch(() => undefined)
651
652    return next(e)
653  })
654
655  on('turn.start', async ($, e, next) => {
656    stopTimers()
657    isWorking = true
658    isDesigning = false
659    calls.clear()
660    asking = undefined
661    flush()
662    show($, 'thinking')
663
664    // Only the terminal's glyph scenes are animated by a tick; the desktop's
665    // drawing animates itself.
666    if (ticker === undefined && (await $.session.surfaces()).includes('terminal')) {
667      ticker = $.clock.every(TICK_MS, () => {
668        void update($, frame, tick => (tick + 1) % 60).catch(() => undefined)
669      })
670    }
671
672    return next(e)
673  })
674
675  on('turn.step', async function* ($, e, next) {
676    if (e.agentId !== undefined) {
677      noteHelper($, e.agentId)
678
679      return yield* next(e)
680    }
681
682    // A reload in the middle of a turn has no turn.start to go by.
683    isWorking = true
684    // The calls of this response whose arguments are still arriving and say
685    // what the call is about, by block: the tool and what has arrived, until
686    // it settles the scene.
687    const arriving = new Map<number, { tool: string; json: string; activity?: Activity }>()
688    const raw = await read($, isRaw)
689
690    for await (const chunk of next(e)) {
691      if (chunk.kind === 'thinking') {
692        // Right after a tool the thinking is the short pause before the next
693        // call: the tool's scene stays until the pause has lasted.
694        if (muse === undefined && calls.size === 0) {
695          show($, 'thinking')
696        }
697      } else if (chunk.kind === 'text' && chunk.text.trim() !== '') {
698        stopMusing()
699        show($, 'talking')
700      } else if (chunk.kind === 'tool') {
701        stopMusing()
702
703        // What a command or an edit is about shows only in its arguments: the
704        // scene waits for enough of them, rather than passing through a
705        // generic one.
706        if (isToldByArgs(chunk.name)) {
707          arriving.set(chunk.index, { tool: chunk.name, json: '' })
708        } else {
709          show($, activityOf(chunk.name))
710        }
711      } else if (chunk.kind === 'input') {
712        const before = arriving.get(chunk.index)
713
714        if (before !== undefined) {
715          const json = before.json + chunk.json
716          const activity = before.activity ?? activityOfStreaming(before.tool, json, isDesigning)
717          const detail = activity === undefined ? '' : detailOfStreaming(before.tool, json, raw)
718
719          // The scene shows as soon as it is settled, and what it is about
720          // as soon as that is in: a command's description comes after the
721          // command, which may be seconds later.
722          if (detail === '') {
723            arriving.set(chunk.index, { tool: before.tool, json, activity })
724          } else {
725            arriving.delete(chunk.index)
726          }
727
728          if (activity !== undefined && (before.activity === undefined || detail !== '')) {
729            show($, activity, detail)
730          }
731        }
732      }
733
734      yield chunk
735    }
736  })
737
738  on('tool.call', async ($, e, next) => {
739    const args = e as unknown as Readonly<Record<string, unknown>>
740
741    if (e.agentId !== undefined) {
742      noteHelper($, e.agentId, isHandback(e.tool) ? {} : { activity: activityOf(e.tool, args) })
743
744      return next(e)
745    }
746
747    isDesigning ||= opensDesign(e.tool, args)
748    const activity = activityOf(e.tool, args, isDesigning)
749    const detail = detailOf(e.tool, args, await read($, isRaw))
750    let hasFailed = false
751    calls.set(e.tool_use_id, { tool: e.tool, activity, detail })
752    stopMusing()
753    show($, activity, detail, HOLD_MS)
754
755    try {
756      const ran = await next(e)
757      hasFailed = ran.deny === undefined && ran.isError === true
758
759      return ran
760    } finally {
761      calls.delete(e.tool_use_id)
762      const wasAsked = asking === e.tool
763
764      if (wasAsked) {
765        asking = undefined
766      }
767
768      // A call that returns once its turn is over, as on an interrupt, says
769      // nothing of the session any more.
770      if (isWorking) {
771        if (hasFailed) {
772          show($, 'error', detail, ERROR_HOLD_MS)
773        }
774
775        // A dialog still open for another call keeps Nukey.
776        if (asking === undefined) {
777          if (calls.size > 0) {
778            resume($)
779          } else {
780            if (wasAsked && !hasFailed) {
781              show($, activity, detail)
782            }
783
784            museLater($)
785          }
786        }
787      }
788    }
789  })
790
791  // The dialog is on screen from here until the person answers; nothing says
792  // when they did, so the scene stays until the call itself returns. A
793  // subagent's dialog is not the main loop's, and a question or a plan put to
794  // the person is asking, not permission.
795  on('classic.PermissionRequest', ($, e, next) => {
796    if (e.agent_id !== undefined) {
797      return next(e)
798    }
799
800    asking = e.tool_name
801
802    if (activityOf(e.tool_name) === 'asking' || e.tool_name === 'ExitPlanMode') {
803      show($, 'asking')
804    } else {
805      show($, 'permission', e.tool_name)
806    }
807
808    return next(e)
809  })
810
811  on('session.compact', async ($, e, next) => {
812    if (e.agentId !== undefined || e.trigger === 'precompute') {
813      return next(e)
814    }
815
816    show($, 'compacting', '', HOLD_MS)
817
818    try {
819      return await next(e)
820    } finally {
821      restore($)
822    }
823  })
824
825  // A helper is there from the moment it starts, seconds before its first call.
826  on('classic.SubagentStart', ($, e, next) => {
827    noteHelper($, e.agent_id)
828
829    return next(e)
830  })
831
832  // The call that starts a subagent says what it is for; a workflow's agents
833  // start without one.
834  on('agent.spawn', async ($, e, next) => {
835    const started = await next(e)
836
837    if (started.agentId !== undefined) {
838      noteHelper($, started.agentId, { about: e.description === '' ? e.subagentType : e.description })
839    }
840
841    return started
842  })
843
844  // A helper's own turn ends once, whether it answered, failed or was stopped.
845  on('turn.complete', ($, e, next) => {
846    if (e.agentId !== undefined) {
847      dropHelper($, e.agentId)
848
849      return next(e)
850    }
851
852    stopTimers()
853    isWorking = false
854    calls.clear()
855    asking = undefined
856    // Nothing of the turn shows once it is over.
857    flush()
858
859    if (e.isAborted) {
860      settle($)
861    } else {
862      // A refusal or an API error ended the turn too, but nothing is done.
863      show($, e.reason === 'answer' ? 'done' : 'error')
864      rest = $.clock.after(REST_AFTER_MS, () => {
865        rest = undefined
866
867        if (!isWorking) {
868          settle($)
869        }
870      })
871    }
872
873    return next(e)
874  })
875
876  // `/clear`, `/resume` and the end of the session: what Nukey knew of the
877  // conversation is gone with it.
878  on('session.end', ($, e, next) => {
879    stopTimers()
880    stopTicking()
881    demo?.cancel()
882    demo = undefined
883    flush()
884    isWorking = false
885    isDesigning = false
886    calls.clear()
887    asking = undefined
888    lingering = []
889    helpers.clear()
890    helperBeat?.cancel()
891    helperBeat = undefined
892    isHelperLineStale = false
893    paint($, 'idle', '')
894
895    return next(e)
896  })
897
898  // The turn may be over while a command or a helper it started runs on: the
899  // Nukey then keeps at it rather than resting, until the next turn begins.
900  on('classic.Stop', ($, e, next) => {
901    lingering = e.background_tasks ?? []
902    void pruneHelpers($)
903
904    if (!isWorking && rest === undefined) {
905      settle($)
906    }
907
908    return next(e)
909  })
910
911  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
912    if (e.props.hasSurvey || (await read($, isHidden))) {
913      return next(e)
914    }
915
916    const shown = await read($, scene)
917    const gauged = (await read($, isMetered)) ? await read($, meter) : undefined
918    const detailed = await read($, isDetailed)
919    const colours = THEMES[await read($, theme)]
920
921    if (e.surface === 'terminal') {
922      return terminalTree($.ui.resolve(e), shown, await read($, frame), gauged, detailed, colours)
923    }
924
925    if (e.surface === 'desktop') {
926      return desktopTree($.ui.resolve(e), shown, gauged, detailed, colours)
927    }
928
929    return next(e)
930  })
931}
932
hooks/activity.ts 408 lines
1import type { Activity } from '../types'
2
3const BY_TOOL: Record<string, Activity> = {
4  Read: 'reading',
5  NotebookRead: 'reading',
6  ReadMcpResourceTool: 'reading',
7  ListMcpResourcesTool: 'reading',
8  ReadMcpResourceDirTool: 'reading',
9  ReadNotifications: 'reading',
10  FetchInboxMessage: 'reading',
11  Skill: 'skill',
12  Edit: 'coding',
13  MultiEdit: 'coding',
14  Write: 'coding',
15  NotebookEdit: 'coding',
16  Bash: 'terminal',
17  KillShell: 'terminal',
18  TaskStop: 'terminal',
19  BashOutput: 'waiting',
20  TaskOutput: 'waiting',
21  Monitor: 'waiting',
22  ScheduleWakeup: 'waiting',
23  CronCreate: 'waiting',
24  CronList: 'waiting',
25  CronDelete: 'waiting',
26  Grep: 'searching',
27  Glob: 'searching',
28  LS: 'searching',
29  ToolSearch: 'searching',
30  ListSkills: 'searching',
31  ListPlugins: 'searching',
32  ListAgents: 'searching',
33  WebFetch: 'web',
34  WebSearch: 'web',
35  Agent: 'delegating',
36  Task: 'delegating',
37  Workflow: 'delegating',
38  SendMessage: 'delegating',
39  AskUserQuestion: 'asking',
40  TodoWrite: 'planning',
41  TaskCreate: 'planning',
42  TaskUpdate: 'planning',
43  TaskList: 'planning',
44  TaskGet: 'planning',
45  EnterPlanMode: 'planning',
46  ExitPlanMode: 'planning',
47  Artifact: 'sharing',
48  SendUserFile: 'sharing',
49  PushNotification: 'sharing',
50  ReportFindings: 'sharing',
51  SuggestSkills: 'sharing',
52  SuggestPluginInstall: 'sharing',
53  EnterWorktree: 'git',
54  ExitWorktree: 'git',
55}
56
57const TESTS = /\b(pytest|vitest|jest|mocha|rspec|phpunit|tox|unittest|(go|cargo|bun|deno|dotnet) test|(npm|pnpm|yarn)( run)? test)\b/
58const INSTALL =
59  /\b((npm|pnpm|yarn|bun) (install|add|i|ci)|pip3? install|uv (add|sync|pip)|poetry (add|install)|brew install|cargo (add|install)|apt(-get)? install)\b/
60// Files that hold prose rather than code: by their extension, or by the names
61// that go without one.
62const PROSE =
63  /\.(md|mdx|markdown|txt|rst|adoc|asciidoc|org|tex|rtf)$|(^|\/)(README|LICEN[CS]E|CHANGELOG|CONTRIBUTING|AUTHORS|NOTICE|TODO)$/i
64// Files that hold how something looks: stylesheets and drawings.
65const LOOKS = /\.(css|scss|sass|less|svg)$/i
66// Files that hold a page: code in a web project, but a design once the turn
67// has taken up design work, as when the page of an artifact is written.
68const PAGE = /\.(html?|xhtml)$/i
69const MEMORY = /\/memory\/|(^|\/)(CLAUDE|MEMORY)\.md$/
70
71// What may stand before the command that does the work: a change of
72// directory, a variable set for the one command, a wrapper.
73const LEAD =
74  /^\s*(?:(?:cd|pushd)\s+(?:"[^"]*"|'[^']*'|\S+)\s*(?:&&|;)\s*|[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+|(?:sudo|time|command|exec|nohup|do|then|else)\s+)/
75// Output sent to a file; a stream copied to another (`2>&1`) or thrown away is none.
76const TO_FILE = /(^|[^0-9&>])>{1,2}\s*(?!\/dev\/null)[^\s&|>]/
77
78const BY_COMMAND: Record<string, Activity> = {
79  git: 'git',
80  gh: 'git',
81  cat: 'reading',
82  head: 'reading',
83  tail: 'reading',
84  less: 'reading',
85  more: 'reading',
86  bat: 'reading',
87  wc: 'reading',
88  nl: 'reading',
89  jq: 'reading',
90  yq: 'reading',
91  sed: 'reading',
92  awk: 'reading',
93  diff: 'reading',
94  stat: 'reading',
95  file: 'reading',
96  grep: 'searching',
97  egrep: 'searching',
98  fgrep: 'searching',
99  rg: 'searching',
100  ag: 'searching',
101  ack: 'searching',
102  find: 'searching',
103  fd: 'searching',
104  ls: 'searching',
105  tree: 'searching',
106  which: 'searching',
107  locate: 'searching',
108  curl: 'web',
109  wget: 'web',
110  http: 'web',
111  https: 'web',
112}
113
114const WRITERS = new Set(['cat', 'echo', 'printf'])
115
116// The command that does the work: the first one, past what leads up to it.
117function programOf(command: string): { name: string; rest: string } {
118  let rest = command
119
120  while (LEAD.test(rest)) {
121    rest = rest.replace(LEAD, '')
122  }
123
124  const word = /^[\w./+-]+/.exec(rest)?.[0] ?? ''
125
126  return { name: word.split('/').pop() ?? word, rest }
127}
128
129// The commands a line chains together, each from where it begins: what stands
130// in quotes is no command, nor is the text a heredoc feeds in.
131function chainOf(command: string): string[] {
132  const lines = command.split('\n')
133  const fed = lines.findIndex(line => line.includes('<<'))
134  const script = (fed === -1 ? lines : lines.slice(0, fed + 1)).join('\n')
135
136  return script.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, '""').split(/&&|\|\||[;|\n]/)
137}
138
139function isGit(command: string): boolean {
140  const { name } = programOf(command.trim())
141
142  return name === 'git' || name === 'gh'
143}
144
145// Which scene a shell command is: a test run, git or an install wherever it
146// stands in the line, else by the first command, so `cat x | grep y` is reading.
147function activityOfCommand(command: string, isDesigning: boolean): Activity {
148  if (TESTS.test(command)) {
149    return 'testing'
150  }
151
152  const { name, rest } = programOf(command)
153
154  if (chainOf(command).some(isGit)) {
155    return 'git'
156  }
157
158  if (INSTALL.test(command)) {
159    return 'installing'
160  }
161
162  const line = rest.split('\n')[0] ?? rest
163
164  if (name === 'tee' || (name === 'sed' && /\s-i\b/.test(line)) || (WRITERS.has(name) && TO_FILE.test(line))) {
165    const words = line.split(/\s+/).map(word => word.replace(/^['"]|['"]$/g, ''))
166
167    const looks = (word: string) => LOOKS.test(word) || (isDesigning && PAGE.test(word))
168
169    return words.some(word => PROSE.test(word)) ? 'writing' : words.some(looks) ? 'designing' : 'coding'
170  }
171
172  return BY_COMMAND[name] ?? 'terminal'
173}
174
175// The command in a shell call's arguments while their JSON is still arriving:
176// what there is of it so far, or nothing before it has begun.
177function commandSoFar(json: string): string | undefined {
178  const written = /"command"\s*:\s*"((?:[^"\\]|\\.)*)/.exec(json)?.[1]
179
180  return written?.replace(/\\(.)/g, (_, escaped: string) => (escaped === 'n' ? '\n' : escaped === 't' ? '\t' : escaped))
181}
182
183// The argument that settles the scene of a call to a tool other than the
184// shell: the file an edit is to, the skill loaded, what an artifact call does.
185const TELLING: Record<string, string> = {
186  Edit: 'file_path',
187  MultiEdit: 'file_path',
188  Write: 'file_path',
189  Skill: 'skill',
190  Artifact: 'action',
191}
192
193// A string argument once all of it has arrived.
194export function whole(json: string, key: string): string | undefined {
195  return new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`).exec(json)?.[1]?.replace(/\\(.)/g, '$1')
196}
197
198// Which scene a call is while its arguments are still arriving, once what has
199// arrived settles it: a long command or a whole file is on its way for
200// seconds, and Nukey should not sit on the scene before it. Nothing while
201// the command's first word is not whole yet, or says nothing by itself.
202export function activityOfStreaming(tool: string, json: string, isDesigning = false): Activity | undefined {
203  const key = TELLING[tool]
204
205  if (key !== undefined) {
206    const value = whole(json, key)
207
208    return value === undefined ? undefined : activityOf(tool, { [key]: value }, isDesigning)
209  }
210
211  const command = commandSoFar(json)
212
213  if (command === undefined) {
214    return undefined
215  }
216
217  const { name, rest } = programOf(command)
218
219  if (!/^[\w./+-]+\s/.test(rest) || name === 'cd' || name === 'pushd') {
220    return undefined
221  }
222
223  const activity = activityOfCommand(command, isDesigning)
224
225  // The file a command writes to tells code from prose, and is in once its line is.
226  if ((activity === 'coding' || activity === 'writing' || activity === 'designing') && !command.includes('\n')) {
227    return undefined
228  }
229
230  // A plain command only once enough of it is in to tell it is no more.
231  return activity !== 'terminal' || command.length >= 40 || command.includes('\n') ? activity : undefined
232}
233
234function isShell(tool: string): boolean {
235  return tool === 'Bash'
236}
237
238// Whether the scene of a call to this tool depends on its arguments.
239export function isToldByArgs(tool: string): boolean {
240  return isShell(tool) || tool in TELLING
241}
242
243// Which scene a call is: by the tool, and for a command or a file by what it
244// is about (`args` absent while the call's arguments are still streaming).
245// `isDesigning` says the turn has taken up design work, which makes a page
246// written from then on part of the design.
247export function activityOf(tool: string, args?: Readonly<Record<string, unknown>>, isDesigning = false): Activity {
248  const known = BY_TOOL[tool]
249  const command = args?.command
250  const path = args?.file_path
251
252  if (isShell(tool) && typeof command === 'string') {
253    return activityOfCommand(command, isDesigning)
254  }
255
256  if (known === 'coding' && typeof path === 'string' && MEMORY.test(path)) {
257    return 'memory'
258  }
259
260  if (known === 'coding' && typeof path === 'string' && PROSE.test(path)) {
261    return 'writing'
262  }
263
264  if (known === 'coding' && typeof path === 'string' && (LOOKS.test(path) || (isDesigning && PAGE.test(path)))) {
265    return 'designing'
266  }
267
268  // A design tool or a skill for designing or drawing, whatever else its name says.
269  if (/design|figma/i.test(tool) || (tool === 'Skill' && typeof args?.skill === 'string' && /design|diagram/i.test(args.skill))) {
270    return 'designing'
271  }
272
273  // An artifact is designed before it is shared: the quickstart call hands
274  // over the design guidance the page is then written to.
275  if (tool === 'Artifact' && args?.action === 'quickstart') {
276    return 'designing'
277  }
278
279  if (known !== undefined) {
280    return known
281  }
282
283  if (/browser|chrome/i.test(tool)) {
284    return 'web'
285  }
286
287  if (/terminal/i.test(tool)) {
288    return 'terminal'
289  }
290
291  // A tool the table does not know, an MCP server's mostly: by the verb its
292  // own name opens with (`mcp__server__search_issues` is `search_issues`).
293  const name = tool.split('__').pop() ?? tool
294
295  if (/search|find|query/i.test(name)) {
296    return 'searching'
297  }
298
299  if (/^(get|read|list|fetch|view)/i.test(name)) {
300    return 'reading'
301  }
302
303  if (/^(show|display|render|open)/i.test(name)) {
304    return 'sharing'
305  }
306
307  return 'tool'
308}
309
310// Whether a call takes up design work: a design tool, a design skill or the
311// start of an artifact. An edit to a stylesheet alone does not, as any web
312// project has those.
313export function opensDesign(tool: string, args?: Readonly<Record<string, unknown>>): boolean {
314  return !isShell(tool) && BY_TOOL[tool] !== 'coding' && activityOf(tool, args) === 'designing'
315}
316
317// A helper at work: a subagent or one of a workflow's agents, with what it is
318// doing now and, where the call that started it said so, what it is for.
319export type Helper = { activity: Activity; about?: string }
320
321// What a helper is doing, in a word or two that reads after a count.
322const DOING: Record<Activity, string> = {
323  idle: 'resting',
324  thinking: 'thinking',
325  reading: 'reading',
326  coding: 'writing code',
327  writing: 'writing text',
328  designing: 'designing',
329  terminal: 'running commands',
330  searching: 'searching',
331  web: 'browsing',
332  delegating: 'delegating',
333  talking: 'replying',
334  asking: 'asking',
335  planning: 'planning',
336  tool: 'using tools',
337  done: 'done',
338  permission: 'waiting for permission',
339  compacting: 'tidying up',
340  error: 'stuck',
341  waiting: 'waiting',
342  testing: 'running tests',
343  git: 'using git',
344  installing: 'installing',
345  skill: 'loading skills',
346  memory: 'remembering',
347  sharing: 'sharing',
348}
349
350// The calls a helper's loop ends on to hand its answer back: no work of its own.
351export function isHandback(tool: string): boolean {
352  return tool === 'SubagentHandback' || tool === 'StructuredOutput'
353}
354
355// The helpers at work in one line: how many, and what they are doing, the
356// most common first. A lone helper goes by what it is for, where that is known.
357export function helpersLine(helpers: readonly Helper[]): string {
358  const [only] = helpers
359
360  if (only === undefined) {
361    return ''
362  }
363
364  if (helpers.length === 1) {
365    return `${only.about ?? '1 helper'}: ${DOING[only.activity]}`
366  }
367
368  const tally = new Map<Activity, number>()
369
370  for (const { activity } of helpers) {
371    tally.set(activity, (tally.get(activity) ?? 0) + 1)
372  }
373
374  const doing = [...tally].sort((one, other) => other[1] - one[1]).map(([activity, count]) => `${count} ${DOING[activity]}`)
375
376  return `${helpers.length} helpers: ${doing.join(', ')}`
377}
378
379// A task still running in the background once the turn is over.
380export type Lingering = { type: string; description: string; command?: string }
381
382// A subscription that stays open for as long as the session does, such as the
383// live updates of a published artifact. Nothing is in progress, so it must not
384// keep Nukey from resting.
385function isStanding(task: Lingering): boolean {
386  return /^live updates for /i.test(task.description)
387}
388
389// What Nukey shows once a turn is over while work goes on in the
390// background: helpers at work when every task is a subagent or a workflow,
391// waiting otherwise; nothing when no task is left, and Nukey may rest.
392export function sceneOfLingering(all: readonly Lingering[]): { activity: Activity; detail: string } | undefined {
393  const tasks = all.filter(task => !isStanding(task))
394  const [first] = tasks
395
396  if (first === undefined) {
397    return undefined
398  }
399
400  const isDelegated = tasks.every(task => task.type === 'subagent' || task.type === 'workflow')
401  const about = (first.command ?? first.description).split('\n')[0] ?? ''
402
403  return {
404    activity: isDelegated ? 'delegating' : 'waiting',
405    detail: tasks.length === 1 ? about : `${tasks.length} tasks in the background`,
406  }
407}
408
hooks/meter.ts 122 lines
1import type { Meter } from '../types'
2import { GREY, SCENE_HEIGHT } from './scenes'
3import type { Theme } from './themes'
4import { DEFAULT_THEME, THEMES } from './themes'
5
6const RED = '#E5534B'
7const LIGHT = '#F2C14E'
8
9const PANEL_WIDTH = 80
10const BUTTONS_WIDTH = 52
11const BARS = 9
12const FIVE_HOUR_BUTTONS = 5
13const WEEK_BUTTONS = 7
14// Past this share the last two units of a gauge turn red: nearly used up.
15const NEARLY_FULL = 85
16
17const round = (value: number) => Math.round(value * 10) / 10
18const share = (percent: number) => Math.max(0, Math.min(1, percent / 100))
19
20// How many of a gauge's units are lit: everything on the meter fills as it is
21// used, the power bars with the context and the buttons with a rate-limit window.
22const litOf = (used: number | null, units: number) => (used === null ? 0 : Math.round(share(used) * units))
23
24// The colour of a lit unit, the last two red once the gauge is nearly full;
25// nothing for a unit not reached yet, which its gauge draws as a ghost, so the
26// whole scale is always in view.
27function colourOf(unit: number, units: number, used: number | null, colour: string): string | undefined {
28  if (unit >= litOf(used, units)) {
29    return undefined
30  }
31
32  return (used ?? 0) > NEARLY_FULL && unit >= units - 2 ? RED : colour
33}
34
35// A row of buttons that light up one by one as a rate-limit window is used, the
36// ones not reached yet in grey. Nothing where the account has no such window.
37function buttons(x: number, y: number, radius: number, gap: number, units: number, used: number | null, trim: string): string {
38  if (used === null) {
39    return ''
40  }
41
42  let row = ''
43
44  for (let unit = 0; unit < units; unit++) {
45    const fill = colourOf(unit, units, used, LIGHT)
46
47    row +=
48      `<circle cx="${round(x + unit * gap)}" cy="${y}" r="${radius}" fill="${fill ?? GREY}"${fill === undefined ? ' opacity="0.6"' : ''} ` +
49      `stroke="${trim}" stroke-width="0.8"/>`
50  }
51
52  return row
53}
54
55// How wide the meter is drawn: the panel alone where the account has no
56// rate-limit window, the panel and its two rows of buttons otherwise.
57export const meterWidth = (meter: Meter) => PANEL_WIDTH + (meter.fiveHour === null && meter.week === null ? 0 : BUTTONS_WIDTH)
58
59// A microwave's control panel: nine power bars, rising left to right, that
60// light up with the context. Beside it a row of five buttons for the five-hour
61// window, and below that seven smaller ones for the week. The panel and the lit
62// bars take the theme's colours.
63export function meterSvg(meter: Meter, theme: Theme = THEMES[DEFAULT_THEME]): string {
64  const width = meterWidth(meter)
65  const floor = 48
66  let bars = ''
67
68  for (let bar = 0; bar < BARS; bar++) {
69    const height = round(8 + bar * 3.6)
70    const fill = colourOf(bar, BARS, meter.context, theme.accent)
71
72    bars +=
73      `<rect x="${round(11 + bar * 6.8)}" y="${round(floor - height)}" width="4.6" height="${height}" rx="1.2" ` +
74      `fill="${fill ?? GREY}"${fill === undefined ? ' opacity="0.4"' : ''}/>`
75  }
76
77  return (
78    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${SCENE_HEIGHT}" width="${width}" height="${SCENE_HEIGHT}" ` +
79    `shape-rendering="geometricPrecision">` +
80    `<rect x="3" y="4" width="74" height="52" rx="6" fill="${theme.body}" stroke="${theme.trim}" stroke-width="1.5"/>` +
81    `<rect x="7" y="8" width="66" height="44" rx="3" fill="${theme.window}"/>` +
82    bars +
83    buttons(90.5, 23, 4, 9.1, FIVE_HOUR_BUTTONS, meter.fiveHour, theme.trim) +
84    buttons(89.3, 43, 2.8, 6.3, WEEK_BUTTONS, meter.week, theme.trim) +
85    `</svg>`
86  )
87}
88
89// The meter in words, for whoever cannot see the drawing.
90export function meterAlt(meter: Meter): string {
91  const parts = [
92    meter.context === null ? 'Context not measured yet' : `Context ${meter.context}% full`,
93    meter.fiveHour === null ? '' : `5-hour limit ${meter.fiveHour}% used`,
94    meter.week === null ? '' : `weekly limit ${meter.week}% used`,
95  ]
96
97  return parts.filter(part => part !== '').join(', ')
98}
99
100// The readings in words and figures, for beside the drawing: one line for each
101// gauge that has a reading.
102export function meterLines(meter: Meter): readonly { label: string; value: string }[] {
103  return [
104    meter.context === null ? undefined : { label: 'Context window', value: `${meter.context}%` },
105    meter.fiveHour === null ? undefined : { label: '5-hour limit', value: `${meter.fiveHour}%` },
106    meter.week === null ? undefined : { label: 'Weekly limit', value: `${meter.week}%` },
107  ].filter(line => line !== undefined)
108}
109
110const units = (lit: number, total: number, on: string, off: string) => on.repeat(lit) + off.repeat(total - lit)
111
112// The terminal's meter: the power bars as a row of blocks, the buttons as dots.
113export function meterRow(meter: Meter): string {
114  return [
115    units(litOf(meter.context, BARS), BARS, '▰', '▱'),
116    meter.fiveHour === null ? '' : units(litOf(meter.fiveHour, FIVE_HOUR_BUTTONS), FIVE_HOUR_BUTTONS, '●', '○'),
117    meter.week === null ? '' : units(litOf(meter.week, WEEK_BUTTONS), WEEK_BUTTONS, '●', '○'),
118  ]
119    .filter(part => part !== '')
120    .join(' ')
121}
122
hooks/scenes.ts 685 lines
1import type { Activity } from '../types'
2import type { Theme } from './themes'
3import { DEFAULT_THEME, THEMES } from './themes'
4
5export const ORANGE = '#D97757'
6export const EYE = '#2B2520'
7export const GREY = '#8C9199'
8export const SLATE = '#5B6470'
9export const PAPER = '#FFFFFF'
10const YELLOW = '#F2C14E'
11const GOLD = '#D9A21B'
12const RED = '#E5534B'
13const BLUE = '#6CB6FF'
14const INK = '#1E1E28'
15const LINE = '#B9BEC6'
16
17export const SCENE_WIDTH = 128
18export const SCENE_HEIGHT = 64
19
20// Nukey stands at one spot in every scene, so a change of scene moves only
21// what is beside it.
22const NUKEY_X = 10
23const NUKEY_Y = 29
24
25type Look = 'ahead' | 'up' | 'right' | 'closed'
26type Arms = 'still' | 'typing' | 'wave' | 'cheer'
27
28type Pose = {
29  look?: Look
30  arms?: Arms
31  bob?: number
32  isShaking?: boolean
33  wear?: string
34}
35
36export type Drawing = { pose: Pose; prop: string }
37
38// Scenes that share a pose share one Nukey drawing, which then stays as it is
39// while the scene beside it changes: so the poses are few and named.
40const WORK: Pose = { look: 'right' }
41const TYPING: Pose = { look: 'right', arms: 'typing' }
42const WAVING: Pose = { look: 'right', arms: 'wave' }
43
44export const loop = (attribute: string, values: string, seconds: number, extra = '') =>
45  `<animate attributeName="${attribute}" values="${values}" dur="${seconds}s" repeatCount="indefinite" ${extra}/>`
46
47export const slide = (values: string, seconds: number, extra = '') =>
48  `<animateTransform attributeName="transform" type="translate" values="${values}" dur="${seconds}s" repeatCount="indefinite" ${extra}/>`
49
50const swing = (values: string, seconds: number) =>
51  `<animateTransform attributeName="transform" type="rotate" values="${values}" dur="${seconds}s" repeatCount="indefinite"/>`
52
53const stroke = (color: string, width: number) =>
54  `fill="none" stroke="${color}" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"`
55
56// The eyes glow in the window, which runs from y 4 to y 20.
57const EYES: Record<Look, { dx: number; y: number; height: number }> = {
58  ahead: { dx: 0, y: 8, height: 5 },
59  up: { dx: 0, y: 6, height: 5 },
60  right: { dx: 2, y: 8, height: 5 },
61  closed: { dx: 0, y: 11, height: 1.5 },
62}
63
64const ARMS: Record<Arms, { y: number; left: string; right: string }> = {
65  still: { y: 11, left: '', right: '' },
66  typing: {
67    y: 11,
68    left: loop('y', '11;9;11', 0.3),
69    right: loop('y', '11;9;11', 0.3, 'begin="0.15s"'),
70  },
71  wave: { y: 11, left: '', right: loop('y', '11;3;11', 0.7) },
72  cheer: { y: 4, left: loop('y', '4;1;4', 0.5), right: loop('y', '4;1;4', 0.5, 'begin="0.25s"') },
73}
74
75function eye(x: number, look: Look, glow: string): string {
76  const { dx, y, height } = EYES[look]
77  const blink =
78    look === 'closed'
79      ? ''
80      : loop('height', `${height};${height};1;${height}`, 3.4, 'keyTimes="0;0.92;0.96;1"') +
81        loop('y', `${y};${y};${y + 2};${y}`, 3.4, 'keyTimes="0;0.92;0.96;1"')
82
83  return `<rect x="${x + dx}" y="${y}" width="3.5" height="${height}" rx="1" fill="${glow}">${blink}</rect>`
84}
85
86// Nukey is 48 by 27 before scaling: two stubby arms, a boxy body with a window
87// for a face and a dial panel at the side, and two feet, in the theme's colours.
88function nukey(x: number, y: number, pose: Pose, theme: Theme, scale = 1, delay = 0): string {
89  const { look = 'ahead', arms = 'still', bob = 1.6, isShaking = false, wear = '' } = pose
90  const motion = isShaking ? slide('0 0;-1.6 0;1.6 0;0 0', 0.28) : slide('0 0;0 -1.6;0 0', bob, `begin="${delay}s"`)
91  const arm = (armX: number, animation: string) =>
92    `<rect x="${armX}" y="${ARMS[arms].y}" width="6" height="6" rx="2" fill="${theme.body}" stroke="${theme.trim}" stroke-width="1.5">${animation}</rect>`
93  const buttons = [13, 16.5, 20].map(buttonY => `<rect x="35" y="${buttonY}" width="5" height="1.8" rx="0.9" fill="${theme.trim}" opacity="0.55"/>`).join('')
94
95  return (
96    `<g transform="translate(${x} ${y}) scale(${scale})">` +
97    `<ellipse cx="24" cy="28" rx="19" ry="1.8" fill="#000" opacity="0.14"/>` +
98    `<g>${motion}` +
99    arm(0, ARMS[arms].left) +
100    arm(42, ARMS[arms].right) +
101    `<rect x="10" y="22" width="5" height="4" rx="1" fill="${theme.trim}"/><rect x="33" y="22" width="5" height="4" rx="1" fill="${theme.trim}"/>` +
102    `<rect x="4" y="0" width="40" height="23" rx="4" fill="${theme.body}" stroke="${theme.trim}" stroke-width="1.5"/>` +
103    `<rect x="8" y="4" width="23" height="16" rx="2.5" fill="${theme.window}"/>` +
104    `${eye(12.5, look, theme.glow)}${eye(22, look, theme.glow)}` +
105    `<ellipse cx="11.5" cy="16.5" rx="1.8" ry="1.1" fill="${theme.cheek}" opacity="0.8"/><ellipse cx="28" cy="16.5" rx="1.8" ry="1.1" fill="${theme.cheek}" opacity="0.8"/>` +
106    `<line x1="33.5" y1="3" x2="33.5" y2="20" stroke="${theme.trim}" stroke-width="1" opacity="0.35"/>` +
107    `<circle cx="37.5" cy="7.5" r="2.8" fill="${theme.body}" stroke="${theme.trim}" stroke-width="1.3"/><line x1="37.5" y1="7.5" x2="37.5" y2="5.4" stroke="${theme.trim}" stroke-width="1.1" stroke-linecap="round"/>` +
108    buttons +
109    `${wear}` +
110    `</g></g>`
111  )
112}
113
114// A small Nukey beside the big one, as part of a scene's prop.
115export const helper = (x: number, y: number, delay: number, theme: Theme) => nukey(x, y, { bob: 0.7 }, theme, 0.5, delay)
116
117export function bubble(inside: string): string {
118  return (
119    `<path d="M69,6 h44 a7,7 0 0 1 7,7 v20 a7,7 0 0 1 -7,7 h-33 l-14,9 l4,-9 h-1 a7,7 0 0 1 -7,-7 v-20 a7,7 0 0 1 7,-7 z" ` +
120    `fill="${PAPER}" stroke="${GREY}" stroke-width="1.5" stroke-linejoin="round"/>` +
121    inside
122  )
123}
124
125export function gear(x: number, y: number, radius: number, seconds: number, turn: 1 | -1): string {
126  const tooth = radius * 0.45
127  const teeth = [0, 45, 90, 135, 180, 225, 270, 315]
128    .map(
129      angle =>
130        `<rect x="${-tooth / 2}" y="${-radius * 1.35}" width="${tooth}" height="${radius * 0.6}" rx="1" transform="rotate(${angle})"/>`,
131    )
132    .join('')
133  const hole = radius * 0.42
134  const ring =
135    `M${-radius},0 a${radius},${radius} 0 1,0 ${radius * 2},0 a${radius},${radius} 0 1,0 ${-radius * 2},0 ` +
136    `M${-hole},0 a${hole},${hole} 0 1,1 ${hole * 2},0 a${hole},${hole} 0 1,1 ${-hole * 2},0`
137
138  return (
139    `<g transform="translate(${x} ${y})"><g fill="#7D8590">` +
140    `<animateTransform attributeName="transform" type="rotate" from="0" to="${360 * turn}" dur="${seconds}s" repeatCount="indefinite"/>` +
141    `${teeth}<path fill-rule="evenodd" d="${ring}"/></g></g>`
142  )
143}
144
145export const pulse = (begin: number) => loop('opacity', '0.15;1;0.15', 1.5, `begin="${begin}s"`)
146
147export const bar = (x: number, y: number, width: number, fill: string, extra = '') =>
148  `<rect x="${x}" y="${y}" width="${width}" height="2.5" rx="1.25" fill="${fill}">${extra}</rect>`
149
150export const sparkle = (x: number, y: number, begin: number) =>
151  `<path transform="translate(${x} ${y})" d="M0,-6 L1.5,-1.5 L6,0 L1.5,1.5 L0,6 L-1.5,1.5 L-6,0 L-1.5,-1.5 Z" fill="#F2C14E" opacity="0.2">${pulse(begin)}</path>`
152
153export const SCENES: Record<Activity, (theme: Theme) => Drawing> = {
154  idle: () => ({
155    pose: { look: 'closed', bob: 3.2 },
156    prop: [
157      { x: 66, y: 31, size: 9, begin: 0 },
158      { x: 76, y: 22, size: 12, begin: 0.5 },
159      { x: 88, y: 12, size: 15, begin: 1 },
160    ]
161      .map(
162        z =>
163          `<text x="${z.x}" y="${z.y}" font-family="ui-monospace,Menlo,monospace" font-weight="700" font-size="${z.size}" fill="${GREY}" opacity="0.15">z${pulse(z.begin)}</text>`,
164      )
165      .join(''),
166  }),
167
168  thinking: () => {
169    const puffs: readonly (readonly [number, number, number])[] = [
170      [86, 19, 9],
171      [98, 14, 11],
172      [111, 18, 9],
173      [98, 22, 9],
174      [72, 22, 3.5],
175      [64, 28, 2.2],
176    ]
177    const outline = puffs.map(([x, y, r]) => `<circle cx="${x}" cy="${y}" r="${r + 1.4}" fill="${GREY}"/>`).join('')
178    const fill = puffs.map(([x, y, r]) => `<circle cx="${x}" cy="${y}" r="${r}" fill="${PAPER}"/>`).join('')
179    const dots = [90, 98, 106]
180      .map((x, i) => `<circle cx="${x}" cy="18" r="2.2" fill="${SLATE}" opacity="0.15">${pulse(i * 0.3)}</circle>`)
181      .join('')
182
183    return { pose: { look: 'up' }, prop: outline + fill + dots }
184  },
185
186  reading: () => ({
187    pose: WORK,
188    prop:
189      `<path d="M74,8 h30 l10,10 v38 h-40 z" fill="${PAPER}" stroke="${LINE}" stroke-width="1.5" stroke-linejoin="round"/>` +
190      `<path d="M104,8 v10 h10" ${stroke(LINE, 1.5)}/>` +
191      [24, 30, 36, 42, 48].map((y, i) => bar(80, y, i % 2 === 0 ? 28 : 21, LINE)).join('') +
192      `<rect x="75" y="20" width="38" height="5.5" rx="1.5" fill="${ORANGE}" opacity="0.35">${loop('y', '20;47;20', 3)}</rect>`,
193  }),
194
195  coding: () => {
196    const lines: readonly (readonly [number, number, number, string])[] = [
197      [72, 23, 20, '#C792EA'],
198      [78, 29, 30, '#82AAFF'],
199      [78, 35, 22, '#C3E88D'],
200      [84, 41, 18, '#F78C6C'],
201      [72, 47, 10, '#C792EA'],
202    ]
203
204    return {
205      pose: TYPING,
206      prop:
207        `<rect x="66" y="8" width="54" height="48" rx="4" fill="${INK}"/>` +
208        [RED, YELLOW, '#3FB950'].map((fill, i) => `<circle cx="${72 + i * 6}" cy="14.5" r="2" fill="${fill}"/>`).join('') +
209        lines
210          .map(([x, y, width, fill], i) =>
211            bar(x, y, width, fill, loop('opacity', '0;0;1;1', 3.6, `keyTimes="0;${(0.08 + i * 0.16).toFixed(2)};${(0.12 + i * 0.16).toFixed(2)};1"`)),
212          )
213          .join(''),
214    }
215  },
216
217  writing: () => {
218    const lines: readonly (readonly [number, number])[] = [
219      [24, 28],
220      [30, 21],
221      [36, 28],
222      [42, 16],
223    ]
224    const at = (i: number) => 0.04 + i * 0.22
225    const path = lines.flatMap(([y, width]) => [`80 ${y + 1.5}`, `${80 + width} ${y + 1.5}`])
226    const times = lines.flatMap((_, i) => [at(i).toFixed(2), (at(i) + 0.2).toFixed(2)])
227
228    return {
229      pose: TYPING,
230      prop:
231        `<path d="M74,8 h30 l10,10 v38 h-40 z" fill="${PAPER}" stroke="${LINE}" stroke-width="1.5" stroke-linejoin="round"/>` +
232        `<path d="M104,8 v10 h10" ${stroke(LINE, 1.5)}/>` +
233        lines
234          .map(([y, width], i) =>
235            bar(80, y, width, GREY, loop('width', `0;0;${width};${width}`, 4, `keyTimes="0;${at(i).toFixed(2)};${(at(i) + 0.2).toFixed(2)};1"`)),
236          )
237          .join('') +
238        `<g>${slide([path[0], ...path, path.at(-1)].join(';'), 4, `keyTimes="0;${times.join(';')};1"`)}` +
239        `<path d="M0,0 l1.2,-4.2 l9,-9 l3,3 l-9,9 z" fill="${YELLOW}" stroke="${SLATE}" stroke-width="1" stroke-linejoin="round"/>` +
240        `<path d="M0,0 l0.6,-2.1 l1.5,1.5 z" fill="${EYE}"/>` +
241        `</g>`,
242    }
243  },
244
245  designing: () => {
246    const NAVY = '#4A5F94'
247    const pieces: readonly ((shown: string) => string)[] = [
248      shown => `<polygon points="80,11 93,9 93,41 78,35" fill="${BLUE}" opacity="0">${shown}</polygon>`,
249      shown => `<polygon points="93,9 108,15 106,39 93,41" fill="${YELLOW}" opacity="0">${shown}</polygon>`,
250      shown =>
251        `<g opacity="0">${shown}<circle cx="86" cy="20" r="3.4" fill="${PAPER}"/><circle cx="87" cy="20" r="1.5" fill="${EYE}"/>` +
252        `<ellipse cx="101" cy="27" rx="4.2" ry="3" fill="${PAPER}"/><circle cx="100" cy="27" r="1.6" fill="${EYE}"/></g>`,
253      shown => `<path d="M93,15 L99,27 L92,29" ${stroke(EYE, 1.8)} opacity="0">${shown}</path>`,
254      shown => `<polygon points="84,33 100,35 91,39" fill="${RED}" opacity="0">${shown}</polygon>`,
255    ]
256    const at = (i: number) => 0.04 + i * 0.16
257
258    return {
259      // A beret: the painter the portrait on the easel is after.
260      pose: {
261        look: 'right',
262        wear:
263          `<ellipse cx="21" cy="-1.5" rx="16" ry="5.2" fill="${NAVY}" transform="rotate(-9 21 -1.5)"/>` +
264          `<rect x="20" y="-9.5" width="2.2" height="4" fill="${NAVY}"/>`,
265      },
266      prop:
267        `<path d="M77,46 L71,61 M109,46 L115,61 M93,46 V59" ${stroke('#B89B6A', 2.2)}/>` +
268        `<rect x="68" y="5" width="50" height="41" rx="1.5" fill="${PAPER}" stroke="#B89B6A" stroke-width="2"/>` +
269        pieces
270          .map((piece, i) => piece(loop('opacity', '0;0;1;1', 5, `keyTimes="0;${at(i).toFixed(2)};${(at(i) + 0.05).toFixed(2)};1"`)))
271          .join('') +
272        `<g>${slide('86 28;86 28;100 28;90 20;97 24;92 37;92 37', 5, 'keyTimes="0;0.04;0.2;0.36;0.52;0.68;1"')}` +
273        `<line x1="2" y1="-3" x2="13" y2="-19" ${stroke(SLATE, 3.2)}/>` +
274        `<path d="M-1,2 l1.5,-6 l4.5,3 z" fill="${ORANGE}"/></g>`,
275    }
276  },
277
278  terminal: () => ({
279    pose: TYPING,
280    prop:
281      `<rect x="64" y="10" width="58" height="44" rx="4" fill="${INK}"/>` +
282      `<path d="M64,19 v-5 a4,4 0 0 1 4,-4 h50 a4,4 0 0 1 4,4 v5 z" fill="#3A3A46"/>` +
283      `<polyline points="70,25 74,28.5 70,32" ${stroke('#7EE787', 2)}/>` +
284      bar(79, 27.25, 24, '#8B949E') +
285      `<rect x="70" y="39" width="46" height="7" rx="3.5" ${stroke('#8B949E', 1.2)}/>` +
286      `<rect x="71.5" y="40.5" width="43" height="4" rx="2" fill="#7EE787">${loop('width', '0;43;43', 2.6, 'keyTimes="0;0.85;1"')}</rect>`,
287  }),
288
289  searching: () => ({
290    pose: WORK,
291    prop:
292      [14, 22, 30, 38, 46].map((y, i) => bar(72, y, i % 2 === 0 ? 46 : 34, '#B9BEC6')).join('') +
293      `<g>${slide('0 0;14 -4;22 8;4 10;0 0', 4)}` +
294      `<line x1="77" y1="35" x2="64" y2="47" stroke="${SLATE}" stroke-width="5" stroke-linecap="round"/>` +
295      `<circle cx="86" cy="26" r="12" fill="#BFE3FF" fill-opacity="0.55" stroke="${SLATE}" stroke-width="3.5"/>` +
296      `<path d="M79,22 a8,8 0 0 1 6,-5" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round"/>` +
297      `</g>`,
298  }),
299
300  web: () => ({
301    pose: WORK,
302    prop:
303      `<rect x="64" y="10" width="58" height="44" rx="4" fill="${PAPER}" stroke="${LINE}" stroke-width="1.5"/>` +
304      `<path d="M64,21 v-7 a4,4 0 0 1 4,-4 h50 a4,4 0 0 1 4,4 v7 z" fill="#E3E6EA" stroke="${LINE}" stroke-width="1.5"/>` +
305      [RED, YELLOW, '#3FB950'].map((fill, i) => `<circle cx="${69.5 + i * 5}" cy="15.5" r="1.6" fill="${fill}"/>`).join('') +
306      `<rect x="86" y="13" width="31" height="5" rx="2.5" fill="${PAPER}"/>` +
307      `<rect x="65" y="21" width="56" height="2.2" fill="${BLUE}">${loop('width', '0;56;56', 1.8, 'keyTimes="0;0.8;1"')}</rect>` +
308      `<rect x="70" y="28" width="18" height="14" rx="2" fill="#BFE3FF"/>` +
309      bar(92, 29, 23, LINE) +
310      bar(92, 34.5, 16, LINE) +
311      bar(92, 40, 21, LINE) +
312      bar(70, 46.5, 40, LINE) +
313      `<g>${slide('102 46;110 36;97 31;102 46', 3)}<path d="M0,0 L0,11 L3,8.2 L5.4,13 L7.4,12 L5,7.4 L9,7.4 Z" fill="${EYE}" stroke="${PAPER}" stroke-width="0.9"/></g>`,
314  }),
315
316  delegating: theme => ({
317    pose: WAVING,
318    prop: helper(66, 40, 0, theme) + helper(96, 40, 0.35, theme) + helper(81, 18, 0.2, theme),
319  }),
320
321  talking: () => ({
322    pose: {},
323    prop: bubble(
324      bar(69, 14, 44, GREY, loop('width', '0;44;44;44', 3.2, 'keyTimes="0;0.3;0.95;1"')) +
325        bar(69, 21.5, 38, GREY, loop('width', '0;0;38;38;38', 3.2, 'keyTimes="0;0.3;0.6;0.95;1"')) +
326        bar(69, 29, 26, GREY, loop('width', '0;0;26;26', 3.2, 'keyTimes="0;0.6;0.85;1"')),
327    ),
328  }),
329
330  asking: () => ({
331    pose: { arms: 'wave' },
332    prop: bubble(
333      `<text x="91" y="33" text-anchor="middle" font-family="ui-sans-serif,system-ui,sans-serif" font-weight="800" font-size="28" fill="${ORANGE}">?${loop('opacity', '1;0.35;1', 1.4)}</text>`,
334    ),
335  }),
336
337  planning: () => ({
338    pose: WORK,
339    prop:
340      `<polygon points="68,14 84,10 100,14 118,10 118,50 100,54 84,50 68,54" fill="#E8F0D8" stroke="#A8B88A" stroke-width="1.5" stroke-linejoin="round"/>` +
341      `<path d="M84,10 v40 M100,14 v40" ${stroke('#C6D3A8', 1.2)}/>` +
342      `<path d="M74,46 C80,36 88,44 94,34 S106,28 110,22" ${stroke(ORANGE, 2.5)} stroke-dasharray="3 4">${loop('stroke-dashoffset', '7;0', 0.7)}</path>` +
343      `<circle cx="74" cy="46" r="3" fill="${SLATE}"/>` +
344      `<g>${slide('0 0;0 -2.5;0 0', 0.9)}<line x1="110" y1="22" x2="110" y2="8" ${stroke(SLATE, 2)}/><polygon points="110,8 120,11.5 110,15" fill="${RED}"/></g>`,
345  }),
346
347  tool: () => ({
348    pose: TYPING,
349    prop: gear(88, 28, 13, 5, 1) + gear(112, 46, 7, 2.7, -1),
350  }),
351
352  done: () => ({
353    pose: { arms: 'cheer', bob: 0.6 },
354    prop:
355      sparkle(66, 12, 0) +
356      sparkle(112, 48, 0.5) +
357      sparkle(116, 12, 1) +
358      `<g transform="translate(92 31)"><g>` +
359      `<animateTransform attributeName="transform" type="scale" values="0.6;1.25;1" dur="0.45s" fill="freeze"/>` +
360      `<rect x="-22" y="-11" width="44" height="20" rx="10" fill="#2DA44E"/>` +
361      `<text x="0" y="4.5" text-anchor="middle" font-family="ui-sans-serif,system-ui,sans-serif" font-weight="800" font-size="13" fill="#FFFFFF">DING!</text>` +
362      `</g></g>`,
363  }),
364
365  permission: () => ({
366    pose: { arms: 'wave' },
367    prop:
368      `<g>` +
369      `<animateTransform attributeName="transform" type="rotate" values="-5 94 40;5 94 40;-5 94 40" dur="0.9s" repeatCount="indefinite"/>` +
370      `<path d="M85,30 v-8 a9,9 0 0 1 18,0 v8" fill="none" stroke="${SLATE}" stroke-width="4.5" stroke-linecap="round"/>` +
371      `<rect x="78" y="29" width="32" height="24" rx="4" fill="#F2C14E"/>` +
372      `<circle cx="94" cy="38" r="3.5" fill="#7A5B12"/><rect x="92.5" y="39" width="3" height="8" rx="1" fill="#7A5B12"/>` +
373      `</g>`,
374  }),
375
376  compacting: () => {
377    const dust: readonly (readonly [number, number, number, number])[] = [
378      [108, 55, 2.6, 0],
379      [114, 51, 2, 0.2],
380      [120, 56, 1.8, 0.4],
381      [104, 50, 1.6, 0.5],
382    ]
383
384    return {
385      pose: TYPING,
386      prop:
387        dust
388          .map(
389            ([x, y, r, begin]) =>
390              `<circle cx="${x}" cy="${y}" r="${r}" fill="${GREY}" opacity="0.2">${pulse(begin)}${slide('0 0;5 -3;0 0', 1, `begin="${begin}s"`)}</circle>`,
391          )
392          .join('') +
393        `<g>${swing('-10 76 8;10 76 8;-10 76 8', 1)}` +
394        `<line x1="76" y1="8" x2="90" y2="44" ${stroke('#B08968', 3.5)}/>` +
395        `<polygon points="82,43 99,40 104,56 80,58" fill="${YELLOW}" stroke="${GOLD}" stroke-width="1.5" stroke-linejoin="round"/>` +
396        `<path d="M86,47 l-1,9 M92,46 l0,10 M98,45 l2,9" ${stroke(GOLD, 1.2)}/>` +
397        `</g>`,
398    }
399  },
400
401  error: () => ({
402    pose: { look: 'up', isShaking: true },
403    prop:
404      `<g>${loop('opacity', '1;0.55;1', 0.8)}` +
405      `<path d="M94,10 L116,50 L72,50 Z" fill="#E5534B" stroke="#E5534B" stroke-width="4" stroke-linejoin="round"/>` +
406      `<rect x="92" y="22" width="4" height="15" rx="2" fill="#FFFFFF"/><circle cx="94" cy="43.5" r="2.5" fill="#FFFFFF"/>` +
407      `</g>`,
408  }),
409
410  waiting: () => {
411    const ticks = [0, 90, 180, 270]
412      .map(angle => `<rect x="93" y="15" width="2" height="4" fill="${SLATE}" transform="rotate(${angle} 94 31)"/>`)
413      .join('')
414
415    return {
416      pose: WORK,
417      prop:
418        `<circle cx="94" cy="31" r="19" fill="${PAPER}" stroke="${SLATE}" stroke-width="3"/>` +
419        ticks +
420        `<line x1="94" y1="31" x2="102" y2="31" stroke="${SLATE}" stroke-width="3" stroke-linecap="round"/>` +
421        `<line x1="94" y1="31" x2="94" y2="18" stroke="${ORANGE}" stroke-width="2.5" stroke-linecap="round">` +
422        `<animateTransform attributeName="transform" type="rotate" from="0 94 31" to="360 94 31" dur="4s" repeatCount="indefinite"/>` +
423        `</line><circle cx="94" cy="31" r="2.5" fill="${SLATE}"/>`,
424    }
425  },
426
427  testing: () => {
428    const rows = [17, 26, 35, 44]
429      .map(
430        (y, i) =>
431          `<circle cx="78" cy="${y + 1.25}" r="3" fill="#3FB950">` +
432          loop('fill', '#6E7681;#6E7681;#3FB950;#3FB950', 3.6, `keyTimes="0;${0.1 + i * 0.2};${0.15 + i * 0.2};1"`) +
433          `</circle>` +
434          bar(85, y, i % 2 === 0 ? 24 : 18, '#8B949E'),
435      )
436      .join('')
437
438    return {
439      pose: WORK,
440      prop: `<rect x="68" y="9" width="50" height="46" rx="4" fill="#1E1E28"/>` + rows,
441    }
442  },
443
444  git: () => {
445    const branch = 'M80,44 C88,44 86,20 94,20 H104 C112,20 110,44 118,44'
446
447    return {
448      pose: WORK,
449      prop:
450        `<line x1="66" y1="44" x2="122" y2="44" stroke="#6CB6FF" stroke-width="3" stroke-linecap="round"/>` +
451        `<path d="${branch}" fill="none" stroke="#C792EA" stroke-width="3" stroke-linecap="round"/>` +
452        [70, 80, 118].map(x => `<circle cx="${x}" cy="44" r="4.5" fill="#6CB6FF"/>`).join('') +
453        [94, 104].map(x => `<circle cx="${x}" cy="20" r="4.5" fill="#C792EA"/>`).join('') +
454        `<circle r="3" fill="#FFFFFF" stroke="${SLATE}" stroke-width="1.5"><animateMotion dur="2.2s" repeatCount="indefinite" path="${branch}"/></circle>`,
455    }
456  },
457
458  installing: () => ({
459    pose: WORK,
460    prop:
461      `<g fill="none" stroke="#2DA44E" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round">` +
462      slide('0 0;0 8;0 0', 1.2) +
463      `<line x1="94" y1="6" x2="94" y2="20"/><polyline points="87,14 94,21 101,14"/></g>` +
464      `<rect x="74" y="32" width="40" height="24" rx="2" fill="#C9A27A"/>` +
465      `<rect x="72" y="29" width="44" height="6" rx="1.5" fill="#B08968"/>` +
466      `<rect x="90.5" y="29" width="7" height="27" fill="#E8D3B0"/>`,
467  }),
468
469  skill: () => ({
470    pose: {
471      look: 'right',
472      wear:
473        `<polygon points="24,-11 48,-4 24,3 0,-4" fill="${EYE}"/><rect x="13" y="-2" width="22" height="4" fill="${EYE}"/>` +
474        `<line x1="42" y1="-4" x2="42" y2="5" stroke="#F2C14E" stroke-width="2"/><circle cx="42" cy="6" r="2" fill="#F2C14E"/>`,
475    },
476    prop:
477      `<g transform="translate(94 30)"><g>` +
478      `<animateTransform attributeName="transform" type="scale" values="1;1.15;1" dur="1.4s" repeatCount="indefinite"/>` +
479      `<polygon points="0,-17 5,-6 17,-5 8,3 11,15 0,9 -11,15 -8,3 -17,-5 -5,-6" fill="#F2C14E" stroke="#D9A21B" stroke-width="1.5" stroke-linejoin="round"/>` +
480      `</g></g>` +
481      sparkle(116, 12, 0) +
482      sparkle(72, 50, 0.7),
483  }),
484
485  memory: () => ({
486    pose: WORK,
487    prop:
488      `<g transform="rotate(-4 94 32)">` +
489      `<path d="M74,14 h40 v30 l-8,8 h-32 z" fill="#FFE27A"/><path d="M114,44 l-8,8 v-8 z" fill="#E0BE45"/>` +
490      bar(80, 24, 28, '#B59B2D', loop('width', '0;28;28', 3, 'keyTimes="0;0.3;1"')) +
491      bar(80, 31, 28, '#B59B2D', loop('width', '0;0;28;28', 3, 'keyTimes="0;0.3;0.6;1"')) +
492      bar(80, 38, 18, '#B59B2D', loop('width', '0;0;18;18', 3, 'keyTimes="0;0.6;0.85;1"')) +
493      `<circle cx="94" cy="15" r="4" fill="#E5534B"/><circle cx="92.8" cy="13.8" r="1.2" fill="#FFFFFF" opacity="0.7"/>` +
494      `</g>`,
495  }),
496
497  sharing: () => ({
498    pose: WAVING,
499    prop:
500      `<path d="M62,50 C74,50 78,44 84,38" fill="none" stroke="${GREY}" stroke-width="2" stroke-dasharray="3 4" stroke-linecap="round"/>` +
501      `<g transform="translate(84 34)"><g>${slide('0 0;10 -8;0 0', 2.4)}` +
502      `<polygon points="0,-2 30,-14 10,4" fill="${PAPER}" stroke="${SLATE}" stroke-width="1.5" stroke-linejoin="round"/>` +
503      `<polygon points="10,4 30,-14 13,14" fill="#D9DEE4" stroke="${SLATE}" stroke-width="1.5" stroke-linejoin="round"/>` +
504      `</g></g>`,
505  }),
506}
507
508// A scene is two drawings side by side: Nukey on the left, which changes
509// only with its pose, and what the scene puts beside it on the right.
510const SPLIT = 60
511
512export const NUKEY_WIDTH = SPLIT
513export const PROP_WIDTH = SCENE_WIDTH - SPLIT
514
515const frame = (x: number, width: number, inner: string) =>
516  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${x} 0 ${width} ${SCENE_HEIGHT}" ` +
517  `width="${width}" height="${SCENE_HEIGHT}" shape-rendering="geometricPrecision">${inner}</svg>`
518
519export function nukeySvg(activity: Activity, theme: Theme = THEMES[DEFAULT_THEME]): string {
520  return frame(0, NUKEY_WIDTH, nukey(NUKEY_X, NUKEY_Y, SCENES[activity](theme).pose, theme))
521}
522
523export function propSvg(activity: Activity, theme: Theme = THEMES[DEFAULT_THEME]): string {
524  return frame(SPLIT, PROP_WIDTH, SCENES[activity](theme).prop)
525}
526
527// Nukey and prop as one drawing, for a page that shows a scene on its own.
528export function drawingSvg(drawing: Drawing, theme: Theme = THEMES[DEFAULT_THEME]): string {
529  return frame(0, SCENE_WIDTH, nukey(NUKEY_X, NUKEY_Y, drawing.pose, theme) + drawing.prop)
530}
531
532type Frame = readonly [string, string, string]
533
534// The face in Nukey's window, by scene: eyes open by default.
535const FACES: Partial<Record<Activity, string>> = {
536  idle: '- -',
537  done: '^ ^',
538  error: 'x x',
539  thinking: '° °',
540  compacting: '- -',
541}
542
543// Nukey in the terminal: a microwave with a face in the window and a dial
544// light that blinks while it works.
545function nukeyRows(activity: Activity, tick: number): Frame {
546  const face = FACES[activity] ?? '◕ ◕'
547  const light = activity === 'idle' || activity === 'done' || tick % 2 === 0 ? '▪' : '▫'
548
549  return ['┌─────┬─┐', `│ ${face} │${light}│`, '└┬───┬┴─┘']
550}
551
552const NO_PROP: Frame = ['', '', '']
553
554const PROPS: Record<Activity, readonly Frame[]> = {
555  idle: [
556    ['    ', ' z  ', '    '],
557    ['  z ', ' z  ', '    '],
558    ['   Z', '  z ', '    '],
559  ],
560  thinking: [
561    [' ╭─────╮', '°│ ·   │', ' ╰─────╯'],
562    [' ╭─────╮', '°│ ··  │', ' ╰─────╯'],
563    [' ╭─────╮', '°│ ··· │', ' ╰─────╯'],
564  ],
565  reading: [
566    ['┌──┬──┐', '│≡≡│≡≡│', '└──┴──┘'],
567    ['┌──┬──┐', '│≡─│≡≡│', '└──┴──┘'],
568    ['┌──┬──┐', '│≡≡│≡─│', '└──┴──┘'],
569  ],
570  coding: [
571    ['┌─────┐', '│</>▌ │', '▀▀▀▀▀▀▀'],
572    ['┌─────┐', '│</>  │', '▀▀▀▀▀▀▀'],
573  ],
574  writing: [
575    ['┌────╮', '│╱   │', '└────┘'],
576    ['┌────╮', '│≡≡╱ │', '└────┘'],
577    ['┌────╮', '│≡≡≡≡│', '└────┘'],
578  ],
579  designing: [
580    ['┌───┐', '│   │', '└┬─┬┘'],
581    ['┌───┐', '│◐  │', '└┬─┬┘'],
582    ['┌───┐', '│◐◣ │', '└┬─┬┘'],
583    ['┌───┐', '│◐◣◕│', '└┬─┬┘'],
584  ],
585  terminal: [
586    ['┌─────┐', '│$ ▌  │', '▀▀▀▀▀▀▀'],
587    ['┌─────┐', '│$    │', '▀▀▀▀▀▀▀'],
588  ],
589  searching: [
590    ['╭─╮≡≡≡≡', '╰─╯≡≡≡≡', '≡≡≡╲≡≡≡'],
591    ['≡≡╭─╮≡≡', '≡≡╰─╯≡≡', '≡≡≡≡≡╲≡'],
592    ['≡≡≡≡╭─╮', '≡≡≡≡╰─╯', '≡≡≡≡≡≡≡'],
593  ],
594  web: [
595    ['┌○○○──┐', '│█░░░░│', '└─────┘'],
596    ['┌○○○──┐', '│███░░│', '└─────┘'],
597    ['┌○○○──┐', '│█████│', '└─────┘'],
598    ['┌○○○──┐', '│≡≡≡≡ │', '└─────┘'],
599  ],
600  delegating: [
601    ['          ', '[◕◕]  [◕◕]', ' ╹╹    ╹╹ '],
602    ['[◕◕]      ', ' ╹╹   [◕◕]', '       ╹╹ '],
603  ],
604  talking: [
605    ['≡≡≡▌  ', '      ', '      '],
606    ['≡≡≡≡≡≡', '≡≡≡▌  ', '      '],
607    ['≡≡≡≡≡≡', '≡≡≡≡≡≡', '≡≡≡▌  '],
608  ],
609  asking: [
610    ['╭─╮', ' ╭╯', ' ● '],
611    ['╭─╮', ' ╭╯', ' ○ '],
612  ],
613  planning: [
614    ['       ', '       ', '●─╯    '],
615    ['       ', '  ╭─╯  ', '●─╯    '],
616    ['    ╭─◆', '  ╭─╯  ', '●─╯    '],
617    ['    ╭─◆', '  ╭─╯  ', '●─╯    '],
618  ],
619  tool: [
620    ['   ', ' ╋ ', '   '],
621    ['   ', ' ╳ ', '   '],
622  ],
623  done: [
624    ['       ', ' DING! ', '       '],
625    ['·     ·', ' DING! ', '·     ·'],
626  ],
627  permission: [
628    ['┌──────┐', '│ y/n▌ │', '└──────┘'],
629    ['┌──────┐', '│ y/n  │', '└──────┘'],
630  ],
631  compacting: [
632    ['   ╱   ', '  ╱    ', ' ▟ ·:· '],
633    ['    ╱  ', '   ╱   ', '  ▟ ·: '],
634    ['     ╱ ', '    ╱  ', '   ▟ · '],
635  ],
636  error: [
637    [' ╻  ', ' ┃  ', ' ●  '],
638    ['  ╻ ', '  ┃ ', '  ● '],
639  ],
640  waiting: [
641    ['╭───╮', '│ ╵ │', '╰───╯'],
642    ['╭───╮', '│ ╶ │', '╰───╯'],
643    ['╭───╮', '│ ╷ │', '╰───╯'],
644    ['╭───╮', '│ ╴ │', '╰───╯'],
645  ],
646  testing: [
647    ['· ──', '· ──', '· ──'],
648    ['✓ ──', '· ──', '· ──'],
649    ['✓ ──', '✓ ──', '· ──'],
650    ['✓ ──', '✓ ──', '✓ ──'],
651  ],
652  git: [
653    ['  ●─●  ', ' ╱   ╲ ', '●──●──●'],
654    ['  ●─◉  ', ' ╱   ╲ ', '●──●──●'],
655    ['  ●─●  ', ' ╱   ╲ ', '●──●──◉'],
656  ],
657  installing: [
658    ['  ▼  ', '     ', '╰───╯'],
659    ['  │  ', '  ▼  ', '╰───╯'],
660    ['  │  ', '  │  ', '╰─▼─╯'],
661  ],
662  skill: [
663    [' ★ ', '   ', '   '],
664    ['   ', ' ★ ', '   '],
665  ],
666  memory: [
667    ['  ▼  ', '┌───┐', '│≡≡≡│'],
668    ['     ', '┌─▼─┐', '│≡≡≡│'],
669    ['     ', '┌─▼─┐', '│≡≡≡│'],
670  ],
671  sharing: [
672    ['     ', '     ', '╰─▲─╯'],
673    ['     ', '  ▲  ', '╰─│─╯'],
674    ['  ▲  ', '  │  ', '╰───╯'],
675  ],
676}
677
678// The terminal has no vector leaf: Nukey is three rows of box glyphs, with
679// the scene's prop beside it, one of a few frames picked by the running tick.
680export function sceneRows(activity: Activity, tick: number): { nukey: Frame; prop: Frame } {
681  const frames = PROPS[activity]
682
683  return { nukey: nukeyRows(activity, tick), prop: frames[tick % frames.length] ?? NO_PROP }
684}
685
hooks/themes.ts 87 lines
1import type { ThemeName } from '../types'
2
3// The colours Nukey and its control panel are drawn in: the body and its trim,
4// the window that is its face, the eyes that glow in it, the cheeks, the lit
5// power bars on the meter, and the colour Nukey takes in the terminal (a theme
6// key there, or a raw colour).
7export type Theme = {
8  name: ThemeName
9  label: string
10  body: string
11  trim: string
12  window: string
13  glow: string
14  cheek: string
15  accent: string
16  terminal: string
17}
18
19export const THEMES: Record<ThemeName, Theme> = {
20  classic: {
21    name: 'classic',
22    label: 'Classic cream',
23    body: '#F5F1E8',
24    trim: '#4A4F5A',
25    window: '#23262E',
26    glow: '#F8F4EA',
27    cheek: '#F4A6B7',
28    accent: '#D97757',
29    terminal: 'claude',
30  },
31  retro: {
32    name: 'retro',
33    label: 'Retro mint',
34    body: '#A8E0CF',
35    trim: '#2F5D55',
36    window: '#1F2A2A',
37    glow: '#FFF6D8',
38    cheek: '#F49A8A',
39    accent: '#E8735A',
40    terminal: '#5FB8A0',
41  },
42  steel: {
43    name: 'steel',
44    label: 'Stainless steel',
45    body: '#C9CED6',
46    trim: '#3D434C',
47    window: '#15181D',
48    glow: '#9BE7FF',
49    cheek: '#E8A0B0',
50    accent: '#4FA3E0',
51    terminal: '#9AA4B2',
52  },
53  midnight: {
54    name: 'midnight',
55    label: 'Midnight',
56    body: '#2E3240',
57    trim: '#8A93A6',
58    window: '#0E1016',
59    glow: '#FFB454',
60    cheek: '#C9737F',
61    accent: '#FFB454',
62    terminal: '#FFB454',
63  },
64  bubblegum: {
65    name: 'bubblegum',
66    label: 'Bubblegum',
67    body: '#FFC6DA',
68    trim: '#8C3B5E',
69    window: '#3A1F2C',
70    glow: '#FFF0F6',
71    cheek: '#FF7FA8',
72    accent: '#FF5C93',
73    terminal: '#FF8FB7',
74  },
75}
76
77export const THEME_NAMES = Object.keys(THEMES) as ThemeName[]
78
79export const DEFAULT_THEME: ThemeName = 'classic'
80
81export const isThemeName = (value: unknown): value is ThemeName => typeof value === 'string' && value in THEMES
82
83// The theme after this one, for `/nukey theme` with nothing after it.
84export function nextTheme(name: ThemeName): ThemeName {
85  return THEME_NAMES[(THEME_NAMES.indexOf(name) + 1) % THEME_NAMES.length] ?? DEFAULT_THEME
86}
87
types/index.d.ts 52 lines
1export type Activity =
2  | 'idle'
3  | 'thinking'
4  | 'reading'
5  | 'coding'
6  | 'writing'
7  | 'designing'
8  | 'terminal'
9  | 'searching'
10  | 'web'
11  | 'delegating'
12  | 'talking'
13  | 'asking'
14  | 'planning'
15  | 'tool'
16  | 'done'
17  | 'permission'
18  | 'compacting'
19  | 'error'
20  | 'waiting'
21  | 'testing'
22  | 'git'
23  | 'installing'
24  | 'skill'
25  | 'memory'
26  | 'sharing'
27
28export type Scene = { activity: Activity; detail: string }
29
30// The colour schemes Nukey can be drawn in, picked with `/nukey theme`.
31export type ThemeName = 'classic' | 'retro' | 'steel' | 'midnight' | 'bubblegum'
32
33// What the control-panel meter shows, each in whole percents used: the context
34// window's fill, and the five-hour and weekly rate-limit windows. `null` where
35// there is no reading: before the first response, or off a subscription.
36export type Meter = { context: number | null; fiveHour: number | null; week: number | null }
37
38declare module 'claude-code' {
39  interface PluginState {
40    'nukey': {
41      scene: Scene
42      isHidden: boolean
43      frame: number
44      meter: Meter
45      isMetered: boolean
46      isDetailed: boolean
47      isRaw: boolean
48      theme: ThemeName
49    }
50  }
51}
52