@g3nomaly/g3n-mods 0.0.0-stage → 0.1.0

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.
Files changed (45) hide show
  1. package/.claude-plugin/marketplace.json +68 -0
  2. package/LICENSE +95 -0
  3. package/mods/agents-panel/.claude-plugin/plugin.json +9 -0
  4. package/mods/agents-panel/hooks/hooks.json +1 -0
  5. package/mods/agents-panel/hooks/register.tsx +224 -0
  6. package/mods/agents-panel/types/index.d.ts +24 -0
  7. package/mods/context-bar/.claude-plugin/plugin.json +9 -0
  8. package/mods/context-bar/hooks/hooks.json +1 -0
  9. package/mods/context-bar/hooks/register.tsx +187 -0
  10. package/mods/context-bar/types/index.d.ts +25 -0
  11. package/mods/custom-crab-agents/.claude-plugin/plugin.json +9 -0
  12. package/mods/custom-crab-agents/hooks/hooks.json +1 -0
  13. package/mods/custom-crab-agents/hooks/register.tsx +369 -0
  14. package/mods/custom-crab-agents/types/index.d.ts +45 -0
  15. package/mods/delete-guard/.claude-plugin/plugin.json +9 -0
  16. package/mods/delete-guard/hooks/hooks.json +1 -0
  17. package/mods/delete-guard/hooks/register.ts +117 -0
  18. package/mods/delete-guard/types/index.d.ts +14 -0
  19. package/mods/focus-mode/.claude-plugin/plugin.json +17 -0
  20. package/mods/focus-mode/hooks/hooks.json +1 -0
  21. package/mods/focus-mode/hooks/register.tsx +147 -0
  22. package/mods/focus-mode/types/index.d.ts +14 -0
  23. package/mods/handoff/.claude-plugin/plugin.json +9 -0
  24. package/mods/handoff/hooks/hooks.json +1 -0
  25. package/mods/handoff/hooks/register.tsx +119 -0
  26. package/mods/handoff/types/index.d.ts +15 -0
  27. package/mods/hide-personal-data/.claude-plugin/plugin.json +17 -0
  28. package/mods/hide-personal-data/hooks/hooks.json +1 -0
  29. package/mods/hide-personal-data/hooks/register.tsx +163 -0
  30. package/mods/hide-personal-data/types/index.d.ts +17 -0
  31. package/mods/mini-game/.claude-plugin/plugin.json +9 -0
  32. package/mods/mini-game/hooks/hooks.json +1 -0
  33. package/mods/mini-game/hooks/register.tsx +211 -0
  34. package/mods/mini-game/types/index.d.ts +21 -0
  35. package/mods/tool-timeline/.claude-plugin/plugin.json +9 -0
  36. package/mods/tool-timeline/hooks/hooks.json +1 -0
  37. package/mods/tool-timeline/hooks/register.tsx +259 -0
  38. package/mods/tool-timeline/types/index.d.ts +25 -0
  39. package/mods/tsconfig.json +17 -0
  40. package/mods/you-should-know/.claude-plugin/plugin.json +23 -0
  41. package/mods/you-should-know/hooks/hooks.json +1 -0
  42. package/mods/you-should-know/hooks/register.tsx +98 -0
  43. package/mods/you-should-know/types/index.d.ts +15 -0
  44. package/package.json +25 -4
  45. package/README.md +0 -3
