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…

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.
/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.
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:
4 edits in 4 files, the lines added and removed, and the request that started the turn.+N -N.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).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.
/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.There are no plugin options.
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.
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:
old_string to new_string), not the whole file.whole file; earlier text not in transcript.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.
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).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.
Adapted from anthropics/claude-code-playground (claude-code/mods/replay-theater), licensed Apache-2.0. The license file is in this folder.
hooks/register.tsx 281 lines1// 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}
281hooks/actions.ts 108 lines1import { 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>
108hooks/archive.ts 181 lines1import { 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}
181hooks/explain.ts 97 lines1import 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}
97hooks/explainer.ts 69 lines1import 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}
69hooks/edits-hook.ts 67 lines1import { 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}
67hooks/home.tsx 33 lines1// 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}
33hooks/hub.ts 21 lines1import { 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}
21hooks/lang.ts 24 lines1// 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)
24hooks/offer.tsx 103 lines1import 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}
103hooks/pane.tsx 132 lines1import { 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}
132hooks/pane-io.ts 36 lines1import 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