SLOPSHOPPER

replay-theater

Replay Theater: step through the file edits of the last turn, or of any earlier turn and past session read from the transcripts, with what each step does, the…

newpanebandguardcommandmodel
★ 2v?MITupdated 2026-10-16partalemre/MODZ/mods/replay-theater
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · replay-theater
│ ┃ Replay ✕ › fix the failing auth test and add an audit log call │ ┃ 3 edits in 3 files · just now +14 −1 │ ┃ ● 1. auth.ts src +2 −1 ⏺ Read(src/auth.ts) │ ┃ ○ 2. audit.ts src +5 −0 ⎿ Read 6 lines │ ┃ ○ 3. cache.ts src +7 −0 ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ● EDIT 1 / 3 ⏺ Bash(bun test) │ ┃ auth.ts src · Edit ⎿ 3 pass, 1 fail │ ┃ − if (!claims) throw new Error('invalid t… │ ┃ + if (!claims || claims.exp < Date.now() … ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ + await audit('refresh', claims.sub) │ ┃ [ Previous ] [ Next ] [ Turns ] c: Clo ✻ Worked for 42s · done 4:20 PM │ │ › /replay │ ⎿ replay-theater: Replay: 3 edits │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Replay
3 edits in 3 files · just now +14 −1 ● 1. auth.ts src +2 −1 ○ 2. audit.ts src +5 −0 ○ 3. cache.ts src +7 −0 ● EDIT 1 / 3 auth.ts src · Edit − if (!claims) throw new Error('invalid token') + if (!claims || claims.exp < Date.now() / 1000) throw ne… + await audit('refresh', claims.sub) [ Previous ] [ Next ] [ Turns ] c: Close
README

replay-theater

Records the file edits Claude makes in a turn, then lets you step through them one by one as colored diffs. From the same pane you can go back through any earlier turn of this session and through this project's finished sessions, read from the transcripts Claude Code already keeps. It never blocks, changes or delays an edit.

Install

/plugin marketplace add partalemre/MODZ
/plugin install replay-theater@modz

Or in one step: /plugin install replay-theater --marketplace partalemre/MODZ.

The MODZ panel (modz) shows its line here.

What you see

An offer card above the prompt, when a turn that edited files ends: the edit and file counts (4 edits · 4 files), the file names, the first sentence of Claude's reply, and Watch the edits (r) or Not now. It sits above the other rows, stays out of the way while a survey is open or a turn is running, and disappears on the next turn.

The pane, from the card or /replay:

  • A header with the age of the turn, 4 edits in 4 files, the lines added and removed, and the request that started the turn.
  • Edits in order: numbered circles for each step (the current one filled, finished ones checked, upcoming ones as rings), each with its file, folder and +N -N.
  • Below, the current step: Edit 2 / 4, the file name, the tool (Edit), one line saying what the change does, which plan step it served (Plan step 3/9 · <task>), and a diff with changed lines and changed words highlighted, plus a legend (Removed, Added, Changed word, Unchanged).
  • Previous (p), Next (n; Finish on the last step), Turns (t) and Close (c, or Esc).

Going back further. Turns lists the turns of this session that edited files, newest first: the prompt that started each one, Turn 3 · 4 edits · 3 files · 2h ago, and Watch (1–9 for the first nine rows). Watching a turn replays it exactly like the last one (step list, diff, the sentence Claude said before each edit, the plan step); Finish brings you back to the turn list. Past sessions (s) lists this project's newest 20 sessions, each with the date it was last written, its first prompt as a title, and its turns and edits (This session is tagged); Open shows a session's turns, and This session (t) goes back to the current one. More (m) shows the next rows of a long list. The empty pane (no edits yet) has Past sessions too.

In the terminal the same information is text, with the diff in Claude Code's own diff colours. If the terminal is too narrow for a pane, the replay is drawn in the band above the prompt. The pane follows Claude Code's theme setting.

What each step does. The one-line description comes from, in order: the edit call's own description; the sentence Claude wrote just before the edit, read from the transcript (main loop and subagents); and, if neither exists, one small model call (see Cost). If none is available the line is left out.

Plan step. TodoWrite, TaskCreate, TaskUpdate and TaskList are watched; each edit records the item that was in_progress when it ran. With no task list there is no plan line.

Commands

  • /replay opens the pane for the last turn's edits.
  • /replay turns opens the list of this session's turns that edited files.
  • /replay sessions opens the list of this project's sessions.

Settings

There are no plugin options.

Cost

Usually no model tokens; going back through earlier turns and sessions never uses any. Only when some steps have neither a description nor a sentence in the transcript, opening the pane for the first time makes one haiku request (low effort, 20-second limit) for those steps. The result is cached for that turn, so reopening makes no call.

Where past edits come from

Nothing extra is recorded for this. Claude Code writes every session's transcript as JSONL in its configuration folder (~/.claude/projects/<project>/<session>.jsonl, and each subagent's under <session>/subagents/); the session's own transcript_path names that folder. The mod only reads it: the person's prompts start turns, and every Edit, MultiEdit, Write and NotebookEdit call that did not fail becomes a step, with the text Claude wrote just before it and the TodoWrite / Task item in progress at the time. A subagent's edits join the turn whose Agent call started it.

What a step shows is what the transcript holds, and its note says so:

  • Edit / MultiEdit: the edited part only (old_string to new_string), not the whole file.
  • Write: a new file is all added lines; a rewrite is a real diff when the transcript kept the earlier text, otherwise the new text alone, noted whole file; earlier text not in transcript.
  • NotebookEdit: the cell's new source; a replaced or deleted cell's old text is not recorded.

Reading is lazy and bounded: the list shows each session's line as soon as it is known, reading one transcript at a time; a session's turns are rebuilt only when it is opened, and the last three read are kept while their files do not change. Transcripts up to 4 MiB are read whole and larger ones stream through dd; of one over 48 MiB only the last 48 MiB are read, and its row says only the newest part of a very large transcript was read. The list lines (title, turns, edits; a few hundred bytes each, for the 20 sessions listed) are kept in the plugin store so the next listing does not read them again. A line that does not parse is skipped. Past turns make no model call.

When the session has no local transcript (its transcript_path is empty), the lists say there is nothing to read.

Limits

  • For Edit, the diff is old_string to new_string; for Write, the file's text on disk before the write is read first. Each step shows one line of context around the change and at most 12 lines (... N more lines).
  • A rejected or failed edit is not recorded, live or read back.
  • The live replay's state lives in the session; earlier turns and sessions come back from the transcripts for as long as Claude Code keeps them.
  • Only the newest 20 sessions are listed.

Language

The interface is English. Set MODZ_LANG=tr in the env block of ~/.claude/settings.json for Turkish. The model-written step lines come back in English.

Credits

Adapted from anthropics/claude-code-playground (claude-code/mods/replay-theater), licensed Apache-2.0. The license file is in this folder.

