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…

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.
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.
| 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 |
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.
| 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 |
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.
The control panel lights up as things are used:
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) | | :- | :- | | |
|
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.
hooks/register.tsx 932 lines1import { 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}
932hooks/activity.ts 408 lines1import 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}
408hooks/meter.ts 122 lines1import 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}
122hooks/scenes.ts 685 lines1import 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}
685hooks/themes.ts 87 lines1import 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}
87types/index.d.ts 52 lines1export 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