Draws your own prompts as a distinct card in the transcript and adds /timeline, a pane listing your prompts that jumps to any of them.

A mod that makes your own prompts easy to find in a long session.
› You · #N, so they stand apart from Claude's output./timeline opens a pane with every prompt of the session, each one a jump link.Prompts are recorded as the session stores them (session.append, door prompt), so the list starts from when the plugin loaded and survives /reload-plugins.
Test: claude plugin test claude/plugins/prompt-timeline
hooks/look.tsx: each reply opens with a magenta pill naming the model (◆ Opus 5.5), and startup notices get a blue ℹ and their /command as a pill. The prompt strip sits under a cyan divider as chips, the latest one highlighted./model or effort change never relabels older replies.hooks/cards.ts): a colored label (Bash, Read, Edit, Write, Search, Web, Tools, Agent, Browser for chrome-devtools, or the MCP server name), one readable title and a dim detail line.hooks/register.tsx 211 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Prompt } from '../types'
5import type { ReplyTag } from '../types'
6import { registerLook, setModel, setSessionId, storeKey } from './look'
7import { registerReplay } from './replay'
8import { PANEL, PROMPT_BAR } from './theme'
9
10const PANE = 'timeline'
11const STRIP = 6
12// Kept in session state, so the list survives /reload-plugins.
13const prompts = atom({ plugin: 'prompt-timeline', key: 'prompts' } as const, [] as Prompt[])
14
15const textOf = (content: readonly { type: string }[]) =>
16 content.map(block => ('text' in block && block.type === 'text' ? String(block.text) : '')).join('\n')
17// The same session value look.tsx draws from; the scanner wants each module to name its own.
18const replyTags = atom({ plugin: 'prompt-timeline', key: 'replyTags' } as const, {} as Record<string, ReplyTag>)
19
20const firstLine = (text: string) => text.trim().split('\n')[0] ?? ''
21export const midTurnText = (rendered: string) =>
22 rendered.match(/The user sent a new message while you were working:\n([\s\S]*?)\n\nThis is how Claude Code surfaces/)?.[1] ?? ''
23
24// Adds the prompts read back from the transcript, keeping an id a card already saved for the
25// same prompt, so a reload does not list it twice.
26const withTranscript = (stdout: string) => (seen: Prompt[]) => {
27 const earlier: Prompt[] = JSON.parse(stdout)
28 const left = [...seen]
29 const merged = earlier.map(one => {
30 const at = left.findIndex(mine => mine.id === one.id || (mine.isDrawn && mine.text === one.text))
31 return at < 0 ? one : left.splice(at, 1)[0]!
32 })
33 return [...merged, ...left]
34}
35// The transcript read at session start can already hold a prompt stored while it ran.
36export const withPrompt = (prompt: Prompt) => (all: Prompt[]) =>
37 all.some(one => one.id === prompt.id) ? all : [...all, prompt]
38// A message typed while a turn runs comes in as a queued_command delivery, with only its rendering
39// on the event. Its row is drawn under the row's own id, so it is saved as drawn: the queue preview
40// drawn under another id with the same text must not take it over.
41export const promptOf = (e: { door: string; uuid: string; message: { name?: string; content: readonly { type: string }[] } }): Prompt | undefined => {
42 const text = e.door === 'prompt' ? textOf(e.message.content) : e.door === 'delivery' && e.message.name === 'queued_command' ? midTurnText(textOf(e.message.content)) : ''
43 if (!text.trim()) return undefined
44 return e.door === 'prompt' ? { id: e.uuid, text } : { id: e.uuid, text, isDrawn: true }
45}
46
47export const register: Register = on => {
48 registerLook(on)
49 registerReplay(on)
50
51 on('session.start', async ($, e, next) => {
52 await $.command.register({ name: 'timeline', description: 'List your prompts in this session and jump to one' })
53 await $.command.register({ name: 'replay', description: "Step through the last turn's file edits side by side" })
54 setModel(await $.session.model())
55 // Prompts sent before the plugin loaded are only in the transcript file.
56 const sessionId = await $.session.id()
57 setSessionId(sessionId)
58 const savedTags = ((await $.store.get(storeKey(sessionId))) ?? {}) as Record<string, ReplyTag>
59 await update($, replyTags, all => ({ ...savedTags, ...all }))
60 const ran = await $.process.run(['node', `${$.plugin.root}/scripts/prompts.cjs`, sessionId])
61 if (ran.exitCode === 0) await update($, prompts, withTranscript(ran.stdout))
62
63 return next(e)
64 })
65
66
67 on('command.run', { command: 'timeline' }, async $ => {
68 await $.ui.open({ id: PANE, title: 'Timeline' })
69
70 return { text: 'Timeline pane opened.' }
71 })
72
73 // Record each prompt as it is stored; its row id is the id the transcript draws it under.
74 on('session.append', async ($, e, next) => {
75 const stored = await next(e)
76 const prompt = stored.deny === undefined && e.agentId === undefined ? promptOf(e) : undefined
77 if (prompt) await update($, prompts, withPrompt(prompt))
78
79 return stored
80 })
81
82 // Your own prompts get a card of their own, numbered as in the timeline.
83 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
84 const list = await read($, prompts)
85 const text = e.props.text.trim()
86 // The row is drawn under an id of its own, not the saved row's, so match on the text and
87 // remember the drawn id: that is what the strip and timeline scroll to. Drawing cannot
88 // write state, so the id is saved just after.
89 let index = list.findIndex(one => one.id === e.requestId)
90 if (index < 0) index = list.findIndex(one => one.text.trim() === text && !one.isDrawn)
91 const match = list[index]
92 if (match && match.id !== e.requestId) {
93 const drawnId = e.requestId
94 $.clock.after(0, () => {
95 void update($, prompts, all => all.map(one => (one === match || one.id === match.id ? { ...one, id: drawnId, isDrawn: true } : one)))
96 })
97 }
98 // Slash commands are saved as commands, not prompts, so they never join the list; they keep the default row.
99 // Task notifications and messages from other agents keep Claude Code's row, which names the sender.
100 if (e.props.task || e.props.from) return next(e)
101 if ((index < 0 && e.props.origin.kind !== 'composer') || !text || text.startsWith('/')) return next(e)
102 const { Box, Text } = $.ui.resolve(e)
103
104 // Every saved prompt is in the list (recorded on save, or read back from the transcript),
105 // so a prompt row that is not there yet is still waiting in the queue.
106 if (index < 0) {
107 return (
108 <Box marginTop={1} marginRight={1} backgroundColor={PANEL}>
109 <Box width={1} backgroundColor="gray" />
110 <Box paddingX={2} paddingY={1} flexDirection="column" flexGrow={1}>
111 <Text dimColor italic>
112 ◌ Queued · sends when the current turn ends
113 </Text>
114 <Text dimColor wrap="wrap">
115 {e.props.text}
116 </Text>
117 </Box>
118 </Box>
119 )
120 }
121
122 return (
123 <Box marginY={1} marginRight={1} backgroundColor={PANEL}>
124 <Box width={1} backgroundColor={PROMPT_BAR} />
125 <Box paddingX={2} paddingY={1} flexGrow={1} justifyContent="space-between" gap={2}>
126 <Text wrap="wrap">{e.props.text}</Text>
127 <Text dimColor>#{index + 1}</Text>
128 </Box>
129 </Box>
130 )
131 })
132
133 // Panes share one dock as tabs, so the always-visible strip lives above the prompt instead.
134 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
135 const list = await read($, prompts)
136 if (list.length === 0 || e.props.hasSurvey) return next(e)
137 const { Box, Button, Text } = $.ui.resolve(e)
138 const rest = await next(e)
139 const recent = list.slice(-STRIP)
140 // Leave room for the icon and `▸ /timeline`, so the row stays one line.
141 const label = Math.max(10, Math.floor((e.props.bodyColumns - 18) / recent.length) - 8)
142
143 return (
144 <Box flexDirection="column">
145 {rest}
146 <Text color="cyan" dimColor>
147 {'─'.repeat(Math.max(10, e.props.bodyColumns))}
148 </Text>
149 <Box justifyContent="space-between">
150 <Box gap={1} flexShrink={1} overflow="hidden">
151 <Text color="cyan" bold>
152 ⏱
153 </Text>
154 {recent.map((one, i) => {
155 const number = list.length - recent.length + i + 1
156 const line = firstLine(one.text)
157 const isLatest = i === recent.length - 1
158 return (
159 <Box key={`chip-${number}`} backgroundColor={isLatest ? 'cyan' : 'blackBright'} paddingX={1}>
160 <Button
161 key={`strip-${number}`}
162 plain
163 label={`#${number} ${line.length > label ? `${line.slice(0, label - 1)}…` : line}`}
164 onPress={() => $.ui.scroll({ to: { requestId: one.id }, block: 'start' })}
165 />
166 </Box>
167 )
168 })}
169 </Box>
170 <Text dimColor>▸ /timeline</Text>
171 </Box>
172 </Box>
173 )
174 })
175
176 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
177 const { Box, Button, Text } = $.ui.resolve(e)
178 const list = await read($, prompts)
179 const width = Math.max(20, e.props.bodyColumns - 10)
180
181 return (
182 <Box flexDirection="column" paddingX={1}>
183 <Box justifyContent="space-between">
184 <Text bold>Your prompts</Text>
185 <Text dimColor>{list.length} this session</Text>
186 </Box>
187 {list.length === 0 && (
188 <Box marginTop={1}>
189 <Text dimColor italic>
190 Prompts show up here as they're drawn in the transcript.
191 </Text>
192 </Box>
193 )}
194 <Box flexDirection="column" marginTop={1}>
195 {list.map((one, i) => (
196 <Box key={one.id} gap={1}>
197 <Text color="cyan">{String(i + 1).padStart(3)}</Text>
198 <Button
199 key={`jump-${i + 1}`}
200 plain
201 label={firstLine(one.text).slice(0, width) || '(empty)'}
202 onPress={() => $.ui.scroll({ to: { requestId: one.id }, block: 'start' })}
203 />
204 </Box>
205 ))}
206 </Box>
207 </Box>
208 )
209 })
210}
211hooks/look.tsx 596 lines1import { atom, read, update } from 'claude-code'
2import type { BoxProps, CodeProps, ElementConstructor, On, RenderChildren, TextProps } from 'claude-code'
3
4import type { ReplyTag } from '../types'
5import { describe, errorLines, pngIn, resultLine } from './cards'
6import { OUTPUT_BAR, PANEL } from './theme'
7
8// The rest of the transcript's look, after OpenCode's: replies as plain text with the model and
9// effort in a footer line where the turn ends, tool calls as one light line each, and output in
10// filled panels; startup notices with an icon and their /command as a pill.
11
12const INDENT = 3
13// Columns a reply frame takes: border and one column of padding on each side, plus the right margin.
14const FRAME = 2
15let model = ''
16let effort = ''
17// The model and effort each reply was made with, by its id, so redrawing an old reply after a
18// /model or effort change keeps what it was made with. Kept in session state across reloads.
19const replyTags = atom({ plugin: 'prompt-timeline', key: 'replyTags' } as const, {} as Record<string, ReplyTag>)
20const enlarged = atom({ plugin: 'prompt-timeline', key: 'enlarged' } as const, null as { png: string; width: number; height: number } | null)
21const SHOT_PANE = 'screenshot'
22// Each Bash call's command and, for calls made since the module loaded, how long it ran, by id:
23// the result row has neither.
24const runs = new Map<string, { command: string; ms?: number }>()
25// An inline script shows this many lines before it folds.
26const SCRIPT_LINES = 20
27// A call's text body (a PR comment, a query) shows this many lines before it folds.
28const BODY_LINES = 8
29// Bash output shows this many lines before it folds.
30const OUTPUT_LINES = 12
31// A file a Bash command changed shows this many diff lines before it folds.
32const DIFF_LINES = 20
33// A terminal cell is about twice as tall as it is wide.
34const rowsFor = (shot: { width: number; height: number }, columns: number) => Math.min(60, Math.max(1, Math.round((columns * shot.height) / shot.width / 2)))
35// Each session's tags are also saved to the plugin's store (a JSON file on disk) under this
36// key, so a quit-and-resume keeps them; the main module loads them at session start.
37// debt: one key per session, never pruned; the store is capped at 4 MiB (~20k replies). Drop the oldest session keys if it fills.
38export const storeKey = (sessionId: string) => `replies:${sessionId}`
39// A reply is keyed by the start of its text: the id it is drawn under may not survive a resume.
40export const replyKey = (text: string) => text.slice(0, 160)
41let sessionId = ''
42export const setSessionId = (id: string) => {
43 sessionId = id
44}
45
46// One color per model family, so a switch is visible at a glance.
47const FAMILY_COLOR: Record<string, string> = { opus: 'magenta', sonnet: 'blue', haiku: 'green', fable: 'yellow' }
48export const familyColor = (label: string) => FAMILY_COLOR[label.split(' ')[0]?.toLowerCase() ?? ''] ?? 'gray'
49
50// Set from the main module's session start; a mod cannot hand `$` across files.
51export const setModel = (id: string) => {
52 model = modelLabel(id)
53}
54
55// Splits markdown into runs of prose and of table rows (lines starting with `|`), leaving fenced
56// code blocks whole.
57export function splitTables(text: string) {
58 const parts: { isTable: boolean; text: string }[] = []
59 let inFence = false
60 for (const line of text.split('\n')) {
61 if (line.trimStart().startsWith('```')) inFence = !inFence
62 const isTable = !inFence && line.trimStart().startsWith('|')
63 const last = parts.at(-1)
64 if (last && last.isTable === isTable) last.text += `\n${line}`
65 else parts.push({ isTable, text: line })
66 }
67
68 return parts.filter(part => part.text.trim())
69}
70
71// A markdown table as cells: the header, then each row, separator line dropped.
72export function parseTable(text: string) {
73 const rows = text
74 .split('\n')
75 .filter(line => line.trim().startsWith('|') && !/^\s*\|[\s:|-]+\|\s*$/.test(line))
76 .map(line => line.trim().replace(/^\||\|$/g, '').split(/(?<!\\)\|/).map(cell => cell.trim()))
77
78 return { header: rows[0] ?? [], rows: rows.slice(1) }
79}
80
81// Column widths that fit `room`: each column keeps its natural width when all fit; otherwise
82// narrow columns keep theirs and the rest share what is left, so long cells wrap instead.
83export function fitColumns(natural: number[], room: number, gap: number) {
84 const space = room - gap * (natural.length - 1)
85 if (room <= 0 || natural.reduce((a, b) => a + b, 0) <= space) return natural
86 const widths = natural.map(() => 0)
87 let left = space
88 let open = natural.map((_, i) => i)
89 // Settle columns narrower than an equal share first, then split the rest evenly.
90 for (;;) {
91 const share = Math.floor(left / open.length)
92 const small = open.filter(i => (natural[i] ?? 0) <= share)
93 if (small.length === 0) {
94 open.forEach((i, k) => (widths[i] = share + (k < left - share * open.length ? 1 : 0)))
95 return widths.map(w => Math.max(4, w))
96 }
97 for (const i of small) {
98 widths[i] = natural[i] ?? 0
99 left -= widths[i] ?? 0
100 }
101 open = open.filter(i => !small.includes(i))
102 if (open.length === 0) return widths
103 }
104}
105
106// The width the markdown element draws a table at: each column as wide as its widest cell, plus
107// one space a side and a border per column.
108export function tableWidth(text: string) {
109 const { header, rows } = parseTable(text)
110 const widths = header.map((cell, c) => Math.max(plainCell(cell).length, ...rows.map(row => plainCell(row[c] ?? '').length)))
111
112 return widths.reduce((sum, w) => sum + w + 3, 1)
113}
114
115// A cell's text as shown: markdown bold and code markers dropped.
116export const plainCell = (cell: string) => cell.replace(/\*\*|`/g, '')
117
118// How many lines `text` takes when word-wrapped at `width`, as the terminal wraps it.
119export function wrappedLines(text: string, width: number) {
120 let lines = 1
121 let used = 0
122 for (const word of text.split(/\s+/).filter(Boolean)) {
123 const length = word.length
124 if (used === 0) used = length
125 else if (used + 1 + length <= width) used += 1 + length
126 else {
127 lines += 1
128 used = length
129 }
130 while (used > width) {
131 lines += 1
132 used -= width
133 }
134 }
135
136 return lines
137}
138
139// `claude-opus-5-5[1m]` reads as `Opus 5.5`; anything else is shown as given.
140export function modelLabel(id: string) {
141 const m = id.match(/claude-([a-z]+)-(\d+)-(\d+)/i)
142
143 return m?.[1] ? `${m[1][0]!.toUpperCase()}${m[1].slice(1)} ${m[2]}.${m[3]}` : id.replace(/\[.*\]$/, '')
144}
145
146type Hunk = { oldStart: number; newStart: number; lines: string[] }
147type BashOutput = {
148 stdout?: string
149 stderr?: string
150 interrupted?: boolean
151 bashEditDiff?: { files?: { filePath: string; hunks: Hunk[] }[] }
152 backgroundTaskId?: string
153 timedOutAfterMs?: number
154 isImage?: boolean
155}
156// Results that carry something this view does not draw (a background task id, a timeout, an
157// image) keep Claude Code's own row, so nothing it shows goes missing.
158const isPlainBash = (output: BashOutput) => !output.backgroundTaskId && !output.timedOutAfterMs && !output.isImage && !output.interrupted
159type Ui = { Box: ElementConstructor<BoxProps>; Code: ElementConstructor<CodeProps>; Text: ElementConstructor<TextProps> }
160
161// Bash output: dim, ANSI stripped, folded after OUTPUT_LINES, stderr in red, then each file the
162// command changed as a framed diff (Claude Code attaches those to the result as bashEditDiff).
163// rtk adds a "[see remaining: tail …]" pointer to its own log; it is not the command's output.
164// Lines that report an outcome: test summaries, check marks, errors.
165const PASSED = /^\s*(?:✔|✓|ok\b|PASS\b)|\b0 fail(?:ed|ures?)?\b|\b\d+ pass(?:ed|ing)?\b/i
166const FAILED = /^\s*(?:✘|✗|×|FAIL\b)|\b[1-9]\d* fail(?:ed|ures?)?\b|\berror\b/i
167
168const INTERPRETERS: [RegExp, string][] = [
169 [/\bpython3?\b/, 'python'],
170 [/\b(?:bun|tsx|ts-node)\b/, 'typescript'],
171 [/\b(?:node|deno)\b/, 'javascript'],
172 [/\bruby\b/, 'ruby'],
173 [/\b(?:bash|sh|zsh)\b/, 'bash'],
174]
175
176// The script a command runs inline: a heredoc fed to an interpreter or written by cat, or an
177// interpreter's -e/-c string. Null for any other command.
178export function scriptOf(command: string): { source: string; language?: string; path?: string } | null {
179 const heredoc = command.match(/^([^\n]*?)<<-?\s*(['"]?)(\w+)\2[^\n]*\n([\s\S]*?)\n\s*\3(?:\n|$)/m)
180 const inline = command.match(/\b(node|bun|deno|python3?|ruby)\s+(?:-e|-c|--eval|-p)\s+(['"])([\s\S]+)\2\s*$/)
181 const [head, source] = heredoc ? [heredoc[1] ?? '', heredoc[4] ?? ''] : inline ? [inline[1] ?? '', inline[3] ?? ''] : ['', '']
182 if (!source.trim()) return null
183 const written = head.match(/\bcat\s*>>?\s*(['"]?)([^\s'"]+)\1/)?.[2]
184 if (written) return { source, path: written }
185 const language = INTERPRETERS.find(([re]) => re.test(head.split(/&&|;|\|/).pop() ?? ''))?.[1]
186 return language ? { source, language } : null
187}
188
189const duration = (ms: number) => (ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`)
190
191// After OpenCode's run view: the script numbered and highlighted, then its output.
192function scriptSection({ Box, Code, Text }: Ui, script: NonNullable<ReturnType<typeof scriptOf>>, ms: number | undefined) {
193 const lines = script.source.split('\n')
194 return (
195 <Box flexDirection="column" marginBottom={1}>
196 <Box justifyContent="space-between">
197 <Text dimColor>Code</Text>
198 {ms !== undefined && <Text dimColor>{`Completed · ${duration(ms)}`}</Text>}
199 </Box>
200 <Code source={lines.slice(0, SCRIPT_LINES).join('\n').slice(0, 10000)} language={script.language} path={script.path} startLine={1} />
201 {lines.length > SCRIPT_LINES && <Text dimColor>{`… ${lines.length - SCRIPT_LINES} more lines`}</Text>}
202 </Box>
203 )
204}
205
206export function bashOutput({ Box, Code, Text }: Ui, output: BashOutput, withStdout = true, run?: { command: string; ms?: number }) {
207 const clean = (text: string | undefined) =>
208 (text ?? '')
209 .replace(/\x1b\[[0-9;]*m/g, '')
210 .replace(/^\[see remaining: .*\]$/gm, '')
211 .replace(/\s+$/, '')
212 const stdout = clean(output.stdout)
213 const stderr = clean(output.stderr)
214 const diffs = output.bashEditDiff?.files ?? []
215 const lines = stdout && withStdout ? stdout.split('\n') : []
216 const hidden = lines.length - OUTPUT_LINES
217 // Printed diffs (git diff, diff -u): the lines from the first hunk on go to the highlighter.
218 const hunk = lines.slice(0, OUTPUT_LINES).findIndex(line => /^@@ -\d/.test(line))
219 const diffPath = stdout.match(/^\+\+\+ (?:b\/)?(\S+)/m)?.[1]
220 const script = withStdout && run ? scriptOf(run.command) : null
221 const isJson = /^[[{]/.test(stdout) && hidden <= 0 && (() => { try { JSON.parse(stdout); return true } catch { return false } })()
222
223 // Full width, so diff lines wrap at the window's edge and not at their frame's own width.
224 return (
225 <Box flexDirection="column" width="100%">
226 {withStdout && !script && lines.length === 0 && !stderr && diffs.length === 0 && <Text dimColor>(no output)</Text>}
227 {(lines.length > 0 || script) && (
228 <Box width="100%" backgroundColor={PANEL} marginTop={1}>
229 <Box width={1} backgroundColor={OUTPUT_BAR} />
230 <Box flexDirection="column" flexGrow={1} paddingX={2} paddingY={1}>
231 {script && scriptSection({ Box, Code, Text }, script, run?.ms)}
232 {script && <Text dimColor>Output</Text>}
233 {script && lines.length === 0 && <Text dimColor>(no output)</Text>}
234 {isJson && <Code source={stdout.slice(0, 10000)} language="json" startLine={1} />}
235 {!isJson && lines.slice(0, hunk < 0 ? OUTPUT_LINES : hunk).map((line, i) => {
236 // `path:12:code` (rg, grep) gets a dim gutter and the code coloured by its file type.
237 const hit = line.match(/^([^\s:]+\.[\w]+):(\d+)[:-](.*)$/)
238 if (hit?.[1] && hit[2]) {
239 return (
240 <Box key={`o${i}`} gap={1}>
241 <Text dimColor>{`${hit[1]}:${hit[2]}`}</Text>
242 {hit[3]?.trim() ? <Code source={hit[3]} path={hit[1]} /> : null}
243 </Box>
244 )
245 }
246 // `138:code` (rg -n on one file): the number in the gutter, the code as written.
247 const numbered = line.match(/^(\d+)[:-](.*)$/)
248 if (numbered?.[1]) {
249 return (
250 <Box key={`o${i}`} gap={1}>
251 <Text dimColor>{numbered[1].padStart(4)}</Text>
252 <Text wrap="truncate-end">{numbered[2] || ' '}</Text>
253 </Box>
254 )
255 }
256 const color = FAILED.test(line) ? 'red' : PASSED.test(line) ? 'green' : undefined
257 return (
258 <Text key={`o${i}`} color={color} dimColor={!color} wrap="truncate-end">
259 {line || ' '}
260 </Text>
261 )
262 })}
263 {!isJson && hunk >= 0 && <Code source={lines.slice(hunk, OUTPUT_LINES).join('\n')} path={diffPath} format="diff" />}
264 {hidden > 0 && <Text dimColor>{`… ${hidden} more lines`}</Text>}
265 </Box>
266 </Box>
267 )}
268 {withStdout && stderr && (
269 <Text color="red" wrap="wrap">
270 {stderr.split('\n').slice(0, OUTPUT_LINES).join('\n')}
271 </Text>
272 )}
273 {diffs.map((file, f) => {
274 // Claude Code's own highlighter draws the hunks: syntax colours from the path, gutters,
275 // and add/remove shading. Hunks past DIFF_LINES lines are left out and counted.
276 let budget = DIFF_LINES
277 const kept: Hunk[] = []
278 for (const hunk of file.hunks) {
279 if (budget <= 0) break
280 kept.push({ ...hunk, lines: hunk.lines.slice(0, budget) })
281 budget -= hunk.lines.length
282 }
283 const total = file.hunks.reduce((sum, hunk) => sum + hunk.lines.length, 0)
284 const shown = kept.reduce((sum, hunk) => sum + hunk.lines.length, 0)
285 const count = (hunk: Hunk, mark: string) => hunk.lines.filter(line => line[0] === mark || line[0] === ' ').length
286 const source = kept
287 .map(hunk => [`@@ -${hunk.oldStart},${count(hunk, '-')} +${hunk.newStart},${count(hunk, '+')} @@`, ...hunk.lines].join('\n'))
288 .join('\n')
289 .slice(0, 10000)
290 const all = file.hunks.flatMap(hunk => hunk.lines)
291 const path = file.filePath.replace(/^\/Users\/[^/]+/, '~')
292 const cut = path.lastIndexOf('/') + 1
293 return (
294 <Box key={`d${f}`} width="100%" marginTop={1} backgroundColor={PANEL}>
295 <Box width={1} backgroundColor={OUTPUT_BAR} />
296 <Box flexDirection="column" flexGrow={1} paddingX={2} paddingY={1}>
297 <Box gap={1}>
298 <Text color="yellow">✎</Text>
299 <Text bold>{path.slice(cut)}</Text>
300 <Text dimColor>{path.slice(0, cut)}</Text>
301 <Text backgroundColor="green" color="black">{` +${all.filter(line => line[0] === '+').length} `}</Text>
302 <Text backgroundColor="red" color="black">{` −${all.filter(line => line[0] === '-').length} `}</Text>
303 </Box>
304 {source && <Code source={source} path={file.filePath} format="diff" />}
305 {total > shown && <Text dimColor>{`… ${total - shown} more lines`}</Text>}
306 </Box>
307 </Box>
308 )
309 })}
310 </Box>
311 )
312}
313
314// Calls Claude Code folded into a group: when the group is expanded each draws its own row, and
315// Claude Code draws that row's result inline rather than as a separate ToolResult row.
316const grouped = new Set<string>()
317// The tag of the reply drawn last: the turn's footer, drawn after its replies, names it.
318let lastTag: ReplyTag | null = null
319
320export function registerLook(on: On) {
321 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
322 for (const call of e.props.calls) if (call.tool_use_id) grouped.add(call.tool_use_id)
323
324 return next(e)
325 })
326
327 // Each model request of the main loop names the model and effort the reply is made with.
328 on('turn.step', async function* ($, e, next) {
329 if (e.agentId === undefined) {
330 model = modelLabel(e.model)
331 effort = e.effort === undefined ? '' : String(e.effort)
332 }
333
334 return yield* next(e)
335 })
336
337 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
338 // Plain text behind a thin bar in the model's colour, so a reply reads apart from Claude
339 // Code's own rows (stop hooks, notices) at the same indent; the model and effort that made
340 // it go in the footer where the turn ends (TurnDuration, below).
341 const { Box, Markdown, Text } = $.ui.resolve(e)
342 const parts = splitTables(e.props.text)
343 const room = (e.viewport?.columns ?? 0) - INDENT - FRAME
344 if (e.props.isFirstOfReply) {
345 const key = replyKey(e.props.text)
346 const saved = (await read($, replyTags))[key]
347 const tag = saved ?? { model, effort }
348 if (!tag.model) return next(e)
349 if (!saved) {
350 // Drawing cannot write state, so the tag is saved just after, in memory and on disk.
351 $.clock.after(0, () => {
352 void (async () => {
353 await update($, replyTags, all => (all[key] ? all : { ...all, [key]: tag }))
354 if (sessionId) await $.store.set(storeKey(sessionId), await read($, replyTags))
355 })()
356 })
357 }
358 lastTag = tag
359 }
360
361 // A table too wide for the reply is drawn as a grid fitted to the window: the markdown element
362 // draws a table at its natural width whatever wraps it. Cells are plain text with their bold and code spans kept,
363 // so each row's height is known and the column dividers line up when a cell wraps.
364 const table = (text: string, i: number) => {
365 const { header, rows } = parseTable(text)
366 const natural = header.map((cell, c) => Math.max(plainCell(cell).length, ...rows.map(row => plainCell(row[c] ?? '').length)))
367 // Each column costs its width, one space a side, and a divider.
368 const widths = fitColumns(natural, room - 1 - 3 * natural.length, 3)
369 const rule = (left: string, mid: string, right: string) => (
370 <Text dimColor>{left + widths.map(w => '─'.repeat(w + 2)).join(mid) + right}</Text>
371 )
372 const cellText = (cell: string, isHeader: boolean) =>
373 cell.split(/(`[^`]+`|\*\*[^*]+\*\*)/).map((piece, k) =>
374 piece.startsWith('`') ? (
375 <Text key={`s${k}`} color="cyan">{piece.slice(1, -1)}</Text>
376 ) : piece.startsWith('**') ? (
377 <Text key={`s${k}`} bold>{piece.slice(2, -2)}</Text>
378 ) : (
379 <Text key={`s${k}`} bold={isHeader}>{piece}</Text>
380 ),
381 )
382 const row = (cells: string[], r: string, isHeader = false) => {
383 const height = Math.max(1, ...widths.map((w, c) => wrappedLines(plainCell(cells[c] ?? ''), w)))
384 const bar = <Text dimColor>{Array.from({ length: height }, () => '│').join('\n')}</Text>
385 return (
386 <Box key={r}>
387 {bar}
388 {widths.map((width, c) => (
389 <Box key={`c${c}`}>
390 <Box width={width + 2} paddingX={1} backgroundColor={isHeader ? PANEL : undefined}>
391 <Text wrap="wrap">{cellText(cells[c] ?? '', isHeader)}</Text>
392 </Box>
393 {bar}
394 </Box>
395 ))}
396 </Box>
397 )
398 }
399
400 return (
401 <Box key={`part-${i}`} flexDirection="column" marginY={1}>
402 {rule('╭', '┬', '╮')}
403 {row(header, 'h', true)}
404 {rule('├', '┼', '┤')}
405 {rows.map((cells, r) => (
406 <Box key={`r${r}`} flexDirection="column">
407 {row(cells, `row${r}`)}
408 {r < rows.length - 1 && rule('├', '┼', '┤')}
409 </Box>
410 ))}
411 {rule('╰', '┴', '╯')}
412 </Box>
413 )
414 }
415
416 return (
417 <Box marginTop={1} marginRight={2}>
418 <Box width={1} backgroundColor={familyColor((lastTag ?? { model }).model || '')} />
419 <Box paddingLeft={INDENT - 1} paddingY={1} flexDirection="column" flexGrow={1}>
420 {parts.map((part, i) =>
421 // Claude Code's own table when it fits; the fitted grid only when the table is wider than
422 // the reply, where the markdown element would run past the edge and wrap its border.
423 part.isTable && tableWidth(part.text) > room ? table(part.text, i) : <Markdown key={`part-${i}`} text={part.text} />,
424 )}
425 </Box>
426 </Box>
427 )
428 })
429
430 // The turn's closing line, as OpenCode draws it: model, effort and time, the model in its
431 // family colour.
432 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
433 if (!lastTag) return next(e)
434 const { Box, Text } = $.ui.resolve(e)
435 const seconds = Math.round(e.props.durationMs / 1000)
436 const time = seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`
437
438 return (
439 <Box paddingLeft={INDENT} marginY={1}>
440 <Text color={familyColor(lastTag.model)}>◆ {lastTag.model}</Text>
441 <Text dimColor>{`${lastTag.effort ? ` · ${lastTag.effort}` : ''} · ${time}`}</Text>
442 </Box>
443 )
444 })
445
446 // Tool calls read as cards: status dot, a colored tool label, one readable title, and a dim
447 // detail line, instead of the raw tool name and its whole input. See cards.ts per tool.
448 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
449 const start = Date.now()
450 const result = await next(e)
451 runs.set(e.tool_use_id, { command: e.command, ms: Date.now() - start })
452 return result
453 })
454
455 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
456 const command = (e.props.input as { command?: unknown } | undefined)?.command
457 if (e.props.tool === 'Bash' && typeof command === 'string' && !runs.has(e.props.tool_use_id)) runs.set(e.props.tool_use_id, { command })
458 const card = describe(e.props.tool, (e.props.input ?? {}) as Record<string, unknown>)
459 if (!card) return next(e)
460 const { Box, Code, Markdown, Text } = $.ui.resolve(e)
461 // A grouped row gets its result here, not as its own ToolResult, so a returned PNG (a
462 // browser screenshot) is drawn under the card. Only the terminal draws pictures.
463 let picture = null
464 const png = pngIn(e.props.output)
465 if (png && e.surface === 'terminal') {
466 const { Button, Image } = $.ui.resolve(e)
467 const open = async () => {
468 await update($, enlarged, () => png)
469 await $.ui.open({ id: SHOT_PANE, title: 'Screenshot', focus: true, closeOnEscape: true, rows: rowsFor(png, 120) + 2, columns: 120 })
470 }
471 picture = (
472 <Box flexDirection="column">
473 <Image source={{ png: png.png }} columns={64} rows={Math.min(40, rowsFor(png, 64))} alt="Screenshot" />
474 <Button key={`enlarge-${e.props.tool_use_id}`} plain label="⤢ Open large" onPress={open} />
475 </Box>
476 )
477 }
478 const state = e.props.isErrored ? 'red' : e.props.isInterrupted ? 'yellow' : e.props.isRunning ? 'gray' : 'green'
479 const error = e.props.isErrored ? errorLines(e.props.output) : undefined
480 const result = error ?? resultLine(e.props.tool, e.props.output)
481
482 return (
483 <Box paddingLeft={INDENT} paddingRight={1} flexDirection="column" marginTop={1}>
484 <Box gap={1}>
485 <Text color={state}>→</Text>
486 <Text color={card.color === 'blackBright' ? 'gray' : card.color} bold>
487 {card.label}
488 </Text>
489 <Text wrap="truncate-end">{card.title}</Text>
490 {e.props.isInterrupted && <Text color="yellow">interrupted</Text>}
491 </Box>
492 {card.detail && (
493 <Box paddingLeft={2}>
494 <Text dimColor wrap="wrap">
495 {card.detail}
496 </Text>
497 </Box>
498 )}
499 {card.body && (
500 <Box paddingLeft={2} marginTop={1} flexDirection="column">
501 <Markdown text={card.body.split('\n').slice(0, BODY_LINES).join('\n')} />
502 {card.body.split('\n').length > BODY_LINES && <Text dimColor>{`… ${card.body.split('\n').length - BODY_LINES} more lines`}</Text>}
503 </Box>
504 )}
505 {picture && (
506 <Box paddingLeft={2} marginTop={1}>
507 {picture}
508 </Box>
509 )}
510 {grouped.has(e.props.tool_use_id) && e.props.output !== undefined && !picture && (
511 <Box paddingLeft={2}>
512 {e.props.tool === 'Bash' && !error ? (
513 bashOutput({ Box, Code, Text }, e.props.output as BashOutput, true, runs.get(e.props.tool_use_id))
514 ) : result ? (
515 <Box flexDirection="column">
516 {result
517 .split('\n')
518 .map((line, i) => (
519 <Text key={i} color={error ? 'red' : undefined} dimColor={!error} wrap="truncate-end">{`${i === 0 ? '↳' : ' '} ${line}`}</Text>
520 ))}
521 </Box>
522 ) : null}
523 </Box>
524 )}
525 </Box>
526 )
527 })
528
529 // Bash output sits under its card; see bashOutput. repo-docs draws its own reindex summary,
530 // and anything Claude Code adds that this does not draw keeps Claude Code's own row.
531 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
532 if (e.props.tool !== 'Bash' || e.props.isErrored) return next(e)
533 const output = (e.props.output ?? {}) as BashOutput
534 if (/repo_docs_index updated=/.test(output.stdout ?? '') || !isPlainBash(output)) return next(e)
535 const { Box, Code, Text } = $.ui.resolve(e)
536 const pad = (tree: RenderChildren) => <Box paddingLeft={INDENT + 2} paddingRight={1} width="100%">{tree}</Box>
537 const run = runs.get(e.props.tool_use_id)
538 // There is no expanded flag here, so output that would fold goes to Claude Code's own row,
539 // which details and ctrl+o unfold. A script and the files the command changed stay drawn here.
540 if ((output.stdout ?? '').replace(/^\[see remaining: .*\]$/gm, '').trimEnd().split('\n').length > OUTPUT_LINES) {
541 const own = await next({ ...e, props: { ...e.props, output: { ...output, bashEditDiff: undefined } } })
542 const script = scriptOf(run?.command ?? '')
543 if (!output.bashEditDiff?.files?.length && !script) return own
544 return (
545 <Box flexDirection="column" width="100%">
546 {script &&
547 pad(
548 <Box width="100%" backgroundColor={PANEL} marginTop={1}>
549 <Box width={1} backgroundColor={OUTPUT_BAR} />
550 <Box flexDirection="column" flexGrow={1} paddingX={2} paddingTop={1}>
551 {scriptSection({ Box, Code, Text }, script, run?.ms)}
552 </Box>
553 </Box>,
554 )}
555 {own}
556 {output.bashEditDiff?.files?.length ? pad(bashOutput({ Box, Code, Text }, output, false)) : null}
557 </Box>
558 )
559 }
560
561 return pad(bashOutput({ Box, Code, Text }, output, true, run))
562 })
563
564 // The enlarged screenshot fills the pane's width; Escape closes it.
565 on('ui.render', { component: 'Pane', requestId: SHOT_PANE }, async ($, e, next) => {
566 const shot = await read($, enlarged)
567 if (!shot || e.surface !== 'terminal') return next(e)
568 const { Box, Image } = $.ui.resolve(e)
569 const columns = Math.max(20, e.props.bodyColumns - 2)
570
571 return (
572 <Box paddingX={1}>
573 <Image source={{ png: shot.png }} columns={columns} rows={rowsFor(shot, columns)} alt="Screenshot" />
574 </Box>
575 )
576 })
577
578 on('ui.render', { component: 'InfoNotice' }, async ($, e) => {
579 const { Box, Text } = $.ui.resolve(e)
580
581 return (
582 <Box marginX={1} gap={1}>
583 <Text color="blue" bold>
584 ℹ
585 </Text>
586 <Text>{e.props.text}</Text>
587 {e.props.command && (
588 <Text backgroundColor="blue" color="black">
589 {` ${e.props.command} `}
590 </Text>
591 )}
592 </Box>
593 )
594 })
595}
596hooks/replay.tsx 172 lines1import { atom, read, update } from 'claude-code'
2import type { On } from 'claude-code'
3
4import type { FileEdit } from '../types'
5
6// Replay: every Edit and Write of a turn, kept as the file before and after, so the last turn's
7// changes can be stepped through side by side in a pane once it ends.
8
9const PANE = 'replay'
10const CONTEXT = 3
11const replay = atom({ plugin: 'prompt-timeline', key: 'replay' } as const, { edits: [] as FileEdit[], at: 0, isNew: false })
12
13// The turn's edits so far; moved into the atom when the turn ends.
14let recording: FileEdit[] = []
15
16// The changed block between two versions: common lines are trimmed from both ends, leaving one
17// region with CONTEXT lines either side. Edit changes one region, which this shows exactly.
18export function changedBlock(before: string, after: string) {
19 const a = before.split('\n')
20 const b = after.split('\n')
21 let start = 0
22 while (start < a.length && start < b.length && a[start] === b[start]) start += 1
23 let end = 0
24 while (end < a.length - start && end < b.length - start && a[a.length - 1 - end] === b[b.length - 1 - end]) end += 1
25 const from = Math.max(0, start - CONTEXT)
26
27 return {
28 from,
29 head: a.slice(from, start),
30 removed: a.slice(start, a.length - end),
31 added: b.slice(start, b.length - end),
32 tail: a.slice(a.length - end, Math.min(a.length, a.length - end + CONTEXT)),
33 }
34}
35
36export function registerReplay(on: On) {
37 // Two hooks, not a loop: a matcher's tool has to be a literal for the input to be typed.
38 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
39 const before = (await $.fs.exists(e.file_path)) ? await $.fs.read(e.file_path) : ''
40 const ran = await next(e)
41 if (ran.deny === undefined && ran.isError !== true) recording.push({ tool: 'Edit', file: e.file_path, before, after: await $.fs.read(e.file_path) })
42
43 return ran
44 })
45 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
46 const before = (await $.fs.exists(e.file_path)) ? await $.fs.read(e.file_path) : ''
47 const ran = await next(e)
48 if (ran.deny === undefined && ran.isError !== true) recording.push({ tool: 'Write', file: e.file_path, before, after: await $.fs.read(e.file_path) })
49
50 return ran
51 })
52
53 on('turn.complete', async ($, e, next) => {
54 const done = await next(e)
55 if (e.agentId === undefined && recording.length > 0) {
56 const edits = recording
57 recording = []
58 await update($, replay, () => ({ edits, at: 0, isNew: true }))
59 }
60
61 return done
62 })
63
64 on('prompt.submit', async ($, e, next) => {
65 await update($, replay, state => ({ ...state, isNew: false }))
66
67 return next(e)
68 })
69
70 on('command.run', { command: 'replay' }, async $ => {
71 if ((await read($, replay)).edits.length === 0) return { text: 'No edits recorded yet.' }
72 await update($, replay, state => ({ ...state, isNew: false }))
73 await $.ui.open({ id: PANE, title: 'Replay', focus: true, closeOnEscape: true })
74
75 return { text: 'Replay opened.' }
76 })
77
78 // A one-line hint above the prompt after a turn that edited files.
79 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
80 const state = await read($, replay)
81 const rest = await next(e)
82 if (!state.isNew || e.props.hasSurvey) return rest
83 const { Box, Text } = $.ui.resolve(e)
84 const files = new Set(state.edits.map(edit => edit.file)).size
85
86 return (
87 <Box flexDirection="column">
88 <Box paddingX={1} gap={1}>
89 <Text color="magenta">✎</Text>
90 <Text dimColor>
91 {state.edits.length} edit{state.edits.length === 1 ? '' : 's'} in {files} file{files === 1 ? '' : 's'} last turn · /replay to step through
92 </Text>
93 </Box>
94 {rest}
95 </Box>
96 )
97 })
98
99 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
100 const { Box, Button, Text } = $.ui.resolve(e)
101 const { edits, at } = await read($, replay)
102 const edit = edits[at]
103 if (!edit) return <Text dimColor>No edits recorded yet.</Text>
104 const block = changedBlock(edit.before, edit.after)
105 const side = Math.max(10, Math.floor((e.props.bodyColumns - 3) / 2))
106 const width = String(block.from + Math.max(block.head.length + block.removed.length, block.head.length + block.added.length) + block.tail.length).length
107 const cell = (n: number | null, line: string | undefined, color?: string) => (
108 <Box width={side}>
109 <Text color={color} dimColor={color === undefined} wrap="truncate-end">
110 {line === undefined ? '' : `${String(n).padStart(width)} ${line}`}
111 </Text>
112 </Box>
113 )
114 const rows = Math.max(block.removed.length, block.added.length)
115 const go = (step: number) => update($, replay, state => ({ ...state, at: (state.at + step + state.edits.length) % state.edits.length }))
116
117 return (
118 <Box flexDirection="column" paddingX={1}>
119 <Box justifyContent="space-between">
120 <Text bold wrap="truncate-end">
121 {edit.tool} {edit.file.split('/').slice(-2).join('/')}
122 </Text>
123 <Box gap={2}>
124 <Button key="replay-prev" plain hotkey="p" label="◀ Prev" onPress={() => go(-1)} />
125 <Text dimColor>
126 {at + 1}/{edits.length}
127 </Text>
128 <Button key="replay-next" plain hotkey="n" label="Next ▶" onPress={() => go(1)} />
129 </Box>
130 </Box>
131 <Box gap={1} marginTop={1}>
132 <Box width={side}>
133 <Text color="red" bold>
134 Before
135 </Text>
136 </Box>
137 <Text dimColor>│</Text>
138 <Box width={side}>
139 <Text color="green" bold>
140 After
141 </Text>
142 </Box>
143 </Box>
144 {block.head.map((line, i) => (
145 <Box key={`h${i}`} gap={1}>
146 {cell(block.from + i + 1, line)}
147 <Text dimColor>│</Text>
148 {cell(block.from + i + 1, line)}
149 </Box>
150 ))}
151 {Array.from({ length: rows }, (_, i) => {
152 const n = block.from + block.head.length + i + 1
153 return (
154 <Box key={`c${i}`} gap={1}>
155 {cell(n, block.removed[i], 'red')}
156 <Text dimColor>│</Text>
157 {cell(n, block.added[i], 'green')}
158 </Box>
159 )
160 })}
161 {block.tail.map((line, i) => (
162 <Box key={`t${i}`} gap={1}>
163 {cell(block.from + block.head.length + block.removed.length + i + 1, line)}
164 <Text dimColor>│</Text>
165 {cell(block.from + block.head.length + block.added.length + i + 1, line)}
166 </Box>
167 ))}
168 </Box>
169 )
170 })
171}
172hooks/theme.ts 6 lines1// The OpenCode-style palette: filled panels a shade lighter than the terminal, with a coloured
2// bar down their left edge in place of a border.
3export const PANEL = '#30333a'
4export const PROMPT_BAR = '#5b8def'
5export const OUTPUT_BAR = '#4a4e57'
6hooks/cards.ts 311 lines1// How each tool's call reads as a card: a colored label, one readable title, an optional dim
2// detail line, and an optional markdown body for text the call sends (a PR comment, a query).
3// `null` keeps Claude Code's own row (or another plugin's, like feedback's).
4
5export type Card = { label: string; color: string; title: string; detail?: string; body?: string }
6type Input = Record<string, unknown>
7
8const str = (value: unknown) => (typeof value === 'string' ? value : '')
9const home = (path: string) => path.replace(/^\/Users\/[^/]+/, '~')
10const base = (path: string) => path.split('/').pop() ?? path
11const dir = (path: string) => home(path.split('/').slice(0, -1).join('/'))
12const human = (name: string) => name.replace(/[_-]+/g, ' ').replace(/^\w/, c => c.toUpperCase())
13const firstLine = (text: string) => text.trim().split('\n')[0] ?? ''
14// A shell command as it reads at a prompt: a leading `cd X &&` becomes the folder shown in
15// front, home and scratchpad paths shorten, the rtk proxy prefix goes, and a chain of steps
16// (`a; b && c`) is split into one step per line, so a long one-liner reads top to bottom.
17const SCRATCH = /\/private\/tmp\/claude-\d+\/[^/\s'"]+\/[0-9a-f-]{36}\/scratchpad/g
18const STEPS_SHOWN = 4
19export function shell(command: string) {
20 let rest = command.trim()
21 let where = ''
22 const cd = rest.match(/^cd\s+("[^"]+"|'[^']+'|\S+)\s*(?:&&|;)\s*/)
23 if (cd?.[1]) {
24 where = base(cd[1].replace(/^(['"])(.*)\1$/, '$2').replace(/\/$/, ''))
25 rest = rest.slice(cd[0].length)
26 }
27 const lines = rest.split('\n')
28 const first = (lines[0] ?? '').replace(SCRATCH, 'scratchpad').replace(/\/Users\/[^/\s'"]+/g, '~')
29 const steps = joinLoops(splitSteps(first))
30 .map(step => step.replace(/^rtk\s+/, ''))
31 .filter(step => !SEPARATOR.test(step))
32
33 return { where, steps, more: lines.length - 1 }
34}
35
36// `echo "==="`, `echo "--- rules:"`: section markers for the output, not steps worth reading.
37const SEPARATOR = /^(?:echo|printf)\s+(?:-e\s+)?['"]?(?:\\n)?(?:={2,}|-{2,}|═+|#{2,}|─+)/
38// A loop or if is one step: `for f in a b; do x; done` splits at its own semicolons otherwise.
39function joinLoops(steps: string[]) {
40 const out: string[] = []
41 let depth = 0
42 for (const step of steps) {
43 const opens = /^(?:for|while|until|if|case)\b/.test(step) ? 1 : 0
44 const closes = /^(?:done|fi|esac)\b/.test(step) ? 1 : 0
45 if (depth > 0) out[out.length - 1] += `; ${step}`
46 else out.push(step)
47 depth = Math.max(0, depth + opens - closes)
48 }
49
50 return out
51}
52
53// Split on && || ; outside quotes and $( ), keeping pipes inside a step.
54function splitSteps(line: string) {
55 const out: string[] = []
56 let buf = ''
57 let quote = ''
58 let depth = 0
59 for (let i = 0; i < line.length; i += 1) {
60 const c = line[i] ?? ''
61 if (quote) {
62 if (c === quote && line[i - 1] !== '\\') quote = ''
63 buf += c
64 continue
65 }
66 if (c === '"' || c === "'") quote = c
67 if (c === '(') depth += 1
68 if (c === ')') depth = Math.max(0, depth - 1)
69 const two = line.slice(i, i + 2)
70 if (depth === 0 && (two === '&&' || two === '||' || c === ';')) {
71 if (buf.trim()) out.push(buf.trim())
72 buf = ''
73 if (two === '&&' || two === '||') i += 1
74 continue
75 }
76 buf += c
77 }
78 if (buf.trim()) out.push(buf.trim())
79
80 return out
81}
82
83export function shellDetail(command: string) {
84 const { where, steps, more } = shell(command)
85 const shown = steps.slice(0, STEPS_SHOWN).map((step, i) => `${i === 0 ? `${where ? `${where} ` : ''}$ ` : ' '}${step}`)
86 const rest = [steps.length > STEPS_SHOWN ? `+${steps.length - STEPS_SHOWN} more steps` : '', more ? `+${more} lines` : ''].filter(Boolean)
87
88 return `${shown.join('\n')}${rest.length ? ` (${rest.join(', ')})` : ''}`
89}
90
91const lineCount = (text: string) => (text ? text.split('\n').length : 0)
92// A script's first line that says something: `() => {` and `async () => {` say nothing.
93const firstCode = (code: string) => code.split('\n').map(line => line.trim()).find(line => line && !/^(async\s*)?(\(\)|function\s*\(\))\s*(=>)?\s*\{?$/.test(line)) ?? ''
94
95function browser(action: string, input: Input): Card {
96 const target = str(input.url) || str(input.uid) || str(input.selector)
97 const moves: Record<string, string> = { reload: 'Reload page', back: 'Go back', forward: 'Go forward' }
98 const waitFor = Array.isArray(input.text) ? input.text.join('" or "') : str(input.text)
99 const titles: Record<string, string> = {
100 navigate_page: str(input.url) ? `Open ${str(input.url)}` : (moves[str(input.type)] ?? 'Navigate'),
101 new_page: `New tab ${str(input.url)}`,
102 click: `${input.dblClick ? 'Double-click' : 'Click'} ${target}`,
103 fill: `Fill ${target}`,
104 fill_form: 'Fill form',
105 hover: `Hover ${target}`,
106 press_key: `Press ${str(input.key)}`,
107 type_text: `Type "${str(input.text)}"`,
108 evaluate_script: 'Run script in page',
109 take_snapshot: 'Snapshot page',
110 take_screenshot: input.fullPage ? 'Screenshot (full page)' : 'Screenshot',
111 list_pages: 'List tabs',
112 select_page: 'Switch tab',
113 wait_for: `Wait for "${waitFor}"`,
114 resize_page: `Resize to ${input.width}×${input.height}`,
115 emulate: `${[str(input.viewport), str(input.networkConditions), input.cpuThrottlingRate ? `CPU ×${input.cpuThrottlingRate}` : ''].filter(Boolean).join(', ').replace(/^(?=.)/, 'Emulate ') || 'Reset emulation'}`,
116 }
117
118 return {
119 label: 'Browser',
120 color: 'magenta',
121 title: titles[action] ?? human(action),
122 detail: action === 'evaluate_script' ? firstCode(str(input.function)) : undefined,
123 }
124}
125
126function docs(action: string, input: Input): Card {
127 const query = `"${str(input.query)}"`
128 const titles: Record<string, string> = {
129 find_docs: `Search ${query}`,
130 read_doc: str(input.path),
131 list_docs: input.source && input.source !== 'all' ? `List ${human(str(input.source))}` : 'List docs',
132 find_libs: `Package ${query}`,
133 }
134
135 return { label: action === 'find_libs' ? 'Packages' : 'Docs', color: 'yellow', title: titles[action] ?? human(action) }
136}
137
138// Any other MCP call: the action in the title; the ids, branches, file and line, or design node
139// as the detail; and the text it sends (a comment, a description, a query) as a markdown body,
140// whole, so its code spans and line breaks survive.
141const TEXT_KEYS = ['content', 'text', 'body', 'message', 'comment', 'description', 'prompt']
142const QUERY_KEYS = ['wiql', 'query', 'sql']
143// Context every call in a project repeats; it says nothing about this call.
144const QUIET = new Set(['action', 'project', 'repositoryId', 'version', 'versionType', 'pageId'])
145function mcpSummary(tool: string, input: Input) {
146 const action = str(input.action)
147 const ids = Array.isArray(input.ids) ? (input.ids as unknown[]) : []
148 const id = Object.entries(input).find(([key, value]) => /Id$/.test(key) && typeof value === 'number')?.[1]
149 const idText = ids.length ? `#${ids.slice(0, 3).join(', #')}${ids.length > 3 ? ` +${ids.length - 3}` : ''}` : id === undefined ? '' : `#${id}`
150 const branch = (ref: unknown) => str(ref).replace(/^refs\/heads\//, '')
151 const branches = input.sourceRefName ? `${branch(input.sourceRefName)} → ${branch(input.targetRefName)}` : input.targetRefName ? `into ${branch(input.targetRefName)}` : ''
152 const file = str(input.filePath) || str(input.file_path) || str(input.path) || str(input.url)
153 const line = [input.rightFileStartLine, input.startLine, input.line].find(value => typeof value === 'number')
154 const node = str(input.nodeId) ? `node ${str(input.nodeId)}` : ''
155 const title = str(input.title)
156 const text = TEXT_KEYS.map(key => str(input[key])).find(Boolean) ?? ''
157 const query = QUERY_KEYS.map(key => str(input[key])).find(Boolean) ?? ''
158 const parts = [idText, branches, str(input.status), file ? `${base(file)}${line === undefined ? '' : `:${line}`}` : '', node, title ? `"${title}"` : '']
159 const fallback = Object.entries(input).find(([key, value]) => typeof value === 'string' && !QUIET.has(key) && value !== text && value !== query)?.[1] as string | undefined
160 const detail = parts.filter(Boolean).join(' · ') || (fallback && !text && !query ? firstLine(fallback) : '')
161 const body = text || (query ? `\`\`\`\n${query}\n\`\`\`` : '')
162
163 return { title: `${human(tool)}${action ? ` · ${action}` : ''}`, detail: detail || undefined, body: body || undefined }
164}
165
166export function describe(tool: string, input: Input): Card | null {
167 const path = str(input.file_path) || str(input.notebook_path)
168 switch (tool) {
169 case 'Bash': {
170 const command = str(input.command)
171 // repo-docs draws its own reindex row.
172 if (command.includes('build-semantic-index.cjs')) return null
173 const description = str(input.description)
174 const detail = shellDetail(command)
175 // With no description the command itself is the title.
176 if (!description) return { label: 'Bash', color: 'blackBright', title: detail.split('\n')[0] ?? '', detail: detail.split('\n').slice(1).join('\n') || undefined }
177 return { label: 'Bash', color: 'blackBright', title: description, detail }
178 }
179 case 'Read': {
180 const offset = Number(input.offset ?? 0)
181 const limit = Number(input.limit ?? 0)
182 const range = limit ? ` lines ${offset + 1}–${offset + limit}` : ''
183 return { label: 'Read', color: 'blue', title: `${base(path)}${range}`, detail: dir(path) }
184 }
185 case 'Edit':
186 case 'MultiEdit':
187 case 'NotebookEdit': {
188 const removed = lineCount(str(input.old_string))
189 const added = lineCount(str(input.new_string))
190 const size = removed || added ? ` −${removed} +${added} lines${input.replace_all ? ', every match' : ''}` : ''
191 return { label: 'Edit', color: 'yellow', title: `${base(path)}${size}`, detail: dir(path) }
192 }
193 case 'Write':
194 return { label: 'Write', color: 'yellow', title: `${base(path)} ${lineCount(str(input.content))} lines`, detail: dir(path) }
195 case 'Grep':
196 case 'Glob':
197 return { label: 'Search', color: 'cyan', title: `"${str(input.pattern)}"`, detail: home(str(input.path)) || undefined }
198 case 'WebFetch': {
199 const url = str(input.url)
200 return { label: 'Web', color: 'cyan', title: url.replace(/^https?:\/\//, ''), detail: firstLine(str(input.prompt)) }
201 }
202 case 'WebSearch':
203 return { label: 'Web', color: 'cyan', title: `Search "${str(input.query)}"` }
204 case 'ToolSearch':
205 return { label: 'Tools', color: 'blackBright', title: `Load ${str(input.query).replace(/^select:/, '')}` }
206 case 'Skill': {
207 const [plugin, name] = str(input.skill).includes(':') ? str(input.skill).split(':') : ['', str(input.skill)]
208 return { label: 'Skill', color: 'magenta', title: `/${name}`, detail: firstLine(str(input.args)) || (plugin ? `from ${plugin}` : undefined) }
209 }
210 case 'SendMessage':
211 return { label: 'Agent', color: 'green', title: `Message: ${str(input.summary) || firstLine(str(input.message))}`, detail: `to ${str(input.to)}` }
212 case 'TaskStop':
213 return { label: 'Task', color: 'blackBright', title: `Stop ${str(input.task_id)}` }
214 case 'ReadNotifications':
215 return { label: 'Inbox', color: 'blackBright', title: 'Read notifications' }
216 case 'SendUserFile': {
217 const files = Array.isArray(input.files) ? (input.files as string[]) : []
218 return { label: 'File', color: 'green', title: `Send ${files.map(base).join(', ')}`, detail: firstLine(str(input.caption)) || undefined }
219 }
220 case 'Agent':
221 case 'Task':
222 return { label: 'Agent', color: 'green', title: str(input.description), detail: str(input.subagent_type) || undefined }
223 }
224 const mcp = tool.match(/^mcp__(.+?)__(.+)$/)
225 if (!mcp?.[1] || !mcp[2]) return null
226 const server = mcp[1].replace(/^plugin_[^_]+_/, '').replace(/^claude_ai_/, '')
227 // The feedback plugin draws its own rows.
228 if (server === 'feedback') return null
229 if (server === 'chrome-devtools') return browser(mcp[2], input)
230 if (server === 'repo-docs') return docs(mcp[2], input)
231 if (server === 'codegraph') return { label: 'Code', color: 'yellow', title: firstLine(str(input.query)) }
232 return { label: human(server), color: 'blue', ...mcpSummary(mcp[2], input) }
233}
234
235// An MCP result keeps its line breaks, so a list of hits reads as a list. find_docs shows every
236// hit; other results show RESULT_LINES and say how many they left out. read_doc returns a whole
237// doc, so it reads like Read: a line count.
238const RESULT_LINES = 8
239function mcpResult(tool: string, text: string) {
240 if (/repo-docs__read_doc$/.test(tool)) return `${text.split('\n').length} lines`
241 const lines = text.trim().split('\n').filter(line => line.trim())
242 if (/repo-docs__find_docs$/.test(tool) || lines.length <= RESULT_LINES) return lines.join('\n')
243 return [...lines.slice(0, RESULT_LINES), `… +${lines.length - RESULT_LINES} more lines`].join('\n')
244}
245
246// A call that errored stores the error text the model read in place of its result.
247export function errorLines(output: unknown): string | undefined {
248 return typeof output === 'string' && output.trim() ? mcpResult('', output) : undefined
249}
250
251// What a call returned, for rows inside an expanded group, where Claude Code draws the result
252// inline instead of as its own row. One line, except an MCP result, which keeps its lines.
253export function resultLine(tool: string, output: unknown): string | undefined {
254 const o = (output ?? {}) as Record<string, unknown>
255 const num = (value: unknown) => (typeof value === 'number' ? value : undefined)
256 const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
257 switch (tool) {
258 case 'Read': {
259 const file = (o.file ?? {}) as Record<string, unknown>
260 const lines = num(file.numLines)
261 if (o.type === 'image') return 'image'
262 if (lines === undefined) return undefined
263 const total = num(file.totalLines)
264 return total !== undefined && total > lines ? `${lines} of ${total} lines` : plural(lines, 'line')
265 }
266 case 'Grep':
267 case 'Glob': {
268 if (typeof output === 'string') return firstLine(output)
269 const matches = num(o.numMatches) ?? num(o.numLines)
270 const files = num(o.numFiles)
271 return [files === undefined ? '' : plural(files, 'file'), matches === undefined ? '' : plural(matches, 'match')].filter(Boolean).join(', ') || undefined
272 }
273 case 'WebFetch':
274 return o.code === undefined ? undefined : `${o.code} ${str(o.codeText)} · ${Math.round(Number(o.bytes ?? 0) / 1024)} KB`
275 case 'WebSearch':
276 return Array.isArray(o.results) ? plural(o.results.length, 'result') : undefined
277 case 'ToolSearch':
278 return Array.isArray(o.matches) ? `${plural(o.matches.length, 'tool')} loaded` : undefined
279 case 'Agent':
280 case 'Task': {
281 const tools = num(o.totalToolUseCount)
282 const tokens = num(o.totalTokens)
283 const ms = num(o.totalDurationMs)
284 const parts = [o.status && o.status !== 'completed' ? str(o.status) : '', tools === undefined ? '' : plural(tools, 'tool call'), tokens === undefined ? '' : `${Math.round(tokens / 1000)}k tokens`, ms === undefined ? '' : `${Math.round(ms / 1000)}s`]
285 return parts.filter(Boolean).join(' · ') || undefined
286 }
287 }
288 if (Array.isArray(output)) {
289 const text = (output as { type?: string; text?: string }[]).find(block => block.type === 'text')?.text
290 return text ? mcpResult(tool, text) : undefined
291 }
292
293 return undefined
294}
295
296// A PNG the tool returned as an MCP image block (a browser screenshot), with its size from the
297// PNG header so the picture keeps its shape.
298export function pngIn(output: unknown): { png: string; width: number; height: number } | null {
299 type Block = { type?: string; data?: string; mimeType?: string; source?: { data?: string; media_type?: string } }
300 const blocks: Block[] = Array.isArray(output) ? output : []
301 // The MCP block form, or the API's `source` form the transcript stores.
302 const image = blocks.find(block => block.type === 'image' && (block.mimeType ?? block.source?.media_type) === 'image/png')
303 const png = image?.data ?? image?.source?.data
304 if (!png) return null
305 const head = atob(png.slice(0, 44))
306 const at = (i: number) => ((head.charCodeAt(i) << 24) | (head.charCodeAt(i + 1) << 16) | (head.charCodeAt(i + 2) << 8) | head.charCodeAt(i + 3)) >>> 0
307
308 return { png, width: at(16), height: at(20) }
309}
310
311types/index.d.ts 10 lines1export type ReplyTag = { model: string; effort: string }
2export type Prompt = { id: string; text: string; isDrawn?: boolean }
3export type FileEdit = { tool: 'Edit' | 'Write'; file: string; before: string; after: string }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'prompt-timeline': { prompts: Prompt[]; replyTags: Record<string, ReplyTag>; enlarged: { png: string; width: number; height: number } | null; replay: { edits: FileEdit[]; at: number; isNew: boolean } }
8 }
9}
10