SLOPSHOPPER

taxi-meter

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

newpanebandcommandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · taxi-meter
│ ┃ 영수증 ✕ › fix the failing auth test and add an audit log call │ ┃ 영 수 증 클로드 택시 · 2025-10-09 │ ┃ ────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ────── ⎿ Read 6 lines │ ┃ 승하차 01:53 → 01:53 (0분) ⏺ Update(src/auth.ts) │ ┃ 주행 새로 처리 0 토큰 ⎿ Added 2 lines, removed 1 line │ ┃ 캐시 재사용 0 토큰 ⏺ Bash(bun test) │ ┃ 차종 - ⎿ 3 pass, 1 fail │ ┃ 요금 ₩0 API 정가 $0.00 · 환율 1,400원 │ ┃ 요청 - ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 제목 생성 같은 숨은 호출 포함 │ ┃ 표시등 장거리 0회 · 5시간 최고 31% ✻ Worked for 42s · done 4:20 PM │ ┃ 누적 오늘 ₩0 · 10/9부터 ₩0 │ ┃ ────────────────────────────────────────── › /meter │ ┃ ────── ⎿ taxi-meter: 🚕 클로드 택시 미터기 │ ┃ 이용해 주셔서 감사합니다 ⎿ taxi-meter: 이번 승차 ₩0 (API 정가 환산 $0.00 · 환율 1,400원) │ ⎿ taxi-meter: 방금 요청 - · 요청 0건 │ ⎿ taxi-meter: 오늘 ₩0 · 최근 7일 ₩0 · 10/9부터 ₩0 │ ⎿ taxi-meter: 5시간 ███░░░░░░░ 31% │ ⎿ taxi-meter: 차종 모범 (claude-opus-5-5) · 컨텍스트 49% │ │ ₩0 [빈차] [모범] Opus 5.5 ·········🐎 5시간 ███░░░░░░░ 31% ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
₩0 [빈차] [모범] Opus 5.5 ·········🐎 5시간 ███░░░░░░░ 31% ⟨Claude Code's own drawing⟩
Pane · 영수증
영 수 증 클로드 택시 · 2025-10-09 ──────────────────────────────────────────────── 승하차 01:53 → 01:53 (0분) 주행 새로 처리 0 토큰 캐시 재사용 0 토큰 차종 - 요금 ₩0 API 정가 $0.00 · 환율 1,400원 요청 - 제목 생성 같은 숨은 호출 포함 표시등 장거리 0회 · 5시간 최고 31% 누적 오늘 ₩0 · 10/9부터 ₩0 ──────────────────────────────────────────────── 이용해 주셔서 감사합니다
README

🚕 devbrothers-mods

클로드 코드를 택시로 개조하는 Claude Code mods

요금 미터기, 내비, 과속 단속 카메라, 블랙박스를 프롬프트 위에 달아 드려요.

Claude Code mods License: MIT

<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-naviClaude의 할 일 목록을 경로로 그리고 음성으로 안내/navi
📸taxi-speedcamrm -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 순서가 실행 순서예요. 여기서도 블랙박스를 맨 앞에 두세요.


🧾 taxi-meter: 요금 미터기

<img src="docs/images/meter.webp" alt="택시 미터기 패널. 일반 Sonnet 5.5, 방금 요청 +₩30, 요금 ₩270, 컨텍스트 4%, 5시간 한도 13%" width="560">

세션 하나가 승차 한 번이에요. Claude가 일하는 동안 말이 달리고 요금이 딸깍딸깍 올라가요.

  • 요금은 쓴 토큰을 API 정가로 환산한 원화예요. 구독(Pro·Max) 요금제에서 실제로 청구되는 돈은 아니고, "내 구독이 API로 치면 얼마어치 일하나"를 보여줘요.
  • 큰 숫자는 세션 동안 올라가기만 해요. 새 프롬프트를 보내도 ₩0으로 돌아가지 않고, 그 요청의 요금은 위쪽 방금 요청 +₩___로 따로 보여요. /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와 같은 값)을 따라가요. 실제 세션으로 맞춰 보니 대화 기록에 남은 응답들을 정가로 계산한 값에, 기록에 안 남는 호출(세션 제목 생성, 프롬프트 추천 등)을 더하면 소수 일곱째 자리까지 같았어요.

  • 기록에 안 남는 호출 비용도 요금에 들어가요. 턴이 끝난 뒤 생기는 호출은 다음 요청의 방금 요청에 섞여 보일 수 있어요(세션 합계는 맞아요).
  • 영수증의 토큰은 새로 처리와 캐시 재사용으로 나눠 보여요. 요청할 때마다 대화 전체를 캐시에서 다시 읽어서, 긴 세션에서는 캐시 재사용이 대부분이에요. 캐시 읽기는 입력 단가의 10분의 1이라 요금 비중은 작아요.
  • 프롬프트 추천 문구가 필요 없다면 settings.json에 "promptSuggestionEnabled": false를 넣어 그 호출을 끌 수 있어요.
