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

บันทึกทุกอย่างใน session เป็น "ฉาก": prompt ของคุณ, tool ที่ Claude เรียก, ผลลัพธ์พร้อมเวลาที่ใช้, error และคำตอบ ดูย้อนหลังได้หลายแบบ เล่นทีละฉากในแผงด้านข้างเหมือนดูหนัง และส่งออกเป็นไฟล์ HTML
| คำสั่ง | ทำอะไร |
|---|---|
/replay | ดูทั้งหมด |
/replay last 5 | 5 ฉากล่าสุด |
/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 แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:
prompt.submit, tool.call, turn.complete)$.clock.every และยกเลิกด้วย cancel()เก็บล่าสุด 200 ฉาก และฉากจะเริ่มใหม่เมื่อ Mod ถูกโหลดใหม่
hooks/register.tsx 274 lines1/**
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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