SLOPSHOPPER

replay-theater-th

บันทึกทุกอย่างที่ Claude ทำเป็นฉากหนัง ดูย้อนหลัง เล่นทีละฉากในแผง และส่งออกเป็น HTML

newpaneguardcommandprompttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · replay-theater-th
│ ┃ replay-theater-th ✕ › fix the failing auth test and add an audit log call │ ┃ 🗣️ ฉากที่ 0 (1/21) ⏹️ │ ┃ เริ่มบันทึกแล้ว 🎬 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /replay │ ⎿ replay-theater-th: 🎬 Replay Theater - 21 ฉาก │ ⎿ replay-theater-th: ======================================== │ ⎿ replay-theater-th: [ฉากที่ 0] 🗣️ เริ่มบันทึกแล้ว 🎬 - 0วิที่แล้ว │ ⎿ replay-theater-th: [ฉากที่ 1] 🗣️ คุณพิมพ์ - 0วิที่แล้ว │ ⎿ replay-theater-th: [ฉากที่ 2] 🔧 เรียก Read - 0วิที่แล้ว │ ⎿ replay-theater-th: [ฉากที่ 3] ✅ Read สำเร็จ - 0วิที่แล้ว (20ms) │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · replay-theater-th
🗣️ ฉากที่ 0 (1/21) ⏹️ เริ่มบันทึกแล้ว 🎬
README

Mod 09: Replay Theater TH

บันทึกทุกอย่างใน session เป็น "ฉาก": prompt ของคุณ, tool ที่ Claude เรียก, ผลลัพธ์พร้อมเวลาที่ใช้, error และคำตอบ ดูย้อนหลังได้หลายแบบ เล่นทีละฉากในแผงด้านข้างเหมือนดูหนัง และส่งออกเป็นไฟล์ HTML

คำสั่ง

คำสั่งทำอะไร
/replayดูทั้งหมด
/replay last 55 ฉากล่าสุด
/replay scene 10ฉากที่ 10 แบบละเอียด
/replay errorsเฉพาะที่พัง
/replay toolsสรุปการใช้ tool
/replay play 1000เล่นในแผง ฉากละ 1 วินาที
/replay stopหยุดเล่น
/replay exportส่งออก HTML
/replay pause / resume / clearหยุดบันทึก / บันทึกต่อ / ล้าง

ติดตั้ง

/plugin install replay-theater-th --marketplace tksoft11/claude-code-mods-th

ต้องตั้ง CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ก่อนเปิด claude (ดู README หลัก)

โค้ดสอนอะไร

เปิด hooks/register.tsx แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:

  1. ดักหลาย event (prompt.submit, tool.call, turn.complete)
  2. จับเวลาการทำงานของ tool
  3. ใช้ timer $.clock.every และยกเลิกด้วย cancel()
  4. escape ข้อความก่อนใส่ HTML

หมายเหตุ

เก็บล่าสุด 200 ฉาก และฉากจะเริ่มใหม่เมื่อ Mod ถูกโหลดใหม่

