Claude의 도구 호출을 블랙박스처럼 녹화: 오류·거부(사고) 직전 동작을 /blackbox에서 돌려보기, 토큰·비밀번호는 가려서 기록

클로드 코드를 택시로 개조하는 Claude Code mods
요금 미터기, 내비, 과속 단속 카메라, 블랙박스를 프롬프트 위에 달아 드려요.
<img src="docs/images/hero.webp" alt="force push를 단속 카메라가 세우고, 미터기·내비·블랙박스가 함께 보이는 Claude Code 화면" width="900">
<sub>실제 세션 화면이에요. Claude가 <code>git push --force</code>를 하려는 순간 단속 카메라가 세웠고, 미터기는 지금까지 ₩240, 내비는 경유지 2/3, 블랙박스는 사고 1건을 기록했어요.</sub>
| mod | 한 줄 요약 | 명령 | |
|---|---|---|---|
| 🧾 | taxi-meter | 이번 세션이 API 요금으로 얼마인지, 5시간·주간 한도를 얼마나 썼는지 | /meter /receipt |
| 🧭 | taxi-navi | Claude의 할 일 목록을 경로로 그리고 음성으로 안내 | /navi |
| 📸 | taxi-speedcam | rm -rf, force push 같은 위험한 명령을 실행 전에 세우고 물어보기 | /speedcam |
| 🎥 | taxi-blackbox | 모든 도구 호출을 녹화하고, 오류·거부 직전 장면을 다시 보기 | /blackbox |
claude plugin marketplace add devbrother2024/devbrothers-mods
claude plugin install taxi-blackbox@devbrothers-mods
claude plugin install taxi-speedcam@devbrothers-mods
claude plugin install taxi-meter@devbrothers-mods
claude plugin install taxi-navi@devbrothers-mods
필요한 것만 골라 설치해도 돼요. 열려 있는 세션에서는 /reload-plugins를 치거나 Claude Code를 다시 시작하면 바로 보입니다. /plugin에서 4 mods active · taxi-blackbox, …가 보이면 성공이에요.
claude plugin marketplace update devbrothers-mods # 새 버전 목록 받기
claude plugin update taxi-meter@devbrothers-mods # mod별로 업데이트
claude plugin uninstall taxi-meter@devbrothers-mods # 삭제
업데이트한 뒤에는 세션을 다시 열어야 새 버전이 적용돼요.
[!TIP] 넷 다 쓴다면 블랙박스를 먼저 설치하세요. mod는 설치한 순서대로 실행돼요. 블랙박스가 단속 카메라보다 앞에 있어야 카메라가 세운 명령까지 사고로 기록됩니다.
[!IMPORTANT] 내비는 할 일 도구가 켜져 있어야 해요. Claude Code v2.1.233부터 Opus 4.8·Sonnet 5·Fable 5 이후 모델은 할 일 도구가 기본으로 꺼져 있어서 Claude가 체크리스트를 만들지 않아요.
CLAUDE_CODE_ENABLE_TODO_TOOLS=1 claude로 시작하거나~/.claude/settings.json의env에 넣어두세요.
git clone https://github.com/devbrother2024/devbrothers-mods
cd devbrothers-mods
claude --plugin-dir plugins/taxi-blackbox --plugin-dir plugins/taxi-speedcam \
--plugin-dir plugins/taxi-meter --plugin-dir plugins/taxi-navi
--plugin-dir 순서가 실행 순서예요. 여기서도 블랙박스를 맨 앞에 두세요.
<img src="docs/images/meter.webp" alt="택시 미터기 패널. 일반 Sonnet 5.5, 방금 요청 +₩30, 요금 ₩270, 컨텍스트 4%, 5시간 한도 13%" width="560">
세션 하나가 승차 한 번이에요. Claude가 일하는 동안 말이 달리고 요금이 딸깍딸깍 올라가요.
방금 요청 +₩___로 따로 보여요. /clear하면 새 승차로 0원부터 다시 세고, --continue로 이어 열면 다시 연 시점부터 새 승차예요.Sonnet 5.5), 출력 속도(tok/s)가, 오른쪽에는 컨텍스트 사용률이, 아래 줄에는 5시간 한도와 리셋 시각, 주간 한도가 나와요./model로 모델을 바꾸면 프롬프트를 보내기 전에 차종과 할증 키가 바로 바뀌어요. 이때 이전 캐시를 못 써서 다음 요청에 대화 전체를 다시 캐시에 쓰는 요금이 붙는데, 그 예상 금액을 알림으로 먼저 알려 드려요(긴 대화에서 Opus로 바꾸면 수천 원이 나오기도 해요).| 키 | 켜지는 때 |
|---|---|
빈차 | 첫 응답 전 |
주행 | Claude가 응답하는 중 |
할증 | 비싼 차종을 타는 중. Opus는 모범, Fable은 블랙 (Sonnet·Haiku는 일반) |
복합 | 컨텍스트 50% 이상, 즉 장거리 |
지불 | 응답이 끝나 이번 요금이 확정됐거나 한도를 다 쓴 때 |
<img src="docs/images/meter-demo.webp" alt="데모 주행 끝 화면. 5시간 한도를 다 써서 빨간 숫자와 하차 안내" width="560">
5시간이나 주간 한도가 90%를 넘으면 "곧 목적지입니다", 100%면 "하차하셔야 합니다 · 재승차 시각"이 뜨고 숫자가 빨간색이 돼요. 위 화면은 /meter demo 데모 주행이에요(연출 숫자라서 미터기에 "데모 주행"이 찍혀요).
<img src="docs/images/receipt.webp" alt="/receipt 영수증 창. 승하차 시각, 새로 처리·캐시 재사용 토큰, 차종, 요금, 요청별 요금, 누적" width="900">
미터기는 토큰을 직접 세지 않고 Claude Code가 계산한 세션 비용(/cost와 같은 값)을 따라가요. 실제 세션으로 맞춰 보니 대화 기록에 남은 응답들을 정가로 계산한 값에, 기록에 안 남는 호출(세션 제목 생성, 프롬프트 추천 등)을 더하면 소수 일곱째 자리까지 같았어요.
방금 요청에 섞여 보일 수 있어요(세션 합계는 맞아요).settings.json에 "promptSuggestionEnabled": false를 넣어 그 호출을 끌 수 있어요.| 명령 | 하는 일 |
|---|---|
/meter | 이번 승차(세션)·방금 요청·오늘·최근 7일·누적 요금 |
/receipt | 영수증 창(승하차 시각, 새로 처리·캐시 재사용 토큰, 차종 비중, 요금, 요청별 요금, 누적) |
/meter reset | 화면 요금과 누적 장부를 모두 0원으로 (데모 중이면 데모도 끝내요) |
/meter demo | 약 10초짜리 데모 주행. 다시 입력하거나 다음 프롬프트를 보내면 실제 미터기로 돌아와요 |
| 설정 | 기본값 | 설명 |
|---|---|---|
krw_per_usd | 1400 | 원화 환산 환율 |
style | led | led는 터미널에서 미터기 패널, compact는 한 줄 |
sound | true | 요금이 오를 때 딸깍, 한도 경고 알림음 |
tick_won | 1000 | 딸깍 소리를 내는 금액 단위 |
누적 장부는 세션을 넘어 90일치를 보관해요. 패널은 창 폭 75칸·높이 9줄 이상에서 나오고, 더 좁으면 한 줄로 바뀌어요.
<img src="docs/images/navi.webp" alt="내비 패널. 위는 주행 중(경유지 1/3, 다음 안내), 아래는 도착(경유지 3곳, 소요 1분)" width="560">
Claude가 세운 할 일 목록이 경로가 되고, 🚕가 지금 하는 단계에 있어요.
TaskCreate·TaskUpdate·TaskList·TodoWrite를 읽기만 하고, 할 일 내용은 바꾸지 않아요. 서브에이전트의 할 일은 경로에 넣지 않아요./navi를 치면 할 일 도구가 켜져 있는지 알려줘요.| 설정 | 기본값 | 설명 |
|---|---|---|
voice | true | 음성 안내 |
voice_name | Yuna | macOS say -v '?'에 나오는 음성 이름 |
sound | true | 단계를 끝낼 때마다 딩 |
<img src="docs/images/speedcam.webp" alt="force push 앞에서 뜬 질문 창(가주세요/세워주세요)과 세운 뒤의 단속 카메라 패널" width="760">
위험한 Bash 명령을 실행 직전에 "찰칵" 잡아요. 무엇이 바뀌는지 보여주고 [가주세요] / [세워주세요]로 물어봐요.
| 단속 | 잡는 명령 예시 |
|---|---|
| 🔄 역주행 감지 | git push --force, git push -f, git push origin +main |
| 🪨 낭떠러지 주의 | rm -rf, rm -r -f, rm --recursive --force |
| 🏫 어린이 보호구역 | DROP TABLE, TRUNCATE, WHERE 없는 DELETE FROM, prisma migrate reset |
| ⏪ 후진 주의 | git reset --hard, git clean -f, git checkout -- ., git stash drop |
| 🛣️ 고속도로 진입 | --prod, wrangler deploy, terraform apply, npm publish |
claude -p처럼 물어볼 사람이 없으면 세워요.[!WARNING] 텍스트 패턴으로 잡는 안전벨트예요. alias나 스크립트 속 명령은 못 잡으니, 권한 설정의
deny규칙을 대신하지 않아요.
| 설정 | 기본값 | 설명 |
|---|---|---|
mode | ask | ask는 매번 묻고, block은 묻지 않고 세워요 |
sound | true | 찰칵 셔터 소리 |
voice | true | 단속 종류 음성 안내 |
voice_name | Yuna | macOS 음성 이름 |
<img src="docs/images/blackbox.webp" alt="/blackbox 창. 타임라인, 직전 5개 동작, 빨간 사고 장면 카드, 아래 REC 줄" width="900">
맨 아래 ● REC 줄이 날짜·시각과 함께 계속 녹화해요(응답 중에는 깜빡여요). 오류나 거부가 생기면 사고 N이 노랗게 켜져요.
/blackbox를 치면 사고 장면이 열려요. 타임라인(사고는 빨강, 고른 사고는 노랑 ▲), 직전 동작 목록, 사고 카드가 보여요. p·n으로 이전·다음 사고를 넘겨요.token=, password=, Bearer, sk-, ghp_, xoxb-, AKIA 형태의 값은 •••로 가려요.| 설정 | 기본값 | 설명 |
|---|---|---|
before | 5 | 사고 하나와 함께 보여줄 직전 동작 수 |
mod의 훅은 Claude Code가 도는 모든 곳에서 실행되지만, 화면은 터미널과 Desktop 앱 Code 탭에만 그려져요.
| 실행 환경 | 택시 팩 화면 |
|---|---|
터미널 claude (VS Code·JetBrains 내장 터미널 포함) | 패널 그대로 |
| Claude Desktop 앱 Code 탭 (로컬 세션) | 한 줄 표시. Desktop에는 LED 그림(Raster)이 없어서 미터기·내비·단속 카메라가 텍스트로 바뀌어요 |
VS Code 확장 채팅, claude -p, Agent SDK | 화면 없음. 단속 카메라는 물어볼 사람이 없어서 위험 명령을 세워요 |
Desktop 앱 Code 탭의 로컬 세션은 터미널과 같은 ~/.claude 설정을 써서, 위 설치 명령으로 깐 mod가 그대로 로드돼요. 소리와 음성은 macOS에서만 나요.
[!CAUTION] mod는 샌드박스 없이 내 권한으로 Claude Code 안에서 실행돼요. 어떤 mod든 설치 전에 코드를 읽어보세요. 이 팩은
$.fs,$.process,$.http를 쓰지 않아요.claude plugin validate plugins/<이름>의calls:줄로 직접 확인할 수 있어요.
/config에서 바꿔요./receipt·/blackbox 창은 전체화면 모드(settings.json의 "tui": "fullscreen")에서 터미널 폭이 110칸 이상이면 오른쪽에 붙고, 그 밖에는 프롬프트 위에 열려요. 위치는 Claude Code가 정해요.n more로 접히고 스크롤돼요. 창을 50줄 이상으로 키우면 다 보여요.이 팩도 Claude Code에게 말로 시켜서 만들고 다듬었어요. Claude Code 세션에서 원하는 걸 말하면 내장 plugin-authoring 스킬이 mod를 써줘요.
프롬프트 위에 현재 git 브랜치를 보여주는 mod 만들어줘
직접 짜보려면 공식 튜토리얼과 화면 그리기 문서에서 시작하세요. 이 저장소의 plugins/*/hooks/register.ts와 tests/도 예제로 쓸 수 있어요.
claude plugin validate plugins/taxi-meter # 잡는 이벤트·호출 목록, 정적 검사
claude plugin test plugins/taxi-meter # 세션·로그인 없이 테스트
python3 scripts/make-sounds.py # 효과음 다시 합성
효과음은 scripts/make-sounds.py가 직접 합성한 파일이라 외부 음원 라이선스가 없어요.
hooks/register.ts 336 lines1import type { Register } from 'claude-code'
2
3type Outcome = 'ok' | 'error' | 'denied'
4type Frame = { at: number; tool: string; what: string; outcome: Outcome; reason?: string; agentId?: string }
5
6const PANE = 'taxi-blackbox'
7const CAPACITY = 500
8const RED = '#ff3b30'
9const YELLOW = '#ffcc00'
10const GREEN = '#34c759'
11const OSD = '#1c1c1e'
12const OSD_TEXT = '#e5e5e5'
13const INK = '#111111'
14
15const TRACK_RGB = 0x111111
16const RAIL_RGB = 0x3a3a3c
17const OK_RGB = 0x8e8e93
18const WINDOW_RGB = 0xf2f2f7
19const INCIDENT_RGB = 0xff3b30
20const PICK_RGB = 0xffcc00
21
22const TOOL_COLOR: Record<string, string> = {
23 Bash: '#ff9f0a',
24 Edit: '#0a84ff',
25 Write: '#0a84ff',
26 NotebookEdit: '#0a84ff',
27 Read: '#8e8e93',
28 Grep: '#8e8e93',
29 Glob: '#8e8e93',
30 WebFetch: '#30b0c7',
31 WebSearch: '#30b0c7',
32}
33const OTHER_TOOL = '#bf5af2'
34
35const SECRETS: readonly [RegExp, string][] = [
36 [/\b((?:api[_-]?key|access[_-]?token|token|secret|password|passwd|pwd)\s*[=:]\s*)("[^"]*"|'[^']*'|[^\s"']+)/gi, '$1•••'],
37 [/\b(Bearer\s+)[A-Za-z0-9._~+/-]+=*/gi, '$1•••'],
38 [/\bsk-[A-Za-z0-9_-]{8,}/g, 'sk-•••'],
39 [/\bgh[pousr]_[A-Za-z0-9]{20,}/g, 'gh•••'],
40 [/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, 'xox•••'],
41 [/\bAKIA[0-9A-Z]{16}\b/g, 'AKIA•••'],
42]
43
44const mask = (text: string) => SECRETS.reduce((out, [pattern, replacement]) => out.replace(pattern, replacement), text)
45
46const oneLine = (text: string, max: number) => {
47 const flat = text.replace(/\s+/g, ' ').trim()
48 return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
49}
50
51const pad = (n: number) => String(n).padStart(2, '0')
52const clockOf = (ms: number) => {
53 const d = new Date(ms)
54 return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
55}
56const stampOf = (ms: number) => {
57 const d = new Date(ms)
58 return `${d.getFullYear()}.${pad(d.getMonth() + 1)}.${pad(d.getDate())} ${clockOf(ms)}`
59}
60const elapsed = (ms: number) => {
61 const total = Math.max(0, Math.floor(ms / 1000))
62 const h = Math.floor(total / 3600)
63 const body = `${pad(Math.floor((total % 3600) / 60))}:${pad(total % 60)}`
64 return h > 0 ? `${h}:${body}` : body
65}
66
67const describe = (e: Record<string, unknown>) => {
68 for (const field of ['command', 'file_path', 'notebook_path', 'pattern', 'url', 'query', 'description', 'subject', 'prompt']) {
69 const value = e[field]
70 if (typeof value === 'string' && value) return value
71 }
72 const question = Array.isArray(e.questions) ? (e.questions[0] as { question?: unknown } | undefined)?.question : undefined
73 return typeof question === 'string' ? question.split('\n')[0] : ''
74}
75
76const base64 = (bytes: Uint8Array) => {
77 const native = bytes as Uint8Array & { toBase64?: () => string }
78 if (native.toBase64) return native.toBase64()
79 let binary = ''
80 for (const byte of bytes) binary += String.fromCharCode(byte)
81 return btoa(binary)
82}
83
84const columnOf = (index: number, count: number, width: number) =>
85 count <= width ? index : Math.floor((index * width) / count)
86
87const timeline = (frames: Frame[], width: number, window: [number, number] | undefined, pick: number | undefined) => {
88 const cells = Array.from({ length: width }, () => ({ glyph: '▁', rgb: RAIL_RGB, rank: 0 }))
89 frames.forEach((frame, i) => {
90 const column = columnOf(i, frames.length, width)
91 const [glyph, rgb, rank] =
92 i === pick
93 ? ['█', PICK_RGB, 4]
94 : frame.outcome !== 'ok'
95 ? ['█', INCIDENT_RGB, 3]
96 : window && i >= window[0] && i < window[1]
97 ? ['▆', WINDOW_RGB, 2]
98 : ['▄', OK_RGB, 1]
99 if (rank > cells[column].rank) cells[column] = { glyph, rgb, rank }
100 })
101 const numbers = cells.flatMap((cell) => [cell.glyph.codePointAt(0) ?? 32, cell.rgb, TRACK_RGB])
102 return { columns: width, rows: 1, cells: base64(new Uint8Array(Uint32Array.from(numbers).buffer)) }
103}
104
105const reasonOf = (out: { deny?: string; isError?: boolean; result?: unknown }) => {
106 if (out.deny) return out.deny
107 if (!out.isError) return undefined
108 return typeof out.result === 'string' ? out.result : JSON.stringify(out.result ?? '')
109}
110
111export const register: Register = (on, options) => {
112 const before = typeof options.before === 'number' && options.before >= 1 ? Math.floor(options.before) : 5
113
114 const frames: Frame[] = []
115 let dropped = 0
116 let startedAt = 0
117 let blink = true
118 let isDriving = false
119 let clock: { cancel: () => void } | undefined
120 let selected: number | undefined
121
122 const incidents = () => frames.flatMap((frame, i) => (frame.outcome === 'ok' ? [] : [i]))
123
124 on('session.start', async ($, e, next) => {
125 const result = await next(e)
126 await $.command.register({ name: 'blackbox', description: '블랙박스: 사고(오류·거부) 직전 동작 돌려보기', immediate: true })
127 startedAt = await $.clock.now()
128 clock?.cancel()
129 clock = $.clock.every(1000, () => {
130 blink = isDriving ? !blink : true
131 $.ui.invalidate('ui.render')
132 })
133 return result
134 })
135
136 on('session.end', async ($, e, next) => {
137 clock?.cancel()
138 return next(e)
139 })
140
141 on('turn.start', async ($, e, next) => {
142 isDriving = true
143 return next(e)
144 })
145
146 on('turn.complete', async ($, e, next) => {
147 const result = await next(e)
148 if (!(e as { agentId?: string }).agentId) {
149 isDriving = false
150 blink = true
151 $.ui.invalidate('ui.render')
152 }
153 return result
154 })
155
156 on('tool.call', async ($, e, next) => {
157 const at = await $.clock.now()
158 const frame: Frame = {
159 at,
160 tool: e.tool,
161 what: oneLine(mask(describe(e as unknown as Record<string, unknown>)), 90),
162 outcome: 'ok',
163 ...(e.agentId ? { agentId: e.agentId } : {}),
164 }
165 const keep = () => {
166 frames.push(frame)
167 if (frames.length > CAPACITY) {
168 frames.shift()
169 dropped += 1
170 }
171 $.ui.invalidate('ui.render')
172 }
173 try {
174 const out = await next(e)
175 const reason = reasonOf(out)
176 if (out.deny) frame.outcome = 'denied'
177 else if (out.isError) frame.outcome = 'error'
178 if (reason) frame.reason = oneLine(mask(reason), 160)
179 keep()
180 return out
181 } catch (error) {
182 frame.outcome = 'error'
183 frame.reason = oneLine(mask(String(error)), 160)
184 keep()
185 throw error
186 }
187 })
188
189 on('command.run', { command: 'blackbox' }, async ($) => {
190 selected = undefined
191 await $.ui.open({ id: PANE, title: '블랙박스', focus: true, closeOnEscape: true })
192 return {}
193 })
194
195 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
196 if (e.props.hasSurvey || startedAt === 0) return next(e)
197 const { Box, Text } = $.ui.resolve(e)
198 const now = await $.clock.now()
199 const count = incidents().length
200 const rec = Box({
201 flexDirection: 'row',
202 columnGap: 1,
203 children: [
204 Text({ bold: true, color: blink ? '#ffffff' : RED, backgroundColor: blink ? RED : OSD, children: [' ● REC '] }),
205 Text({ color: OSD_TEXT, backgroundColor: OSD, children: [` ${stampOf(now)} `] }),
206 Text({ dimColor: true, children: [`${elapsed(now - startedAt)} · 기록 ${frames.length + dropped}`] }),
207 ...(count > 0 ? [Text({ bold: true, color: INK, backgroundColor: YELLOW, children: [` 사고 ${count} `] })] : []),
208 Text({ dimColor: true, children: ['/blackbox'] }),
209 ],
210 })
211 const above = await next(e)
212 return above ? Box({ flexDirection: 'column', children: [above, rec] }) : rec
213 })
214
215 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
216 if (e.requestId !== PANE) return next(e)
217 const elements = $.ui.resolve(e)
218 const { Box, Text, Button } = elements
219 const redraw = () => $.ui.invalidate('ui.render')
220 const list = incidents()
221 const pick = list.length === 0 ? undefined : Math.min(selected ?? list.length - 1, list.length - 1)
222 const width = Math.max(20, e.props.bodyColumns - 4)
223
224 const fixed = (child: ReturnType<typeof Text>) => Box({ flexShrink: 0, children: [child] })
225 const fill = (child: ReturnType<typeof Text>) => Box({ flexGrow: 1, flexShrink: 1, minWidth: 0, children: [child] })
226 const isWide = e.props.bodyColumns >= 60
227
228 const badge = (frame: Frame, isIncident: boolean) =>
229 Text({
230 bold: true,
231 color: INK,
232 backgroundColor: isIncident ? RED : (TOOL_COLOR[frame.tool] ?? OTHER_TOOL),
233 children: [` ${frame.agentId ? '↳ ' : ''}${frame.tool} `],
234 })
235
236 const line = (frame: Frame, isIncident: boolean) =>
237 Box({
238 flexDirection: 'row',
239 columnGap: 1,
240 children: [
241 fixed(Text({ dimColor: true, children: [clockOf(frame.at)] })),
242 fixed(badge(frame, isIncident)),
243 fill(Text({ wrap: 'truncate-end', ...(isIncident ? { bold: true } : { dimColor: true }), children: [frame.what || '-'] })),
244 ],
245 })
246
247 const scrubber = (window: [number, number] | undefined, at: number | undefined) => {
248 if (!('Raster' in elements) || frames.length === 0) return []
249 const caret = at === undefined ? [] : [Text({ color: YELLOW, bold: true, children: [`${' '.repeat(columnOf(at, frames.length, width))}▲`] })]
250 return [elements.Raster({ key: 'timeline', ...timeline(frames, width, window, at) }), ...caret]
251 }
252
253 const header = (mode: 'REC' | 'PLAY', title: string, stamp: number) =>
254 Box({
255 flexDirection: 'row',
256 columnGap: 1,
257 children: [
258 fixed(
259 Text({
260 bold: true,
261 color: mode === 'REC' ? '#ffffff' : INK,
262 backgroundColor: mode === 'REC' ? RED : GREEN,
263 children: [mode === 'REC' ? ' ● REC ' : ' ▶ PLAY '],
264 }),
265 ),
266 fill(Text({ bold: true, wrap: 'truncate-end', children: [title] })),
267 fixed(Text({ color: OSD_TEXT, backgroundColor: OSD, children: [` ${isWide ? stampOf(stamp) : clockOf(stamp)} `] })),
268 ],
269 })
270
271 if (pick === undefined) {
272 return Box({
273 flexDirection: 'column',
274 paddingX: 1,
275 children: [
276 header('REC', `블랙박스 · 기록 ${frames.length + dropped} · 사고 없음`, await $.clock.now()),
277 Text({ dimColor: true, children: ['오류나 거부가 생기면 그 직전 동작을 여기서 돌려볼 수 있어요. 최근 동작:'] }),
278 ...scrubber([Math.max(0, frames.length - before), frames.length], undefined),
279 ...frames.slice(-before).map((frame) => line(frame, false)),
280 ],
281 })
282 }
283
284 const at = list[pick]
285 const incident = frames[at]
286 const label = incident.outcome === 'denied' ? '거부' : '오류'
287 return Box({
288 flexDirection: 'column',
289 paddingX: 1,
290 children: [
291 header('PLAY', `사고 ${pick + 1}/${list.length} · ${incident.tool} ${label}`, incident.at),
292 ...scrubber([Math.max(0, at - before), at], at),
293 Text({ dimColor: true, children: [`직전 ${before}개 동작`] }),
294 ...frames.slice(Math.max(0, at - before), at).map((frame) => line(frame, false)),
295 Box({
296 flexDirection: 'column',
297 borderStyle: 'round',
298 borderColor: RED,
299 paddingX: 1,
300 children: [
301 fixed(Text({ bold: true, color: '#ffffff', backgroundColor: RED, children: [' 사고 장면 '] })),
302 line(incident, true),
303 Text({ color: RED, wrap: 'wrap', children: [`└ ${incident.reason ?? label}`] }),
304 ],
305 }),
306 Box({
307 flexDirection: 'row',
308 columnGap: 2,
309 children: [
310 Button({
311 key: 'prev',
312 label: '◀ 이전 사고',
313 hotkey: 'p',
314 dimColor: pick === 0,
315 onPress: () => {
316 selected = Math.max(0, pick - 1)
317 redraw()
318 },
319 }),
320 Button({
321 key: 'next',
322 label: '다음 사고 ▶',
323 hotkey: 'n',
324 dimColor: pick === list.length - 1,
325 onPress: () => {
326 selected = Math.min(list.length - 1, pick + 1)
327 redraw()
328 },
329 }),
330 ],
331 }),
332 ],
333 })
334 })
335}
336