Replays this session's tool calls scene by scene, with step controls and autoplay.

hooks/register.tsx 140 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Scene } from '../types'
5import { summarize } from './summary'
6
7const PANE = 'replay-theater'
8const scenes = atom({ plugin: 'replay-theater', key: 'scenes' } as const, [])
9const pos = atom({ plugin: 'replay-theater', key: 'pos' } as const, 0)
10const playing = atom({ plugin: 'replay-theater', key: 'playing' } as const, false)
11const turnNo = atom({ plugin: 'replay-theater', key: 'turn' } as const, 0)
12
13let reel: Timer | null = null
14
15function clamp(n: number, len: number) {
16 return Math.max(0, Math.min(len - 1, n))
17}
18
19async function go($: EngineInterface, to: (cur: number, len: number) => number) {
20 const len = (await read($, scenes)).length
21 if (!len) return
22 await update($, pos, cur => clamp(to(cur, len), len))
23}
24
25async function stop($: EngineInterface) {
26 reel?.cancel()
27 reel = null
28 await update($, playing, () => false)
29}
30
31async function play($: EngineInterface) {
32 await stop($)
33 await update($, playing, () => true)
34 reel = $.clock.every(1500, () => {
35 void (async () => {
36 const len = (await read($, scenes)).length
37 const at = await read($, pos)
38 if (at >= len - 1) return stop($)
39 await update($, pos, p => clamp(p + 1, len))
40 })()
41 })
42}
43
44function outputOf(ran: unknown): string {
45 const r = (ran ?? {}) as Record<string, unknown>
46 if (typeof r.deny === 'string') return `denied: ${r.deny}`
47 if (typeof r.text === 'string') return r.text
48 try {
49 return JSON.stringify(r.result ?? '')
50 } catch {
51 return ''
52 }
53}
54
55export const register: Register = on => {
56 on('session.start', async ($, e, next) => {
57 await $.command.register({ name: 'replay', description: 'Open Replay Theater (/replay last, /replay <n>)' })
58 return next(e)
59 })
60
61 on('command.run', { command: 'replay' }, async ($, e) => {
62 const arg = e.args.trim()
63 const len = (await read($, scenes)).length
64 if (arg === 'last') await update($, pos, () => Math.max(0, len - 1))
65 else if (/^\d+$/.test(arg)) await update($, pos, () => clamp(Number(arg) - 1, Math.max(1, len)))
66 else await update($, pos, () => 0)
67 await $.ui.open({ id: PANE, title: 'Replay Theater' })
68 return { text: len ? `Replay Theater: ${len} scenes.` : 'Replay Theater: nothing recorded yet.' }
69 })
70
71 on('turn.start', async ($, e, next) => {
72 await update($, turnNo, n => n + 1)
73 return next(e)
74 })
75
76 on('tool.call', async ($, e, next) => {
77 const at = await $.clock.now()
78 const ran = await next(e)
79 const out = outputOf(ran).replace(/\r/g, '')
80 const scene: Scene = {
81 id: e.tool_use_id,
82 tool: e.tool,
83 what: summarize(e.tool, e),
84 out: out.length > 1200 ? `${out.slice(0, 1200)}…` : out,
85 isError: ('deny' in ran && ran.deny !== undefined) || ran.isError === true,
86 at,
87 ms: (await $.clock.now()) - at,
88 turn: await read($, turnNo),
89 }
90 await update($, scenes, s => [...s, scene].slice(-300))
91 return ran
92 }).catch(($, e, next) => next(e)) // an observer: never block the call
93
94 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
95 const { Box, Text, Button } = $.ui.resolve(e)
96 const list = await read($, scenes)
97 if (!list.length) return <Text dimColor>No scenes yet — every tool call this session becomes one.</Text>
98
99 const i = clamp(await read($, pos), list.length)
100 const s = list[i]!
101 const isPlaying = await read($, playing)
102 const first = list[0]!.at
103 const t = Math.round((s.at - first) / 1000)
104 const lines = s.out.split('\n')
105 const room = Math.max(3, (e.viewport?.rows ?? 30) - 12)
106 const width = Math.max(10, (e.props.bodyColumns ?? 60) - 2)
107 const barW = Math.min(40, width)
108 const head = Math.round((i / Math.max(1, list.length - 1)) * (barW - 1))
109
110 return (
111 <Box flexDirection="column">
112 <Text bold>
113 🎬 Scene {i + 1}/{list.length} · turn {s.turn} · +{t}s
114 </Text>
115 <Text dimColor>{'─'.repeat(head)}●{'─'.repeat(barW - 1 - head)}</Text>
116 <Text> </Text>
117 <Text color={s.isError ? 'red' : 'cyan'} bold>
118 {s.isError ? '✗' : '✓'} {s.tool} <Text dimColor>({s.ms} ms)</Text>
119 </Text>
120 <Text wrap="wrap">{s.what}</Text>
121 <Text> </Text>
122 {lines.slice(0, room).map((l, n) => (
123 <Text key={`o${n}`} dimColor wrap="truncate-end">
124 {l || ' '}
125 </Text>
126 ))}
127 {lines.length > room && <Text dimColor>… {lines.length - room} more lines</Text>}
128 <Text> </Text>
129 <Box>
130 <Button key="first" label="⏮" onPress={() => go($, () => 0)} />
131 <Button key="prev" label="◀ Prev" onPress={() => go($, c => c - 1)} />
132 <Button key="play" label={isPlaying ? '⏸ Pause' : '▶ Play'} onPress={() => (isPlaying ? stop($) : play($))} />
133 <Button key="next" label="Next ▶" onPress={() => go($, c => c + 1)} />
134 <Button key="last" label="⏭" onPress={() => go($, (_, len) => len - 1)} />
135 </Box>
136 </Box>
137 )
138 })
139}
140hooks/summary.ts 11 lines1/** One short line describing what a tool call is about. */
2export function summarize(tool: string, input: unknown): string {
3 const x = (input ?? {}) as Record<string, unknown>
4 const pick = ['file_path', 'command', 'pattern', 'url', 'query', 'description', 'path', 'skill']
5 for (const k of pick) {
6 const v = x[k]
7 if (typeof v === 'string' && v) return v.replace(/\s+/g, ' ').slice(0, 90)
8 }
9 return tool
10}
11types/index.d.ts 17 lines1export type Scene = {
2 id: string
3 tool: string
4 what: string
5 out: string
6 isError: boolean
7 at: number
8 ms: number
9 turn: number
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'replay-theater': { scenes: Scene[]; pos: number; playing: boolean; turn: number }
15 }
16}
17