명령하는 일
/meter이번 승차(세션)·방금 요청·오늘·최근 7일·누적 요금
/receipt영수증 창(승하차 시각, 새로 처리·캐시 재사용 토큰, 차종 비중, 요금, 요청별 요금, 누적)
/meter reset화면 요금과 누적 장부를 모두 0원으로 (데모 중이면 데모도 끝내요)
/meter demo약 10초짜리 데모 주행. 다시 입력하거나 다음 프롬프트를 보내면 실제 미터기로 돌아와요
설정기본값설명
krw_per_usd1400원화 환산 환율
styleledled는 터미널에서 미터기 패널, compact는 한 줄
soundtrue요금이 오를 때 딸깍, 한도 경고 알림음
tick_won1000딸깍 소리를 내는 금액 단위

누적 장부는 세션을 넘어 90일치를 보관해요. 패널은 창 폭 75칸·높이 9줄 이상에서 나오고, 더 좁으면 한 줄로 바뀌어요.


🧭 taxi-navi: 내비

<img src="docs/images/navi.webp" alt="내비 패널. 위는 주행 중(경유지 1/3, 다음 안내), 아래는 도착(경유지 3곳, 소요 1분)" width="560">

Claude가 세운 할 일 목록이 경로가 되고, 🚕가 지금 하는 단계에 있어요.

  • 첫 작업이 시작되면 "경로 안내를 시작합니다", 진행 중에 단계가 늘거나 줄면 "경로를 재탐색합니다"(화살표가 5초 동안 유턴으로 바뀌어요), 다 끝나면 "목적지에 도착했습니다"라고 말해요.
  • 단계 하나를 끝낼 때마다 딩 소리가 나요. 긴 작업을 맡기고 자리를 비워도 돼요.
  • TaskCreate·TaskUpdate·TaskList·TodoWrite를 읽기만 하고, 할 일 내용은 바꾸지 않아요. 서브에이전트의 할 일은 경로에 넣지 않아요.
  • /navi를 치면 할 일 도구가 켜져 있는지 알려줘요.
설정기본값설명
voicetrue음성 안내
voice_nameYunamacOS say -v '?'에 나오는 음성 이름
soundtrue단계를 끝낼 때마다 딩

📸 taxi-speedcam: 과속 단속 카메라

<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에게 이유를 전해요. Claude는 같은 명령을 다시 시도하지 않고 다른 방법을 물어봐요.
  • 질문 창을 닫거나 claude -p처럼 물어볼 사람이 없으면 세워요.
  • 잡는 순간 화면이 하얗게 번쩍이고, 결과(통과했어요·정차했어요)가 5초 동안 남아요.

[!WARNING] 텍스트 패턴으로 잡는 안전벨트예요. alias나 스크립트 속 명령은 못 잡으니, 권한 설정의 deny 규칙을 대신하지 않아요.

설정기본값설명
modeaskask는 매번 묻고, block은 묻지 않고 세워요
soundtrue찰칵 셔터 소리
voicetrue단속 종류 음성 안내
voice_nameYunamacOS 음성 이름

🎥 taxi-blackbox: 블랙박스

<img src="docs/images/blackbox.webp" alt="/blackbox 창. 타임라인, 직전 5개 동작, 빨간 사고 장면 카드, 아래 REC 줄" width="900">

맨 아래 ● REC 줄이 날짜·시각과 함께 계속 녹화해요(응답 중에는 깜빡여요). 오류나 거부가 생기면 사고 N이 노랗게 켜져요.

  • /blackbox를 치면 사고 장면이 열려요. 타임라인(사고는 빨강, 고른 사고는 노랑 ▲), 직전 동작 목록, 사고 카드가 보여요. p·n으로 이전·다음 사고를 넘겨요.
  • 도구마다 색 배지가 붙어요. Bash 주황, Edit·Write 파랑, Read·검색 회색, 웹 청록이에요.
  • 이번 세션의 도구 호출을 최대 500개 메모리에만 기억하고 파일로 남기지 않아요. token=, password=, Bearer, sk-, ghp_, xoxb-, AKIA 형태의 값은 •••로 가려요.
설정기본값설명
before5사고 하나와 함께 보여줄 직전 동작 수

어디서 보이나요

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가 정해요.
  • 넷을 다 켜면 프롬프트 위에 최대 18줄 정도가 필요해요. 전체화면 모드에서는 이 영역이 터미널 높이의 절반까지라, 창이 낮으면 아래쪽이 n more로 접히고 스크롤돼요. 창을 50줄 이상으로 키우면 다 보여요.
  • Claude Code 2.1.289에서 만들고 테스트했어요. mods API는 릴리스 사이에 바뀔 수 있어요.

내 mod 만들기

이 팩도 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가 직접 합성한 파일이라 외부 음원 라이선스가 없어요.

라이선스

MIT · 만든 사람 개발동생

Source 1 files
hooks/register.ts 776 lines
1import 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