A crab above the prompt acts out what Claude is doing: reading, writing code, thinking, searching and more. A shell beside it shows context and rate-limit…

A Claude Code mod: a small crab above the prompt acts out what Claude is doing, in the terminal and in the desktop app. Beside it, a shell 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 sTomerG/agent-kit
claude plugin install crab-cam@agent-kit
Requires Claude Code v2.1.287 or later; tested with v2.1.288.
To get new versions, run claude plugin update crab-cam@agent-kit, or enable auto-update for the marketplace under /plugin → Marketplaces.
| Command | What it does | | :- | :- | | /crab-cam | Hide or show the crab | | /crab-cam demo | Play every scene, three seconds each | | /crab-cam meter | Hide or show the shell meter | | /crab-cam details | Show or hide the meter's readings in figures beside the shell | | /crab-cam commands | Show commands as typed, or go back to the description of what they do |
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 | | done, resting | the turn ended |
Work inside a subagent is not shown; the crab stays on "putting helpers to work" until the helper returns.
The shell fills as things are used:
The last two units of a gauge turn red above 85%. The pearls are left out on accounts without rate-limit windows.
The meter comes in two forms, and /crab-cam meter hides it altogether:
| Default | With details (/crab-cam details) | | :- | :- | | |
|
claude --plugin-dir ./plugins/crab-cam # load from disk, reload on save
claude plugin test plugins/crab-cam # run the tests
claude plugin validate plugins/crab-cam # check the manifest and hooks
npx tsx plugins/crab-cam/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 902 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Activity, Meter, Scene } 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 { CRAB_WIDTH, PROP_WIDTH, SCENE_HEIGHT, crabSvg, propSvg, sceneRows } from './scenes'
18
19const COMMAND = 'crab-cam'
20const REST_AFTER_MS = 8000
21const TICK_MS = 600
22// The detail may run on to a second line: the layout cuts whatever is wider
23// than the room before the shell, so the limit only keeps it to two lines.
24const DETAIL_LENGTH = 140
25// A detail up to this long is taken to fit on one line.
26const ONE_LINE = 60
27// How long a tool's scene stays up at least, so a call that returns at once
28// is still seen; a failure stays longer.
29const HOLD_MS = 1200
30const ERROR_HOLD_MS = 2500
31const MAX_HELD = 3
32// How long the last tool's scene stays once the tool is through before the
33// crab goes back to thinking: the model's pause between two calls is mostly
34// shorter, and the work then reads as one stretch of reading or searching.
35const THINK_AFTER_MS = 5000
36const DEMO_MS = 3000
37const DEMO_DETAIL = 'demo'
38const DEMO: readonly Activity[] = [
39 'thinking',
40 'reading',
41 'coding',
42 'writing',
43 'designing',
44 'terminal',
45 'testing',
46 'git',
47 'installing',
48 'searching',
49 'web',
50 'delegating',
51 'planning',
52 'skill',
53 'memory',
54 'tool',
55 'sharing',
56 'waiting',
57 'permission',
58 'error',
59 'compacting',
60 'talking',
61 'asking',
62 'done',
63]
64
65const scene = atom({ plugin: 'crab-cam', key: 'scene' } as const, { activity: 'idle', detail: '' })
66const isHidden = atom({ plugin: 'crab-cam', key: 'isHidden' } as const, false)
67const frame = atom({ plugin: 'crab-cam', key: 'frame' } as const, 0)
68const meter = atom({ plugin: 'crab-cam', key: 'meter' } as const, { context: null, fiveHour: null, week: null })
69const isMetered = atom({ plugin: 'crab-cam', key: 'isMetered' } as const, true)
70const isDetailed = atom({ plugin: 'crab-cam', key: 'isDetailed' } as const, false)
71// Whether a command shows as typed rather than by its description.
72const isRaw = atom({ plugin: 'crab-cam', key: 'isRaw' } as const, false)
73
74const LABELS: Record<Activity, string> = {
75 idle: 'Claude is resting',
76 thinking: 'Claude is thinking',
77 reading: 'Claude is reading',
78 coding: 'Claude is writing code',
79 writing: 'Claude is writing text',
80 designing: 'Claude is designing',
81 terminal: 'Claude is running a command',
82 searching: 'Claude is searching',
83 web: 'Claude is browsing the web',
84 delegating: 'Claude is putting helpers to work',
85 talking: 'Claude is writing a reply',
86 asking: 'Claude has a question for you',
87 planning: 'Claude is making a plan',
88 tool: 'Claude is using a tool',
89 done: 'Done!',
90 permission: 'Claude is waiting for your permission',
91 compacting: 'Claude is tidying up its memory',
92 error: 'Oops, that went wrong',
93 waiting: 'Claude is waiting on a background task',
94 testing: 'Claude is running tests',
95 git: 'Claude is working with git',
96 installing: 'Claude is installing packages',
97 skill: 'Claude is loading a skill',
98 memory: 'Claude is remembering something',
99 sharing: 'Claude is sharing something with you',
100}
101
102// A command comes with a description of what it does in plain words, the one
103// the app shows too: that goes before the command itself.
104const DETAIL_KEYS = ['file_path', 'notebook_path', 'description', 'command', 'pattern', 'query', 'url', 'skill'] as const
105// The same with the command itself first, for who wants to see what runs.
106const RAW_KEYS = ['file_path', 'notebook_path', 'command', 'pattern', 'query', 'url', 'description', 'skill'] as const
107
108// A detail cut to what the band has room for.
109function clip(text: string): string {
110 return text.length > DETAIL_LENGTH ? `${text.slice(0, DETAIL_LENGTH - 1)}…` : text
111}
112
113// What the call is about, in a few words: the file's name, what the command
114// does, the pattern.
115function detailOf(tool: string, args: Readonly<Record<string, unknown>>, raw = false): string {
116 for (const key of raw ? RAW_KEYS : DETAIL_KEYS) {
117 const value = args[key]
118
119 if (typeof value !== 'string' || value === '') {
120 continue
121 }
122
123 const isPath = key === 'file_path' || key === 'notebook_path'
124
125 return clip(isPath ? (value.split('/').pop() ?? value) : (value.split('\n')[0] ?? value))
126 }
127
128 return tool.startsWith('mcp__') ? (tool.split('__').pop() ?? '') : ''
129}
130
131// The arguments that say what a call is about before it is made, once all of
132// one has arrived: the file written to, the skill, what a command does.
133const EARLY_KEYS = ['file_path', 'skill', 'description'] as const
134const RAW_EARLY_KEYS = ['file_path', 'skill', 'command'] as const
135
136// What a call is about while its arguments are still arriving; nothing until
137// one of the telling ones is whole.
138function detailOfStreaming(tool: string, json: string, raw: boolean): string {
139 for (const key of raw ? RAW_EARLY_KEYS : EARLY_KEYS) {
140 const value = whole(json, key)
141
142 if (value !== undefined) {
143 return detailOf(tool, { [key]: value })
144 }
145 }
146
147 return ''
148}
149
150type Painted = { activity: Activity; detail: string; hold: number }
151
152type Call = { tool: string; activity: Activity; detail: string }
153
154let isWorking = false
155// Whether this turn has taken up design work, by a design skill or tool or
156// the start of an artifact: a page written after that is designed, not coded.
157let isDesigning = false
158// The main loop's calls still running, by tool_use_id, in the order begun.
159const calls = new Map<string, Call>()
160let pending: Promise<unknown> = Promise.resolve()
161let rest: Timer | undefined
162let ticker: Timer | undefined
163let demo: Timer | undefined
164let muse: Timer | undefined
165// The tool a permission dialog is open for, while one is.
166let asking: string | undefined
167// What the last stop of the main loop left running in the background.
168let lingering: readonly Lingering[] = []
169// The helpers at work, by agent id: subagents and a workflow's agents alike,
170// each from its first event until its own turn ends, during a turn and after.
171const helpers = new Map<string, Helper>()
172// Runs while the helpers' line was drawn a moment ago; what changes meanwhile
173// is drawn once it is through, so six helpers at once do not make it flicker.
174let helperBeat: Timer | undefined
175let isHelperLineStale = false
176// The scene on screen, the hold it is under, and what came in meanwhile.
177let current: Painted | undefined
178let busy: Timer | undefined
179let held: Painted[] = []
180let trailing: Painted | undefined
181
182// The next scene due: held ones in the order made, then the latest unheld one.
183function takeQueued(): Painted | undefined {
184 const [first, ...others] = held
185 held = others
186
187 if (first !== undefined) {
188 return first
189 }
190
191 const last = trailing
192 trailing = undefined
193
194 return last
195}
196
197// Draws a scene, or queues it while the one on screen is still held. Writes
198// run one after another and off the caller's path, so a stream of chunks
199// never waits on the crab and two changes land in the order made.
200function paint($: EngineInterface, activity: Activity, detail: string, hold = 0): void {
201 const wanted: Painted = { activity, detail, hold }
202
203 if (busy !== undefined) {
204 if (hold > 0) {
205 held = [...held, wanted].slice(-MAX_HELD)
206 trailing = undefined
207 } else {
208 trailing = wanted
209 }
210
211 return
212 }
213
214 if (current?.activity !== activity || current.detail !== detail) {
215 const next: Scene = { activity, detail }
216 pending = pending.then(() => update($, scene, () => next)).catch(() => undefined)
217 }
218
219 current = wanted
220
221 if (hold > 0) {
222 busy = $.clock.after(hold, () => {
223 busy = undefined
224 const due = takeQueued()
225
226 if (due !== undefined) {
227 paint($, due.activity, due.detail, due.hold)
228 }
229 })
230 }
231}
232
233// Drops the hold on the scene on screen and what is queued behind it, so the
234// next scene shows at once and nothing from before it shows after.
235function flush(): void {
236 busy?.cancel()
237 busy = undefined
238 held = []
239 trailing = undefined
240}
241
242// What the session is doing; held back while the demo has the crab. Helpers
243// at work show as how many there are and what they are doing, which says more
244// than the description of the call that started them.
245function show($: EngineInterface, activity: Activity, detail = '', hold = 0): void {
246 if (demo === undefined) {
247 const isCounted = activity === 'delegating' && helpers.size > 0
248 paint($, activity, isCounted ? clip(helpersLine([...helpers.values()])) : detail, hold)
249 }
250}
251
252function stopMusing(): void {
253 muse?.cancel()
254 muse = undefined
255}
256
257// Back to thinking once nothing new has come for a while.
258function museLater($: EngineInterface): void {
259 stopMusing()
260 muse = $.clock.after(THINK_AFTER_MS, () => {
261 muse = undefined
262
263 if (isWorking && calls.size === 0) {
264 show($, 'thinking')
265 }
266 })
267}
268
269function stopTicking(): void {
270 ticker?.cancel()
271 ticker = undefined
272}
273
274// The scene between turns: the background work still going, or rest.
275function settle($: EngineInterface): void {
276 if (helpers.size > 0) {
277 show($, 'delegating')
278
279 return
280 }
281
282 // A subagent's end is heard as it happens, so one the stop still listed is
283 // through by now; a workflow may be between two of its agents.
284 const scene = sceneOfLingering(lingering.filter(task => task.type !== 'subagent'))
285
286 if (scene === undefined) {
287 stopTicking()
288 show($, 'idle')
289 } else {
290 show($, scene.activity, clip(scene.detail))
291 }
292}
293
294// The scene of a turn under way: the call still running that began last, else
295// thinking.
296function resume($: EngineInterface): void {
297 const last = [...calls.values()].pop()
298
299 if (last === undefined) {
300 show($, 'thinking')
301 } else {
302 show($, last.activity, last.detail)
303 }
304}
305
306// Back to what the session is doing, after something else had the crab.
307function restore($: EngineInterface): void {
308 if (isWorking) {
309 resume($)
310 } else {
311 settle($)
312 }
313}
314
315// Draws the helpers' line where the crab is on helpers: during a turn only
316// while the scene is theirs already, between turns whenever the crab is settled.
317function drawHelpers($: EngineInterface): void {
318 if (!isWorking) {
319 if (rest === undefined) {
320 settle($)
321 }
322 } else if (current?.activity === 'delegating') {
323 if (helpers.size > 0) {
324 show($, 'delegating')
325 } else {
326 resume($)
327 }
328 }
329}
330
331// A helper came, went or took up something else.
332function helpersChanged($: EngineInterface): void {
333 if (helperBeat !== undefined) {
334 isHelperLineStale = true
335
336 return
337 }
338
339 drawHelpers($)
340 helperBeat = $.clock.after(HOLD_MS, () => {
341 helperBeat = undefined
342
343 if (isHelperLineStale) {
344 isHelperLineStale = false
345 helpersChanged($)
346 }
347 })
348}
349
350// Takes note of a helper: that it is there, and what is new about it.
351function noteHelper($: EngineInterface, id: string, news: Partial<Helper> = {}): void {
352 const before = helpers.get(id)
353 const now: Helper = { activity: 'thinking', ...before, ...news }
354 helpers.set(id, now)
355
356 if (before?.activity !== now.activity || before.about !== now.about) {
357 helpersChanged($)
358 }
359}
360
361function dropHelper($: EngineInterface, id: string): void {
362 if (helpers.delete(id)) {
363 helpersChanged($)
364 }
365}
366
367// Drops the helpers that are through without their end having been heard, as
368// after a crash: those the engine lists as no longer running, and those it
369// does not list at all, a workflow's, once no workflow is left running.
370async function pruneHelpers($: EngineInterface): Promise<void> {
371 if (helpers.size === 0) {
372 return
373 }
374
375 const listed = await $.agent.list().catch(() => undefined)
376
377 if (listed === undefined) {
378 return
379 }
380
381 const hasWorkflow = lingering.some(task => task.type === 'workflow')
382
383 for (const id of [...helpers.keys()]) {
384 const info = listed.find(agent => agent.id === id)
385
386 if (info === undefined ? !hasWorkflow : info.status !== 'running') {
387 dropHelper($, id)
388 }
389 }
390}
391
392function stopTimers(): void {
393 stopMusing()
394 rest?.cancel()
395 rest = undefined
396}
397
398// Walks every scene in turn, so each animation can be seen without a task
399// that happens to raise it; the session's own scenes wait until it is through.
400function playDemo($: EngineInterface): void {
401 demo?.cancel()
402 flush()
403 let index = 0
404 paint($, DEMO[0] ?? 'idle', DEMO_DETAIL)
405
406 demo = $.clock.every(DEMO_MS, () => {
407 index += 1
408 const activity = DEMO[index]
409
410 if (activity === undefined) {
411 demo?.cancel()
412 demo = undefined
413 restore($)
414
415 return
416 }
417
418 paint($, activity, DEMO_DETAIL)
419 })
420}
421
422type Measured = {
423 context: { percent?: number }
424 rateLimits: readonly { kind: string; percentUsed: number }[]
425}
426
427// Takes a measurement of the session over into the meter, redrawing the band
428// only when a figure the shell shows has moved.
429async function gauge($: EngineInterface, measured: Measured): Promise<void> {
430 const used = (kind: string) => {
431 const limit = measured.rateLimits.find(one => one.kind === kind)
432
433 return limit === undefined ? null : Math.round(limit.percentUsed)
434 }
435 const now: Meter = { context: measured.context.percent ?? null, fiveHour: used('five_hour'), week: used('seven_day') }
436 const before = await read($, meter)
437
438 if (before.context !== now.context || before.fiveHour !== now.fiveHour || before.week !== now.week) {
439 await update($, meter, () => now)
440 }
441}
442
443// The meter's readings spelled out as a small table: the labels dim in one
444// column, the figures bold in the next, so the figures line up.
445function readings(table: Elements['terminal'] | Elements['desktop'], gauged: Meter) {
446 const { Box, Text } = table
447 const lines = meterLines(gauged)
448
449 return (
450 <Box flexDirection="row" gap={2} flexShrink={0}>
451 <Box flexDirection="column" flexShrink={0}>
452 {lines.map(line => (
453 <Text dimColor>{line.label}</Text>
454 ))}
455 </Box>
456 <Box flexDirection="column" alignItems="flex-end" flexShrink={0}>
457 {lines.map(line => (
458 <Text bold>{line.value}</Text>
459 ))}
460 </Box>
461 </Box>
462 )
463}
464
465// The line under the label: a short one on one line and cut at the edge, a
466// long one wrapped onto as many rows as it needs. No room is set aside for a
467// second row, which would stand empty under a long one that fits on one.
468function detailLine(table: Elements['terminal'] | Elements['desktop'], detail: string) {
469 const { Text } = table
470
471 return (
472 <Text dimColor wrap={detail.length <= ONE_LINE ? 'truncate-end' : 'wrap'}>
473 {detail === '' ? ' ' : detail}
474 </Text>
475 )
476}
477
478function terminalTree(
479 table: Elements['terminal'],
480 shown: Scene,
481 tick: number,
482 gauged: Meter | undefined,
483 detailed: boolean,
484) {
485 const { Box, Text } = table
486 const { activity, detail } = shown
487 const rows = sceneRows(activity, tick)
488
489 return (
490 <Box flexDirection="row" gap={2} width="100%">
491 <Box flexDirection="column" flexShrink={0}>
492 {rows.crab.map(row => (
493 <Text color="claude">{row}</Text>
494 ))}
495 </Box>
496 <Box flexDirection="column" flexShrink={0}>
497 {rows.prop.map(row => (
498 <Text dimColor>{row === '' ? ' ' : row}</Text>
499 ))}
500 </Box>
501 <Box flexDirection="column" flexGrow={1}>
502 {detail.length <= ONE_LINE && <Text> </Text>}
503 <Text bold wrap="truncate-end">
504 {LABELS[activity]}
505 </Text>
506 {detailLine(table, detail)}
507 </Box>
508 {gauged !== undefined && (
509 <Box flexDirection="column" flexShrink={0}>
510 <Text> </Text>
511 <Text color="claude">{meterRow(gauged)}</Text>
512 </Box>
513 )}
514 {gauged !== undefined && detailed && readings(table, gauged)}
515 </Box>
516 )
517}
518
519function desktopTree(table: Elements['desktop'], shown: Scene, gauged: Meter | undefined, detailed: boolean) {
520 const { Box, Svg, Text } = table
521 const { activity, detail } = shown
522 const label = LABELS[activity]
523
524 return (
525 <Box flexDirection="row" alignItems="center" gap={2} width="100%">
526 <Box flexDirection="row" gap={0} flexShrink={0}>
527 <Svg source={crabSvg(activity)} alt="The Claude crab" width={CRAB_WIDTH} height={SCENE_HEIGHT} />
528 <Svg
529 source={propSvg(activity)}
530 alt={detail === '' ? label : `${label}: ${detail}`}
531 width={PROP_WIDTH}
532 height={SCENE_HEIGHT}
533 />
534 </Box>
535 <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
536 <Text bold>{label}</Text>
537 {detail !== '' && detailLine(table, detail)}
538 </Box>
539 {gauged !== undefined && <Svg source={meterSvg(gauged)} alt={meterAlt(gauged)} width={meterWidth(gauged)} height={SCENE_HEIGHT} />}
540 {gauged !== undefined && detailed && readings(table, gauged)}
541 </Box>
542 )
543}
544
545export const register: Register = on => {
546 on('session.start', async ($, e, next) => {
547 await $.command.register({
548 name: COMMAND,
549 description:
550 'Hide or show the crab; "demo" plays every scene, "meter" shows or hides the shell, "details" its readings in figures, "commands" shows commands as typed',
551 argumentHint: '[demo|meter|details|commands]',
552 })
553
554 // A reload in the middle of a turn finds the turn's scene in place and
555 // leaves it; only a scene that was on its way to rest is put to rest.
556 const stored = await read($, scene)
557
558 if (stored.activity === 'done' || stored.activity === 'error') {
559 show($, 'idle')
560 }
561
562 // The figures the session already has; later ones come with session.measure.
563 void $.session
564 .usage()
565 .then(usage => gauge($, usage))
566 .catch(() => undefined)
567
568 return next(e)
569 })
570
571 on('command.run', { command: COMMAND }, async ($, e) => {
572 const argument = e.args.trim()
573
574 if (argument === 'demo') {
575 await update($, isHidden, () => false)
576 playDemo($)
577
578 return { text: `The crab plays all ${DEMO.length} scenes, ${DEMO_MS / 1000} seconds each.` }
579 }
580
581 if (argument === 'meter') {
582 const metered = await update($, isMetered, value => !value)
583
584 return {
585 text: metered
586 ? 'The shell is back at the right of the band. Everything fills as it is used: the ribs with the context, the row of five pearls with the 5-hour limit, the seven smaller ones below it with the week.'
587 : 'The shell is gone. /crab-cam meter puts it back.',
588 }
589 }
590
591 if (argument === 'details') {
592 const detailed = await update($, isDetailed, value => !value)
593
594 if (detailed) {
595 await update($, isMetered, () => true)
596 }
597
598 return {
599 text: detailed
600 ? 'The readings are spelled out beside the shell. /crab-cam details hides them again.'
601 : 'The readings are gone; the shell stays. /crab-cam details puts them back.',
602 }
603 }
604
605 if (argument === 'commands') {
606 const raw = await update($, isRaw, value => !value)
607
608 return {
609 text: raw
610 ? 'Commands now show as typed. /crab-cam commands goes back to describing what they do.'
611 : 'Commands are described by what they do again. /crab-cam commands shows them as typed.',
612 }
613 }
614
615 const hidden = await update($, isHidden, value => !value)
616
617 return { text: hidden ? 'The crab is hidden. Type /crab-cam to bring it back.' : 'The crab is back.' }
618 })
619
620 on('session.measure', async ($, e, next) => {
621 await gauge($, e).catch(() => undefined)
622
623 return next(e)
624 })
625
626 on('turn.start', async ($, e, next) => {
627 stopTimers()
628 isWorking = true
629 isDesigning = false
630 calls.clear()
631 asking = undefined
632 flush()
633 show($, 'thinking')
634
635 // Only the terminal's glyph scenes are animated by a tick; the desktop's
636 // drawing animates itself.
637 if (ticker === undefined && (await $.session.surfaces()).includes('terminal')) {
638 ticker = $.clock.every(TICK_MS, () => {
639 void update($, frame, tick => (tick + 1) % 60).catch(() => undefined)
640 })
641 }
642
643 return next(e)
644 })
645
646 on('turn.step', async function* ($, e, next) {
647 if (e.agentId !== undefined) {
648 noteHelper($, e.agentId)
649
650 return yield* next(e)
651 }
652
653 // A reload in the middle of a turn has no turn.start to go by.
654 isWorking = true
655 // The calls of this response whose arguments are still arriving and say
656 // what the call is about, by block: the tool and what has arrived, until
657 // it settles the scene.
658 const arriving = new Map<number, { tool: string; json: string; activity?: Activity }>()
659 const raw = await read($, isRaw)
660
661 for await (const chunk of next(e)) {
662 if (chunk.kind === 'thinking') {
663 // Right after a tool the thinking is the short pause before the next
664 // call: the tool's scene stays until the pause has lasted.
665 if (muse === undefined && calls.size === 0) {
666 show($, 'thinking')
667 }
668 } else if (chunk.kind === 'text' && chunk.text.trim() !== '') {
669 stopMusing()
670 show($, 'talking')
671 } else if (chunk.kind === 'tool') {
672 stopMusing()
673
674 // What a command or an edit is about shows only in its arguments: the
675 // scene waits for enough of them, rather than passing through a
676 // generic one.
677 if (isToldByArgs(chunk.name)) {
678 arriving.set(chunk.index, { tool: chunk.name, json: '' })
679 } else {
680 show($, activityOf(chunk.name))
681 }
682 } else if (chunk.kind === 'input') {
683 const before = arriving.get(chunk.index)
684
685 if (before !== undefined) {
686 const json = before.json + chunk.json
687 const activity = before.activity ?? activityOfStreaming(before.tool, json, isDesigning)
688 const detail = activity === undefined ? '' : detailOfStreaming(before.tool, json, raw)
689
690 // The scene shows as soon as it is settled, and what it is about
691 // as soon as that is in: a command's description comes after the
692 // command, which may be seconds later.
693 if (detail === '') {
694 arriving.set(chunk.index, { tool: before.tool, json, activity })
695 } else {
696 arriving.delete(chunk.index)
697 }
698
699 if (activity !== undefined && (before.activity === undefined || detail !== '')) {
700 show($, activity, detail)
701 }
702 }
703 }
704
705 yield chunk
706 }
707 })
708
709 on('tool.call', async ($, e, next) => {
710 const args = e as unknown as Readonly<Record<string, unknown>>
711
712 if (e.agentId !== undefined) {
713 noteHelper($, e.agentId, isHandback(e.tool) ? {} : { activity: activityOf(e.tool, args) })
714
715 return next(e)
716 }
717
718 isDesigning ||= opensDesign(e.tool, args)
719 const activity = activityOf(e.tool, args, isDesigning)
720 const detail = detailOf(e.tool, args, await read($, isRaw))
721 let hasFailed = false
722 calls.set(e.tool_use_id, { tool: e.tool, activity, detail })
723 stopMusing()
724 show($, activity, detail, HOLD_MS)
725
726 try {
727 const ran = await next(e)
728 hasFailed = ran.deny === undefined && ran.isError === true
729
730 return ran
731 } finally {
732 calls.delete(e.tool_use_id)
733 const wasAsked = asking === e.tool
734
735 if (wasAsked) {
736 asking = undefined
737 }
738
739 // A call that returns once its turn is over, as on an interrupt, says
740 // nothing of the session any more.
741 if (isWorking) {
742 if (hasFailed) {
743 show($, 'error', detail, ERROR_HOLD_MS)
744 }
745
746 // A dialog still open for another call keeps the crab.
747 if (asking === undefined) {
748 if (calls.size > 0) {
749 resume($)
750 } else {
751 if (wasAsked && !hasFailed) {
752 show($, activity, detail)
753 }
754
755 museLater($)
756 }
757 }
758 }
759 }
760 })
761
762 // The dialog is on screen from here until the person answers; nothing says
763 // when they did, so the scene stays until the call itself returns. A
764 // subagent's dialog is not the main loop's, and a question or a plan put to
765 // the person is asking, not permission.
766 on('classic.PermissionRequest', ($, e, next) => {
767 if (e.agent_id !== undefined) {
768 return next(e)
769 }
770
771 asking = e.tool_name
772
773 if (activityOf(e.tool_name) === 'asking' || e.tool_name === 'ExitPlanMode') {
774 show($, 'asking')
775 } else {
776 show($, 'permission', e.tool_name)
777 }
778
779 return next(e)
780 })
781
782 on('session.compact', async ($, e, next) => {
783 if (e.agentId !== undefined || e.trigger === 'precompute') {
784 return next(e)
785 }
786
787 show($, 'compacting', '', HOLD_MS)
788
789 try {
790 return await next(e)
791 } finally {
792 restore($)
793 }
794 })
795
796 // A helper is there from the moment it starts, seconds before its first call.
797 on('classic.SubagentStart', ($, e, next) => {
798 noteHelper($, e.agent_id)
799
800 return next(e)
801 })
802
803 // The call that starts a subagent says what it is for; a workflow's agents
804 // start without one.
805 on('agent.spawn', async ($, e, next) => {
806 const started = await next(e)
807
808 if (started.agentId !== undefined) {
809 noteHelper($, started.agentId, { about: e.description === '' ? e.subagentType : e.description })
810 }
811
812 return started
813 })
814
815 // A helper's own turn ends once, whether it answered, failed or was stopped.
816 on('turn.complete', ($, e, next) => {
817 if (e.agentId !== undefined) {
818 dropHelper($, e.agentId)
819
820 return next(e)
821 }
822
823 stopTimers()
824 isWorking = false
825 calls.clear()
826 asking = undefined
827 // Nothing of the turn shows once it is over.
828 flush()
829
830 if (e.isAborted) {
831 settle($)
832 } else {
833 // A refusal or an API error ended the turn too, but nothing is done.
834 show($, e.reason === 'answer' ? 'done' : 'error')
835 rest = $.clock.after(REST_AFTER_MS, () => {
836 rest = undefined
837
838 if (!isWorking) {
839 settle($)
840 }
841 })
842 }
843
844 return next(e)
845 })
846
847 // `/clear`, `/resume` and the end of the session: what the crab knew of the
848 // conversation is gone with it.
849 on('session.end', ($, e, next) => {
850 stopTimers()
851 stopTicking()
852 demo?.cancel()
853 demo = undefined
854 flush()
855 isWorking = false
856 isDesigning = false
857 calls.clear()
858 asking = undefined
859 lingering = []
860 helpers.clear()
861 helperBeat?.cancel()
862 helperBeat = undefined
863 isHelperLineStale = false
864 paint($, 'idle', '')
865
866 return next(e)
867 })
868
869 // The turn may be over while a command or a helper it started runs on: the
870 // crab then keeps at it rather than resting, until the next turn begins.
871 on('classic.Stop', ($, e, next) => {
872 lingering = e.background_tasks ?? []
873 void pruneHelpers($)
874
875 if (!isWorking && rest === undefined) {
876 settle($)
877 }
878
879 return next(e)
880 })
881
882 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
883 if (e.props.hasSurvey || (await read($, isHidden))) {
884 return next(e)
885 }
886
887 const shown = await read($, scene)
888 const gauged = (await read($, isMetered)) ? await read($, meter) : undefined
889 const detailed = await read($, isDetailed)
890
891 if (e.surface === 'terminal') {
892 return terminalTree($.ui.resolve(e), shown, await read($, frame), gauged, detailed)
893 }
894
895 if (e.surface === 'desktop') {
896 return desktopTree($.ui.resolve(e), shown, gauged, detailed)
897 }
898
899 return next(e)
900 })
901}
902hooks/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 the crab 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 the crab from resting.
385function isStanding(task: Lingering): boolean {
386 return /^live updates for /i.test(task.description)
387}
388
389// What the crab 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 the crab 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 124 lines1import type { Meter } from '../types'
2import { GREY, ORANGE, SCENE_HEIGHT } from './scenes'
3
4const RED = '#E5534B'
5const HINGE = '#C9A66B'
6const PEARL = '#F2C14E'
7
8const SHELL_WIDTH = 80
9const PEARLS_WIDTH = 52
10const RIBS = 9
11const FIVE_HOUR_PEARLS = 5
12const WEEK_PEARLS = 7
13// Past this share the last two units of a gauge turn red: nearly used up.
14const NEARLY_FULL = 85
15
16const round = (value: number) => Math.round(value * 10) / 10
17const share = (percent: number) => Math.max(0, Math.min(1, percent / 100))
18
19// How many of a gauge's units are lit: everything on the meter fills as it is
20// used, the ribs with the context and the pearls with a rate-limit window.
21const litOf = (used: number | null, units: number) => (used === null ? 0 : Math.round(share(used) * units))
22
23// The colour of a lit unit, the last two red once the gauge is nearly full;
24// nothing for a unit not reached yet, which its gauge draws as a ghost, so the
25// whole scale is always in view.
26function colourOf(unit: number, units: number, used: number | null, colour: string): string | undefined {
27 if (unit >= litOf(used, units)) {
28 return undefined
29 }
30
31 return (used ?? 0) > NEARLY_FULL && unit >= units - 2 ? RED : colour
32}
33
34// A row of pearls that light up one by one as a rate-limit window is used, the
35// ones not reached yet in grey. Nothing where the account has no such window.
36function pearls(x: number, y: number, radius: number, gap: number, units: number, used: number | null): string {
37 if (used === null) {
38 return ''
39 }
40
41 let row = ''
42
43 for (let unit = 0; unit < units; unit++) {
44 const fill = colourOf(unit, units, used, PEARL)
45
46 row += `<circle cx="${round(x + unit * gap)}" cy="${y}" r="${radius}" fill="${fill ?? GREY}"${fill === undefined ? ' opacity="0.6"' : ''}/>`
47 }
48
49 return row
50}
51
52// How wide the meter is drawn: the shell alone where the account has no
53// rate-limit window, the shell and its two rows of pearls otherwise.
54export const meterWidth = (meter: Meter) => SHELL_WIDTH + (meter.fiveHour === null && meter.week === null ? 0 : PEARLS_WIDTH)
55
56// The shell: nine ribs that colour in with the context. Beside it a row of five
57// pearls for the five-hour window, and below that seven smaller ones for the week.
58export function meterSvg(meter: Meter): string {
59 const centerX = 40
60 const centerY = 50
61 const radius = 35
62 const at = (degrees: number, length: number) => {
63 const angle = (degrees * Math.PI) / 180
64
65 return `${round(centerX + length * Math.sin(angle))},${round(centerY - length * Math.cos(angle))}`
66 }
67 const width = meterWidth(meter)
68 let fan = ''
69
70 for (let rib = 0; rib < RIBS; rib++) {
71 const from = -71 + rib * 16
72 const to = from + 14
73 const fill = colourOf(rib, RIBS, meter.context, ORANGE)
74
75 fan +=
76 `<path d="M${centerX},${centerY} L${at(from, radius)} Q${at((from + to) / 2, radius * 1.1)} ${at(to, radius)} Z" ` +
77 `fill="${fill ?? GREY}"${fill === undefined ? ' opacity="0.45"' : ''}/>`
78 }
79
80 return (
81 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${SCENE_HEIGHT}" width="${width}" height="${SCENE_HEIGHT}" ` +
82 `shape-rendering="geometricPrecision">` +
83 fan +
84 `<path d="M33,50 h14 l-2,6 h-10 Z" fill="${HINGE}"/>` +
85 pearls(90.5, 23, 4, 9.1, FIVE_HOUR_PEARLS, meter.fiveHour) +
86 pearls(89.3, 43, 2.8, 6.3, WEEK_PEARLS, meter.week) +
87 `</svg>`
88 )
89}
90
91// The meter in words, for whoever cannot see the drawing.
92export function meterAlt(meter: Meter): string {
93 const parts = [
94 meter.context === null ? 'Context not measured yet' : `Context ${meter.context}% full`,
95 meter.fiveHour === null ? '' : `5-hour limit ${meter.fiveHour}% used`,
96 meter.week === null ? '' : `weekly limit ${meter.week}% used`,
97 ]
98
99 return parts.filter(part => part !== '').join(', ')
100}
101
102// The readings in words and figures, for beside the drawing: one line for each
103// gauge that has a reading.
104export function meterLines(meter: Meter): readonly { label: string; value: string }[] {
105 return [
106 meter.context === null ? undefined : { label: 'Context window', value: `${meter.context}%` },
107 meter.fiveHour === null ? undefined : { label: '5-hour limit', value: `${meter.fiveHour}%` },
108 meter.week === null ? undefined : { label: 'Weekly limit', value: `${meter.week}%` },
109 ].filter(line => line !== undefined)
110}
111
112const units = (lit: number, total: number, on: string, off: string) => on.repeat(lit) + off.repeat(total - lit)
113
114// The terminal's meter: the ribs as a row of blocks, the pearls as dots.
115export function meterRow(meter: Meter): string {
116 return [
117 units(litOf(meter.context, RIBS), RIBS, '▰', '▱'),
118 meter.fiveHour === null ? '' : units(litOf(meter.fiveHour, FIVE_HOUR_PEARLS), FIVE_HOUR_PEARLS, '●', '○'),
119 meter.week === null ? '' : units(litOf(meter.week, WEEK_PEARLS), WEEK_PEARLS, '●', '○'),
120 ]
121 .filter(part => part !== '')
122 .join(' ')
123}
124hooks/scenes.ts 659 lines1import type { Activity } from '../types'
2
3export const ORANGE = '#D97757'
4export const EYE = '#2B2520'
5export const GREY = '#8C9199'
6export const SLATE = '#5B6470'
7export const PAPER = '#FFFFFF'
8const YELLOW = '#F2C14E'
9const GOLD = '#D9A21B'
10const RED = '#E5534B'
11const BLUE = '#6CB6FF'
12const INK = '#1E1E28'
13const LINE = '#B9BEC6'
14
15export const SCENE_WIDTH = 128
16export const SCENE_HEIGHT = 64
17
18// The crab stands at one spot in every scene, so a change of scene moves only
19// what is beside it.
20const CRAB_X = 10
21const CRAB_Y = 29
22
23type Look = 'ahead' | 'up' | 'right' | 'closed'
24type Arms = 'still' | 'typing' | 'wave' | 'cheer'
25
26type Pose = {
27 look?: Look
28 arms?: Arms
29 bob?: number
30 isShaking?: boolean
31 wear?: string
32}
33
34export type Drawing = { pose: Pose; prop: string }
35
36// Scenes that share a pose share one crab drawing, which then stays as it is
37// while the scene beside it changes: so the poses are few and named.
38const WORK: Pose = { look: 'right' }
39const TYPING: Pose = { look: 'right', arms: 'typing' }
40const WAVING: Pose = { look: 'right', arms: 'wave' }
41
42export const loop = (attribute: string, values: string, seconds: number, extra = '') =>
43 `<animate attributeName="${attribute}" values="${values}" dur="${seconds}s" repeatCount="indefinite" ${extra}/>`
44
45export const slide = (values: string, seconds: number, extra = '') =>
46 `<animateTransform attributeName="transform" type="translate" values="${values}" dur="${seconds}s" repeatCount="indefinite" ${extra}/>`
47
48const swing = (values: string, seconds: number) =>
49 `<animateTransform attributeName="transform" type="rotate" values="${values}" dur="${seconds}s" repeatCount="indefinite"/>`
50
51const stroke = (color: string, width: number) =>
52 `fill="none" stroke="${color}" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"`
53
54const EYES: Record<Look, { dx: number; y: number; height: number }> = {
55 ahead: { dx: 0, y: 6, height: 6 },
56 up: { dx: 0, y: 4, height: 6 },
57 right: { dx: 2, y: 6, height: 6 },
58 closed: { dx: 0, y: 10, height: 1.5 },
59}
60
61const ARMS: Record<Arms, { y: number; left: string; right: string }> = {
62 still: { y: 9, left: '', right: '' },
63 typing: {
64 y: 9,
65 left: loop('y', '9;7;9', 0.3),
66 right: loop('y', '9;7;9', 0.3, 'begin="0.15s"'),
67 },
68 wave: { y: 9, left: '', right: loop('y', '9;2;9', 0.7) },
69 cheer: { y: 3, left: loop('y', '3;1;3', 0.5), right: loop('y', '3;1;3', 0.5, 'begin="0.25s"') },
70}
71
72function eye(x: number, look: Look): string {
73 const { dx, y, height } = EYES[look]
74 const blink =
75 look === 'closed'
76 ? ''
77 : loop('height', `${height};${height};1;${height}`, 3.4, 'keyTimes="0;0.92;0.96;1"') +
78 loop('y', `${y};${y};${y + 3};${y}`, 3.4, 'keyTimes="0;0.92;0.96;1"')
79
80 return `<rect x="${x + dx}" y="${y}" width="4" height="${height}" fill="${EYE}">${blink}</rect>`
81}
82
83// The crab is 48 by 29 before scaling: body, two arms, two eyes, four legs.
84function crab(x: number, y: number, pose: Pose, scale = 1, delay = 0): string {
85 const { look = 'ahead', arms = 'still', bob = 1.6, isShaking = false, wear = '' } = pose
86 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"`)
87 const legs = [9, 16, 28, 35].map(legX => `<rect x="${legX}" y="22" width="4" height="7"/>`).join('')
88
89 return (
90 `<g transform="translate(${x} ${y}) scale(${scale})">` +
91 `<ellipse cx="24" cy="30" rx="19" ry="1.8" fill="#000" opacity="0.14"/>` +
92 `<g>${motion}` +
93 `<g fill="${ORANGE}">` +
94 `<rect x="6" y="0" width="36" height="22"/>` +
95 `<rect x="0" y="${ARMS[arms].y}" width="6" height="7">${ARMS[arms].left}</rect>` +
96 `<rect x="42" y="${ARMS[arms].y}" width="6" height="7">${ARMS[arms].right}</rect>` +
97 `${legs}</g>` +
98 `${eye(13, look)}${eye(31, look)}${wear}` +
99 `</g></g>`
100 )
101}
102
103// A small crab beside the big one, as part of a scene's prop.
104export const helper = (x: number, y: number, delay: number) => crab(x, y, { bob: 0.7 }, 0.5, delay)
105
106export function bubble(inside: string): string {
107 return (
108 `<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" ` +
109 `fill="${PAPER}" stroke="${GREY}" stroke-width="1.5" stroke-linejoin="round"/>` +
110 inside
111 )
112}
113
114export function gear(x: number, y: number, radius: number, seconds: number, turn: 1 | -1): string {
115 const tooth = radius * 0.45
116 const teeth = [0, 45, 90, 135, 180, 225, 270, 315]
117 .map(
118 angle =>
119 `<rect x="${-tooth / 2}" y="${-radius * 1.35}" width="${tooth}" height="${radius * 0.6}" rx="1" transform="rotate(${angle})"/>`,
120 )
121 .join('')
122 const hole = radius * 0.42
123 const ring =
124 `M${-radius},0 a${radius},${radius} 0 1,0 ${radius * 2},0 a${radius},${radius} 0 1,0 ${-radius * 2},0 ` +
125 `M${-hole},0 a${hole},${hole} 0 1,1 ${hole * 2},0 a${hole},${hole} 0 1,1 ${-hole * 2},0`
126
127 return (
128 `<g transform="translate(${x} ${y})"><g fill="#7D8590">` +
129 `<animateTransform attributeName="transform" type="rotate" from="0" to="${360 * turn}" dur="${seconds}s" repeatCount="indefinite"/>` +
130 `${teeth}<path fill-rule="evenodd" d="${ring}"/></g></g>`
131 )
132}
133
134export const pulse = (begin: number) => loop('opacity', '0.15;1;0.15', 1.5, `begin="${begin}s"`)
135
136export const bar = (x: number, y: number, width: number, fill: string, extra = '') =>
137 `<rect x="${x}" y="${y}" width="${width}" height="2.5" rx="1.25" fill="${fill}">${extra}</rect>`
138
139export const sparkle = (x: number, y: number, begin: number) =>
140 `<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>`
141
142export const SCENES: Record<Activity, () => Drawing> = {
143 idle: () => ({
144 pose: { look: 'closed', bob: 3.2 },
145 prop: [
146 { x: 66, y: 31, size: 9, begin: 0 },
147 { x: 76, y: 22, size: 12, begin: 0.5 },
148 { x: 88, y: 12, size: 15, begin: 1 },
149 ]
150 .map(
151 z =>
152 `<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>`,
153 )
154 .join(''),
155 }),
156
157 thinking: () => {
158 const puffs: readonly (readonly [number, number, number])[] = [
159 [86, 19, 9],
160 [98, 14, 11],
161 [111, 18, 9],
162 [98, 22, 9],
163 [72, 22, 3.5],
164 [64, 28, 2.2],
165 ]
166 const outline = puffs.map(([x, y, r]) => `<circle cx="${x}" cy="${y}" r="${r + 1.4}" fill="${GREY}"/>`).join('')
167 const fill = puffs.map(([x, y, r]) => `<circle cx="${x}" cy="${y}" r="${r}" fill="${PAPER}"/>`).join('')
168 const dots = [90, 98, 106]
169 .map((x, i) => `<circle cx="${x}" cy="18" r="2.2" fill="${SLATE}" opacity="0.15">${pulse(i * 0.3)}</circle>`)
170 .join('')
171
172 return { pose: { look: 'up' }, prop: outline + fill + dots }
173 },
174
175 reading: () => ({
176 pose: WORK,
177 prop:
178 `<path d="M74,8 h30 l10,10 v38 h-40 z" fill="${PAPER}" stroke="${LINE}" stroke-width="1.5" stroke-linejoin="round"/>` +
179 `<path d="M104,8 v10 h10" ${stroke(LINE, 1.5)}/>` +
180 [24, 30, 36, 42, 48].map((y, i) => bar(80, y, i % 2 === 0 ? 28 : 21, LINE)).join('') +
181 `<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>`,
182 }),
183
184 coding: () => {
185 const lines: readonly (readonly [number, number, number, string])[] = [
186 [72, 23, 20, '#C792EA'],
187 [78, 29, 30, '#82AAFF'],
188 [78, 35, 22, '#C3E88D'],
189 [84, 41, 18, '#F78C6C'],
190 [72, 47, 10, '#C792EA'],
191 ]
192
193 return {
194 pose: TYPING,
195 prop:
196 `<rect x="66" y="8" width="54" height="48" rx="4" fill="${INK}"/>` +
197 [RED, YELLOW, '#3FB950'].map((fill, i) => `<circle cx="${72 + i * 6}" cy="14.5" r="2" fill="${fill}"/>`).join('') +
198 lines
199 .map(([x, y, width, fill], i) =>
200 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"`)),
201 )
202 .join(''),
203 }
204 },
205
206 writing: () => {
207 const lines: readonly (readonly [number, number])[] = [
208 [24, 28],
209 [30, 21],
210 [36, 28],
211 [42, 16],
212 ]
213 const at = (i: number) => 0.04 + i * 0.22
214 const path = lines.flatMap(([y, width]) => [`80 ${y + 1.5}`, `${80 + width} ${y + 1.5}`])
215 const times = lines.flatMap((_, i) => [at(i).toFixed(2), (at(i) + 0.2).toFixed(2)])
216
217 return {
218 pose: TYPING,
219 prop:
220 `<path d="M74,8 h30 l10,10 v38 h-40 z" fill="${PAPER}" stroke="${LINE}" stroke-width="1.5" stroke-linejoin="round"/>` +
221 `<path d="M104,8 v10 h10" ${stroke(LINE, 1.5)}/>` +
222 lines
223 .map(([y, width], i) =>
224 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"`)),
225 )
226 .join('') +
227 `<g>${slide([path[0], ...path, path.at(-1)].join(';'), 4, `keyTimes="0;${times.join(';')};1"`)}` +
228 `<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"/>` +
229 `<path d="M0,0 l0.6,-2.1 l1.5,1.5 z" fill="${EYE}"/>` +
230 `</g>`,
231 }
232 },
233
234 designing: () => {
235 const NAVY = '#4A5F94'
236 const pieces: readonly ((shown: string) => string)[] = [
237 shown => `<polygon points="80,11 93,9 93,41 78,35" fill="${BLUE}" opacity="0">${shown}</polygon>`,
238 shown => `<polygon points="93,9 108,15 106,39 93,41" fill="${YELLOW}" opacity="0">${shown}</polygon>`,
239 shown =>
240 `<g opacity="0">${shown}<circle cx="86" cy="20" r="3.4" fill="${PAPER}"/><circle cx="87" cy="20" r="1.5" fill="${EYE}"/>` +
241 `<ellipse cx="101" cy="27" rx="4.2" ry="3" fill="${PAPER}"/><circle cx="100" cy="27" r="1.6" fill="${EYE}"/></g>`,
242 shown => `<path d="M93,15 L99,27 L92,29" ${stroke(EYE, 1.8)} opacity="0">${shown}</path>`,
243 shown => `<polygon points="84,33 100,35 91,39" fill="${RED}" opacity="0">${shown}</polygon>`,
244 ]
245 const at = (i: number) => 0.04 + i * 0.16
246
247 return {
248 // A beret and a striped shirt: the painter the portrait on the easel is after.
249 pose: {
250 look: 'right',
251 wear:
252 `<rect x="6" y="14.5" width="36" height="2.2" fill="#F5F4EF"/><rect x="6" y="18.6" width="36" height="2.2" fill="#F5F4EF"/>` +
253 `<ellipse cx="21" cy="-1.5" rx="16" ry="5.2" fill="${NAVY}" transform="rotate(-9 21 -1.5)"/>` +
254 `<rect x="20" y="-9.5" width="2.2" height="4" fill="${NAVY}"/>`,
255 },
256 prop:
257 `<path d="M77,46 L71,61 M109,46 L115,61 M93,46 V59" ${stroke('#B89B6A', 2.2)}/>` +
258 `<rect x="68" y="5" width="50" height="41" rx="1.5" fill="${PAPER}" stroke="#B89B6A" stroke-width="2"/>` +
259 pieces
260 .map((piece, i) => piece(loop('opacity', '0;0;1;1', 5, `keyTimes="0;${at(i).toFixed(2)};${(at(i) + 0.05).toFixed(2)};1"`)))
261 .join('') +
262 `<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"')}` +
263 `<line x1="2" y1="-3" x2="13" y2="-19" ${stroke(SLATE, 3.2)}/>` +
264 `<path d="M-1,2 l1.5,-6 l4.5,3 z" fill="${ORANGE}"/></g>`,
265 }
266 },
267
268 terminal: () => ({
269 pose: TYPING,
270 prop:
271 `<rect x="64" y="10" width="58" height="44" rx="4" fill="${INK}"/>` +
272 `<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"/>` +
273 `<polyline points="70,25 74,28.5 70,32" ${stroke('#7EE787', 2)}/>` +
274 bar(79, 27.25, 24, '#8B949E') +
275 `<rect x="70" y="39" width="46" height="7" rx="3.5" ${stroke('#8B949E', 1.2)}/>` +
276 `<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>`,
277 }),
278
279 searching: () => ({
280 pose: WORK,
281 prop:
282 [14, 22, 30, 38, 46].map((y, i) => bar(72, y, i % 2 === 0 ? 46 : 34, '#B9BEC6')).join('') +
283 `<g>${slide('0 0;14 -4;22 8;4 10;0 0', 4)}` +
284 `<line x1="77" y1="35" x2="64" y2="47" stroke="${SLATE}" stroke-width="5" stroke-linecap="round"/>` +
285 `<circle cx="86" cy="26" r="12" fill="#BFE3FF" fill-opacity="0.55" stroke="${SLATE}" stroke-width="3.5"/>` +
286 `<path d="M79,22 a8,8 0 0 1 6,-5" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round"/>` +
287 `</g>`,
288 }),
289
290 web: () => ({
291 pose: WORK,
292 prop:
293 `<rect x="64" y="10" width="58" height="44" rx="4" fill="${PAPER}" stroke="${LINE}" stroke-width="1.5"/>` +
294 `<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"/>` +
295 [RED, YELLOW, '#3FB950'].map((fill, i) => `<circle cx="${69.5 + i * 5}" cy="15.5" r="1.6" fill="${fill}"/>`).join('') +
296 `<rect x="86" y="13" width="31" height="5" rx="2.5" fill="${PAPER}"/>` +
297 `<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>` +
298 `<rect x="70" y="28" width="18" height="14" rx="2" fill="#BFE3FF"/>` +
299 bar(92, 29, 23, LINE) +
300 bar(92, 34.5, 16, LINE) +
301 bar(92, 40, 21, LINE) +
302 bar(70, 46.5, 40, LINE) +
303 `<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>`,
304 }),
305
306 delegating: () => ({
307 pose: WAVING,
308 prop: helper(66, 40, 0) + helper(96, 40, 0.35) + helper(81, 18, 0.2),
309 }),
310
311 talking: () => ({
312 pose: {},
313 prop: bubble(
314 bar(69, 14, 44, GREY, loop('width', '0;44;44;44', 3.2, 'keyTimes="0;0.3;0.95;1"')) +
315 bar(69, 21.5, 38, GREY, loop('width', '0;0;38;38;38', 3.2, 'keyTimes="0;0.3;0.6;0.95;1"')) +
316 bar(69, 29, 26, GREY, loop('width', '0;0;26;26', 3.2, 'keyTimes="0;0.6;0.85;1"')),
317 ),
318 }),
319
320 asking: () => ({
321 pose: { arms: 'wave' },
322 prop: bubble(
323 `<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>`,
324 ),
325 }),
326
327 planning: () => ({
328 pose: WORK,
329 prop:
330 `<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"/>` +
331 `<path d="M84,10 v40 M100,14 v40" ${stroke('#C6D3A8', 1.2)}/>` +
332 `<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>` +
333 `<circle cx="74" cy="46" r="3" fill="${SLATE}"/>` +
334 `<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>`,
335 }),
336
337 tool: () => ({
338 pose: TYPING,
339 prop: gear(88, 28, 13, 5, 1) + gear(112, 46, 7, 2.7, -1),
340 }),
341
342 done: () => ({
343 pose: { arms: 'cheer', bob: 0.6 },
344 prop:
345 sparkle(66, 12, 0) +
346 sparkle(112, 48, 0.5) +
347 sparkle(116, 12, 1) +
348 `<g transform="translate(90 28)"><g>` +
349 `<animateTransform attributeName="transform" type="scale" values="0.6;1.25;1" dur="0.45s" fill="freeze"/>` +
350 `<circle r="11" fill="#2DA44E"/>` +
351 `<polyline points="-5,0 -1.5,4 5.5,-4" fill="none" stroke="#FFFFFF" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>` +
352 `</g></g>`,
353 }),
354
355 permission: () => ({
356 pose: { arms: 'wave' },
357 prop:
358 `<g>` +
359 `<animateTransform attributeName="transform" type="rotate" values="-5 94 40;5 94 40;-5 94 40" dur="0.9s" repeatCount="indefinite"/>` +
360 `<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"/>` +
361 `<rect x="78" y="29" width="32" height="24" rx="4" fill="#F2C14E"/>` +
362 `<circle cx="94" cy="38" r="3.5" fill="#7A5B12"/><rect x="92.5" y="39" width="3" height="8" rx="1" fill="#7A5B12"/>` +
363 `</g>`,
364 }),
365
366 compacting: () => {
367 const dust: readonly (readonly [number, number, number, number])[] = [
368 [108, 55, 2.6, 0],
369 [114, 51, 2, 0.2],
370 [120, 56, 1.8, 0.4],
371 [104, 50, 1.6, 0.5],
372 ]
373
374 return {
375 pose: TYPING,
376 prop:
377 dust
378 .map(
379 ([x, y, r, begin]) =>
380 `<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>`,
381 )
382 .join('') +
383 `<g>${swing('-10 76 8;10 76 8;-10 76 8', 1)}` +
384 `<line x1="76" y1="8" x2="90" y2="44" ${stroke('#B08968', 3.5)}/>` +
385 `<polygon points="82,43 99,40 104,56 80,58" fill="${YELLOW}" stroke="${GOLD}" stroke-width="1.5" stroke-linejoin="round"/>` +
386 `<path d="M86,47 l-1,9 M92,46 l0,10 M98,45 l2,9" ${stroke(GOLD, 1.2)}/>` +
387 `</g>`,
388 }
389 },
390
391 error: () => ({
392 pose: { look: 'up', isShaking: true },
393 prop:
394 `<g>${loop('opacity', '1;0.55;1', 0.8)}` +
395 `<path d="M94,10 L116,50 L72,50 Z" fill="#E5534B" stroke="#E5534B" stroke-width="4" stroke-linejoin="round"/>` +
396 `<rect x="92" y="22" width="4" height="15" rx="2" fill="#FFFFFF"/><circle cx="94" cy="43.5" r="2.5" fill="#FFFFFF"/>` +
397 `</g>`,
398 }),
399
400 waiting: () => {
401 const ticks = [0, 90, 180, 270]
402 .map(angle => `<rect x="93" y="15" width="2" height="4" fill="${SLATE}" transform="rotate(${angle} 94 31)"/>`)
403 .join('')
404
405 return {
406 pose: WORK,
407 prop:
408 `<circle cx="94" cy="31" r="19" fill="${PAPER}" stroke="${SLATE}" stroke-width="3"/>` +
409 ticks +
410 `<line x1="94" y1="31" x2="102" y2="31" stroke="${SLATE}" stroke-width="3" stroke-linecap="round"/>` +
411 `<line x1="94" y1="31" x2="94" y2="18" stroke="${ORANGE}" stroke-width="2.5" stroke-linecap="round">` +
412 `<animateTransform attributeName="transform" type="rotate" from="0 94 31" to="360 94 31" dur="4s" repeatCount="indefinite"/>` +
413 `</line><circle cx="94" cy="31" r="2.5" fill="${SLATE}"/>`,
414 }
415 },
416
417 testing: () => {
418 const rows = [17, 26, 35, 44]
419 .map(
420 (y, i) =>
421 `<circle cx="78" cy="${y + 1.25}" r="3" fill="#3FB950">` +
422 loop('fill', '#6E7681;#6E7681;#3FB950;#3FB950', 3.6, `keyTimes="0;${0.1 + i * 0.2};${0.15 + i * 0.2};1"`) +
423 `</circle>` +
424 bar(85, y, i % 2 === 0 ? 24 : 18, '#8B949E'),
425 )
426 .join('')
427
428 return {
429 pose: WORK,
430 prop: `<rect x="68" y="9" width="50" height="46" rx="4" fill="#1E1E28"/>` + rows,
431 }
432 },
433
434 git: () => {
435 const branch = 'M80,44 C88,44 86,20 94,20 H104 C112,20 110,44 118,44'
436
437 return {
438 pose: WORK,
439 prop:
440 `<line x1="66" y1="44" x2="122" y2="44" stroke="#6CB6FF" stroke-width="3" stroke-linecap="round"/>` +
441 `<path d="${branch}" fill="none" stroke="#C792EA" stroke-width="3" stroke-linecap="round"/>` +
442 [70, 80, 118].map(x => `<circle cx="${x}" cy="44" r="4.5" fill="#6CB6FF"/>`).join('') +
443 [94, 104].map(x => `<circle cx="${x}" cy="20" r="4.5" fill="#C792EA"/>`).join('') +
444 `<circle r="3" fill="#FFFFFF" stroke="${SLATE}" stroke-width="1.5"><animateMotion dur="2.2s" repeatCount="indefinite" path="${branch}"/></circle>`,
445 }
446 },
447
448 installing: () => ({
449 pose: WORK,
450 prop:
451 `<g fill="none" stroke="#2DA44E" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round">` +
452 slide('0 0;0 8;0 0', 1.2) +
453 `<line x1="94" y1="6" x2="94" y2="20"/><polyline points="87,14 94,21 101,14"/></g>` +
454 `<rect x="74" y="32" width="40" height="24" rx="2" fill="#C9A27A"/>` +
455 `<rect x="72" y="29" width="44" height="6" rx="1.5" fill="#B08968"/>` +
456 `<rect x="90.5" y="29" width="7" height="27" fill="#E8D3B0"/>`,
457 }),
458
459 skill: () => ({
460 pose: {
461 look: 'right',
462 wear:
463 `<polygon points="24,-11 48,-4 24,3 0,-4" fill="${EYE}"/><rect x="13" y="-2" width="22" height="4" fill="${EYE}"/>` +
464 `<line x1="42" y1="-4" x2="42" y2="5" stroke="#F2C14E" stroke-width="2"/><circle cx="42" cy="6" r="2" fill="#F2C14E"/>`,
465 },
466 prop:
467 `<g transform="translate(94 30)"><g>` +
468 `<animateTransform attributeName="transform" type="scale" values="1;1.15;1" dur="1.4s" repeatCount="indefinite"/>` +
469 `<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"/>` +
470 `</g></g>` +
471 sparkle(116, 12, 0) +
472 sparkle(72, 50, 0.7),
473 }),
474
475 memory: () => ({
476 pose: WORK,
477 prop:
478 `<g transform="rotate(-4 94 32)">` +
479 `<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"/>` +
480 bar(80, 24, 28, '#B59B2D', loop('width', '0;28;28', 3, 'keyTimes="0;0.3;1"')) +
481 bar(80, 31, 28, '#B59B2D', loop('width', '0;0;28;28', 3, 'keyTimes="0;0.3;0.6;1"')) +
482 bar(80, 38, 18, '#B59B2D', loop('width', '0;0;18;18', 3, 'keyTimes="0;0.6;0.85;1"')) +
483 `<circle cx="94" cy="15" r="4" fill="#E5534B"/><circle cx="92.8" cy="13.8" r="1.2" fill="#FFFFFF" opacity="0.7"/>` +
484 `</g>`,
485 }),
486
487 sharing: () => ({
488 pose: WAVING,
489 prop:
490 `<path d="M62,50 C74,50 78,44 84,38" fill="none" stroke="${GREY}" stroke-width="2" stroke-dasharray="3 4" stroke-linecap="round"/>` +
491 `<g transform="translate(84 34)"><g>${slide('0 0;10 -8;0 0', 2.4)}` +
492 `<polygon points="0,-2 30,-14 10,4" fill="${PAPER}" stroke="${SLATE}" stroke-width="1.5" stroke-linejoin="round"/>` +
493 `<polygon points="10,4 30,-14 13,14" fill="#D9DEE4" stroke="${SLATE}" stroke-width="1.5" stroke-linejoin="round"/>` +
494 `</g></g>`,
495 }),
496}
497
498// A scene is two drawings side by side: the crab on the left, which changes
499// only with its pose, and what the scene puts beside it on the right.
500const SPLIT = 60
501
502export const CRAB_WIDTH = SPLIT
503export const PROP_WIDTH = SCENE_WIDTH - SPLIT
504
505const frame = (x: number, width: number, inner: string) =>
506 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${x} 0 ${width} ${SCENE_HEIGHT}" ` +
507 `width="${width}" height="${SCENE_HEIGHT}" shape-rendering="geometricPrecision">${inner}</svg>`
508
509export function crabSvg(activity: Activity): string {
510 return frame(0, CRAB_WIDTH, crab(CRAB_X, CRAB_Y, SCENES[activity]().pose))
511}
512
513export function propSvg(activity: Activity): string {
514 return frame(SPLIT, PROP_WIDTH, SCENES[activity]().prop)
515}
516
517// Crab and prop as one drawing, for a page that shows a scene on its own.
518export function drawingSvg(drawing: Drawing): string {
519 return frame(0, SCENE_WIDTH, crab(CRAB_X, CRAB_Y, drawing.pose) + drawing.prop)
520}
521
522const CRAB_ROWS = [' ▐▛███▜▌ ', '▝▜█████▛▘', ' ▘▘ ▝▝ '] as const
523
524type Frame = readonly [string, string, string]
525
526const NO_PROP: Frame = ['', '', '']
527
528const PROPS: Record<Activity, readonly Frame[]> = {
529 idle: [
530 [' ', ' z ', ' '],
531 [' z ', ' z ', ' '],
532 [' Z', ' z ', ' '],
533 ],
534 thinking: [
535 [' ╭─────╮', '°│ · │', ' ╰─────╯'],
536 [' ╭─────╮', '°│ ·· │', ' ╰─────╯'],
537 [' ╭─────╮', '°│ ··· │', ' ╰─────╯'],
538 ],
539 reading: [
540 ['┌──┬──┐', '│≡≡│≡≡│', '└──┴──┘'],
541 ['┌──┬──┐', '│≡─│≡≡│', '└──┴──┘'],
542 ['┌──┬──┐', '│≡≡│≡─│', '└──┴──┘'],
543 ],
544 coding: [
545 ['┌─────┐', '│</>▌ │', '▀▀▀▀▀▀▀'],
546 ['┌─────┐', '│</> │', '▀▀▀▀▀▀▀'],
547 ],
548 writing: [
549 ['┌────╮', '│╱ │', '└────┘'],
550 ['┌────╮', '│≡≡╱ │', '└────┘'],
551 ['┌────╮', '│≡≡≡≡│', '└────┘'],
552 ],
553 designing: [
554 ['┌───┐', '│ │', '└┬─┬┘'],
555 ['┌───┐', '│◐ │', '└┬─┬┘'],
556 ['┌───┐', '│◐◣ │', '└┬─┬┘'],
557 ['┌───┐', '│◐◣◕│', '└┬─┬┘'],
558 ],
559 terminal: [
560 ['┌─────┐', '│$ ▌ │', '▀▀▀▀▀▀▀'],
561 ['┌─────┐', '│$ │', '▀▀▀▀▀▀▀'],
562 ],
563 searching: [
564 ['╭─╮≡≡≡≡', '╰─╯≡≡≡≡', '≡≡≡╲≡≡≡'],
565 ['≡≡╭─╮≡≡', '≡≡╰─╯≡≡', '≡≡≡≡≡╲≡'],
566 ['≡≡≡≡╭─╮', '≡≡≡≡╰─╯', '≡≡≡≡≡≡≡'],
567 ],
568 web: [
569 ['┌○○○──┐', '│█░░░░│', '└─────┘'],
570 ['┌○○○──┐', '│███░░│', '└─────┘'],
571 ['┌○○○──┐', '│█████│', '└─────┘'],
572 ['┌○○○──┐', '│≡≡≡≡ │', '└─────┘'],
573 ],
574 delegating: [
575 [' ', '▗▟█▙▖ ▗▟█▙▖', ' ▘ ▝ ▘ ▝ '],
576 ['▗▟█▙▖ ', ' ▘ ▝ ▗▟█▙▖', ' ▘ ▝ '],
577 ],
578 talking: [
579 ['≡≡≡▌ ', ' ', ' '],
580 ['≡≡≡≡≡≡', '≡≡≡▌ ', ' '],
581 ['≡≡≡≡≡≡', '≡≡≡≡≡≡', '≡≡≡▌ '],
582 ],
583 asking: [
584 ['╭─╮', ' ╭╯', ' ● '],
585 ['╭─╮', ' ╭╯', ' ○ '],
586 ],
587 planning: [
588 [' ', ' ', '●─╯ '],
589 [' ', ' ╭─╯ ', '●─╯ '],
590 [' ╭─◆', ' ╭─╯ ', '●─╯ '],
591 [' ╭─◆', ' ╭─╯ ', '●─╯ '],
592 ],
593 tool: [
594 [' ', ' ╋ ', ' '],
595 [' ', ' ╳ ', ' '],
596 ],
597 done: [
598 [' ▐███▌ ', ' ▀█▀ ', ' ▄█▄ '],
599 ['·▐███▌·', ' ▀█▀ ', ' ▄█▄ '],
600 ],
601 permission: [
602 ['┌──────┐', '│ y/n▌ │', '└──────┘'],
603 ['┌──────┐', '│ y/n │', '└──────┘'],
604 ],
605 compacting: [
606 [' ╱ ', ' ╱ ', ' ▟ ·:· '],
607 [' ╱ ', ' ╱ ', ' ▟ ·: '],
608 [' ╱ ', ' ╱ ', ' ▟ · '],
609 ],
610 error: [
611 [' ╻ ', ' ┃ ', ' ● '],
612 [' ╻ ', ' ┃ ', ' ● '],
613 ],
614 waiting: [
615 ['╭───╮', '│ ╵ │', '╰───╯'],
616 ['╭───╮', '│ ╶ │', '╰───╯'],
617 ['╭───╮', '│ ╷ │', '╰───╯'],
618 ['╭───╮', '│ ╴ │', '╰───╯'],
619 ],
620 testing: [
621 ['· ──', '· ──', '· ──'],
622 ['✓ ──', '· ──', '· ──'],
623 ['✓ ──', '✓ ──', '· ──'],
624 ['✓ ──', '✓ ──', '✓ ──'],
625 ],
626 git: [
627 [' ●─● ', ' ╱ ╲ ', '●──●──●'],
628 [' ●─◉ ', ' ╱ ╲ ', '●──●──●'],
629 [' ●─● ', ' ╱ ╲ ', '●──●──◉'],
630 ],
631 installing: [
632 [' ▼ ', ' ', '╰───╯'],
633 [' │ ', ' ▼ ', '╰───╯'],
634 [' │ ', ' │ ', '╰─▼─╯'],
635 ],
636 skill: [
637 [' ★ ', ' ', ' '],
638 [' ', ' ★ ', ' '],
639 ],
640 memory: [
641 [' ▼ ', '┌───┐', '│≡≡≡│'],
642 [' ', '┌─▼─┐', '│≡≡≡│'],
643 [' ', '┌─▼─┐', '│≡≡≡│'],
644 ],
645 sharing: [
646 [' ', ' ', '╰─▲─╯'],
647 [' ', ' ▲ ', '╰─│─╯'],
648 [' ▲ ', ' │ ', '╰───╯'],
649 ],
650}
651
652// The terminal has no vector leaf: the crab is three rows of block glyphs, with
653// the scene's prop beside it, one of a few frames picked by the running tick.
654export function sceneRows(activity: Activity, tick: number): { crab: Frame; prop: Frame } {
655 const frames = PROPS[activity]
656
657 return { crab: CRAB_ROWS, prop: frames[tick % frames.length] ?? NO_PROP }
658}
659types/index.d.ts 48 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// What the shell meter shows, each in whole percents used: the context
31// window's fill, and the five-hour and weekly rate-limit windows. `null` where
32// there is no reading: before the first response, or off a subscription.
33export type Meter = { context: number | null; fiveHour: number | null; week: number | null }
34
35declare module 'claude-code' {
36 interface PluginState {
37 'crab-cam': {
38 scene: Scene
39 isHidden: boolean
40 frame: number
41 meter: Meter
42 isMetered: boolean
43 isDetailed: boolean
44 isRaw: boolean
45 }
46 }
47}
48