SLOPSHOPPER

play-by-play

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

newpanebandguardcommandtoast
★ 1v0.1.4MITupdated 2026-10-14modmyclaude/claude-code-mods/mods/play-by-play
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · play-by-play
│ ┃ Play-by-Play ✕ › fix the failing auth test and add an audit log call │ ┃ Latest turn · "changes made between turns" │ ┃ 3 files · +14 −1 · 3 edits · biggest: src/c… ⏺ Read(src/auth.ts) │ ┃ 1: src/auth.ts 2: src/audit.ts 3: src/cach ⎿ Read 6 lines │ ┃ [ Prev ] [ Next ] [ Close ] Step 1 of 3 ⏺ Update(src/auth.ts) │ ┃ ▣ client module ./keys.ts ⎿ Added 2 lines, removed 1 line │ ┃ src/auth.ts edit · +2 −1 ⏺ Bash(bun test) │ ┃ line numbers are relative to the edit ⎿ 3 pass, 1 fail │ ┃ 1 -if (!claims) throw new Error('invalid │ ┃ 1 +if (!claims || claims.exp < Date.now() ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 2 + await audit('refresh', claims.sub) │ ✻ Worked for 42s · done 4:20 PM │ │ › /replay │ │ ⟨Claude Code's own drawing⟩ 3 edits · 3 files · +14 −1 · press r or /replay [ Replay ] [ Hide ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ 3 edits · 3 files · +14 −1 · press r or /replay [ Replay ] [ Hide ]
Pane · Play-by-Play
Latest turn · "changes made between turns" 3 files · +14 −1 · 3 edits · biggest: src/cache.ts +7 −0 1: src/auth.ts 2: src/audit.ts 3: src/cache.ts [ Prev ] [ Next ] [ Close ] Step 1 of 3 ▣ client module ./keys.ts src/auth.ts edit · +2 −1 line numbers are relative to the edit 1 -if (!claims) throw new Error('invalid token') 1 +if (!claims || claims.exp < Date.now() / 1000) throw n 2 + await audit('refresh', claims.sub)
README

<img src="../../brand/pixel-icons/svg/play-by-play.svg" width="64" height="64" alt="">

Play-by-Play

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

Install

/plugin install play-by-play --marketplace modmyclaude/claude-code-mods

Use it

  • After a turn that changed files, a hint shows above the prompt: 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.
  • The pane shows, top to bottom:
  • a scoreboard: files touched, lines added and removed, edit count, and the file with the biggest change;
  • a jump list of the files, numbered 1-9 in the order they were first changed;
  • Prev, Next and Close, and "Step 2 of 5";
  • the step: file name, what happened (edit, write, create, delete, rename, cell), +/− 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.
  • Keys while the pane has the keyboard (it takes it when /replay opens it):
KeyDoes
nnext step
pprevious step
1-9first change of that file
q, Escclose
arrow keysafter a click on the pane's "Keys" line: left/up previous, right/down next
up / downwithout that click, only on a step taller than the pane

The mouse wheel and Page Up / Page Down still scroll a long diff.

Settings

None.

What it can touch

  • Reads: the records the Edit, Write and NotebookEdit tools return (the text before and after), and the first 80 characters of each prompt you send, kept in the session's memory for the turn's header in the pane. It never opens a file itself.
  • Runs: around every Bash command, read-only or not, read-only git plumbing in the session's folder, each with --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.
  • Cost: 3 or 4 git calls before each command and 2 or 3 after it (none after one Claude Code marks read-only, such as 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.
  • Writes: nothing. It never reverts or edits a file. What it records lives in the session's memory and is gone when the session ends.
  • Network: none. Model calls: none.

Limits

  • Shell changes need a git repository at the session's folder. Elsewhere commands run unwatched.
  • Files git ignores (node_modules, build output) are not tracked, by design.
  • An untracked file a command changes or deletes shows as a step without lines, since git kept no earlier copy, unless a file tool wrote that file earlier in the session.
  • A file you save in your editor while a command waits for approval or runs shows as a change "via shell".
  • A command run in the background is compared when it starts running in the background, not when it ends.
  • Shell tracking pauses for the rest of the session, saying so once in the transcript, when a git snapshot takes over 2 seconds, when git does not answer within 3 seconds, or when the uncommitted changes are over 4 MiB. File tools are still tracked.
  • Up to 25 changed files are compared per command; the rest are counted in one "more files" step. A step draws at most 400 diff lines; a turn keeps at most 300,000 characters of diff and 500 steps. A step of very long lines (minified code) past 100,000 characters is cut by the pane and drawn without colour. Counts always cover the whole change.
  • Arrow keys need a click on the "Keys" line first; without it, up and down step only on a step taller than the pane (the engine sends them as scroll keys only then). VS Code and the mobile app have no key line: use the buttons there. The band shows on the terminal and the desktop app only.
  • File names with control characters are drawn with them replaced (ANSI sequences dropped, others shown as U+FFFD).
  • A binary file's size is shown when git keeps the earlier copy (a delete or move); a new or changed binary in the working tree shows without it.
  • r in the prompt box types an r; it presses Replay only once the band has the keyboard.

Built from a prompt

This mod was built from prompts/play-by-play.md.

More mods: the full catalogue.

Source 8 files
hooks/register.tsx 439 lines
1// 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}
439
hooks/bolt-draw.tsx 86 lines
1// 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}
86
hooks/diff.ts 390 lines
1// 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}
390
hooks/shell.ts 332 lines
1// 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}
332
hooks/steps.ts 316 lines
1// 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}
316
hooks/keys.ts 15 lines
1// 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
15
hooks/bolt.ts 109 lines
1// 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}
109
types/index.d.ts 59 lines
1// 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