SLOPSHOPPER

crab-crew

A crew panel for standard subagents (/crew): each agent type is a crab character with its own role, look and lines, animated by its status and progress.

newpanebandguardcommandprompt
v0.1.0MITupdated 2026-10-09marwansaab/Claude-Mods/plugins/crab-crew
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · crab-crew
│ ┃ Crab Crew ✕ › fix the failing auth test and add an audit log call │ ┃ (\/)(-,,-)(\/) z Captain Clawd · Off watch,… │ ┃ No crew dispatched yet. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Meet the roster: ⏺ Update(src/auth.ts) │ ┃ Deckhand (general-purpose) Can-do all-round… ⎿ Added 2 lines, removed 1 line │ ┃ Scout (Explore) Curious and quick; looks, n… ⏺ Bash(bun test) │ ┃ Navigator (Plan) Measures twice, plots the … ⎿ 3 pass, 1 fail │ ┃ Professor (claude-code-guide) Has read ever… │ ┃ Signaller (statusline-setup) Tinkers with t… ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Understudy (fork) Knows everything the Capt… │ ┃ Cook (other agent types) Throws it all in t… ✻ Worked for 42s · done 4:20 PM │ ┃ Sleuth (other agent types) Follows every cl… │ ┃ Wizard (other agent types) Mutters spells a… › /crew │ ┃ Diver (other agent types) Goes deep, comes … ⎿ crab-crew: All hands on deck: the Crab Crew is up. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Crab Crew
(\/)(-,,-)(\/) z Captain Clawd · Off watch, awaiting orders No crew dispatched yet. Meet the roster: Deckhand (general-purpose) Can-do all-rounder: mops, fixes,… Scout (Explore) Curious and quick; looks, never touches. Navigator (Plan) Measures twice, plots the course. Professor (claude-code-guide) Has read every manual twice. Signaller (statusline-setup) Tinkers with the lights and si… Understudy (fork) Knows everything the Captain knows. Cook (other agent types) Throws it all in the pan and flips. Sleuth (other agent types) Follows every clue to the end. Wizard (other agent types) Mutters spells at the code. Diver (other agent types) Goes deep, comes up with treasure.
README

Crab Crew

A Claude Code mod that turns your subagents into a crew of pixel crabs. Every agent type is played by its own character, with a role, a costume, a few lines of its own and a name. Each crab is animated by what its agent is doing and how far along it is.

It works with the standard agents (general-purpose, Explore, Plan and the rest) and with any custom agent type. No skills, prompts or tool calls are needed from the model: the mod watches the agents itself.

The crew panel

What you get

  • The crew panel (/crew toggles it). At the top is Captain Clawd, the main thread: at the helm while Claude works, asleep when it waits for you. Under the captain is one row per subagent:
  • who it is (Pip the Scout), its task, model, current action, tool calls, tokens and time;
  • a speech bubble in character ("Land ho? Hunting config.ts"), or a thought bubble while it thinks;
  • a beach lane on which the crab walks toward its flag as the work progresses, leaving footprints behind it.
  • The band above the prompt: the crabs at work, a one-line summary, overall progress, a button that opens the panel, and ✕ to dismiss. It clears itself at your next prompt once everyone is back.
  • The roster: Meet the crew in the panel, which is also shown while nobody is out.

The band above the prompt

The crew

Agent typeCharacterNamesTraitProp
general-purposeDeckhandBarnacle, Rusty, Sal…Can-do all-roundermop
ExploreScoutPip, Wren, Kit…Curious and quick; looks, never touchesspyglass
PlanNavigatorAda, Vela, Orion…Measures twice, plots the coursechart
claude-code-guideProfessorKelp, Sage, Quill…Has read every manual twicebook, mortarboard
statusline-setupSignallerSparky, Dot, MorseTinkers with the lights and signalssemaphore flag
forkUnderstudyEcho, Mirror, TwinKnows everything the Captain knows—
any other typeCook, Sleuth, Wizard or Diverdealt by the type's name, so the same type always gets the same characterpan, magnifier, wand, snorkel

A second crab of the same character takes the next name; after the last name the list starts again with II (Pip II).

How the crabs move

Every character in every pose

PoseWhenMotion
walkworking, last tool call a few seconds agolegs step, body bobs, the prop moves (mop swishes, spyglass sweeps, flag waves…)
scurrya tool call in the last 3 secondsthe same, at double pace
thinkwaiting on the modeleyes up, body sways, thought bubble with blinking dots
waita tool call running for more than 20 secondstaps a foot under a flipping hourglass
cheerjust finished (6 seconds)jumps with both claws up
restfinishedhappy eyes, sits still
dizzyended on an error or refusalupside down, legs wiggling, stars
sleepstopped by youeyes closed, dimmed, z z rising

All motion is CSS and stops when the system asks for reduced motion. The terminal draws the classic ASCII crab (\/)(°,,°)(\/) on a text lane, with frames switched once a second.

Progress

Standard agents don't report their progress, so the lane uses the best signal available:

  • Real progress when the agent keeps a todo list (TodoWrite, or TaskCreate / TaskUpdate): completed items out of all items, with half credit for the one in progress. Shown as 3/5 todos.
  • An estimate otherwise: it rises with the agent's tool calls toward 92% at a pace typical of its character (scouts are quick, deckhands take longer). Shown as ≈45%. The crab reaches the flag only when the agent actually finishes.

Tokens are summed from each model request's usage. The mod shows no cost: it has no reliable price table to base one on.

Install

At the prompt of a Claude Code terminal session:

/plugin install crab-crew --marketplace marwansaab/Claude-Mods

Answer y to add the marketplace and choose a scope. Then type /crew, or just let Claude dispatch an agent.

Commands

CommandDoes
/crewShows or hides the crew panel.

Settings

Set them in /config, or for a mod loaded with --plugin-dir, in ~/.claude/settings.json:

{ "pluginConfigs": { "crab-crew": { "options": { "autoOpen": false, "showQuips": true } } } }
OptionDefaultEffect
autoOpentrueOpens the panel the first time a subagent starts in a session. In a narrow terminal (under 144 columns) a pane that opens by itself waits until there is room; /crew always opens it.
showQuipstrueSpeech bubbles and lines in character. Off, the rows keep only the plain facts.

How it works

The mod hooks the engine's own events, so the model does nothing special:

EventUsed for
agent.spawncasts the new subagent: its character from the agent type, and the next free name
turn.stepmarks the crab as thinking; adds the request's tokens
tool.callthe crab's activity and target (reading config.ts), tool count, errors, todo progress; the captain's activity on the main thread
turn.completehow the run ended: back aboard, overboard or called back
prompt.submitclears the band once nobody is out
ui.render (Pane, AbovePrompt)draws the panel and the band: SVG on the desktop, text on the terminal

Every hook only observes, and is written so that a failure inside the mod can never fail a tool call, a spawn or a turn.

Limits

  • The estimate is an estimate: an agent that makes many quick calls reaches the flag area early and waits there until it finishes.
  • The function-hook API is early access; the mod was built against Claude Code 2.1.293.

Credits

Inspired by savvy-progress by johnnyvizz (MIT), whose panel and pixel crabs showed the way. Crab Crew is a separate implementation that needs no skill or reporting tool.

Licence

MIT, see the repository's LICENSE.

