@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 +3 -3
- package/mods/custom-crab-agents/hooks/register.tsx +223 -31
- package/mods/custom-crab-agents/types/index.d.ts +12 -1
- package/mods/focus-mode/hooks/register.tsx +37 -5
- package/mods/focus-mode/types/index.d.ts +1 -1
- package/mods/tool-timeline/hooks/register.tsx +6 -3
- package/package.json +1 -1
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
|
|
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.
|
|
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
|
|
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
|
|
21
|
-
const TIERS: Readonly<Record<string, { name: string; color: string
|
|
22
|
-
low: { name: 'light', color: '#3fa66b'
|
|
23
|
-
medium: { name: 'medium', color: '#4c8bf5'
|
|
24
|
-
high: { name: 'careful', color: '#e0a030'
|
|
25
|
-
xhigh: { name: 'heavy', color: '#e05a4f'
|
|
26
|
-
max: { name: 'heavy', color: '#e05a4f'
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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',
|
|
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
|
|
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
|
|
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(
|
|
138
|
-
const rows = [...HATS[
|
|
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 =
|
|
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
|
-
*
|
|
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
|
|
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 = (
|
|
435
|
-
if (e.surface === 'terminal') return <Text color={isPlanned ? '#6b7280' : PALETTE.b}
|
|
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(
|
|
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(
|
|
670
|
+
{sprite(role, false)}
|
|
482
671
|
<Box flexDirection="column" flexGrow={1}>
|
|
483
672
|
<Box flexDirection="row" justifyContent="space-between">
|
|
484
|
-
<
|
|
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}>{
|
|
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('
|
|
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:
|
|
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
|
|
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
|
-
<
|
|
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
|
}
|
|
@@ -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>
|