@g3nomaly/g3n-mods 0.1.4 → 0.1.5

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.
package/README.md CHANGED
@@ -22,9 +22,9 @@ Then open the project in VS Code and trust the workspace. New sessions load the
22
22
  |---|---|---|
23
23
  | `agents-panel` | The agents in `.claude/agents` with their model and a run button. A running agent shows a spinner, and a toast says when it starts and finishes | `/agents-panel` |
24
24
  | `context-bar` | One bar of context-window use split by source, with the total and where compaction starts | `/context-bar` |
25
- | `custom-crab-agents` | Adds each subagent to the agents panel as a crab with a hat: effort tier, model, steps, context, tokens, cost and time. Needs `agents-panel`, which installing it adds | `/agents-panel` |
25
+ | `custom-crab-agents` | Adds each subagent to the agents panel as a crab: its role (from its name or type) picks its hat and what it holds, such as a reviewer's deerstalker and magnifying glass, a tester's work helmet and wrench, a designer's top hat and brush. Shows model, effort, steps, context, tokens, cost and time. Click a crab to open its whole conversation in a tab and message it there. Needs `agents-panel`, which installing it adds | `/agents-panel` |
26
26
  | `delete-guard` | Off by default: deletes run unasked. Turn it on with the switch in its pane, or `/delete-guard on`; it stays on in later sessions. While on, `rm -rf` and other permanent deletes wait on a card listing the files, their sizes and whether git can restore them, until you press **Delete** or **Cancel**. Cancel tells Claude which trash command to use instead. With nothing to draw the card on, the delete is refused | `/delete-guard on \| off` |
27
- | `focus-mode` | A timed focus block in which you're away: Claude's questions are refused, so it picks for itself and says what it assumed, and permission prompts are refused, so it works around the step and lists it for later. Countdown, progress bar and end button. 25 minutes unless you pass a number | `/focus-mode [minutes] \| end` |
27
+ | `focus-mode` | A timed focus block in which you're away: Claude's questions are refused, so it picks for itself and says what it assumed, and permission prompts are refused, so it works around the step and lists it for later. Countdown, progress bar and end button. Set the length with − and + in the pane, or pass the minutes; the length you pick is kept for later blocks | `/focus-mode [minutes] \| end` |
28
28
  | `handoff` | Writes a handoff with the `handoff` skill, clears the session, and attaches the handoff to your next prompt | `/handoff-new [what the next session is for]` |
29
29
  | `hide-personal-data` | Masks names, emails, phone numbers, IBANs and card numbers in replies. Hover a mask to read it | `/hide-personal-data on \| off` |
30
30
  | `mini-game` | Minesweeper, to play while Claude works | `/mini-game` |
@@ -35,7 +35,7 @@ For `agents-panel`, `context-bar` and `tool-timeline`, the command opens the pan
35
35
 
36
36
  Three mods take settings, declared under `userConfig` in their `.claude-plugin/plugin.json`:
37
37
 
38
- - `focus-mode`: `minutes`, the default block length (25).
38
+ - `focus-mode`: `minutes`, the block length before you pick one (25).
39
39
  - `hide-personal-data`: `names`, extra names to always mask, separated by commas.
40
40
  - `you-should-know`: `minChars`, the shortest reply that gets a card (1500), and `model`, the model that picks the key point (`haiku`).
41
41
 
@@ -2,7 +2,7 @@
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, PlannedItem, Role } from '../types'
6
6
 
7
7
  /** The agents-panel mod's pane, which this mod depends on and draws its crabs into. */
8
8
  const PANEL = 'agents-panel'
@@ -13,19 +13,47 @@ const ADDON_KEY = 'addon:'
13
13
  const crabs = atom({ plugin: 'custom-crab-agents', key: 'crabs' } as const, [])
14
14
  const planned = atom({ plugin: 'custom-crab-agents', key: 'planned' } as const, [])
15
15
  const contextWindow = atom({ plugin: 'custom-crab-agents', key: 'window' } as const, 1_000_000)