Source 4 files
hooks/register.tsx 500 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Activity, Captain, CrewMember, CrewPanel } from '../types'
5import {
6  BAND_H,
7  FINISHED_H,
8  HEADER_H,
9  ROSTER_H,
10  RUNNING_H,
11  asciiLane,
12  bandSvg,
13  finishedSvg,
14  headerSvg,
15  rosterSvg,
16  runningSvg,
17} from './art'
18import {
19  CAPTAIN,
20  CHEER_MS,
21  ROSTER,
22  actionOf,
23  activityOf,
24  captainQuip,
25  characterKeyOf,
26  characterOf,
27  crewNameOf,
28  crewProgress,
29  crewSummary,
30  crewTotals,
31  detailOf,
32  elapsed,
33  fmtTime,
34  fmtTokens,
35  modelName,
36  poseOf,
37  progressLabel,
38  progressOf,
39  quipOf,
40  statusOf,
41  titleOf,
42  todoPatch,
43} from './crew'
44
45const crew = atom({ plugin: 'crab-crew', key: 'crew' } as const, [])
46const captain = atom({ plugin: 'crab-crew', key: 'captain' } as const, {
47  isBusy: false,
48  activity: 'other',
49  detail: '',
50  lastActiveAt: 0,
51})
52const panel = atom({ plugin: 'crab-crew', key: 'panel' } as const, {
53  isDoneCollapsed: false,
54  isRosterOpen: false,
55  hasAutoOpened: false,
56  bandSince: 0,
57})
58const now = atom({ plugin: 'crab-crew', key: 'now' } as const, 0)
59
60const PANE = 'crab-crew'
61const PANE_TITLE = 'Crab Crew'
62const MAX_CREW = 60
63const CLAY = '#D97757'
64const STATUS_GLYPH: Record<string, string> = { running: '●', done: '✓', failed: '✗', stopped: '■' }
65
66/** The panel is decoration: nothing it does may break a tool call, a spawn or a turn. */
67async function safely(work: () => Promise<unknown>): Promise<void> {
68  try {
69    await work()
70  } catch {
71    // Dropped: a missed update only costs a frame of the drawing.
72  }
73}
74
75const touch = ($: EngineInterface, agentId: string, fn: (m: CrewMember) => CrewMember) =>
76  update($, crew, list => list.map(m => (m.agentId === agentId ? fn(m) : m)))
77
78const setCaptain = ($: EngineInterface, patch: Partial<Captain>) => update($, captain, c => ({ ...c, ...patch }))
79
80async function openPane($: EngineInterface): Promise<void> {
81  const at = await $.clock.now()
82  await update($, now, () => at)
83  await $.ui.open({ id: PANE, title: PANE_TITLE })
84}
85
86// Opens the crew pane, or closes it when it is up; true when it ends up open.
87async function togglePane($: EngineInterface): Promise<boolean> {
88  const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
89  if (isOpen) {
90    await $.ui.close({ id: PANE })
91    return false
92  }
93  await openPane($)
94  return true
95}
96
97/** Crew the band shows: started or finished after the person last moved on. */
98const bandCrew = (list: readonly CrewMember[], p: CrewPanel): CrewMember[] =>
99  list.filter(m => m.startedAt > p.bandSince || (m.endedAt ?? -1) > p.bandSince)
100
101export const register: Register = (on, options) => {
102  const autoOpen = options.autoOpen !== false
103  const showQuips = options.showQuips !== false
104
105  on('session.start', async ($, e, next) => {
106    const started = await next(e)
107    await $.command.register({
108      name: 'crew',
109      description: 'Show or hide the Crab Crew: every subagent as a crab with a role, animated by its status and progress',
110    })
111
112    // Ticks the clocks and the animations' poses while anyone works or celebrates; quiet otherwise.
113    $.clock.every(1000, () => {
114      void safely(async () => {
115        const list = await read($, crew)
116        const at = await $.clock.now()
117        const isLive = list.some(m => m.status === 'running' || (m.endedAt !== undefined && at - m.endedAt < CHEER_MS + 1000))
118        if (isLive) await update($, now, () => at)
119      })
120    })
121    return started
122  })
123
124  on('command.run', { command: 'crew' }, async $ => {
125    const isOpen = await togglePane($)
126    return { text: isOpen ? 'All hands on deck: the Crab Crew is up.' : 'Crab Crew stood down.' }
127  })
128
129  // A new subagent joins the crew, cast by its agent type.
130  on('agent.spawn', async ($, e, next) => {
131    const started = await next(e)
132    if (started.deny !== undefined) return started
133
134    await safely(async () => {
135      const at = await $.clock.now()
136      const character = characterKeyOf(e.subagentType)
137      await update($, crew, list => {
138        const member: CrewMember = {
139          id: started.agentId ?? e.tool_use_id,
140          agentId: started.agentId,
141          type: e.subagentType,
142          character,
143          name: crewNameOf(character, list),
144          description: e.description,
145          model: started.model,
146          status: 'running',
147          startedAt: at,
148          lastActiveAt: at,
149          activity: 'start',
150          detail: '',
151          toolCalls: 0,
152          errors: 0,
153          steps: 0,
154          contextTokens: 0,
155          tokens: 0,
156        }
157        return [...list.filter(m => m.id !== member.id), member].slice(-MAX_CREW)
158      })
159      await update($, now, () => at)
160      if (!autoOpen) return
161      const p = await read($, panel)
162      if (p.hasAutoOpened) return
163      await update($, panel, prev => ({ ...prev, hasAutoOpened: true }))
164      await $.ui.open({ id: PANE, title: PANE_TITLE })
165    })
166    return started
167  }).catch(($, e, next) => next(e))
168
169  // Each model request: the crab (or the captain) is thinking; then its tokens.
170  on('turn.step', async function* ($, e, next) {
171    const agentId = e.agentId
172    await safely(async () => {
173      const at = await $.clock.now()
174      if (agentId) {
175        await touch($, agentId, m => ({
176          ...m,
177          status: 'running',
178          endedAt: undefined,
179          activity: 'think',
180          detail: '',
181          lastActiveAt: at,
182          effort: typeof e.effort === 'string' ? e.effort : m.effort,
183        }))
184      } else {
185        await setCaptain($, { isBusy: true, activity: 'think', detail: '', lastActiveAt: at })
186      }
187    })
188
189    const result = yield* next(e)
190
191    await safely(async () => {
192      const usage = result.usage
193      if (!agentId || !usage) return
194      const sum =
195        (usage.input_tokens || 0) +
196        (usage.output_tokens || 0) +
197        (usage.cache_read_input_tokens || 0) +
198        (usage.cache_creation_input_tokens || 0)
199      await touch($, agentId, m => ({
200        ...m,
201        model: usage.model || m.model,
202        contextTokens: sum,
203        tokens: m.tokens + sum,
204        steps: m.steps + 1,
205      }))
206    })
207    return result
208  })
209
210  // Every tool call: what the crab is doing, its todos, and how its calls went.
211  on('tool.call', async ($, e, next) => {
212    const tool = String(e.tool)
213    const input = e as unknown as Record<string, unknown>
214    const activity: Activity = activityOf(tool)
215    const detail = detailOf(input)
216    const agentId = e.agentId
217
218    await safely(async () => {
219      const at = await $.clock.now()
220      if (agentId) {
221        await touch($, agentId, m => ({
222          ...m,
223          activity,
224          detail,
225          lastActiveAt: at,
226          toolCalls: m.toolCalls + 1,
227          ...todoPatch(tool, input, m),
228        }))
229      } else {
230        await setCaptain($, { isBusy: true, activity, detail, lastActiveAt: at })
231      }
232    })
233
234    const result = await next(e)
235
236    await safely(async () => {
237      if (!agentId) return
238      const at = await $.clock.now()
239      await touch($, agentId, m => ({ ...m, lastActiveAt: at, errors: m.errors + (result.isError ? 1 : 0) }))
240    })
241    return result
242  }).catch(($, e, next) => next(e))
243
244  // A run ends: back aboard, overboard, or called back.
245  on('turn.complete', async ($, e, next) => {
246    await safely(async () => {
247      const at = await $.clock.now()
248      if (e.agentId) {
249        const usage = e.usage
250        await touch($, e.agentId as string, m => ({
251          ...m,
252          status: statusOf(e.reason),
253          endedAt: at,
254          lastActiveAt: at,
255          ...(m.steps === 0 && usage
256            ? {
257                model: usage.model || m.model,
258                tokens:
259                  usage.input_tokens + usage.output_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens,
260              }
261            : {}),
262        }))
263      } else {
264        await setCaptain($, { isBusy: false, activity: 'other', detail: '', lastActiveAt: at })
265      }
266      await update($, now, () => at)
267    })
268    return next(e)
269  })
270
271  // A new prompt with nobody out: the band clears; the panel keeps the history.
272  on('prompt.submit', async ($, e, next) => {
273    await safely(async () => {
274      const list = await read($, crew)
275      if (list.some(m => m.status === 'running')) return
276      const at = await $.clock.now()
277      await update($, panel, p => ({ ...p, bandSince: at }))
278    })
279    return next(e)
280  }).catch(($, e, next) => next(e))
281
282  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
283    const ui = $.ui.resolve(e)
284    const { Box, Text, Button } = ui
285    const list = await read($, crew)
286    const c = await read($, captain)
287    const p = await read($, panel)
288    const at = Math.max(await read($, now), ...list.map(m => m.startedAt), 0)
289    const tick = Math.floor(at / 1000)
290
291    const running = list.filter(m => m.status === 'running').reverse()
292    const finished = list.filter(m => m.status !== 'running').reverse()
293    const isRosterShown = p.isRosterOpen || list.length === 0
294
295    const toggleRoster = (
296      <Button
297        key="roster"
298        label={p.isRosterOpen ? 'Hide roster' : 'Meet the crew'}
299        plain
300        onPress={() => update($, panel, prev => ({ ...prev, isRosterOpen: !prev.isRosterOpen }))}
301      />
302    )
303    const toggleDone = (
304      <Button
305        key="done"
306        label={`${p.isDoneCollapsed ? '▸' : '▾'} Back from duty · ${finished.length}`}
307        plain
308        onPress={() => update($, panel, prev => ({ ...prev, isDoneCollapsed: !prev.isDoneCollapsed }))}
309      />
310    )
311
312    if (e.surface === 'desktop' && 'Svg' in ui) {
313      const { Svg } = ui
314      const W = Math.max(260, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
315      return (
316        <Box flexDirection="column">
317          <Svg
318            source={headerSvg(W, c, list, at)}
319            alt={`${CAPTAIN.name}: ${captainQuip(c)}. ${crewSummary(list, at)}`}
320            width={W}
321            height={HEADER_H}
322          />
323          {list.length > 0 && toggleRoster}
324          {running.length > 0 && <Text dimColor>At work · {running.length}</Text>}
325          {running.map(m => (
326            <Svg
327              key={m.id}
328              source={runningSvg(W, m, at, showQuips)}
329              alt={`${titleOf(m)}: ${m.description}. ${actionOf(m, at)}, ${progressLabel(m)}`}
330              width={W}
331              height={RUNNING_H}
332            />
333          ))}
334          {finished.length > 0 && toggleDone}
335          {!p.isDoneCollapsed &&
336            finished.map(m => (
337              <Svg
338                key={m.id}
339                source={finishedSvg(W, m, at, showQuips)}
340                alt={`${titleOf(m)}: ${m.description}. ${actionOf(m, at)}`}
341                width={W}
342                height={FINISHED_H}
343              />
344            ))}
345          {isRosterShown && <Text dimColor>{list.length ? 'The roster' : 'No crew out yet. Meet the roster:'}</Text>}
346          {isRosterShown &&
347            ROSTER.map(r => (
348              <Svg
349                key={`roster-${r.character.key}`}
350                source={rosterSvg(W, r.character, r.types)}
351                alt={`${r.character.role} (${r.types}): ${r.character.trait}`}
352                width={W}
353                height={ROSTER_H}
354              />
355            ))}
356        </Box>
357      )
358    }
359
360    // Terminal and the other text surfaces: the same crew in text, ASCII crabs on their lanes.
361    const cols = Math.max(28, e.props.bodyColumns || 40)
362    const row = (m: CrewMember) => {
363      const ch = characterOf(m.character)
364      const pose = poseOf(m, at)
365      const lane = asciiLane(pose, tick, progressOf(m).value, Math.min(cols - 2, 48))
366      const isRunning = m.status === 'running'
367      return (
368        <Box key={m.id} flexDirection="column" marginBottom={1}>
369          <Box flexDirection="row" gap={1}>
370            <Text color={ch.color} bold wrap="truncate-end">
371              {titleOf(m)}
372            </Text>
373            <Text dimColor wrap="truncate-end">
374              {m.description || m.type}
375            </Text>
376            <Text color={m.status === 'failed' ? 'red' : m.status === 'done' ? 'green' : ch.color}>{STATUS_GLYPH[m.status]}</Text>
377          </Box>
378          {isRunning ? (
379            <Text wrap="truncate-end">
380              <Text dimColor>{lane.before}</Text>
381              <Text color={CLAY}>{lane.crab}</Text>
382              <Text color={ch.color}>{lane.after}</Text>
383            </Text>
384          ) : (
385            <Text wrap="truncate-end">
386              <Text color={CLAY}>{lane.crab}</Text>
387            </Text>
388          )}
389          {showQuips && (
390            <Text italic dimColor wrap="truncate-end">
391              {'  “'}
392              {quipOf(m, at)}
393              {'”'}
394            </Text>
395          )}
396          <Text dimColor wrap="truncate-end">
397            {'  '}
398            {[modelName(m.model), actionOf(m, at), progressLabel(m), `${m.toolCalls} tools`, m.tokens ? `${fmtTokens(m.tokens)} tok` : '', fmtTime(elapsed(m, at))]
399              .filter(Boolean)
400              .join(' · ')}
401          </Text>
402        </Box>
403      )
404    }
405
406    const t = crewTotals(list, at)
407    return (
408      <Box flexDirection="column">
409        <Text wrap="truncate-end">
410          <Text color={CLAY}>{c.isBusy ? '(\\/)(°,,°)(\\/)' : '(\\/)(-,,-)(\\/) z'}</Text>{' '}
411          <Text bold color={CAPTAIN.color}>
412            {CAPTAIN.name}
413          </Text>
414          <Text dimColor> · {captainQuip(c)}</Text>
415        </Text>
416        <Text dimColor wrap="truncate-end">
417          {list.length ? `${crewSummary(list, at)} · ${fmtTokens(t.tokens)} tok · ${fmtTime(t.time)}` : 'No crew dispatched yet.'}
418        </Text>
419        {list.length > 0 && toggleRoster}
420        <Box flexDirection="column" marginTop={1}>
421          {running.length > 0 && <Text dimColor>At work · {running.length}</Text>}
422          {running.map(row)}
423          {finished.length > 0 && toggleDone}
424          {!p.isDoneCollapsed && finished.map(row)}
425          {isRosterShown && <Text dimColor>{list.length ? 'The roster' : 'Meet the roster:'}</Text>}
426          {isRosterShown &&
427            ROSTER.map(r => (
428              <Text key={`roster-${r.character.key}`} wrap="truncate-end">
429                <Text color={r.character.color} bold>
430                  {r.character.role}
431                </Text>
432                <Text dimColor>
433                  {' '}
434                  ({r.types}) {r.character.trait}
435                </Text>
436              </Text>
437            ))}
438        </Box>
439      </Box>
440    )
441  })
442
443  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
444    if (e.props.hasSurvey) return next(e)
445    const list = await read($, crew)
446    const p = await read($, panel)
447    const shown = bandCrew(list, p)
448    if (shown.length === 0) return next(e)
449
450    const ui = $.ui.resolve(e)
451    const { Box, Text, Button } = ui
452    const at = Math.max(await read($, now), ...shown.map(m => m.startedAt))
453    const progress = crewProgress(shown)
454    const crewButton = <Button key="crab-crew-open" label={`Crew ×${shown.length}`} plain onPress={() => void togglePane($)} />
455    const dismiss = (
456      <Button
457        key="crab-crew-dismiss"
458        label="✕"
459        plain
460        role="dismiss"
461        onPress={async () => {
462          const t = await $.clock.now()
463          await update($, panel, prev => ({ ...prev, bandSince: t }))
464        }}
465      />
466    )
467
468    if ('Svg' in ui) {
469      const { Svg } = ui
470      const width = Math.max(200, Math.min(1600, (e.props.bodyColumns || 100) * 8 - 120))
471      return (
472        <Box flexDirection="row" alignItems="center" gap={1}>
473          <Svg source={bandSvg(width, shown, at, progress)} alt={`Crab Crew: ${crewSummary(shown, at)}`} width={width} height={BAND_H} />
474          {crewButton}
475          {dismiss}
476        </Box>
477      )
478    }
479
480    const running = shown.filter(m => m.status === 'running')
481    const lead = running[0]
482    const tick = Math.floor(at / 1000)
483    const crabFace = running.length ? (tick % 2 ? '(\\|)(°,,°)(|/)' : '(\\/)(°,,°)(\\/)') : '\\(^,,^)/'
484    const barW = Math.max(6, Math.min(20, e.props.bodyColumns - 60))
485    const filled = Math.round(barW * progress)
486    return (
487      <Box flexDirection="row" gap={1}>
488        <Text color={CLAY}>{crabFace}</Text>
489        <Text wrap="truncate-end">
490          {lead ? `${titleOf(lead)}: ${quipOf(lead, at)}` : 'All back aboard'}
491          <Text dimColor> · {crewSummary(shown, at)}</Text>
492        </Text>
493        <Text color={running.length ? CLAY : 'green'}>{'█'.repeat(filled) + '░'.repeat(Math.max(0, barW - filled))}</Text>
494        {crewButton}
495        {dismiss}
496      </Box>
497    )
498  })
499}
500
hooks/art.ts 476 lines
1import type { Captain, CrewMember } from '../types'
2import {
3  CAPTAIN,
4  actionOf,
5  captainQuip,
6  characterOf,
7  crewSummary,
8  crewTotals,
9  elapsed,
10  fmtTime,
11  fmtTokens,
12  modelName,
13  poseOf,
14  progressLabel,
15  progressOf,
16  quipOf,
17  titleOf,
18} from './crew'
19import type { Character, Pose } from './crew'
20
21// ---------------------------------------------------------------------------
22// Pixel crabs: a 30×28 grid, one costume per character. The body keeps the
23// brand clay; the character lives in its hat, its prop and how it moves.
24
25const CLAY = '#D97757'
26const INK = '#1F1E1D'
27const GOLD = '#F5C542'
28
29/** `cls` puts a pixel in a group: `bd` (default) body and costume, `la`/`lb` the leg pairs, else a prop. */
30type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
31
32const stamp = (f: Fill, x: number, y: number, rows: readonly string[], map: Record<string, string>, cls?: string): void =>
33  rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
34
35type Face = 'open' | 'up' | 'happy' | 'x' | 'closed'
36
37const eyes = (f: Fill, face: Face): void => {
38  for (const x of [9, 19]) {
39    if (face === 'open') f(x, 12, 2, 2, INK)
40    else if (face === 'up') f(x, 11, 2, 2, INK)
41    else if (face === 'happy') stamp(f, x - 1, 12, ['.kk.', 'k..k'], { k: INK })
42    else if (face === 'x') stamp(f, x - 1, 11, ['k.k', '.k.', 'k.k'], { k: INK })
43    else f(x - 1, 13, 4, 1, INK)
44  }
45}
46
47type Look = { face: Face; armL: number; armR: number }
48
49const body = (f: Fill, look: Look): void => {
50  f(7, 10, 16, 12, CLAY)
51  f(3, 14 + look.armL, 4, 4, CLAY)
52  f(23, 14 + look.armR, 4, 4, CLAY)
53  eyes(f, look.face)
54  f(7, 22, 2, 4, CLAY, 'la')
55  f(17, 22, 2, 4, CLAY, 'la')
56  f(11, 22, 2, 4, CLAY, 'lb')
57  f(21, 22, 2, 4, CLAY, 'lb')
58}
59
60type Costume = {
61  /** How far the right claw is raised to hold the prop (negative is up). */
62  hold: number
63  draw: (f: Fill, color: string) => void
64}
65
66const COSTUMES: Record<string, Costume> = {
67  // Captain: navy cap with a gold badge; turns the helm while the main thread works.
68  captain: {
69    hold: 0,
70    draw: f => {
71      stamp(f, 6, 5, ['....nnnnnnnnnn....', '..nnnnnnggnnnnnn..', '.nnnnnnggggnnnnnn.', '.nnnnnnnggnnnnnnn.', 'kkkkkkkkkkkkkkkkkk'], { n: '#2B3A67', g: GOLD, k: INK })
72      stamp(f, 22, 12, ['...h...', '.bbbbb.', '.b.b.b.', 'hbbsbbh', '.b.b.b.', '.bbbbb.', '...h...'], { b: '#8B5A2B', h: '#C08A50', s: GOLD }, 'wh')
73    },
74  },
75  // Deckhand: red bandana; swishes a mop.
76  deckhand: {
77    hold: 0,
78    draw: (f, t) => {
79      stamp(f, 7, 7, ['....rrrrrrrr....', '..rrrrwrrrrwrr..', 'rrrrrrrrrrrrrrrr'], { r: t, w: '#F8F6F1' })
80      f(5, 9, 2, 1, t)
81      f(4, 10, 2, 1, t)
82      stamp(f, 24, 4, ['..h..', '..h..', '..h..', '..h..', '..h..', '..h..', '..h..', '..h..', '..h..', '..h..', 'mmmmm', 'm.m.m', 'm.m.m'], { h: '#A0703F', m: '#E9E4D6' }, 'mop')
83    },
84  },
85  // Scout: bush hat; sweeps the horizon with a spyglass.
86  scout: {
87    hold: -4,
88    draw: (f, t) => {
89      stamp(f, 6, 6, ['....gggggggggg....', '...gyyyyyyyyyyg...', 'gggggggggggggggggg'], { g: t, y: '#C9A86A' })
90      stamp(f, 23, 7, ['bbbbbll', 'bbbbbll'], { b: '#B8862B', l: '#7A5A1E' }, 'sg')
91      f(29, 7, 1, 1, '#A9D6F5', 'sg')
92    },
93  },
94  // Navigator: bicorne; unrolls a chart.
95  navigator: {
96    hold: 0,
97    draw: f => {
98      stamp(f, 6, 6, ['kk..............kk', 'kkkk..kkkkkk..kkkk', '.kkkkkkkkkkkkkkkk.', '..gggggggggggggg..'], { k: '#2F2A3A', g: GOLD })
99      stamp(f, 8, 16, ['pppppppppppppp', 'p.pp.pp.ppprpp', 'pppppppppprrrp', 'pp.pp.pp.pprpp'], { p: '#EBD9A8', '.': '#C9A86A', r: '#D0453F' }, 'map')
100    },
101  },
102  // Professor: mortarboard and spectacles (drawn after the eyes); flips the pages of a book.
103  professor: {
104    hold: 0,
105    draw: f => {
106      stamp(f, 5, 5, ['.....kkkkkkkkkk.....', 'kkkkkkkkkkkkkkkkkkkk', '.....kkkkkkkkkk.....', '......kkkkkkkkk.....'], { k: '#2F2A3A' })
107      f(23, 6, 1, 4, GOLD, 'ts')
108      f(22, 10, 3, 1, GOLD, 'ts')
109      for (const x of [8, 18]) {
110        f(x, 11, 4, 1, '#3A3A3C')
111        f(x, 14, 4, 1, '#3A3A3C')
112        f(x, 11, 1, 4, '#3A3A3C')
113        f(x + 3, 11, 1, 4, '#3A3A3C')
114      }
115      f(12, 12, 6, 1, '#3A3A3C')
116      stamp(f, 0, 13, ['bbb.bbb', 'bwwbwwb', 'bwwbwwb', 'bbbbbbb'], { b: '#6B3E9E', w: '#F8F6F1' })
117      f(4, 14, 2, 2, '#E9E4D6', 'pg')
118    },
119  },
120  // Signaller: headset; waves a semaphore flag.
121  signaller: {
122    hold: -4,
123    draw: f => {
124      f(8, 7, 14, 1, '#3A3A3C')
125      f(7, 8, 1, 3, '#3A3A3C')
126      f(22, 8, 1, 3, '#3A3A3C')
127      f(5, 10, 2, 4, '#D0453F')
128      f(23, 7, 2, 3, '#D0453F')
129      f(26, 2, 1, 12, '#8E929A')
130      stamp(f, 27, 2, ['yyy', 'ryy', 'rry'], { y: GOLD, r: '#D0453F' }, 'fl')
131    },
132  },
133  // Echo (a fork): beanie with a pompom; a faint double of the captain.
134  echo: {
135    hold: 0,
136    draw: (f, t) => {
137      stamp(f, 7, 5, ['.......pp.......', '....pppppppp....', '..pppppppppppp..', '.pppppppppppppp.', 'wwwwwwwwwwwwwwww'], { p: t, w: '#C9BFFA' })
138    },
139  },
140  // Cook: toque; tosses an omelette.
141  cook: {
142    hold: -4,
143    draw: f => {
144      stamp(f, 7, 1, ['...wwwwwwwwww...', '.wwwwwwwwwwwwww.', 'wwwwwwwwwwwwwwww', 'wwwwwwgwwwwgwwww', '.wwwwwgwwwwgwww.', '..wwwwgwwwwgww..', '..gggggggggggg..', '..wwwwwwwwwwww..', '..wwwwwwwwwwww..'], { w: '#F4F3EE', g: '#D2D1C8' })
145      f(22, 9, 7, 2, '#4A4A48')
146      f(24, 8, 3, 1, '#F5B731', 'eg')
147    },
148  },
149  // Sleuth: deerstalker; peers through a magnifier.
150  sleuth: {
151    hold: -4,
152    draw: f => {
153      stamp(f, 6, 4, ['......bbbbbb......', '....bbcbbcbbbb....', '...bbbbbbbbbbbb...', '..bcbbcbbcbbcbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: '#7A4A26', c: '#A0703F', d: '#5A3519' })
154      stamp(f, 23, 2, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'mg')
155      f(24, 3, 3, 3, 'rgba(169,214,245,.7)', 'mg')
156      f(25, 7, 1, 3, '#7A4A26', 'mg')
157    },
158  },
159  // Wizard: starry hat; the wand sparkles.
160  wizard: {
161    hold: -4,
162    draw: f => {
163      stamp(f, 7, 0, ['.......bb.......', '......bbbb......', '......bsbb......', '.....bbbbbb.....', '.....bbbbsb.....', '....bbbbbbbb....', '...bsbbbbbbbb...', '..bbbbbbbbbbbb..', 'bbbbbbbbbbbbbbbb'], { b: '#4B3B8F', s: GOLD })
164      f(6, 8, 18, 1, '#4B3B8F')
165      f(26, 5, 1, 6, '#5A3519')
166      stamp(f, 25, 2, ['.s.', 'sss', '.s.'], { s: GOLD }, 'sp')
167    },
168  },
169  // Diver: mask and snorkel; bubbles rise.
170  diver: {
171    hold: 0,
172    draw: f => {
173      f(7, 10, 16, 1, '#2E8BC0')
174      stamp(f, 7, 11, ['mggggm..mggggm', 'mggggmmmmggggm', 'mmmmmm..mmmmmm'], { m: '#2E8BC0', g: 'rgba(169,214,245,.45)' })
175      f(23, 4, 1, 8, '#F07E2E')
176      f(21, 11, 2, 1, '#F07E2E')
177      f(24, 1, 2, 2, '#A9D6F5', 'bu')
178      f(26, 0, 1, 1, '#A9D6F5', 'bu2')
179    },
180  },
181}
182
183
184const lookOf = (pose: Pose, hold: number): Look => {
185  switch (pose) {
186    case 'think':
187      return { face: 'up', armL: 0, armR: hold }
188    case 'cheer':
189      return { face: 'happy', armL: -8, armR: -8 }
190    case 'rest':
191      return { face: 'happy', armL: 0, armR: hold }
192    case 'dizzy':
193      return { face: 'x', armL: 0, armR: 0 }
194    case 'sleep':
195      return { face: 'closed', armL: 0, armR: 0 }
196    default:
197      return { face: 'open', armL: 0, armR: hold }
198  }
199}
200
201/**
202 * Pure CSS on the compositor, no redraws. Periods divide one second, so the
203 * once-a-second redraw of a running row restarts them in phase.
204 */
205export const CRAB_CSS = `<style>
206g{transform-box:fill-box}
207.p-walk .la{animation:st .5s steps(1) infinite}.p-walk .lb{animation:st .5s steps(1) infinite -.25s}.p-walk .bd{animation:bob .5s steps(1) infinite -.125s}
208.p-fast .la{animation:st .25s steps(1) infinite}.p-fast .lb{animation:st .25s steps(1) infinite -.125s}.p-fast .bd{animation:bob .25s steps(1) infinite}
209@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
210.p-think .bd{transform-origin:50% 100%;animation:sway 1s ease-in-out infinite}@keyframes sway{25%{transform:rotate(-3deg)}75%{transform:rotate(3deg)}}
211.p-wait .lb{animation:tap .5s steps(1) infinite}@keyframes tap{50%{transform:translateY(-2px)}}
212.p-cheer{animation:jump .5s ease-out infinite}@keyframes jump{40%{transform:translateY(-4px)}}
213.p-dizzy .la{animation:wig .25s steps(1) infinite}.p-dizzy .lb{animation:wig .25s steps(1) infinite -.125s}@keyframes wig{50%{transform:translateX(1px)}}
214.zz{opacity:0;animation:zz 2s linear infinite}.zz2{animation-delay:-1s}@keyframes zz{0%{opacity:0;transform:translate(0,0)}20%{opacity:1}100%{opacity:0;transform:translate(4px,-8px)}}
215.dot{animation:blink 1s steps(1) infinite}.dot2{animation-delay:-.66s}.dot3{animation-delay:-.33s}@keyframes blink{50%{opacity:.15}}
216.star{animation:blink .5s steps(1) infinite}.star2{animation-delay:-.25s}
217.hg{transform-origin:50% 50%;animation:hg 1s steps(1) infinite}@keyframes hg{50%{transform:rotate(180deg)}}
218.on .wh{transform-origin:50% 50%;animation:spin 1s linear infinite}@keyframes spin{to{transform:rotate(90deg)}}
219.on .mop{transform-origin:50% 0;animation:swish .5s ease-in-out infinite}@keyframes swish{25%{transform:rotate(-14deg)}75%{transform:rotate(14deg)}}
220.on .sg{transform-origin:0 50%;animation:scan 1s ease-in-out infinite}@keyframes scan{25%{transform:rotate(-12deg)}75%{transform:rotate(6deg)}}
221.on .map{transform-origin:0 50%;animation:unroll 1s ease-in-out infinite}@keyframes unroll{50%{transform:scaleX(.55)}}
222.on .pg{transform-origin:0 50%;animation:flip .5s steps(1) infinite}@keyframes flip{50%{transform:scaleX(-1)}}
223.on .ts{transform-origin:50% 0;animation:swing 1s ease-in-out infinite}@keyframes swing{25%{transform:rotate(20deg)}75%{transform:rotate(-20deg)}}
224.on .fl{transform-origin:0 50%;animation:wave .25s steps(1) infinite}@keyframes wave{50%{transform:skewY(-14deg) scaleX(.85)}}
225.on .eg{animation:toss 1s ease-in-out infinite}@keyframes toss{30%{transform:translateY(-5px) scaleY(-1)}60%{transform:none}}
226.on .mg{animation:peer 1s steps(1) infinite}@keyframes peer{25%{transform:translate(-1px,1px)}50%{transform:translate(-2px,2px)}75%{transform:translate(-1px,1px)}}
227.on .sp{animation:blink .25s steps(1) infinite}
228.on .bu,.on .bu2{animation:rise 1s linear infinite}.on .bu2{animation-delay:-.5s}@keyframes rise{0%{opacity:0;transform:translateY(2px)}30%{opacity:1}100%{opacity:0;transform:translateY(-6px)}}
229@media (prefers-reduced-motion: reduce){*{animation:none!important}.zz{opacity:1}}
230</style>`
231
232/** One crab at (x, y), in the costume of `key` (a character, or `captain`), drawn in `pose`. */
233export const crab = (x: number, y: number, key: string, pose: Pose, scale = 1.1, color = '#888780'): string => {
234  const groups = new Map<string, string[]>([['bd', []]])
235  const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
236    const list = groups.get(cls) ?? []
237    groups.set(cls, list)
238    list.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
239  }
240  const costume = COSTUMES[key] ?? COSTUMES.deckhand
241  body(f, lookOf(pose, costume?.hold ?? 0))
242  costume?.draw(f, color)
243
244  const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
245  const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
246  const inner = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>${group('la')}${group('lb')}`
247  const isOn = pose === 'walk' || pose === 'fast'
248  const flipped = pose === 'dizzy' ? `<g transform="rotate(180 15 17)">${inner}</g>` : inner
249
250  const extras: string[] = []
251  if (pose === 'dizzy') {
252    extras.push(`<g fill="${GOLD}"><rect class="star" x="6" y="27" width="3" height="1"/><rect class="star" x="7" y="26" width="1" height="3"/><rect class="star star2" x="21" y="27" width="3" height="1"/><rect class="star star2" x="22" y="26" width="1" height="3"/></g>`)
253  }
254  if (pose === 'sleep') {
255    extras.push(`<text class="zz m" x="24" y="9" font-family="${FONT}" font-size="7" font-weight="700">z</text><text class="zz zz2 m" x="27" y="5" font-family="${FONT}" font-size="5" font-weight="700">z</text>`)
256  }
257  if (pose === 'wait') {
258    const hg: string[] = []
259    stamp((cx, cy, w, h, c) => hg.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`), 24, 0, ['hhhhh', '.sss.', '..s..', '.s.s.', 'hhhhh'], { h: '#8B5A2B', s: GOLD })
260    extras.push(`<g class="hg">${hg.join('')}</g>`)
261  }
262
263  return `<g transform="translate(${x},${y}) scale(${scale})" shape-rendering="crispEdges"${pose === 'sleep' && key !== 'captain' ? ' opacity="0.7"' : ''}><g class="c-${key} p-${pose}${isOn ? ' on' : ''}">${flipped}</g>${extras.join('')}</g>`
264}
265
266// ---------------------------------------------------------------------------
267// Text measurement and SVG scaffolding.
268
269export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif"
270
271export const xml = (s: string): string =>
272  s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
273
274/** Rough advance of system UI text, in em; enough to size bubbles and cut lines. */
275const charEm = (ch: string): number => (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Zmw@%]/.test(ch) ? 0.72 : 0.56)
276
277export const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
278
279export const fitText = (s: string, size: number, maxW: number): string => {
280  if (textWidth(s, size) <= maxW) return s
281  let out = ''
282  for (const ch of s) {
283    if (textWidth(out + ch + '…', size) > maxW) break
284    out += ch
285  }
286  return out + '…'
287}
288
289const PALETTE = `<style>
290.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#9a9a96}.ln{stroke:#e4e4e1}.sand{fill:#E9DCBC}.fp{fill:#CDB98C}.bub{fill:#ffffff;stroke:#d8d6d0}.k{fill:#ecebe8}
291@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.ln{stroke:#333331}.sand{fill:#4a4232}.fp{fill:#6b5f45}.bub{fill:#2b2b2a;stroke:#444442}.k{fill:#2c2c2b}}
292.live{animation:p 1s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
293@media (prefers-reduced-motion: reduce){.live{animation:none}}
294</style>`
295
296const svg = (W: number, H: number, body: string): string =>
297  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PALETTE}${CRAB_CSS}${body}</svg>`
298
299const statusMark = (x: number, y: number, status: string, color: string): string => {
300  if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
301  if (status === 'done') return `<path d="M${x - 5} ${y}l3.5 3.5 6.5-7" fill="none" stroke="#3B9C5F" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`
302  if (status === 'failed') return `<path d="M${x - 4} ${y - 4}l8 8M${x + 4} ${y - 4}l-8 8" stroke="#D0453F" stroke-width="1.8" stroke-linecap="round"/>`
303  return `<rect x="${x - 4}" y="${y - 4}" width="8" height="8" rx="1.5" fill="#9a9a96"/>`
304}
305
306/** A speech bubble, or a thought bubble with dots that blink in turn. */
307const bubble = (x: number, y: number, text: string, isFlipped: boolean, isThought: boolean, maxW: number): string => {
308  const size = 11
309  const dotsW = isThought ? 16 : 0
310  const shown = fitText(text, size, Math.max(30, maxW - 14 - dotsW))
311  const w = Math.round(textWidth(shown, size) + 14 + dotsW)
312  const h = 18
313  const tailX = isFlipped ? x + w - 12 : x + 12
314  const tail = isThought
315    ? `<circle class="bub" cx="${tailX + (isFlipped ? 2 : -2)}" cy="${y + h + 3}" r="2.2"/><circle class="bub" cx="${tailX + (isFlipped ? 5 : -5)}" cy="${y + h + 7}" r="1.4"/>`
316    : `<path class="bub" d="M${tailX - 4} ${y + h - 0.5}L${tailX + (isFlipped ? 4 : -4)} ${y + h + 6}L${tailX + 4} ${y + h - 0.5}Z"/><rect class="bub" style="stroke:none" x="${tailX - 3.4}" y="${y + h - 2}" width="6.8" height="2"/>`
317  const dots = isThought
318    ? [0, 1, 2].map(i => `<circle class="m dot${i ? ` dot${i + 1}` : ''}" cx="${x + w - dotsW - 2 + i * 5 + 3}" cy="${y + h / 2}" r="1.5"/>`).join('')
319    : ''
320  return `<g><rect class="bub" x="${x}" y="${y}" width="${w}" height="${h}" rx="9"/>${tail}<text class="t" x="${x + 7}" y="${y + 12.5}" font-family="${FONT}" font-size="${size}">${xml(shown)}</text>${dots}</g>`
321}
322
323// ---------------------------------------------------------------------------
324// The panel's drawings (desktop): each row is one SVG.
325
326export const RUNNING_H = 98
327export const FINISHED_H = 54
328export const HEADER_H = 58
329export const ROSTER_H = 44
330export const BAND_H = 26
331
332const CRAB_W = 33
333
334export const runningSvg = (W: number, m: CrewMember, at: number, showQuips: boolean): string => {
335  const c = characterOf(m.character)
336  const pose = poseOf(m, at)
337  const { value } = progressOf(m)
338  const right = `${fmtTime(elapsed(m, at))}`
339  const meta = [modelName(m.model), actionOf(m, at), `${m.toolCalls} tools`, m.tokens ? `${fmtTokens(m.tokens)} tok` : ''].filter(Boolean).join(' · ')
340  const label = progressLabel(m)
341  const metaW = W - textWidth(label, 11) - 14
342
343  // The lane: the crab walks toward the flag as its work progresses.
344  const laneBottom = RUNNING_H - 8
345  const crabX = Math.round(value * (W - CRAB_W - 20))
346  const crabY = laneBottom - 31
347  const prints: string[] = []
348  for (let fx = 4, k = 0; fx < crabX - 2; fx += 7, k++) prints.push(`<rect class="fp" x="${fx}" y="${laneBottom - 2 + (k % 2)}" width="3" height="1.5" rx=".75"/>`)
349
350  let speech = ''
351  if (showQuips) {
352    const text = quipOf(m, at)
353    const room = W - 20
354    const bw = Math.min(room, textWidth(text, 11) + 14 + (pose === 'think' ? 16 : 0))
355    const isFlipped = crabX + 26 + bw > W - 18
356    const bx = isFlipped ? Math.max(0, crabX + 8 - bw) : crabX + 26
357    speech = bubble(bx, 38, text, isFlipped, pose === 'think', room)
358  }
359
360  return svg(
361    W,
362    RUNNING_H,
363    `<text x="0" y="14" font-family="${FONT}" font-size="13"><tspan class="t" font-weight="600">${xml(m.name)}</tspan><tspan fill="${c.color}" font-weight="600"> the ${xml(c.role)}</tspan><tspan class="s"> · ${xml(fitText(m.description || m.type, 13, Math.max(40, W - 60 - textWidth(`${m.name} the ${c.role} · `, 13))))}</tspan></text>
364<text class="s" x="${W - 14}" y="14" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${right}</text>
365${statusMark(W - 4, 10, m.status, c.color)}
366<text class="s" x="0" y="30" font-family="${FONT}" font-size="11">${xml(fitText(meta, 11, metaW))}</text>
367<text x="${W}" y="30" text-anchor="end" font-family="${FONT}" font-size="11" font-weight="600" fill="${c.color}" font-variant-numeric="tabular-nums">${label}</text>
368<rect class="sand" x="0" y="${laneBottom}" width="${W}" height="3" rx="1.5"/>
369${prints.join('')}
370<rect class="m" x="${W - 11}" y="${laneBottom - 26}" width="1.5" height="26"/>
371<path d="M${W - 9.5} ${laneBottom - 26}L${W - 1} ${laneBottom - 21.5}L${W - 9.5} ${laneBottom - 17}Z" fill="${c.color}"/>
372${speech}
373${crab(crabX, crabY, m.character, pose, 1.1, c.color)}
374<line class="ln" x1="0" y1="${RUNNING_H - 0.5}" x2="${W}" y2="${RUNNING_H - 0.5}"/>`,
375  )
376}
377
378export const finishedSvg = (W: number, m: CrewMember, at: number, showQuips: boolean): string => {
379  const c = characterOf(m.character)
380  const pose = poseOf(m, at)
381  const how = m.status === 'done' ? 'Back aboard' : m.status === 'failed' ? 'Went overboard' : 'Called back'
382  const meta = [how, fmtTime(elapsed(m, at)), modelName(m.model), `${m.toolCalls} tools`, m.tokens ? `${fmtTokens(m.tokens)} tok` : ''].filter(Boolean).join(' · ')
383  const textW = W - 40 - 16
384  return svg(
385    W,
386    FINISHED_H,
387    `${crab(0, 16, m.character, pose, 1, c.color)}
388<text x="40" y="14" font-family="${FONT}" font-size="13"><tspan class="t" font-weight="600">${xml(m.name)}</tspan><tspan fill="${c.color}" font-weight="600"> the ${xml(c.role)}</tspan><tspan class="s"> · ${xml(fitText(m.description || m.type, 13, Math.max(40, textW - textWidth(`${m.name} the ${c.role} · `, 13))))}</tspan></text>
389<text class="s" x="40" y="30" font-family="${FONT}" font-size="11">${xml(fitText(meta, 11, textW))}</text>
390${showQuips ? `<text class="m" x="40" y="45" font-family="${FONT}" font-size="11" font-style="italic">“${xml(fitText(quipOf(m, at), 11, textW))}”</text>` : ''}
391${statusMark(W - 6, 10, m.status, c.color)}
392<line class="ln" x1="0" y1="${FINISHED_H - 0.5}" x2="${W}" y2="${FINISHED_H - 0.5}"/>`,
393  )
394}
395
396export const headerSvg = (W: number, captain: Captain, crew: readonly CrewMember[], at: number): string => {
397  const pose: Pose = captain.isBusy ? (captain.activity === 'think' ? 'think' : 'walk') : 'sleep'
398  const t = crewTotals(crew, at)
399  const stats = crew.length ? `${crewSummary(crew, at)} · ${fmtTokens(t.tokens)} tok · ${fmtTime(t.time)}` : 'No crew dispatched yet'
400  const textW = W - 44
401  return svg(
402    W,
403    HEADER_H,
404    `${crab(0, 18, 'captain', pose, 1.1, CAPTAIN.color)}
405<text x="44" y="15" font-family="${FONT}" font-size="14"><tspan class="t" font-weight="600">${CAPTAIN.name}</tspan><tspan class="s" font-size="12"> · ${CAPTAIN.role}</tspan></text>
406<text class="s" x="44" y="32" font-family="${FONT}" font-size="12">${xml(fitText(captainQuip(captain), 12, textW))}</text>
407<text class="m" x="44" y="49" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${xml(fitText(stats, 11, textW))}</text>
408<line class="ln" x1="0" y1="${HEADER_H - 0.5}" x2="${W}" y2="${HEADER_H - 0.5}"/>`,
409  )
410}
411
412export const rosterSvg = (W: number, c: Character, types: string): string =>
413  svg(
414    W,
415    ROSTER_H,
416    `${crab(0, 8, c.key, 'walk', 1, c.color)}
417<text x="40" y="16" font-family="${FONT}" font-size="12"><tspan fill="${c.color}" font-weight="600">${xml(c.role)}</tspan><tspan class="s"> · ${xml(types)} · ${xml(c.names.slice(0, 3).join(', '))}…</tspan></text>
418<text class="m" x="40" y="32" font-family="${FONT}" font-size="11" font-style="italic">${xml(fitText(c.trait, 11, W - 44))}</text>`,
419  )
420
421/** The band above the prompt: the crew at work, walking, and how far along they are. */
422export const bandSvg = (W: number, crew: readonly CrewMember[], at: number, progress: number): string => {
423  const running = crew.filter(m => m.status === 'running')
424  const shown = (running.length ? running : crew).slice(-Math.max(1, Math.floor((W * 0.45) / 26)))
425  const crabs = shown.map((m, i) => crab(i * 26, 3, m.character, running.length ? poseOf(m, at) : 'rest', 0.72, characterOf(m.character).color)).join('')
426  const x0 = shown.length * 26 + 6
427  const names = running.length
428    ? `${running.slice(0, 3).map(m => m.name).join(', ')}${running.length > 3 ? ` +${running.length - 3}` : ''}: ${crewSummary(crew, at)}`
429    : `All back aboard · ${crewSummary(crew, at)}`
430  const barW = Math.max(40, Math.min(160, W * 0.2))
431  const textW = W - x0 - barW - 52
432  const barX = W - barW - 40
433  return svg(
434    W,
435    BAND_H,
436    `${crabs}
437<text class="t" x="${x0}" y="17" font-family="${FONT}" font-size="12.5">${xml(fitText(names, 12.5, textW))}</text>
438<rect class="k" x="${barX}" y="9" width="${barW}" height="8" rx="4"/>
439<rect x="${barX}" y="9" width="${Math.round(barW * progress)}" height="8" rx="4" fill="${running.length ? CLAY : '#3B9C5F'}"/>
440<text class="s" x="${W}" y="17" text-anchor="end" font-family="${FONT}" font-size="11.5" font-variant-numeric="tabular-nums">${running.length ? '≈' : ''}${Math.round(progress * 100)}%</text>`,
441  )
442}
443
444// ---------------------------------------------------------------------------
445// Terminal crabs: the classic ASCII crab, its frame switched by the clock tick.
446
447export const asciiCrab = (pose: Pose, tick: number): string => {
448  const odd = tick % 2 === 1
449  switch (pose) {
450    case 'walk':
451    case 'fast':
452      return odd ? '(\\|)(°,,°)(|/)' : '(\\/)(°,,°)(\\/)'
453    case 'think':
454      return odd ? '(\\/)(˘,,˘)(\\/)' : '(\\/)(°,,°)(\\/)'
455    case 'wait':
456      return '(\\/)(°,,°)(\\/)'
457    case 'cheer':
458      return odd ? '\\(^,,^)/' : '_(^,,^)_'
459    case 'rest':
460      return '(\\/)(^,,^)(\\/)'
461    case 'dizzy':
462      return '(/\\)(x,,x)(/\\)'
463    case 'sleep':
464      return '(\\/)(-,,-)(\\/)'
465  }
466}
467
468/** A crab on its lane in text: footprints behind it, the flag ahead. */
469export const asciiLane = (pose: Pose, tick: number, value: number, width: number): { before: string; crab: string; after: string } => {
470  const c = asciiCrab(pose, tick)
471  const room = Math.max(0, width - c.length - 2)
472  const isMoving = pose === 'walk' || pose === 'fast'
473  const pos = Math.min(room, Math.round(value * room) + (isMoving && tick % 2 ? 1 : 0))
474  return { before: '.'.repeat(pos), crab: c, after: ' '.repeat(Math.max(0, room - pos)) + ' ⚑' }
475}
476
hooks/crew.ts 465 lines
1import type { Activity, Captain, CrewMember, CrewStatus } from '../types'
2
3// ---------------------------------------------------------------------------
4// The roster: one crab character per standard agent type, and a pool that
5// any other agent type (a plugin's, a project's own) is dealt from by name.
6
7/** What a crab says depends on its mood: its activity, or how its run ended. */
8export type Mood = Activity | 'wait' | 'done' | 'failed' | 'stopped'
9
10/** How a crab is drawn: each pose is a body, a face and a motion. */
11export type Pose = 'walk' | 'fast' | 'think' | 'wait' | 'cheer' | 'rest' | 'dizzy' | 'sleep'
12
13export type Character = {
14  key: string
15  role: string
16  names: readonly string[]
17  color: string
18  trait: string
19  /** Tool calls in a typical job: sets the estimated-progress curve. */
20  pace: number
21  quips: Partial<Record<Mood, readonly string[]>>
22}
23
24const GENERIC: Record<Mood, readonly string[]> = {
25  start: ['Reporting for duty!', 'Aye aye!', 'Claws ready.'],
26  think: ['Hmm, let me think', 'Pondering', 'Mulling it over'],
27  read: ['Reading {x}', 'Peeking at {x}'],
28  search: ['Searching for {x}', 'Combing the sand'],
29  edit: ['Tweaking {x}', 'Patching {x}'],
30  shell: ['Running {x}', 'Cranking the engine'],
31  web: ['Checking {x}', 'Asking the wider sea'],
32  delegate: ['Calling in help: {x}', 'Need a hand here!'],
33  plan: ['Making a list', 'Checking it twice'],
34  other: ['On it', 'Busy, busy'],
35  wait: ['Still waiting on {x}', 'Tap, tap, tap'],
36  done: ['Back aboard!', 'Job done.', 'Nailed it.'],
37  failed: ['Man overboard!', 'That went sideways.'],
38  stopped: ['Called back to the ship.', 'Standing down.'],
39}
40
41export const CHARACTERS: Record<string, Character> = {
42  deckhand: {
43    key: 'deckhand',
44    role: 'Deckhand',
45    names: ['Barnacle', 'Rusty', 'Sal', 'Dory', 'Mack'],
46    color: '#D0453F',
47    trait: 'Can-do all-rounder: mops, fixes, hauls.',
48    pace: 22,
49    quips: {
50      start: ["Aye, I'll handle it!", 'Rolling up me claws.'],
51      edit: ['Hammering {x} into shape', 'Swabbing {x}'],
52      shell: ['Hauling on {x}'],
53      wait: ['Leaning on the mop'],
54      done: ['Shipshape and done!', "Deck's clean, Cap'n."],
55      failed: ['Slipped on the deck!'],
56    },
57  },
58  scout: {
59    key: 'scout',
60    role: 'Scout',
61    names: ['Pip', 'Wren', 'Kit', 'Marlo', 'Juno'],
62    color: '#4E9A3A',
63    trait: 'Curious and quick; looks, never touches.',
64    pace: 14,
65    quips: {
66      start: ['Off to explore!', 'Spyglass ready.'],
67      read: ['Spyglass on {x}', 'Ooh, what is {x}?'],
68      search: ['Land ho? Hunting {x}', 'Scanning for {x}'],
69      think: ['Mapping what I saw'],
70      done: ['Scouting report ready!', 'Charted it all!'],
71      failed: ['Lost in the fog!'],
72    },
73  },
74  navigator: {
75    key: 'navigator',
76    role: 'Navigator',
77    names: ['Ada', 'Vela', 'Orion', 'Mags'],
78    color: '#378ADD',
79    trait: 'Measures twice, plots the course.',
80    pace: 16,
81    quips: {
82      start: ['Unrolling the charts.'],
83      think: ['Plotting a course', 'Weighing the winds'],
84      read: ['Studying {x}'],
85      search: ['Taking soundings: {x}'],
86      plan: ['Marking waypoints'],
87      done: ['Course plotted!', 'Heading set, Captain.'],
88      failed: ['Off the edge of the map!'],
89    },
90  },
91  professor: {
92    key: 'professor',
93    role: 'Professor',
94    names: ['Kelp', 'Sage', 'Quill', 'Tomes'],
95    color: '#7F77DD',
96    trait: 'Has read every manual twice.',
97    pace: 10,
98    quips: {
99      start: ['Ahem. To the library!'],
100      read: ['Consulting {x}'],
101      web: ['Looking it up: {x}'],
102      think: ['Fascinating, one moment'],
103      done: ['Footnotes included.'],
104      failed: ['The page is missing!'],
105    },
106  },
107  signaller: {
108    key: 'signaller',
109    role: 'Signaller',
110    names: ['Sparky', 'Dot', 'Morse'],
111    color: '#BA7517',
112    trait: 'Tinkers with the lights and signals.',
113    pace: 8,
114    quips: {
115      start: ['Signals up!'],
116      edit: ['Rewiring {x}'],
117      shell: ['Testing the lamp: {x}'],
118      done: ["Signal's lit!"],
119    },
120  },
121  echo: {
122    key: 'echo',
123    role: 'Understudy',
124    names: ['Echo', 'Mirror', 'Twin'],
125    color: '#8F7CEC',
126    trait: 'Knows everything the Captain knows.',
127    pace: 20,
128    quips: {
129      start: ['Same brain, new claws.'],
130      think: ['As the Captain would'],
131      done: ['Echo complete.'],
132    },
133  },
134  // The pool: other agent types are dealt one of these by name.
135  cook: {
136    key: 'cook',
137    role: 'Cook',
138    names: ['Cookie', 'Saffron', 'Basil'],
139    color: '#D85A30',
140    trait: 'Throws it all in the pan and flips.',
141    pace: 20,
142    quips: {
143      start: ['Firing up the galley!'],
144      edit: ['Stirring {x}', 'A pinch of {x}'],
145      shell: ['Turning up the heat'],
146      done: ['Order up!'],
147      failed: ['Burnt the omelette!'],
148    },
149  },
150  sleuth: {
151    key: 'sleuth',
152    role: 'Sleuth',
153    names: ['Inky', 'Clue', 'Sherl'],
154    color: '#A0703F',
155    trait: 'Follows every clue to the end.',
156    pace: 18,
157    quips: {
158      start: ['The game is afoot!'],
159      search: ['Following the trail: {x}'],
160      read: ['Examining {x}'],
161      think: ['Connecting the dots'],
162      done: ['Case closed.'],
163      failed: ['The trail went cold.'],
164    },
165  },
166  wizard: {
167    key: 'wizard',
168    role: 'Wizard',
169    names: ['Merla', 'Coral', 'Zap'],
170    color: '#7B6BD8',
171    trait: 'Mutters spells at the code.',
172    pace: 20,
173    quips: {
174      start: ['By my shell!'],
175      edit: ['Enchanting {x}'],
176      shell: ['Casting {x}'],
177      think: ['Consulting the stars'],
178      done: ['Ta-da!'],
179      failed: ['The spell fizzled!'],
180    },
181  },
182  diver: {
183    key: 'diver',
184    role: 'Diver',
185    names: ['Bubbles', 'Finn', 'Marina'],
186    color: '#2E8BC0',
187    trait: 'Goes deep, comes up with treasure.',
188    pace: 20,
189    quips: {
190      start: ['Taking the plunge!'],
191      search: ['Diving for {x}'],
192      read: ['Deep in {x}'],
193      done: ['Surfaced with treasure!'],
194      failed: ['Out of air!'],
195    },
196  },
197}
198
199const POOL = ['cook', 'sleuth', 'wizard', 'diver'] as const
200
201const BY_TYPE: Record<string, string> = {
202  'general-purpose': 'deckhand',
203  claude: 'deckhand',
204  Explore: 'scout',
205  Plan: 'navigator',
206  'claude-code-guide': 'professor',
207  'statusline-setup': 'signaller',
208  fork: 'echo',
209}
210
211/** The roster as the panel shows it: the standard crew first, then the pool. */
212export const ROSTER: readonly { character: Character; types: string }[] = [
213  { character: CHARACTERS.deckhand as Character, types: 'general-purpose' },
214  { character: CHARACTERS.scout as Character, types: 'Explore' },
215  { character: CHARACTERS.navigator as Character, types: 'Plan' },
216  { character: CHARACTERS.professor as Character, types: 'claude-code-guide' },
217  { character: CHARACTERS.signaller as Character, types: 'statusline-setup' },
218  { character: CHARACTERS.echo as Character, types: 'fork' },
219  ...POOL.map(k => ({ character: CHARACTERS[k] as Character, types: 'other agent types' })),
220]
221
222export const CAPTAIN = {
223  name: 'Captain Clawd',
224  role: 'main thread',
225  color: '#4A6FB5',
226  quips: {
227    start: ['All hands!'],
228    think: ['Charting the course'],
229    read: ['Reviewing {x}'],
230    search: ['Scanning the horizon: {x}'],
231    edit: ['Taking the wheel: {x}'],
232    shell: ['Full steam: {x}'],
233    web: ['Signalling ashore: {x}'],
234    delegate: ['Dispatching the crew: {x}'],
235    plan: ['Updating the log'],
236    other: ['At the helm'],
237  } as Record<Activity, readonly string[]>,
238  idle: 'Off watch, awaiting orders',
239}
240
241/** `savvy:helper` and `helper` name the same agent; the plugin prefix is dropped. */
242export const bareType = (type: string): string => type.replace(/^[^:]*:/, '')
243
244/** FNV-1a: a stable pick from a name, the same on every load. */
245export const hash = (s: string): number => {
246  let h = 0x811c9dc5
247  for (let i = 0; i < s.length; i++) {
248    h ^= s.charCodeAt(i)
249    h = Math.imul(h, 0x01000193)
250  }
251  return h >>> 0
252}
253
254export const characterKeyOf = (type: string): string => {
255  const known = BY_TYPE[type] ?? BY_TYPE[bareType(type)]
256  return known ?? POOL[hash(bareType(type)) % POOL.length] ?? 'deckhand'
257}
258
259export const characterOf = (key: string): Character => CHARACTERS[key] ?? (CHARACTERS.deckhand as Character)
260
261const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X']
262
263/** The next free name for this character: Pip, Wren, ..., then Pip II. */
264export const crewNameOf = (key: string, crew: readonly CrewMember[]): string => {
265  const { names } = characterOf(key)
266  const taken = crew.filter(m => m.character === key).length
267  const base = names[taken % names.length] ?? 'Crab'
268  const round = Math.floor(taken / names.length) + 1
269  return round > 1 ? `${base} ${ROMAN[round] ?? round}` : base
270}
271
272export const titleOf = (m: Pick<CrewMember, 'name' | 'character'>): string => `${m.name} the ${characterOf(m.character).role}`
273
274// ---------------------------------------------------------------------------
275// What a tool call says about a crab's activity.
276
277export const activityOf = (tool: string): Activity => {
278  if (/^(Read|NotebookRead)$/.test(tool)) return 'read'
279  if (/^(Grep|Glob|LS|ToolSearch)$/.test(tool)) return 'search'
280  if (/^(Edit|MultiEdit|Write|NotebookEdit)$/.test(tool)) return 'edit'
281  if (/^(Bash|PowerShell|Monitor|BashOutput)$/.test(tool)) return 'shell'
282  if (/^(WebFetch|WebSearch)$/.test(tool) || (tool.startsWith('mcp__') && /browser|chrome|fetch|web/i.test(tool))) return 'web'
283  if (/^(Agent|Task|SendMessage|Workflow)$/.test(tool)) return 'delegate'
284  if (/^(TodoWrite|TaskCreate|TaskUpdate|TaskList|TaskGet|EnterPlanMode|ExitPlanMode)$/.test(tool)) return 'plan'
285  return 'other'
286}
287
288const str = (v: unknown): string => (typeof v === 'string' ? v : '')
289
290const baseName = (p: string): string => p.split(/[\\/]/).filter(Boolean).pop() ?? p
291
292export const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s)
293
294/** The call's target in a few characters: a file's name, a pattern, a command's head, a host. */
295export const detailOf = (input: Record<string, unknown>): string => {
296  const file = str(input.file_path) || str(input.notebook_path)
297  if (file) return baseName(file)
298  if (str(input.pattern)) return clip(str(input.pattern), 28)
299  const command = str(input.command).trim().split('\n')[0] ?? ''
300  if (command) return clip(command, 28)
301  const url = str(input.url)
302  if (url) return /^[a-z]+:\/\/([^/?#]+)/i.exec(url)?.[1] ?? clip(url, 28)
303  if (str(input.query)) return clip(str(input.query), 28)
304  if (str(input.description)) return clip(str(input.description), 28)
305  if (str(input.skill)) return str(input.skill)
306  if (str(input.path)) return baseName(str(input.path))
307  return ''
308}
309
310type Todo = { status?: unknown; activeForm?: unknown; content?: unknown }
311
312/**
313 * Progress a standard agent reports without knowing it: its own todo list.
314 * TodoWrite carries the whole list; TaskCreate and TaskUpdate are counted.
315 */
316export const todoPatch = (tool: string, input: Record<string, unknown>, m: CrewMember): Partial<CrewMember> => {
317  if (tool === 'TodoWrite' && Array.isArray(input.todos)) {
318    const todos = input.todos as Todo[]
319    const done = todos.filter(t => t.status === 'completed').length
320    const active = todos.find(t => t.status === 'in_progress')
321    return {
322      todoTotal: todos.length,
323      todoDone: done + (active ? 0.5 : 0),
324      todoNote: active ? str(active.activeForm) || str(active.content) || undefined : undefined,
325    }
326  }
327  if (tool === 'TaskCreate') return { todoTotal: (m.todoTotal ?? 0) + 1, todoDone: m.todoDone ?? 0 }
328  if (tool === 'TaskUpdate') {
329    const total = m.todoTotal ?? 0
330    const done = Math.floor(m.todoDone ?? 0)
331    if (input.status === 'completed') return { todoDone: Math.min(total, done + 1), todoNote: undefined }
332    if (input.status === 'deleted') return { todoTotal: Math.max(0, total - 1), todoDone: Math.min(done, Math.max(0, total - 1)) }
333    if (input.status === 'in_progress') return { todoDone: done + 0.5, todoNote: str(input.activeForm) || str(input.subject) || undefined }
334  }
335  return {}
336}
337
338export const statusOf = (reason: string): CrewStatus =>
339  reason === 'answer' ? 'done' : reason === 'aborted' ? 'stopped' : 'failed'
340
341// ---------------------------------------------------------------------------
342// Progress, pose and lines.
343
344/** No call for this long while running: the crab waits, tapping a foot. */
345export const STALL_MS = 20_000
346/** A call this recent: the crab scurries. */
347export const BURST_MS = 3_000
348/** How long a crab that just finished celebrates. */
349export const CHEER_MS = 6_000
350
351export type Progress = { value: number; isEstimate: boolean }
352
353/**
354 * Real progress when the agent keeps a todo list; otherwise an estimate that
355 * climbs with its tool calls toward 92% at the character's usual pace and
356 * only reaches the flag when the agent is done.
357 */
358export const progressOf = (m: CrewMember): Progress => {
359  if (m.status === 'done') return { value: 1, isEstimate: false }
360  if (m.todoTotal) return { value: Math.min(1, (m.todoDone ?? 0) / m.todoTotal), isEstimate: false }
361  const pace = characterOf(m.character).pace
362  return { value: 0.92 * (1 - Math.exp(-(m.toolCalls + 0.5) / pace)), isEstimate: true }
363}
364
365export const progressLabel = (m: CrewMember): string => {
366  if (m.status === 'done') return '100%'
367  if (m.todoTotal) return `${Math.floor(m.todoDone ?? 0)}/${m.todoTotal} todos`
368  return `≈${Math.round(progressOf(m).value * 100)}%`
369}
370
371export const poseOf = (m: CrewMember, at: number): Pose => {
372  if (m.status === 'done') return at - (m.endedAt ?? 0) < CHEER_MS ? 'cheer' : 'rest'
373  if (m.status === 'failed') return 'dizzy'
374  if (m.status === 'stopped') return 'sleep'
375  if (m.activity === 'think') return 'think'
376  if (at - m.lastActiveAt > STALL_MS) return 'wait'
377  return at - m.lastActiveAt < BURST_MS ? 'fast' : 'walk'
378}
379
380export const moodOf = (m: CrewMember, at: number): Mood => {
381  if (m.status !== 'running') return m.status
382  return poseOf(m, at) === 'wait' ? 'wait' : m.activity
383}
384
385const fill = (template: string, detail: string): string => template.replace('{x}', clip(detail, 24) || 'the code')
386
387/** The crab's line: in character, and stable until it does something new. */
388export const quipOf = (m: CrewMember, at: number): string => {
389  const mood = moodOf(m, at)
390  const list = characterOf(m.character).quips[mood] ?? GENERIC[mood]
391  const i = hash(m.id + mood) + (m.status === 'running' ? m.toolCalls : 0)
392  return fill(list[i % list.length] ?? '', m.detail)
393}
394
395export const captainQuip = (c: Captain): string => {
396  if (!c.isBusy) return CAPTAIN.idle
397  const list = CAPTAIN.quips[c.activity] ?? CAPTAIN.quips.other
398  return fill(list[0] ?? '', c.detail)
399}
400
401/** What the crab is doing, plainly: the tool's target, or its todo in progress. */
402export const actionOf = (m: CrewMember, at: number): string => {
403  if (m.status === 'done') return 'back aboard'
404  if (m.status === 'failed') return 'went overboard'
405  if (m.status === 'stopped') return 'called back'
406  if (m.todoNote) return m.todoNote
407  if (m.activity === 'think') return 'thinking'
408  if (m.activity === 'start') return 'setting out'
409  const verb = { read: 'reading', search: 'searching', edit: 'editing', shell: 'running', web: 'browsing', delegate: 'delegating', plan: 'planning', other: 'working' }[m.activity]
410  const waited = at - m.lastActiveAt > STALL_MS ? ` · waiting ${fmtTime(at - m.lastActiveAt)}` : ''
411  return `${verb}${m.detail ? ' ' + m.detail : ''}${waited}`
412}
413
414// ---------------------------------------------------------------------------
415// Formatting.
416
417export const modelName = (id: string): string => {
418  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
419  const [, family = '', major = '', minor] = m ?? []
420  if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
421  return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
422}
423
424export const fmtTokens = (n: number): string =>
425  n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
426
427export const fmtTime = (ms: number): string => {
428  const s = Math.max(0, Math.round(ms / 1000))
429  const h = Math.floor(s / 3600)
430  const m = Math.floor((s % 3600) / 60)
431  const ss = String(s % 60).padStart(2, '0')
432  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
433}
434
435export const elapsed = (m: CrewMember, at: number): number => (m.endedAt ?? Math.max(at, m.startedAt)) - m.startedAt
436
437export const crewTotals = (crew: readonly CrewMember[], at: number) => {
438  const tokens = crew.reduce((s, m) => s + m.tokens, 0)
439  const start = Math.min(...crew.map(m => m.startedAt))
440  const end = Math.max(...crew.map(m => m.endedAt ?? Math.max(at, m.startedAt)))
441  return {
442    running: crew.filter(m => m.status === 'running').length,
443    done: crew.filter(m => m.status === 'done').length,
444    failed: crew.filter(m => m.status === 'failed').length,
445    stopped: crew.filter(m => m.status === 'stopped').length,
446    tokens,
447    time: crew.length ? end - start : 0,
448  }
449}
450
451export const crewSummary = (crew: readonly CrewMember[], at: number): string => {
452  const t = crewTotals(crew, at)
453  const parts = [
454    t.running ? `${t.running} at work` : '',
455    t.done ? `${t.done} back aboard` : '',
456    t.failed ? `${t.failed} overboard` : '',
457    t.stopped ? `${t.stopped} called back` : '',
458  ].filter(Boolean)
459  return parts.join(' · ') || 'no crew out'
460}
461
462/** Mean progress of the given crew, 0 to 1. */
463export const crewProgress = (crew: readonly CrewMember[]): number =>
464  crew.length ? crew.reduce((s, m) => s + (m.status === 'running' ? progressOf(m).value : 1), 0) / crew.length : 0
465
types/index.d.ts 62 lines
1export type CrewStatus = 'running' | 'done' | 'failed' | 'stopped'
2
3/** What a crab is doing right now, from the tool it last called. */
4export type Activity = 'start' | 'think' | 'read' | 'search' | 'edit' | 'shell' | 'web' | 'delegate' | 'plan' | 'other'
5
6export type CrewMember = {
7  id: string
8  agentId?: string
9  /** The agent type as spawned: `general-purpose`, `Explore`, `Plan`, a plugin's agent. */
10  type: string
11  /** Key into the character roster. */
12  character: string
13  /** The crab's given name, e.g. `Pip`. */
14  name: string
15  description: string
16  model: string
17  effort?: string
18  status: CrewStatus
19  startedAt: number
20  endedAt?: number
21  lastActiveAt: number
22  activity: Activity
23  /** The target of the last call: a file name, a pattern, a command. */
24  detail: string
25  toolCalls: number
26  errors: number
27  steps: number
28  contextTokens: number
29  tokens: number
30  /** From the agent's own TodoWrite / TaskCreate / TaskUpdate calls, when it makes any. */
31  todoDone?: number
32  todoTotal?: number
33  todoNote?: string
34}
35
36/** The main thread, drawn as the captain at the top of the panel. */
37export type Captain = {
38  isBusy: boolean
39  activity: Activity
40  detail: string
41  lastActiveAt: number
42}
43
44export type CrewPanel = {
45  isDoneCollapsed: boolean
46  isRosterOpen: boolean
47  hasAutoOpened: boolean
48  /** The band above the prompt shows crew started or ended after this time. */
49  bandSince: number
50}
51
52declare module 'claude-code' {
53  interface PluginState {
54    'crab-crew': {
55      crew: CrewMember[]
56      captain: Captain
57      panel: CrewPanel
58      now: number
59    }
60  }
61}
62