Source 36 files
hooks/register.tsx 281 lines
1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3//
4// Replay Theater: records the file edits Claude makes in a turn, then lets you step through them in a pane,
5// one diff at a time. Modified 2026 (MODZ): Tidemark drawing, an offer card that stacks with the band, what each
6// change does (Claude's own words first, one Haiku call at most), and the plan step each edit served.
7
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10
11import { bringReplay, handlersOf, openList, openReplay } from './actions'
12import { stdoutOf, whereOf } from './archive'
13import type { ArchiveIo } from './archive'
14import { shortLine } from './explain'
15import { explainReplay, why } from './explainer'
16import type { ExplainIo } from './explainer'
17import { hookEdits } from './edits-hook'
18import { withHome } from './home'
19import { HUB_PANE, hubOf, isOpenFor } from './hub'
20import { setLang } from './lang'
21import { offerCard } from './offer'
22import { motion } from './pane'
23import { LIST_PAGE, PANE_ROWS_MAX } from './pane-io'
24import type { PaneIo } from './pane-io'
25import { pinned } from './pin'
26import { isOffStage, shownOf } from './stage'
27import { bodyView } from './screen'
28import type { Site, Snapshot } from './screen'
29import { themeOf } from './ui/theme'
30import { words } from './words'
31import type { HubCard } from '../types'
32
33const pendingAtom = atom({ plugin: 'replay-theater', key: 'pending' } as const, [])
34const replayAtom = atom({ plugin: 'replay-theater', key: 'replay' } as const, null)
35const indexAtom = atom({ plugin: 'replay-theater', key: 'index' } as const, 0)
36const offeredAtom = atom({ plugin: 'replay-theater', key: 'isOffered' } as const, false)
37const viewAtom = atom({ plugin: 'replay-theater', key: 'view' } as const, { isOpen: false, inBand: false, theme: 'auto' })
38const planAtom = atom({ plugin: 'replay-theater', key: 'plan' } as const, [])
39const browseAtom = atom({ plugin: 'replay-theater', key: 'browse' } as const, { mode: 'replay', source: 'live', shown: LIST_PAGE })
40const sessionsAtom = atom({ plugin: 'replay-theater', key: 'sessions' } as const, { status: 'loading', rows: [], older: 0 })
41const turnsAtom = atom({ plugin: 'replay-theater', key: 'turns' } as const, null)
42const pastAtom = atom({ plugin: 'replay-theater', key: 'past' } as const, null)
43const PANE_ID = 'replay-theater'
44const HUB = { plugin: 'replay-theater', key: 'hub' } as const
45const HOME = { plugin: 'replay-theater', key: 'home' } as const
46const KEPT_KEY = 'sessions'
47const PANE_COLUMNS = 64
48
49type On = Parameters<Register>[0]
50
51// The session's folder, for relative paths, and its transcript, whose folder holds this project's sessions;
52// both set when the session starts. `kept`: the sessions read last, while their transcripts do not change.
53const flags = { cwd: '', ask: '', transcript: '' }
54const kept: ArchiveIo['kept'] = new Map()
55
56// The engine calls explaining needs, as closures: only this module holds `$`.
57function explainIo($: EngineInterface): ExplainIo {
58  return {
59    replay: () => read($, replayAtom),
60    change: (id, change) => update($, replayAtom, replay => (replay !== null && replay.id === id ? change(replay) : replay)),
61    messages: agentId => $.session.messages(agentId === undefined ? {} : { agentId }),
62    complete: request => $.model.complete(request),
63    log: line => $.ui.log(line, { to: 'debug' }),
64  }
65}
66
67function archiveIo($: EngineInterface): ArchiveIo {
68  return { list: dir => $.fs.list(dir), read: path => $.fs.read(path), pieces: argv => stdoutOf($.process.spawn({ argv })), log: line => $.ui.log(line, { to: 'debug' }), kept }
69}
70
71// Every open of the pane is a person's (a press, /replay, the hub's row): placed, it tells a running hub it holds
72// the panel, so the hub and every other pane step aside (kit/stage.ts).
73async function reportShown($: EngineInterface): Promise<void> {
74  try {
75    if ((await $.state.get({ plugin: 'modz', key: 'isOn' })).value !== true) return
76    await $.state.set({ plugin: 'replay-theater', key: 'shown' }, shownOf(HUB_PANE, await $.clock.now()))
77  } catch (error) {
78    $.ui.log(`replay-theater: shown failed (${why(error)})`, { to: 'debug' })
79  }
80}
81
82// The pane's actions over this session's values and the engine, as closures.
83function paneIo($: EngineInterface): PaneIo {
84  const title = words().title
85
86  return {
87    browse: { get: () => read($, browseAtom), set: change => update($, browseAtom, change) },
88    view: { get: () => read($, viewAtom), set: change => update($, viewAtom, change) },
89    index: { get: () => read($, indexAtom), set: change => update($, indexAtom, change) },
90    offered: { get: () => read($, offeredAtom), set: change => update($, offeredAtom, change) },
91    replay: { get: () => read($, replayAtom), set: change => update($, replayAtom, change) },
92    past: { get: () => read($, pastAtom), set: change => update($, pastAtom, change) },
93    turns: { get: () => read($, turnsAtom), set: change => update($, turnsAtom, change) },
94    sessions: { get: () => read($, sessionsAtom), set: change => update($, sessionsAtom, change) },
95    kept: { get: () => $.store.get(KEPT_KEY), set: value => $.store.set(KEPT_KEY, value) },
96    open: async rows => {
97      const size = rows === undefined ? {} : { closeOnEscape: true as const, rows, columns: PANE_COLUMNS }
98      motion.reset()
99      const isPlaced = (await $.ui.open({ id: PANE_ID, title, focus: true, ...size })).isPlaced !== false
100      if (isPlaced) await reportShown($)
101
102      return isPlaced
103    },
104    close: () => $.ui.close({ id: PANE_ID }),
105    sleep: ms => $.clock.sleep(ms),
106    explain: id => void explainReplay(explainIo($), id),
107    archive: archiveIo($),
108    where: () => whereOf(flags.transcript, flags.cwd),
109  }
110}
111
112// What the pane draws, from every value it shows, with its presses.
113async function drawn($: EngineInterface, ui: Record<string, any>, site: Site) {
114  const snap: Snapshot = {
115    browse: await read($, browseAtom), view: await read($, viewAtom), replay: await read($, replayAtom), past: await read($, pastAtom),
116    index: await read($, indexAtom), sessions: await read($, sessionsAtom), turns: await read($, turnsAtom), now: await $.clock.now(),
117  }
118
119  return bodyView(ui, snap, { site, on: handlersOf(paneIo($), snap.browse) })
120}
121
122async function refreshTheme($: EngineInterface): Promise<void> {
123  try {
124    const rows = await $.config.list()
125    const theme = themeOf(rows.find(row => row.key === 'theme')?.value)
126    await update($, viewAtom, view => ({ ...view, theme }))
127  } catch (error) {
128    $.ui.log(`replay-theater: theme not readable (${why(error)})`, { to: 'debug' })
129  }
130}
131
132// The main panel's card (DESIGN.md §9), written only when what it shows changes so the panel does not redraw for nothing.
133async function writeHub($: EngineInterface): Promise<void> {
134  const shown = hubOf(await read($, replayAtom))
135  const { value } = await $.state.get(HUB)
136  if (value !== undefined && value.detail === shown.detail && value.tone === shown.tone) return
137  const card: HubCard = { pane: HUB_PANE, ...shown, at: await $.clock.now() }
138  await $.state.set(HUB, card)
139}
140
141function hookSession(on: On): void {
142  on('session.start', async ($, e, next) => {
143    // The interface language (kit/lang.ts). Spelled out: the engine admits an env read by a literal name only.
144    setLang(await $.env.get('MODZ_LANG').catch(() => undefined))
145    const r = await next(e)
146    flags.cwd = e.cwd.replace(/\/$/, '')
147    await $.command.register({ name: 'replay', description: words().command, argumentHint: '[turns|sessions]' })
148    await refreshTheme($)
149    await writeHub($)
150
151    return r
152  })
153
154  on('session.end', async ($, e, next) => {
155    if (e.reason === 'clear') await update($, planAtom, () => [])
156
157    return next(e)
158  }).catch(($, e, next) => next(e))
159
160  on('state.set', { plugin: 'modz', key: 'open' }, async ($, e, next) => {
161    const done = await next(e)
162    if (isOpenFor(e.value, HUB_PANE)) await bringReplay(paneIo($))
163
164    return done
165  }).catch(($, e, next) => next(e))
166
167  // Another pane took the main panel: this one steps aside rather than wait as a tab. A replay shown in the band
168  // has no pane there, so it stays.
169  on('state.set', { plugin: 'modz', key: 'stage' }, async ($, e, next) => {
170    const done = await next(e)
171    const view = await read($, viewAtom)
172    if (isOffStage(e.value, HUB_PANE) && view.isOpen && !view.inBand) await $.ui.close({ id: PANE_ID })
173
174    return done
175  }).catch(($, e, next) => next(e))
176}
177
178// `/replay` opens the last turn; `/replay turns` this session's turns, `/replay sessions` the project's sessions.
179function hookCommand(on: On): void {
180  // The transcript's path comes with the classic start, and again after /clear, a resume or a fork.
181  on('classic.SessionStart', async ($, e, next) => {
182    flags.transcript = typeof e.transcript_path === 'string' ? e.transcript_path : ''
183
184    return next(e)
185  }).catch(($, e, next) => next(e))
186
187  on('command.run', { command: 'replay' }, async ($, e) => {
188    const list = String(e.args ?? '').trim().toLowerCase()
189    if (list === 'turns' || list === 'sessions') {
190      await openList(paneIo($), { mode: list })
191
192      return {}
193    }
194    const opened = await openReplay(paneIo($))
195    const count = (await read($, replayAtom))?.steps.length ?? 0
196    if (opened.hasEdits) return { text: words().commandCount(count) }
197
198    // Only a terminal too narrow for the pane hears it as text.
199    return opened.isPlaced ? {} : { text: words().commandEmpty }
200  })
201}
202
203function hookRecording(on: On): void {
204  hookEdits(on, flags)
205
206  on('turn.start', async ($, e, next) => {
207    if ((e as { agentId?: string }).agentId === undefined) {
208      flags.ask = typeof (e as { text?: unknown }).text === 'string' ? (e as { text: string }).text : ''
209      await update($, pendingAtom, () => [])
210      await update($, offeredAtom, () => false)
211    }
212
213    return next(e)
214  })
215
216  // At the end of a main-loop turn the pending edits become the replay and the offer card shows.
217  on('turn.complete', async ($, e, next) => {
218    const r = await next(e)
219    const pending = await read($, pendingAtom)
220    if (e.agentId !== undefined || pending.length === 0) return r
221    const turn = { quote: shortLine(e.answer ?? ''), ask: shortLine(flags.ask), at: await $.clock.now() }
222    await update($, replayAtom, () => ({ id: e.turnId, steps: pending, explain: 'none' as const, ...turn }))
223    await update($, pendingAtom, () => [])
224    await update($, indexAtom, () => 0)
225    await update($, offeredAtom, () => true)
226    await refreshTheme($)
227    await writeHub($)
228
229    return r
230  })
231}
232
233function hookBand(on: On): void {
234  // The offer stacks above the other band lines; a survey holds the band alone.
235  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
236    if (e.props.hasSurvey) return next(e)
237    const theirs = await next(e)
238    const view = await read($, viewAtom)
239    const replay = await read($, replayAtom)
240    const ui = $.ui.resolve(e)
241    const { Box } = ui
242    if (view.isOpen && view.inBand) {
243      const body = await drawn($, ui, { surface: e.surface, columns: e.props.bodyColumns, inBand: true, maxRows: e.props.maxRows })
244
245      return pinned(e, <Box flexDirection="column">{body}{theirs}</Box>)
246    }
247    if (!(await read($, offeredAtom)) || e.props.isWorking || view.isOpen || replay === null) return theirs
248    const offer = { steps: replay.steps, quote: replay.quote, theme: view.theme, columns: e.props.bodyColumns, surface: e.surface }
249    const actions = { onOpen: () => void openReplay(paneIo($)), onDismiss: () => void update($, offeredAtom, () => false) }
250
251    return pinned(e, <Box flexDirection="column">{offerCard(ui, offer, actions)}{theirs}</Box>)
252  })
253}
254
255function hookPane(on: On): void {
256  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
257    const site = { surface: e.surface, columns: e.props.bodyColumns, inBand: false, maxRows: e.props.scroll?.bodyRows ?? PANE_ROWS_MAX }
258    const isHubOn = (await $.state.get({ plugin: 'modz', key: 'isOn' })).value === true
259    // Written when pressed, never while drawing; a failure only goes to the debug log.
260    const goHome = () => void $.clock.now().then(at => $.state.set(HOME, { at })).catch(() => $.ui.log('replay-theater: could not go back to MODZ', { to: 'debug' }))
261    const ui = $.ui.resolve(e)
262
263    return pinned(e, withHome(ui, await drawn($, ui, site), isHubOn ? goHome : null))
264  })
265
266  // The person closed the pane (Escape, the close mark): keep the view in step.
267  on('ui.close', { id: PANE_ID }, async ($, e, next) => {
268    await update($, viewAtom, view => ({ ...view, isOpen: false, inBand: false }))
269
270    return next(e)
271  }).catch(($, e, next) => next(e))
272}
273
274export const register: Register = on => {
275  hookSession(on)
276  hookCommand(on)
277  hookRecording(on)
278  hookBand(on)
279  hookPane(on)
280}
281
hooks/actions.ts 108 lines
1import { listSessions, loadPast, loadTurns } from './browse'
2import { why } from './explainer'
3import { LIST_PAGE, PANE_ROWS_MAX } from './pane-io'
4import type { PaneIo } from './pane-io'
5import type { ReplayBrowse, ReplayStep } from '../types'
6
7// What opens the pane and what its presses do: the live replay, a session's turns, the session list, a turn
8// read back; stepping, finishing and closing.
9
10export type ListMode = { mode: 'turns' | 'sessions'; session?: string }
11
12const BAND_RELEASE_MS = 200
13const PANE_ROWS_BASE = 12
14const LIST_ROWS_MAX = 8
15const DIFF_ROWS_MAX = 13
16const EMPTY_ROWS = 8
17
18function paneRows(steps: ReplayStep[]): number {
19  const longest = Math.max(0, ...steps.map(step => step.diff.length))
20
21  return Math.min(PANE_ROWS_MAX, PANE_ROWS_BASE + Math.min(steps.length, LIST_ROWS_MAX) + Math.min(longest, DIFF_ROWS_MAX))
22}
23
24// The pane opens with nothing to replay too: it says so there, not in the transcript.
25export async function openReplay(io: PaneIo): Promise<{ hasEdits: boolean; isPlaced: boolean }> {
26  const replay = await io.replay.get()
27  const steps = replay?.steps ?? []
28  const hasEdits = steps.length > 0
29  await io.browse.set(() => ({ mode: 'replay', source: 'live', shown: LIST_PAGE }))
30  if (hasEdits) {
31    await io.index.set(() => 0)
32    await io.offered.set(() => false)
33  }
34  await io.view.set(view => ({ ...view, isOpen: true, inBand: false }))
35  // The pane takes the keys only while nothing else holds them: the band's button held them, so wait a beat.
36  await io.sleep(BAND_RELEASE_MS)
37  const isPlaced = await io.open(hasEdits ? paneRows(steps) : EMPTY_ROWS)
38  // The band shows a replay when the pane has no room; an empty one has nothing to show there.
39  if (!isPlaced) await io.view.set(view => ({ ...view, isOpen: hasEdits, inBand: hasEdits }))
40  if (hasEdits && replay !== null) io.explain(replay.id)
41
42  return { hasEdits, isPlaced }
43}
44
45// The main panel's open request: an open pane only comes forward, keeping its place.
46export async function bringReplay(io: PaneIo): Promise<void> {
47  const view = await io.view.get()
48  if (view.isOpen && !view.inBand) await io.open()
49  else await openReplay(io)
50}
51
52// A list in the pane: read in the background and shown at once with what is known; a closed pane opens.
53export async function openList(io: PaneIo, list: ListMode): Promise<void> {
54  await io.browse.set(() => ({ mode: list.mode, source: 'live', shown: LIST_PAGE }))
55  const reading = list.mode === 'sessions' ? listSessions(io) : loadTurns(io, list.session ?? io.where().current)
56  void reading.catch((error: unknown) => io.archive.log(`replay-theater: reading failed (${why(error)})`))
57  if ((await io.view.get()).isOpen) return
58  await io.view.set(view => ({ ...view, isOpen: true, inBand: false }))
59  await io.sleep(BAND_RELEASE_MS)
60  if (!(await io.open(PANE_ROWS_MAX))) await io.view.set(view => ({ ...view, inBand: true }))
61}
62
63async function watchTurn(io: PaneIo, turn: number): Promise<void> {
64  const session = (await io.turns.get())?.session ?? io.where().current
65  if (await loadPast(io, { session, turn })) await io.browse.set(browse => ({ ...browse, mode: 'replay', source: 'past' }))
66}
67
68export async function closeReplay(io: PaneIo): Promise<void> {
69  const view = await io.view.get()
70  await io.view.set(current => ({ ...current, isOpen: false, inBand: false }))
71  if (!view.inBand) await io.close()
72}
73
74async function step(io: PaneIo, delta: number): Promise<void> {
75  const browse = await io.browse.get()
76  const replay = browse.source === 'past' ? await io.past.get() : await io.replay.get()
77  const total = replay?.steps.length ?? 0
78  await io.index.set(index => Math.max(0, Math.min(index + delta, total - 1)))
79}
80
81// The session a "Turns" press lists: the one the replay on screen came from.
82async function turnsOfShown(io: PaneIo): Promise<void> {
83  const browse = await io.browse.get()
84  const past = browse.source === 'past' ? await io.past.get() : null
85  await openList(io, { mode: 'turns', session: past?.origin?.session ?? io.where().current })
86}
87
88// Every press of the pane and of a replay drawn in the band; the last step of a turn read back returns to its list.
89export function handlersOf(io: PaneIo, browse: ReplayBrowse) {
90  const turns = () => void turnsOfShown(io)
91  const close = () => void closeReplay(io)
92
93  return {
94    onPrev: () => void step(io, -1),
95    onNext: () => void step(io, 1),
96    onFinish: browse.source === 'past' ? turns : close,
97    onTurns: turns,
98    onSessions: () => void openList(io, { mode: 'sessions' }),
99    onThisSession: () => void openList(io, { mode: 'turns', session: io.where().current }),
100    onSession: (id: string) => void openList(io, { mode: 'turns', session: id }),
101    onTurn: (n: number) => void watchTurn(io, n),
102    onMore: () => void io.browse.set(current => ({ ...current, shown: current.shown + LIST_PAGE })),
103    onClose: close,
104  }
105}
106
107export type Handlers = ReturnType<typeof handlersOf>
108
hooks/archive.ts 181 lines
1import { why } from './explainer'
2import { foldSession, inTimeOrder } from './history'
3import type { PastSession } from './history'
4import { eventsOfLine, MAIN, newReader } from './transcript'
5import type { LineReader, TranscriptEvent } from './transcript'
6import type { Where } from './pane-io'
7
8// Reading this project's session transcripts: the folder Claude Code keeps them in (the one the session's own
9// `transcript_path` names), each `<id>.jsonl` and its subagents' `<id>/subagents/agent-<agent>.jsonl`. Read only.
10
11export type ArchiveEntry = { name: string; kind: string; size: number; mtimeMs: number }
12export type SessionFile = { id: string; path: string; size: number; mtimeMs: number }
13export type ReadSession = PastSession & { isCut: boolean }
14type Kept = { size: number; mtimeMs: number; session: ReadSession }
15
16// What reading needs from the engine, as closures the hooks module makes; `kept` holds the last sessions read.
17export type ArchiveIo = {
18  list: (dir: string) => Promise<ArchiveEntry[]>
19  read: (path: string) => Promise<string>
20  pieces: (argv: readonly string[]) => AsyncIterable<string>
21  log: (line: string) => void
22  kept: Map<string, Kept>
23}
24
25// The engine reads a file whole up to 4 MiB; a larger transcript streams through `dd` (`tail` writes in pieces
26// small enough to be thirty times slower), at most READ_CAP_BLOCKS of its end, so a huge one costs a bounded
27// read and its newest turns are the ones kept.
28const MIB = 1024 * 1024
29const FS_READ_MAX = 4 * MIB
30const READ_CAP_BLOCKS = 48
31const KEPT_MAX = 3
32const SUBAGENTS_MAX = 64
33const SESSION_FILE = /^(.+)\.jsonl$/
34const AGENT_FILE = /^agent-(.+)\.jsonl$/
35
36// A spawned child's standard output, piece by piece; a child that wrote only errors fails the read.
37export async function* stdoutOf(stream: AsyncIterable<{ stream: string; text: string }>): AsyncGenerator<string> {
38  let errors = ''
39  let hasOutput = false
40  for await (const chunk of stream) {
41    if (chunk.stream === 'stdout') {
42      hasOutput = true
43      yield chunk.text
44    } else errors += chunk.text
45  }
46  if (!hasOutput && errors.trim() !== '') throw new Error(errors.trim())
47}
48
49const folderOf = (path: string): string => path.slice(0, Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')))
50
51// The transcripts' folder and this session's id, from its own transcript's path ('' for none).
52export function whereOf(transcript: string, cwd: string): Where {
53  const dir = transcript === '' ? '' : folderOf(transcript)
54
55  return { dir, current: transcript.slice(dir.length + 1).replace(SESSION_FILE, '$1'), cwd }
56}
57
58// The session transcripts in a folder, newest first.
59export async function sessionFiles(io: ArchiveIo, dir: string): Promise<SessionFile[]> {
60  const entries = await io.list(dir)
61
62  return entries
63    .flatMap(entry => {
64      const found = entry.kind === 'file' ? entry.name.match(SESSION_FILE) : null
65
66      return found === null ? [] : [{ id: found[1] as string, path: `${dir}/${entry.name}`, size: entry.size, mtimeMs: entry.mtimeMs }]
67    })
68    .sort((a, b) => b.mtimeMs - a.mtimeMs)
69}
70
71async function streamLines(io: ArchiveIo, file: { path: string; size: number }, onLine: (line: string) => void): Promise<boolean> {
72  const skip = Math.max(0, Math.ceil(file.size / MIB) - READ_CAP_BLOCKS)
73  const isCut = skip > 0
74  // A cut read starts inside a record: its first line is dropped.
75  let isHead = isCut
76  let rest = ''
77  for await (const piece of io.pieces(['dd', `if=${file.path}`, `bs=${MIB}`, `skip=${skip}`])) {
78    const parts = (rest + piece).split('\n')
79    rest = parts.pop() ?? ''
80    for (const line of parts) {
81      if (isHead) isHead = false
82      else onLine(line)
83    }
84  }
85  if (rest !== '' && !isHead) onLine(rest)
86
87  return isCut
88}
89
90// Every line of a transcript, in order; true when only its end was read.
91async function eachLine(io: ArchiveIo, file: { path: string; size: number }, onLine: (line: string) => void): Promise<boolean> {
92  if (file.size <= FS_READ_MAX) {
93    // A running session's file may pass the limit after it was listed; the stream below reads it then.
94    const text = await io.read(file.path).catch((error: unknown) => {
95      io.log(`replay-theater: ${file.path} read whole failed, streaming it (${why(error)})`)
96
97      return null
98    })
99    if (text !== null) {
100      for (const line of text.split('\n')) onLine(line)
101
102      return false
103    }
104  }
105
106  return streamLines(io, file, onLine)
107}
108
109async function ownerOf(io: ArchiveIo, path: string): Promise<string | undefined> {
110  try {
111    const meta: unknown = JSON.parse(await io.read(path))
112    const owner = typeof meta === 'object' && meta !== null ? (meta as { toolUseId?: unknown }).toolUseId : undefined
113
114    return typeof owner === 'string' ? owner : undefined
115  } catch (error) {
116    io.log(`replay-theater: ${path} not readable (${why(error)})`)
117
118    return undefined
119  }
120}
121
122type SubagentFile = { agent: string; path: string; size: number; owner?: string }
123
124async function subagentFiles(io: ArchiveIo, dir: string, id: string): Promise<SubagentFile[]> {
125  const folder = `${dir}/${id}/subagents`
126  // Most sessions started no subagent and have no such folder.
127  const entries = await io.list(folder).catch((): ArchiveEntry[] => [])
128  const names = new Set(entries.map(entry => entry.name))
129  const files = entries.flatMap(entry => {
130    const found = entry.kind === 'file' ? entry.name.match(AGENT_FILE) : null
131
132    return found === null ? [] : [{ agent: found[1] as string, path: `${folder}/${entry.name}`, size: entry.size }]
133  })
134  const out: SubagentFile[] = []
135  for (const file of files.slice(0, SUBAGENTS_MAX)) {
136    const meta = `agent-${file.agent}.meta.json`
137    const owner = names.has(meta) ? await ownerOf(io, `${folder}/${meta}`) : undefined
138    out.push(owner === undefined ? file : { ...file, owner })
139  }
140
141  return out
142}
143
144async function readEvents(io: ArchiveIo, file: { path: string; size: number }, reader: LineReader): Promise<{ events: TranscriptEvent[]; isCut: boolean }> {
145  const events: TranscriptEvent[] = []
146  const isCut = await eachLine(io, file, line => {
147    for (const event of eventsOfLine(line, reader)) events.push(event)
148  })
149  if (reader.skipped > 0) io.log(`replay-theater: ${reader.skipped} unreadable lines skipped in ${file.path}`)
150
151  return { events, isCut }
152}
153
154async function readSession(io: ArchiveIo, file: SessionFile, dir: string): Promise<ReadSession> {
155  const { events, isCut } = await readEvents(io, file, newReader(MAIN))
156  const owners = new Map<string, string>()
157  for (const sub of await subagentFiles(io, dir, file.id)) {
158    try {
159      events.push(...(await readEvents(io, sub, newReader(sub.agent))).events)
160      if (sub.owner !== undefined) owners.set(sub.agent, sub.owner)
161    } catch (error) {
162      io.log(`replay-theater: subagent ${sub.agent} not readable (${why(error)})`)
163    }
164  }
165
166  return { ...foldSession(inTimeOrder(events), owners), isCut }
167}
168
169// A session read whole, from the last few kept while its transcript has not changed.
170export async function sessionOf(io: ArchiveIo, file: SessionFile, dir: string): Promise<ReadSession> {
171  const hit = io.kept.get(file.id)
172  if (hit !== undefined && hit.size === file.size && hit.mtimeMs === file.mtimeMs) return hit.session
173  const session = await readSession(io, file, dir)
174  io.kept.delete(file.id)
175  io.kept.set(file.id, { size: file.size, mtimeMs: file.mtimeMs, session })
176  const oldest = io.kept.keys().next().value
177  if (io.kept.size > KEPT_MAX && oldest !== undefined) io.kept.delete(oldest)
178
179  return session
180}
181
hooks/explain.ts 97 lines
1import type { SessionMessage } from 'claude-code'
2
3import { EDIT_TOOLS } from './record'
4import { words } from './words'
5import type { ReplayStep } from '../types'
6
7export const EXPLAIN_MODEL = 'haiku'
8export const MAX_MODEL_STEPS = 20
9const NOTE_MAX = 90
10const DIFF_PER_STEP = 600
11const TOKENS_PER_STEP = 40
12
13// One plain line from Claude's prose: the first sentence, markdown dropped, cut to fit a row.
14export function shortLine(text: string): string {
15  const line = text.split('\n').map(part => part.replace(/^[\s#>*-]+/, '').trim()).find(part => part !== '') ?? ''
16  const plain = line.replace(/[*_`]/g, '')
17  const sentence = (plain.match(/^(.+?[.!?])(\s|$)/)?.[1] ?? plain).replace(/[.:;,]+$/, '').trim()
18
19  return sentence.length > NOTE_MAX ? `${sentence.slice(0, NOTE_MAX - 1).trimEnd()}…` : sentence
20}
21
22function isPrompt(message: SessionMessage): boolean {
23  return message.role === 'user' && message.text.trim() !== '' && (message.toolResults ?? []).length === 0
24}
25
26// What Claude said before each edit, keyed by tool_use_id. A text covers the edits of its own message
27// and of the text-less messages after it, until another tool runs or the person writes.
28export function notesFromMessages(messages: SessionMessage[], ids: Set<string>): Map<string, string> {
29  const notes = new Map<string, string>()
30  let pending = ''
31  for (const message of messages) {
32    if (isPrompt(message)) pending = ''
33    if (message.role !== 'assistant') continue
34    if (message.text.trim() !== '') pending = shortLine(message.text)
35    for (const use of message.toolUses) {
36      if (ids.has(use.tool_use_id) && pending !== '') notes.set(use.tool_use_id, pending)
37    }
38    if (message.toolUses.some(use => !EDIT_TOOLS.has(use.tool))) pending = ''
39  }
40
41  return notes
42}
43
44// The tool_use_ids of the steps one loop (main: undefined) made.
45export function idsOfLoop(steps: ReplayStep[], agentId: string | undefined): Set<string> {
46  return new Set(steps.flatMap(step => (step.agentId === agentId && step.toolUseId !== undefined ? [step.toolUseId] : [])))
47}
48
49// Indexes of the steps that still need a line, at most as many as one call explains.
50export function unexplained(steps: ReplayStep[]): number[] {
51  return steps.flatMap((step, index) => (step.said === undefined && step.diff.length > 0 ? [index] : [])).slice(0, MAX_MODEL_STEPS)
52}
53
54function diffText(step: ReplayStep): string {
55  return step.diff.map(line => `${line.op === '~' ? ' ' : line.op}${line.t}`).join('\n').slice(0, DIFF_PER_STEP)
56}
57
58export function buildPrompt(steps: ReplayStep[], indexes: number[]): { prompt: string; maxTokens: number } {
59  const blocks = indexes.map((index, n) => {
60    const step = steps[index] as ReplayStep
61
62    return `### ${n + 1}: ${step.file}\n${diffText(step)}`
63  })
64  // The lines show in the pane, so the model answers in the pane's language.
65  const w = words()
66  const prompt = [
67    w.explainAsk,
68    w.explainForm,
69    '',
70    ...blocks,
71  ].join('\n')
72
73  return { prompt, maxTokens: TOKENS_PER_STEP * indexes.length }
74}
75
76// "1: ..." lines of the reply mapped back to the step indexes they were asked for.
77export function parseReply(text: string, indexes: number[]): Map<number, string> {
78  const notes = new Map<number, string>()
79  for (const line of text.split('\n')) {
80    const found = line.match(/^\s*(\d+)\s*[:.)-]\s*(.+)$/)
81    const index = found === null ? undefined : indexes[Number(found[1]) - 1]
82    const note = found === null ? '' : shortLine(found[2] as string)
83    if (index !== undefined && note !== '') notes.set(index, note)
84  }
85
86  return notes
87}
88
89// The steps with each one lacking a line given the one `noteOf` finds, if any.
90export function withNotes(steps: ReplayStep[], noteOf: (step: ReplayStep, index: number) => string | undefined): ReplayStep[] {
91  return steps.map((step, index) => {
92    const said = step.said ?? noteOf(step, index)
93
94    return said === undefined ? step : { ...step, said }
95  })
96}
97
hooks/explainer.ts 69 lines
1import type { ModelCompleteRequest, SessionMessage } from 'claude-code'
2
3import { EXPLAIN_MODEL, buildPrompt, idsOfLoop, notesFromMessages, parseReply, unexplained, withNotes } from './explain'
4import type { Replay, ReplayStep } from '../types'
5
6const MODEL_TIMEOUT_MS = 20_000
7
8type Reply = { isAnswered: true; text: string } | { isAnswered: false; reason: string }
9
10// What explaining needs from the engine, as closures the hooks module makes (it alone holds `$`).
11export type ExplainIo = {
12  replay: () => Promise<Replay | null>
13  change: (id: string, change: (replay: Replay) => Replay) => Promise<unknown>
14  messages: (agentId: string | undefined) => Promise<SessionMessage[] | unknown>
15  complete: (request: ModelCompleteRequest) => Promise<Reply>
16  log: (line: string) => void
17}
18
19export function why(error: unknown): string {
20  return error instanceof Error ? error.message : 'bilinmiyor'
21}
22
23// What Claude itself wrote before each edit, from the main transcript and each subagent's: zero tokens.
24async function transcriptNotes(io: ExplainIo, steps: ReplayStep[]): Promise<Map<string, string>> {
25  const notes = new Map<string, string>()
26  for (const agentId of new Set(steps.map(step => step.agentId))) {
27    const ids = idsOfLoop(steps, agentId)
28    if (ids.size === 0) continue
29    try {
30      const found = await io.messages(agentId)
31      if (Array.isArray(found)) notesFromMessages(found as SessionMessage[], ids).forEach((note, id) => notes.set(id, note))
32    } catch (error) {
33      io.log(`replay-theater: transcript not readable (${why(error)})`)
34    }
35  }
36
37  return notes
38}
39
40// One cheap call for every step nothing else explains; an empty map when none is needed or it fails.
41async function modelNotes(io: ExplainIo, steps: ReplayStep[]): Promise<Map<number, string>> {
42  const indexes = unexplained(steps)
43  if (indexes.length === 0) return new Map()
44  const { prompt, maxTokens } = buildPrompt(steps, indexes)
45  const reply = await io.complete({ model: EXPLAIN_MODEL, prompt, maxTokens, effort: 'low', timeoutMs: MODEL_TIMEOUT_MS })
46  if (reply.isAnswered) return parseReply(reply.text, indexes)
47  io.log(`replay-theater: no explanation from the model (${reply.reason})`)
48
49  return new Map()
50}
51
52// Fills each step's line once per replay, the first time it is opened; the lines stay cached on it.
53export async function explainReplay(io: ExplainIo, id: string): Promise<void> {
54  const replay = await io.replay()
55  if (replay === null || replay.id !== id || replay.explain !== 'none') return
56  await io.change(id, current => ({ ...current, explain: 'pending' }))
57  try {
58    const said = await transcriptNotes(io, replay.steps)
59    await io.change(id, current => ({ ...current, steps: withNotes(current.steps, step => (step.toolUseId === undefined ? undefined : said.get(step.toolUseId))) }))
60    const fresh = await io.replay()
61    const guessed = fresh === null || fresh.id !== id ? new Map<number, string>() : await modelNotes(io, fresh.steps)
62    await io.change(id, current => ({ ...current, steps: withNotes(current.steps, (_, index) => guessed.get(index)) }))
63  } catch (error) {
64    io.log(`replay-theater: explaining failed (${why(error)})`)
65  } finally {
66    await io.change(id, current => ({ ...current, explain: 'done' }))
67  }
68}
69
hooks/edits-hook.ts 67 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { why } from './explainer'
5import { currentStep, planAfter, PLAN_TOOLS } from './plan'
6import { EDIT_TOOLS, isAnswered, stepsFor } from './record'
7import type { EditCall } from './record'
8import type { ReplayStep } from '../types'
9
10// The edits a turn makes and the task list they serve, recorded as the calls run; register.tsx makes the replay of
11// them when the turn ends.
12
13// The engine reads each module's use of the mod's values from that module's own source.
14const pendingAtom = atom({ plugin: 'replay-theater', key: 'pending' } as const, [])
15const planAtom = atom({ plugin: 'replay-theater', key: 'plan' } as const, [])
16
17type On = Parameters<Register>[0]
18type Call = EditCall & Record<string, unknown>
19// The session's folder, for relative paths; register.tsx sets it when the session starts.
20type Where = { cwd: string }
21
22async function readBefore($: EngineInterface, path: string | undefined): Promise<{ text: string; isNew: boolean }> {
23  try {
24    if (path !== undefined && (await $.fs.exists(path))) return { text: await $.fs.read(path), isNew: false }
25  } catch (error) {
26    $.ui.log(`replay-theater: ${path} not readable, shown as new (${why(error)})`, { to: 'debug' })
27  }
28
29  return { text: '', isNew: true }
30}
31
32// An edit is read before it runs and kept only once it was answered; recording never stops the call.
33async function recordEdit<R extends { deny?: unknown; isError?: unknown }>($: EngineInterface, call: { e: Call; where: Where }, run: () => Promise<R>): Promise<R> {
34  const { e, where } = call
35  let steps: ReplayStep[] = []
36  try {
37    steps = await stepsFor({ cwd: where.cwd, before: path => readBefore($, path) }, e)
38  } catch (error) {
39    $.ui.log(`replay-theater: could not read an edit (${why(error)})`, { to: 'debug' })
40  }
41  const result = await run()
42  if (steps.length === 0 || !isAnswered(result)) return result
43  const plan = currentStep(await read($, planAtom)) ?? undefined
44  await update($, pendingAtom, pending => [...pending, ...steps.map(one => (plan === undefined ? one : { ...one, plan }))])
45
46  return result
47}
48
49// The task list: TodoWrite is the main loop's own; TaskCreate / TaskUpdate share one list across loops.
50async function trackPlan<R extends { deny?: unknown; isError?: unknown; result?: unknown }>($: EngineInterface, e: Call, run: () => Promise<R>): Promise<R> {
51  const result = await run()
52  if (!isAnswered(result) || (e.tool === 'TodoWrite' && e.agentId !== undefined)) return result
53  await update($, planAtom, items => planAfter(items, e, result.result))
54
55  return result
56}
57
58export function hookEdits(on: On, where: Where): void {
59  on('tool.call', async ($, e, next) => {
60    const call = e as unknown as Call
61    if (EDIT_TOOLS.has(call.tool)) return recordEdit($, { e: call, where }, () => next(e))
62    if ((PLAN_TOOLS as readonly string[]).includes(call.tool)) return trackPlan($, call, () => next(e))
63
64    return next(e)
65  }).catch(($, e, next) => next(e))
66}
67
hooks/home.tsx 33 lines
1// MODZ kit — the way back to the hub from a mod's pane (DESIGN.md §9). While the hub runs it keeps
2// `{ plugin: 'modz', key: 'isOn' }` true; a pane then draws `HomeButton` first, and its press writes the mod's
3// own `{ plugin: '<mod>', key: 'home' }` value (`{ at }`). The hub hears it, comes forward, and opens again if
4// it was closed, so the person always has a way back, even with one pane left and no tab to click.
5
6// The resolved element table of the surface that draws the pane.
7type Table = Record<string, any>
8
9export const HOME_LABEL = '← MODZ'
10
11export function HomeButton(ui: Table, onPress: () => void) {
12  const { Box, Button } = ui
13
14  return (
15    <Box key="modz-home-row" flexDirection="row">
16      <Button key="modz-home" label={HOME_LABEL} variant="secondary" onPress={onPress} />
17    </Box>
18  )
19}
20
21// The pane's tree with the way back above it; null while the hub is off draws the pane as it is.
22export function withHome<T>(ui: Table, tree: T, onHome: (() => void) | null) {
23  if (onHome === null) return tree
24  const { Box } = ui
25
26  return (
27    <Box flexDirection="column" rowGap={1}>
28      {HomeButton(ui, onHome)}
29      {tree}
30    </Box>
31  )
32}
33
hooks/hub.ts 21 lines
1import { offerFacts } from './offer'
2import type { HubTone, Replay } from '../types'
3
4// The MODZ main panel's side of this mod (DESIGN.md §9): the one-line card it collects, and its "open"
5// requests, which name a pane by key.
6
7export const HUB_PANE = 'replay'
8
9export type HubShown = { detail: string; tone: HubTone }
10
11// "4 edits · 4 files" for the last turn with edits; nothing to say before one.
12export function hubOf(replay: Replay | null): HubShown {
13  if (replay === null || replay.steps.length === 0) return { detail: '', tone: 'idle' }
14
15  return { detail: offerFacts(replay.steps).title, tone: 'ok' }
16}
17
18export function isOpenFor(value: unknown, pane: string): boolean {
19  return typeof value === 'object' && value !== null && (value as { pane?: unknown }).pane === pane
20}
21
hooks/lang.ts 24 lines
1// MODZ kit — the interface language: English, or Turkish when the person sets MODZ_LANG=tr (the "env" of
2// ~/.claude/settings.json). One value per mod: its register file reads the variable at session.start with
3// `setLang(await $.env.get('MODZ_LANG'))` (the engine only admits an env read whose name is a literal), and every
4// drawing reads it back through `pick` and `upper`.
5export type Lang = 'en' | 'tr'
6
7let current: Lang = 'en'
8
9export const langOf = (value: unknown): Lang => (typeof value === 'string' && /^tr/i.test(value.trim()) ? 'tr' : 'en')
10
11export function setLang(value: unknown): Lang {
12  current = langOf(value)
13
14  return current
15}
16
17export const lang = (): Lang => current
18
19// The current language's entry of a table that fills in both: `pick({ en: 'Open', tr: 'Aç' })`.
20export const pick = <T>(table: Readonly<Record<Lang, T>>): T => table[current]
21
22// Capitals for eyebrows and labels: Turkish keeps its dotted İ, English never gets one.
23export const upper = (text: string): string => text.toLocaleUpperCase(current)
24
hooks/offer.tsx 103 lines
1import type { ReplayStep } from '../types'
2import { baseName } from './pane-svg'
3import { escape, eyebrow, fit, widthFor } from './ui/svg'
4import { palette, svgDoc } from './ui/theme'
5import type { Theme } from './ui/theme'
6import { upper } from './lang'
7import { words } from './words'
8
9// The resolved element table of the surface that draws the band.
10export type Ui = Record<string, any>
11
12export type OfferModel = { steps: ReplayStep[]; quote: string; theme: Theme; columns: number; surface: string }
13export type OfferHandlers = { onOpen: () => void; onDismiss: () => void }
14
15const HEAD_H = 58
16const TILE = 38
17const TEXT_X = 52
18const CARD_CHROME_COLUMNS = 8
19// Below this the "/replay" hint would push the buttons into two lines; the buttons win.
20const HINT_MIN_COLUMNS = 64
21
22export function offerFacts(steps: ReplayStep[]): { title: string; files: string } {
23  const names = [...new Set(steps.map(step => baseName(step.file)))]
24  const fileCount = new Set(steps.map(step => step.file)).size
25
26  return { title: words().editsAndFiles(steps.length, fileCount), files: names.join(' · ') }
27}
28
29function playTile(): string {
30  const tile = `<rect x="0.5" y="2.5" width="${TILE - 1}" height="${TILE - 1}" rx="10" fill="var(--plan-t)" stroke="var(--plan-l)"/>`
31  const play = '<path d="M15.5 14.5L27 21.5L15.5 28.5Z" fill="var(--plan)" stroke="var(--plan)" stroke-width="1.75" stroke-linejoin="round"/>'
32
33  return tile + play
34}
35
36export function offerSvg(model: OfferModel, width: number): string {
37  const facts = offerFacts(model.steps)
38  const room = width - TEXT_X
39  const body = [
40    playTile(),
41    `<text x="${TEXT_X}" y="14" class="eyebrow f-plan">${eyebrow(words().title)}</text>`,
42    `<text x="${TEXT_X}" y="36" class="headline hi">${escape(fit(facts.title, room, 17))}</text>`,
43    `<text x="${TEXT_X}" y="54" class="label lo">${escape(fit(facts.files, room, 12))}</text>`,
44  ]
45
46  return svgDoc({ width, height: HEAD_H, theme: model.theme }, body.join(''))
47}
48
49function actions(ui: Ui, on: OfferHandlers, hasHint: boolean) {
50  const { Box, Text, Button } = ui
51  const w = words()
52
53  return (
54    <Box key="rt-actions" flexDirection="row" columnGap={2} alignItems="center">
55      <Button key="rt-open" variant="primary" hotkey="r" label={w.watch} onPress={on.onOpen} />
56      {hasHint && <Text dimColor>{w.orType}</Text>}
57      <Box flexGrow={1} />
58      <Button key="rt-dismiss" plain label={w.notNow} onPress={on.onDismiss} />
59    </Box>
60  )
61}
62
63function desktopCard(ui: Ui, model: OfferModel, on: OfferHandlers) {
64  const { Box, Text, Svg } = ui
65  const width = widthFor(model.columns - CARD_CHROME_COLUMNS, { min: 260, max: 560 })
66  const tone = model.theme === 'auto' ? null : palette(model.theme === 'light')
67
68  return (
69    <Box key="rt-offer" flexDirection="column" borderStyle="round" borderColor={tone?.plan.line} backgroundColor={tone?.card} paddingX={2} paddingY={1} gap={1}>
70      <Svg source={offerSvg(model, width)} alt={`${words().title}: ${offerFacts(model.steps).title}`} width={width} height={HEAD_H} />
71      {model.quote !== '' && (
72        <Text key="rt-quote" wrap="wrap">
73          <Text bold>{'Claude: '}</Text>
74          <Text dimColor>{model.quote}</Text>
75        </Text>
76      )}
77      {actions(ui, on, model.columns >= HINT_MIN_COLUMNS)}
78    </Box>
79  )
80}
81
82function terminalCard(ui: Ui, model: OfferModel, on: OfferHandlers) {
83  const { Box, Text } = ui
84  const facts = offerFacts(model.steps)
85
86  return (
87    <Box key="rt-offer" flexDirection="column" paddingX={1}>
88      <Box key="rt-offer-head" flexDirection="row" columnGap={1}>
89        <Text color="merged" bold>{`▶ ${upper(words().title)}`}</Text>
90        <Text bold>{facts.title}</Text>
91        <Box flexShrink={1}>
92          <Text dimColor wrap="truncate-end">{facts.files}</Text>
93        </Box>
94      </Box>
95      {actions(ui, on, model.columns >= HINT_MIN_COLUMNS)}
96    </Box>
97  )
98}
99
100export function offerCard(ui: Ui, model: OfferModel, on: OfferHandlers) {
101  return model.surface === 'terminal' || !('Svg' in ui) ? terminalCard(ui, model, on) : desktopCard(ui, model, on)
102}
103
hooks/pane.tsx 132 lines
1import { buttons } from './buttons'
2import type { PaneHandlers } from './buttons'
3import { diffSvg } from './diff-svg'
4import { paneMotion } from './kit/moving'
5import type { Ui } from './offer'
6import { baseName, detailSvg, overviewSvg, totals } from './pane-svg'
7import type { PaneModel } from './pane-svg'
8import type { ReplayStep } from '../types'
9import { terminalView } from './terminal'
10import { escape, eyebrow, widthFor, wrap } from './ui/svg'
11import { svgDoc } from './ui/theme'
12import type { Theme } from './ui/theme'
13import { upper } from './lang'
14import { words } from './words'
15
16export type PaneSite = { surface: string; columns: number; inBand: boolean; maxRows: number }
17
18const PANE_GUTTER_COLUMNS = 3
19// The sideways scrollbar of a diff wider than the pane takes this much under it.
20const SCROLLBAR_ROOM = 12
21// The replay outlives turns without edits, so an empty pane means none yet this session.
22const emptyText = (): string => `${words().emptyLead}. ${words().emptyRest}`
23const EMPTY_LEAD_Y = 40
24const EMPTY_LINE_H = 19
25const EMPTY_REST_SIZE = 13
26
27function altOf(model: PaneModel): string {
28  const sum = totals(model.steps)
29  const step = model.steps[model.index]
30
31  const w = words()
32
33  return `${w.editsInFiles(model.steps.length, sum.files)}; ${w.nowOn(model.index + 1, step === undefined ? '' : baseName(step.file))}`
34}
35
36// The pane's drawings on the desktop (kit/moving.ts), its lists' too: reset as the pane opens, so they cascade in.
37export const motion = paneMotion()
38
39// A step's drawings follow the edit itself: its tool call, or the file and which edit of that file it is.
40function stepKey(steps: ReplayStep[], index: number): string {
41  const step = steps[index]
42  if (step === undefined) return 'none'
43  if (step.toolUseId !== undefined) return step.toolUseId
44  const before = steps.slice(0, index).filter(other => other.file === step.file).length
45
46  return `${step.file}#${before}`
47}
48
49function desktopView(ui: Ui, model: PaneModel, on: PaneHandlers) {
50  const { Box, Svg } = ui
51  const overview = overviewSvg(model)
52  const detail = detailSvg(model)
53  const step = model.steps[model.index]
54  const id = stepKey(model.steps, model.index)
55  const diff = diffSvg({ lines: step?.diff ?? [], width: model.width, theme: model.theme })
56  // A wider diff scrolls sideways in its frame instead of cutting its lines, with room for the scrollbar under it.
57  const isWide = diff.contentWidth > model.width
58  const diffHeight = isWide ? diff.height + SCROLLBAR_ROOM : diff.height
59  // "3m ago" reads on as time passes; only the replay itself is a change worth a sweep.
60  const { age, ...shown } = model
61  const overviewPic = motion.pic('rt-overview', { source: overview.source, alt: altOf(model), width: model.width, height: overview.height }, { shows: { ...shown, isAged: (age ?? '') !== '' } })
62  const detailPic = motion.pic(`rt-detail-${id}`, { source: detail.source, alt: step?.said ?? (step === undefined ? '' : step.file), width: model.width, height: detail.height })
63  const diffPic = motion.pic(`rt-diff-${id}`, { source: diff.source, alt: words().diffAlt, width: model.width, height: diffHeight })
64
65  return (
66    <Box key="rt-pane" flexDirection="column" gap={1} paddingX={1} paddingY={1}>
67      <Svg key="rt-overview" {...overviewPic} />
68      <Svg key={`rt-detail-${id}`} {...detailPic} />
69      <Svg key={`rt-diff-${id}`} {...diffPic} />
70      {buttons(ui, model, on)}
71    </Box>
72  )
73}
74
75// The eyebrow, the lead and one sentence under it.
76function emptySvg(width: number, theme: Theme): { source: string; height: number } {
77  const w = words()
78  const rest = wrap(w.emptyRest, { width, size: EMPTY_REST_SIZE, maxLines: 2 })
79  const lead = `<text x="0" y="${EMPTY_LEAD_Y}" class="headline hi">${escape(w.emptyLead)}</text>`
80  const lines = rest.map((part, n) => `<text x="0" y="${EMPTY_LEAD_Y + (n + 1) * EMPTY_LINE_H + 2}" class="mid" font-size="${EMPTY_REST_SIZE}">${escape(part)}</text>`)
81  const height = EMPTY_LEAD_Y + rest.length * EMPTY_LINE_H + 10
82  const body = `<rect x="0" y="5" width="6" height="6" rx="1.5" fill="var(--plan)"/><text x="13" y="12" class="eyebrow f-plan">${eyebrow(w.title)}</text>${lead}${lines.join('')}`
83
84  return { source: svgDoc({ width, height, theme }, body), height }
85}
86
87function emptyView(ui: Ui, spec: { surface: string; width: number; theme: Theme; on: PaneHandlers }) {
88  const { Box, Text, Button, Svg } = ui
89  const w = words()
90  const close = <Button key="rt-close" plain hotkey="c" label={w.close} onPress={spec.on.onClose} />
91  const sessions = <Button key="rt-sessions" variant="secondary" hotkey="s" label={w.pastSessions} onPress={spec.on.onSessions} />
92  const actions = (
93    <Box flexDirection="row" columnGap={2}>
94      {sessions}
95      <Box flexGrow={1} />
96      {close}
97    </Box>
98  )
99  if (spec.surface === 'terminal' || !('Svg' in ui)) {
100    return (
101      <Box key="rt-empty" flexDirection="column" gap={1} paddingX={1}>
102        <Text color="merged" bold>{`● ${upper(w.title)}`}</Text>
103        <Text bold>{w.emptyLead}</Text>
104        <Text dimColor>{w.emptyRest}</Text>
105        {actions}
106      </Box>
107    )
108  }
109  const drawn = emptySvg(spec.width, spec.theme)
110
111  return (
112    <Box key="rt-empty" flexDirection="column" gap={1} paddingX={1} paddingY={1}>
113      <Svg key="rt-empty-svg" {...motion.pic('rt-empty-svg', { source: drawn.source, alt: emptyText(), width: spec.width, height: drawn.height })} />
114      {actions}
115    </Box>
116  )
117}
118
119export const paneWidth = (columns: number): number => widthFor(columns - PANE_GUTTER_COLUMNS, { min: 280, max: 720 })
120
121export function replayView(ui: Ui, model: Omit<PaneModel, 'width'>, site: PaneSite & { on: PaneHandlers }) {
122  const width = paneWidth(site.columns)
123  if (model.steps.length === 0) return emptyView(ui, { surface: site.surface, width, theme: model.theme, on: site.on })
124  const index = Math.max(0, Math.min(model.index, model.steps.length - 1))
125  const full = { ...model, index, width }
126  if (site.surface === 'terminal' || site.inBand || !('Svg' in ui)) {
127    return terminalView(ui, full, { isCompact: site.inBand, maxRows: site.maxRows, on: site.on })
128  }
129
130  return desktopView(ui, full, site.on)
131}
132
hooks/pane-io.ts 36 lines
1import type { ArchiveIo } from './archive'
2import type { Replay, ReplayBrowse, ReplaySessions, ReplayTurns, ReplayView } from '../types'
3
4// What the pane's actions need from the engine, as closures the hooks module makes: only it holds `$`.
5
6// How many rows a list shows before "More", and the tallest the pane opens.
7export const LIST_PAGE = 8
8export const PANE_ROWS_MAX = 30
9
10// One session value: read it, or change it from what it holds.
11export type Cell<T> = { get: () => Promise<T>; set: (change: (value: T) => T) => Promise<unknown> }
12
13// Where the transcripts are: their folder ('' when the session keeps none), this session's id, its folder.
14export type Where = { dir: string; current: string; cwd: string }
15
16export type PaneIo = {
17  browse: Cell<ReplayBrowse>
18  view: Cell<ReplayView>
19  index: Cell<number>
20  offered: Cell<boolean>
21  replay: Cell<Replay | null>
22  past: Cell<Replay | null>
23  turns: Cell<ReplayTurns | null>
24  sessions: Cell<ReplaySessions>
25  // The session list lines kept across sessions (`$.store`), as stored: checked where they are read.
26  kept: { get: () => Promise<unknown>; set: (value: unknown) => Promise<void> }
27  // Opens the pane `rows` tall, or with none only brings it forward; false when it found no room.
28  open: (rows?: number) => Promise<boolean>
29  close: () => Promise<void>
30  sleep: (ms: number) => Promise<void>
31  // Fills the live replay's step lines the first time it is opened.
32  explain: (id: string) => void
33  archive: ArchiveIo
34  where: () => Where
35}
36