16
+ const chats = atom({ plugin: 'custom-crab-agents', key: 'chats' } as const, [])
17
+
18
+ /** Pane id prefix of a subagent's chat; the viewer opens a pane whose id starts with `tab-` in a tab of its own. */
19
+ const CHAT_PREFIX = 'tab-chat-'
20
+
21
+ /** Most of a subagent's conversation entries a chat draws, newest kept. */
22
+ const CHAT_ROWS = 300
16
23
 
17
24
  const KEEP = 30
18
25
  const PIXEL = 4
19
26
 
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
+ /** Effort level → tier name and the color of the crab's bar. */
28
+ const TIERS: Readonly<Record<string, { name: string; color: string }>> = {
29
+ low: { name: 'light', color: '#3fa66b' },
30
+ medium: { name: 'medium', color: '#4c8bf5' },
31
+ high: { name: 'careful', color: '#e0a030' },
32
+ xhigh: { name: 'heavy', color: '#e05a4f' },
33
+ max: { name: 'heavy', color: '#e05a4f' },
34
+ }
35
+ const DEFAULT_TIER = { name: 'medium', color: '#4c8bf5' }
36
+
37
+ /** Words in an agent's title or type that give its role, tried in order; the first match wins. */
38
+ const ROLE_RULES: ReadonlyArray<[Role, RegExp]> = [
39
+ ['review', /review|audit|inspect|critic|check/i],
40
+ ['test', /test|\bqa\b|verif/i],
41
+ ['design', /design|artist|\bart\b|style|graphic|visual|\bui\b/i],
42
+ ['explore', /explor|research|search|scout|find/i],
43
+ ['plan', /plan|architect|\bspec/i],
44
+ ['build', /build|implement|\bcode|\bfix|refactor|general-purpose/i],
45
+ ]
46
+
47
+ /** What each role is called on its crab, and the emoji it gets in the terminal. */
48
+ const ROLES: Readonly<Record<Role, { name: string; emoji: string }>> = {
49
+ review: { name: 'reviewer', emoji: '🔍' },
50
+ test: { name: 'tester', emoji: '🔧' },
51
+ design: { name: 'designer', emoji: '🎩' },
52
+ explore: { name: 'explorer', emoji: '🧭' },
53
+ plan: { name: 'planner', emoji: '📜' },
54
+ build: { name: 'builder', emoji: '🔨' },
55
+ other: { name: 'helper', emoji: '' },
27
56
  }
28
- const DEFAULT_TIER = { name: 'medium', color: '#4c8bf5', hat: 'sailor' as const }
29
57
 
30
58
  /** Sprite rows, 12 pixels wide; each letter is a palette color, `.` is empty. */