@@ -0,0 +1,211 @@
1
+ // packages/g3n-mods/mods/mini-game/hooks/register.tsx
2
+ import { atom, read, update } from 'claude-code'
3
+ import type { EngineInterface, Register } from 'claude-code'
4
+
5
+ import type { Game } from '../types'
6
+
7
+ const PANE = 'mini-game'
8
+ const TITLE = 'Minesweeper'
9
+ const game = atom({ plugin: 'mini-game', key: 'game' } as const, null)
10
+ const best = atom({ plugin: 'mini-game', key: 'best' } as const, null)
11
+
12
+ const SIZE = 9
13
+ const MINES = 10
14
+ const BEST_KEY = 'best-seconds'
15
+ /** How long a turn runs before the mod suggests a game, once per session. */
16
+ const HINT_AFTER_MS = 60_000
17
+ const NUMBER_COLORS = [undefined, '#6b8fd6', '#7cc47f', '#e06c6c', '#9b87f5', '#e0775a', '#5fb3c4', '#d0d0d0', '#8a8f98']
18
+
19
+ /** Cells around `cell` on a `size` × `size` board. */
20
+ export function neighbors(cell: number, size: number): number[] {
21
+ const row = Math.floor(cell / size)
22
+ const col = cell % size
23
+ const found: number[] = []
24
+ for (let dr = -1; dr <= 1; dr++) {
25
+ for (let dc = -1; dc <= 1; dc++) {
26
+ const r = row + dr
27
+ const c = col + dc
28
+ if ((dr !== 0 || dc !== 0) && r >= 0 && r < size && c >= 0 && c < size) found.push(r * size + c)
29
+ }
30
+ }
31
+ return found
32
+ }
33
+
34
+ function adjacentMines(board: Game, cell: number): number {
35
+ return neighbors(cell, board.size).filter(n => board.mines.includes(n)).length
36
+ }
37
+
38
+ /** Places mines anywhere but `safe` and the cells around it. */
39
+ function placeMines(board: Game, safe: number): number[] {
40
+ const banned = new Set([safe, ...neighbors(safe, board.size)])
41
+ const free = Array.from({ length: board.size * board.size }, (_, i) => i).filter(i => !banned.has(i))
42
+ for (let i = free.length - 1; i > 0; i--) {
43
+ const j = Math.floor(Math.random() * (i + 1))
44
+ ;[free[i], free[j]] = [free[j] ?? 0, free[i] ?? 0]
45
+ }
46
+ return free.slice(0, board.mineCount)
47
+ }
48
+
49
+ /**
50
+ * Reveals one cell: the first reveal places the mines, a zero opens its
51
+ * neighbours, a mine ends the game, and the last safe cell wins it.
52
+ *
53
+ * @param now - Milliseconds since the epoch, stamped as the start or end time
54
+ */
55
+ export function reveal(board: Game, cell: number, now: number): Game {
56
+ if (board.status === 'won' || board.status === 'lost') return board
57
+ if (board.revealed.includes(cell) || board.flagged.includes(cell)) return board
58
+
59
+ const mines = board.status === 'ready' ? placeMines(board, cell) : board.mines
60
+ const started: Game = board.status === 'ready' ? { ...board, mines, status: 'playing', startedAt: now } : board
61
+ if (mines.includes(cell)) return { ...started, revealed: [...started.revealed, cell], status: 'lost', endedAt: now }
62
+
63
+ const open = new Set(started.revealed)
64
+ const queue = [cell]
65
+ while (queue.length > 0) {
66
+ const next = queue.pop() ?? cell
67
+ if (open.has(next) || started.flagged.includes(next)) continue
68
+ open.add(next)
69
+ if (adjacentMines(started, next) === 0) queue.push(...neighbors(next, started.size).filter(n => !open.has(n)))
70
+ }
71
+
72
+ const revealed = [...open]
73
+ const isWon = revealed.length === started.size * started.size - started.mineCount
74
+ return isWon ? { ...started, revealed, status: 'won', endedAt: now } : { ...started, revealed }
75
+ }
76
+
77
+ function newGame(now: number): Game {
78
+ return { size: SIZE, mineCount: MINES, mines: [], revealed: [], flagged: [], status: 'ready', isFlagMode: false, startedAt: now }
79
+ }
80
+
81
+ /** Starts a new board and shows the pane. */
82
+ async function startGame($: EngineInterface): Promise<void> {
83
+ const now = await $.clock.now()
84
+ await update($, game, () => newGame(now))
85
+ await $.ui.open({ id: PANE, title: TITLE })
86
+ }
87
+
88
+ /** Reveals or flags a cell, as the flag mode says, and records a best time on a win. */
89
+ async function pressCell($: EngineInterface, cell: number): Promise<void> {
90
+ const now = await $.clock.now()
91
+ const after = await update($, game, board => {
92
+ if (board === null) return board
93
+ if (!board.isFlagMode) return reveal(board, cell, now)
94
+ if (board.revealed.includes(cell) || board.status === 'won' || board.status === 'lost') return board
95
+ const flagged = board.flagged.includes(cell) ? board.flagged.filter(c => c !== cell) : [...board.flagged, cell]
96
+ return { ...board, flagged }
97
+ })
98
+ if (after?.status === 'won' && after.endedAt === now) {
99
+ const seconds = Math.round((now - after.startedAt) / 1000)
100
+ const previous = await read($, best)
101
+ if (previous === null || seconds < previous) {
102
+ await update($, best, () => seconds)
103
+ await $.store.set(BEST_KEY, seconds)
104
+ }
105
+ $.ui.toast(`Cleared in ${seconds}s${previous === null || seconds < previous ? ', a new best' : ''}`)
106
+ }
107
+ }
108
+
109
+ async function toggleFlagMode($: EngineInterface): Promise<void> {
110
+ await update($, game, board => (board === null ? board : { ...board, isFlagMode: !board.isFlagMode }))
111
+ }
112
+
113
+ /** Redraws the timer once a second while a game is on. */
114
+ async function tick($: EngineInterface): Promise<void> {
115
+ if ((await read($, game))?.status === 'playing') $.ui.invalidate('ui.render')
116
+ }
117
+
118
+ let hintTurn: string | undefined
119
+ let isHinted = false
120
+
121
+ /** Suggests a game once per session, when a turn is still running after a minute. */
122
+ async function hint($: EngineInterface, turnId: string): Promise<void> {
123
+ if (isHinted || hintTurn !== turnId) return
124
+ if ((await $.ui.panes()).some(pane => pane.id === PANE)) return
125
+ isHinted = true
126
+ $.ui.toast('Waiting on Claude? /mini-game opens Minesweeper.')
127
+ }
128
+
129
+ export const register: Register = on => {
130
+ on('session.start', async ($, e, next) => {
131
+ await $.command.register({ name: 'mini-game', description: 'Play Minesweeper while Claude works' })
132
+ const stored = await $.store.get(BEST_KEY)
133
+ if (typeof stored === 'number') await update($, best, () => stored)
134
+ $.clock.every(1000, () => void tick($))
135
+ return next(e)
136
+ })
137
+
138
+ on('command.run', { command: 'mini-game' }, async $ => {
139
+ const current = await read($, game)
140
+ if (current === null || current.status === 'won' || current.status === 'lost') await startGame($)
141
+ else await $.ui.open({ id: PANE, title: TITLE })
142
+ return { text: 'Minesweeper is open. Clear the board without hitting a mine.' }
143
+ })
144
+
145
+ on('turn.start', async ($, e, next) => {
146
+ hintTurn = e.turnId
147
+ const turnId = e.turnId
148
+ $.clock.after(HINT_AFTER_MS, () => void hint($, turnId))
149
+ return next(e)
150
+ })
151
+
152
+ on('turn.complete', async ($, e, next) => {
153
+ if (e.agentId === undefined) hintTurn = undefined
154
+ return next(e)
155
+ })
156
+
157
+ on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
158
+ const { Box, Text, Button } = $.ui.resolve(e)
159
+ const board = await read($, game)
160
+ if (board === null) {
161
+ return <Button key="new" label="new game" variant="primary" onPress={() => startGame($)} />
162
+ }
163
+
164
+ const now = await $.clock.now()
165
+ const elapsed = board.status === 'ready' ? 0 : Math.round(((board.endedAt ?? now) - board.startedAt) / 1000)
166
+ const record = await read($, best)
167
+ const isOver = board.status === 'won' || board.status === 'lost'
168
+
169
+ const cellView = (cell: number) => {
170
+ const isRevealed = board.revealed.includes(cell)
171
+ const isMine = board.mines.includes(cell)
172
+ if (isRevealed || (isOver && isMine)) {
173
+ if (isMine) return <Text color="#e06c6c">✹ </Text>
174
+ const n = adjacentMines(board, cell)
175
+ return <Text color={NUMBER_COLORS[n]}>{n === 0 ? ' ' : `${n} `}</Text>
176
+ }
177
+ const label = board.flagged.includes(cell) ? '⚑' : '■'
178
+ if (isOver) return <Text dimColor>{label} </Text>
179
+ return (
180
+ <Button key={`cell:${cell}`} plain onPress={() => pressCell($, cell)}>
181
+ <Text color={label === '⚑' ? '#e0775a' : '#6b7280'}>{label}</Text>
182
+ </Button>
183
+ )
184
+ }
185
+
186
+ const rows = Array.from({ length: board.size }, (_, r) => (
187
+ <Box flexDirection="row" columnGap={1}>
188
+ {Array.from({ length: board.size }, (_, c) => cellView(r * board.size + c))}
189
+ </Box>
190
+ ))
191
+
192
+ const status = board.status === 'won'
193
+ ? <Text color="#7cc47f" bold>Cleared in {elapsed}s</Text>
194
+ : board.status === 'lost'
195
+ ? <Text color="#e06c6c" bold>Boom. {board.revealed.length} cells cleared</Text>
196
+ : <Text dimColor>💣 {board.mineCount - board.flagged.length} · ⏱ {elapsed}s{record === null ? '' : ` · best ${record}s`}</Text>
197
+
198
+ return (
199
+ <Box flexDirection="column" gap={1}>
200
+ <Box flexDirection="row" justifyContent="space-between" alignItems="center">
201
+ {status}
202
+ <Box flexDirection="row" columnGap={1}>
203
+ {isOver ? '' : <Button key="flag" label={board.isFlagMode ? '⚑ flagging' : '⚑ flag'} variant={board.isFlagMode ? 'primary' : 'secondary'} onPress={() => toggleFlagMode($)} />}
204
+ <Button key="new" label="new game" onPress={() => startGame($)} />
205
+ </Box>
206
+ </Box>
207
+ <Box flexDirection="column">{rows}</Box>
208
+ </Box>
209
+ )
210
+ })
211
+ }
@@ -0,0 +1,21 @@
1
+ // packages/g3n-mods/mods/mini-game/types/index.d.ts
2
+
3
+ /** A Minesweeper board; cells are numbered row by row from 0. Times are milliseconds since the epoch. */
4
+ export type Game = {
5
+ size: number
6
+ mineCount: number
7
+ /** Empty until the first reveal, which never hits a mine */
8
+ mines: number[]
9
+ revealed: number[]
10
+ flagged: number[]
11
+ status: 'ready' | 'playing' | 'won' | 'lost'
12
+ isFlagMode: boolean
13
+ startedAt: number
14
+ endedAt?: number
15
+ }
16
+
17
+ declare module 'claude-code' {
18
+ interface PluginState {
19
+ 'mini-game': { game: Game | null; best: number | null }
20
+ }
21
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "tool-timeline",
3
+ "version": "0.1.0",
4
+ "description": "Each tool call with its duration; a denied call is struck through next to the call that replaced it.",
5
+ "author": {
6
+ "name": "g3ntrovert"
7
+ },
8
+ "types": "./types/index.d.ts"
9
+ }
@@ -0,0 +1 @@
1
+ { "modules": ["./register.tsx"] }
@@ -0,0 +1,259 @@
1
+ // packages/g3n-mods/mods/tool-timeline/hooks/register.tsx
2
+ import { atom, read, update } from 'claude-code'
3
+ import type { EngineInterface, Register } from 'claude-code'
4
+
5
+ import type { CallStatus, TimelineCall } from '../types'
6
+
7
+ const PANE = 'tool-timeline'
8
+ const TITLE = 'Tool timeline'
9
+ const calls = atom({ plugin: 'tool-timeline', key: 'calls' } as const, [])
10
+
11
+ /** Calls kept in state; older ones fall off. */
12
+ const KEEP = 100
13
+
14
+ /** Approximate pixel width of one cell of the editor's code font. */
15
+ const CELL_PX = 7
16
+
17
+ const TRACK = '#2b3240'
18
+ const ERROR = '#e06c6c'
19
+ const DIM = '#8a8f98'
20
+
21
+ /** Badge color per tool family. */
22
+ const TOOL_COLORS: ReadonlyArray<[RegExp, string]> = [
23
+ [/^Bash$|^PowerShell$/, '#e5b567'],
24
+ [/^Read$/, '#5b8def'],
25
+ [/^(Edit|Write|NotebookEdit)$/, '#7cc47f'],
26
+ [/^(Grep|Glob|LS)$/, '#9b87f5'],
27
+ [/^Web/, '#5fb3c4'],
28
+ [/^(Agent|Task)$/, '#f09bb5'],
29
+ ]
30
+
31
+ /** Input fields that say most about a call, tried in order. */
32
+ const LABEL_FIELDS = ['command', 'file_path', 'notebook_path', 'pattern', 'url', 'query', 'description', 'skill', 'prompt'] as const
33
+
34
+ function colorOf(tool: string): string {
35
+ return TOOL_COLORS.find(([match]) => match.test(tool))?.[1] ?? DIM
36
+ }
37
+
38
+ function badgeOf(tool: string): string {
39
+ if (tool === 'Bash' || tool === 'PowerShell') return '$'
40
+ return tool.replace(/^mcp__[^_]+__/, '').charAt(0).toUpperCase()
41
+ }
42
+
43
+ /** Short form of a call's input: a command, a file's name, a pattern, or the first text field. */
44
+ export function labelOf(input: Record<string, unknown>): string {
45
+ for (const field of LABEL_FIELDS) {
46
+ const value = input[field]
47
+ if (typeof value !== 'string' || value.length === 0) continue
48
+ const text = field === 'file_path' || field === 'notebook_path' ? value.split('/').pop() ?? value : value
49
+ return text.replace(/\s+/g, ' ').trim()
50
+ }
51
+ const first = Object.values(input).find((value): value is string => typeof value === 'string')
52
+ return first?.replace(/\s+/g, ' ').trim() ?? ''
53
+ }
54
+
55
+ /** Text of a tool result's content, which is a string or a list of text blocks. */
56
+ function resultText(content: unknown): string {
57
+ if (typeof content === 'string') return content
58
+ if (!Array.isArray(content)) return ''
59
+ return content
60
+ .map(block => (typeof block === 'object' && block !== null && 'text' in block && typeof block.text === 'string' ? block.text : ''))
61
+ .join('\n')
62
+ }
63
+
64
+ /** Whether an error text reads as a refusal: a hook's deny, the permission dialog's no, a blocked call. */
65
+ function isRefusal(text: string): boolean {
66
+ return /\bdenied\b|\bblocked\b|doesn't want to proceed|was rejected|permission/i.test(text)
67
+ }
68
+
69
+ function seconds(ms: number): string {
70
+ return ms >= 10_000 ? `${Math.round(ms / 1000)}s` : `${(ms / 1000).toFixed(1)}s`
71
+ }
72
+
73
+ /** Short note for the callout, e.g. `use the trash` for delete-guard's deny. */
74
+ function shortNote(note: string | undefined): string {
75
+ if (note === undefined) return ''
76
+ if (/trash/i.test(note)) return 'use the trash'
77
+ const text = note.replace(/^[\w-]+:\s*/, '')
78
+ return text.length > 40 ? `${text.slice(0, 39)}…` : text
79
+ }
80
+
81
+ /** Adds a call or changes the one with its id; the list stays in start order and capped. */
82
+ async function patchCall($: EngineInterface, id: string, patch: (call: TimelineCall | undefined) => TimelineCall): Promise<void> {
83
+ await update($, calls, list => {
84
+ const index = list.findIndex(call => call.id === id)
85
+ if (index === -1) return [...list, patch(undefined)].slice(-KEEP)
86
+ return list.map((call, i) => (i === index ? patch(call) : call))
87
+ })
88
+ }
89
+
90
+ /** Ends a call with its status, keeping the first ending it got. */
91
+ async function settleCall($: EngineInterface, id: string, status: CallStatus, note?: string): Promise<void> {
92
+ const endedAt = await $.clock.now()
93
+ await patchCall($, id, call =>
94
+ call === undefined
95
+ ? { id, tool: 'tool', label: '', status, startedAt: endedAt, endedAt, note }
96
+ : call.endedAt !== undefined ? call : { ...call, status, endedAt, note },
97
+ )
98
+ }
99
+
100
+ /** Redraws the pane while a call runs, so its bar and time move. */
101
+ async function tick($: EngineInterface): Promise<void> {
102
+ const list = await read($, calls)
103
+ if (list.some(call => call.status === 'running')) $.ui.invalidate('ui.render')
104
+ }
105
+
106
+ /** The call a denied call was replaced by: the next call of the same tool. */
107
+ function replacementOf(list: readonly TimelineCall[], index: number): TimelineCall | undefined {
108
+ const denied = list[index]
109
+ return denied === undefined ? undefined : list.slice(index + 1).find(call => call.tool === denied.tool)
110
+ }
111
+
112
+ function barSvg(width: number, fill: number, color: string): string {
113
+ const w = Math.max(0, Math.min(width, fill))
114
+ 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>`
115
+ }
116
+
117
+ export const register: Register = on => {
118
+ on('session.start', async ($, e, next) => {
119
+ await $.command.register({ name: 'tool-timeline', description: 'Show or hide the tool call timeline' })
120
+ void $.ui.open({ id: PANE, title: TITLE })
121
+ $.clock.every(1000, () => void tick($))
122
+ return next(e)
123
+ })
124
+
125
+ on('command.run', { command: 'tool-timeline' }, async $ => {
126
+ if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
127
+ await $.ui.close({ id: PANE })
128
+ return { text: 'Tool timeline hidden.' }
129
+ }
130
+ await $.ui.open({ id: PANE, title: TITLE })
131
+ return { text: 'Tool timeline shown.' }
132
+ })
133
+
134
+ on('tool.call', async ($, e, next) => {
135
+ const startedAt = await $.clock.now()
136
+ const label = labelOf(e as unknown as Record<string, unknown>)
137
+ await patchCall($, e.tool_use_id, call => ({
138
+ ...call,
139
+ id: e.tool_use_id,
140
+ tool: e.tool,
141
+ label,
142
+ status: 'running',
143
+ startedAt,
144
+ agentId: e.agentId,
145
+ }))
146
+
147
+ const ran = await next(e)
148
+ if ('deny' in ran) await settleCall($, e.tool_use_id, 'denied', ran.deny)
149
+ else if (ran.isError === true) await settleCall($, e.tool_use_id, isRefusal(ran.text ?? '') ? 'denied' : 'error', ran.text)
150
+ else await settleCall($, e.tool_use_id, 'ok')
151
+ return ran
152
+ }).catch(($, e, next) => next(e))
153
+
154
+ on('session.append', async ($, e, next) => {
155
+ const stored = await next(e)
156
+ if (e.door === 'response') {
157
+ const now = await $.clock.now()
158
+ for (const block of e.message.content) {
159
+ if (block.type !== 'tool_use' || typeof block.id !== 'string' || typeof block.name !== 'string') continue
160
+ const input = typeof block.input === 'object' && block.input !== null ? (block.input as Record<string, unknown>) : {}
161
+ const id = block.id
162
+ const tool = block.name
163
+ await patchCall($, id, call => call ?? { id, tool, label: labelOf(input), status: 'queued', startedAt: now, agentId: e.agentId })
164
+ }
165
+ }
166
+ if (e.door === 'tool-result') {
167
+ for (const block of e.message.content) {
168
+ if (block.type !== 'tool_result' || typeof block.tool_use_id !== 'string') continue
169
+ const list = await read($, calls)
170
+ const call = list.find(one => one.id === block.tool_use_id)
171
+ if (call?.status === 'queued') {
172
+ const text = resultText(block.content)
173
+ await settleCall($, block.tool_use_id, block.is_error === true ? 'denied' : 'ok', text)
174
+ }
175
+ }
176
+ }
177
+ return stored
178
+ }).catch(($, e, next) => next(e))
179
+
180
+ on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
181
+ const { Box, Text } = $.ui.resolve(e)
182
+ const list = await read($, calls)
183
+ if (list.length === 0) return <Text dimColor>No tool calls yet.</Text>
184
+
185
+ const now = await $.clock.now()
186
+ const room = Math.max(4, (e.viewport?.rows ?? 30) - 6)
187
+ const shown = list.slice(-room)
188
+ const durationOf = (call: TimelineCall): number => (call.endedAt ?? now) - call.startedAt
189
+ const longest = Math.max(1, ...shown.map(durationOf))
190
+ const total = list.reduce((sum, call) => sum + (call.status === 'denied' ? 0 : durationOf(call)), 0)
191
+ const barCells = Math.max(8, Math.floor(e.props.bodyColumns * 0.3))
192
+ const labelCells = Math.max(12, e.props.bodyColumns - barCells - 12)
193
+
194
+ const bar = (call: TimelineCall) => {
195
+ const color = call.status === 'error' ? ERROR : call.status === 'ok' && /^Bash$/.test(call.tool) ? '#5fae6e' : colorOf(call.tool)
196
+ const share = call.status === 'denied' ? 0.04 : Math.max(0.03, durationOf(call) / longest)
197
+ if (e.surface === 'terminal') {
198
+ const filled = Math.max(1, Math.round(share * barCells))
199
+ return (
200
+ <Text>
201
+ <Text color={color}>{'▬'.repeat(filled)}</Text>
202
+ <Text color={TRACK}>{'▬'.repeat(barCells - filled)}</Text>
203
+ </Text>
204
+ )
205
+ }
206
+ const { Svg } = $.ui.resolve(e)
207
+ const width = barCells * CELL_PX
208
+ return <Svg source={barSvg(width, share * width, color)} alt={`${call.tool} ${seconds(durationOf(call))}`} width={width} height={8} />
209
+ }
210
+
211
+ const outcome = (call: TimelineCall) => {
212
+ switch (call.status) {
213
+ case 'denied': return <Text color={ERROR}>denied</Text>
214
+ case 'error': return <Text color={ERROR}>{seconds(durationOf(call))} ✗</Text>
215
+ case 'ok': return <Text dimColor>{seconds(durationOf(call))}</Text>
216
+ case 'running': return <Text color="#e5b567">{seconds(durationOf(call))}…</Text>
217
+ case 'queued': return <Text dimColor>queued</Text>
218
+ }
219
+ }
220
+
221
+ const rows = shown.map((call, index) => {
222
+ const replacement = call.status === 'denied' ? replacementOf(shown, index) : undefined
223
+ return (
224
+ <Box flexDirection="column">
225
+ <Box flexDirection="row" columnGap={1}>
226
+ <Text bold color="#1b1f27" backgroundColor={colorOf(call.tool)}> {badgeOf(call.tool)} </Text>
227
+ <Box width={labelCells}>
228
+ <Text wrap="truncate-end" strikethrough={call.status === 'denied'} color={call.status === 'denied' ? ERROR : undefined}>
229
+ {call.agentId === undefined ? '' : '↳ '}{call.tool} <Text dimColor>{call.label}</Text>
230
+ </Text>
231
+ </Box>
232
+ {bar(call)}
233
+ <Box width={8} justifyContent="flex-end">{outcome(call)}</Box>
234
+ </Box>
235
+ {call.status === 'denied' && (
236
+ <Box borderStyle="round" borderColor="#7a3b3b" paddingX={1} flexDirection="row" justifyContent="space-between">
237
+ <Text wrap="truncate-end">
238
+ <Text color={ERROR}>⊘ </Text>
239
+ <Text strikethrough dimColor>{call.label}</Text>
240
+ {replacement === undefined ? '' : <Text> → <Text color="#7cc47f">{replacement.label}</Text></Text>}
241
+ </Text>
242
+ <Text color={ERROR}>denied{call.note === undefined ? '' : ` · ${shortNote(call.note)}`}</Text>
243
+ </Box>
244
+ )}
245
+ </Box>
246
+ )
247
+ })
248
+
249
+ return (
250
+ <Box flexDirection="column" gap={0}>
251
+ <Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
252
+ <Text bold>Tool timeline</Text>
253
+ <Text dimColor>{list.length} call{list.length === 1 ? '' : 's'} · {seconds(total)}</Text>
254
+ </Box>
255
+ {rows}
256
+ </Box>
257
+ )
258
+ })
259
+ }
@@ -0,0 +1,25 @@
1
+ // packages/g3n-mods/mods/tool-timeline/types/index.d.ts
2
+
3
+ /** How a call ended: still waiting or running, done, failed, or refused before it ran. */
4
+ export type CallStatus = 'queued' | 'running' | 'ok' | 'error' | 'denied'
5
+
6
+ /** One tool call on the timeline; times are milliseconds since the epoch. */
7
+ export type TimelineCall = {
8
+ id: string
9
+ tool: string
10
+ /** Short form of the call's input, e.g. `rm -rf dist` or `package.json` */
11
+ label: string
12
+ status: CallStatus
13
+ startedAt: number
14
+ endedAt?: number
15
+ /** Why it was denied or failed, as the deny or error text said */
16
+ note?: string
17
+ /** Set on calls a subagent made */
18
+ agentId?: string
19
+ }
20
+
21
+ declare module 'claude-code' {
22
+ interface PluginState {
23
+ 'tool-timeline': { calls: TimelineCall[] }
24
+ }
25
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2023",
4
+ "lib": ["es2023"],
5
+ "types": [],
6
+ "module": "esnext",
7
+ "moduleResolution": "bundler",
8
+ "strict": true,
9
+ "noUncheckedIndexedAccess": true,
10
+ "noEmit": true,
11
+ "skipLibCheck": true,
12
+ "jsx": "react",
13
+ "jsxFactory": "h",
14
+ "jsxFragmentFactory": "Fragment"
15
+ },
16
+ "include": ["../types/claude-code.d.ts", "*/hooks", "*/types"]
17
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "you-should-know",
3
+ "version": "0.1.0",
4
+ "description": "A card under long replies that flags their key point: a headline and one supporting line.",
5
+ "author": {
6
+ "name": "g3ntrovert"
7
+ },
8
+ "types": "./types/index.d.ts",
9
+ "userConfig": {
10
+ "minChars": {
11
+ "type": "number",
12
+ "title": "Minimum reply length",
13
+ "default": 1500,
14
+ "description": "Replies shorter than this many characters get no card"
15
+ },
16
+ "model": {
17
+ "type": "string",
18
+ "title": "Model",
19
+ "default": "haiku",
20
+ "description": "Model that picks the key point"
21
+ }
22
+ }
23
+ }
@@ -0,0 +1 @@
1
+ { "modules": ["./register.tsx"] }
@@ -0,0 +1,98 @@
1
+ // packages/g3n-mods/mods/you-should-know/hooks/register.tsx
2
+ import { atom, read, update } from 'claude-code'
3
+ import type { EngineInterface, Register } from 'claude-code'
4
+
5
+ import type { Card } from '../types'
6
+
7
+ const cards = atom({ plugin: 'you-should-know', key: 'cards' } as const, [])
8
+
9
+ const DEFAULT_MIN_CHARS = 1500
10
+ const DEFAULT_MODEL = 'haiku'
11
+ const TAIL = 200
12
+ const KEEP = 20
13
+ /** Most of a reply sent to the model; the end of a reply carries its conclusion. */
14
+ const MAX_PROMPT_CHARS = 40_000
15
+
16
+ const SYSTEM = [
17
+ 'You read one reply that an AI coding assistant wrote to its user, and pick the single point the user must not miss:',
18
+ 'a cost, a risk, a decision they have to make, a breaking change, or a surprising fact.',
19
+ 'Answer with JSON only, no prose and no code fence: {"headline": "...", "detail": "..."}.',
20
+ 'The headline is a plain statement of at most 70 characters. The detail is one supporting line of at most 120 characters.',
21
+ 'Use the reply\'s own facts and numbers. If nothing in the reply stands out, answer {"skip": true}.',
22
+ ].join(' ')
23
+
24
+ const BULB_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"><circle cx="20" cy="20" r="20" fill="#3d3424"/><path d="M20 11a6 6 0 0 0-3.6 10.8c.8.6 1.1 1.3 1.1 2.2v1h5v-1c0-.9.3-1.6 1.1-2.2A6 6 0 0 0 20 11z" fill="none" stroke="#e5b567" stroke-width="1.6"/><path d="M17.5 28h5M18.3 30.5h3.4" stroke="#e5b567" stroke-width="1.6" stroke-linecap="round"/></svg>'
25
+
26
+ function normalize(text: string): string {
27
+ return text.replace(/\s+/g, ' ').trim()
28
+ }
29
+
30
+ /** Reads the model's answer: a card's headline and detail, or `undefined` for a skip or anything malformed. */
31
+ export function parseCard(text: string): Omit<Card, 'tail'> | undefined {
32
+ const json = /\{[\s\S]*\}/.exec(text)?.[0]
33
+ if (json === undefined) return undefined
34
+ let value: unknown
35
+ try {
36
+ value = JSON.parse(json)
37
+ } catch {
38
+ return undefined
39
+ }
40
+ if (typeof value !== 'object' || value === null || !('headline' in value) || !('detail' in value)) return undefined
41
+ const { headline, detail } = value
42
+ if (typeof headline !== 'string' || typeof detail !== 'string' || headline.trim().length === 0) return undefined
43
+ return { headline: headline.trim().slice(0, 90), detail: detail.trim().slice(0, 160) }
44
+ }
45
+
46
+ /** Asks the model for the reply's key point and keeps the card when there is one. */
47
+ async function extract($: EngineInterface, answer: string, model: string): Promise<void> {
48
+ const result = await $.model.complete({ model, system: SYSTEM, prompt: answer.slice(-MAX_PROMPT_CHARS), maxTokens: 300 })
49
+ if (!result.isAnswered) return
50
+ const card = parseCard(result.text)
51
+ if (card === undefined) return
52
+ const tail = normalize(answer).slice(-TAIL)
53
+ await update($, cards, list => [...list.filter(one => one.tail !== tail), { tail, ...card }].slice(-KEEP))
54
+ }
55
+
56
+ export const register: Register = (on, options) => {
57
+ const minChars = Number(options.minChars ?? DEFAULT_MIN_CHARS)
58
+ const model = typeof options.model === 'string' && options.model.length > 0 ? options.model : DEFAULT_MODEL
59
+
60
+ on('turn.complete', async ($, e, next) => {
61
+ const result = await next(e)
62
+ if (e.agentId === undefined && e.reason === 'answer' && e.answer.length >= minChars) {
63
+ const answer = e.answer
64
+ $.clock.after(0, () => void extract($, answer, model))
65
+ }
66
+ return result
67
+ }).catch(($, e, next) => next(e))
68
+
69
+ on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
70
+ if (e.props.isSummary === true) return next(e)
71
+ const text = normalize(e.props.text)
72
+ if (text.length < 20) return next(e)
73
+ const card = (await read($, cards)).find(one => one.tail.endsWith(text.slice(-TAIL)))
74
+ if (card === undefined) return next(e)
75
+
76
+ const { Box, Text } = $.ui.resolve(e)
77
+ const icon = e.surface === 'terminal'
78
+ ? <Text color="#e5b567">💡</Text>
79
+ : (() => {
80
+ const { Svg } = $.ui.resolve(e)
81
+ return <Svg source={BULB_SVG} alt="key point" width={40} height={40} />
82
+ })()
83
+
84
+ return (
85
+ <Box flexDirection="column" gap={1}>
86
+ {await next(e)}
87
+ <Box borderStyle="round" borderColor="#3a3f4b" paddingX={2} paddingY={1} flexDirection="row" columnGap={2} alignItems="center">
88
+ {icon}
89
+ <Box flexDirection="column">
90
+ <Text bold color="#e5b567">YOU SHOULD KNOW</Text>
91
+ <Text bold>{card.headline}</Text>
92
+ <Text dimColor>{card.detail}</Text>
93
+ </Box>
94
+ </Box>
95
+ </Box>
96
+ )
97
+ })
98
+ }