SLOPSHOPPER

prompt-timeline

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.

newpanebandspinnerrowsguard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-timeline
│ ┃ Timeline ✕ │ ┃ Your prompts 0 this session │ ┃ ◌ Queued · sends when the current turn ends │ ┃ Prompts show up here as they're drawn in fix the failing auth test and add an audit log call │ ┃ the transcript. │ ┃ │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ │ → Edit auth.ts │ /work/app/src │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ │ │ Done. I made refresh reject expired claims and added an audit │ call. │ │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /timeline │ ⎿ prompt-timeline: Timeline pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Timeline
Your prompts 0 this session Prompts show up here as they're drawn in the transcript.
Pane · Replay
Edit src/auth.ts p: ◀ Prev 1/1 n: Next ▶ Before │ After 6 return issue(claims.s… │ 6 return issue(claims.s… 7 } │ 7 } 8 │ 8
Your message
◌ Queued · sends when the current turn ends fix the failing auth test and add an audit log call
Claude's reply
Done. I made refresh reject expired claims and added an audit call.
Tool row
→ Edit auth.ts /work/app/src
README

prompt-timeline

A mod that makes your own prompts easy to find in a long session.

  • Your prompts draw as a cyan card headed › You · #N, so they stand apart from Claude's output.
  • A strip above the prompt box lists your last six prompts; click one to jump back to it. It sits above the prompt rather than in a pane because panes share one dock as tabs.
  • /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

  • A prompt that is still queued (sent while a turn runs) draws as a dim "◌ Queued" card until it is sent. Slash commands keep the default row.
  • 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.
  • Each reply keeps the model and effort it was made with (saved per session in the plugin's store on disk, keyed by the start of the reply's text), so a later /model or effort change never relabels older replies.
  • A Bash call's card leads with its description and shows the command as one dim line.
  • Tool calls draw as cards (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.
Source 6 files
hooks/register.tsx 211 lines
1import { 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}
211
hooks/look.tsx 596 lines
1import { 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}
596
hooks/replay.tsx 172 lines
1import { 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}
172
hooks/theme.ts 6 lines
1// 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'
6
hooks/cards.ts 311 lines
1// 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
311
types/index.d.ts 10 lines
1export 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