@g3nomaly/g3n-mods 0.1.1 → 0.1.3
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 +76 -0
- package/mods/agents-panel/hooks/register.tsx +1 -0
- package/mods/custom-crab-agents/hooks/register.tsx +1 -0
- package/mods/focus-mode/hooks/register.tsx +1 -0
- package/mods/mini-game/hooks/register.tsx +1 -0
- package/mods/tool-timeline/hooks/register.tsx +102 -38
- package/mods/tool-timeline/types/index.d.ts +2 -0
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# @g3nomaly/g3n-mods
|
|
2
|
+
|
|
3
|
+
The g3n mods for Claude Code. A mod adds a pane, a status line, a slash command or a hook to Claude Code.
|
|
4
|
+
|
|
5
|
+
This package holds the mods themselves. You don't install it directly. The installer, [`@g3nomaly/mods`](https://www.npmjs.com/package/@g3nomaly/mods), knows it as the built-in `g3n-mods` source and copies mods out of it into your project.
|
|
6
|
+
|
|
7
|
+
## How to install
|
|
8
|
+
|
|
9
|
+
Run these from the root of your project:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
bunx @g3nomaly/mods init # creates .claude/mods.json and sets up VS Code
|
|
13
|
+
bunx @g3nomaly/mods add context-bar # installs one mod
|
|
14
|
+
bunx @g3nomaly/mods add g3n-mods # installs every mod in this package
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Then open the project in VS Code and trust the workspace. New sessions load the mods, and `/reload-plugins` loads them into a session that is already open. Updating, removing and installing for every project with `--user` are covered in the [`@g3nomaly/mods` README](https://www.npmjs.com/package/@g3nomaly/mods).
|
|
18
|
+
|
|
19
|
+
## The mods
|
|
20
|
+
|
|
21
|
+
| Mod | What it does | Command |
|
|
22
|
+
|---|---|---|
|
|
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
|
+
| `context-bar` | One bar of context-window use split by source, with the total and where compaction starts | `/context-bar` |
|
|
25
|
+
| `custom-crab-agents` | Each subagent as a crab with a hat: effort tier, model, steps, context, tokens, cost and time | `/crabs` |
|
|
26
|
+
| `delete-guard` | Denies `rm -rf` and other permanent deletes and tells Claude which trash command to use instead | `/delete-guard on \| off` |
|
|
27
|
+
| `focus-mode` | A timed focus block with a countdown, a progress bar and an end button. 25 minutes unless you pass a number | `/focus-mode [minutes] \| end` |
|
|
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
|
+
| `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
|
+
| `mini-game` | Minesweeper, to play while Claude works | `/mini-game` |
|
|
31
|
+
| `tool-timeline` | Each tool call with its duration. A denied call is struck through, next to the call that replaced it | `/tool-timeline` |
|
|
32
|
+
| `you-should-know` | A card under long replies with their key point, a headline and one supporting line. It costs one small Haiku call per reply of 1500 characters or more | none |
|
|
33
|
+
|
|
34
|
+
For `agents-panel`, `context-bar`, `custom-crab-agents` and `tool-timeline`, the command opens the pane and running it again closes it.
|
|
35
|
+
|
|
36
|
+
Three mods take settings, declared under `userConfig` in their `.claude-plugin/plugin.json`:
|
|
37
|
+
|
|
38
|
+
- `focus-mode`: `minutes`, the default block length (25).
|
|
39
|
+
- `hide-personal-data`: `names`, extra names to always mask, separated by commas.
|
|
40
|
+
- `you-should-know`: `minChars`, the shortest reply that gets a card (1500), and `model`, the model that picks the key point (`haiku`).
|
|
41
|
+
|
|
42
|
+
## Requirements
|
|
43
|
+
|
|
44
|
+
- [Bun](https://bun.sh), to run the installer
|
|
45
|
+
- Claude Code for VS Code (`anthropic.claude-code`) 2.1.296 or newer. The mods are typed against that release.
|
|
46
|
+
|
|
47
|
+
The mods also load in the terminal, but they are built for the VS Code extension.
|
|
48
|
+
|
|
49
|
+
## Making your own mod
|
|
50
|
+
|
|
51
|
+
Each mod here is a plugin folder under `mods/`, and `.claude-plugin/marketplace.json` lists them:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
mods/<name>/
|
|
55
|
+
├── .claude-plugin/plugin.json # name, version, description
|
|
56
|
+
├── hooks/hooks.json # { "modules": ["./register.tsx"] }
|
|
57
|
+
├── hooks/register.tsx # exports `register`
|
|
58
|
+
└── types/index.d.ts
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The folder name must match `name` in `plugin.json`. Mods are TypeScript only, and the installer refuses a mod that contains any `.js`, `.mjs`, `.cjs` or `.jsx` file. To share your own mods, put them in a git repository, npm package or local folder with the same layout and register it with `bunx @g3nomaly/mods source add`.
|
|
62
|
+
|
|
63
|
+
## Development
|
|
64
|
+
|
|
65
|
+
Run `bun install` at the repository root first, then these from `packages/g3n-mods`:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
bun run build # rewrites .claude-plugin/marketplace.json from each mod's plugin.json
|
|
69
|
+
bun run typecheck
|
|
70
|
+
bun run validate # runs `claude plugin validate` on every mod
|
|
71
|
+
bun run types # copies the mod API types into types/claude-code.d.ts
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
[Elastic License 2.0](https://www.elastic.co/licensing/elastic-license). See `LICENSE`.
|
|
@@ -157,6 +157,7 @@ export const register: Register = on => {
|
|
|
157
157
|
on('session.start', async ($, e, next) => {
|
|
158
158
|
await $.command.register({ name: 'agents-panel', description: 'Show or hide the agents panel' })
|
|
159
159
|
await loadAgents($)
|
|
160
|
+
void $.ui.open({ id: PANE, title: TITLE })
|
|
160
161
|
$.clock.every(1000, () => void pollRunning($))
|
|
161
162
|
return next(e)
|
|
162
163
|
})
|
|
@@ -340,6 +340,7 @@ export const register: Register = on => {
|
|
|
340
340
|
on('session.start', async ($, e, next) => {
|
|
341
341
|
await $.command.register({ name: 'crabs', description: 'Show or hide the subagent crabs' })
|
|
342
342
|
await readWindow($)
|
|
343
|
+
void $.ui.open({ id: PANE, title: TITLE })
|
|
343
344
|
$.clock.every(1000, () => void tick($))
|
|
344
345
|
return next(e)
|
|
345
346
|
})
|
|
@@ -122,6 +122,7 @@ export const register: Register = (on, options) => {
|
|
|
122
122
|
description: `Start a focus block (${defaultMinutes} min by default), or end the running one`,
|
|
123
123
|
argumentHint: '[minutes] | end',
|
|
124
124
|
})
|
|
125
|
+
void $.ui.open({ id: PANE, title: TITLE })
|
|
125
126
|
$.clock.every(1000, () => void tick($))
|
|
126
127
|
return next(e)
|
|
127
128
|
})
|
|
@@ -187,6 +187,7 @@ export const register: Register = on => {
|
|
|
187
187
|
await $.command.register({ name: 'mini-game', description: 'Play Minesweeper while Claude works' })
|
|
188
188
|
const stored = await $.store.get(BEST_KEY)
|
|
189
189
|
if (typeof stored === 'number') await update($, best, () => stored)
|
|
190
|
+
void $.ui.open({ id: PANE, title: TITLE })
|
|
190
191
|
$.clock.every(1000, () => void tick($))
|
|
191
192
|
return next(e)
|
|
192
193
|
})
|
|
@@ -28,6 +28,15 @@ const TOOL_COLORS: ReadonlyArray<[RegExp, string]> = [
|
|
|
28
28
|
[/^(Agent|Task)$/, '#f09bb5'],
|
|
29
29
|
]
|
|
30
30
|
|
|
31
|
+
/** Background of a row's hover card. */
|
|
32
|
+
const CARD = '#1b1f27'
|
|
33
|
+
|
|
34
|
+
/** Idle time between calls, in milliseconds, kept on the bars' axis; longer idle stretches shrink to this. */
|
|
35
|
+
const IDLE_MS = 200
|
|
36
|
+
|
|
37
|
+
/** Most characters of a call's input the hover card keeps. */
|
|
38
|
+
const DETAIL_MAX = 2000
|
|
39
|
+
|
|
31
40
|
/** Input fields that say most about a call, tried in order. */
|
|
32
41
|
const LABEL_FIELDS = ['command', 'file_path', 'notebook_path', 'pattern', 'url', 'query', 'description', 'skill', 'prompt'] as const
|
|
33
42
|
|
|
@@ -59,6 +68,47 @@ export function labelOf(input: Record<string, unknown>): string {
|
|
|
59
68
|
return first?.replace(/\s+/g, ' ').trim() ?? ''
|
|
60
69
|
}
|
|
61
70
|
|
|
71
|
+
/**
|
|
72
|
+
* @brief Finds a call's whole input text for the row's hover card: the full command, path, pattern or first text field.
|
|
73
|
+
*
|
|
74
|
+
* @param input - Arguments of the tool call.
|
|
75
|
+
* @returns The text with its line breaks, cut at `DETAIL_MAX` characters.
|
|
76
|
+
*/
|
|
77
|
+
export function detailOf(input: Record<string, unknown>): string {
|
|
78
|
+
const field = LABEL_FIELDS.find(name => typeof input[name] === 'string' && input[name] !== '')
|
|
79
|
+
const value = field === undefined ? Object.values(input).find((one): one is string => typeof one === 'string') : (input[field] as string)
|
|
80
|
+
const text = value?.trim() ?? ''
|
|
81
|
+
return text.length > DETAIL_MAX ? `${text.slice(0, DETAIL_MAX - 1)}…` : text
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Where a call sits on the bars' axis, in milliseconds from the first shown call's start. */
|
|
85
|
+
type Span = { from: number; to: number }
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @brief Places calls on one time axis, shrinking idle stretches between them to `IDLE_MS`, so the bars read as a waterfall.
|
|
89
|
+
*
|
|
90
|
+
* @param list - Calls in start order.
|
|
91
|
+
* @param now - Current time, the end of calls still running.
|
|
92
|
+
* @returns Each call's span, in `list` order, and the axis length (at least 1).
|
|
93
|
+
*/
|
|
94
|
+
export function placeCalls(list: readonly TimelineCall[], now: number): { spans: Span[]; length: number } {
|
|
95
|
+
const origin = list[0]?.startedAt ?? now
|
|
96
|
+
let busyUntil = origin
|
|
97
|
+
let shift = 0
|
|
98
|
+
let length = 1
|
|
99
|
+
const spans = list.map(call => {
|
|
100
|
+
const end = Math.max(call.startedAt, call.endedAt ?? now)
|
|
101
|
+
const idle = call.startedAt - busyUntil
|
|
102
|
+
if (idle > IDLE_MS) shift += idle - IDLE_MS
|
|
103
|
+
busyUntil = Math.max(busyUntil, end)
|
|
104
|
+
const from = call.startedAt - origin - shift
|
|
105
|
+
const to = end - origin - shift
|
|
106
|
+
length = Math.max(length, to)
|
|
107
|
+
return { from, to }
|
|
108
|
+
})
|
|
109
|
+
return { spans, length }
|
|
110
|
+
}
|
|
111
|
+
|
|
62
112
|
/**
|
|
63
113
|
* @brief Joins the text of a tool result's content, which is a string or a list of text blocks.
|
|
64
114
|
*
|
|
@@ -146,16 +196,16 @@ function replacementOf(list: readonly TimelineCall[], index: number): TimelineCa
|
|
|
146
196
|
}
|
|
147
197
|
|
|
148
198
|
/**
|
|
149
|
-
* @brief Builds the SVG of a track with a bar
|
|
199
|
+
* @brief Builds the SVG of a track with a bar from `x` that is `fill` pixels long.
|
|
150
200
|
*
|
|
151
201
|
* @param width - Width of the track in pixels.
|
|
152
|
-
* @param
|
|
202
|
+
* @param x - Start of the bar in pixels.
|
|
203
|
+
* @param fill - Length of the bar in pixels.
|
|
153
204
|
* @param color - Fill color.
|
|
154
205
|
* @returns The SVG markup.
|
|
155
206
|
*/
|
|
156
|
-
function barSvg(width: number, fill: number, color: string): string {
|
|
157
|
-
|
|
158
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="8" viewBox="0 0 ${width} 8"><rect width="${width}" height="8" rx="2" fill="${TRACK}"/><rect width="${w.toFixed(1)}" height="8" rx="2" fill="${color}"/></svg>`
|
|
207
|
+
function barSvg(width: number, x: number, fill: number, color: string): string {
|
|
208
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="8" viewBox="0 0 ${width} 8"><rect width="${width}" height="8" rx="2" fill="${TRACK}"/><rect x="${x.toFixed(1)}" width="${fill.toFixed(1)}" height="8" rx="2" fill="${color}"/></svg>`
|
|
159
209
|
}
|
|
160
210
|
|
|
161
211
|
/**
|
|
@@ -182,12 +232,15 @@ export const register: Register = on => {
|
|
|
182
232
|
|
|
183
233
|
on('tool.call', async ($, e, next) => {
|
|
184
234
|
const startedAt = await $.clock.now()
|
|
185
|
-
const
|
|
235
|
+
const input = e as unknown as Record<string, unknown>
|
|
236
|
+
const label = labelOf(input)
|
|
237
|
+
const detail = detailOf(input)
|
|
186
238
|
await patchCall($, e.tool_use_id, call => ({
|
|
187
239
|
...call,
|
|
188
240
|
id: e.tool_use_id,
|
|
189
241
|
tool: e.tool,
|
|
190
242
|
label,
|
|
243
|
+
detail,
|
|
191
244
|
status: 'running',
|
|
192
245
|
startedAt,
|
|
193
246
|
agentId: e.agentId,
|
|
@@ -209,7 +262,7 @@ export const register: Register = on => {
|
|
|
209
262
|
const input = typeof block.input === 'object' && block.input !== null ? (block.input as Record<string, unknown>) : {}
|
|
210
263
|
const id = block.id
|
|
211
264
|
const tool = block.name
|
|
212
|
-
await patchCall($, id, call => call ?? { id, tool, label: labelOf(input), status: 'queued', startedAt: now, agentId: e.agentId })
|
|
265
|
+
await patchCall($, id, call => call ?? { id, tool, label: labelOf(input), detail: detailOf(input), status: 'queued', startedAt: now, agentId: e.agentId })
|
|
213
266
|
}
|
|
214
267
|
}
|
|
215
268
|
if (e.door === 'tool-result') {
|
|
@@ -236,27 +289,32 @@ export const register: Register = on => {
|
|
|
236
289
|
const shown = list.slice(-room)
|
|
237
290
|
/** Length of a call so far, or up to its end when it has one. */
|
|
238
291
|
const durationOf = (call: TimelineCall): number => (call.endedAt ?? now) - call.startedAt
|
|
239
|
-
const longest = Math.max(1, ...shown.map(durationOf))
|
|
240
292
|
const total = list.reduce((sum, call) => sum + (call.status === 'denied' ? 0 : durationOf(call)), 0)
|
|
293
|
+
const axis = placeCalls(shown, now)
|
|
241
294
|
const barCells = Math.max(8, Math.floor(e.props.bodyColumns * 0.3))
|
|
242
295
|
const labelCells = Math.max(12, e.props.bodyColumns - barCells - 12)
|
|
243
296
|
|
|
244
|
-
/** Draws
|
|
245
|
-
const bar = (call: TimelineCall) => {
|
|
297
|
+
/** Draws a call's bar on the shared axis: where it started and how long it ran, idle time between calls shrunk. */
|
|
298
|
+
const bar = (call: TimelineCall, span: Span) => {
|
|
246
299
|
const color = call.status === 'error' ? ERROR : call.status === 'ok' && /^Bash$/.test(call.tool) ? '#5fae6e' : colorOf(call.tool)
|
|
247
|
-
const
|
|
300
|
+
const start = span.from / axis.length
|
|
301
|
+
const share = call.status === 'denied' ? 0.03 : Math.max(0.02, (span.to - span.from) / axis.length)
|
|
248
302
|
if (e.surface === 'terminal') {
|
|
249
303
|
const filled = Math.max(1, Math.round(share * barCells))
|
|
304
|
+
const before = Math.min(barCells - filled, Math.round(start * barCells))
|
|
250
305
|
return (
|
|
251
306
|
<Text>
|
|
307
|
+
<Text color={TRACK}>{'▬'.repeat(before)}</Text>
|
|
252
308
|
<Text color={color}>{'▬'.repeat(filled)}</Text>
|
|
253
|
-
<Text color={TRACK}>{'▬'.repeat(barCells - filled)}</Text>
|
|
309
|
+
<Text color={TRACK}>{'▬'.repeat(barCells - before - filled)}</Text>
|
|
254
310
|
</Text>
|
|
255
311
|
)
|
|
256
312
|
}
|
|
257
313
|
const { Svg } = $.ui.resolve(e)
|
|
258
314
|
const width = barCells * CELL_PX
|
|
259
|
-
|
|
315
|
+
const fill = Math.max(3, share * width)
|
|
316
|
+
const x = Math.min(width - fill, start * width)
|
|
317
|
+
return <Svg source={barSvg(width, x, fill, color)} alt={`${call.tool} ${seconds(durationOf(call))}`} width={width} height={8} />
|
|
260
318
|
}
|
|
261
319
|
|
|
262
320
|
/** Renders a call's status at the end of its row: its time, `denied`, `queued` or a mark for an error. */
|
|
@@ -270,32 +328,37 @@ export const register: Register = on => {
|
|
|
270
328
|
}
|
|
271
329
|
}
|
|
272
330
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
<
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
<
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
</Box>
|
|
287
|
-
{call.status === 'denied' && (
|
|
288
|
-
<Box borderStyle="round" borderColor="#7a3b3b" paddingX={1} flexDirection="row" justifyContent="space-between">
|
|
289
|
-
<Text wrap="truncate-end">
|
|
290
|
-
<Text color={ERROR}>⊘ </Text>
|
|
291
|
-
<Text strikethrough dimColor>{call.label}</Text>
|
|
292
|
-
{replacement === undefined ? '' : <Text> → <Text color="#7cc47f">{replacement.label}</Text></Text>}
|
|
293
|
-
</Text>
|
|
294
|
-
<Text color={ERROR}>denied{call.note === undefined ? '' : ` · ${shortNote(call.note)}`}</Text>
|
|
295
|
-
</Box>
|
|
296
|
-
)}
|
|
331
|
+
// One line per call; the keyed row reveals a card with the call's whole input while the pointer is on it.
|
|
332
|
+
const rows = shown.map((call, index) => (
|
|
333
|
+
<Box key={call.id} flexDirection="row" columnGap={1}>
|
|
334
|
+
<Text bold color={CARD} backgroundColor={colorOf(call.tool)}> {badgeOf(call.tool)} </Text>
|
|
335
|
+
<Box width={labelCells}>
|
|
336
|
+
<Text wrap="truncate-end" strikethrough={call.status === 'denied'} color={call.status === 'denied' ? ERROR : undefined}>
|
|
337
|
+
{call.agentId === undefined ? '' : '↳ '}{call.tool} <Text dimColor>{call.label}</Text>
|
|
338
|
+
</Text>
|
|
339
|
+
</Box>
|
|
340
|
+
{bar(call, axis.spans[index] ?? { from: 0, to: 0 })}
|
|
341
|
+
<Box width={8} justifyContent="flex-end">{outcome(call)}</Box>
|
|
342
|
+
<Box position="absolute" top={1} left={4} right={0} display="none" hover={{ display: 'flex' }} backgroundColor={CARD} borderStyle="round" borderColor={DIM} paddingX={1}>
|
|
343
|
+
<Text wrap="wrap">{call.tool} <Text dimColor>{call.detail ?? call.label}</Text></Text>
|
|
297
344
|
</Box>
|
|
298
|
-
|
|
345
|
+
</Box>
|
|
346
|
+
))
|
|
347
|
+
|
|
348
|
+
// Denied calls get their callout under the list, so every call keeps to its one line.
|
|
349
|
+
const callouts = shown.flatMap((call, index) => {
|
|
350
|
+
if (call.status !== 'denied') return []
|
|
351
|
+
const replacement = replacementOf(shown, index)
|
|
352
|
+
return [
|
|
353
|
+
<Box borderStyle="round" borderColor="#7a3b3b" paddingX={1} flexDirection="row" justifyContent="space-between">
|
|
354
|
+
<Text wrap="truncate-end">
|
|
355
|
+
<Text color={ERROR}>⊘ </Text>
|
|
356
|
+
<Text strikethrough dimColor>{call.label}</Text>
|
|
357
|
+
{replacement === undefined ? '' : <Text> → <Text color="#7cc47f">{replacement.label}</Text></Text>}
|
|
358
|
+
</Text>
|
|
359
|
+
<Text color={ERROR}>denied{call.note === undefined ? '' : ` · ${shortNote(call.note)}`}</Text>
|
|
360
|
+
</Box>,
|
|
361
|
+
]
|
|
299
362
|
})
|
|
300
363
|
|
|
301
364
|
return (
|
|
@@ -305,6 +368,7 @@ export const register: Register = on => {
|
|
|
305
368
|
<Text dimColor>{list.length} call{list.length === 1 ? '' : 's'} · {seconds(total)}</Text>
|
|
306
369
|
</Box>
|
|
307
370
|
{rows}
|
|
371
|
+
{callouts.length > 0 && <Box flexDirection="column" marginTop={1}>{callouts}</Box>}
|
|
308
372
|
</Box>
|
|
309
373
|
)
|
|
310
374
|
})
|
|
@@ -9,6 +9,8 @@ export type TimelineCall = {
|
|
|
9
9
|
tool: string
|
|
10
10
|
/** Short form of the call's input, e.g. `rm -rf dist` or `package.json` */
|
|
11
11
|
label: string
|
|
12
|
+
/** The call's whole input text for the row's hover card, e.g. the full command or path */
|
|
13
|
+
detail?: string
|
|
12
14
|
status: CallStatus
|
|
13
15
|
startedAt: number
|
|
14
16
|
endedAt?: number
|