프롬프트 위 택시 미터기 패널: 세션 요금(API 정가 환산)과 방금 요청 요금, 모델 이름·차종·할증 키, 5시간·주간 한도와 리셋 시각, /meter·/receipt

클로드 코드를 택시로 개조하는 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 776 lines1import type { Register, SessionMeasureInput } from 'claude-code'
2
3type Tier = '일반' | '모범' | '블랙'
4type Limit = { kind: string; percentUsed: number; resetsAt?: string }
5type Ledger = { since: string; totalUsd: number; days: Record<string, number> }
6type Line = { text: string; color?: string; bold?: boolean; dim?: boolean }
7type State = '빈차' | '주행' | '대기' | '하차'
8type View = { usd: number; prompt: number; limits: Limit[]; model?: string; longHaul: boolean; isWorking: boolean; isDemo: boolean; speed: number; context?: number }
9type Demo = { elapsed: number; resetsAt: string; ticks: number; warned: number }
10
11const RECEIPT = 'taxi-receipt'
12const TICK = 'sounds/tick.wav'
13const CHIME = 'sounds/chime.wav'
14const LEDGER = 'ledger'
15const TRACK = 10
16const LONG_HAUL_PERCENT = 50
17const LEDGER_DAYS = 90
18const MAX_REQUESTS = 50
19const RECEIPT_REQUESTS = 5
20const DAY_MS = 86_400_000
21
22const RED = '#ff3b30'
23const YELLOW = '#ffcc00'
24const GREEN = '#34c759'
25const BAR = 10
26
27const PANEL_WIDTH = 70
28const PANEL_ROWS = 9
29const SCREEN = '#121212'
30const SCREEN_RGB = 0x121212
31const BEZEL = '#5f656d'
32const DIGIT_RGB = 0xf2f6ff
33const STOP_RGB = 0xff3b30
34const HORSE_RGB = 0x5ee35a
35const HORSE_IDLE_RGB = 0x2f7a33
36const BRAND = '#ffd60a'
37const PURPLE = '#d07cff'
38const BLUE = '#4d8dff'
39const LCD_GREEN = '#b6f24a'
40const LEGEND = '#9aa0a6'
41const KEY_TEXT = '#111111'
42
43const KEYS: readonly { label: string; on: string; off: string }[] = [
44 { label: '빈차', on: '#f4f4f4', off: '#3b3d40' },
45 { label: '주행', on: '#bdb8ff', off: '#2f2d4d' },
46 { label: '할증', on: '#ff5c8a', off: '#4a1d2a' },
47 { label: '복합', on: '#ffd60a', off: '#463b0a' },
48 { label: '지불', on: '#8fe04c', off: '#24401a' },
49]
50
51const DEMO_STEP_MS = 100
52const DEMO_BOARD_MS = 600
53const DEMO_OPUS_MS = 1_500
54const DEMO_NEAR_MS = 7_000
55const DEMO_ARRIVE_MS = 10_500
56const DEMO_USD = 34.5
57const DEMO_TICK_WON = 2_000
58const DEMO_FIVE_HOUR_FROM = 41
59const DEMO_WEEK_FROM = 23
60const DEMO_WEEK_TO = 31
61const DEMO_CONTEXT_FROM = 20
62const DEMO_CONTEXT_TO = 85
63
64const FONT: Record<string, readonly string[]> = {
65 '0': ['.#######.', '##.....##', '##.....##', '##.....##', '##.....##', '##.....##', '##.....##', '.#######.'],
66 '1': ['...###...', '.#####...', '....##...', '....##...', '....##...', '....##...', '....##...', '.#######.'],
67 '2': ['.#######.', '##.....##', '.......##', '.....###.', '...###...', '.###.....', '##.......', '#########'],
68 '3': ['.#######.', '##.....##', '.......##', '...#####.', '.......##', '.......##', '##.....##', '.#######.'],
69 '4': ['.....###.', '....####.', '...##.##.', '..##..##.', '.##...##.', '#########', '......##.', '......##.'],
70 '5': ['#########', '##.......', '##.......', '########.', '.......##', '.......##', '##.....##', '.#######.'],
71 '6': ['..######.', '.##......', '##.......', '########.', '##.....##', '##.....##', '##.....##', '.#######.'],
72 '7': ['#########', '.......##', '......##.', '.....##..', '....##...', '...##....', '...##....', '...##....'],
73 '8': ['.#######.', '##.....##', '##.....##', '.#######.', '##.....##', '##.....##', '##.....##', '.#######.'],
74 '9': ['.#######.', '##.....##', '##.....##', '##.....##', '.########', '.......##', '......##.', '.######..'],
75 ',': ['...', '...', '...', '...', '...', '.##', '.##', '##.'],
76}
77
78const HORSE: readonly (readonly string[])[] = [
79 [
80 '..................##..',
81 '................######',
82 '...............####.##',
83 '##............####....',
84 '.###############......',
85 '..#############.......',
86 '..##.##.....##.##.....',
87 '.##...##...##...##....',
88 ],
89 [
90 '..................##..',
91 '................######',
92 '...............####.##',
93 '.#............####....',
94 '################......',
95 '..#############.......',
96 '...##.##...##.##......',
97 '...##.##...##.##......',
98 ],
99].map((rows) => rows.map((row) => [...row].reverse().join('')))
100
101const QUADRANT = [' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█']
102
103const TIER_COLOR: Record<Tier, string> = { 일반: 'gray', 모범: YELLOW, 블랙: 'white' }
104
105const STATE_LAMP: Record<State, Line> = {
106 하차: { text: '[하차]', color: RED, bold: true },
107 주행: { text: '[주행]', color: GREEN, bold: true },
108 빈차: { text: '[빈차]', color: RED, bold: true },
109 대기: { text: '[대기]', color: YELLOW },
110}
111
112const positive = (value: unknown, fallback: number) =>
113 typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback
114
115const comma = (n: number) => String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
116
117const man = (n: number) => (n >= 10_000 ? `${comma(n / 10_000)}만` : comma(n))
118
119const pad = (n: number) => String(n).padStart(2, '0')
120
121const bar = (percent: number) => {
122 const filled = Math.min(BAR, Math.max(0, Math.floor((percent / 100) * BAR)))
123 return '█'.repeat(filled) + '░'.repeat(BAR - filled)
124}
125
126const clockOfMs = (ms: number) => {
127 const d = new Date(ms)
128 return `${pad(d.getHours())}:${pad(d.getMinutes())}`
129}
130
131const clockOf = (iso?: string) => {
132 const ms = iso ? Date.parse(iso) : Number.NaN
133 return Number.isNaN(ms) ? undefined : clockOfMs(ms)
134}
135
136const dayKey = (ms: number) => {
137 const d = new Date(ms)
138 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
139}
140
141const dateTimeOf = (iso?: string) => {
142 const ms = iso ? Date.parse(iso) : Number.NaN
143 if (Number.isNaN(ms)) return undefined
144 const d = new Date(ms)
145 return `${d.getMonth() + 1}/${d.getDate()} ${clockOfMs(ms)}`
146}
147
148const monthDay = (iso: string) => {
149 const ms = Date.parse(iso)
150 if (Number.isNaN(ms)) return '처음'
151 const d = new Date(ms)
152 return `${d.getMonth() + 1}/${d.getDate()}`
153}
154
155const duration = (ms: number) => {
156 const minutes = Math.max(0, Math.round(ms / 60_000))
157 const hours = Math.floor(minutes / 60)
158 return hours > 0 ? `${hours}시간 ${minutes % 60}분` : `${minutes}분`
159}
160
161const tierOf = (model?: string): Tier | undefined => {
162 if (!model) return undefined
163 if (/fable/i.test(model)) return '블랙'
164 if (/opus/i.test(model)) return '모범'
165 return '일반'
166}
167
168const modelName = (model?: string) => {
169 if (!model) return undefined
170 const match = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?!\d)/i.exec(model)
171 if (!match) return model.replace(/\[.*\]$/, '')
172 const [, family, major, minor] = match
173 return `${family[0].toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? `.${minor}` : ''}`
174}
175
176const isLedger = (value: unknown): value is Ledger =>
177 typeof value === 'object' &&
178 value !== null &&
179 typeof (value as Ledger).totalUsd === 'number' &&
180 typeof (value as Ledger).since === 'string' &&
181 typeof (value as Ledger).days === 'object'
182
183const credit = (ledger: Ledger, now: number, usd: number): Ledger => {
184 const day = dayKey(now)
185 const days = Object.fromEntries(
186 Object.entries({ ...ledger.days, [day]: (ledger.days[day] ?? 0) + usd })
187 .sort(([a], [b]) => a.localeCompare(b))
188 .slice(-LEDGER_DAYS),
189 )
190 return { since: ledger.since || new Date(now).toISOString(), totalUsd: ledger.totalUsd + usd, days }
191}
192
193const base64 = (bytes: Uint8Array) => {
194 const native = bytes as Uint8Array & { toBase64?: () => string }
195 if (native.toBase64) return native.toBase64()
196 let binary = ''
197 for (const byte of bytes) binary += String.fromCharCode(byte)
198 return btoa(binary)
199}
200
201const quadrants = (bitmap: readonly string[], on: number, off: number) => {
202 const width = Math.ceil(Math.max(...bitmap.map((row) => row.length)) / 2) * 2
203 const height = Math.ceil(bitmap.length / 2) * 2
204 const lit = (x: number, y: number) => bitmap[y]?.[x] === '#'
205 const numbers: number[] = []
206 for (let y = 0; y < height; y += 2) {
207 for (let x = 0; x < width; x += 2) {
208 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)
209 numbers.push(QUADRANT[mask].codePointAt(0) ?? 32, on, off)
210 }
211 }
212 return { columns: width / 2, rows: height / 2, cells: base64(new Uint8Array(Uint32Array.from(numbers).buffer)) }
213}
214
215const fareBitmap = (text: string) => {
216 const rows = Array.from({ length: 8 }, () => '')
217 for (const char of text) {
218 const glyph = FONT[char]
219 if (!glyph) continue
220 glyph.forEach((row, y) => (rows[y] += `${row}.`))
221 }
222 return rows
223}
224
225export const register: Register = (on, options) => {
226 const rate = positive(options.krw_per_usd, 1400)
227 const style = options.style === 'compact' ? 'compact' : 'led'
228 const sound = options.sound !== false
229 const tickWon = positive(options.tick_won, 1000)
230
231 let startedAt = 0
232 let lastCostUsd = 0
233 let sessionUsd = 0
234 let requests: number[] = []
235 let limits: Limit[] = []
236 let contextPercent: number | undefined
237 let model: string | undefined
238 let frame = 0
239 let ticks = 0
240 let warned = 0
241 let longHaul = false
242 let longHaulCount = 0
243 let peakFiveHour = 0
244 let tokens = 0
245 let freshTokens = 0
246 let cachedTokens = 0
247 let speed = 0
248 const tokensByTier: Record<Tier, number> = { 일반: 0, 모범: 0, 블랙: 0 }
249 let ledger: Ledger = { since: '', totalUsd: 0, days: {} }
250 let timer: { cancel: () => void } | undefined
251 let demo: Demo | undefined
252 let demoTimer: { cancel: () => void } | undefined
253
254 const fare = (usd: number) => Math.round((usd * rate) / 10) * 10
255 const won = (usd: number) => `₩${comma(fare(usd))}`
256 const limitOf = (kind: string) => limits.find((limit) => limit.kind === kind)
257
258 const recentUsd = (now: number, days: number) => {
259 let sum = 0
260 for (let i = 0; i < days; i++) sum += ledger.days[dayKey(now - i * DAY_MS)] ?? 0
261 return sum
262 }
263
264 const promptUsd = () => requests[requests.length - 1] ?? 0
265
266 const realView = (isWorking: boolean): View => ({
267 usd: sessionUsd,
268 prompt: promptUsd(),
269 limits,
270 model,
271 longHaul,
272 isWorking,
273 isDemo: false,
274 speed: isWorking ? speed : 0,
275 context: contextPercent,
276 })
277
278 const demoView = (ride: Demo): View => {
279 const between = (from: number, to: number) => Math.min(1, Math.max(0, (ride.elapsed - from) / (to - from)))
280 const progress = between(DEMO_BOARD_MS, DEMO_ARRIVE_MS)
281 const isArrived = ride.elapsed >= DEMO_ARRIVE_MS
282 const fiveHour = isArrived
283 ? 100
284 : ride.elapsed <= DEMO_NEAR_MS
285 ? DEMO_FIVE_HOUR_FROM + (90 - DEMO_FIVE_HOUR_FROM) * between(DEMO_BOARD_MS, DEMO_NEAR_MS)
286 : 90 + 9 * between(DEMO_NEAR_MS, DEMO_ARRIVE_MS)
287 const isWorking = ride.elapsed >= DEMO_BOARD_MS && !isArrived
288 const context = DEMO_CONTEXT_FROM + (DEMO_CONTEXT_TO - DEMO_CONTEXT_FROM) * progress
289 return {
290 usd: DEMO_USD * progress,
291 prompt: 0,
292 limits: [
293 { kind: 'five_hour', percentUsed: Math.floor(fiveHour), resetsAt: ride.resetsAt },
294 { kind: 'seven_day', percentUsed: Math.floor(DEMO_WEEK_FROM + (DEMO_WEEK_TO - DEMO_WEEK_FROM) * progress) },
295 ],
296 model: ride.elapsed >= DEMO_OPUS_MS ? 'claude-opus-5-5' : undefined,
297 longHaul: context >= LONG_HAUL_PERCENT,
298 isWorking,
299 isDemo: true,
300 speed: isWorking ? 62 + 24 * Math.sin(ride.elapsed / 650) : 0,
301 context,
302 }
303 }
304
305 const endDemo = () => {
306 demoTimer?.cancel()
307 demoTimer = undefined
308 demo = undefined
309 }
310
311 const stateOf = (view: View): State => {
312 const isOut = view.limits.some((limit) => (limit.kind === 'five_hour' || limit.kind === 'seven_day') && limit.percentUsed >= 100)
313 if (isOut) return '하차'
314 if (view.isWorking) return '주행'
315 return view.usd === 0 ? '빈차' : '대기'
316 }
317
318 const status = (view: View): Line => STATE_LAMP[stateOf(view)]
319
320 const lamps = (view: View): Line[] => {
321 const tier = tierOf(view.model)
322 return [
323 status(view),
324 ...(tier ? [{ text: `[${tier}]`, color: TIER_COLOR[tier], bold: tier !== '일반' }] : []),
325 ...(view.longHaul ? [{ text: '[장거리]', color: 'cyan' }] : []),
326 ]
327 }
328
329 const limitLine = (view: View): Line => {
330 const find = (kind: string) => view.limits.find((limit) => limit.kind === kind)
331 const five = find('five_hour')
332 const week = find('seven_day')
333 const credit = find('spend_limit')
334 if (!five && !week && !credit) return { text: '한도는 첫 응답 뒤에 표시돼요', dim: true }
335 const reset = clockOf(five?.resetsAt)
336 if (five && five.percentUsed >= 100) {
337 return { text: `하차하셔야 합니다${reset ? ` · ${reset} 재승차` : ''}`, color: RED, bold: true }
338 }
339 if (week && week.percentUsed >= 100) {
340 const weekReset = dateTimeOf(week.resetsAt)
341 return { text: `하차하셔야 합니다 · 주간 한도${weekReset ? ` · ${weekReset} 재승차` : ''}`, color: RED, bold: true }
342 }
343 const text = [
344 five ? `5시간 ${bar(five.percentUsed)} ${five.percentUsed}%${reset ? ` · ${reset} 리셋` : ''}` : undefined,
345 week ? `주간 ${week.percentUsed}%` : undefined,
346 credit ? `크레딧 한도 ${credit.percentUsed}%` : undefined,
347 ]
348 .filter((part) => part !== undefined)
349 .join(' │ ')
350 if ((five && five.percentUsed >= 90) || (week && week.percentUsed >= 90)) {
351 return { text: `곧 목적지입니다 · ${text}`, color: YELLOW, bold: true }
352 }
353 return { text }
354 }
355
356 const track = () => {
357 const horse = TRACK - 1 - (frame % TRACK)
358 return Array.from({ length: TRACK }, (_, i) => (i === horse ? '🐎' : '·')).join('')
359 }
360
361 const report = (now: number) => {
362 const five = limitOf('five_hour')
363 const week = limitOf('seven_day')
364 return [
365 '🚕 클로드 택시 미터기',
366 `이번 승차 ${won(sessionUsd)} (API 정가 환산 $${sessionUsd.toFixed(2)} · 환율 ${comma(rate)}원)`,
367 `방금 요청 ${requests.length > 0 ? `+${won(promptUsd())}` : '-'} · 요청 ${requests.length}건`,
368 `오늘 ${won(recentUsd(now, 1))} · 최근 7일 ${won(recentUsd(now, 7))} · ${monthDay(ledger.since)}부터 ${won(ledger.totalUsd)}`,
369 five || week ? limitLine(realView(false)).text : '한도: 아직 응답이 없어요',
370 `차종 ${tierOf(model) ?? '-'}${model ? ` (${model})` : ''} · 컨텍스트 ${contextPercent === undefined ? '-' : `${contextPercent}%`}`,
371 ].join('\n')
372 }
373
374 const requestsLine = () => {
375 if (requests.length === 0) return '-'
376 const recent = requests.slice(-RECEIPT_REQUESTS).map((usd) => won(usd)).join(' · ')
377 return requests.length > RECEIPT_REQUESTS ? `${requests.length}건 · 최근 ${recent}` : `${requests.length}건 · ${recent}`
378 }
379
380 const shares = () => {
381 const total = tokens || 1
382 const parts = (Object.entries(tokensByTier) as [Tier, number][])
383 .filter(([, n]) => n > 0)
384 .map(([tier, n]) => `${tier} ${Math.round((n / total) * 100)}%`)
385 return parts.length > 0 ? parts.join(' · ') : '-'
386 }
387
388 on('session.start', async ($, e, next) => {
389 const result = await next(e)
390 await $.command.register({
391 name: 'meter',
392 description: '택시 미터기: 이번 승차(세션)·방금 요청·오늘·누적 요금과 한도 (reset은 미터기와 누적 초기화, demo는 촬영용 데모 주행)',
393 argumentHint: '[reset|demo]',
394 immediate: true,
395 })
396 await $.command.register({ name: 'receipt', description: '택시 영수증 열기', immediate: true })
397 const now = await $.clock.now()
398 const saved = await $.store.get(LEDGER)
399 ledger = isLedger(saved) ? saved : { since: new Date(now).toISOString(), totalUsd: 0, days: {} }
400 const usage = await $.session.usage()
401 startedAt = now
402 lastCostUsd = usage.cost?.usd ?? 0
403 limits = [...usage.rateLimits]
404 contextPercent = usage.context.percent
405 return result
406 })
407
408 on('classic.SessionStart', async ($, e, next) => {
409 if (e.model) model = e.model
410 if (e.source === 'clear') {
411 sessionUsd = 0
412 requests = []
413 ticks = 0
414 }
415 $.ui.invalidate('ui.render')
416 return next(e)
417 })
418
419 on('classic.PostModelSwitch', async ($, e, next) => {
420 model = e.to_model
421 const recache = fare(e.estimated_cache_write_usd ?? 0)
422 if (recache >= 10) {
423 $.ui.toast(`차종 변경: ${tierOf(e.to_model) ?? '-'} · 다음 요청에 재캐시 약 ₩${comma(recache)} 붙어요`, { timeoutMs: 8000 })
424 }
425 $.ui.invalidate('ui.render')
426 return next(e)
427 })
428
429 on('session.end', async ($, e, next) => {
430 timer?.cancel()
431 endDemo()
432 return next(e)
433 })
434
435 on('turn.start', async ($, e, next) => {
436 endDemo()
437 if (e.text) requests = [...requests, 0].slice(-MAX_REQUESTS)
438 timer?.cancel()
439 timer = $.clock.every(250, () => {
440 frame += 1
441 $.ui.invalidate('ui.render')
442 })
443 $.ui.invalidate('ui.render')
444 return next(e)
445 })
446
447 on('turn.complete', async ($, e, next) => {
448 const result = await next(e)
449 if (!(e as { agentId?: string }).agentId) {
450 timer?.cancel()
451 timer = undefined
452 $.ui.invalidate('ui.render')
453 }
454 return result
455 })
456
457 on('turn.step', async function* ($, e, next) {
458 if (!e.agentId) model = e.model
459 const stepStartedAt = await $.clock.now()
460 const result = yield* next(e)
461 const usage = result.usage
462 const seconds = ((await $.clock.now()) - stepStartedAt) / 1000
463 if (!e.agentId && usage?.output_tokens && seconds > 0) speed = usage.output_tokens / seconds
464 if (usage) {
465 const used =
466 (usage.input_tokens ?? 0) +
467 (usage.output_tokens ?? 0) +
468 (usage.cache_read_input_tokens ?? 0) +
469 (usage.cache_creation_input_tokens ?? 0)
470 tokens += used
471 cachedTokens += usage.cache_read_input_tokens ?? 0
472 freshTokens += used - (usage.cache_read_input_tokens ?? 0)
473 tokensByTier[tierOf(usage.model ?? e.model) ?? '일반'] += used
474 }
475 if (!e.agentId) {
476 const { delta, isTick, isChime } = absorb(await $.session.usage(), await $.clock.now())
477 if (delta > 0) {
478 const saved = await $.store.get(LEDGER)
479 ledger = credit(isLedger(saved) ? saved : ledger, await $.clock.now(), delta)
480 await $.store.set(LEDGER, ledger)
481 }
482 if (isTick && sound) $.audio.play({ asset: TICK }).catch(() => {})
483 if (isChime && sound) $.audio.play({ asset: CHIME }).catch(() => {})
484 $.ui.invalidate('ui.render')
485 }
486 return result
487 })
488
489 const absorb = (m: Pick<SessionMeasureInput, 'context' | 'rateLimits' | 'cost'>, now: number) => {
490 limits = [...m.rateLimits]
491 contextPercent = m.context.percent ?? contextPercent
492 const isLong = (contextPercent ?? 0) >= LONG_HAUL_PERCENT
493 if (isLong && !longHaul) longHaulCount += 1
494 longHaul = isLong
495
496 const five = limitOf('five_hour')
497 if (five) peakFiveHour = Math.max(peakFiveHour, five.percentUsed)
498
499 const costUsd = m.cost?.usd
500 const isNewRide = costUsd !== undefined && costUsd < lastCostUsd
501 const delta = costUsd === undefined ? 0 : isNewRide ? costUsd : costUsd - lastCostUsd
502 if (costUsd !== undefined) lastCostUsd = costUsd
503 if (isNewRide) {
504 sessionUsd = 0
505 requests = []
506 ticks = 0
507 startedAt = now
508 tokens = 0
509 tokensByTier.일반 = tokensByTier.모범 = tokensByTier.블랙 = 0
510 longHaulCount = 0
511 peakFiveHour = 0
512 }
513 sessionUsd += delta
514 if (delta > 0 && requests.length > 0) requests = [...requests.slice(0, -1), promptUsd() + delta]
515 const step = Math.floor(fare(sessionUsd) / tickWon)
516 const isTick = step > ticks
517 if (isTick) ticks = step
518
519 const used = Math.max(five?.percentUsed ?? 0, limitOf('seven_day')?.percentUsed ?? 0)
520 const level = used >= 100 ? 100 : used >= 90 ? 90 : 0
521 const isChime = level > warned
522 warned = level
523 return { delta, isTick, isChime }
524 }
525
526 on('session.measure', async ($, e, next) => {
527 const result = await next(e)
528 const { delta, isTick, isChime } = absorb(e, await $.clock.now())
529 if (delta > 0) {
530 const saved = await $.store.get(LEDGER)
531 ledger = credit(isLedger(saved) ? saved : ledger, await $.clock.now(), delta)
532 await $.store.set(LEDGER, ledger)
533 }
534 if (isTick && sound) $.audio.play({ asset: TICK }).catch(() => {})
535 if (isChime && sound) $.audio.play({ asset: CHIME }).catch(() => {})
536 $.ui.invalidate('ui.render')
537 return result
538 })
539
540 on('command.run', { command: 'meter' }, async ($, e) => {
541 const now = await $.clock.now()
542 const arg = e.args.trim()
543 if (arg === 'reset') {
544 ledger = { since: new Date(now).toISOString(), totalUsd: 0, days: {} }
545 await $.store.set(LEDGER, ledger)
546 sessionUsd = 0
547 requests = []
548 ticks = 0
549 endDemo()
550 $.ui.invalidate('ui.render')
551 return { text: '미터기와 누적 요금을 0원으로 초기화했어요.' }
552 }
553 if (arg === 'demo') {
554 if (demo) {
555 endDemo()
556 $.ui.invalidate('ui.render')
557 return { text: '데모 주행을 끝내고 실제 미터기로 돌아왔어요.' }
558 }
559 const reset = new Date(now)
560 reset.setHours(reset.getHours() + 4, 0, 0, 0)
561 demo = { elapsed: 0, resetsAt: reset.toISOString(), ticks: 0, warned: 0 }
562 frame = 0
563 demoTimer = $.clock.every(DEMO_STEP_MS, () => {
564 if (!demo) return
565 demo.elapsed += DEMO_STEP_MS
566 const view = demoView(demo)
567 if (view.isWorking) frame += 1
568 const step = Math.floor(fare(view.usd) / DEMO_TICK_WON)
569 if (step > demo.ticks) {
570 demo.ticks = step
571 if (sound) $.audio.play({ asset: TICK }).catch(() => {})
572 }
573 const five = view.limits[0].percentUsed
574 const level = five >= 100 ? 100 : five >= 90 ? 90 : 0
575 if (level > demo.warned) {
576 demo.warned = level
577 if (sound) $.audio.play({ asset: CHIME }).catch(() => {})
578 }
579 if (demo.elapsed >= DEMO_ARRIVE_MS) {
580 demoTimer?.cancel()
581 demoTimer = undefined
582 }
583 $.ui.invalidate('ui.render')
584 })
585 $.ui.invalidate('ui.render')
586 return {}
587 }
588 return { text: report(now) }
589 })
590
591 on('command.run', { command: 'receipt' }, async ($) => {
592 await $.ui.open({ id: RECEIPT, title: '영수증', focus: true, closeOnEscape: true, columns: 56 })
593 return {}
594 })
595
596 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
597 if (e.props.hasSurvey) return next(e)
598 const elements = $.ui.resolve(e)
599 const { Box, Text } = elements
600 const text = (line: Line) =>
601 Text({
602 ...(line.color ? { color: line.color } : {}),
603 ...(line.bold ? { bold: true } : {}),
604 ...(line.dim ? { dimColor: true } : {}),
605 children: [line.text],
606 })
607 const view = demo ? demoView(demo) : realView(e.props.isWorking)
608 const lampRow = lamps(view).map(text)
609 const limit = text(limitLine(view))
610 const horse = Text({ children: [track()] })
611
612 const panel = () => {
613 if (!('Raster' in elements)) return undefined
614 const { Raster } = elements
615 const state = stateOf(view)
616 const tier = tierOf(view.model)
617 const lit = [
618 state === '빈차',
619 state === '주행',
620 tier === '모범' || tier === '블랙',
621 view.longHaul,
622 state === '대기' || state === '하차',
623 ]
624 const cell = (width: number, children: ReturnType<typeof Text>[]) =>
625 Box({ width, flexDirection: 'column', children })
626 const blank = Text({ children: [' '] })
627 const legend = limitLine(view)
628
629 return Box({
630 width: PANEL_WIDTH,
631 flexDirection: 'column',
632 borderStyle: 'round',
633 borderColor: BEZEL,
634 backgroundColor: SCREEN,
635 paddingX: 1,
636 children: [
637 Box({
638 flexDirection: 'row',
639 justifyContent: 'space-between',
640 children: [
641 Box({
642 flexDirection: 'row',
643 columnGap: 3,
644 children: [
645 Text({ color: BRAND, bold: true, children: ['클로드·택시+'] }),
646 Text({ color: BLUE, bold: true, children: [tier ?? '--'] }),
647 Text({ color: '#ffffff', children: [modelName(view.model) ?? '모델 확인 중'] }),
648 Text({ color: LCD_GREEN, bold: true, children: [`${view.speed.toFixed(1)} tok/s`] }),
649 ],
650 }),
651 view.prompt > 0
652 ? Text({ color: LCD_GREEN, bold: true, children: [`방금 요청 +${won(view.prompt)}`] })
653 : Text({ color: LEGEND, children: ['ELECTRONIC TAXIMETER'] }),
654 ],
655 }),
656 Box({
657 flexDirection: 'row',
658 columnGap: 1,
659 children: [
660 cell(11, [
661 Raster({
662 key: 'horse',
663 ...quadrants(HORSE[view.isWorking ? frame % HORSE.length : 0], view.isWorking ? HORSE_RGB : HORSE_IDLE_RGB, SCREEN_RGB),
664 }),
665 ]),
666 Box({
667 flexGrow: 1,
668 flexDirection: 'row',
669 justifyContent: 'flex-end',
670 children: [
671 Raster({ key: 'fare', ...quadrants(fareBitmap(comma(fare(view.usd))), state === '하차' ? STOP_RGB : DIGIT_RGB, SCREEN_RGB) }),
672 ],
673 }),
674 cell(5, [blank, Text({ color: '#ffffff', bold: true, children: ['원'] }), Text({ color: LEGEND, children: ['(WON)'] })]),
675 cell(8, [
676 blank,
677 Text({ color: LCD_GREEN, bold: true, children: [view.context === undefined ? '--%' : `${Math.round(view.context)}%`] }),
678 Text({ color: LEGEND, children: ['컨텍스트'] }),
679 ]),
680 ],
681 }),
682 Box({
683 flexDirection: 'row',
684 columnGap: 1,
685 children: [
686 Text({ color: '#ffffff', backgroundColor: BLUE, bold: true, children: [` ${state} `] }),
687 Text({
688 color: legend.color ?? PURPLE,
689 ...(legend.bold ? { bold: true } : {}),
690 wrap: 'truncate-end',
691 children: [legend.text],
692 }),
693 ],
694 }),
695 Box({
696 flexDirection: 'row',
697 justifyContent: 'space-between',
698 children: [
699 Box({
700 flexDirection: 'row',
701 columnGap: 1,
702 children: KEYS.map((key, i) =>
703 Text({
704 color: lit[i] ? KEY_TEXT : LEGEND,
705 backgroundColor: lit[i] ? key.on : key.off,
706 bold: lit[i],
707 children: [` ${key.label} `],
708 }),
709 ),
710 }),
711 Text({ color: LEGEND, children: [view.isDemo ? '데모 주행' : 'API 정가 환산'] }),
712 ],
713 }),
714 ],
715 })
716 }
717
718 const fits = e.surface === 'terminal' && e.props.bodyColumns >= PANEL_WIDTH && e.props.maxRows >= PANEL_ROWS
719 const meter =
720 (style === 'led' && fits ? panel() : undefined) ??
721 Box({
722 flexDirection: 'row',
723 columnGap: 1,
724 children: [
725 Text({ color: RED, bold: true, children: [won(view.usd)] }),
726 ...(view.prompt > 0 ? [Text({ dimColor: true, children: [`방금 +${won(view.prompt)}`] })] : []),
727 ...lampRow,
728 ...(view.model ? [Text({ dimColor: true, children: [modelName(view.model) ?? ''] })] : []),
729 horse,
730 limit,
731 ...(view.isDemo ? [Text({ dimColor: true, children: ['데모 주행'] })] : []),
732 ],
733 })
734
735 const below = await next(e)
736 return below ? Box({ flexDirection: 'column', children: [meter, below] }) : meter
737 })
738
739 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
740 if (e.requestId !== RECEIPT) return next(e)
741 const { Box, Text } = $.ui.resolve(e)
742 const now = await $.clock.now()
743 const row = (label: string, ...values: ReturnType<typeof Text>[]) =>
744 Box({ flexDirection: 'row', columnGap: 1, children: [Box({ width: 7, children: [Text({ dimColor: true, children: [label] })] }), ...values] })
745 const plain = (value: string) => Text({ children: [value] })
746 const rule = Text({ dimColor: true, children: ['─'.repeat(48)] })
747 return Box({
748 flexDirection: 'column',
749 paddingX: 1,
750 children: [
751 Box({
752 flexDirection: 'row',
753 columnGap: 2,
754 children: [Text({ bold: true, children: ['영 수 증'] }), Text({ dimColor: true, children: [`클로드 택시 · ${dayKey(now)}`] })],
755 }),
756 rule,
757 row('승하차', plain(`${clockOfMs(startedAt)} → ${clockOfMs(now)} (${duration(now - startedAt)})`)),
758 row('주행', plain(`새로 처리 ${man(freshTokens)} 토큰`)),
759 row('', plain(`캐시 재사용 ${man(cachedTokens)} 토큰`)),
760 row('차종', plain(shares())),
761 row(
762 '요금',
763 Text({ bold: true, color: RED, children: [won(sessionUsd)] }),
764 Text({ dimColor: true, children: [`API 정가 $${sessionUsd.toFixed(2)} · 환율 ${comma(rate)}원`] }),
765 ),
766 row('요청', plain(requestsLine())),
767 row('', Text({ dimColor: true, children: ['제목 생성 같은 숨은 호출 포함'] })),
768 row('표시등', plain(`장거리 ${longHaulCount}회 · 5시간 최고 ${peakFiveHour}%`)),
769 row('누적', plain(`오늘 ${won(recentUsd(now, 1))} · ${monthDay(ledger.since)}부터 ${won(ledger.totalUsd)}`)),
770 rule,
771 Text({ children: ['이용해 주셔서 감사합니다'] }),
772 ],
773 })
774 })
775}
776