Replays every change Claude made in a turn, shell edits included.

<img src="../../brand/pixel-icons/svg/play-by-play.svg" width="64" height="64" alt="">
Replays every change Claude made in a turn, shell edits included.
/plugin install play-by-play --marketplace modmyclaude/claude-code-mods
4 edits · 3 files · +38 −12 · press r or /replay, with a Replay and a Hide button. r and x press them once the band has the keyboard (click it, or ctrl+x tab)./replay opens a pane on the latest turn. /replay <n> opens an earlier one: 1 is the latest, up to 10 turns that changed files are kept. It runs at once, even while Claude is working, and shows only finished turns: a turn still running is never shown half done (the pane says one is in progress), and an open pane stays on its turn when the running one ends. The command answers the model nothing; its messages are toasts. A headless run, with no pane, gets the steps as text.+/− counts, via shell when a command made it, and the diff with removed lines red and added lines green. A binary file shows as "binary file changed (N bytes)", never as a diff./replay opens it):| Key | Does |
|---|---|
n | next step |
p | previous step |
1-9 | first change of that file |
q, Esc | close |
| arrow keys | after a click on the pane's "Keys" line: left/up previous, right/down next |
| up / down | without that click, only on a step taller than the pane |
The mouse wheel and Page Up / Page Down still scroll a long diff.
None.
--no-optional-locks: git rev-parse, git diff-index -p, git ls-files --others, git hash-object (without -w), and for changed files git show <HEAD>:<path>, git cat-file --batch-check and git diff --no-index (an untracked file). Porcelain git diff is not used, because it rewrites .git/index. Nothing is staged, stashed, committed or written, and .git/index is never touched.ls), plus one or two per changed file. Each diffs the whole uncommitted tree against HEAD, and hash-object reads every untracked, unignored file, up to 500 of them (past that, untracked files are listed by name and their contents are not read). In a 100,000-file repository that is about half a second per command, and slower after many files were touched (a checkout, a formatter) until your own git status refreshes the index; see the pause below.node_modules, build output) are not tracked, by design.r in the prompt box types an r; it presses Replay only once the band has the keyboard.This mod was built from prompts/play-by-play.md.
More mods: the full catalogue.
hooks/register.tsx 439 lines1// Play-by-Play: records every file change of a turn (file tools and shell
2// commands alike) and replays them one step at a time in a pane.
3//
4// Read-only by design: it never writes, reverts or stages anything. Shell
5// changes come from read-only git commands run before and after each Bash
6// call; file-tool changes come from the tool's own record.
7
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10
11import type { PlayByPlayStep, PlayByPlayTurn } from '../types'
12import { boltSprite } from './bolt-draw'
13import { clean, cleanName } from './diff'
14import { shellSteps, snapshot, type Run, type Snapshot } from './shell'
15import {
16 addSteps,
17 cellStep,
18 editStep,
19 excerpt,
20 jumpList,
21 keyAction,
22 MAX_TURNS,
23 navigate,
24 parseBack,
25 plusMinus,
26 relPath,
27 score,
28 stepTitle,
29 summaryLine,
30 writeStep,
31 type Nav,
32} from './steps'
33
34const PANE = 'play-by-play'
35const TITLE = 'Play-by-Play'
36/** A snapshot slower than this pauses shell tracking for the session. */
37const SLOW_MS = 2_000
38/** Each git call's limit: a call past it fails, which pauses tracking too. */
39const GIT_TIMEOUT_MS = SLOW_MS + 1_000
40/** Narrower than this, the pane keeps every column for its line and Bolt steps out. */
41const BOLT_MIN_COLUMNS = 40
42
43const current = atom({ plugin: 'play-by-play', key: 'current' } as const, null)
44const turns = atom({ plugin: 'play-by-play', key: 'turns' } as const, [])
45const hint = atom({ plugin: 'play-by-play', key: 'hint' } as const, false)
46const view = atom({ plugin: 'play-by-play', key: 'view' } as const, { back: 1, step: 0 })
47const shellPaused = atom({ plugin: 'play-by-play', key: 'shellPaused' } as const, false)
48
49type Dollar = EngineInterface
50
51/** A line for the debug log; logging must never break the hook it reports from. */
52function debug($: Dollar, text: string) {
53 try {
54 $.ui.log(`play-by-play: ${text}`, { to: 'debug' })
55 } catch {
56 // nothing to do: the log is best effort
57 }
58}
59
60// The last text a file tool gave each file, so a later shell change to an
61// untracked file can still be diffed. Memory only, small, oldest out first.
62const known = new Map<string, string>()
63function remember(file: string, text: string | undefined) {
64 if (text === undefined || text.length > 512_000) return
65 known.delete(file)
66 known.set(file, text)
67 if (known.size > 64) known.delete(known.keys().next().value ?? '')
68}
69
70// Which files a file tool changed, by a running count, so a shell snapshot
71// that spans a concurrent tool edit does not record that edit twice.
72let seq = 0
73const toolEdited = new Map<string, number>()
74
75/**
76 * Pauses shell tracking for the rest of the session, so a large repository
77 * never taxes every command; says so once, however many calls find it slow.
78 */
79async function pauseShell($: Dollar, why: string) {
80 const held = await $.state.get({ plugin: 'play-by-play', key: 'shellPaused' })
81 if (held.value === true) return
82 const { isSet } = await $.state.set({ plugin: 'play-by-play', key: 'shellPaused' }, true, { ifVersion: held.version })
83 if (isSet) {
84 try {
85 $.ui.log(`play-by-play: shell tracking paused for this session (${why}); file tools are still tracked.`)
86 } catch {
87 // best effort, as with debug()
88 }
89 }
90}
91
92/** Snapshots on the session's clock: one over SLOW_MS, failing, or too large pauses tracking. */
93async function timedSnapshot($: Dollar, run: Run, at?: Snapshot): Promise<Snapshot | null> {
94 const startedAt = await $.clock.now()
95 const taken = await snapshot(run, at).catch((error: unknown) => {
96 debug($, `git failed around a command: ${String(error)}`)
97 return 'failed' as const
98 })
99 const spent = (await $.clock.now()) - startedAt
100 if (taken === 'failed') await pauseShell($, 'git did not answer in time here')
101 else if (taken === 'too-large') await pauseShell($, 'the uncommitted changes are over 4 MiB')
102 else if (spent > SLOW_MS) await pauseShell($, `git took ${(spent / 1000).toFixed(1)} s here`)
103 return typeof taken === 'string' ? null : taken
104}
105
106async function record($: Dollar, steps: readonly PlayByPlayStep[]) {
107 if (steps.length === 0) return
108 await update($, current, turn =>
109 addSteps(turn ?? { id: 'between-turns', prompt: 'changes made between turns', steps: [], dropped: 0 }, steps),
110 )
111}
112
113/** Moves a finished turn into the kept list and raises the hint. */
114async function finishTurn($: Dollar) {
115 const taken: { turn: PlayByPlayTurn | null } = { turn: null }
116 await update($, current, turn => {
117 taken.turn = turn
118 return null
119 })
120 const turn = taken.turn
121 if (turn === null || turn.steps.length + turn.dropped === 0) return
122 await update($, turns, list => [...list, turn].slice(-MAX_TURNS))
123 // An open pane keeps showing the turn it was on, now one further back.
124 await update($, view, at => ({ ...at, back: Math.min(MAX_TURNS, at.back + 1) }))
125 await update($, hint, () => true)
126}
127
128async function go($: Dollar, nav: Nav) {
129 if (nav.go === 'close') return $.ui.close({ id: PANE })
130 const list = await read($, turns)
131 await update($, view, at => {
132 const turn = list[list.length - at.back]
133 return turn === undefined ? at : navigate(at, nav, turn)
134 })
135}
136
137/** A short message for the person alone: the model never reads a toast. */
138function tell($: Dollar, text: string) {
139 try {
140 $.ui.toast(text)
141 } catch {
142 // best effort, as with debug()
143 }
144}
145
146/**
147 * Opens the pane on a kept turn. Only finished turns are kept, so a turn
148 * still running is never shown half done (the pane says one is running).
149 * Answers text only where no surface places the pane (a headless run), since
150 * a command's text is read by the model and the pane is the answer.
151 */
152async function openReplay($: Dollar, back: number): Promise<string | undefined> {
153 const list = await read($, turns)
154 const turn = list[list.length - back]
155 if (turn === undefined) {
156 tell(
157 $,
158 list.length === 0
159 ? 'nothing to replay yet: no finished turn has changed files.'
160 : `only ${list.length} turn${list.length === 1 ? '' : 's'} with changes kept; try /replay 1 to ${list.length}.`,
161 )
162 return undefined
163 }
164 await update($, view, () => ({ back, step: 0 }))
165 const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true, closeOnEscape: true })
166 if (opened.isPlaced) return undefined
167 const which = back === 1 ? 'the latest turn' : `turn ${back} back`
168 const lines = turn.steps.map(
169 (step, index) =>
170 `${index + 1}. ${cleanName(stepTitle(step))} (${step.kind}) ${plusMinus(step.added, step.removed)}${step.via === 'shell' ? ' via shell' : ''}`,
171 )
172 return [`${which}: ${summaryLine(turn)}`, ...lines].join('\n')
173}
174
175export const register: Register = on => {
176 on('session.start', async ($, e, next) => {
177 try {
178 await $.command.register({
179 name: 'replay',
180 description: 'Step through the file changes of a turn, shell edits included',
181 argumentHint: '[n]',
182 // Runs at once, mid-turn too, to replay earlier turns while Claude works.
183 immediate: true,
184 })
185 } catch (error) {
186 debug($, `could not register /replay: ${String(error)}`)
187 }
188 return next(e)
189 })
190
191 on('turn.start', async ($, e, next) => {
192 // Turn boundaries are the main loop's alone. The types say a subagent's
193 // run raises no turn.start; should one carry an agentId anyway, it must
194 // not split the person's turn (its edits still land in the main turn).
195 if ('agentId' in e && e.agentId !== undefined) return next(e)
196 await finishTurn($) // a turn whose end was missed is kept, not lost
197 await update($, current, () => ({ id: e.turnId, prompt: excerpt(e.text), steps: [], dropped: 0 }))
198 await update($, hint, () => false)
199 return next(e)
200 })
201
202 on('turn.complete', async ($, e, next) => {
203 // A subagent's run ends inside the main turn; only the main loop closes it.
204 if (e.agentId === undefined) await finishTurn($)
205 return next(e)
206 })
207
208 on('tool.call', async ($, e, next) => {
209 if (e.tool === 'Bash') {
210 if (await read($, shellPaused)) return next(e)
211 // Snapshot before the command; outside a git repository, or if git
212 // fails, the command just runs.
213 const run: Run = (argv, init) => $.process.run(argv, { timeoutMs: GIT_TIMEOUT_MS, ...init })
214 const mark = seq
215 const before = await timedSnapshot($, run)
216 const ran = await next(e)
217 // A denied command ran nothing, and core marks one it held read-only
218 // (ls, cat, git status): no second snapshot. A failed one may still
219 // have changed files.
220 if (before === null || ran.deny !== undefined || ran.isReadOnly === true) return ran
221 if (await read($, shellPaused)) return ran // the first snapshot was already too slow
222 try {
223 const after = await timedSnapshot($, run, before)
224 if (after !== null) {
225 // Shown relative to the session's folder, as file-tool steps are.
226 const display = (path: string) => {
227 if (before.prefix === '') return path
228 return path.startsWith(before.prefix) ? path.slice(before.prefix.length) : `${before.root}/${path}`
229 }
230 const steps = await shellSteps(run, before, after, { display, known: file => known.get(file) })
231 const editedMeanwhile = (file: string | undefined) =>
232 file !== undefined && (toolEdited.get(file) ?? -1) > mark
233 await record($, steps.filter(step => !editedMeanwhile(step.file) && !editedMeanwhile(step.from)))
234 }
235 } catch (error) {
236 debug($, `could not read what Bash changed: ${String(error)}`)
237 }
238 return ran
239 }
240
241 if (e.tool !== 'Edit' && e.tool !== 'Write' && e.tool !== 'NotebookEdit') return next(e)
242 const ran = await next(e)
243 // Denied, failed or held for review: the file did not change.
244 if (ran.deny !== undefined || ran.isError === true) return ran
245 try {
246 const cwd = await $.session.cwd()
247 let step: PlayByPlayStep | null
248 let file: string
249 if (e.tool === 'Edit') {
250 file = relPath(e.file_path, cwd)
251 const made = editStep(file, e, ran.result)
252 step = made.step
253 remember(file, made.after)
254 } else if (e.tool === 'Write') {
255 file = relPath(e.file_path, cwd)
256 const made = writeStep(file, e, ran.result)
257 step = made.step
258 remember(file, made.after)
259 } else {
260 file = relPath(e.notebook_path, cwd)
261 step = cellStep(file, e, ran.result)
262 }
263 if (step !== null) {
264 toolEdited.set(file, ++seq)
265 await record($, [step])
266 }
267 } catch (error) {
268 debug($, `could not record ${e.tool}: ${String(error)}`)
269 }
270 return ran
271 }).catch(($, e, next) => next(e)) // an observer: whatever fails here, the tool runs untouched
272
273 on('command.run', { command: 'replay' }, async ($, e) => {
274 const back = parseBack(e.args)
275 if (back === null) {
276 tell($, `usage: /replay [n], where n is 1 (the latest turn) to ${MAX_TURNS}.`)
277 return {}
278 }
279 const text = await openReplay($, back)
280 return text === undefined ? {} : { text }
281 })
282
283 // The hint in the band above the prompt after a turn that changed files.
284 // It composes: other mods' band lines are drawn first, so an overflowing
285 // band never pushes their buttons out of view; the hint comes after them.
286 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
287 if (e.props.hasSurvey || e.props.isWorking || !(await read($, hint))) return next(e)
288 const list = await read($, turns)
289 const turn = list[list.length - 1]
290 if (turn === undefined) return next(e)
291 const { Box, Button, Text } = $.ui.resolve(e)
292 const below = await next(e)
293 return (
294 <Box flexDirection="column">
295 {below}
296 <Box key="play-by-play" flexDirection="row" flexWrap="wrap" columnGap={1}>
297 <Text>{clean(`${summaryLine(turn)} \u00b7 press r or /replay`)}</Text>
298 <Button key="replay" hotkey="r" label="Replay" onPress={() => void openReplay($, 1)} />
299 <Button key="hide" hotkey="x" label="Hide" role="dismiss" dimColor onPress={() => void update($, hint, () => false)} />
300 </Box>
301 </Box>
302 )
303 })
304
305 // The replay pane.
306 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
307 const { Box, Button, Code, Text } = $.ui.resolve(e)
308 const close = (
309 <Button key="close" hotkey="q" label="Close" role="dismiss" onPress={() => void $.ui.close({ id: PANE })} />
310 )
311 const list = await read($, turns)
312 const at = await read($, view)
313 const turn = list[list.length - at.back]
314 if (turn === undefined || turn.steps.length === 0) {
315 // Bolt sleeps while there is nothing to replay: a static sprite,
316 // only where the pane has room for it.
317 const roomy = e.props.bodyColumns >= BOLT_MIN_COLUMNS
318 return (
319 <Box flexDirection="column">
320 <Box key="empty" flexDirection="row" gap={2} alignItems="center">
321 {roomy ? boltSprite($.ui.resolve(e), e.surface, { size: 'tiny', pose: 'sleep' }) : null}
322 <Text dimColor>Nothing to replay yet: no turn has changed files.</Text>
323 </Box>
324 {close}
325 </Box>
326 )
327 }
328
329 const last = turn.steps.length - 1
330 const index = Math.min(Math.max(0, at.step), last)
331 const step = turn.steps[index] ?? turn.steps[0]
332 if (step === undefined) return close
333 const board = score(turn)
334 const files = jumpList(turn)
335 const width = Math.max(24, e.props.bodyColumns)
336 const short = (path: string, max: number) => (path.length > max ? `…${path.slice(-(max - 1))}` : path)
337
338 // Arrow keys reach a Client region; surfaces without one get buttons alone.
339 const keys =
340 e.surface === 'terminal' || e.surface === 'desktop' ? (
341 (() => {
342 const elements = $.ui.resolve(e)
343 return elements.Client({ key: 'keys', module: './keys.ts', props: { hint: 'Keys: n/p step, 1-9 jump to a file, q closes; click this line for the arrow keys' } })
344 })()
345 ) : (
346 <Text dimColor>Arrow keys need the terminal or the desktop app here; use the buttons.</Text>
347 )
348
349 const where = list.length > 1 ? `Turn ${at.back} of ${list.length}, newest first` : 'Latest turn'
350 const biggest = board.biggest
351 ? ` · biggest: ${cleanName(short(board.biggest.file, Math.floor(width / 2)))} ${plusMinus(board.biggest.added, board.biggest.removed)}`
352 : ''
353 const dropped = turn.dropped > 0 ? ` · ${turn.dropped} more not kept` : ''
354 // A turn still running is never replayed half done; say that one is.
355 const running = await read($, current)
356 const inFlight = running !== null && running.steps.length > 0
357
358 return (
359 <Box flexDirection="column">
360 <Text dimColor wrap="truncate-end">
361 {where}
362 {turn.prompt ? ` · "${cleanName(turn.prompt)}"` : ''}
363 </Text>
364 {inFlight ? (
365 <Text color="warning">A turn in progress has changed files; its steps show here once it ends.</Text>
366 ) : null}
367 <Text bold wrap="truncate-end">
368 {`${board.files} file${board.files === 1 ? '' : 's'} · ${plusMinus(board.added, board.removed)} · ${board.edits} edit${board.edits === 1 ? '' : 's'}${biggest}${dropped}`}
369 </Text>
370 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
371 {files.slice(0, 9).map((entry, i) => (
372 <Button
373 key={`file-${i + 1}`}
374 hotkey={String(i + 1)}
375 plain
376 label={cleanName(short(entry.file, Math.max(12, Math.floor(width / 3))))}
377 dimColor={entry.file !== step.file}
378 onPress={() => void go($, { go: 'jump', file: i + 1 })}
379 />
380 ))}
381 {files.length > 9 ? <Text dimColor>+{files.length - 9} more files</Text> : null}
382 </Box>
383 <Box flexDirection="row" columnGap={1}>
384 <Button key="prev" hotkey="p" label="Prev" dimColor={index === 0} onPress={() => void go($, { go: 'prev' })} />
385 <Button
386 key="next"
387 hotkey="n"
388 label="Next"
389 variant="primary"
390 dimColor={index === last}
391 onPress={() => void go($, { go: 'next' })}
392 />
393 {close}
394 <Text>
395 Step {index + 1} of {turn.steps.length}
396 </Text>
397 </Box>
398 {keys}
399 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
400 <Text bold wrap="truncate-start">
401 {cleanName(stepTitle(step))}
402 </Text>
403 <Text dimColor>
404 {step.kind} {'·'} {plusMinus(step.added, step.removed)}
405 </Text>
406 {step.via === 'shell' ? <Text color="warning">{'·'} via shell</Text> : null}
407 </Box>
408 {step.note ? <Text dimColor>{clean(step.note)}</Text> : null}
409 {step.hunks !== '' ? (
410 <Code source={clean(step.hunks)} format="diff" path={cleanName(step.file)} />
411 ) : (
412 <Text dimColor>No line detail for this step.</Text>
413 )}
414 </Box>
415 )
416 })
417
418 // Arrow keys while the pane holds the keyboard: an arrow moves the window
419 // one row and carries no pointer (the wheel always does). Up and down step
420 // through the changes; the wheel and the page keys still scroll a long diff.
421 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
422 if (e.origin.kind !== 'person' || e.pointer !== undefined || Math.abs(e.by) !== 1) return next(e)
423 await go($, e.by > 0 ? { go: 'next' } : { go: 'prev' })
424 return next({ ...e, offset: 0 })
425 }).catch(($, e, next) => next(e)) // on failure the window scrolls as usual
426
427 // Keys from the pane's Client line.
428 on('ui.message', { requestId: PANE }, async ($, e) => {
429 const data = e.data
430 const key =
431 typeof data === 'object' && data !== null && typeof (data as { key?: unknown }).key === 'string'
432 ? (data as { key: string }).key
433 : null
434 const nav = key === null ? null : keyAction(key)
435 if (nav !== null) await go($, nav)
436 return {}
437 })
438}
439hooks/bolt-draw.tsx 86 lines1// Draws Bolt, the modmyclaude mascot (brand/bolt-sprites), on the surface a hook draws for.
2// Same file in every mod that shows Bolt: mods install on their own, so they cannot share code.
3// Terminal: one Raster, a cell grid. Desktop: one code-font cell per character, since its Text is
4// proportional and a row of half-blocks would drift. Elsewhere: coloured Text runs.
5// Bolt is static here: no timer, no redraw of its own. Never draw it over a safety hold or in a toast.
6
7import type { BoxProps, ElementConstructor, RasterProps, RenderElement, TextProps } from 'claude-code'
8
9import { boltCells, boltRuns } from './bolt'
10import type { BoltCell, BoltSmallPose, BoltTinyPose } from './bolt'
11
12type Elements = {
13 Box: ElementConstructor<BoxProps>
14 Text: ElementConstructor<TextProps>
15 Raster?: ElementConstructor<RasterProps>
16}
17
18export type BoltPick = { size: 'small'; pose: BoltSmallPose } | { size: 'tiny'; pose: BoltTinyPose }
19
20/** Columns a pose takes: small is 10, tiny is 3. */
21export const BOLT_COLUMNS = { small: 10, tiny: 3 } as const
22
23/** The cells of a pose: half-blocks, two pixel rows per cell. */
24export function boltOf(pick: BoltPick): BoltCell[][] {
25 return pick.size === 'small' ? boltCells('small', pick.pose) : boltCells('tiny', pick.pose)
26}
27
28/** Bolt as one element under `key`, drawn the way `surface` lays out cells. */
29export function boltSprite(elements: Elements, surface: string, pick: BoltPick, key = 'bolt'): RenderElement {
30 const { Box, Text, Raster } = elements
31 const cells = boltOf(pick)
32 const columns = cells[0]?.length ?? 0
33 if (surface === 'terminal' && Raster !== undefined) {
34 return <Raster key={key} columns={columns} rows={cells.length} cells={rasterCells(cells)} />
35 }
36 const paint = (cell: BoltCell): TextProps => ({
37 ...(cell.fg !== undefined ? { color: cell.fg } : {}),
38 ...(cell.bg !== undefined ? { backgroundColor: cell.bg } : {}),
39 })
40 if (surface === 'desktop') {
41 return (
42 <Box key={key} flexDirection="column" flexShrink={0}>
43 {cells.map(line => (
44 <Box flexDirection="row" flexWrap="nowrap">
45 {line.map(cell =>
46 cell.fg === undefined && cell.bg === undefined ? <Box width={1} /> : <Box width={1}><Text {...paint(cell)}>{cell.ch}</Text></Box>,
47 )}
48 </Box>
49 ))}
50 </Box>
51 )
52 }
53 return (
54 <Box key={key} flexDirection="column" flexShrink={0}>
55 {cells.map(line => (
56 <Text>
57 {boltRuns(line).map(run => {
58 const text = run.ch.replace(/ /g, ' ')
59 return run.fg === undefined && run.bg === undefined ? text : <Text {...paint(run)}>{text}</Text>
60 })}
61 </Text>
62 ))}
63 </Box>
64 )
65}
66
67/** RasterProps.cells: standard base64 of little-endian u32 [codePoint, fg, bg] per cell, row-major. */
68export function rasterCells(cells: readonly (readonly BoltCell[])[]): string {
69 const DEFAULT = 0x01000000
70 const count = cells.reduce((sum, line) => sum + line.length, 0)
71 const view = new DataView(new ArrayBuffer(count * 12))
72 let at = 0
73 for (const line of cells) {
74 for (const cell of line) {
75 view.setUint32(at, cell.ch.codePointAt(0) ?? 32, true)
76 view.setUint32(at + 4, cell.fg === undefined ? DEFAULT : parseInt(cell.fg.slice(1), 16), true)
77 view.setUint32(at + 8, cell.bg === undefined ? DEFAULT : parseInt(cell.bg.slice(1), 16), true)
78 at += 12
79 }
80 }
81 let binary = ''
82 const bytes = new Uint8Array(view.buffer)
83 for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i] ?? 0)
84 return btoa(binary)
85}
86hooks/diff.ts 390 lines1// Pure text work: a line diff, unified hunks, and a reader for `git diff` output.
2// Nothing here touches `$`, so every function is unit-tested on its own.
3
4/** One line of an edit script: kept (' '), removed ('-') or added ('+'). */
5export type Op = { type: ' ' | '-' | '+'; text: string }
6
7/** One unified-diff hunk; `lines` carry their ' ', '-', '+' or '\' prefix. */
8export type Hunk = {
9 oldStart: number
10 oldLines: number
11 newStart: number
12 newLines: number
13 lines: string[]
14}
15
16/** Above this many LCS cells the changed middle is shown as one block. */
17const MAX_CELLS = 4_000_000
18
19/** A text as lines; a final newline does not make an empty last line. */
20export function splitLines(text: string): string[] {
21 if (text === '') return []
22 const lines = text.split('\n')
23 if (lines[lines.length - 1] === '') lines.pop()
24 return lines
25}
26
27/**
28 * The shortest edit script from `a` to `b` by longest common subsequence.
29 * Common head and tail are trimmed first (most edits are local), and a middle
30 * too large to compare is reported as removed-then-added: counts stay exact
31 * for that block, only the pairing is coarser.
32 */
33export function diffLines(a: readonly string[], b: readonly string[], maxCells = MAX_CELLS): Op[] {
34 let head = 0
35 while (head < a.length && head < b.length && a[head] === b[head]) head++
36 let tail = 0
37 while (
38 tail < a.length - head &&
39 tail < b.length - head &&
40 a[a.length - 1 - tail] === b[b.length - 1 - tail]
41 ) {
42 tail++
43 }
44 const ops: Op[] = a.slice(0, head).map(text => ({ type: ' ', text }))
45 const x = a.slice(head, a.length - tail)
46 const y = b.slice(head, b.length - tail)
47 const n = x.length
48 const m = y.length
49
50 if (n === 0 || m === 0 || (n + 1) * (m + 1) > maxCells) {
51 for (const text of x) ops.push({ type: '-', text })
52 for (const text of y) ops.push({ type: '+', text })
53 } else {
54 // dp[i][j] = LCS length of x[i..] and y[j..]; Uint16 holds it since min(n, m) < 2000 here.
55 const w = m + 1
56 const dp = new Uint16Array((n + 1) * w)
57 for (let i = n - 1; i >= 0; i--) {
58 for (let j = m - 1; j >= 0; j--) {
59 dp[i * w + j] =
60 x[i] === y[j]
61 ? (dp[(i + 1) * w + j + 1] ?? 0) + 1
62 : Math.max(dp[(i + 1) * w + j] ?? 0, dp[i * w + j + 1] ?? 0)
63 }
64 }
65 let i = 0
66 let j = 0
67 while (i < n && j < m) {
68 if (x[i] === y[j]) {
69 ops.push({ type: ' ', text: x[i] ?? '' })
70 i++
71 j++
72 } else if ((dp[(i + 1) * w + j] ?? 0) >= (dp[i * w + j + 1] ?? 0)) {
73 ops.push({ type: '-', text: x[i] ?? '' })
74 i++
75 } else {
76 ops.push({ type: '+', text: y[j] ?? '' })
77 j++
78 }
79 }
80 for (; i < n; i++) ops.push({ type: '-', text: x[i] ?? '' })
81 for (; j < m; j++) ops.push({ type: '+', text: y[j] ?? '' })
82 }
83 for (const text of a.slice(a.length - tail)) ops.push({ type: ' ', text })
84 return ops
85}
86
87/** Groups an edit script into hunks with `context` kept lines around each change. */
88export function toHunks(ops: readonly Op[], context = 3): Hunk[] {
89 const hunks: Hunk[] = []
90 const changed = ops.flatMap((op, index) => (op.type === ' ' ? [] : [index]))
91 if (changed.length === 0) return hunks
92
93 // Old and new line counts consumed before each op, for the @@ numbers.
94 const oldBefore: number[] = []
95 const newBefore: number[] = []
96 let o = 0
97 let n = 0
98 for (const op of ops) {
99 oldBefore.push(o)
100 newBefore.push(n)
101 if (op.type !== '+') o++
102 if (op.type !== '-') n++
103 }
104
105 let k = 0
106 while (k < changed.length) {
107 const first = changed[k] ?? 0
108 let last = first
109 // Merge changes whose context windows touch.
110 while (k + 1 < changed.length && (changed[k + 1] ?? 0) - last <= context * 2 + 1) {
111 k++
112 last = changed[k] ?? last
113 }
114 k++
115 const from = Math.max(0, first - context)
116 const to = Math.min(ops.length - 1, last + context)
117 const slice = ops.slice(from, to + 1)
118 const oldLines = slice.filter(op => op.type !== '+').length
119 const newLines = slice.filter(op => op.type !== '-').length
120 const oldAt = oldBefore[from] ?? 0
121 const newAt = newBefore[from] ?? 0
122 hunks.push({
123 // Unified diff numbers an empty side by the line before it.
124 oldStart: oldLines > 0 ? oldAt + 1 : oldAt,
125 oldLines,
126 newStart: newLines > 0 ? newAt + 1 : newAt,
127 newLines,
128 lines: slice.map(op => op.type + op.text),
129 })
130 }
131 return hunks
132}
133
134/** Lines added and removed across hunks. */
135export function countHunks(hunks: readonly Hunk[]): { added: number; removed: number } {
136 let added = 0
137 let removed = 0
138 for (const hunk of hunks) {
139 for (const line of hunk.lines) {
140 if (line.startsWith('+')) added++
141 else if (line.startsWith('-')) removed++
142 }
143 }
144 return { added, removed }
145}
146
147/**
148 * The hunks as unified-diff text, at most `maxLines` diff lines. Whole hunks
149 * are kept while they fit; a first hunk too long alone is cut and its header
150 * recounted, so the text always parses as hunks.
151 */
152export function hunksText(
153 hunks: readonly Hunk[],
154 maxLines = Infinity,
155): { text: string; shown: number; total: number } {
156 const total = hunks.reduce((sum, hunk) => sum + hunk.lines.length, 0)
157 const parts: string[] = []
158 let shown = 0
159 for (const hunk of hunks) {
160 if (shown + hunk.lines.length <= maxLines) {
161 parts.push(header(hunk), ...hunk.lines)
162 shown += hunk.lines.length
163 continue
164 }
165 if (shown === 0) {
166 const lines = hunk.lines.slice(0, Math.max(1, maxLines))
167 const cut = {
168 ...hunk,
169 oldLines: lines.filter(line => line[0] === ' ' || line[0] === '-').length,
170 newLines: lines.filter(line => line[0] === ' ' || line[0] === '+').length,
171 lines,
172 }
173 parts.push(header(cut), ...lines)
174 shown = lines.length
175 }
176 break
177 }
178 return { text: parts.map(clean).join('\n'), shown, total }
179}
180
181function header(hunk: Hunk): string {
182 return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`
183}
184
185// ANSI escape sequences (CSI, OSC, and the one-byte C1 CSI), dropped whole.
186// eslint-disable-next-line no-control-regex
187const ANSI = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?|\u009b[0-?]*[ -/]*[@-~]/g
188// C0 but tab and newline, DEL, and C1 (U+0080..U+009F, what stray binary bytes decode to).
189// eslint-disable-next-line no-control-regex
190const CONTROLS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g
191
192/**
193 * Text safe to draw: a surface takes tab and newline as its only control
194 * characters and refuses a whole tree holding any other. ANSI sequences go,
195 * a line's trailing CR goes, and every other control becomes U+FFFD.
196 */
197export function clean(text: string): string {
198 return text.replace(/\r$/, '').replace(ANSI, '').replace(CONTROLS, '\uFFFD')
199}
200
201/**
202 * A name drawn on one line (a file path, a rename target, a prompt excerpt):
203 * as `clean`, and a tab or newline in it becomes U+FFFD too. Paths may hold
204 * any byte but NUL and `/`, so every path is untrusted text.
205 */
206export function cleanName(text: string): string {
207 return clean(text).replace(/[\t\n\r]/g, '\uFFFD')
208}
209
210/** Git's own test for binary content: a NUL in the first 8000 characters. */
211export function isBinary(text: string): boolean {
212 return text.slice(0, 8000).includes('\0')
213}
214
215/** One file's section of `git diff` output. */
216export type FileDiff = {
217 /** Path before the change; null for an added file. */
218 oldPath: string | null
219 /** Path after the change; null for a deleted file. */
220 newPath: string | null
221 binary: boolean
222 hunks: Hunk[]
223 /** The section's text, to tell a changed section from an unchanged one. */
224 raw: string
225}
226
227/**
228 * Reads `git diff` output run with `--src-prefix=a/ --dst-prefix=b/`. Hunk
229 * bodies are consumed by their @@ counts, so a content line can never be
230 * mistaken for a header.
231 */
232export function parseGitDiff(text: string): FileDiff[] {
233 const files: FileDiff[] = []
234 const lines = splitLines(text)
235 let file: (FileDiff & { rawLines: string[]; gitHeader: string }) | null = null
236 let hunk: Hunk | null = null
237 let oldLeft = 0
238 let newLeft = 0
239
240 const finish = () => {
241 if (file === null) return
242 if (file.oldPath === null && file.newPath === null) {
243 // No ---/+++ or rename lines (a mode change, an empty new file): the
244 // header names the same path twice, `a/<p> b/<p>`.
245 const rest = file.gitHeader
246 const path = unquote(rest.slice(2, 2 + Math.max(0, (rest.length - 5) / 2)))
247 file.oldPath = file.rawLines.some(line => line.startsWith('new file mode')) ? null : path
248 file.newPath = file.rawLines.some(line => line.startsWith('deleted file mode')) ? null : path
249 }
250 const { rawLines, gitHeader, ...section } = file
251 files.push({ ...section, raw: rawLines.join('\n') })
252 file = null
253 }
254
255 for (const line of lines) {
256 if (hunk !== null && (oldLeft > 0 || newLeft > 0)) {
257 hunk.lines.push(line)
258 file?.rawLines.push(line)
259 if (line.startsWith(' ')) {
260 oldLeft--
261 newLeft--
262 } else if (line.startsWith('-')) oldLeft--
263 else if (line.startsWith('+')) newLeft--
264 else if (line === '') {
265 // Some tools strip the space of an empty context line.
266 hunk.lines[hunk.lines.length - 1] = ' '
267 oldLeft--
268 newLeft--
269 }
270 continue
271 }
272 if (line.startsWith('\\') && hunk !== null) {
273 hunk.lines.push(line)
274 file?.rawLines.push(line)
275 continue
276 }
277 if (line.startsWith('diff --git ')) {
278 finish()
279 hunk = null
280 file = {
281 oldPath: null,
282 newPath: null,
283 binary: false,
284 hunks: [],
285 raw: '',
286 rawLines: [line],
287 gitHeader: line.slice('diff --git '.length),
288 }
289 continue
290 }
291 if (file === null) continue
292 file.rawLines.push(line)
293 const at = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line)
294 if (at) {
295 hunk = {
296 oldStart: Number(at[1]),
297 oldLines: at[2] === undefined ? 1 : Number(at[2]),
298 newStart: Number(at[3]),
299 newLines: at[4] === undefined ? 1 : Number(at[4]),
300 lines: [],
301 }
302 oldLeft = hunk.oldLines
303 newLeft = hunk.newLines
304 file.hunks.push(hunk)
305 } else if (line.startsWith('rename from ')) file.oldPath = unquote(line.slice(12))
306 else if (line.startsWith('rename to ')) file.newPath = unquote(line.slice(10))
307 else if (line.startsWith('--- ')) file.oldPath = sidePath(line.slice(4), 'a/')
308 else if (line.startsWith('+++ ')) file.newPath = sidePath(line.slice(4), 'b/')
309 else if (line.startsWith('Binary files ')) {
310 file.binary = true
311 const names = /^Binary files (.+) and (.+) differ$/.exec(line)
312 if (names) {
313 file.oldPath = sidePath(names[1] ?? '', 'a/')
314 file.newPath = sidePath(names[2] ?? '', 'b/')
315 }
316 }
317 }
318 finish()
319 return files
320}
321
322/** A `---`/`+++` path: /dev/null is none; the prefix and git's trailing tab go. */
323function sidePath(spec: string, prefix: string): string | null {
324 const path = unquote(spec.replace(/\t$/, ''))
325 if (path === '/dev/null') return null
326 return path.startsWith(prefix) ? path.slice(prefix.length) : path
327}
328
329/** Undoes git's C-style quoting of an unusual path (`"a/we\tird"`). */
330export function unquote(spec: string): string {
331 if (!(spec.length >= 2 && spec.startsWith('"') && spec.endsWith('"'))) return spec
332 const body = spec.slice(1, -1)
333 const bytes: number[] = []
334 const encoder = new TextEncoder()
335 const simple: Record<string, string> = { n: '\n', t: '\t', r: '\r', '"': '"', '\\': '\\', a: '\x07', b: '\b', f: '\f', v: '\v' }
336 for (let i = 0; i < body.length; i++) {
337 const char = body[i] ?? ''
338 if (char !== '\\') {
339 bytes.push(...encoder.encode(char))
340 continue
341 }
342 const next = body[i + 1] ?? ''
343 const octal = /^[0-7]{3}/.exec(body.slice(i + 1))
344 if (octal) {
345 bytes.push(parseInt(octal[0], 8))
346 i += 3
347 } else {
348 bytes.push(...encoder.encode(simple[next] ?? next))
349 i += 1
350 }
351 }
352 return new TextDecoder().decode(new Uint8Array(bytes))
353}
354
355/**
356 * Applies hunks made against `base` and returns the result. Exact bytes
357 * matter only for hashing, so a final newline is tracked from the
358 * `\ No newline at end of file` markers.
359 */
360export function applyHunks(base: string, hunks: readonly Hunk[]): string {
361 const from = splitLines(base)
362 const out: string[] = []
363 let at = 0
364 let endsWithNewline = base === '' || base.endsWith('\n')
365 for (const hunk of hunks) {
366 const start = Math.max(0, hunk.oldLines > 0 ? hunk.oldStart - 1 : hunk.oldStart)
367 while (at < start && at < from.length) out.push(from[at++] ?? '')
368 let previous = ' '
369 for (const line of hunk.lines) {
370 const mark = line[0] ?? ' '
371 if (mark === '\\') {
372 // The marker speaks for the line before it: a new-side line means the
373 // result ends without a newline; an old-side one that it now has one.
374 endsWithNewline = previous === '-'
375 continue
376 }
377 previous = mark
378 if (mark === '+') out.push(line.slice(1))
379 else if (mark === '-') at++
380 else {
381 out.push(line.slice(1))
382 at++
383 }
384 }
385 }
386 while (at < from.length) out.push(from[at++] ?? '')
387 if (out.length === 0) return ''
388 return out.join('\n') + (endsWithNewline ? '\n' : '')
389}
390hooks/shell.ts 332 lines1// What a shell command changed, from git snapshots taken before and after it.
2//
3// Every git call here is read-only plumbing, run with --no-optional-locks:
4// rev-parse, diff-index, ls-files, hash-object (without -w), show, cat-file,
5// and diff --no-index for an untracked file. Porcelain `git diff <commit>`
6// is avoided on purpose: it refreshes and rewrites .git/index (taking
7// index.lock) whenever files are stat-dirty. Nothing is staged or written.
8
9import type { PlayByPlayStep } from '../types'
10import { applyHunks, isBinary, parseGitDiff, type FileDiff } from './diff'
11import { bareStep, binaryNote, stepFromTexts } from './steps'
12
13/** `$.process.run`'s shape, narrowed to what is read here (a test can fake it). */
14export type Run = (
15 argv: readonly string[],
16 init?: { cwd?: string; stdin?: string },
17) => Promise<{ exitCode: number; stdout: string; isStdoutTruncated: boolean }>
18
19/** Git's empty tree: the base in a repository with no commit yet. */
20export const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
21/** Changed files looked at per command; past it one "more" step stands in. */
22export const MAX_SHELL_FILES = 25
23/** Above this many untracked files their contents are not hashed. */
24const MAX_HASHED = 500
25
26/** Output options shared by diff-index and diff --no-index, so both parse alike. */
27const DIFF = [
28 '--no-color',
29 '--no-ext-diff',
30 '--no-textconv',
31 '--no-relative',
32 '--src-prefix=a/',
33 '--dst-prefix=b/',
34 '-M',
35 '--ignore-submodules',
36]
37
38/** The working tree as git sees it, against a fixed base commit. */
39export type Snapshot = {
40 root: string
41 /** The session folder's place in the repository (`src/`), '' at its root. */
42 prefix: string
43 /** HEAD when the command started, kept for the after-snapshot so a commit
44 * made by the command does not hide what it changed. */
45 base: string
46 files: FileDiff[]
47 /** Untracked, unignored paths and their blob hash (null: not hashed). */
48 untracked: Map<string, string | null>
49}
50
51function git(run: Run, root: string, args: readonly string[], stdin?: string) {
52 return run(['git', '--no-optional-locks', '-c', 'core.quotepath=off', ...args], {
53 cwd: root,
54 ...(stdin !== undefined ? { stdin } : {}),
55 })
56}
57
58/** Why there is no snapshot: not in a repository, or more pending than can be read whole. */
59export type NoSnapshot = 'not-a-repo' | 'too-large'
60
61/**
62 * Snapshots the repository the session runs in, in two or three git calls
63 * (three or four the first time, to find the repository). `at` reuses a
64 * first snapshot's root and base, so the two compare like for like.
65 */
66export async function snapshot(
67 run: Run,
68 at?: Pick<Snapshot, 'root' | 'prefix' | 'base'>,
69): Promise<Snapshot | NoSnapshot> {
70 let where = at
71 if (where === undefined) {
72 // One call: root, prefix and HEAD. With no commit yet git fails on HEAD
73 // but still prints the first two; outside a repository it prints nothing.
74 const top = await run(['git', '--no-optional-locks', 'rev-parse', '--show-toplevel', '--show-prefix', 'HEAD'])
75 const [root = '', prefix = '', head = ''] = top.stdout.split('\n')
76 if (root === '') return 'not-a-repo'
77 where = { root, prefix, base: top.exitCode === 0 && /^[0-9a-f]{40,64}$/.test(head) ? head : EMPTY_TREE }
78 }
79 const diff = await git(run, where.root, ['diff-index', '-p', ...DIFF, where.base, '--'])
80 if (diff.isStdoutTruncated) return 'too-large'
81 if (diff.exitCode !== 0) return 'not-a-repo'
82 const listed = await git(run, where.root, ['ls-files', '--others', '--exclude-standard', '-z'])
83 if (listed.isStdoutTruncated) return 'too-large'
84 if (listed.exitCode !== 0) return 'not-a-repo'
85 const paths = listed.stdout.split('\0').filter(path => path !== '')
86 const untracked = new Map<string, string | null>(paths.map(path => [path, null]))
87 if (paths.length > 0 && paths.length <= MAX_HASHED && !paths.some(path => path.includes('\n'))) {
88 const hashed = await git(
89 run,
90 where.root,
91 ['hash-object', '--no-filters', '--stdin-paths'],
92 `${paths.join('\n')}\n`,
93 )
94 const hashes = hashed.stdout.split('\n').filter(line => line !== '')
95 if (hashed.exitCode === 0 && hashes.length === paths.length) {
96 paths.forEach((path, index) => untracked.set(path, hashes[index] ?? null))
97 }
98 }
99 return { ...where, files: parseGitDiff(diff.stdout), untracked }
100}
101
102/** Git's blob id for a text: the hash `git hash-object` prints for its bytes. */
103export async function blobHash(text: string): Promise<string> {
104 const body = new TextEncoder().encode(text)
105 const head = new TextEncoder().encode(`blob ${body.length}\0`)
106 const all = new Uint8Array(head.length + body.length)
107 all.set(head)
108 all.set(body, head.length)
109 const digest = await crypto.subtle.digest('SHA-1', all)
110 return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('')
111}
112
113/** What a path held in a snapshot. */
114type State =
115 | { kind: 'text'; text: string }
116 | { kind: 'absent' }
117 | { kind: 'binary'; bytes?: number | null; hash?: string | null }
118 | { kind: 'untracked'; hash: string | null }
119 | { kind: 'unknown' }
120
121export type ShellOptions = {
122 /** The path as steps show it, from a repository-relative one. */
123 display: (path: string) => string
124 /** The last text a file tool gave a file, by shown path (to diff an untracked file). */
125 known: (file: string) => string | undefined
126}
127
128/** The steps a command made, comparing the snapshots taken around it. */
129export async function shellSteps(
130 run: Run,
131 before: Snapshot,
132 after: Snapshot,
133 options: ShellOptions,
134): Promise<PlayByPlayStep[]> {
135 // Base contents, read once each per command: the text, a binary file (and
136 // its size), null when the base has no such path, undefined when unreadable.
137 type Base = string | { binary: number | null; hash: string | null } | null | undefined
138 const baseTexts = new Map<string, Promise<Base>>()
139 const readBase = async (path: string): Promise<Base> => {
140 const spec = `${before.base}:${path}`
141 // `--` makes git read `spec` as a revision only, never a glob pathspec
142 // (`app/[slug]/page.tsx` would otherwise answer an empty file).
143 const shown = await git(run, before.root, ['show', spec, '--'])
144 if (shown.exitCode !== 0) return null
145 if (shown.isStdoutTruncated || isBinary(shown.stdout)) {
146 if (!isBinary(shown.stdout)) return undefined
147 // One call for its blob id and size: "<id> blob <size>".
148 const check = await git(run, before.root, ['cat-file', '--batch-check'], `${spec}\n`)
149 const [hash = '', type = '', size = ''] = check.stdout.trim().split(' ')
150 const ok = check.exitCode === 0 && type === 'blob'
151 return { binary: ok && /^\d+$/.test(size) ? Number(size) : null, hash: ok ? hash : null }
152 }
153 return shown.stdout
154 }
155 const base = (path: string) => {
156 let text = baseTexts.get(path)
157 if (text === undefined) {
158 text = readBase(path)
159 baseTexts.set(path, text)
160 }
161 return text
162 }
163 const fromBase = (read: Base, absent: State): State =>
164 read === undefined
165 ? { kind: 'unknown' }
166 : read === null
167 ? absent
168 : typeof read === 'string'
169 ? { kind: 'text', text: read }
170 : { kind: 'binary', bytes: read.binary, hash: read.hash }
171
172 const stateOf = async (snap: Snapshot, path: string): Promise<State> => {
173 const entry = snap.files.find(file => file.newPath === path)
174 if (entry !== undefined) {
175 if (entry.binary) return { kind: 'binary' }
176 const from = entry.oldPath === null ? '' : await base(entry.oldPath)
177 if (typeof from === 'string') return { kind: 'text', text: applyHunks(from, entry.hunks) }
178 return from !== null && from !== undefined ? { kind: 'binary' } : { kind: 'unknown' }
179 }
180 if (snap.files.some(file => file.oldPath === path)) return { kind: 'absent' }
181 if (snap.untracked.has(path)) return { kind: 'untracked', hash: snap.untracked.get(path) ?? null }
182 return fromBase(await base(path), { kind: 'absent' })
183 }
184
185 // An untracked file had no earlier copy in git; a file tool may have left
186 // us one, trusted only when its hash matches what was on disk.
187 const earlier = async (path: string, hash: string | null): Promise<State> => {
188 const text = options.known(options.display(path))
189 if (text !== undefined && hash !== null && (await blobHash(text)) === hash) return { kind: 'text', text }
190 return { kind: 'untracked', hash }
191 }
192
193 // An untracked file now on disk, read through git so no file is opened here.
194 const current = async (path: string): Promise<State> => {
195 const shown = await git(run, after.root, ['diff', ...DIFF.filter(arg => arg !== '-M'), '--no-index', '--', '/dev/null', path])
196 if (shown.exitCode > 1 || shown.isStdoutTruncated) return { kind: 'unknown' }
197 const [file] = parseGitDiff(shown.stdout)
198 if (file?.binary) return { kind: 'binary' }
199 return { kind: 'text', text: applyHunks('', file?.hunks ?? []) }
200 }
201
202 // Paths whose section of the diff changed, or whose untracked file did.
203 const candidates = new Set<string>()
204 const beforeRaw = new Set(before.files.map(file => file.raw))
205 const afterRaw = new Set(after.files.map(file => file.raw))
206 const touch = (file: FileDiff) => {
207 if (file.oldPath !== null) candidates.add(file.oldPath)
208 if (file.newPath !== null) candidates.add(file.newPath)
209 }
210 for (const file of after.files) if (!beforeRaw.has(file.raw)) touch(file)
211 for (const file of before.files) if (!afterRaw.has(file.raw)) touch(file)
212 const untracked = [...new Set([...before.untracked.keys(), ...after.untracked.keys()])].sort()
213 for (const path of untracked) {
214 const was = before.untracked.get(path)
215 const now = after.untracked.get(path)
216 if (before.untracked.has(path) !== after.untracked.has(path) || (was !== null && now !== null && was !== now)) {
217 candidates.add(path)
218 }
219 }
220
221 type Change = { path: string; was: State; now: State }
222 const changes: Change[] = []
223 const paths = [...candidates]
224 for (const path of paths.slice(0, MAX_SHELL_FILES)) {
225 let was = await stateOf(before, path)
226 let now = await stateOf(after, path)
227 if (was.kind === 'untracked') was = await earlier(path, was.hash)
228 if (now.kind === 'untracked') now = await current(path)
229 // Untracked before and after: git's own hashes settle it.
230 const wasHash = before.untracked.get(path)
231 const nowHash = after.untracked.get(path)
232 const rehashed = wasHash != null && nowHash != null && wasHash !== nowHash
233 if (!rehashed && !(await differs(was, now))) continue
234 changes.push({ path, was, now })
235 }
236
237 // Pair a removed path with an added one into a rename: git's own pairing
238 // first (a staged `git mv`), then identical content (a plain `mv`).
239 const renames = new Map<string, string>() // new path -> old path
240 const renamedAway = new Set<string>()
241 const pair = (to: string, from: string) => {
242 renames.set(to, from)
243 renamedAway.add(from)
244 }
245 const isGone = (change: Change) => change.now.kind === 'absent' && change.was.kind !== 'absent'
246 for (const file of after.files) {
247 const { oldPath, newPath } = file
248 if (oldPath === null || newPath === null || oldPath === newPath) continue
249 const gone = changes.find(change => change.path === oldPath)
250 const made = changes.find(change => change.path === newPath)
251 // git paired them itself (a staged `git mv`), binary files included.
252 if (gone && made && isGone(gone) && made.was.kind === 'absent' && made.now.kind !== 'absent') pair(newPath, oldPath)
253 }
254 for (const gone of changes) {
255 if (!isGone(gone) || renamedAway.has(gone.path)) continue
256 const wasText = gone.was.kind === 'text' ? gone.was.text : undefined
257 const wasHash = gone.was.kind === 'untracked' || gone.was.kind === 'binary' ? (gone.was.hash ?? null) : null
258 if (wasText === undefined && wasHash === null) continue
259 for (const made of changes) {
260 const { now } = made
261 if (made.was.kind !== 'absent' || (now.kind !== 'text' && now.kind !== 'binary') || renames.has(made.path)) continue
262 // Bytes are best compared by git's own hashes (a binary or untracked file).
263 const madeHash = after.untracked.get(made.path) ?? (now.kind === 'text' ? await blobHash(now.text) : null)
264 const same = wasText !== undefined ? now.kind === 'text' && now.text === wasText : madeHash === wasHash
265 if (same) {
266 pair(made.path, gone.path)
267 break
268 }
269 }
270 }
271
272 const steps: PlayByPlayStep[] = []
273 for (const { path, was, now } of changes) {
274 if (renamedAway.has(path)) continue
275 const file = options.display(path)
276 const from = renames.get(path)
277 if (from !== undefined) {
278 const old = changes.find(change => change.path === from)
279 if (now.kind !== 'text' || (old?.was.kind !== 'text' && old?.was.kind !== 'untracked')) {
280 // A moved binary file: no lines to show.
281 const bytes = old?.was.kind === 'binary' ? old.was.bytes : undefined
282 steps.push({ ...bareStep(file, 'rename', 'shell', binaryNote(bytes)), from: options.display(from) })
283 continue
284 }
285 // An untracked file moved whole has no earlier text; its hash matched.
286 const oldText = old.was.kind === 'text' ? old.was.text : now.text
287 const step = stepFromTexts(file, oldText, now.text, { via: 'shell', kind: 'rename', from: options.display(from) })
288 if (step) steps.push(step)
289 continue
290 }
291 const step = describe(file, was, now)
292 if (step) steps.push(step)
293 }
294 if (paths.length > MAX_SHELL_FILES) {
295 const left = paths.length - MAX_SHELL_FILES
296 steps.push(bareStep(`${left} more file${left === 1 ? '' : 's'}`, 'more', 'shell', 'past the per-command cap; not compared'))
297 }
298 return steps
299}
300
301/** Whether two states of a path differ, leaning to "same" where git cannot tell. */
302async function differs(was: State, now: State): Promise<boolean> {
303 if (was.kind === 'text' && now.kind === 'text') return was.text !== now.text
304 if (was.kind === 'absent' && now.kind === 'absent') return false
305 if (was.kind === 'untracked') {
306 if (now.kind === 'absent') return true
307 // `git add` of a file the command did not change: same hash, no step.
308 if (now.kind === 'text') return was.hash !== null && (await blobHash(now.text)) !== was.hash
309 return false
310 }
311 return true
312}
313
314/** One path's change as a step. */
315function describe(file: string, was: State, now: State): PlayByPlayStep | null {
316 const textOf = (state: State) => (state.kind === 'text' ? state.text : null)
317 if ((was.kind === 'text' || was.kind === 'absent') && (now.kind === 'text' || now.kind === 'absent')) {
318 return stepFromTexts(file, textOf(was), textOf(now), { via: 'shell' })
319 }
320 if (was.kind === 'untracked') {
321 return now.kind === 'absent'
322 ? bareStep(file, 'delete', 'shell', 'deleted; it was untracked, so git kept no copy to show')
323 : bareStep(file, 'change', 'shell', 'changed; it was untracked, so git kept no earlier copy to compare')
324 }
325 if (was.kind === 'binary' || now.kind === 'binary') {
326 const kind = was.kind === 'absent' ? 'create' : now.kind === 'absent' ? 'delete' : 'change'
327 const bytes = now.kind === 'binary' ? now.bytes : was.kind === 'binary' ? was.bytes : undefined
328 return bareStep(file, kind, 'shell', binaryNote(bytes))
329 }
330 return bareStep(file, 'change', 'shell', 'changed; too large to compare')
331}
332hooks/steps.ts 316 lines1// Pure step logic: turning a tool call or a before/after pair into a step,
2// the turn's scoreboard, and moving through steps. No `$` here.
3
4import type { PlayByPlayKind, PlayByPlayStep, PlayByPlayTurn, PlayByPlayView } from '../types'
5import { countHunks, diffLines, hunksText, isBinary, splitLines, toHunks } from './diff'
6
7/** Diff lines kept per step for drawing; counts always cover the whole change. */
8export const MAX_STEP_LINES = 400
9/** Diff text kept per turn; later steps keep their counts and drop the lines. */
10export const MAX_TURN_CHARS = 300_000
11/** Steps kept per turn; past it they are counted in `dropped`. */
12export const MAX_TURN_STEPS = 500
13/** Turns kept for /replay <n>. */
14export const MAX_TURNS = 10
15
16/** A step from the text a file had before and after (null: it did not exist). */
17export function stepFromTexts(
18 file: string,
19 before: string | null,
20 after: string | null,
21 options: { via: 'tool' | 'shell'; kind?: PlayByPlayKind; from?: string; note?: string },
22): PlayByPlayStep | null {
23 const kind =
24 options.kind ?? (before === null ? 'create' : after === null ? 'delete' : 'edit')
25 if (isBinary(before ?? '') || isBinary(after ?? '')) {
26 // Never diff bytes as lines: they decode to control characters.
27 if (before === after) return null
28 const bytes = new TextEncoder().encode(after ?? before ?? '').length
29 const step = bareStep(file, kind === 'edit' || kind === 'write' ? 'change' : kind, options.via, binaryNote(bytes))
30 return options.from !== undefined ? { ...step, from: options.from } : step
31 }
32 const ops = diffLines(splitLines(before ?? ''), splitLines(after ?? ''))
33 const hunks = toHunks(ops)
34 // A step that changes nothing (a no-op write, a final newline alone) is
35 // only worth keeping when it says something: a rename, or a new empty file.
36 if (hunks.length === 0 && kind !== 'rename' && kind !== 'create' && kind !== 'delete') return null
37 const { added, removed } = countHunks(hunks)
38 const { text, shown, total } = hunksText(hunks, MAX_STEP_LINES)
39 const cut = shown < total ? `showing ${shown} of ${total} diff lines` : undefined
40 const note = [options.note, cut].filter(Boolean).join('; ') || undefined
41 return {
42 file,
43 ...(options.from !== undefined ? { from: options.from } : {}),
44 kind,
45 via: options.via,
46 added,
47 removed,
48 hunks: text,
49 ...(note ? { note } : {}),
50 }
51}
52
53/** The note a binary step shows: `binary file changed (1234 bytes)`. */
54export function binaryNote(bytes?: number | null): string {
55 return bytes === undefined || bytes === null ? 'binary file changed' : `binary file changed (${bytes} bytes)`
56}
57
58/** A step with no line detail (binary, or no earlier copy to compare). */
59export function bareStep(
60 file: string,
61 kind: PlayByPlayKind,
62 via: 'tool' | 'shell',
63 note: string,
64 counts: { added?: number; removed?: number } = {},
65): PlayByPlayStep {
66 return { file, kind, via, added: counts.added ?? 0, removed: counts.removed ?? 0, hunks: '', note }
67}
68
69/** Reads a string field off a value of unknown shape. */
70export function field(value: unknown, key: string): string | undefined {
71 if (typeof value !== 'object' || value === null) return undefined
72 const found = (value as Record<string, unknown>)[key]
73 return typeof found === 'string' ? found : undefined
74}
75
76function flag(value: unknown, key: string): boolean {
77 return typeof value === 'object' && value !== null && (value as Record<string, unknown>)[key] === true
78}
79
80/** Replaces `oldString` with `newString` once, or everywhere, as Edit does. */
81export function applyEdit(text: string, oldString: string, newString: string, all: boolean): string {
82 if (oldString === '') return newString
83 if (all) return text.split(oldString).join(newString)
84 const at = text.indexOf(oldString)
85 return at < 0 ? text : text.slice(0, at) + newString + text.slice(at + oldString.length)
86}
87
88/** What an Edit call did, from its record when it has one, else from its input. */
89export function editStep(
90 file: string,
91 input: { old_string: string; new_string: string; replace_all?: boolean },
92 result: unknown,
93): { step: PlayByPlayStep | null; after?: string } {
94 if (flag(result, 'staged')) return { step: null }
95 // The record says what was applied, the person's own changes included.
96 const oldString = field(result, 'oldString') ?? input.old_string
97 const newString = field(result, 'newString') ?? input.new_string
98 const all =
99 typeof result === 'object' && result !== null && 'replaceAll' in result
100 ? flag(result, 'replaceAll')
101 : input.replace_all === true
102 if (typeof result === 'object' && result !== null && 'originalFile' in result) {
103 const original = field(result, 'originalFile')
104 if (original === undefined) {
105 // originalFile null: Edit made a new file from an empty old_string.
106 return { step: stepFromTexts(file, null, newString, { via: 'tool', kind: 'create' }), after: newString }
107 }
108 const after = applyEdit(original, oldString, newString, all)
109 return { step: stepFromTexts(file, original, after, { via: 'tool', kind: 'edit' }), after }
110 }
111 // No record of the file (a hook above answered for the tool): show the
112 // replaced text alone, numbered from 1.
113 return {
114 step: stepFromTexts(file, oldString, newString, {
115 via: 'tool',
116 kind: 'edit',
117 note: 'line numbers are relative to the edit',
118 }),
119 }
120}
121
122/** What a Write call did. */
123export function writeStep(
124 file: string,
125 input: { content: string },
126 result: unknown,
127): { step: PlayByPlayStep | null; after?: string } {
128 if (flag(result, 'staged')) return { step: null }
129 const content = field(result, 'content') ?? input.content
130 const type = field(result, 'type')
131 const original = field(result, 'originalFile')
132 if (type === 'create') {
133 return { step: stepFromTexts(file, null, content, { via: 'tool', kind: 'create' }), after: content }
134 }
135 if (original !== undefined) {
136 return { step: stepFromTexts(file, original, content, { via: 'tool', kind: 'write' }), after: content }
137 }
138 const lines = splitLines(content).length
139 return {
140 step: bareStep(file, 'write', 'tool', 'rewritten whole; the earlier copy was too large to compare', {
141 added: lines,
142 }),
143 after: content,
144 }
145}
146
147/** What a NotebookEdit call did to its cell. */
148export function cellStep(
149 file: string,
150 input: { new_source: string; edit_mode?: string; cell_id?: string },
151 result: unknown,
152): PlayByPlayStep | null {
153 if (field(result, 'error')) return null
154 const mode = field(result, 'edit_mode') ?? input.edit_mode ?? 'replace'
155 const before = mode === 'insert' ? null : (field(result, 'old_source') ?? '')
156 const after = mode === 'delete' ? null : (field(result, 'new_source') ?? input.new_source)
157 const cell = field(result, 'cell_id') ?? input.cell_id
158 const note = `${mode} cell${cell ? ` ${cell}` : ''}`
159 return stepFromTexts(file, before, after, { via: 'tool', kind: 'cell', note })
160}
161
162/** A path relative to `cwd` when it lies inside it. */
163export function relPath(path: string, cwd: string): string {
164 const root = cwd.endsWith('/') ? cwd : `${cwd}/`
165 return path.startsWith(root) ? path.slice(root.length) : path
166}
167
168/** Adds steps to a turn, keeping the per-turn caps. */
169export function addSteps(turn: PlayByPlayTurn, steps: readonly PlayByPlayStep[]): PlayByPlayTurn {
170 let chars = turn.steps.reduce((sum, step) => sum + step.hunks.length, 0)
171 const kept = [...turn.steps]
172 let dropped = turn.dropped
173 for (const step of steps) {
174 if (kept.length >= MAX_TURN_STEPS) {
175 dropped++
176 continue
177 }
178 if (chars + step.hunks.length > MAX_TURN_CHARS && step.hunks !== '') {
179 const note = [step.note, 'lines dropped: this turn changed too much to keep them all']
180 .filter(Boolean)
181 .join('; ')
182 kept.push({ ...step, hunks: '', note })
183 continue
184 }
185 chars += step.hunks.length
186 kept.push(step)
187 }
188 return { ...turn, steps: kept, dropped }
189}
190
191/** The files a turn touched, in order of first change, with that step's index. */
192export function jumpList(turn: PlayByPlayTurn): { file: string; step: number }[] {
193 const seen = new Map<string, number>()
194 turn.steps.forEach((step, index) => {
195 if (step.kind !== 'more' && !seen.has(step.file)) seen.set(step.file, index)
196 })
197 return [...seen].map(([file, step]) => ({ file, step }))
198}
199
200export type Score = {
201 edits: number
202 files: number
203 added: number
204 removed: number
205 /** The file with the most lines changed, ties to the first touched. */
206 biggest: { file: string; added: number; removed: number } | null
207}
208
209/** The scoreboard for a turn. */
210export function score(turn: PlayByPlayTurn): Score {
211 const perFile = new Map<string, { added: number; removed: number }>()
212 let added = 0
213 let removed = 0
214 let edits = 0
215 for (const step of turn.steps) {
216 if (step.kind === 'more') continue
217 edits++
218 added += step.added
219 removed += step.removed
220 const total = perFile.get(step.file) ?? { added: 0, removed: 0 }
221 perFile.set(step.file, { added: total.added + step.added, removed: total.removed + step.removed })
222 }
223 let biggest: Score['biggest'] = null
224 for (const [file, total] of perFile) {
225 if (biggest === null || total.added + total.removed > biggest.added + biggest.removed) {
226 biggest = { file, ...total }
227 }
228 }
229 return { edits: edits + turn.dropped, files: perFile.size, added, removed, biggest }
230}
231
232const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`
233
234/** `+38 −12`, with a real minus sign. */
235export function plusMinus(added: number, removed: number): string {
236 return `+${added} −${removed}`
237}
238
239/** `4 edits · 3 files · +38 −12`. */
240export function summaryLine(turn: PlayByPlayTurn): string {
241 const { edits, files, added, removed } = score(turn)
242 return `${plural(edits, 'edit')} · ${plural(files, 'file')} · ${plusMinus(added, removed)}`
243}
244
245/** A movement through a turn's steps. */
246export type Nav =
247 | { go: 'next' }
248 | { go: 'prev' }
249 | { go: 'first' }
250 | { go: 'last' }
251 | { go: 'jump'; file: number }
252 | { go: 'close' }
253
254/** The step a movement lands on, kept inside the turn. */
255export function navigate(view: PlayByPlayView, nav: Nav, turn: PlayByPlayTurn): PlayByPlayView {
256 const last = Math.max(0, turn.steps.length - 1)
257 const clamp = (step: number) => Math.min(last, Math.max(0, step))
258 switch (nav.go) {
259 case 'next':
260 return { ...view, step: clamp(view.step + 1) }
261 case 'prev':
262 return { ...view, step: clamp(view.step - 1) }
263 case 'first':
264 return { ...view, step: 0 }
265 case 'last':
266 return { ...view, step: last }
267 case 'jump': {
268 const target = jumpList(turn)[nav.file - 1]
269 return target === undefined ? view : { ...view, step: target.step }
270 }
271 case 'close':
272 return view
273 }
274}
275
276/** What a key does in the replay pane, or null for a key it ignores. */
277export function keyAction(key: string): Nav | null {
278 switch (key) {
279 case 'right':
280 case 'down':
281 case 'n':
282 return { go: 'next' }
283 case 'left':
284 case 'up':
285 case 'p':
286 return { go: 'prev' }
287 case 'home':
288 return { go: 'first' }
289 case 'end':
290 return { go: 'last' }
291 case 'q':
292 return { go: 'close' }
293 }
294 return /^[1-9]$/.test(key) ? { go: 'jump', file: Number(key) } : null
295}
296
297/** Parses `/replay [n]`: which kept turn, 1 being the latest. */
298export function parseBack(args: string): number | null {
299 const text = args.trim()
300 if (text === '') return 1
301 if (!/^\d+$/.test(text)) return null
302 const back = Number(text)
303 return back >= 1 && back <= MAX_TURNS ? back : null
304}
305
306/** The first `max` characters of a prompt, on one line. */
307export function excerpt(text: string, max = 80): string {
308 const line = text.replace(/\s+/g, ' ').trim()
309 return line.length > max ? `${line.slice(0, max - 1)}…` : line
310}
311
312/** `src/a.ts`, `old.ts -> new.ts` for a rename. */
313export function stepTitle(step: PlayByPlayStep): string {
314 return step.from !== undefined ? `${step.from} -> ${step.file}` : step.file
315}
316hooks/keys.ts 15 lines1// The replay pane's key line: a Client region that hands the hooks module the
2// keys pressed while it has the focus (a click gives it), since a pane's own
3// Buttons take only letter and digit hotkeys, never the arrows.
4
5import type { ClientModule } from 'claude-code'
6
7const Keys: ClientModule<{ hint: string }> = (props, surface) => {
8 surface.onKey(event => {
9 if (event.ctrl !== true && event.meta !== true) surface.post({ key: event.key })
10 })
11 return surface.elements.Text({ dimColor: true, children: props.hint })
12}
13
14export default Keys
15hooks/bolt.ts 109 lines1// Bolt sprites for mods, v1.0.0. GENERATED by brand/bolt-sprites/tools/build.py: edit design.py, not this file.
2// Copy this file into your mod's hooks/ folder (mods install on their own, so they cannot import from brand/).
3// Rules (brand/BRAND.md): pink is the antenna light only, blush the cheeks only. Never recolour, rotate or stretch.
4// Never draw Bolt over a safety hold, and never in a toast (toasts are uncoloured text).
5
6export const BOLT_PALETTE = {
7 B: '#C8F53C',
8 S: '#9BC21A',
9 O: '#FF5C8A',
10 K: '#FF8FB1',
11 E: '#101114',
12 W: '#FBFBF8',
13 G: '#8E95A3',
14} as const
15
16export type BoltSize = 'small' | 'tiny'
17export type BoltSmallPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep' | 'sweat' | 'think' | 'egg' | 'egg-wobble-l' | 'egg-wobble-r' | 'egg-crack-1' | 'egg-crack-2' | 'egg-crack-3' | 'egg-hatch'
18export type BoltTinyPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep'
19export type BoltPose = BoltSmallPose | BoltTinyPose
20
21/** One text cell: a character and its colours (absent = the surface's own). */
22export type BoltCell = { ch: string; fg?: string; bg?: string }
23
24/** Pixel rows: one palette letter per pixel, '.' empty. Heights are even (two pixel rows per cell). */
25export const BOLT_SPRITES = {
26 small: {
27 'idle': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '..SS..SS..'],
28 'run1': ['...OO.....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', 'SS......SS'],
29 'run2': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', '...SSSS...'],
30 'jump': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '...S..S...'],
31 'crash': ['......OO..', '.....S....', '..BBBBBB..', '.BBBBBBBB.', 'BEBEBBEBEB', 'BBEBBBBEBB', 'BEBEBBEBEB', 'BBBBEEBBBB', '.SSSSSSSS.', 'SS......SS'],
32 'cheer': ['S...OO...S', 'S...SS...S', 'SBBBBBBBBS', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBEEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
33 'sleep': ['....SS....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBBBBBBBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBBBBBBB', '.SSSSSSSS.', '..SS..SS..'],
34 'sweat': ['....OO....', '....SS...G', '..BBBBBB.G', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
35 'think': ['....OO....', '....SS....', '..BBBBBB..', '.BBWEBWEB.', 'BBBEEBEEBB', 'BBBBBBBBBB', 'BKKBBBBKKB', 'BBBBBEBBBB', '.SSSSSSSS.', '..SS..SS..'],
36 'egg': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
37 'egg-wobble-l': ['...OO.....', '...SS.....', '..BWBB....', '.BWBBBB...', '.BBBBBB...', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
38 'egg-wobble-r': ['.....OO...', '.....SS...', '....BWBB..', '...BWBBBB.', '...BBBBBB.', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
39 'egg-crack-1': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBBBBBB.', '.BBEBBBBS.', '..BBBBBS..', '...SSSS...'],
40 'egg-crack-2': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBBBB.', '.BBEBEBBS.', '..BBBBBS..', '...SSSS...'],
41 'egg-crack-3': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBEBB.', '.BBEBEBES.', '..BBBBBS..', '...SSSS...'],
42 'egg-hatch': ['......OO..', '......SS..', '....BWBBBB', '....B.B.B.', '..........', '..........', '.B.B.B.BB.', '.BSSSSSSB.', '..BBBBBS..', '...SSSS...'],
43 },
44 tiny: {
45 'idle': ['.O.', 'BEB', 'BBB', 'S.S'],
46 'run1': ['.O.', 'BEB', 'BBB', 'S.S'],
47 'run2': ['.O.', 'BEB', 'BBB', '.S.'],
48 'jump': ['.O.', 'BEB', 'BBB', '.S.'],
49 'crash': ['...', '.SO', 'BEB', 'SSS'],
50 'cheer': ['SOS', 'BEB', 'BBB', 'S.S'],
51 'sleep': ['.S.', 'BSB', 'BBB', 'S.S'],
52 },
53} as const
54
55/** Letters drawn over a whole cell (cell coordinates). */
56const GLYPHS: Record<string, readonly { row: number; col: number; ch: string; color: keyof typeof BOLT_PALETTE }[]> = {
57 'small/sleep': [{ row: 0, col: 8, ch: 'z', color: 'G' }],
58}
59
60/** The two run frames, in order. Swap them about every 120 ms while running. */
61export const BOLT_RUN = ['run1', 'run2'] as const
62
63function colour(key: string | undefined): string | undefined {
64 if (key === undefined || key === '.') return undefined
65 return (BOLT_PALETTE as Record<string, string>)[key]
66}
67
68/**
69 * Packs a pose into half-block text cells: rows / 2 lines of `cols` cells.
70 * Terminal: draw with Raster (see hatchling's rasterCells). Desktop: one fixed-width cell per character.
71 */
72export function boltCells(size: 'small', pose: BoltSmallPose): BoltCell[][]
73export function boltCells(size: 'tiny', pose: BoltTinyPose): BoltCell[][]
74export function boltCells(size: BoltSize, pose: BoltPose): BoltCell[][] {
75 const rows: readonly string[] = (BOLT_SPRITES[size] as Record<string, readonly string[]>)[pose] ?? []
76 const out: BoltCell[][] = []
77 for (let r = 0; r + 1 < rows.length; r += 2) {
78 const top = rows[r] ?? ''
79 const bottom = rows[r + 1] ?? ''
80 const line: BoltCell[] = []
81 for (let c = 0; c < top.length; c += 1) {
82 const t = colour(top[c])
83 const b = colour(bottom[c])
84 if (t === undefined && b === undefined) line.push({ ch: ' ' })
85 else if (b === undefined) line.push({ ch: '\u2580', fg: t })
86 else if (t === undefined) line.push({ ch: '\u2584', fg: b })
87 else if (t === b) line.push({ ch: '\u2588', fg: t })
88 else line.push({ ch: '\u2580', fg: t, bg: b })
89 }
90 out.push(line)
91 }
92 for (const glyph of GLYPHS[`${size}/${pose}`] ?? []) {
93 const line = out[glyph.row]
94 if (line !== undefined && glyph.col < line.length) line[glyph.col] = { ch: glyph.ch, fg: BOLT_PALETTE[glyph.color] }
95 }
96 return out
97}
98
99/** Merges a row of cells into runs of one style, for coloured Text spans. */
100export function boltRuns(line: readonly BoltCell[]): BoltCell[] {
101 const runs: BoltCell[] = []
102 for (const cell of line) {
103 const last = runs[runs.length - 1]
104 if (last !== undefined && last.fg === cell.fg && last.bg === cell.bg) last.ch += cell.ch
105 else runs.push({ ...cell })
106 }
107 return runs
108}
109types/index.d.ts 59 lines1// The values play-by-play keeps in $.state, so a hot reload loses nothing.
2
3/** What made a step. */
4export type PlayByPlayKind =
5 | 'edit' // a file changed in place
6 | 'write' // a file overwritten whole
7 | 'create' // a new file
8 | 'delete' // a file removed
9 | 'rename' // a file moved, maybe changed too
10 | 'cell' // one notebook cell
11 | 'change' // changed, but there is no line detail to show
12 | 'more' // a placeholder for files left out past the cap
13
14/** One change, in the order it happened. */
15export type PlayByPlayStep = {
16 /** The path shown, relative to the session's folder when it lies inside it. */
17 file: string
18 /** For a rename: the path it had before. */
19 from?: string
20 kind: PlayByPlayKind
21 /** `shell` when a Bash command made the change rather than a file tool. */
22 via: 'tool' | 'shell'
23 added: number
24 removed: number
25 /** Unified-diff hunks, capped for drawing; '' when there is no line detail. */
26 hunks: string
27 /** Why detail is missing or cut, shown under the step's header. */
28 note?: string
29}
30
31/** One turn's changes. */
32export type PlayByPlayTurn = {
33 id: string
34 /** The start of the prompt that began the turn. */
35 prompt: string
36 steps: PlayByPlayStep[]
37 /** Changes past the per-turn step cap, counted but not kept. */
38 dropped: number
39}
40
41/** What the replay pane shows: which kept turn (1 = latest) and which step. */
42export type PlayByPlayView = { back: number; step: number }
43
44declare module 'claude-code' {
45 interface PluginState {
46 'play-by-play': {
47 /** The turn running now; null between turns. */
48 current: PlayByPlayTurn | null
49 /** Finished turns that changed files, oldest first, the last 10. */
50 turns: PlayByPlayTurn[]
51 /** Whether the hint above the prompt is up. */
52 hint: boolean
53 view: PlayByPlayView
54 /** Shell tracking paused for the session: git was too slow or the pending diff too large. */
55 shellPaused: boolean
56 }
57 }
58}
59