@g3nomaly/g3n-mods 0.1.4 → 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,24 @@ 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
+
47
+ /** Most characters of a call's label kept; the pane cuts it to one line anyway. */
48
+ const LABEL_MAX = 200
49
+
40
50
  /** Input fields that say most about a call, tried in order. */
41
51
  const LABEL_FIELDS = ['command', 'file_path', 'notebook_path', 'pattern', 'url', 'query', 'description', 'skill', 'prompt'] as const
42
52
 
@@ -62,14 +72,14 @@ export function labelOf(input: Record<string, unknown>): string {
62
72
  const value = input[field]
63
73
  if (typeof value !== 'string' || value.length === 0) continue
64
74
  const text = field === 'file_path' || field === 'notebook_path' ? value.split('/').pop() ?? value : value
65
- return text.replace(/\s+/g, ' ').trim()
75
+ return text.replace(/\s+/g, ' ').trim().slice(0, LABEL_MAX)
66
76
  }
67
77
  const first = Object.values(input).find((value): value is string => typeof value === 'string')
68
- return first?.replace(/\s+/g, ' ').trim() ?? ''
78
+ return first?.replace(/\s+/g, ' ').trim().slice(0, LABEL_MAX) ?? ''
69
79
  }
70
80
 
71
81
  /**
72
- * @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.
73
83
  *
74
84
  * @param input - Arguments of the tool call.
75
85
  * @returns The text with its line breaks, cut at `DETAIL_MAX` characters.
@@ -109,6 +119,24 @@ export function placeCalls(list: readonly TimelineCall[], now: number): { spans:
109
119
  return { spans, length }
110
120
  }
111
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
+
112
140
  /**
113
141
  * @brief Joins the text of a tool result's content, which is a string or a list of text blocks.
114
142
  *
@@ -208,6 +236,16 @@ function barSvg(width: number, x: number, fill: number, color: string): string {
208
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>`
209
237
  }
210
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
+
211
249
  /**
212
250
  * @brief Subscribes the timeline's handlers to the engine's events, its command and its pane.
213
251
  *
@@ -280,17 +318,53 @@ export const register: Register = on => {
280
318
  }).catch(($, e, next) => next(e))
281
319
 
282
320
  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
283
- const { Box, Text } = $.ui.resolve(e)
321
+ const { Box, Text, Button } = $.ui.resolve(e)
284
322
  const list = await read($, calls)
285
- if (list.length === 0) return <Text dimColor>No tool calls yet.</Text>
286
-
323
+ const chosen = await read($, selected)
287
324
  const now = await $.clock.now()
288
- const room = Math.max(4, (e.viewport?.rows ?? 30) - 6)
289
- const shown = list.slice(-room)
325
+ const shown = list.slice(-ROWS)
290
326
  /** Length of a call so far, or up to its end when it has one. */
291
327
  const durationOf = (call: TimelineCall): number => (call.endedAt ?? now) - call.startedAt
292
328
  const total = list.reduce((sum, call) => sum + (call.status === 'denied' ? 0 : durationOf(call)), 0)
293
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
+
294
368
  const barCells = Math.max(8, Math.floor(e.props.bodyColumns * 0.3))
295
369
  const labelCells = Math.max(12, e.props.bodyColumns - barCells - 12)
296
370
 
@@ -328,38 +402,69 @@ export const register: Register = on => {
328
402
  }
329
403
  }
330
404
 
331
- // 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.
332
407
  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>
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
+ )}
344
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>
345
431
  </Box>
346
432
  ))
347
433
 
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
- ]
362
- })
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)
363
468
 
364
469
  return (
365
470
  <Box flexDirection="column" gap={0}>
@@ -368,7 +473,8 @@ export const register: Register = on => {
368
473
  <Text dimColor>{list.length} call{list.length === 1 ? '' : 's'} · {seconds(total)}</Text>
369
474
  </Box>
370
475
  {rows}
371
- {callouts.length > 0 && <Box flexDirection="column" marginTop={1}>{callouts}</Box>}
476
+ {slots}
477
+ {callout}
372
478
  </Box>
373
479
  )
374
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.4",
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"