위험한 Bash 명령 앞의 과속 단속 카메라: force push·rm -rf·DB 삭제·변경 폐기·운영 배포를 찰칵 잡아 [가주세요]/[세워주세요]로 묻습니다

클로드 코드를 택시로 개조하는 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 263 lines1import type { Register } from 'claude-code'
2
3type Camera = { label: string; note: string; say: string; matches: (command: string) => boolean }
4type Catch = { at: number; label: string; command: string; decision?: '통과' | '정차' }
5
6const SHUTTER = 'sounds/shutter.wav'
7const GO = '가주세요'
8const STOP = '세워주세요'
9const FLASH_MS = 5_000
10const STROBE_MS = 300
11const SPEAK_AFTER_SHUTTER_MS = 300
12const HISTORY = 20
13const RED = '#ff3b30'
14const GREEN = '#34c759'
15const YELLOW = '#ffcc00'
16const INK = '#111111'
17const WHITE = '#ffffff'
18const DIM = '#8e8e93'
19
20const PANEL_WIDTH = 70
21const PANEL_ROWS = 5
22const SCREEN = '#121212'
23const STROBE = '#f5f5f7'
24const SIGN = '#d70015'
25const SIGN_RGB = 0xd70015
26const STRIPE_RGB = 0xffcc00
27const STRIPE_GAP_RGB = 0x111111
28
29const CAMERA_ICON = [
30 '....####........',
31 '.##############.',
32 '.##...####...##.',
33 '.##..##..##..##.',
34 '.##...####...##.',
35 '.##############.',
36]
37
38const QUADRANT = [' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█']
39
40const base64 = (bytes: Uint8Array) => {
41 const native = bytes as Uint8Array & { toBase64?: () => string }
42 if (native.toBase64) return native.toBase64()
43 let binary = ''
44 for (const byte of bytes) binary += String.fromCharCode(byte)
45 return btoa(binary)
46}
47
48const quadrants = (lit: (x: number, y: number) => boolean, columns: number, rows: number, on: number, off: number) => {
49 const numbers: number[] = []
50 for (let y = 0; y < rows * 2; y += 2) {
51 for (let x = 0; x < columns * 2; x += 2) {
52 const mask = (lit(x, y) ? 1 : 0) | (lit(x + 1, y) ? 2 : 0) | (lit(x, y + 1) ? 4 : 0) | (lit(x + 1, y + 1) ? 8 : 0)
53 numbers.push(QUADRANT[mask].codePointAt(0) ?? 32, on, off)
54 }
55 }
56 return { columns, rows, cells: base64(new Uint8Array(Uint32Array.from(numbers).buffer)) }
57}
58
59const icon = quadrants((x, y) => CAMERA_ICON[y]?.[x] === '#', 8, 3, 0xffffff, SIGN_RGB)
60const stripe = (columns: number) =>
61 quadrants((x, y) => Math.floor((x + y) / 4) % 2 === 0, columns, 1, STRIPE_RGB, STRIPE_GAP_RGB)
62
63const segments = (command: string) => command.split(/&&|\|\||[;|\n]/).map((part) => part.trim().split(/\s+/))
64
65const gitSubcommand = (words: string[]) => {
66 const git = words.indexOf('git')
67 if (git < 0) return -1
68 for (let i = git + 1; i < words.length; i++) {
69 if (words[i] === '-C' || words[i] === '-c') i++
70 else if (!words[i].startsWith('-')) return i
71 }
72 return -1
73}
74
75const isForcePush = (command: string) =>
76 segments(command).some((words) => {
77 const push = gitSubcommand(words)
78 if (push < 0 || words[push] !== 'push') return false
79 return words
80 .slice(push + 1)
81 .some(
82 (word) =>
83 /^--force(?:-with-lease|-if-includes)?(?:=|$)/.test(word) || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(word) || /^\+\S+/.test(word),
84 )
85 })
86
87const isRecursiveForceRm = (command: string) =>
88 segments(command).some((words) => {
89 const rm = words.findIndex((word) => word === 'rm' || word.endsWith('/rm'))
90 if (rm < 0) return false
91 const flags = words.slice(rm + 1).filter((word) => word.startsWith('-'))
92 const has = (short: RegExp, long: string) => flags.some((flag) => flag === long || (/^-[a-zA-Z]+$/.test(flag) && short.test(flag)))
93 return has(/[rR]/, '--recursive') && has(/f/, '--force')
94 })
95
96const DATABASE =
97 /\b(?:drop\s+(?:table|database|schema)|truncate\s+(?:table\s+)?\w)|\bdelete\s+from\s+[\w."`]+\s*(?:;|$|["'])|\bprisma\s+(?:migrate\s+reset|db\s+push\s+--force-reset)|\brails\s+db:(?:drop|reset)|\bdropdb\b/i
98
99const DISCARD =
100 /\bgit\b(?:\s+-C\s+\S+)?\s+(?:reset\s+(?:\S+\s+)*--hard|clean\s+-[a-zA-Z]*f|checkout\s+(?:-f\s+)?(?:--\s+)?\.(?:\s|$)|restore\s+(?:--\S+\s+)*\.(?:\s|$)|stash\s+(?:drop|clear))/
101
102const DEPLOY =
103 /(?:^|\s)--prod(?:\s|=|$)|\bterraform\s+(?:apply|destroy)\b|\b(?:npm|pnpm|yarn)\s+publish\b|\b(?:wrangler|fly|flyctl|firebase)\s+deploy\b|\bkubectl\b[^\n;&|]*\b(?:apply|delete|rollout)\b[^\n;&|]*\bprod/
104
105const CAMERAS: readonly Camera[] = [
106 { label: '역주행 감지', note: '원격 브랜치 히스토리를 덮어씁니다', say: '역주행 차량이 감지되었습니다.', matches: isForcePush },
107 { label: '낭떠러지 주의', note: '파일을 휴지통 없이 통째로 지웁니다', say: '전방 낭떠러지 주의.', matches: isRecursiveForceRm },
108 { label: '어린이 보호구역', note: '데이터베이스의 데이터를 지웁니다', say: '어린이 보호구역입니다. 속도를 줄이세요.', matches: (c) => DATABASE.test(c) },
109 { label: '후진 주의', note: '커밋하지 않은 변경을 버립니다', say: '후진 주의.', matches: (c) => DISCARD.test(c) },
110 { label: '고속도로 진입', note: '운영 환경에 바로 반영합니다', say: '고속도로에 진입합니다.', matches: (c) => DEPLOY.test(c) },
111]
112
113const pad = (n: number) => String(n).padStart(2, '0')
114const clockOf = (ms: number) => {
115 const d = new Date(ms)
116 return `${pad(d.getHours())}:${pad(d.getMinutes())}`
117}
118const short = (command: string) => (command.length > 72 ? `${command.slice(0, 71)}…` : command)
119
120export const register: Register = (on, options) => {
121 const mode = options.mode === 'block' ? 'block' : 'ask'
122 const sound = options.sound !== false
123 const voice = options.voice !== false
124 const voiceName =
125 typeof options.voice_name === 'string' && options.voice_name.trim() ? options.voice_name.trim() : 'Yuna'
126
127 const caught: Catch[] = []
128 let flash: Catch | undefined
129 let flashTimer: { cancel: () => void } | undefined
130
131 on('session.start', async ($, e, next) => {
132 const result = await next(e)
133 await $.command.register({ name: 'speedcam', description: '과속 단속 카메라: 이번 세션 단속 기록', immediate: true })
134 return result
135 })
136
137 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
138 const camera = CAMERAS.find((candidate) => candidate.matches(e.command))
139 if (!camera) return next(e)
140
141 const record: Catch = { at: await $.clock.now(), label: camera.label, command: e.command }
142 caught.push(record)
143 if (caught.length > HISTORY) caught.shift()
144 flashTimer?.cancel()
145 flash = record
146 $.ui.invalidate('ui.render')
147 $.clock.after(STROBE_MS, () => $.ui.invalidate('ui.render'))
148
149 if (sound) $.audio.play({ asset: SHUTTER }).catch(() => {})
150 if (voice) {
151 const line = camera.say
152 $.clock.after(sound ? SPEAK_AFTER_SHUTTER_MS : 0, () => $.audio.speak(line, { voice: voiceName }).catch(() => {}))
153 }
154
155 let answer = STOP
156 if (mode === 'ask') {
157 try {
158 answer = await $.ui.ask(`📸 찰칵! [${camera.label}] ${camera.note}. 그대로 갈까요?\n$ ${short(e.command)}`, [GO, STOP])
159 } catch {}
160 }
161 record.decision = answer === GO ? '통과' : '정차'
162 flashTimer = $.clock.after(FLASH_MS, () => {
163 flash = undefined
164 $.ui.invalidate('ui.render')
165 })
166 $.ui.invalidate('ui.render')
167
168 if (record.decision === '통과') return next(e)
169 return {
170 deny:
171 `과속 단속 카메라(taxi-speedcam)에서 차를 세웠어요: [${camera.label}] ${camera.note}. ` +
172 '이 명령은 실행되지 않았습니다. 같은 명령을 다시 시도하지 말고, 더 안전한 방법을 제안하거나 사용자에게 먼저 물어보세요.',
173 }
174 })
175
176 on('command.run', { command: 'speedcam' }, async () => {
177 if (caught.length === 0) return { text: '📸 이번 세션 단속 기록이 없어요. 안전 운전 중이에요.' }
178 const passed = caught.filter((record) => record.decision === '통과').length
179 return {
180 text: [
181 `📸 단속 ${caught.length}회 · 통과 ${passed} · 정차 ${caught.length - passed}`,
182 ...caught.map((record) => `${clockOf(record.at)} [${record.label}] ${record.decision ?? '대기'} · ${short(record.command)}`),
183 ].join('\n'),
184 }
185 })
186
187 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
188 if (e.props.hasSurvey || !flash) return next(e)
189 const elements = $.ui.resolve(e)
190 const { Box, Text } = elements
191 const record = flash
192 const sign = CAMERAS.find((candidate) => candidate.label === record.label)
193 const isStrobe = (await $.clock.now()) - record.at < STROBE_MS
194
195 const panel = () => {
196 if (!('Raster' in elements) || e.props.bodyColumns < PANEL_WIDTH || e.props.maxRows < PANEL_ROWS) return undefined
197 const { Raster } = elements
198 const ink = isStrobe ? INK : WHITE
199 const status = !record.decision
200 ? Text({ bold: true, color: INK, backgroundColor: YELLOW, children: [' 단속 중 '] })
201 : record.decision === '통과'
202 ? Text({ bold: true, color: INK, backgroundColor: GREEN, children: [' 통과했어요 '] })
203 : Text({ bold: true, color: WHITE, backgroundColor: RED, children: [' 정차했어요 '] })
204 return Box({
205 width: PANEL_WIDTH,
206 flexDirection: 'column',
207 backgroundColor: isStrobe ? STROBE : SCREEN,
208 children: [
209 Raster({ key: 'stripe-top', ...stripe(PANEL_WIDTH) }),
210 Box({
211 flexDirection: 'row',
212 columnGap: 2,
213 paddingX: 1,
214 children: [
215 Box({ backgroundColor: SIGN, paddingX: 1, children: [Raster({ key: 'camera', ...icon })] }),
216 Box({
217 flexDirection: 'column',
218 flexGrow: 1,
219 children: [
220 Box({
221 flexDirection: 'row',
222 justifyContent: 'space-between',
223 children: [
224 Box({
225 flexDirection: 'row',
226 columnGap: 1,
227 children: [Text({ bold: true, color: WHITE, backgroundColor: SIGN, children: [` 📸 ${record.label} `] }), status],
228 }),
229 Text({ bold: true, color: isStrobe ? INK : RED, children: ['SPEED CAMERA'] }),
230 ],
231 }),
232 Text({ bold: true, color: ink, children: [sign?.note ?? ''] }),
233 Text({ color: isStrobe ? INK : DIM, wrap: 'truncate-end', children: [`$ ${short(record.command)}`] }),
234 ],
235 }),
236 ],
237 }),
238 Raster({ key: 'stripe-bottom', ...stripe(PANEL_WIDTH) }),
239 ],
240 })
241 }
242
243 const line = () => {
244 const status = record.decision
245 ? Text({ bold: true, color: record.decision === '통과' ? GREEN : RED, children: [record.decision === '통과' ? '통과했어요' : '정차했어요'] })
246 : Text({ bold: true, color: RED, children: ['단속 중'] })
247 return Box({
248 flexDirection: 'row',
249 columnGap: 1,
250 children: [
251 Text({ bold: true, color: RED, children: [`📸 [${record.label}]`] }),
252 status,
253 Text({ dimColor: true, wrap: 'truncate-end', children: [short(record.command)] }),
254 ],
255 })
256 }
257
258 const camera = panel() ?? line()
259 const below = await next(e)
260 return below ? Box({ flexDirection: 'column', children: [camera, below] }) : camera
261 })
262}
263