SLOPSHOPPER

crab-cam

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…

newbandguardcommandtimeragents
★ 4v0.2.0MITupdated 2026-10-05sTomerG/agent-kit/plugins/crab-cam
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · crab-cam
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /crab-cam ⎿ crab-cam: The crab is hidden. Type /crab-cam to bring it back. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

crab-cam

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.

The crab-cam band cycling through thinking, reading, searching, writing code, running tests, git and done

Install

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.

Commands

| 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 |

Scenes

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

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

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

Meter

The shell fills as things are used:

  • Ribs: the context window.
  • Five pearls: the 5-hour rate limit.
  • Seven smaller pearls: the weekly rate limit.

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) | | :- | :- | | The meter filling up: the shell with the context window, the pearls with the rate limits | The same meter with its readings in figures beside it |

Development

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.

Source 5 files
hooks/register.tsx 902 lines
1import { 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}
902
hooks/activity.ts 408 lines
1import type { Activity } from '../types'
2
3const BY_TOOL: Record<string, Activity> = {
4  Read: 'reading',
5  NotebookRead: 'reading',
6  ReadMcpResourceTool: 'reading',
7  ListMcpResourcesTool: 'reading',
8  ReadMcpResourceDirTool: 'reading',
9  ReadNotifications: 'reading',
10  FetchInboxMessage: 'reading',
11  Skill: 'skill',
12  Edit: 'coding',
13  MultiEdit: 'coding',
14  Write: 'coding',
15  NotebookEdit: 'coding',
16  Bash: 'terminal',
17  KillShell: 'terminal',
18  TaskStop: 'terminal',
19  BashOutput: 'waiting',
20  TaskOutput: 'waiting',
21  Monitor: 'waiting',
22  ScheduleWakeup: 'waiting',
23  CronCreate: 'waiting',
24  CronList: 'waiting',
25  CronDelete: 'waiting',
26  Grep: 'searching',
27  Glob: 'searching',
28  LS: 'searching',
29  ToolSearch: 'searching',
30  ListSkills: 'searching',
31  ListPlugins: 'searching',
32  ListAgents: 'searching',
33  WebFetch: 'web',
34  WebSearch: 'web',
35  Agent: 'delegating',
36  Task: 'delegating',
37  Workflow: 'delegating',
38  SendMessage: 'delegating',
39  AskUserQuestion: 'asking',
40  TodoWrite: 'planning',
41  TaskCreate: 'planning',
42  TaskUpdate: 'planning',
43  TaskList: 'planning',
44  TaskGet: 'planning',
45  EnterPlanMode: 'planning',
46  ExitPlanMode: 'planning',
47  Artifact: 'sharing',
48  SendUserFile: 'sharing',
49  PushNotification: 'sharing',
50  ReportFindings: 'sharing',
51  SuggestSkills: 'sharing',
52  SuggestPluginInstall: 'sharing',
53  EnterWorktree: 'git',
54  ExitWorktree: 'git',
55}
56
57const TESTS = /\b(pytest|vitest|jest|mocha|rspec|phpunit|tox|unittest|(go|cargo|bun|deno|dotnet) test|(npm|pnpm|yarn)( run)? test)\b/
58const INSTALL =
59  /\b((npm|pnpm|yarn|bun) (install|add|i|ci)|pip3? install|uv (add|sync|pip)|poetry (add|install)|brew install|cargo (add|install)|apt(-get)? install)\b/
60// Files that hold prose rather than code: by their extension, or by the names
61// that go without one.
62const PROSE =
63  /\.(md|mdx|markdown|txt|rst|adoc|asciidoc|org|tex|rtf)$|(^|\/)(README|LICEN[CS]E|CHANGELOG|CONTRIBUTING|AUTHORS|NOTICE|TODO)$/i
64// Files that hold how something looks: stylesheets and drawings.
65const LOOKS = /\.(css|scss|sass|less|svg)$/i
66// Files that hold a page: code in a web project, but a design once the turn
67// has taken up design work, as when the page of an artifact is written.
68const PAGE = /\.(html?|xhtml)$/i
69const MEMORY = /\/memory\/|(^|\/)(CLAUDE|MEMORY)\.md$/
70
71// What may stand before the command that does the work: a change of
72// directory, a variable set for the one command, a wrapper.
73const LEAD =
74  /^\s*(?:(?:cd|pushd)\s+(?:"[^"]*"|'[^']*'|\S+)\s*(?:&&|;)\s*|[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+|(?:sudo|time|command|exec|nohup|do|then|else)\s+)/
75// Output sent to a file; a stream copied to another (`2>&1`) or thrown away is none.
76const TO_FILE = /(^|[^0-9&>])>{1,2}\s*(?!\/dev\/null)[^\s&|>]/
77
78const BY_COMMAND: Record<string, Activity> = {
79  git: 'git',
80  gh: 'git',
81  cat: 'reading',
82  head: 'reading',
83  tail: 'reading',
84  less: 'reading',
85  more: 'reading',
86  bat: 'reading',
87  wc: 'reading',
88  nl: 'reading',
89  jq: 'reading',
90  yq: 'reading',
91  sed: 'reading',
92  awk: 'reading',
93  diff: 'reading',
94  stat: 'reading',
95  file: 'reading',
96  grep: 'searching',
97  egrep: 'searching',
98  fgrep: 'searching',
99  rg: 'searching',
100  ag: 'searching',
101  ack: 'searching',
102  find: 'searching',
103  fd: 'searching',
104  ls: 'searching',
105  tree: 'searching',
106  which: 'searching',
107  locate: 'searching',
108  curl: 'web',
109  wget: 'web',
110  http: 'web',
111  https: 'web',
112}
113
114const WRITERS = new Set(['cat', 'echo', 'printf'])
115
116// The command that does the work: the first one, past what leads up to it.
117function programOf(command: string): { name: string; rest: string } {
118  let rest = command
119
120  while (LEAD.test(rest)) {
121    rest = rest.replace(LEAD, '')
122  }
123
124  const word = /^[\w./+-]+/.exec(rest)?.[0] ?? ''
125
126  return { name: word.split('/').pop() ?? word, rest }
127}
128
129// The commands a line chains together, each from where it begins: what stands
130// in quotes is no command, nor is the text a heredoc feeds in.
131function chainOf(command: string): string[] {
132  const lines = command.split('\n')
133  const fed = lines.findIndex(line => line.includes('<<'))
134  const script = (fed === -1 ? lines : lines.slice(0, fed + 1)).join('\n')
135
136  return script.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, '""').split(/&&|\|\||[;|\n]/)
137}
138
139function isGit(command: string): boolean {
140  const { name } = programOf(command.trim())
141
142  return name === 'git' || name === 'gh'
143}
144
145// Which scene a shell command is: a test run, git or an install wherever it
146// stands in the line, else by the first command, so `cat x | grep y` is reading.
147function activityOfCommand(command: string, isDesigning: boolean): Activity {
148  if (TESTS.test(command)) {
149    return 'testing'
150  }
151
152  const { name, rest } = programOf(command)
153
154  if (chainOf(command).some(isGit)) {
155    return 'git'
156  }
157
158  if (INSTALL.test(command)) {
159    return 'installing'
160  }
161
162  const line = rest.split('\n')[0] ?? rest
163
164  if (name === 'tee' || (name === 'sed' && /\s-i\b/.test(line)) || (WRITERS.has(name) && TO_FILE.test(line))) {
165    const words = line.split(/\s+/).map(word => word.replace(/^['"]|['"]$/g, ''))
166
167    const looks = (word: string) => LOOKS.test(word) || (isDesigning && PAGE.test(word))
168
169    return words.some(word => PROSE.test(word)) ? 'writing' : words.some(looks) ? 'designing' : 'coding'
170  }
171
172  return BY_COMMAND[name] ?? 'terminal'
173}
174
175// The command in a shell call's arguments while their JSON is still arriving:
176// what there is of it so far, or nothing before it has begun.
177function commandSoFar(json: string): string | undefined {
178  const written = /"command"\s*:\s*"((?:[^"\\]|\\.)*)/.exec(json)?.[1]
179
180  return written?.replace(/\\(.)/g, (_, escaped: string) => (escaped === 'n' ? '\n' : escaped === 't' ? '\t' : escaped))
181}
182
183// The argument that settles the scene of a call to a tool other than the
184// shell: the file an edit is to, the skill loaded, what an artifact call does.
185const TELLING: Record<string, string> = {
186  Edit: 'file_path',
187  MultiEdit: 'file_path',
188  Write: 'file_path',
189  Skill: 'skill',
190  Artifact: 'action',
191}
192
193// A string argument once all of it has arrived.
194export function whole(json: string, key: string): string | undefined {
195  return new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`).exec(json)?.[1]?.replace(/\\(.)/g, '$1')
196}
197
198// Which scene a call is while its arguments are still arriving, once what has
199// arrived settles it: a long command or a whole file is on its way for
200// seconds, and 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}
408
hooks/meter.ts 124 lines
1import 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}
124
hooks/scenes.ts 659 lines
1import 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}
659
types/index.d.ts 48 lines
1export type Activity =
2  | 'idle'
3  | 'thinking'
4  | 'reading'
5  | 'coding'
6  | 'writing'
7  | 'designing'
8  | 'terminal'
9  | 'searching'
10  | 'web'
11  | 'delegating'
12  | 'talking'
13  | 'asking'
14  | 'planning'
15  | 'tool'
16  | 'done'
17  | 'permission'
18  | 'compacting'
19  | 'error'
20  | 'waiting'
21  | 'testing'
22  | 'git'
23  | 'installing'
24  | 'skill'
25  | 'memory'
26  | 'sharing'
27
28export type Scene = { activity: Activity; detail: string }
29
30// 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