31
59
  const BODY = [
@@ -38,15 +66,37 @@ const BODY = [
38
66
  '..b.b..b.b..',
39
67
  '.b..b..b..b.',
40
68
  ]
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
69
+ /** Each role's hat, four rows over the body: an inspector's deerstalker, a work helmet, a top hat, and so on. */
70
+ const HATS: Readonly<Record<Role, readonly string[]>> = {
71
+ review: ['............', '....hhhh....', '...hHhHhh...', '.hhhhhhhhhh.'],
72
+ test: ['.....aa.....', '...yyyyyy...', '..yyyyyyyy..', '.yyyyyyyyyy.'],
73
+ design: ['...tttttt...', '...tttttt...', '...vvvvvv...', '.tttttttttt.'],
74
+ explore: ['............', '....pppp....', '...pppppp...', '.pppppppppp.'],
75
+ plan: ['............', '.mmmmmmmmmmq', '...mmmmmm..q', '...mmmmmm...'],
76
+ build: ['............', '...gggg.....', '..gggggg....', '..ggggggggg.'],
77
+ other: ['............', '...wwwwww...', '..wwwwwwww..', '..uuuuuuuu..'],
78
+ }
79
+
80
+ /** What each role holds in its right claw, four pixels wide beside the body, by sprite row. */
81
+ const PROPS: Readonly<Record<Role, Readonly<Record<number, string>>>> = {
82
+ review: { 3: '.oo.', 4: 'oLLo', 5: 'oLLo', 6: '.oo.', 7: 's...' },
83
+ test: { 4: 'w.w.', 5: 'www.', 6: '.w..', 7: '.w..', 8: '.w..' },
84
+ design: { 2: '..P.', 3: '..P.', 4: '..f.', 5: '.s..', 6: '.s..', 7: 's...' },
85
+ explore: { 2: '.rrr', 3: '.rrr', 4: '.s..', 5: '.s..', 6: '.s..', 7: '.s..' },
86
+ plan: { 5: 'eee.', 6: 'eEe.', 7: 'eee.' },
87
+ build: { 3: 'mmm.', 4: 'mmm.', 5: '.s..', 6: '.s..', 7: '.s..' },
88
+ other: {},
89
+ }
90
+
91
+ /** Width of the whole sprite in pixels: the 12-pixel crab and the 4-pixel item it holds. */
92
+ const SPRITE_COLUMNS = 16
93
+
47
94
  const PALETTE: Readonly<Record<string, string>> = {
48
95
  b: '#e07a5f', C: '#c8553d', k: '#1e1e1e',
49
- g: '#3fa66b', w: '#f0f0f0', u: '#4c8bf5', y: '#f2c14e', t: '#e8e8e8', n: '#7a4a2a',
96
+ g: '#3fa66b', w: '#f0f0f0', u: '#4c8bf5', y: '#f2c14e', a: '#fff3b0',
97
+ h: '#8b6b3e', H: '#c9a46a', t: '#22252b', v: '#9b59d0', p: '#d8c38f',
98
+ m: '#2f3542', q: '#f2c14e', o: '#c9d1d9', L: '#9fd3e6', s: '#7a4a2a',
99
+ P: '#4c8bf5', f: '#c9d1d9', r: '#e05a4f', e: '#efe6c8', E: '#b8a77a',
50
100
  }
51
101
 
52
102
  /** Price per million tokens: input, output, cache read, cache write. First match wins. */
@@ -60,6 +110,21 @@ const PRICES: ReadonlyArray<[RegExp, [number, number, number, number]]> = [
60
110
  [/haiku/, [1, 5, 0.1, 1.25]],
61
111
  ]
62
112
 
113
+ /**
114
+ * Finds an agent's role from its title, then its subagent type.
115
+ *
116
+ * @param title - What the agent was started to do, e.g. `review: read mod files`.
117
+ * @param subagentType - Its subagent type, e.g. `Explore`.
118
+ * @returns The first role whose words match, or `other`.
119
+ */
120
+ export function roleOf(title: string, subagentType: string): Role {
121
+ for (const text of [title, subagentType]) {
122
+ const found = ROLE_RULES.find(([, words]) => words.test(text))
123
+ if (found !== undefined) return found[0]
124
+ }
125
+ return 'other'
126
+ }
127
+
63
128
  /**
64
129
  * Looks up the tier of an effort level, falling back to the default tier.
65
130
  *
@@ -128,14 +193,14 @@ function minutes(ms: number): string {
128
193
  }
129
194
 
130
195
  /**
131
- * Draws the crab as one SVG: hat rows for the tier on top of the body; a planned crab is drawn grey.
196
+ * Draws the crab as one SVG: its role's hat over the body, and the item it holds; a planned crab is drawn grey.
132
197
  *
133
- * @param hat - Hat to draw, a key of `HATS`.
198
+ * @param role - Role whose hat and item to draw.
134
199
  * @param isPlanned - Whether the crab is planned work, not a running or finished subagent.
135
200
  * @returns The SVG markup as a string.
136
201
  */
137
- function crabSvg(hat: keyof typeof HATS, isPlanned: boolean): string {
138
- const rows = [...HATS[hat], ...BODY]
202
+ function crabSvg(role: Role, isPlanned: boolean): string {
203
+ const rows = [...HATS[role], ...BODY].map((row, y) => row + (PROPS[role][y] ?? '....'))
139
204
  const rects: string[] = []
140
205
  rows.forEach((row, y) => {
141
206
  for (let x = 0; x < row.length; x++) {
@@ -143,7 +208,7 @@ function crabSvg(hat: keyof typeof HATS, isPlanned: boolean): string {
143
208
  if (color !== undefined) rects.push(`<rect x="${x * PIXEL}" y="${y * PIXEL}" width="${PIXEL}" height="${PIXEL}" fill="${color}"/>`)
144
209
  }
145
210
  })
146
- const width = 12 * PIXEL
211
+ const width = SPRITE_COLUMNS * PIXEL
147
212
  const height = rows.length * PIXEL
148
213
  const filter = isPlanned ? ' opacity="0.45" style="filter:grayscale(1)"' : ''
149
214
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"${filter}>${rects.join('')}</svg>`
@@ -350,7 +415,60 @@ async function readWindow($: EngineInterface): Promise<void> {
350
415
  }
351
416
 
352
417
  /**
353
- * Registers this mod's session, tool, subagent and turn hooks, and its section of the agents panel.
418
+ * Opens a crab's conversation as a pane of its own, which the viewer shows in a tab of its own.
419
+ *
420
+ * @param $ - Engine handle the press came through.
421
+ * @param crabId - `id` of the crab whose agent to show.
422
+ */
423
+ async function openChat($: EngineInterface, crabId: string): Promise<void> {
424
+ const crab = (await read($, crabs)).find(one => one.id === crabId)
425
+ if (crab?.agentId === undefined) {
426
+ $.ui.toast('This agent has no conversation yet; try again once it has started.')
427
+ return
428
+ }
429
+ const agentId = crab.agentId
430
+ const id = `${CHAT_PREFIX}${agentId.replace(/[^A-Za-z0-9_-]/g, '')}`.slice(0, 64)
431
+ await update($, chats, list => [...list.filter(chat => chat.id !== id), { id, agentId, title: crab.title }])
432
+ await $.ui.open({ id, title: `Chat · ${crab.title}`, focus: true })
433
+ }
434
+
435
+ /**
436
+ * Sends a message typed in a chat to its agent, which resumes a finished one.
437
+ *
438
+ * @param $ - Engine handle the submit came through.
439
+ * @param agentId - Agent the chat shows.
440
+ * @param text - What the person typed.
441
+ */
442
+ async function sendToAgent($: EngineInterface, agentId: string, text: string): Promise<void> {
443
+ const message = text.trim()
444
+ if (message === '') return
445
+ const sent = await $.session.send({ to: { agentId }, text: message }).catch((error: unknown) => ({ isDelivered: false as const, reason: String(error) }))
446
+ if (!sent.isDelivered) {
447
+ $.ui.toast(`Not delivered: ${sent.reason ?? 'the agent could not be reached'}`)
448
+ return
449
+ }
450
+ await patchCrab($, crab => crab.agentId === agentId, crab => ({ ...crab, status: 'running', durationMs: undefined }))
451
+ $.ui.invalidate('ui.render')
452
+ }
453
+
454
+ /**
455
+ * Shortens a tool call to the text its one line in a chat shows: a command, a file's name, a pattern.
456
+ *
457
+ * @param input - Arguments of the tool call.
458
+ * @returns The label with whitespace collapsed, at most 200 characters.
459
+ */
460
+ function toolLabel(input: Record<string, unknown>): string {
461
+ for (const key of ['command', 'file_path', 'pattern', 'url', 'query', 'description', 'prompt']) {
462
+ const value = input[key]
463
+ if (typeof value !== 'string' || value === '') continue
464
+ const text = key === 'file_path' ? value.split('/').pop() ?? value : value
465
+ return text.replace(/\s+/g, ' ').trim().slice(0, 200)
466
+ }
467
+ return ''
468
+ }
469
+
470
+ /**
471
+ * Registers this mod's session, tool, subagent and turn hooks, its section of the agents panel, and the agents' chats.
354
472
  *
355
473
  * @param on - Hook registration function the engine passes in.
356
474
  */
@@ -406,10 +524,80 @@ export const register: Register = on => {
406
524
  return result
407
525
  }).catch(($, e, next) => next(e))
408
526
 
527
+ // Forgets a chat once its pane is closed.
528
+ on('ui.close', async ($, e, next) => {
529
+ if (e.id.startsWith(CHAT_PREFIX)) await update($, chats, list => list.filter(chat => chat.id !== e.id))
530
+ return next(e)
531
+ }).catch(($, e, next) => next(e))
532
+
533
+ // A subagent's chat: its whole conversation, each tool call on one line, and a box to message it.
534
+ on('ui.render', { component: 'Pane' }, async ($, e, next) => {
535
+ const chat = (await read($, chats)).find(one => one.id === e.requestId)
536
+ if (chat === undefined) return next(e)
537
+ const { Box, Text, Markdown } = $.ui.resolve(e)
538
+ const crab = (await read($, crabs)).find(one => one.agentId === chat.agentId)
539
+ const found = await $.session.messages({ agentId: chat.agentId })
540
+ const role = roleOf(chat.title, crab?.subagentType ?? '')
541
+ const status = crab === undefined ? '' : crab.status === 'running' ? '● running' : crab.status === 'failed' ? '✗ failed' : '✓ finished'
542
+
543
+ const header = (
544
+ <Box flexDirection="row" columnGap={1}>
545
+ <Text bold wrap="truncate-end">{chat.title}</Text>
546
+ <Text dimColor wrap="truncate-end">{ROLES[role].name}{crab === undefined ? '' : ` · ${modelName(crab.model)} · ${status}`}</Text>
547
+ </Box>
548
+ )
549
+ if ('deny' in found) return <Box flexDirection="column" gap={1}>{header}<Text dimColor>{found.deny}</Text></Box>
550
+
551
+ const rows = found.slice(-CHAT_ROWS).flatMap((message, index) => {
552
+ const parts = []
553
+ if (message.role === 'user' && message.text.trim() !== '') {
554
+ parts.push(
555
+ <Box key={`m${index}`} borderStyle="round" borderColor="#3a3f4b" paddingX={1} marginTop={1}>
556
+ <Text wrap="wrap">{message.text}</Text>
557
+ </Box>,
558
+ )
559
+ }
560
+ if (message.role === 'assistant' && message.text.trim() !== '') {
561
+ parts.push(<Box key={`m${index}`} marginTop={1}><Markdown text={message.text} /></Box>)
562
+ }
563
+ message.toolUses.forEach((use, n) => {
564
+ parts.push(
565
+ <Box key={`t${index}:${n}`} flexDirection="row" columnGap={1}>
566
+ <Text color={use.isError === true ? '#e06c6c' : '#7cc47f'}>⏺</Text>
567
+ <Text wrap="truncate-end">{use.tool} <Text dimColor>{toolLabel(use.input)}</Text></Text>
568
+ </Box>,
569
+ )
570
+ })
571
+ return parts
572
+ })
573
+
574
+ const input = e.surface === 'mobile'
575
+ ? ''
576
+ : (() => {
577
+ const { Input } = $.ui.resolve(e)
578
+ return (
579
+ <Input
580
+ key="send"
581
+ placeholder={crab?.status === 'running' ? 'Message this agent; it reads it after its current step' : 'Message this agent to continue it'}
582
+ submitLabel="Send"
583
+ onSubmit={value => sendToAgent($, chat.agentId, value)}
584
+ />
585
+ )
586
+ })()
587
+
588
+ return (
589
+ <Box flexDirection="column">
590
+ {header}
591
+ {rows.length === 0 ? <Text dimColor>Nothing in this conversation yet.</Text> : rows}
592
+ <Box marginTop={1}>{input}</Box>
593
+ </Box>
594
+ )
595
+ })
596
+
409
597
  // Draws into the agents panel: under the panel's tree when the panel's hook lies beneath this one, or alone with an
410
598
  // `addon:` key that the panel's hook picks up when it lies above. Either way the crabs end up under the agents.
411
599
  on('ui.render', { component: 'Pane', requestId: PANEL }, async ($, e, next) => {
412
- const { Box, Text } = $.ui.resolve(e)
600
+ const { Box, Text, Button } = $.ui.resolve(e)
413
601
  const below: RenderElement | undefined = await next(e).catch(() => undefined)
414
602
  const panel = below !== undefined && holdsKey(below, PANEL_KEY) ? below : undefined
415
603
  /** Puts the crabs' section under the panel's tree, when it was drawn beneath. */
@@ -427,14 +615,14 @@ export const register: Register = on => {
427
615
  /**
428
616
  * Draws a crab sprite: an emoji in the terminal, an SVG elsewhere.
429
617
  *
430
- * @param hat - Hat to draw, a key of `HATS`.
618
+ * @param role - Role whose hat and item to draw.
431
619
  * @param isPlanned - Whether the crab is planned work.
432
620
  * @returns The sprite element.
433
621
  */
434
- const sprite = (hat: keyof typeof HATS, isPlanned: boolean) => {
435
- if (e.surface === 'terminal') return <Text color={isPlanned ? '#6b7280' : PALETTE.b}>🦀</Text>
622
+ const sprite = (role: Role, isPlanned: boolean) => {
623
+ if (e.surface === 'terminal') return <Text color={isPlanned ? '#6b7280' : PALETTE.b}>{ROLES[role].emoji}🦀</Text>
436
624
  const { Svg } = $.ui.resolve(e)
437
- return <Svg source={crabSvg(hat, isPlanned)} alt="crab" width={12 * PIXEL} height={11 * PIXEL} />
625
+ return <Svg source={crabSvg(role, isPlanned)} alt={`${ROLES[role].name} crab`} width={SPRITE_COLUMNS * PIXEL} height={12 * PIXEL} />
438
626
  }
439
627
  /**
440
628
  * Draws a progress bar for a crab.
@@ -461,6 +649,7 @@ export const register: Register = on => {
461
649
  */
462
650
  const card = (crab: Crab) => {
463
651
  const tier = tierOf(crab.effort)
652
+ const role = roleOf(crab.title, crab.subagentType)
464
653
  const elapsed = crab.durationMs ?? now - crab.startedAt
465
654
  const tokens = crab.tokens ?? 0
466
655
  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
@@ -478,14 +667,17 @@ export const register: Register = on => {
478
667
  : <Text color="#7cc47f">✓</Text>
479
668
  return (
480
669
  <Box flexDirection="row" columnGap={2} paddingY={1}>
481
- {sprite(tier.hat, false)}
670
+ {sprite(role, false)}
482
671
  <Box flexDirection="column" flexGrow={1}>
483
672
  <Box flexDirection="row" justifyContent="space-between">
484
- <Text bold wrap="truncate-end">{crab.title}</Text>
673
+ <Button key={`chat:${crab.id}`} plain onPress={() => openChat($, crab.id)}>
674
+ <Text bold wrap="truncate-end">{crab.title}</Text>
675
+ <Text dimColor> ↗</Text>
676
+ </Button>
485
677
  {mark}
486
678
  </Box>
487
679
  <Text>
488
- <Text color={tier.color}>{tier.name}</Text>
680
+ <Text color={tier.color}>{ROLES[role].name}</Text>
489
681
  <Text dimColor> {modelName(crab.model)}{crab.effort === undefined ? '' : ` · ${crab.effort}`}</Text>
490
682
  </Text>
491
683
  <Box flexDirection="row" justifyContent="space-between">
@@ -510,7 +702,7 @@ export const register: Register = on => {
510
702
  {plan.length > 0 && <Text dimColor>Planned · {plan.length}</Text>}
511
703
  {plan.map(item => (
512
704
  <Box flexDirection="row" columnGap={2} paddingY={1}>
513
- {sprite('beanie', true)}
705
+ {sprite(roleOf(item.content, ''), true)}
514
706
  <Box flexDirection="column" flexGrow={1}>
515
707
  <Box flexDirection="row" justifyContent="space-between">
516
708
  <Text bold dimColor wrap="truncate-end">{item.index}. {item.content}</Text>
@@ -38,8 +38,19 @@ 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
+
41
52
  declare module 'claude-code' {
42
53
  interface PluginState {
43
- 'custom-crab-agents': { crabs: Crab[]; planned: PlannedItem[]; window: number }
54
+ 'custom-crab-agents': { crabs: Crab[]; planned: PlannedItem[]; window: number; chats: ChatPane[] }
44
55
  }
45
56
  }
@@ -8,8 +8,17 @@ const PANE = 'focus-mode'
8
8
  const TITLE = 'Focus'
9
9
  const block = atom({ plugin: 'focus-mode', key: 'block' } as const, null)
10
10
  const finished = atom({ plugin: 'focus-mode', key: 'finished' } as const, 0)
11
+ const length = atom({ plugin: 'focus-mode', key: 'length' } as const, 0)
11
12
 
12
13
  const DEFAULT_MINUTES = 25
14
+
15
+ /** Minutes the pane's − and + buttons move the block length by, and its bounds. */
16
+ const STEP_MINUTES = 5
17
+ const MIN_MINUTES = 5
18
+ const MAX_MINUTES = 240
19
+
20
+ /** Key in the plugin's store that remembers the chosen block length between sessions. */
21
+ const LENGTH_KEY = 'minutes'
13
22
  const MAX_SQUARES = 30
14
23
  const FILLED = '#e6b450'
15
24
  const EMPTY = '#3a3f4b'
@@ -59,6 +68,18 @@ function squaresSvg(count: number, filled: number): string {
59
68
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${size}" viewBox="0 0 ${width} ${size}">${rects.join('')}</svg>`
60
69
  }
61
70
 
71
+ /**
72
+ * Sets the block length the pane's start button uses, for this session and later ones.
73
+ *
74
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
75
+ * @param minutes - New length in minutes, kept within `MIN_MINUTES` and `MAX_MINUTES`.
76
+ */
77
+ async function setLength($: EngineInterface, minutes: number): Promise<void> {
78
+ const kept = Math.min(MAX_MINUTES, Math.max(MIN_MINUTES, Math.round(minutes)))
79
+ await update($, length, () => kept)
80
+ await $.store.set(LENGTH_KEY, kept)
81
+ }
82
+
62
83
  /**
63
84
  * Starts a block of `minutes`, replacing one already running, and shows the pane.
64
85
  *
@@ -130,9 +151,11 @@ export const register: Register = (on, options) => {
130
151
  const defaultMinutes = Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_MINUTES
131
152
 
132
153
  on('session.start', async ($, e, next) => {
154
+ const remembered = Number(await $.store.get(LENGTH_KEY).catch(() => undefined))
155
+ await update($, length, () => (Number.isFinite(remembered) && remembered > 0 ? remembered : defaultMinutes))
133
156
  await $.command.register({
134
157
  name: 'focus-mode',
135
- description: `Start a focus block (${defaultMinutes} min by default) in which Claude asks you nothing, or end the running one`,
158
+ description: 'Start a focus block in which Claude asks you nothing (the length you set, or the minutes given), or end the running one',
136
159
  argumentHint: '[minutes] | end',
137
160
  })
138
161
  void $.ui.open({ id: PANE, title: TITLE })
@@ -146,10 +169,13 @@ export const register: Register = (on, options) => {
146
169
  await endFocus($, false)
147
170
  return { text: 'Focus block ended.' }
148
171
  }
149
- const minutes = arg === '' ? defaultMinutes : Number(arg)
172
+ const chosen = (await read($, length)) || defaultMinutes
173
+ const minutes = arg === '' ? chosen : Number(arg)
150
174
  if (!Number.isFinite(minutes) || minutes <= 0 || minutes > 600) {
151
175
  return { text: `"${arg}" is not a number of minutes. Use /focus-mode 25, or /focus-mode end.` }
152
176
  }
177
+ // A length typed with the command becomes the one the pane starts with next time.
178
+ if (arg !== '') await setLength($, minutes)
153
179
  await startFocus($, minutes)
154
180
  return { text: `Focus block started: ${minutes} min.` }
155
181
  })
@@ -179,10 +205,16 @@ export const register: Register = (on, options) => {
179
205
 
180
206
  if (current === null) {
181
207
  const done = await read($, finished)
208
+ const minutes = (await read($, length)) || defaultMinutes
182
209
  return (
183
- <Box flexDirection="row" justifyContent="space-between" alignItems="center">
184
- <Text dimColor>{done > 0 ? `${done} focus block${done === 1 ? '' : 's'} done this session.` : 'No focus block running.'}</Text>
185
- <Button key="start" label={`start ${defaultMinutes} min`} variant="primary" onPress={() => startFocus($, defaultMinutes)} />
210
+ <Box flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={2}>
211
+ <Text dimColor wrap="truncate-end">{done > 0 ? `${done} focus block${done === 1 ? '' : 's'} done this session.` : 'No focus block running.'}</Text>
212
+ <Box flexDirection="row" alignItems="center" columnGap={1}>
213
+ <Button key="shorter" label="−" onPress={() => setLength($, minutes - STEP_MINUTES)} />
214
+ <Text bold>{minutes} min</Text>
215
+ <Button key="longer" label="+" onPress={() => setLength($, minutes + STEP_MINUTES)} />
216
+ <Button key="start" label="start" variant="primary" onPress={() => startFocus($, minutes)} />
217
+ </Box>
186
218
  </Box>
187
219
  )
188
220
  }
@@ -9,6 +9,6 @@ export type FocusBlock = {
9
9
 
10
10
  declare module 'claude-code' {
11
11
  interface PluginState {
12
- 'focus-mode': { block: FocusBlock | null; finished: number }
12
+ 'focus-mode': { block: FocusBlock | null; finished: number; length: number }
13
13
  }
14
14
  }
@@ -37,6 +37,9 @@ const IDLE_MS = 200
37
37
  /** Most characters of a call's input the hover card keeps. */
38
38
  const DETAIL_MAX = 2000
39
39
 
40
+ /** Most characters of a call's label kept; the pane cuts it to one line anyway. */
41
+ const LABEL_MAX = 200
42
+
40
43
  /** Input fields that say most about a call, tried in order. */
41
44
  const LABEL_FIELDS = ['command', 'file_path', 'notebook_path', 'pattern', 'url', 'query', 'description', 'skill', 'prompt'] as const
42
45
 
@@ -62,10 +65,10 @@ export function labelOf(input: Record<string, unknown>): string {
62
65
  const value = input[field]
63
66
  if (typeof value !== 'string' || value.length === 0) continue
64
67
  const text = field === 'file_path' || field === 'notebook_path' ? value.split('/').pop() ?? value : value
65
- return text.replace(/\s+/g, ' ').trim()
68
+ return text.replace(/\s+/g, ' ').trim().slice(0, LABEL_MAX)
66
69
  }
67
70
  const first = Object.values(input).find((value): value is string => typeof value === 'string')
68
- return first?.replace(/\s+/g, ' ').trim() ?? ''
71
+ return first?.replace(/\s+/g, ' ').trim().slice(0, LABEL_MAX) ?? ''
69
72
  }
70
73
 
71
74
  /**
@@ -331,7 +334,7 @@ export const register: Register = on => {
331
334
  // One line per call; the keyed row reveals a card with the call's whole input while the pointer is on it.
332
335
  const rows = shown.map((call, index) => (
333
336
  <Box key={call.id} flexDirection="row" columnGap={1}>
334
- <Text bold color={CARD} backgroundColor={colorOf(call.tool)}> {badgeOf(call.tool)} </Text>
337
+ <Text bold color={CARD} backgroundColor={colorOf(call.tool)}>{e.surface === 'terminal' ? ` ${badgeOf(call.tool)} ` : badgeOf(call.tool)}</Text>
335
338
  <Box width={labelCells}>
336
339
  <Text wrap="truncate-end" strikethrough={call.status === 'denied'} color={call.status === 'denied' ? ERROR : undefined}>
337
340
  {call.agentId === undefined ? '' : '↳ '}{call.tool} <Text dimColor>{call.label}</Text>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@g3nomaly/g3n-mods",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "The g3n Claude Code mods: panes, status lines, commands and hooks, installed with @g3nomaly/mods.",
5
5
  "type": "module",
6
6
  "files": [