@g3nomaly/g3n-mods 0.1.5 → 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.
- package/README.md +16 -3
- package/mods/agents-panel/hooks/register.tsx +49 -21
- package/mods/agents-panel/types/index.d.ts +16 -1
- package/mods/agents-panel/view/view.css +51 -0
- package/mods/agents-panel/view/view.ts +126 -0
- package/mods/custom-crab-agents/hooks/register.tsx +108 -95
- package/mods/custom-crab-agents/hooks/sprite.ts +131 -0
- package/mods/custom-crab-agents/types/index.d.ts +35 -0
- package/mods/custom-crab-agents/view/view.css +74 -0
- package/mods/custom-crab-agents/view/view.ts +154 -0
- package/mods/delete-guard/hooks/register.tsx +115 -33
- package/mods/delete-guard/types/index.d.ts +2 -0
- package/mods/tool-timeline/hooks/register.tsx +141 -38
- package/mods/tool-timeline/types/index.d.ts +33 -2
- package/mods/tool-timeline/view/view.css +124 -0
- package/mods/tool-timeline/view/view.ts +164 -0
- package/mods/tsconfig.view.json +14 -0
- package/package.json +2 -2
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// packages/g3n-mods/mods/tool-timeline/hooks/register.tsx
|
|
2
2
|
import { atom, read, update } from 'claude-code'
|
|
3
|
-
import type { EngineInterface, Register } from 'claude-code'
|
|
3
|
+
import type { EngineInterface, Register, RenderElement } from 'claude-code'
|
|
4
4
|
|
|
5
|
-
import type { CallStatus, TimelineCall } from '../types'
|
|
5
|
+
import type { CallStatus, TimelineCall, TimelineViewData } from '../types'
|
|
6
6
|
|
|
7
7
|
const PANE = 'tool-timeline'
|
|
8
8
|
const TITLE = 'Tool timeline'
|
|
9
9
|
const calls = atom({ plugin: 'tool-timeline', key: 'calls' } as const, [])
|
|
10
|
+
const selected = atom({ plugin: 'tool-timeline', key: 'selected' } as const, '')
|
|
10
11
|
|
|
11
12
|
/** Calls kept in state; older ones fall off. */
|
|
12
13
|
const KEEP = 100
|
|
@@ -28,15 +29,21 @@ const TOOL_COLORS: ReadonlyArray<[RegExp, string]> = [
|
|
|
28
29
|
[/^(Agent|Task)$/, '#f09bb5'],
|
|
29
30
|
]
|
|
30
31
|
|
|
31
|
-
/**
|
|
32
|
+
/** Text color on a badge. */
|
|
32
33
|
const CARD = '#1b1f27'
|
|
33
34
|
|
|
35
|
+
/** Rows of the box under the calls: the clicked call in full, else the latest denied call; fixed, so the pane never changes height. */
|
|
36
|
+
const DETAIL_ROWS = 3
|
|
37
|
+
|
|
34
38
|
/** Idle time between calls, in milliseconds, kept on the bars' axis; longer idle stretches shrink to this. */
|
|
35
39
|
const IDLE_MS = 200
|
|
36
40
|
|
|
37
|
-
/** Most characters of a call's input the
|
|
41
|
+
/** Most characters of a call's input the detail box keeps. */
|
|
38
42
|
const DETAIL_MAX = 2000
|
|
39
43
|
|
|
44
|
+
/** Call rows the pane always draws, empty slots filling the rest, so its height never changes and panes beside it stay put. */
|
|
45
|
+
const ROWS = 10
|
|
46
|
+
|
|
40
47
|
/** Most characters of a call's label kept; the pane cuts it to one line anyway. */
|
|
41
48
|
const LABEL_MAX = 200
|
|
42
49
|
|
|
@@ -72,7 +79,7 @@ export function labelOf(input: Record<string, unknown>): string {
|
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
/**
|
|
75
|
-
* @brief Finds a call's whole input text for the
|
|
82
|
+
* @brief Finds a call's whole input text for the detail box: the full command, path, pattern or first text field.
|
|
76
83
|
*
|
|
77
84
|
* @param input - Arguments of the tool call.
|
|
78
85
|
* @returns The text with its line breaks, cut at `DETAIL_MAX` characters.
|
|
@@ -112,6 +119,24 @@ export function placeCalls(list: readonly TimelineCall[], now: number): { spans:
|
|
|
112
119
|
return { spans, length }
|
|
113
120
|
}
|
|
114
121
|
|
|
122
|
+
/**
|
|
123
|
+
* @brief Places every call on a waterfall of its own screen: the newest `ROWS` calls on one axis, the `ROWS` before
|
|
124
|
+
* them on another, and so on, so each screenful of the scrolled list reads as a waterfall of its own.
|
|
125
|
+
*
|
|
126
|
+
* @param list - Calls in start order.
|
|
127
|
+
* @param now - Current time, the end of calls still running.
|
|
128
|
+
* @returns Each call's bar as shares of its screen's axis, from 0 to 1, in `list` order.
|
|
129
|
+
*/
|
|
130
|
+
export function screenSpans(list: readonly TimelineCall[], now: number): Span[] {
|
|
131
|
+
const spans: Span[] = new Array(list.length)
|
|
132
|
+
for (let end = list.length; end > 0; end -= ROWS) {
|
|
133
|
+
const start = Math.max(0, end - ROWS)
|
|
134
|
+
const axis = placeCalls(list.slice(start, end), now)
|
|
135
|
+
axis.spans.forEach((span, i) => { spans[start + i] = { from: span.from / axis.length, to: span.to / axis.length } })
|
|
136
|
+
}
|
|
137
|
+
return spans
|
|
138
|
+
}
|
|
139
|
+
|
|
115
140
|
/**
|
|
116
141
|
* @brief Joins the text of a tool result's content, which is a string or a list of text blocks.
|
|
117
142
|
*
|
|
@@ -211,6 +236,16 @@ function barSvg(width: number, x: number, fill: number, color: string): string {
|
|
|
211
236
|
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>`
|
|
212
237
|
}
|
|
213
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Shows a call in full in the box under the rows, or clears the box when that call is shown already.
|
|
241
|
+
*
|
|
242
|
+
* @param $ - Engine handle the press came through.
|
|
243
|
+
* @param id - Tool use id of the clicked call.
|
|
244
|
+
*/
|
|
245
|
+
async function toggleSelected($: EngineInterface, id: string): Promise<void> {
|
|
246
|
+
await update($, selected, current => (current === id ? '' : id))
|
|
247
|
+
}
|
|
248
|
+
|
|
214
249
|
/**
|
|
215
250
|
* @brief Subscribes the timeline's handlers to the engine's events, its command and its pane.
|
|
216
251
|
*
|
|
@@ -283,17 +318,53 @@ export const register: Register = on => {
|
|
|
283
318
|
}).catch(($, e, next) => next(e))
|
|
284
319
|
|
|
285
320
|
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
|
|
286
|
-
const { Box, Text } = $.ui.resolve(e)
|
|
321
|
+
const { Box, Text, Button } = $.ui.resolve(e)
|
|
287
322
|
const list = await read($, calls)
|
|
288
|
-
|
|
289
|
-
|
|
323
|
+
const chosen = await read($, selected)
|
|
290
324
|
const now = await $.clock.now()
|
|
291
|
-
const
|
|
292
|
-
const shown = list.slice(-room)
|
|
325
|
+
const shown = list.slice(-ROWS)
|
|
293
326
|
/** Length of a call so far, or up to its end when it has one. */
|
|
294
327
|
const durationOf = (call: TimelineCall): number => (call.endedAt ?? now) - call.startedAt
|
|
295
328
|
const total = list.reduce((sum, call) => sum + (call.status === 'denied' ? 0 : durationOf(call)), 0)
|
|
296
329
|
const axis = placeCalls(shown, now)
|
|
330
|
+
|
|
331
|
+
// The viewer draws the timeline with the mod's own view: hand it the calls as data.
|
|
332
|
+
if (e.surface === 'vscode') {
|
|
333
|
+
const { Input } = $.ui.resolve(e)
|
|
334
|
+
const spans = screenSpans(list, now)
|
|
335
|
+
const data: TimelineViewData = {
|
|
336
|
+
count: list.length,
|
|
337
|
+
total: seconds(total),
|
|
338
|
+
// Every call kept, not just a screenful: the view scrolls through them.
|
|
339
|
+
calls: list.map((call, index) => {
|
|
340
|
+
const span = spans[index] ?? { from: 0, to: 0 }
|
|
341
|
+
const replacement = call.status === 'denied' ? replacementOf(list, index)?.label : undefined
|
|
342
|
+
return {
|
|
343
|
+
id: call.id,
|
|
344
|
+
tool: call.tool,
|
|
345
|
+
badge: badgeOf(call.tool),
|
|
346
|
+
color: colorOf(call.tool),
|
|
347
|
+
label: call.label,
|
|
348
|
+
detail: call.detail ?? call.label,
|
|
349
|
+
status: call.status,
|
|
350
|
+
isSub: call.agentId !== undefined,
|
|
351
|
+
from: span.from,
|
|
352
|
+
to: span.to,
|
|
353
|
+
barColor: call.status === 'error' ? ERROR : call.status === 'ok' && call.tool === 'Bash' ? '#5fae6e' : colorOf(call.tool),
|
|
354
|
+
time: seconds(durationOf(call)),
|
|
355
|
+
...(call.note === undefined ? {} : { note: shortNote(call.note) }),
|
|
356
|
+
...(replacement === undefined ? {} : { replacement }),
|
|
357
|
+
}
|
|
358
|
+
}),
|
|
359
|
+
}
|
|
360
|
+
return (
|
|
361
|
+
<Box key="g3n-view:tool-timeline">
|
|
362
|
+
<Text>{JSON.stringify(data)}</Text>
|
|
363
|
+
<Input key="g3n-act:tool-timeline" onSubmit={() => undefined} />
|
|
364
|
+
</Box>
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
|
|
297
368
|
const barCells = Math.max(8, Math.floor(e.props.bodyColumns * 0.3))
|
|
298
369
|
const labelCells = Math.max(12, e.props.bodyColumns - barCells - 12)
|
|
299
370
|
|
|
@@ -331,38 +402,69 @@ export const register: Register = on => {
|
|
|
331
402
|
}
|
|
332
403
|
}
|
|
333
404
|
|
|
334
|
-
// One line per call; the
|
|
405
|
+
// One line per call; a click shows the call in full in the box under the rows, so nothing moves. A Button holds only
|
|
406
|
+
// text, so a plain one covers the row; the terminal, where it would hide the row, has no cover.
|
|
335
407
|
const rows = shown.map((call, index) => (
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
408
|
+
<Box key={`row:${call.id}`} flexDirection="row" columnGap={1} alignItems="center">
|
|
409
|
+
<Text bold color={CARD} backgroundColor={colorOf(call.tool)}>{e.surface === 'terminal' ? ` ${badgeOf(call.tool)} ` : badgeOf(call.tool)}</Text>
|
|
410
|
+
<Box width={labelCells}>
|
|
411
|
+
<Text wrap="truncate-end" strikethrough={call.status === 'denied'} color={call.status === 'denied' ? ERROR : undefined} bold={call.id === chosen}>
|
|
412
|
+
{call.agentId === undefined ? '' : '↳ '}{call.tool} <Text dimColor>{call.label}</Text>
|
|
413
|
+
</Text>
|
|
414
|
+
</Box>
|
|
415
|
+
{bar(call, axis.spans[index] ?? { from: 0, to: 0 })}
|
|
416
|
+
<Box width={8} justifyContent="flex-end">{outcome(call)}</Box>
|
|
417
|
+
{e.surface === 'terminal'
|
|
418
|
+
? ''
|
|
419
|
+
: (
|
|
420
|
+
<Box position="absolute" top={0} left={0} right={0} bottom={0}>
|
|
421
|
+
<Button key={`call:${call.id}`} plain label={call.id === chosen ? 'hide' : 'details'} onPress={() => toggleSelected($, call.id)} />
|
|
422
|
+
</Box>
|
|
423
|
+
)}
|
|
347
424
|
</Box>
|
|
425
|
+
))
|
|
426
|
+
|
|
427
|
+
// Empty lines up to ROWS, so the pane keeps one height as calls arrive; a badge is no taller than a line.
|
|
428
|
+
const slots = Array.from({ length: ROWS - shown.length }, (_, index) => (
|
|
429
|
+
<Box key={`slot:${index}`}>
|
|
430
|
+
<Text dimColor>{index === 0 && shown.length === 0 ? 'No tool calls yet.' : ' '}</Text>
|
|
348
431
|
</Box>
|
|
349
432
|
))
|
|
350
433
|
|
|
351
|
-
//
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
434
|
+
// A fixed box under the rows: the clicked call in full, else the latest denied call, else a hint.
|
|
435
|
+
const picked = list.find(call => call.id === chosen)
|
|
436
|
+
const deniedAt = shown.map(call => call.status).lastIndexOf('denied')
|
|
437
|
+
const denied = shown[deniedAt]
|
|
438
|
+
const replacement = deniedAt === -1 ? undefined : replacementOf(shown, deniedAt)
|
|
439
|
+
const detailBox = (body: RenderElement, edge: string) => (
|
|
440
|
+
<Box borderStyle="round" borderColor={edge} paddingX={1} flexDirection="column" marginTop={1} height={DETAIL_ROWS} overflow="hidden">
|
|
441
|
+
{body}
|
|
442
|
+
</Box>
|
|
443
|
+
)
|
|
444
|
+
const callout = picked !== undefined
|
|
445
|
+
? detailBox(
|
|
446
|
+
<Box flexDirection="column">
|
|
447
|
+
<Box flexDirection="row" justifyContent="space-between" columnGap={2}>
|
|
448
|
+
<Text bold>{picked.tool}</Text>
|
|
449
|
+
{outcome(picked)}
|
|
450
|
+
</Box>
|
|
451
|
+
<Text wrap="wrap" dimColor>{picked.detail ?? picked.label}</Text>
|
|
452
|
+
</Box>,
|
|
453
|
+
colorOf(picked.tool),
|
|
454
|
+
)
|
|
455
|
+
: denied !== undefined
|
|
456
|
+
? detailBox(
|
|
457
|
+
<Box flexDirection="row" justifyContent="space-between" columnGap={2}>
|
|
458
|
+
<Text wrap="truncate-end">
|
|
459
|
+
<Text color={ERROR}>⊘ </Text>
|
|
460
|
+
<Text strikethrough dimColor>{denied.label}</Text>
|
|
461
|
+
{replacement === undefined ? '' : <Text> → <Text color="#7cc47f">{replacement.label}</Text></Text>}
|
|
462
|
+
</Text>
|
|
463
|
+
<Text color={ERROR}>denied{denied.note === undefined ? '' : ` · ${shortNote(denied.note)}`}</Text>
|
|
464
|
+
</Box>,
|
|
465
|
+
'#7a3b3b',
|
|
466
|
+
)
|
|
467
|
+
: detailBox(<Text dimColor>Click a call to see it in full.</Text>, TRACK)
|
|
366
468
|
|
|
367
469
|
return (
|
|
368
470
|
<Box flexDirection="column" gap={0}>
|
|
@@ -371,7 +473,8 @@ export const register: Register = on => {
|
|
|
371
473
|
<Text dimColor>{list.length} call{list.length === 1 ? '' : 's'} · {seconds(total)}</Text>
|
|
372
474
|
</Box>
|
|
373
475
|
{rows}
|
|
374
|
-
{
|
|
476
|
+
{slots}
|
|
477
|
+
{callout}
|
|
375
478
|
</Box>
|
|
376
479
|
)
|
|
377
480
|
})
|
|
@@ -9,7 +9,7 @@ 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
|
|
12
|
+
/** The call's whole input text for the detail box under the rows, e.g. the full command or path */
|
|
13
13
|
detail?: string
|
|
14
14
|
status: CallStatus
|
|
15
15
|
startedAt: number
|
|
@@ -20,8 +20,39 @@ export type TimelineCall = {
|
|
|
20
20
|
agentId?: string
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** One call as the view draws it: its badge, its line, where its bar sits and what it ended with. */
|
|
24
|
+
export type TimelineViewCall = {
|
|
25
|
+
id: string
|
|
26
|
+
tool: string
|
|
27
|
+
/** One letter, `$` for shell tools */
|
|
28
|
+
badge: string
|
|
29
|
+
color: string
|
|
30
|
+
label: string
|
|
31
|
+
detail: string
|
|
32
|
+
status: CallStatus
|
|
33
|
+
/** Made by a subagent, not the main thread */
|
|
34
|
+
isSub: boolean
|
|
35
|
+
/** Start and end of the bar, as shares of the axis from 0 to 1 */
|
|
36
|
+
from: number
|
|
37
|
+
to: number
|
|
38
|
+
barColor: string
|
|
39
|
+
/** Duration so far, e.g. `0.3s` */
|
|
40
|
+
time: string
|
|
41
|
+
/** The reason a denied or failed call gave, shortened */
|
|
42
|
+
note?: string
|
|
43
|
+
/** The label of the call that replaced a denied one */
|
|
44
|
+
replacement?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What the timeline's view gets: every call kept, oldest first, and their totals. */
|
|
48
|
+
export type TimelineViewData = {
|
|
49
|
+
count: number
|
|
50
|
+
total: string
|
|
51
|
+
calls: TimelineViewCall[]
|
|
52
|
+
}
|
|
53
|
+
|
|
23
54
|
declare module 'claude-code' {
|
|
24
55
|
interface PluginState {
|
|
25
|
-
'tool-timeline': { calls: TimelineCall[] }
|
|
56
|
+
'tool-timeline': { calls: TimelineCall[]; selected: string }
|
|
26
57
|
}
|
|
27
58
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/* packages/g3n-mods/mods/tool-timeline/view/view.css */
|
|
2
|
+
.tl {
|
|
3
|
+
--row-height: 26px;
|
|
4
|
+
--mono: var(--vscode-editor-font-family, ui-monospace, monospace);
|
|
5
|
+
--dim: var(--vscode-descriptionForeground, #9d9d9d);
|
|
6
|
+
--bad: #e06c6c;
|
|
7
|
+
--busy: #e5b567;
|
|
8
|
+
--track: color-mix(in srgb, var(--vscode-foreground) 10%, transparent);
|
|
9
|
+
display: flex;
|
|
10
|
+
flex-direction: column;
|
|
11
|
+
gap: 10px;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.tl-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
|
15
|
+
.tl-title { font-weight: 600; font-size: 14px; }
|
|
16
|
+
.tl-sum { color: var(--dim); font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
17
|
+
|
|
18
|
+
/* Ten rows tall whatever the number of calls; more scroll inside. */
|
|
19
|
+
.tl-rows {
|
|
20
|
+
display: flex;
|
|
21
|
+
flex-direction: column;
|
|
22
|
+
height: calc(var(--row-height) * 10);
|
|
23
|
+
overflow-y: auto;
|
|
24
|
+
overscroll-behavior: contain;
|
|
25
|
+
scrollbar-width: thin;
|
|
26
|
+
scrollbar-color: color-mix(in srgb, var(--vscode-foreground) 25%, transparent) transparent;
|
|
27
|
+
}
|
|
28
|
+
.tl-row { flex-shrink: 0; }
|
|
29
|
+
|
|
30
|
+
/* A row is a real button: badge, the call, its bar on the shared axis, its time. */
|
|
31
|
+
.tl-row {
|
|
32
|
+
display: grid;
|
|
33
|
+
grid-template-columns: 20px minmax(0, 1fr) minmax(80px, 34%) 64px;
|
|
34
|
+
align-items: center;
|
|
35
|
+
column-gap: 12px;
|
|
36
|
+
height: var(--row-height);
|
|
37
|
+
margin: 0;
|
|
38
|
+
padding: 0 8px;
|
|
39
|
+
border: 0;
|
|
40
|
+
border-radius: 6px;
|
|
41
|
+
background: transparent;
|
|
42
|
+
color: inherit;
|
|
43
|
+
font: inherit;
|
|
44
|
+
font-family: var(--mono);
|
|
45
|
+
font-size: 13px;
|
|
46
|
+
text-align: left;
|
|
47
|
+
cursor: pointer;
|
|
48
|
+
transition: background-color 100ms ease;
|
|
49
|
+
}
|
|
50
|
+
.tl-row:hover { background: color-mix(in srgb, var(--vscode-foreground) 6%, transparent); }
|
|
51
|
+
.tl-row:focus-visible { outline: 1px solid var(--vscode-focusBorder); outline-offset: -1px; }
|
|
52
|
+
.tl-row.is-selected { background: color-mix(in srgb, var(--vscode-foreground) 9%, transparent); }
|
|
53
|
+
.tl-row.tl-empty { cursor: default; pointer-events: none; }
|
|
54
|
+
|
|
55
|
+
.tl-badge {
|
|
56
|
+
display: inline-flex;
|
|
57
|
+
align-items: center;
|
|
58
|
+
justify-content: center;
|
|
59
|
+
width: 20px;
|
|
60
|
+
height: 20px;
|
|
61
|
+
border-radius: 5px;
|
|
62
|
+
background: var(--badge);
|
|
63
|
+
color: #1b1f27;
|
|
64
|
+
font-family: var(--mono);
|
|
65
|
+
font-size: 12px;
|
|
66
|
+
font-weight: 700;
|
|
67
|
+
line-height: 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.tl-label { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
|
71
|
+
.tl-tool { color: var(--vscode-foreground); }
|
|
72
|
+
.tl-arg { color: var(--dim); margin-left: 0.6ch; }
|
|
73
|
+
.tl-row[data-status="denied"] .tl-label { color: var(--bad); text-decoration: line-through; text-decoration-color: var(--bad); }
|
|
74
|
+
.tl-row[data-status="denied"] .tl-tool, .tl-row[data-status="denied"] .tl-arg { color: var(--bad); }
|
|
75
|
+
|
|
76
|
+
.tl-track { position: relative; height: 6px; border-radius: 3px; background: var(--track); overflow: hidden; }
|
|
77
|
+
.tl-fill { position: absolute; top: 0; bottom: 0; min-width: 3px; border-radius: 3px; transition: left 200ms ease, width 200ms ease; }
|
|
78
|
+
.tl-row[data-status="running"] .tl-fill { animation: tl-pulse 1.2s ease-in-out infinite; }
|
|
79
|
+
@keyframes tl-pulse { 50% { opacity: 0.55; } }
|
|
80
|
+
|
|
81
|
+
.tl-time { text-align: right; font-family: var(--mono); font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
82
|
+
.tl-time[data-tone="dim"] { color: var(--dim); }
|
|
83
|
+
.tl-time[data-tone="bad"] { color: var(--bad); }
|
|
84
|
+
.tl-time[data-tone="busy"] { color: var(--busy); }
|
|
85
|
+
|
|
86
|
+
/* The box under the rows keeps one height whatever it shows, so the pane never moves. */
|
|
87
|
+
.tl-detail {
|
|
88
|
+
box-sizing: border-box;
|
|
89
|
+
height: 150px;
|
|
90
|
+
padding: 8px 12px;
|
|
91
|
+
border: 1px solid var(--track);
|
|
92
|
+
border-radius: 8px;
|
|
93
|
+
overflow: hidden;
|
|
94
|
+
display: flex;
|
|
95
|
+
flex-direction: column;
|
|
96
|
+
justify-content: center;
|
|
97
|
+
gap: 4px;
|
|
98
|
+
}
|
|
99
|
+
.tl-detail.is-call { justify-content: flex-start; border-color: color-mix(in srgb, var(--badge) 55%, transparent); }
|
|
100
|
+
.tl-detail.is-denied { border-color: color-mix(in srgb, var(--bad) 45%, transparent); background: color-mix(in srgb, var(--bad) 7%, transparent); }
|
|
101
|
+
.tl-detail-head { display: grid; grid-template-columns: 20px 1fr auto; align-items: center; column-gap: 10px; }
|
|
102
|
+
.tl-detail-tool { font-weight: 600; font-family: var(--mono); font-size: 13px; }
|
|
103
|
+
.tl-detail-body {
|
|
104
|
+
flex: 1;
|
|
105
|
+
min-height: 0;
|
|
106
|
+
margin: 0;
|
|
107
|
+
overflow: auto;
|
|
108
|
+
white-space: pre-wrap;
|
|
109
|
+
word-break: break-word;
|
|
110
|
+
color: var(--dim);
|
|
111
|
+
font-family: var(--mono);
|
|
112
|
+
font-size: 12px;
|
|
113
|
+
line-height: 1.45;
|
|
114
|
+
}
|
|
115
|
+
.tl-detail-note { color: var(--bad); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
116
|
+
.tl-hint { color: var(--dim); font-size: 12px; text-align: center; }
|
|
117
|
+
|
|
118
|
+
.tl-callout { display: flex; align-items: center; justify-content: space-between; gap: 12px; font-family: var(--mono); font-size: 13px; }
|
|
119
|
+
.tl-callout-what { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
|
120
|
+
.tl-callout-mark { color: var(--bad); margin-right: 0.8ch; }
|
|
121
|
+
.tl-callout-from { color: var(--dim); text-decoration-color: var(--bad); }
|
|
122
|
+
.tl-callout-arrow { color: var(--dim); margin: 0 0.8ch; }
|
|
123
|
+
.tl-callout-to { color: #7cc47f; }
|
|
124
|
+
.tl-callout-why { color: var(--bad); font-family: var(--vscode-font-family, sans-serif); font-size: 12px; white-space: nowrap; }
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// packages/g3n-mods/mods/tool-timeline/view/view.ts
|
|
2
|
+
import type { TimelineViewCall, TimelineViewData } from '../types'
|
|
3
|
+
|
|
4
|
+
/** What the viewer hands a view: a way to send the mod an action and to open a link. */
|
|
5
|
+
type ViewApi = { act: (name: string, arg?: unknown) => void; openLink: (href: string) => void }
|
|
6
|
+
|
|
7
|
+
/** Rows the list shows at once; more calls scroll inside it, and fewer leave empty rows, so its height never changes. */
|
|
8
|
+
const ROWS = 10
|
|
9
|
+
|
|
10
|
+
/** Makes an element with a class, and text when given. */
|
|
11
|
+
function el<K extends keyof HTMLElementTagNameMap>(tag: K, className: string, text?: string): HTMLElementTagNameMap[K] {
|
|
12
|
+
const node = document.createElement(tag)
|
|
13
|
+
node.className = className
|
|
14
|
+
if (text !== undefined) node.textContent = text
|
|
15
|
+
return node
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Sets an element's text only when it changed, so the DOM is left alone between identical updates. */
|
|
19
|
+
function setText(node: Element, text: string): void {
|
|
20
|
+
if (node.textContent !== text) node.textContent = text
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The text and tone at the end of a row: its time, `denied`, `queued`, or its time with a mark. */
|
|
24
|
+
function outcomeOf(call: TimelineViewCall): { text: string; tone: string } {
|
|
25
|
+
switch (call.status) {
|
|
26
|
+
case 'denied': return { text: 'denied', tone: 'bad' }
|
|
27
|
+
case 'error': return { text: `${call.time} ✗`, tone: 'bad' }
|
|
28
|
+
case 'running': return { text: `${call.time}…`, tone: 'busy' }
|
|
29
|
+
case 'queued': return { text: 'queued', tone: 'dim' }
|
|
30
|
+
case 'ok': return { text: call.time, tone: 'dim' }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @brief Mounts the tool timeline: a header with the totals, a list of every kept call that shows ten at a time and
|
|
36
|
+
* scrolls, and a box under it that shows the clicked call in full, else the latest denied call. Each row is a real
|
|
37
|
+
* button. The list follows the newest call unless the person scrolled up to read.
|
|
38
|
+
*
|
|
39
|
+
* @param root - The shadow root the viewer gives this view.
|
|
40
|
+
* @param _api - Unused: the timeline sends its mod no actions.
|
|
41
|
+
* @returns The instance the viewer hands each update to.
|
|
42
|
+
*/
|
|
43
|
+
export function mount(root: ShadowRoot, _api: ViewApi): { update: (data: unknown) => void } {
|
|
44
|
+
const frame = el('div', 'tl')
|
|
45
|
+
const head = el('header', 'tl-head')
|
|
46
|
+
const title = el('span', 'tl-title', 'Tool timeline')
|
|
47
|
+
const sum = el('span', 'tl-sum')
|
|
48
|
+
head.append(title, sum)
|
|
49
|
+
const rows = el('div', 'tl-rows')
|
|
50
|
+
const detail = el('div', 'tl-detail')
|
|
51
|
+
frame.append(head, rows, detail)
|
|
52
|
+
root.append(frame)
|
|
53
|
+
|
|
54
|
+
const byId = new Map<string, HTMLButtonElement>()
|
|
55
|
+
let calls: TimelineViewCall[] = []
|
|
56
|
+
let selected: string | undefined
|
|
57
|
+
|
|
58
|
+
/** Builds the row of a call once; `fill` keeps it current. */
|
|
59
|
+
const makeRow = (id: string): HTMLButtonElement => {
|
|
60
|
+
const row = el('button', 'tl-row')
|
|
61
|
+
row.type = 'button'
|
|
62
|
+
row.dataset.id = id
|
|
63
|
+
const badge = el('span', 'tl-badge')
|
|
64
|
+
const label = el('span', 'tl-label')
|
|
65
|
+
label.append(el('span', 'tl-tool'), el('span', 'tl-arg'))
|
|
66
|
+
const track = el('span', 'tl-track')
|
|
67
|
+
track.append(el('span', 'tl-fill'))
|
|
68
|
+
const time = el('span', 'tl-time')
|
|
69
|
+
row.append(badge, label, track, time)
|
|
70
|
+
row.addEventListener('click', () => {
|
|
71
|
+
selected = selected === id ? undefined : id
|
|
72
|
+
draw()
|
|
73
|
+
})
|
|
74
|
+
return row
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Brings a row up to date with its call. */
|
|
78
|
+
const fill = (row: HTMLButtonElement, call: TimelineViewCall): void => {
|
|
79
|
+
const [badge, label, track, time] = Array.from(row.children) as HTMLElement[]
|
|
80
|
+
if (badge === undefined || label === undefined || track === undefined || time === undefined) return
|
|
81
|
+
setText(badge, call.badge)
|
|
82
|
+
badge.style.setProperty('--badge', call.color)
|
|
83
|
+
const [tool, arg] = Array.from(label.children) as HTMLElement[]
|
|
84
|
+
if (tool !== undefined) setText(tool, `${call.isSub ? '↳ ' : ''}${call.tool}`)
|
|
85
|
+
if (arg !== undefined) setText(arg, call.label)
|
|
86
|
+
row.title = `${call.tool} ${call.detail}`
|
|
87
|
+
const bar = track.firstElementChild as HTMLElement
|
|
88
|
+
const from = Math.min(1, Math.max(0, call.from))
|
|
89
|
+
const width = call.status === 'denied' ? 0.03 : Math.max(0.02, call.to - call.from)
|
|
90
|
+
bar.style.left = `${(Math.min(from, 1 - width) * 100).toFixed(2)}%`
|
|
91
|
+
bar.style.width = `${(width * 100).toFixed(2)}%`
|
|
92
|
+
bar.style.background = call.barColor
|
|
93
|
+
const outcome = outcomeOf(call)
|
|
94
|
+
setText(time, outcome.text)
|
|
95
|
+
time.dataset.tone = outcome.tone
|
|
96
|
+
row.dataset.status = call.status
|
|
97
|
+
row.classList.toggle('is-selected', call.id === selected)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Draws the box under the rows: the selected call in full, else the latest denied call, else a hint. */
|
|
101
|
+
const drawDetail = (): void => {
|
|
102
|
+
const picked = calls.find(call => call.id === selected)
|
|
103
|
+
const denied = [...calls].reverse().find(call => call.status === 'denied')
|
|
104
|
+
detail.replaceChildren()
|
|
105
|
+
detail.className = 'tl-detail'
|
|
106
|
+
if (picked !== undefined) {
|
|
107
|
+
detail.classList.add('is-call')
|
|
108
|
+
detail.style.setProperty('--badge', picked.color)
|
|
109
|
+
const top = el('div', 'tl-detail-head')
|
|
110
|
+
const badge = el('span', 'tl-badge', picked.badge)
|
|
111
|
+
badge.style.setProperty('--badge', picked.color)
|
|
112
|
+
const outcome = outcomeOf(picked)
|
|
113
|
+
const time = el('span', 'tl-time', outcome.text)
|
|
114
|
+
time.dataset.tone = outcome.tone
|
|
115
|
+
top.append(badge, el('span', 'tl-detail-tool', picked.tool), time)
|
|
116
|
+
detail.append(top, el('pre', 'tl-detail-body', picked.detail))
|
|
117
|
+
if (picked.note !== undefined && picked.status !== 'ok') detail.append(el('div', 'tl-detail-note', picked.note))
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
if (denied !== undefined) {
|
|
121
|
+
detail.classList.add('is-denied')
|
|
122
|
+
const line = el('div', 'tl-callout')
|
|
123
|
+
const what = el('span', 'tl-callout-what')
|
|
124
|
+
what.append(el('span', 'tl-callout-mark', '⊘'), el('s', 'tl-callout-from', denied.label))
|
|
125
|
+
if (denied.replacement !== undefined) what.append(el('span', 'tl-callout-arrow', '→'), el('span', 'tl-callout-to', denied.replacement))
|
|
126
|
+
line.append(what, el('span', 'tl-callout-why', `denied${denied.note === undefined ? '' : ` · ${denied.note}`}`))
|
|
127
|
+
detail.append(line)
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
detail.append(el('div', 'tl-hint', calls.length === 0 ? 'No tool calls yet.' : 'Click a call to see it in full.'))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Lays the rows out: the calls' rows in order, then empty slots up to `ROWS`; keeps to the newest when it was there. */
|
|
134
|
+
const draw = (): void => {
|
|
135
|
+
const isAtEnd = rows.scrollHeight - rows.scrollTop - rows.clientHeight < 4
|
|
136
|
+
const wanted: HTMLElement[] = calls.map(call => {
|
|
137
|
+
let row = byId.get(call.id)
|
|
138
|
+
if (row === undefined) {
|
|
139
|
+
row = makeRow(call.id)
|
|
140
|
+
byId.set(call.id, row)
|
|
141
|
+
}
|
|
142
|
+
fill(row, call)
|
|
143
|
+
return row
|
|
144
|
+
})
|
|
145
|
+
for (const [id, row] of byId) if (!calls.some(call => call.id === id)) { row.remove(); byId.delete(id) }
|
|
146
|
+
for (let i = wanted.length; i < ROWS; i++) wanted.push((rows.children[i] as HTMLElement | undefined)?.classList.contains('tl-empty') === true ? rows.children[i] as HTMLElement : el('div', 'tl-row tl-empty'))
|
|
147
|
+
wanted.forEach((row, index) => {
|
|
148
|
+
if (rows.children[index] !== row) rows.insertBefore(row, rows.children[index] ?? null)
|
|
149
|
+
})
|
|
150
|
+
while (rows.children.length > wanted.length) rows.lastElementChild?.remove()
|
|
151
|
+
if (isAtEnd) rows.scrollTop = rows.scrollHeight
|
|
152
|
+
drawDetail()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
update: (data: unknown) => {
|
|
157
|
+
const view = data as TimelineViewData | null
|
|
158
|
+
calls = view?.calls ?? []
|
|
159
|
+
if (selected !== undefined && !calls.some(call => call.id === selected)) selected = undefined
|
|
160
|
+
setText(sum, view === null ? '' : `${view.count} call${view.count === 1 ? '' : 's'} · ${view.total}`)
|
|
161
|
+
draw()
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "es2023",
|
|
4
|
+
"lib": ["es2023", "dom", "dom.iterable"],
|
|
5
|
+
"types": [],
|
|
6
|
+
"module": "esnext",
|
|
7
|
+
"moduleResolution": "bundler",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noUncheckedIndexedAccess": true,
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"skipLibCheck": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["*/view", "*/hooks/sprite.ts"]
|
|
14
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@g3nomaly/g3n-mods",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
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": [
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
],
|
|
10
10
|
"scripts": {
|
|
11
11
|
"build": "bun run build:marketplace",
|
|
12
|
-
"typecheck": "tsc --noEmit && tsc --noEmit --project mods/tsconfig.json",
|
|
12
|
+
"typecheck": "tsc --noEmit && tsc --noEmit --project mods/tsconfig.json && tsc --noEmit --project mods/tsconfig.view.json",
|
|
13
13
|
"validate": "bun scripts/validate.ts",
|
|
14
14
|
"build:marketplace": "bun scripts/build-marketplace.ts",
|
|
15
15
|
"types": "bun scripts/sync-types.ts"
|