Claude의 할 일 목록을 내비게이션처럼: 진행 경로·다음 안내 표시, 계획이 바뀌면 '경로를 재탐색합니다', 끝나면 도착 안내

클로드 코드를 택시로 개조하는 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 342 lines1import type { Register } from 'claude-code'
2
3type Status = 'pending' | 'in_progress' | 'completed'
4type Stop = { id: string; subject: string; status: Status; activeForm?: string }
5type Cue = { say?: string; ding?: boolean; reroute?: boolean }
6type Line = { text: string; color?: string; bold?: boolean; dim?: boolean }
7
8const DING = 'sounds/ding.wav'
9const TODO_TOOLS_HINT =
10 'Opus 4.8·Sonnet 5·Fable 5 이후 모델은 할 일 도구가 기본으로 꺼져 있어요. CLAUDE_CODE_ENABLE_TODO_TOOLS=1 claude 로 시작하면 내비가 경로를 그립니다.'
11const REROUTE_QUIET_MS = 5_000
12const SPEAK_AFTER_DING_MS = 400
13const MAX_NODES = 12
14const GREEN = '#34c759'
15const YELLOW = '#ffcc00'
16
17const PANEL_WIDTH = 70
18const PANEL_ROWS = 3
19const TILE_WIDTH = 8
20const SCREEN = '#00005f'
21const TILE = '#005fd7'
22const TILE_RGB = 0x005fd7
23const ARRIVE_TILE = '#2c2c2e'
24const ARRIVE_TILE_RGB = 0x2c2c2e
25const ROUTE_DONE = '#5fafff'
26const ROUTE_LEFT = '#5f5f87'
27const LABEL = '#87afff'
28const WHITE = '#ffffff'
29
30const ARROWS = {
31 straight: ['.....##.....', '....####....', '...######...', '..########..', '....####....', '....####....'],
32 reroute: ['...######...', '..##....##..', '..##....##..', '######..##..', '.####...##..', '..##....##..'],
33} as const
34
35const QUADRANT = [' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█']
36
37const base64 = (bytes: Uint8Array) => {
38 const native = bytes as Uint8Array & { toBase64?: () => string }
39 if (native.toBase64) return native.toBase64()
40 let binary = ''
41 for (const byte of bytes) binary += String.fromCharCode(byte)
42 return btoa(binary)
43}
44
45const quadrants = (lit: (x: number, y: number) => boolean, columns: number, rows: number, on: number, off: number) => {
46 const numbers: number[] = []
47 for (let y = 0; y < rows * 2; y += 2) {
48 for (let x = 0; x < columns * 2; x += 2) {
49 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)
50 numbers.push(QUADRANT[mask].codePointAt(0) ?? 32, on, off)
51 }
52 }
53 return { columns, rows, cells: base64(new Uint8Array(Uint32Array.from(numbers).buffer)) }
54}
55
56const arrow = (kind: keyof typeof ARROWS) => quadrants((x, y) => ARROWS[kind][y]?.[x] === '#', 6, 3, 0xffffff, TILE_RGB)
57const flag = quadrants((x, y) => x === 0 || (x >= 2 && y < 4 && (Math.floor(x / 2) + Math.floor(y / 2)) % 2 === 0), 6, 3, 0xffffff, ARRIVE_TILE_RGB)
58
59const route = (stops: Stop[], width: number, isArrived: boolean): Line[] => {
60 const focus = isArrived ? stops.length : Math.max(0, stops.findIndex((stop) => stop.status !== 'completed'))
61 const start = Math.min(Math.max(0, focus - 4), Math.max(0, stops.length - MAX_NODES))
62 const shown = stops.slice(start, start + MAX_NODES)
63 const nodes = shown.map((stop, i) =>
64 isArrived || stop.status === 'completed'
65 ? { text: '●', color: GREEN, width: 1 }
66 : start + i === focus
67 ? { text: '🚕', width: 2 }
68 : { text: '○', color: ROUTE_LEFT, width: 1 },
69 )
70 const finish = isArrived ? { text: '🏁', width: 2 } : { text: '◎', color: WHITE, width: 1 }
71 const fixed = nodes.reduce((sum, node) => sum + node.width, 0) + finish.width
72 const segment = Math.max(1, Math.min(16, Math.floor((width - fixed) / Math.max(1, shown.length))))
73 const parts: Line[] = []
74 nodes.forEach((node, i) => {
75 const isDone = isArrived || start + i < focus
76 parts.push(node, isDone ? { text: '━'.repeat(segment), color: ROUTE_DONE } : { text: '─'.repeat(segment), color: ROUTE_LEFT })
77 })
78 parts.push(finish)
79 return parts
80}
81
82const isStatus = (value: unknown): value is Status =>
83 value === 'pending' || value === 'in_progress' || value === 'completed'
84
85const minutes = (ms: number) => `${Math.max(1, Math.round(ms / 60_000))}분`
86
87const road = (stops: Stop[]): Line[] => {
88 const focus = Math.max(0, stops.findIndex((stop) => stop.status !== 'completed'))
89 const start = Math.min(Math.max(0, focus - 4), Math.max(0, stops.length - MAX_NODES))
90 const shown = stops.slice(start, start + MAX_NODES)
91 const parts: Line[] = start > 0 ? [{ text: '…━━', color: GREEN }] : []
92 shown.forEach((stop, i) => {
93 if (i > 0) parts.push(stop.status === 'pending' ? { text: '──', dim: true } : { text: '━━', color: GREEN })
94 parts.push(
95 stop.status === 'completed'
96 ? { text: '●', color: GREEN }
97 : stop.status === 'in_progress'
98 ? { text: '◉', color: YELLOW, bold: true }
99 : { text: '○', dim: true },
100 )
101 })
102 if (start + MAX_NODES < stops.length) parts.push({ text: '──…', dim: true })
103 return parts
104}
105
106export const register: Register = (on, options) => {
107 const voice = options.voice !== false
108 const voiceName =
109 typeof options.voice_name === 'string' && options.voice_name.trim() ? options.voice_name.trim() : 'Yuna'
110 const sound = options.sound !== false
111
112 let stops: Stop[] = []
113 let started = false
114 let startedAt = 0
115 let lastReroute = Number.NEGATIVE_INFINITY
116 let arrived: { count: number; ms: number } | undefined
117 const retired = new Set<string>()
118
119 const finished = (list: Stop[]) => list.filter((stop) => stop.status === 'completed').length
120
121 const arrive = (now: number): Cue => {
122 arrived = { count: stops.length, ms: now - startedAt }
123 for (const stop of stops) retired.add(stop.id)
124 stops = []
125 started = false
126 return { say: '목적지에 도착했습니다. 안내를 종료합니다.', ding: true }
127 }
128
129 const apply = (next: Stop[], now: number): Cue => {
130 const before = stops
131 stops = next
132 if (stops.length === 0) {
133 started = false
134 return {}
135 }
136 const isAllDone = finished(stops) === stops.length
137 if (!started) {
138 if (stops.every((stop) => stop.status === 'pending')) return {}
139 started = true
140 startedAt = now
141 arrived = undefined
142 return isAllDone ? arrive(now) : { say: `경로 안내를 시작합니다. 경유지 ${stops.length}곳입니다.` }
143 }
144 if (isAllDone) return arrive(now)
145 const ids = new Set(before.map((stop) => stop.id))
146 const isChanged = stops.length !== before.length || stops.some((stop) => !ids.has(stop.id))
147 if (isChanged && now - lastReroute >= REROUTE_QUIET_MS) {
148 lastReroute = now
149 return { say: `경로를 재탐색합니다. 경유지 ${stops.length}곳입니다.`, reroute: true }
150 }
151 return finished(stops) > finished(before) ? { ding: true } : {}
152 }
153
154 const report = (hasTodoTools: boolean) => {
155 if (stops.length === 0) {
156 if (arrived) return `🏁 목적지 도착 · 경유지 ${arrived.count}곳 · 소요 ${minutes(arrived.ms)}`
157 const empty = '안내 중인 경로가 없어요. Claude가 할 일 목록을 만들면 여기에 나타나요.'
158 return hasTodoTools ? empty : `${empty}\n${TODO_TOOLS_HINT}`
159 }
160 const mark = { completed: '✓', in_progress: '▶', pending: '·' } as const
161 return [`🧭 경로 안내 ${finished(stops)}/${stops.length}`, ...stops.map((stop) => `${mark[stop.status]} ${stop.subject}`)].join('\n')
162 }
163
164 on('session.start', async ($, e, next) => {
165 const result = await next(e)
166 await $.command.register({ name: 'navi', description: '택시 내비: 지금 경로(할 일 목록)와 진행 상황', immediate: true })
167 return result
168 })
169
170 on('turn.start', async ($, e, next) => {
171 if (arrived) {
172 arrived = undefined
173 $.ui.invalidate('ui.render')
174 }
175 return next(e)
176 })
177
178 on('tool.call', { tool: ['TaskCreate', 'TaskUpdate', 'TaskList', 'TodoWrite'] }, async ($, e, next) => {
179 const out = await next(e)
180 if (e.agentId || out.deny || out.isError) return out
181
182 let updated: Stop[] | undefined
183 switch (e.tool) {
184 case 'TaskCreate': {
185 const id = (out.result as { task?: { id?: string } } | undefined)?.task?.id
186 if (id) updated = [...stops, { id, subject: e.subject, status: 'pending', activeForm: e.activeForm }]
187 break
188 }
189 case 'TaskUpdate': {
190 if (e.status === 'deleted') {
191 updated = stops.filter((stop) => stop.id !== e.taskId)
192 break
193 }
194 const status = e.status
195 updated = stops.map((stop) =>
196 stop.id === e.taskId
197 ? {
198 ...stop,
199 ...(isStatus(status) ? { status } : {}),
200 ...(e.subject ? { subject: e.subject } : {}),
201 ...(e.activeForm ? { activeForm: e.activeForm } : {}),
202 }
203 : stop,
204 )
205 break
206 }
207 case 'TaskList': {
208 const tasks = (out.result as { tasks?: { id: string; subject: string; status: string }[] } | undefined)?.tasks
209 if (Array.isArray(tasks)) {
210 updated = tasks
211 .filter((task) => !retired.has(task.id) && isStatus(task.status))
212 .map((task) => ({
213 id: task.id,
214 subject: task.subject,
215 status: task.status as Status,
216 activeForm: stops.find((stop) => stop.id === task.id)?.activeForm,
217 }))
218 }
219 break
220 }
221 case 'TodoWrite': {
222 updated = e.todos
223 .map((todo) => ({ id: `todo:${todo.content}`, subject: todo.content, status: todo.status, activeForm: todo.activeForm }))
224 .filter((stop) => !retired.has(stop.id))
225 break
226 }
227 }
228 if (!updated) return out
229
230 const cue = apply(updated, await $.clock.now())
231 if (cue.reroute) $.clock.after(REROUTE_QUIET_MS, () => $.ui.invalidate('ui.render'))
232 if (cue.ding && sound) $.audio.play({ asset: DING }).catch(() => {})
233 if (cue.say && voice) {
234 const text = cue.say
235 if (cue.ding && sound) $.clock.after(SPEAK_AFTER_DING_MS, () => $.audio.speak(text, { voice: voiceName }).catch(() => {}))
236 else $.audio.speak(text, { voice: voiceName }).catch(() => {})
237 }
238 $.ui.invalidate('ui.render')
239 return out
240 })
241
242 on('command.run', { command: 'navi' }, async ($) => {
243 const flag = await $.env.get('CLAUDE_CODE_ENABLE_TODO_TOOLS')
244 return { text: report(/^(1|true)$/i.test(flag ?? '')) }
245 })
246
247 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
248 if (e.props.hasSurvey || (stops.length === 0 && !arrived)) return next(e)
249 const elements = $.ui.resolve(e)
250 const { Box, Text } = elements
251 const text = (line: Line) =>
252 Text({
253 ...(line.color ? { color: line.color } : {}),
254 ...(line.bold ? { bold: true } : {}),
255 ...(line.dim ? { dimColor: true } : {}),
256 children: [line.text],
257 })
258
259 const current = stops.find((stop) => stop.status === 'in_progress')
260 const upcoming = stops.find((stop) => stop.status === 'pending')
261 const guide = [
262 current ? `지금 ${current.activeForm || current.subject}` : undefined,
263 upcoming ? `다음 안내 ${upcoming.subject}` : undefined,
264 ]
265 .filter((part) => part !== undefined)
266 .join(' · ')
267
268 const now = await $.clock.now()
269 const panel = () => {
270 if (!('Raster' in elements) || e.props.bodyColumns < PANEL_WIDTH || e.props.maxRows < PANEL_ROWS) return undefined
271 const { Raster } = elements
272 const trip = stops.length === 0 ? arrived : undefined
273 const isArrived = trip !== undefined
274 const isRerouting = !isArrived && now - lastReroute < REROUTE_QUIET_MS
275 const list = isArrived ? [] : stops
276 const row = (left: ReturnType<typeof Text>[], right: ReturnType<typeof Text>) =>
277 Box({
278 flexDirection: 'row',
279 justifyContent: 'space-between',
280 children: [Box({ flexDirection: 'row', columnGap: 1, flexShrink: 1, children: left }), right],
281 })
282 const info = trip
283 ? [
284 row([Text({ bold: true, color: GREEN, children: ['목적지에 도착했습니다'] })], Text({ bold: true, color: WHITE, children: ['안내 종료'] })),
285 Box({ flexDirection: 'row', children: route(Array.from({ length: trip.count }, (_, i) => ({ id: String(i), subject: '', status: 'completed' as const })), PANEL_WIDTH - TILE_WIDTH - 3, true).map(text) }),
286 Text({ color: LABEL, children: [`경유지 ${trip.count}곳 · 소요 ${minutes(trip.ms)}`] }),
287 ]
288 : [
289 row(
290 [
291 Text({ color: isRerouting ? YELLOW : LABEL, bold: isRerouting, children: [isRerouting ? '경로 재탐색' : '다음 안내'] }),
292 Text({ bold: true, color: WHITE, wrap: 'truncate-end', children: [upcoming?.subject ?? '목적지'] }),
293 ],
294 Text({ bold: true, color: WHITE, children: [`경유지 ${finished(list)}/${list.length}`] }),
295 ),
296 Box({ flexDirection: 'row', children: route(list, PANEL_WIDTH - TILE_WIDTH - 3, false).map(text) }),
297 row(
298 [Text({ color: GREEN, wrap: 'truncate-end', children: [current ? `지금 ${current.activeForm || current.subject}` : '경로를 계산하고 있어요'] })],
299 Text({ color: LABEL, children: [`경과 ${minutes(startedAt ? now - startedAt : 0)}`] }),
300 ),
301 ]
302 return Box({
303 width: PANEL_WIDTH,
304 flexDirection: 'row',
305 columnGap: 1,
306 backgroundColor: SCREEN,
307 children: [
308 Box({
309 width: TILE_WIDTH,
310 paddingX: 1,
311 backgroundColor: isArrived ? ARRIVE_TILE : TILE,
312 children: [Raster({ key: 'sign', ...(isArrived ? flag : arrow(isRerouting ? 'reroute' : 'straight')) })],
313 }),
314 Box({ flexDirection: 'column', flexGrow: 1, paddingRight: 1, children: info }),
315 ],
316 })
317 }
318
319 const navi =
320 panel() ??
321 (stops.length === 0 && arrived
322 ? Text({ color: GREEN, bold: true, children: [`🏁 목적지에 도착했습니다 · 경유지 ${arrived.count}곳 · 소요 ${minutes(arrived.ms)}`] })
323 : Box({
324 flexDirection: 'column',
325 children: [
326 Box({
327 flexDirection: 'row',
328 children: [
329 Text({ children: ['🧭 '] }),
330 ...road(stops).map(text),
331 Text({ bold: true, children: [` ${finished(stops)}/${stops.length}`] }),
332 ],
333 }),
334 Text({ wrap: 'truncate-end', children: [guide || '경로를 계산하고 있어요'] }),
335 ],
336 }))
337
338 const below = await next(e)
339 return below ? Box({ flexDirection: 'column', children: [navi, below] }) : navi
340 })
341}
342