@g3nomaly/g3n-mods 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,9 @@
2
2
  import { atom, read, update } from 'claude-code'
3
3
  import type { EngineInterface, Register, RenderElement } from 'claude-code'
4
4
 
5
- import type { Crab, CrabSteps, PlannedItem } from '../types'
5
+ import type { Crab, CrabSteps, CrabsViewData, PlannedItem, Role } from '../types'
6
+ import { PALETTE, PIXEL, SPRITE_COLUMNS, SPRITE_ROWS, crabSvg, phaseOf } from './sprite'
7
+ import type { Pose } from './sprite'
6
8
 
7
9
  /** The agents-panel mod's pane, which this mod depends on and draws its crabs into. */
8
10
  const PANEL = 'agents-panel'
@@ -13,40 +15,45 @@ const ADDON_KEY = 'addon:'
13
15
  const crabs = atom({ plugin: 'custom-crab-agents', key: 'crabs' } as const, [])
14
16
  const planned = atom({ plugin: 'custom-crab-agents', key: 'planned' } as const, [])
15
17
  const contextWindow = atom({ plugin: 'custom-crab-agents', key: 'window' } as const, 1_000_000)
18
+ const chats = atom({ plugin: 'custom-crab-agents', key: 'chats' } as const, [])
19
+
20
+ /** Pane id prefix of a subagent's chat; the viewer opens a pane whose id starts with `tab-` in a tab of its own. */
21
+ const CHAT_PREFIX = 'tab-chat-'
22
+
23
+ /** Most of a subagent's conversation entries a chat draws, newest kept. */
24
+ const CHAT_ROWS = 300
16
25
 
17
26
  const KEEP = 30
