SLOPSHOPPER

replay-theater

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

newpaneguardcommandtimer
v0.1.0no licenseupdated 2026-10-22ranslu/king-pedro/.claude/skills/replay-theater
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · replay-theater
│ ┃ Replay Theater ✕ › fix the failing auth test and add an audit log call │ ┃ 🎬 Scene 1/9 · turn 1 · +0s │ ┃ ●─────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ✓ Read (20 ms) ⏺ Update(src/auth.ts) │ ┃ /work/app/src/auth.ts ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ import { verify } from './jwt' ⎿ 3 pass, 1 fail │ ┃ │ ┃ export async function refresh(token: string… ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ const claims = await verify(token) │ ┃ if (!claims) throw new Error('invalid tok… ✻ Worked for 42s · done 4:20 PM │ ┃ return issue(claims.sub) │ ┃ } › /replay │ ┃ ⎿ replay-theater: Replay Theater: 9 scenes. │ ┃ │ ┃ [ ⏮ ][ ◀ Prev ][ ▶ Play ][ Next ▶ ][ ⏭ ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Replay Theater
🎬 Scene 1/9 · turn 1 · +0s ●─────────────────────────────────────── ✓ Read (20 ms) /work/app/src/auth.ts import { verify } from './jwt' export async function refresh(token: string) { const claims = await verify(token) if (!claims) throw new Error('invalid token') return issue(claims.sub) } [ ⏮ ][ ◀ Prev ][ ▶ Play ][ Next ▶ ][ ⏭ ]
Source 3 files
hooks/register.tsx 140 lines
1import { 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}
140
hooks/summary.ts 11 lines
1/** 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}
11
types/index.d.ts 17 lines
1export 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