Source 1 files
hooks/register.tsx 274 lines
1/**
2 * ============================================================================
3 * MOD 09: Replay Theater TH - ดูย้อนหลังว่า Claude ทำอะไรไปบ้างแบบหนัง (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: ตัวอย่าง replay ของ Anthropic
6 *
7 * หน้าที่ของ Mod นี้:
8 * 1. บันทึกทุกอย่างเป็น "ฉาก": prompt ของคุณ, tool ที่เรียก, ผลลัพธ์, คำตอบของ Claude
9 * 2. /replay ดูทั้งหมด หรือกรองดู (last, scene, errors, tools)
10 * 3. /replay play เล่นทีละฉากในแผงด้านข้าง เหมือนดูหนัง
11 * 4. /replay export ส่งออกเป็นไฟล์ HTML เปิดดูใน browser ได้
12 */
13
14import type { EngineInterface, Register, Timer } from 'claude-code'
15
16type SceneType = 'prompt' | 'tool_call' | 'tool_result' | 'error' | 'answer'
17
18type TheaterScene = {
19  id: number
20  time: number // เวลา (ms)
21  type: SceneType
22  title: string
23  content: string
24  durationMs?: number // เฉพาะผลของ tool
25}
26
27type Playback = {
28  index: number // ฉากที่กำลังเล่น (ลำดับใน scenes)
29  isPlaying: boolean
30}
31
32const PANE = 'replay-theater-th'
33const MAX_SCENES = 200
34
35const ICON: Record<SceneType, string> = { prompt: '🗣️', tool_call: '🔧', tool_result: '✅', error: '❌', answer: '💬' }
36
37let sceneIdCounter = 0
38let isRecording = true
39let projectRoot = ''
40let playTimer: Timer | undefined
41let scenes: TheaterScene[] = [] // ทุกฉาก (เริ่มใหม่เมื่อ reload mod)
42let playback: Playback = { index: 0, isPlaying: false }
43
44// เปลี่ยน playback แล้วสั่งให้แผงวาดใหม่
45function setPlayback($: EngineInterface, value: Playback): void {
46  playback = value
47  $.ui.invalidate('ui.render')
48}
49
50// ==========================================================================
51// บทที่ 1: เก็บ Timeline แบบฉากหนัง
52// ==========================================================================
53async function addScene($: EngineInterface, type: SceneType, title: string, content: string, durationMs?: number): Promise<void> {
54  if (!isRecording) return
55  const scene: TheaterScene = { id: sceneIdCounter++, time: await $.clock.now(), type, title, content: content.slice(0, 500), durationMs }
56  scenes = [...scenes, scene].slice(-MAX_SCENES) // เก็บแค่ 200 ฉากล่าสุด
57  $.ui.invalidate('ui.render')
58}
59
60function timeAgo(ms: number): string {
61  const sec = Math.floor(ms / 1000)
62  return sec < 60 ? `${sec}วิที่แล้ว` : sec < 3600 ? `${Math.floor(sec / 60)}นาทีที่แล้ว` : `${Math.floor(sec / 3600)}ชม.ที่แล้ว`
63}
64
65function sceneLine(s: TheaterScene, now: number): string {
66  const took = s.durationMs !== undefined ? ` (${s.durationMs}ms)` : ''
67  return `[ฉากที่ ${s.id}] ${ICON[s.type]} ${s.title} - ${timeAgo(now - s.time)}${took}`
68}
69
70// สรุป input ของ tool สั้นๆ เช่น "อ่านไฟล์: src/App.tsx"
71function describeInput(e: Record<string, unknown>): string {
72  for (const [key, label] of [['file_path', 'ไฟล์'], ['command', 'คำสั่ง'], ['url', 'URL'], ['pattern', 'ค้นหา'], ['description', 'งาน']] as const) {
73    const value = e[key]
74    if (typeof value === 'string' && value) return `${label}: ${value}`
75  }
76  return ''
77}
78
79// ==========================================================================
80// บทที่ 4: ส่งออกเป็น HTML (escape ข้อความก่อนใส่ HTML เสมอ!)
81// ==========================================================================
82function escapeHtml(text: string): string {
83  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
84}
85
86function buildTheaterHTML(scenes: TheaterScene[]): string {
87  const colors: Record<SceneType, string> = { prompt: '#1565c0', tool_call: '#6a1b9a', tool_result: '#2e7d32', error: '#c62828', answer: '#ef6c00' }
88  const items = scenes
89    .map(
90      s => `<div class="scene" style="border-left-color:${colors[s.type]}">
91  <div class="head">[ฉากที่ ${s.id}] ${ICON[s.type]} ${escapeHtml(s.title)} <span>${new Date(s.time).toLocaleTimeString('th-TH')}${s.durationMs !== undefined ? ` · ${s.durationMs}ms` : ''}</span></div>
92  ${s.content ? `<pre>${escapeHtml(s.content)}</pre>` : ''}
93</div>`,
94    )
95    .join('\n')
96
97  return `<!doctype html>
98<html lang="th"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
99<title>Replay Theater</title>
100<style>
101  body { font-family: system-ui, sans-serif; background: #111; color: #eee; max-width: 900px; margin: 0 auto; padding: 16px; }
102  .scene { border-left: 4px solid; background: #1c1c1c; margin: 8px 0; padding: 8px 12px; border-radius: 4px; }
103  .head span { color: #888; font-size: 0.85em; margin-left: 8px; }
104  pre { white-space: pre-wrap; word-break: break-word; color: #bbb; margin: 6px 0 0; }
105</style></head>
106<body><h1>🎬 Replay Theater</h1><p>${scenes.length} ฉาก</p>
107${items}
108</body></html>
109`
110}
111
112// ==========================================================================
113// บทที่ 3: เล่นทีละฉากด้วย timer ($.clock.every)
114// ==========================================================================
115function stopPlaying(): void {
116  playTimer?.cancel()
117  playTimer = undefined
118}
119
120function startPlaying($: EngineInterface, ms: number): void {
121  stopPlaying()
122  setPlayback($, { index: 0, isPlaying: true })
123  playTimer = $.clock.every(ms, () => {
124    const index = playback.index + 1
125    if (index >= scenes.length) {
126      stopPlaying()
127      setPlayback($, { index: Math.max(0, scenes.length - 1), isPlaying: false })
128    } else {
129      setPlayback($, { index, isPlaying: true })
130    }
131  })
132}
133
134const HELP = [
135  '/replay              ดูทั้งหมด',
136  '/replay last 5       ดู 5 ฉากหลังสุด',
137  '/replay scene 10     ดูฉากที่ 10 แบบละเอียด',
138  '/replay errors       ดูเฉพาะที่พัง',
139  '/replay tools        สรุปว่าใช้ tool อะไรไปเท่าไหร่',
140  '/replay play 1000    เล่นในแผง ฉากละ 1 วินาที (/replay stop เพื่อหยุด)',
141  '/replay export       ส่งออกเป็น HTML',
142  '/replay pause | resume | clear',
143].join('\n')
144
145export const register: Register = on => {
146  on('session.start', async ($, e, next) => {
147    projectRoot = e.cwd
148    isRecording = true
149    await $.command.register({ name: 'replay', description: 'ดูย้อนหลังว่า Claude ทำอะไรไปบ้าง (/replay help)', argumentHint: '[last|scene|errors|tools|play|export|...]' })
150    await addScene($, 'prompt', 'เริ่มบันทึกแล้ว 🎬', '')
151    return next(e)
152  })
153
154  // ==========================================================================
155  // บทที่ 2: ดักทุก event ที่อยากบันทึก
156  // ==========================================================================
157  on('prompt.submit', async ($, e, next) => {
158    if (!e.isMeta) await addScene($, 'prompt', e.command ? `คำสั่ง /${e.command.name}` : 'คุณพิมพ์', e.text)
159    return next(e)
160  })
161
162  on('tool.call', async ($, e, next) => {
163    const input = describeInput(e as unknown as Record<string, unknown>)
164    await addScene($, 'tool_call', `เรียก ${e.tool}`, input)
165
166    const startedAt = await $.clock.now()
167    const ran = await next(e)
168    const took = (await $.clock.now()) - startedAt
169
170    if (ran.deny !== undefined) await addScene($, 'error', `${e.tool} ถูกปฏิเสธ`, ran.deny, took)
171    else if (ran.isError) await addScene($, 'error', `${e.tool} ล้มเหลว`, ran.text ?? '', took)
172    else await addScene($, 'tool_result', `${e.tool} สำเร็จ`, (ran.text ?? '').slice(0, 300), took)
173    return ran
174  })
175
176  on('turn.complete', async ($, e, next) => {
177    if (!e.agentId && e.answer) await addScene($, 'answer', 'Claude ตอบ', e.answer)
178    return next(e)
179  })
180
181  // ==========================================================================
182  // บทที่ 5: แผงสำหรับเล่น (Pane) - วาดฉากที่ playback.index ชี้อยู่
183  // ==========================================================================
184  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
185    const { Box, Text } = $.ui.resolve(e)
186    const scene = scenes[playback.index]
187
188    if (!scene) return <Text dimColor>ยังไม่มีฉาก</Text>
189    return (
190      <Box flexDirection="column">
191        <Text bold>
192          {ICON[scene.type]} ฉากที่ {scene.id} ({playback.index + 1}/{scenes.length}) {playback.isPlaying ? '▶️' : '⏹️'}
193        </Text>
194        <Text>{scene.title}</Text>
195        {scene.content && <Text dimColor>{scene.content}</Text>}
196      </Box>
197    )
198  })
199
200  on('command.run', { command: 'replay' }, async ($, e) => {
201    const [sub = '', value = ''] = e.args.trim().split(/\s+/)
202    const now = await $.clock.now()
203
204    switch (sub) {
205      case 'help':
206        return { text: HELP }
207      case 'last': {
208        const n = Math.max(1, Number(value) || 5)
209        return { text: scenes.slice(-n).map(s => sceneLine(s, now)).join('\n') || 'ยังไม่มีฉาก' }
210      }
211      case 'scene': {
212        const s = scenes.find(x => x.id === Number(value))
213        if (!s) return { text: `❌ ไม่มีฉากที่ ${value}` }
214        return { text: `${sceneLine(s, now)}\nType: ${s.type}\n${s.content}` }
215      }
216      case 'errors': {
217        const errors = scenes.filter(s => s.type === 'error')
218        return { text: errors.length ? errors.map(s => `${sceneLine(s, now)}\n  ${s.content.slice(0, 200)}`).join('\n') : '🎉 ไม่มี error เลย' }
219      }
220      case 'tools': {
221        const count = new Map<string, number>()
222        for (const s of scenes) if (s.type === 'tool_call') count.set(s.title, (count.get(s.title) ?? 0) + 1)
223        const rows = [...count.entries()].sort((a, b) => b[1] - a[1]).map(([t, c]) => `🔧 ${t.replace('เรียก ', '').padEnd(14)} ${c} ครั้ง`)
224        return { text: rows.length ? rows.join('\n') : 'ยังไม่ได้เรียก tool' }
225      }
226      case 'play': {
227        if (scenes.length === 0) return { text: 'ยังไม่มีฉากให้เล่น' }
228        const ms = Math.max(200, Number(value) || 1000)
229        const opened = await $.ui.open({ id: PANE, title: '🎬 Replay Theater' })
230        if (!opened.isPlaced) return { text: `เปิดแผงไม่ได้ (${opened.reason})` }
231        startPlaying($, ms)
232        return { text: `▶️ เล่น ${scenes.length} ฉาก ฉากละ ${ms}ms (/replay stop เพื่อหยุด)` }
233      }
234      case 'stop':
235        stopPlaying()
236        setPlayback($, { index: 0, isPlaying: false })
237        return { text: '⏹️ หยุดเล่นแล้ว' }
238      case 'export': {
239        const d = new Date(now) // ใช้เวลาเครื่อง เช่น 2026-10-23-153000
240        const pad = (x: number) => String(x).padStart(2, '0')
241        const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
242        const sep = projectRoot.includes('\\') ? '\\' : '/'
243        const path = `${projectRoot}${sep}replay-${date}.html`
244        await $.fs.write(path, buildTheaterHTML(scenes))
245        return { text: `💾 ส่งออกแล้ว: ${path} (เปิดใน browser ได้เลย)` }
246      }
247      case 'pause':
248        isRecording = false
249        return { text: '⏸️ หยุดบันทึกชั่วคราว (/replay resume เพื่อบันทึกต่อ)' }
250      case 'resume':
251        isRecording = true
252        return { text: '⏺️ บันทึกต่อแล้ว' }
253      case 'clear':
254        stopPlaying()
255        scenes = []
256        setPlayback($, { index: 0, isPlaying: false })
257        return { text: '🧹 ล้างฉากทั้งหมดแล้ว' }
258      default: {
259        const header = `🎬 Replay Theater - ${scenes.length} ฉาก${isRecording ? '' : ' (หยุดบันทึกอยู่)'}\n========================================`
260        return { text: [header, ...scenes.slice(-40).map(s => sceneLine(s, now)), '', '/replay help = ดูคำสั่งทั้งหมด'].join('\n') }
261      }
262    }
263  })
264}
265
266/**
267 * ========================================================================
268 * การบ้าน / ต่อยอด
269 * ========================================================================
270 * 1. เพิ่มปุ่ม ⏮️ ⏯️ ⏭️ ในแผง (Button + onPress) เพื่อเลื่อนฉากเอง
271 * 2. เก็บฉากลง $.store เพื่อดูย้อนหลังข้าม session
272 * 3. ใช้ทำคอร์สสอน: export HTML แล้วแชร์ให้ผู้เรียนดูว่า AI ทำงานทีละขั้นยังไง
273 */
274