18
- const PIXEL = 4
19
-
20
- /** Effort level → tier name, color and hat. */
21
- const TIERS: Readonly<Record<string, { name: string; color: string; hat: keyof typeof HATS }>> = {
22
- low: { name: 'light', color: '#3fa66b', hat: 'cap' },
23
- medium: { name: 'medium', color: '#4c8bf5', hat: 'sailor' },
24
- high: { name: 'careful', color: '#e0a030', hat: 'hardhat' },
25
- xhigh: { name: 'heavy', color: '#e05a4f', hat: 'beanie' },
26
- max: { name: 'heavy', color: '#e05a4f', hat: 'beanie' },
27
+
28
+ /** Effort level → tier name and the color of the crab's bar. */
29
+ const TIERS: Readonly<Record<string, { name: string; color: string }>> = {
30
+ low: { name: 'light', color: '#3fa66b' },
31
+ medium: { name: 'medium', color: '#4c8bf5' },
32
+ high: { name: 'careful', color: '#e0a030' },
33
+ xhigh: { name: 'heavy', color: '#e05a4f' },
34
+ max: { name: 'heavy', color: '#e05a4f' },
27
35
  }
28
- const DEFAULT_TIER = { name: 'medium', color: '#4c8bf5', hat: 'sailor' as const }
29
-
30
- /** Sprite rows, 12 pixels wide; each letter is a palette color, `.` is empty. */
31
- const BODY = [
32
- '...k....k...',
33
- 'C..b....b..C',
34
- 'CC.bbbbbb.CC',
35
- '.CbbbbbbbbC.',
36
- '..bbbbbbbb..',
37
- '..bbbbbbbb..',
38
- '..b.b..b.b..',
39
- '.b..b..b..b.',
36
+ const DEFAULT_TIER = { name: 'medium', color: '#4c8bf5' }
37
+
38
+ /** Words in an agent's title or type that give its role, tried in order; the first match wins. */
39
+ const ROLE_RULES: ReadonlyArray<[Role, RegExp]> = [
40
+ ['review', /review|audit|inspect|critic|check/i],
41
+ ['test', /test|\bqa\b|verif/i],
42
+ ['design', /design|artist|\bart\b|style|graphic|visual|\bui\b/i],
43
+ ['explore', /explor|research|search|scout|find/i],
44
+ ['plan', /plan|architect|\bspec/i],
45
+ ['build', /build|implement|\bcode|\bfix|refactor|general-purpose/i],
40
46
  ]
41
- const HATS = {
42
- cap: ['...gggg.....', '..gggggg....', '..ggggggggg.'],
43
- sailor: ['...wwwwww...', '..wwwwwwww..', '..uuuuuuuu..'],
44
- hardhat: ['....yyyy....', '...yyyyyy...', '.yyyyyyyyyy.'],
45
- beanie: ['.....t......', '...nnnnnn...', '..nnnnnnnn..'],
46
- } as const
47
- const PALETTE: Readonly<Record<string, string>> = {
48
- b: '#e07a5f', C: '#c8553d', k: '#1e1e1e',
49
- g: '#3fa66b', w: '#f0f0f0', u: '#4c8bf5', y: '#f2c14e', t: '#e8e8e8', n: '#7a4a2a',
47
+
48
+ /** What each role is called on its crab, and the emoji it gets in the terminal. */
49
+ const ROLES: Readonly<Record<Role, { name: string; emoji: string }>> = {
50
+ review: { name: 'reviewer', emoji: '🔍' },
51
+ test: { name: 'tester', emoji: '🔧' },
52
+ design: { name: 'designer', emoji: '🎩' },
53
+ explore: { name: 'explorer', emoji: '🧭' },
54
+ plan: { name: 'planner', emoji: '📜' },
55
+ build: { name: 'builder', emoji: '🔨' },
56
+ other: { name: 'helper', emoji: '' },
50
57
  }
51
58
 
52
59
  /** Price per million tokens: input, output, cache read, cache write. First match wins. */
@@ -60,6 +67,21 @@ const PRICES: ReadonlyArray<[RegExp, [number, number, number, number]]> = [
60
67
  [/haiku/, [1, 5, 0.1, 1.25]],
61
68
  ]
62
69
 
70
+ /**
71
+ * Finds an agent's role from its title, then its subagent type.
72
+ *
73
+ * @param title - What the agent was started to do, e.g. `review: read mod files`.
74
+ * @param subagentType - Its subagent type, e.g. `Explore`.
75
+ * @returns The first role whose words match, or `other`.
76
+ */
77
+ export function roleOf(title: string, subagentType: string): Role {
78
+ for (const text of [title, subagentType]) {
79
+ const found = ROLE_RULES.find(([, words]) => words.test(text))
80
+ if (found !== undefined) return found[0]
81
+ }
82
+ return 'other'
83
+ }
84
+
63
85
  /**
64
86
  * Looks up the tier of an effort level, falling back to the default tier.
65
87
  *
@@ -127,28 +149,6 @@ function minutes(ms: number): string {
127
149
  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
128
150
  }
129
151
 
130
- /**
131
- * Draws the crab as one SVG: hat rows for the tier on top of the body; a planned crab is drawn grey.
132
- *
133
- * @param hat - Hat to draw, a key of `HATS`.
134
- * @param isPlanned - Whether the crab is planned work, not a running or finished subagent.
135
- * @returns The SVG markup as a string.
136
- */
137
- function crabSvg(hat: keyof typeof HATS, isPlanned: boolean): string {
138
- const rows = [...HATS[hat], ...BODY]
139
- const rects: string[] = []
140
- rows.forEach((row, y) => {
141
- for (let x = 0; x < row.length; x++) {
142
- const color = PALETTE[row.charAt(x)]
143
- if (color !== undefined) rects.push(`<rect x="${x * PIXEL}" y="${y * PIXEL}" width="${PIXEL}" height="${PIXEL}" fill="${color}"/>`)
144
- }
145
- })
146
- const width = 12 * PIXEL
147
- const height = rows.length * PIXEL
148
- const filter = isPlanned ? ' opacity="0.45" style="filter:grayscale(1)"' : ''
149
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"${filter}>${rects.join('')}</svg>`
150
- }
151
-
152
152
  /**
153
153
  * Draws a progress bar as one SVG, filled to `share` of its width.
154
154
  *
@@ -350,7 +350,103 @@ async function readWindow($: EngineInterface): Promise<void> {
350
350
  }
351
351
 
352
352
  /**
353
- * Registers this mod's session, tool, subagent and turn hooks, and its section of the agents panel.
353
+ * @brief Works out what a crab's card says: its tier and role, how full its bar is, its step line and its stats.
354
+ *
355
+ * @param crab - Crab to describe, see `Crab`.
356
+ * @param window - The session's context window, in tokens.
357
+ * @param now - Current time, the end of a crab still running.
358
+ * @returns The card's facts.
359
+ */
360
+ function factsOf(crab: Crab, window: number, now: number) {
361
+ const tier = tierOf(crab.effort)
362
+ const role = roleOf(crab.title, crab.subagentType)
363
+ const elapsed = crab.durationMs ?? now - crab.startedAt
364
+ const tokens = crab.tokens ?? 0
365
+ const share = crab.steps.total > 0 ? crab.steps.done / crab.steps.total : crab.status === 'running' ? 0.08 + Math.min(0.8, crab.requests / 40) : 1
366
+ const stepLine = crab.steps.total > 0
367
+ ? `${crab.steps.done}/${crab.steps.total}${crab.steps.current === undefined ? '' : ` · ${crab.steps.current}`}`
368
+ : crab.status === 'running' ? `step ${crab.requests}` : `${crab.requests} step${crab.requests === 1 ? '' : 's'}`
369
+ const stats = [
370
+ tokens > 0 ? `ctx ${Math.max(1, Math.round((tokens / window) * 100))}%` : '',
371
+ tokens > 0 ? formatTokens(tokens) : '',
372
+ crab.costUsd === undefined ? '' : `≈$${crab.costUsd.toFixed(2)}`,
373
+ minutes(elapsed),
374
+ ].filter(part => part.length > 0).join(' · ')
375
+ return { tier, role, share, stepLine, stats }
376
+ }
377
+
378
+ /**
379
+ * Carries out an action the crabs' view sent: `openChat` with a crab's id.
380
+ *
381
+ * @param $ - Engine handle the action came through.
382
+ * @param value - The action as the view sent it, JSON of `{ name, arg }`.
383
+ */
384
+ async function act($: EngineInterface, value: string): Promise<void> {
385
+ let action: { name?: unknown; arg?: unknown }
386
+ try {
387
+ action = JSON.parse(value) as { name?: unknown; arg?: unknown }
388
+ } catch {
389
+ return
390
+ }
391
+ const arg = typeof action.arg === 'object' && action.arg !== null ? (action.arg as Record<string, unknown>) : {}
392
+ if (action.name === 'openChat' && typeof arg.id === 'string') await openChat($, arg.id)
393
+ }
394
+
395
+ /**
396
+ * Opens a crab's conversation as a pane of its own, which the viewer shows in a tab of its own.
397
+ *
398
+ * @param $ - Engine handle the press came through.
399
+ * @param crabId - `id` of the crab whose agent to show.
400
+ */
401
+ async function openChat($: EngineInterface, crabId: string): Promise<void> {
402
+ const crab = (await read($, crabs)).find(one => one.id === crabId)
403
+ if (crab?.agentId === undefined) {
404
+ $.ui.toast('This agent has no conversation yet; try again once it has started.')
405
+ return
406
+ }
407
+ const agentId = crab.agentId
408
+ const id = `${CHAT_PREFIX}${agentId.replace(/[^A-Za-z0-9_-]/g, '')}`.slice(0, 64)
409
+ await update($, chats, list => [...list.filter(chat => chat.id !== id), { id, agentId, title: crab.title }])
410
+ await $.ui.open({ id, title: `Chat · ${crab.title}`, focus: true })
411
+ }
412
+
413
+ /**
414
+ * Sends a message typed in a chat to its agent, which resumes a finished one.
415
+ *
416
+ * @param $ - Engine handle the submit came through.
417
+ * @param agentId - Agent the chat shows.
418
+ * @param text - What the person typed.
419
+ */
420
+ async function sendToAgent($: EngineInterface, agentId: string, text: string): Promise<void> {
421
+ const message = text.trim()
422
+ if (message === '') return
423
+ const sent = await $.session.send({ to: { agentId }, text: message }).catch((error: unknown) => ({ isDelivered: false as const, reason: String(error) }))
424
+ if (!sent.isDelivered) {
425
+ $.ui.toast(`Not delivered: ${sent.reason ?? 'the agent could not be reached'}`)
426
+ return
427
+ }
428
+ await patchCrab($, crab => crab.agentId === agentId, crab => ({ ...crab, status: 'running', durationMs: undefined }))
429
+ $.ui.invalidate('ui.render')
430
+ }
431
+
432
+ /**
433
+ * Shortens a tool call to the text its one line in a chat shows: a command, a file's name, a pattern.
434
+ *
435
+ * @param input - Arguments of the tool call.
436
+ * @returns The label with whitespace collapsed, at most 200 characters.
437
+ */
438
+ function toolLabel(input: Record<string, unknown>): string {
439
+ for (const key of ['command', 'file_path', 'pattern', 'url', 'query', 'description', 'prompt']) {
440
+ const value = input[key]
441
+ if (typeof value !== 'string' || value === '') continue
442
+ const text = key === 'file_path' ? value.split('/').pop() ?? value : value
443
+ return text.replace(/\s+/g, ' ').trim().slice(0, 200)
444
+ }
445
+ return ''
446
+ }
447
+
448
+ /**
449
+ * Registers this mod's session, tool, subagent and turn hooks, its section of the agents panel, and the agents' chats.
354
450
  *
355
451
  * @param on - Hook registration function the engine passes in.
356
452
  */
@@ -406,10 +502,85 @@ export const register: Register = on => {
406
502
  return result
407
503
  }).catch(($, e, next) => next(e))
408
504
 
505
+ // Forgets a chat once its pane is closed.
506
+ on('ui.close', async ($, e, next) => {
507
+ if (e.id.startsWith(CHAT_PREFIX)) await update($, chats, list => list.filter(chat => chat.id !== e.id))
508
+ return next(e)
509
+ }).catch(($, e, next) => next(e))
510
+
511
+ // A subagent's chat: its whole conversation, each tool call on one line, and a box to message it.
512
+ on('ui.render', { component: 'Pane' }, async ($, e, next) => {
513
+ const chat = (await read($, chats)).find(one => one.id === e.requestId)
514
+ if (chat === undefined) return next(e)
515
+ const { Box, Text, Markdown } = $.ui.resolve(e)
516
+ const crab = (await read($, crabs)).find(one => one.agentId === chat.agentId)
517
+ const found = await $.session.messages({ agentId: chat.agentId })
518
+ const role = roleOf(chat.title, crab?.subagentType ?? '')
519
+ const status = crab === undefined ? '' : crab.status === 'running' ? '● running' : crab.status === 'failed' ? '✗ failed' : '✓ finished'
520
+
521
+ const header = (
522
+ <Box flexDirection="row" columnGap={1}>
523
+ <Text bold wrap="truncate-end">{chat.title}</Text>
524
+ <Text dimColor wrap="truncate-end">{ROLES[role].name}{crab === undefined ? '' : ` · ${modelName(crab.model)} · ${status}`}</Text>
525
+ </Box>
526
+ )
527
+ if ('deny' in found) return <Box flexDirection="column" gap={1}>{header}<Text dimColor>{found.deny}</Text></Box>
528
+
529
+ const rows = found.slice(-CHAT_ROWS).flatMap((message, index) => {
530
+ const parts = []
531
+ if (message.role === 'user' && message.text.trim() !== '') {
532
+ parts.push(
533
+ <Box key={`m${index}`} borderStyle="round" borderColor="#3a3f4b" backgroundColor="#24272e" paddingX={1} marginTop={1}>
534
+ <Text wrap="wrap">{message.text}</Text>
535
+ </Box>,
536
+ )
537
+ }
538
+ if (message.role === 'assistant' && message.text.trim() !== '') {
539
+ parts.push(<Box key={`m${index}`} marginTop={1}><Markdown text={message.text} /></Box>)
540
+ }
541
+ message.toolUses.forEach((use, n) => {
542
+ parts.push(
543
+ <Box key={`t${index}:${n}`} flexDirection="row" columnGap={1}>
544
+ <Text color={use.isError === true ? '#e06c6c' : '#7cc47f'}>⏺</Text>
545
+ <Text wrap="truncate-end">{use.tool} <Text dimColor>{toolLabel(use.input)}</Text></Text>
546
+ </Box>,
547
+ )
548
+ })
549
+ return parts
550
+ })
551
+
552
+ const working = crab?.status === 'running'
553
+ ? <Box marginTop={1}><Text color="#d97757">✻ Working… <Text dimColor>{crab.steps.current ?? `step ${crab.requests}`}</Text></Text></Box>
554
+ : ''
555
+
556
+ const input = e.surface === 'mobile'
557
+ ? ''
558
+ : (() => {
559
+ const { Input } = $.ui.resolve(e)
560
+ return (
561
+ <Input
562
+ key="send"
563
+ placeholder={crab?.status === 'running' ? `Message ${chat.title}… it reads it after its current step` : `Reply to ${chat.title}…`}
564
+ submitLabel="Send"
565
+ onSubmit={value => sendToAgent($, chat.agentId, value)}
566
+ />
567
+ )
568
+ })()
569
+
570
+ return (
571
+ <Box flexDirection="column">
572
+ {header}
573
+ {rows.length === 0 ? <Text dimColor>Nothing in this conversation yet.</Text> : rows}
574
+ {working}
575
+ <Box marginTop={1}>{input}</Box>
576
+ </Box>
577
+ )
578
+ })
579
+
409
580
  // Draws into the agents panel: under the panel's tree when the panel's hook lies beneath this one, or alone with an
410
581
  // `addon:` key that the panel's hook picks up when it lies above. Either way the crabs end up under the agents.
411
582
  on('ui.render', { component: 'Pane', requestId: PANEL }, async ($, e, next) => {
412
- const { Box, Text } = $.ui.resolve(e)
583
+ const { Box, Text, Button } = $.ui.resolve(e)
413
584
  const below: RenderElement | undefined = await next(e).catch(() => undefined)
414
585
  const panel = below !== undefined && holdsKey(below, PANEL_KEY) ? below : undefined
415
586
  /** Puts the crabs' section under the panel's tree, when it was drawn beneath. */
@@ -419,6 +590,40 @@ export const register: Register = on => {
419
590
  const plan = await read($, planned)
420
591
  const window = await read($, contextWindow)
421
592
  const now = await $.clock.now()
593
+
594
+ // The viewer draws the crabs with the mod's own view: hand it the cards as data, inside the key the panel looks for.
595
+ if (e.surface === 'vscode') {
596
+ const { Input } = $.ui.resolve(e)
597
+ const data: CrabsViewData = {
598
+ crabs: list.map(crab => {
599
+ const facts = factsOf(crab, window, now)
600
+ return {
601
+ id: crab.id,
602
+ title: crab.title,
603
+ role: facts.role,
604
+ roleName: ROLES[facts.role].name,
605
+ color: facts.tier.color,
606
+ model: modelName(crab.model),
607
+ ...(crab.effort === undefined ? {} : { effort: crab.effort }),
608
+ status: crab.status,
609
+ stepLine: facts.stepLine,
610
+ stats: facts.stats,
611
+ share: facts.share,
612
+ phase: phaseOf(crab.id),
613
+ }
614
+ }),
615
+ planned: plan.map(item => ({ index: item.index, content: item.content, after: item.after, role: roleOf(item.content, '') })),
616
+ }
617
+ return joined(
618
+ <Box key={`${ADDON_KEY}crabs`}>
619
+ <Box key="g3n-view:custom-crab-agents">
620
+ <Text>{JSON.stringify(data)}</Text>
621
+ <Input key="g3n-act:custom-crab-agents" onSubmit={value => act($, value)} />
622
+ </Box>
623
+ </Box>,
624
+ )
625
+ }
626
+
422
627
  if (list.length === 0 && plan.length === 0) {
423
628
  return joined(<Box key={`${ADDON_KEY}crabs`}><Text dimColor>No subagents yet. Each one shows up here as a crab.</Text></Box>)
424
629
  }
@@ -427,14 +632,15 @@ export const register: Register = on => {
427
632
  /**
428
633
  * Draws a crab sprite: an emoji in the terminal, an SVG elsewhere.
429
634
  *
430
- * @param hat - Hat to draw, a key of `HATS`.
431
- * @param isPlanned - Whether the crab is planned work.
635
+ * @param role - Role whose hat and item to draw.
636
+ * @param pose - Walking while the agent runs, standing once done, grey while planned.
637
+ * @param id - The crab's id, which offsets its animations from its neighbours'.
432
638
  * @returns The sprite element.
433
639
  */
434
- const sprite = (hat: keyof typeof HATS, isPlanned: boolean) => {
435
- if (e.surface === 'terminal') return <Text color={isPlanned ? '#6b7280' : PALETTE.b}>🦀</Text>
640
+ const sprite = (role: Role, pose: Pose, id: string) => {
641
+ if (e.surface === 'terminal') return <Text color={pose === 'planned' ? '#6b7280' : PALETTE.b}>{ROLES[role].emoji}🦀</Text>
436
642
  const { Svg } = $.ui.resolve(e)
437
- return <Svg source={crabSvg(hat, isPlanned)} alt="crab" width={12 * PIXEL} height={11 * PIXEL} />
643
+ return <Svg source={crabSvg(role, pose, phaseOf(id))} alt={`${ROLES[role].name} crab`} width={SPRITE_COLUMNS * PIXEL} height={SPRITE_ROWS * PIXEL} />
438
644
  }
439
645
  /**
440
646
  * Draws a progress bar for a crab.
@@ -460,32 +666,29 @@ export const register: Register = on => {
460
666
  * @returns The card element.
461
667
  */
462
668
  const card = (crab: Crab) => {
463
- const tier = tierOf(crab.effort)
464
- const elapsed = crab.durationMs ?? now - crab.startedAt
465
- const tokens = crab.tokens ?? 0
466
- const share = crab.steps.total > 0 ? crab.steps.done / crab.steps.total : crab.status === 'running' ? 0.08 + Math.min(0.8, crab.requests / 40) : 1
467
- const stepLine = crab.steps.total > 0
468
- ? `${crab.steps.done}/${crab.steps.total}${crab.steps.current === undefined ? '' : ` · ${crab.steps.current}`}`
469
- : crab.status === 'running' ? `step ${crab.requests}` : `${crab.requests} step${crab.requests === 1 ? '' : 's'}`
470
- const stats = [
471
- tokens > 0 ? `ctx ${Math.max(1, Math.round((tokens / window) * 100))}%` : '',
472
- tokens > 0 ? formatTokens(tokens) : '',
473
- crab.costUsd === undefined ? '' : `≈$${crab.costUsd.toFixed(2)}`,
474
- minutes(elapsed),
475
- ].filter(part => part.length > 0).join(' · ')
669
+ const { tier, role, share, stepLine, stats } = factsOf(crab, window, now)
476
670
  const mark = crab.status === 'running' ? <Text color="#2f6b4a">●</Text>
477
671
  : crab.status === 'failed' ? <Text color="#e06c6c">✗</Text>
478
672
  : <Text color="#7cc47f">✓</Text>
673
+ // The whole card opens the agent's chat: a Button holds only text, so a plain one covers the card. The terminal,
674
+ // where a cover would hide the card, gets a line of its own instead.
675
+ const opener = e.surface === 'terminal'
676
+ ? <Button key={`chat:${crab.id}`} label="open chat" onPress={() => openChat($, crab.id)} />
677
+ : (
678
+ <Box position="absolute" top={0} left={0} right={0} bottom={0}>
679
+ <Button key={`chat:${crab.id}`} plain label="Open chat ↗" onPress={() => openChat($, crab.id)} />
680
+ </Box>
681
+ )
479
682
  return (
480
- <Box flexDirection="row" columnGap={2} paddingY={1}>
481
- {sprite(tier.hat, false)}
683
+ <Box key={`crab:${crab.id}`} flexDirection="row" columnGap={2} paddingY={1} paddingX={1}>
684
+ {sprite(role, crab.status === 'running' ? 'running' : 'idle', crab.id)}
482
685
  <Box flexDirection="column" flexGrow={1}>
483
686
  <Box flexDirection="row" justifyContent="space-between">
484
687
  <Text bold wrap="truncate-end">{crab.title}</Text>
485
688
  {mark}
486
689
  </Box>
487
690
  <Text>
488
- <Text color={tier.color}>{tier.name}</Text>
691
+ <Text color={tier.color}>{ROLES[role].name}</Text>
489
692
  <Text dimColor> {modelName(crab.model)}{crab.effort === undefined ? '' : ` · ${crab.effort}`}</Text>
490
693
  </Text>
491
694
  <Box flexDirection="row" justifyContent="space-between">
@@ -493,7 +696,9 @@ export const register: Register = on => {
493
696
  <Text dimColor>{stats}</Text>
494
697
  </Box>
495
698
  {progress(share, tier.color)}
699
+ {e.surface === 'terminal' ? opener : ''}
496
700
  </Box>
701
+ {e.surface === 'terminal' ? '' : opener}
497
702
  </Box>
498
703
  )
499
704
  }
@@ -510,7 +715,7 @@ export const register: Register = on => {
510
715
  {plan.length > 0 && <Text dimColor>Planned · {plan.length}</Text>}
511
716
  {plan.map(item => (
512
717
  <Box flexDirection="row" columnGap={2} paddingY={1}>
513
- {sprite('beanie', true)}
718
+ {sprite(roleOf(item.content, ''), 'planned', String(item.index))}
514
719
  <Box flexDirection="column" flexGrow={1}>
515
720
  <Box flexDirection="row" justifyContent="space-between">
516
721
  <Text bold dimColor wrap="truncate-end">{item.index}. {item.content}</Text>
@@ -0,0 +1,131 @@
1
+ // packages/g3n-mods/mods/custom-crab-agents/hooks/sprite.ts
2
+ import type { Role } from '../types'
3
+
4
+ /**
5
+ * The crab sprite, shared by the hooks module, which draws it as an Svg on surfaces without the mod's view, and by
6
+ * the view in `view/view.ts`. Plain data and string building only, so both can import it.
7
+ */
8
+
9
+ /** Size of one sprite pixel, in CSS pixels. */
10
+ export const PIXEL = 4
11
+
12
+ /**
13
+ * The body: Claude's mascot as the Claude Code logo draws it, 16 pixels wide. A wide body, two dark eye slits,
14
+ * arms out to both sides, four short legs. Each letter is a palette color, `.` is empty.
15
+ */
16
+ export const BODY = [
17
+ '..bbbbbbbbbbbb..',
18
+ '..bbkbbbbbbkbb..',
19
+ '..bbkbbbbbbkbb..',
20
+ 'CCbbbbbbbbbbbbCC',
21
+ '..bbbbbbbbbbbb..',
22
+ '...b.b....b.b...',
23
+ '...b.b....b.b...',
24
+ ]
25
+
26
+ /** Each role's hat, four rows over the body, 12 pixels wide and centred on it: an inspector's deerstalker, a work helmet, a top hat, and so on. */
27
+ export const HATS: Readonly<Record<Role, readonly string[]>> = {
28
+ review: ['............', '....hhhh....', '...hHhHhh...', '.hhhhhhhhhh.'],
29
+ test: ['.....aa.....', '...yyyyyy...', '..yyyyyyyy..', '.yyyyyyyyyy.'],
30
+ design: ['...tttttt...', '...tttttt...', '...vvvvvv...', '.tttttttttt.'],
31
+ explore: ['............', '....pppp....', '...pppppp...', '.pppppppppp.'],
32
+ plan: ['............', '.mmmmmmmmmmq', '...mmmmmm..q', '...mmmmmm...'],
33
+ build: ['............', '...gggg.....', '..gggggg....', '..ggggggggg.'],
34
+ other: ['............', '...wwwwww...', '..wwwwwwww..', '..uuuuuuuu..'],
35
+ }
36
+
37
+ /** What each role holds in its right arm, four pixels wide beside the body, by sprite row; the arm is on row 7. */
38
+ export const PROPS: Readonly<Record<Role, Readonly<Record<number, string>>>> = {
39
+ review: { 3: '.oo.', 4: 'oLLo', 5: 'oLLo', 6: '.oo.', 7: 's...' },
40
+ test: { 3: '.w.w', 4: '.www', 5: '..w.', 6: '.w..', 7: 'w...' },
41
+ design: { 2: '...P', 3: '..P.', 4: '..f.', 5: '.s..', 6: '.s..', 7: 's...' },
42
+ explore: { 2: '.rrr', 3: '.rrr', 4: '.s..', 5: '.s..', 6: '.s..', 7: 'ss..' },
43
+ plan: { 5: 'eee.', 6: 'eEe.', 7: 'eee.' },
44
+ build: { 3: 'mmm.', 4: 'mmm.', 5: '.s..', 6: '.s..', 7: 's...' },
45
+ other: {},
46
+ }
47
+
48
+ /** Size of the whole sprite in pixels: the 16-pixel body and the 4-pixel item it holds, under a 4-row hat. */
49
+ export const SPRITE_COLUMNS = 20
50
+ export const SPRITE_ROWS = 11
51
+
52
+ export const PALETTE: Readonly<Record<string, string>> = {
53
+ b: '#d97757', C: '#c8613f', k: '#1e1e1e',
54
+ g: '#3fa66b', w: '#f0f0f0', u: '#4c8bf5', y: '#f2c14e', a: '#fff3b0',
55
+ h: '#8b6b3e', H: '#c9a46a', t: '#22252b', v: '#9b59d0', p: '#d8c38f',
56
+ m: '#2f3542', q: '#f2c14e', o: '#c9d1d9', L: '#9fd3e6', s: '#7a4a2a',
57
+ P: '#4c8bf5', f: '#c9d1d9', r: '#e05a4f', e: '#efe6c8', E: '#b8a77a',
58
+ }
59
+
60
+ /** The sprite rows of the legs, below the hat and the body above them, and the second leg pose of a walking crab. */
61
+ const LEG_ROWS = [9, 10]
62
+ const STEP_LEGS = '..b...b..b...b..'
63
+
64
+ /** The sprite cells of the eyes, as column and row, which close for a moment when the crab blinks. */
65
+ const EYES: ReadonlyArray<[number, number]> = [[4, 5], [4, 6], [11, 5], [11, 6]]
66
+
67
+ /** How a crab is drawn: walking while its agent runs, standing and blinking once done, grey while only planned. */
68
+ export type Pose = 'running' | 'idle' | 'planned'
69
+
70
+ /**
71
+ * Turns an id into a start offset for the crab's animations, so crabs side by side do not move in step.
72
+ *
73
+ * @param id - The crab's id, e.g. its Agent call's tool_use_id.
74
+ * @returns An offset in seconds, from 0 up to 4.
75
+ */
76
+ export function phaseOf(id: string): number {
77
+ let hash = 0
78
+ for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0
79
+ return (hash % 4000) / 1000
80
+ }
81
+
82
+ /**
83
+ * Draws pixels as SVG rects, one per colored cell.
84
+ *
85
+ * @param rows - Sprite rows; each letter is a `PALETTE` color, `.` is empty.
86
+ * @param only - The row numbers to draw; all when absent.
87
+ * @returns The rects as markup.
88
+ */
89
+ function pixels(rows: readonly string[], only?: readonly number[]): string {
90
+ const rects: string[] = []
91
+ rows.forEach((row, y) => {
92
+ if (only !== undefined && !only.includes(y)) return
93
+ for (let x = 0; x < row.length; x++) {
94
+ const color = PALETTE[row.charAt(x)]
95
+ if (color !== undefined) rects.push(`<rect x="${x * PIXEL}" y="${y * PIXEL}" width="${PIXEL}" height="${PIXEL}" fill="${color}"/>`)
96
+ }
97
+ })
98
+ return rects.join('')
99
+ }
100
+
101
+ /**
102
+ * @brief Draws the crab as one SVG: its role's hat over the body and the item it holds. A running crab bobs and walks,
103
+ * every crab but a planned one blinks now and then, and a planned crab is drawn grey and still.
104
+ *
105
+ * The motion is the SVG's own animation (SMIL), so it needs nothing from the surface drawing it.
106
+ *
107
+ * @param role - Role whose hat and item to draw.
108
+ * @param pose - Walking, standing or planned.
109
+ * @param phase - Seconds into its animations the crab starts, see `phaseOf`.
110
+ * @returns The SVG markup as a string.
111
+ */
112
+ export function crabSvg(role: Role, pose: Pose, phase = 0): string {
113
+ const rows = [...HATS[role].map(row => `..${row}..`), ...BODY].map((row, y) => row + (PROPS[role][y] ?? '....'))
114
+ const width = SPRITE_COLUMNS * PIXEL
115
+ const height = SPRITE_ROWS * PIXEL
116
+ // crispEdges keeps the pixels sharp, with no seams between them, while the crab bobs.
117
+ const open = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" shape-rendering="crispEdges"`
118
+ if (pose === 'planned') return `${open} opacity="0.45" style="filter:grayscale(1)">${pixels(rows)}</svg>`
119
+
120
+ const begin = `begin="-${phase.toFixed(2)}s"`
121
+ const above = rows.map((_, y) => y).filter(y => !LEG_ROWS.includes(y))
122
+ const eyelids = EYES.map(([x, y]) => `<rect x="${x * PIXEL}" y="${y * PIXEL}" width="${PIXEL}" height="${PIXEL}" fill="${PALETTE.b}"/>`).join('')
123
+ const blink = `<g opacity="0">${eyelids}<animate attributeName="opacity" values="0;1;0" keyTimes="0;0.94;0.97" calcMode="discrete" dur="${pose === 'running' ? 3.2 : 4.6}s" repeatCount="indefinite" ${begin}/></g>`
124
+ if (pose === 'idle') return `${open}>${pixels(rows)}${blink}</svg>`
125
+
126
+ // Running: the body bobs half a pixel and the legs swap between two poses, as a walk.
127
+ const stepped = rows.map((row, y) => (LEG_ROWS.includes(y) ? STEP_LEGS + row.slice(STEP_LEGS.length) : row))
128
+ const swap = (from: number) => `<animate attributeName="opacity" values="${from};${1 - from}" calcMode="discrete" dur="0.5s" repeatCount="indefinite" ${begin}/>`
129
+ const bob = `<animateTransform attributeName="transform" type="translate" values="0 0;0 -2;0 0" dur="0.5s" repeatCount="indefinite" ${begin}/>`
130
+ return `${open}><g>${pixels(rows, above)}${blink}${bob}</g><g>${pixels(rows, LEG_ROWS)}${swap(1)}</g><g opacity="0">${pixels(stepped, LEG_ROWS)}${swap(0)}</g></svg>`
131
+ }
@@ -38,8 +38,54 @@ export type PlannedItem = {
38
38
  after: number[]
39
39
  }
40
40
 
41
+ /** What an agent does, read from its title or type; it picks the crab's hat and the item it holds. */
42
+ export type Role = 'review' | 'test' | 'design' | 'explore' | 'plan' | 'build' | 'other'
43
+
44
+ /** A subagent's conversation opened as a pane of its own. */
45
+ export type ChatPane = {
46
+ /** Pane id, `tab-chat-` and the agent's id */
47
+ id: string
48
+ agentId: string
49
+ title: string
50
+ }
51
+
52
+ /** One crab as the view draws it. */
53
+ export type CrabView = {
54
+ id: string
55
+ title: string
56
+ role: Role
57
+ roleName: string
58
+ /** The bar's color, from the effort tier */
59
+ color: string
60
+ model: string
61
+ effort?: string
62
+ status: 'running' | 'finished' | 'failed'
63
+ /** e.g. `3/5 · Writing hero copy`, or `step 4` */
64
+ stepLine: string
65
+ /** e.g. `ctx 4% · 38k · ≈$0.05 · 0:26` */
66
+ stats: string
67
+ /** Filled part of the bar, from 0 to 1 */
68
+ share: number
69
+ /** Seconds into its animations the crab starts, so neighbours do not move in step */
70
+ phase: number
71
+ }
72
+
73
+ /** One planned item as the view draws it: a grey crab. */
74
+ export type PlannedView = {
75
+ index: number
76
+ content: string
77
+ after: number[]
78
+ role: Role
79
+ }
80
+
81
+ /** What the crabs' view gets. */
82
+ export type CrabsViewData = {
83
+ crabs: CrabView[]
84
+ planned: PlannedView[]
85
+ }
86
+
41
87
  declare module 'claude-code' {
42
88
  interface PluginState {
43
- 'custom-crab-agents': { crabs: Crab[]; planned: PlannedItem[]; window: number }
89
+ 'custom-crab-agents': { crabs: Crab[]; planned: PlannedItem[]; window: number; chats: ChatPane[] }
44
90
  }
45
91
  }