@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.
@@ -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
- /** Background of a row's hover card. */
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 hover card keeps. */
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 row's hover card: the full command, path, pattern or first text field.
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
- if (list.length === 0) return <Text dimColor>No tool calls yet.</Text>
289
-
323
+ const chosen = await read($, selected)
290
324
  const now = await $.clock.now()
291
- const room = Math.max(4, (e.viewport?.rows ?? 30) - 6)
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 keyed row reveals a card with the call's whole input while the pointer is on it.
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
- <Box key={call.id} flexDirection="row" columnGap={1}>
337
- <Text bold color={CARD} backgroundColor={colorOf(call.tool)}>{e.surface === 'terminal' ? ` ${badgeOf(call.tool)} ` : badgeOf(call.tool)}</Text>
338
- <Box width={labelCells}>
339
- <Text wrap="truncate-end" strikethrough={call.status === 'denied'} color={call.status === 'denied' ? ERROR : undefined}>
340
- {call.agentId === undefined ? '' : '↳ '}{call.tool} <Text dimColor>{call.label}</Text>
341
- </Text>
342
- </Box>
343
- {bar(call, axis.spans[index] ?? { from: 0, to: 0 })}
344
- <Box width={8} justifyContent="flex-end">{outcome(call)}</Box>
345
- <Box position="absolute" top={1} left={4} right={0} display="none" hover={{ display: 'flex' }} backgroundColor={CARD} borderStyle="round" borderColor={DIM} paddingX={1}>
346
- <Text wrap="wrap">{call.tool} <Text dimColor>{call.detail ?? call.label}</Text></Text>
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
- // Denied calls get their callout under the list, so every call keeps to its one line.
352
- const callouts = shown.flatMap((call, index) => {
353
- if (call.status !== 'denied') return []
354
- const replacement = replacementOf(shown, index)
355
- return [
356
- <Box borderStyle="round" borderColor="#7a3b3b" paddingX={1} flexDirection="row" justifyContent="space-between">
357
- <Text wrap="truncate-end">
358
- <Text color={ERROR}>⊘ </Text>
359
- <Text strikethrough dimColor>{call.label}</Text>
360
- {replacement === undefined ? '' : <Text> → <Text color="#7cc47f">{replacement.label}</Text></Text>}
361
- </Text>
362
- <Text color={ERROR}>denied{call.note === undefined ? '' : ` · ${shortNote(call.note)}`}</Text>
363
- </Box>,
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
- {callouts.length > 0 && <Box flexDirection="column" marginTop={1}>{callouts}</Box>}
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 row's hover card, e.g. the full command or path */
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.5",
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"