A taxi meter in the status line: the session's cost ticks up live as tokens burn.

English | 简体中文
A taxi meter for Claude Code. Every token Claude burns makes the fare in your status line jump: $0.03… $0.47… $2.18. Watching it tick does more for cost awareness than any dashboard.

In Claude Code:
/plugin marketplace add gxcsoccer/claude-taxi-meter
/plugin install taxi-meter@claude-taxi-meter
The meter starts in your next session (or right away after /reload-plugins). Try /taxi demo to see a ride without spending anything.
Requires a recent Claude Code: the meter is a mod, built on Claude Code's function-hooks plugin API, which is in early access. It was built and tested on 2.1.289.
A live meter in the status line
🚕 FOR HIRE $1.20 ← idle
🚖 HIRED● $1.27 ▲0.03 · trip $0.07 · 🔥$0.42/min ← Claude is writing
🚕 WAITING⏳ $1.31 · trip $0.11 · Bash ← a tool is running
🚕 FOR HIRE $1.34 · last $0.14 ← ride over
/cost shows.A meter pane (/taxi meter): big LED digits, ride timer, burn rate, budget bar, a 30-minute spend sparkline and your recent rides, with hotkey buttons.
A budget (/taxi budget 5): the status line shows budget 49%, then ⚠️ budget 84%, then 🛑 OVER BUDGET, with warnings at 80% and 100%. It warns; it never blocks you.
Payback mode for subscribers (/taxi plan 200): on a Pro or Max plan, per-token cost doesn't hurt, so the meter flips the story and shows how many times over this month's usage pays back your plan: 💎 3.2× plan, with a 🎉 at 1×, 2×, 3×, 5×, 10×… Subscribers are detected automatically and get a one-time hint.
Receipts to share: /taxi prints a receipt card (rides, jumps, priciest prompt, lifetime total, and what the money would buy in lattes). /taxi share copies a short summary to your clipboard.
| Command | What it does |
|---|---|
/taxi | Print the receipt |
/taxi meter | Open the live meter pane |
/taxi demo | A pretend 14-second ride, for trying it out or recording a GIF. Nothing is billed or recorded |
/taxi share | Copy a ride summary to the clipboard |
/taxi plan 200 | Set your subscription's monthly price (USD) to see payback; off clears it |
/taxi budget 5 | Set this session's fare limit (USD); off clears it |
/taxi usd · cny | Switch currency |
/taxi en · zh | Switch language |
/taxi hide · show | Hide or show the status line meter |
/taxi runs immediately, even while Claude is still working.
Set them in /config, or under pluginConfigs["taxi-meter"].options in settings.json.
| Option | Default | |
|---|---|---|
language | en | en (FOR HIRE / HIRED / WAITING) or zh (空车 / 载客 / 等候) |
currency | USD | USD or CNY |
cnyRate | 7.1 | CNY per USD |
sound | true | Ka-ching at milestones (macOS) |
bigTripUsd | 0.5 | Toast a receipt for turns at least this expensive |
budgetUsd | 0 | Default fare limit per session; 0 for none |
planUsd | 0 | Subscription price per month; turns on payback mode. 0 for API billing |
Claude Code mods are plugins that run inside Claude Code itself, hooked into its events. The meter uses a handful:
| Hook | What the meter does with it |
|---|---|
turn.step | Reads Claude's output as it streams and estimates the fare live: about 4 characters per token at the model's output price. Silent thinking is billed by time, like a cab stuck in traffic |
session.measure | Settles on the session's real cost, the figure /cost shows |
ui.status | Draws the meter in the status line |
ui.render | Draws the meter pane and the receipt card |
command.run | Answers /taxi |
The live estimate only fills the gap while a response is streaming. The total always lands on the real number.
Is the fare accurate? The total is: it is the session's own cost ledger. Only the ticking in between responses is an estimate.
What's the ⚠ before the meter? That's how Claude Code currently shows a plugin's status entry next to its own notices. The meter isn't reporting a problem.
I'm on a subscription. Is this money I'm paying? No. The fare is what the same usage would cost at API prices. Set /taxi plan to see it as payback instead.
Does /taxi demo cost anything? No. It's a scripted animation: nothing is billed, and your rides, totals and milestones are left untouched.
claude --plugin-dir /path/to/claude-taxi-meter # run from a checkout
claude plugin validate . && claude plugin test . # check and test
To update an installed copy: claude plugin marketplace update claude-taxi-meter && claude plugin update taxi-meter@claude-taxi-meter.
hooks/register.tsx 923 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Live, Trip } from '../types'
5import {
6 PANE_WORDS,
7 bigDigits,
8 bigTripToast,
9 budgetPct,
10 budgetToast,
11 costOf,
12 duration,
13 face,
14 MILESTONES,
15 milestoneAt,
16 milestoneToast,
17 monthKey,
18 paybackAt,
19 paybackText,
20 paybackToast,
21 shareCard,
22 money,
23 moneyUsd,
24 receipt,
25 rollStep,
26 sparkline,
27 spendBuckets,
28 streamingCost,
29 toUnits,
30} from './meter'
31import type { Currency, Lang, Phase } from './meter'
32
33const trips = atom({ plugin: 'taxi-meter', key: 'trips' } as const, [])
34const milestone = atom({ plugin: 'taxi-meter', key: 'milestone' } as const, 0)
35const jumpCount = atom({ plugin: 'taxi-meter', key: 'jumps' } as const, 0)
36const budget = atom({ plugin: 'taxi-meter', key: 'budgetUsd' } as const, -1)
37const budgetWarned = atom({ plugin: 'taxi-meter', key: 'budgetWarned' } as const, 0)
38const isHidden = atom({ plugin: 'taxi-meter', key: 'isHidden' } as const, false)
39const isPaneOpen = atom({ plugin: 'taxi-meter', key: 'isPaneOpen' } as const, false)
40const history = atom({ plugin: 'taxi-meter', key: 'history' } as const, [])
41const live = atom({ plugin: 'taxi-meter', key: 'live' } as const, {
42 phase: 'idle',
43 units: 0,
44 flash: 0,
45 trip: null,
46 rate: 0,
47 tool: null,
48 isLit: true,
49 rideMs: 0,
50} as Live)
51
52const PANE = 'taxi-meter'
53
54/** How often the drum turns, ms. */
55const TICK = 120
56/** Thinking streams nothing we can count, so bill it by time like a cab in traffic: tokens a second. */
57const QUIET_TPS = 40
58/** Quiet this long and the meter starts charging for time. */
59const QUIET_AFTER = 400
60/** How long the ▲ of a jump stays lit. */
61const FLASH_MS = 1500
62/** Jumps closer together than this read as one burst under the ▲. */
63const BURST_MS = 400
64
65type Step = {
66 model: string
67 chars: number
68 quietTokens: number
69 lastChunkAt: number
70 /** What the response cost, once its stop arrived; until then the stream is estimated. */
71 final: number | null
72}
73
74type Ride = { startUsd: number; startedAt: number; prompt: string; jumps: number }
75
76/** A pretend ride for recording: drives the drum on its own clock, bills nothing, records nothing. */
77type Demo = {
78 startedAt: number
79 /** The fare the drum stood at when it began, USD. */
80 startUsd: number
81 /** What the whole ride adds, USD. */
82 total: number
83 /** What it has added so far, USD. */
84 added: number
85 phase: Phase
86 tool: string | null
87 jumps: number
88 /** The milestone it rang, so it rings once. */
89 rung: number
90 /** The last ride's fare before it, put back when it ends. */
91 lastTrip: number | null
92 timer: Timer | null
93}
94
95/** The demo's script, ms from its start. */
96const DEMO = { stream1: 800, tool: 5000, stream2: 6800, end: 10000, done: 14000 }
97
98const cfg = {
99 lang: 'en' as Lang,
100 currency: 'USD' as Currency,
101 cnyRate: 7.1,
102 hasSound: true,
103 bigTripUsd: 0.5,
104 budgetUsd: 0,
105 /** The subscription's monthly price, USD; 0 off a plan. */
106 planUsd: 0,
107}
108
109// The ledger is the truth (what /cost says); steps are what is still on its way to it.
110const m = {
111 ledger: 0,
112 steps: new Map<string, Step>(),
113 /** Units on the drum; -1 until the first reading. */
114 shown: -1,
115 settledSince: 0,
116 flash: 0,
117 flashUntil: 0,
118 lastJumpAt: 0,
119 now: 0,
120 ticker: null as Timer | null,
121 isTicking: false,
122 lastFace: '',
123 lastLive: '',
124 isLit: true,
125 tools: 0,
126 tool: null as string | null,
127 ride: null as Ride | null,
128 lastTrip: null as number | null,
129 model: '',
130 samples: [] as [number, number][],
131 demo: null as Demo | null,
132 // Session values the tick reads synchronously, kept in step with their atoms.
133 budgetUsd: 0,
134 /** API value this calendar month, across sessions, as last credited. */
135 monthUsd: 0,
136 isHidden: false,
137 isPaneOpen: false,
138}
139
140function clip(text: string): string {
141 const one = text.replace(/\s+/g, ' ').trim()
142 return one.length > 24 ? `${one.slice(0, 23)}…` : one || '…'
143}
144
145const units = (usd: number) => toUnits(usd, cfg.currency, cfg.cnyRate)
146const factor = () => (cfg.currency === 'CNY' ? cfg.cnyRate : 1)
147
148function target(): number {
149 let usd = m.ledger + (m.demo?.added ?? 0)
150 for (const s of m.steps.values()) usd += s.final ?? streamingCost(s.model, s.chars, s.quietTokens)
151 return usd
152}
153
154function phase(): Phase {
155 if (m.demo) return m.demo.phase
156 const isStreaming = [...m.steps.values()].some(s => s.final === null)
157 if (isStreaming) return 'hired'
158 if (m.tools > 0) return 'waiting'
159 return m.ride !== null || m.steps.size > 0 ? 'hired' : 'idle'
160}
161
162function snapshot(): Live {
163 const first = m.samples[0]
164 const last = m.samples[m.samples.length - 1]
165 const rate =
166 first && last && m.now - first[0] >= 5000 ? ((last[1] - first[1]) / (m.now - first[0])) * 60000 : 0
167 return {
168 phase: phase(),
169 units: Math.max(0, m.shown),
170 flash: m.now < m.flashUntil ? m.flash : 0,
171 trip:
172 m.demo && m.demo.phase !== 'idle'
173 ? Math.max(0, m.shown - units(m.demo.startUsd))
174 : m.ride !== null
175 ? Math.max(0, m.shown - units(m.ride.startUsd))
176 : m.lastTrip,
177 rate,
178 tool: m.demo ? m.demo.tool : m.tool,
179 isLit: m.isLit,
180 rideMs:
181 m.demo && m.demo.phase !== 'idle'
182 ? Math.max(0, m.now - m.demo.startedAt)
183 : m.ride !== null
184 ? Math.max(0, m.now - m.ride.startedAt)
185 : 0,
186 }
187}
188
189/** This month's API value over the plan's price, the drum's unsettled part included; null off a plan. */
190function payback(): number | null {
191 if (cfg.planUsd <= 0) return null
192 const unsettled = Math.max(0, m.shown / 100 / factor() - m.ledger)
193 return (m.monthUsd + unsettled) / cfg.planUsd
194}
195
196async function draw($: EngineInterface): Promise<void> {
197 const now = snapshot()
198 const text = face(
199 { ...now, budget: m.budgetUsd > 0 ? units(m.budgetUsd) : null, payback: payback() },
200 cfg.lang,
201 cfg.currency,
202 )
203 const shownText = m.isHidden ? '' : text
204 if (shownText !== m.lastFace) {
205 m.lastFace = shownText
206 $.ui.status(m.isHidden ? undefined : text)
207 }
208 if (m.isPaneOpen) {
209 // The pane shows the ride clock too, so it redraws once a second even when the drum rests.
210 const key = `${text}|${Math.floor(now.rideMs / 1000)}`
211 if (key !== m.lastLive) {
212 m.lastLive = key
213 await update($, live, () => now)
214 }
215 }
216}
217
218/** Rings once the drum itself rolls past a milestone, and warns as the budget runs out. */
219async function ring($: EngineInterface): Promise<void> {
220 const usd = m.shown / 100 / factor()
221 if (m.demo) {
222 // The demo rings like the real thing and writes nothing down.
223 const passed = milestoneAt(usd)
224 if (passed > m.demo.rung) {
225 m.demo.rung = passed
226 $.ui.toast(milestoneToast(passed, cfg.lang, cfg.currency, cfg.cnyRate), { timeoutMs: 5000 })
227 if (cfg.hasSound) await $.audio.play({ asset: 'sounds/kaching.wav' }).catch(() => undefined)
228 }
229 return
230 }
231 const passed = milestoneAt(usd)
232 if (passed > (await read($, milestone))) {
233 await update($, milestone, () => passed)
234 $.ui.toast(milestoneToast(passed, cfg.lang, cfg.currency, cfg.cnyRate), { timeoutMs: 5000 })
235 if (cfg.hasSound) await $.audio.play({ asset: 'sounds/kaching.wav' }).catch(() => undefined)
236 }
237
238 const x = payback()
239 if (x !== null) {
240 // Once per level per month, across sessions.
241 const month = monthKey(await $.clock.now())
242 const held = ((await $.store.get('paybackRung')) ?? {}) as { month?: string; level?: number }
243 const level = paybackAt(x)
244 if (level > (held.month === month ? (held.level ?? 0) : 0)) {
245 await $.store.set('paybackRung', { month, level })
246 $.ui.toast(paybackToast(level, moneyUsd(cfg.planUsd, cfg.currency, cfg.cnyRate), cfg.lang), { timeoutMs: 8000 })
247 if (cfg.hasSound) await $.audio.play({ asset: 'sounds/kaching.wav' }).catch(() => undefined)
248 }
249 }
250
251 const pct = budgetPct({ units: m.shown, budget: m.budgetUsd > 0 ? units(m.budgetUsd) : null })
252 if (pct === null) return
253 const level = pct >= 100 ? 100 : pct >= 80 ? 80 : 0
254 if (level > (await read($, budgetWarned))) {
255 await update($, budgetWarned, () => level)
256 const limit = moneyUsd(m.budgetUsd, cfg.currency, cfg.cnyRate)
257 $.ui.toast(budgetToast(Math.min(pct, 100), limit, cfg.lang), { timeoutMs: 8000 })
258 if (cfg.hasSound && level === 100) await $.audio.play({ asset: 'sounds/kaching.wav' }).catch(() => undefined)
259 }
260}
261
262async function tick($: EngineInterface): Promise<void> {
263 if (m.isTicking) return
264 m.isTicking = true
265 try {
266 const at = await $.clock.now()
267 const dt = m.now === 0 ? 0 : at - m.now
268 m.now = at
269 m.isLit = Math.floor(at / 480) % 2 === 0
270
271 for (const s of m.steps.values()) {
272 if (s.final === null && at - s.lastChunkAt > QUIET_AFTER) s.quietTokens += (QUIET_TPS * dt) / 1000
273 }
274
275 const goal = units(target())
276 if (m.shown < 0) m.shown = goal
277 const step = rollStep(goal - m.shown)
278 if (step > 0) {
279 m.shown += step
280 m.flash = (at - m.lastJumpAt <= BURST_MS ? m.flash : 0) + step
281 m.lastJumpAt = at
282 m.flashUntil = at + FLASH_MS
283 if (m.demo) m.demo.jumps += 1
284 else {
285 if (m.ride) m.ride.jumps += 1
286 await update($, jumpCount, n => n + 1)
287 }
288 await ring($)
289 }
290
291 const last = m.samples[m.samples.length - 1]
292 if (!last || at - last[0] >= 1000) {
293 m.samples.push([at, m.shown])
294 while (m.samples.length > 0 && at - m.samples[0]![0] > 60000) m.samples.shift()
295 }
296
297 // An estimate that ran ahead of the ledger: once nothing is moving, own up to it.
298 const isQuiet = phase() === 'idle' && m.steps.size === 0
299 if (isQuiet && goal < m.shown) {
300 if (m.settledSince === 0) m.settledSince = at
301 if (at - m.settledSince > 2000) m.shown = goal
302 } else {
303 m.settledSince = 0
304 }
305
306 await draw($)
307
308 if (isQuiet && goal === m.shown && at >= m.flashUntil) {
309 m.ticker?.cancel()
310 m.ticker = null
311 m.samples.length = 0
312 }
313 } finally {
314 m.isTicking = false
315 }
316}
317
318/** Keeps the drum turning until the cab is idle and the drum caught up. */
319async function run($: EngineInterface): Promise<void> {
320 if (m.ticker === null) m.ticker = $.clock.every(TICK, () => void tick($))
321 await tick($)
322}
323
324/** Takes the session's ledger, and drops the responses it now covers. */
325async function settle($: EngineInterface, usd: number): Promise<void> {
326 if (usd + 1e-9 < m.ledger) {
327 // A fresh session under us: the meter starts over.
328 m.shown = units(usd)
329 m.ride = null
330 m.lastTrip = null
331 }
332 m.ledger = usd
333 for (const [key, s] of m.steps) if (s.final !== null) m.steps.delete(key)
334
335 await credit($, usd)
336 const at = await $.clock.now()
337 await update($, history, list =>
338 list[list.length - 1]?.[1] === usd ? list : [...list, [at, usd] as [number, number]].slice(-500),
339 )
340 await run($)
341}
342
343/**
344 * Adds what this session spent since last seen to the lifetime odometer. Kept per
345 * session id in the store, so a reload, a resume or a second process never counts twice.
346 */
347async function credit($: EngineInterface, usd: number): Promise<void> {
348 const sid = await $.session.id()
349 const raw = await $.store.get('seenBySession')
350 const seen = { ...((raw ?? {}) as Record<string, number>) }
351 const before = seen[sid] ?? 0
352 let lifetime = Number((await $.store.get('lifetimeUsd')) ?? 0)
353 if (raw === undefined) {
354 // 0.1.0 counted only from when the mod loaded: open the book with this session's whole fare.
355 lifetime = Math.max(lifetime, usd)
356 } else if (usd > before) {
357 lifetime += usd - before
358 } else {
359 return
360 }
361 // Newest last, so the oldest sessions fall off the end.
362 delete seen[sid]
363 seen[sid] = usd
364 await $.store.set('seenBySession', Object.fromEntries(Object.entries(seen).slice(-200)))
365 const grown = lifetime - Number((await $.store.get('lifetimeUsd')) ?? 0)
366 await $.store.set('lifetimeUsd', lifetime)
367
368 // The month's book: a store from before months were kept opens it with the whole lifetime.
369 const month = monthKey(await $.clock.now())
370 const months = (await $.store.get('monthUsd')) as Record<string, number> | undefined
371 const book = months === undefined ? { [month]: lifetime } : { ...months, [month]: (months[month] ?? 0) + grown }
372 await $.store.set('monthUsd', Object.fromEntries(Object.entries(book).slice(-24)))
373 m.monthUsd = book[month] ?? 0
374}
375
376async function loadMonth($: EngineInterface): Promise<void> {
377 const months = (await $.store.get('monthUsd')) as Record<string, number> | undefined
378 // Before months were kept, everything counted so far stands for this month.
379 m.monthUsd =
380 months === undefined
381 ? Number((await $.store.get('lifetimeUsd')) ?? 0)
382 : (months[monthKey(await $.clock.now())] ?? 0)
383}
384
385/** The text `/taxi share` copies, from the same figures as the receipt. */
386async function figures($: EngineInterface) {
387 await resync($)
388 const usage = await $.session.usage()
389 return {
390 lang: cfg.lang,
391 currency: cfg.currency,
392 cnyRate: cfg.cnyRate,
393 ledger: usage.cost?.usd ?? m.ledger,
394 trips: await read($, trips),
395 jumps: await read($, jumpCount),
396 startedAt: usage.startedAt,
397 now: await $.clock.now(),
398 lifetime: Number((await $.store.get('lifetimeUsd')) ?? 0),
399 model: m.model || (await read($, trips)).at(-1)?.model || '',
400 monthUsd: m.monthUsd,
401 planUsd: cfg.planUsd,
402 }
403}
404
405async function resync($: EngineInterface): Promise<void> {
406 const { cost } = await $.session.usage()
407 if (cost) await settle($, cost.usd)
408}
409
410async function setBudget($: EngineInterface, usd: number): Promise<void> {
411 m.budgetUsd = Math.max(0, usd)
412 await update($, budget, () => m.budgetUsd)
413 // A new limit warns afresh from where the fare stands now.
414 const pct = budgetPct({ units: m.shown, budget: m.budgetUsd > 0 ? units(m.budgetUsd) : null }) ?? 0
415 await update($, budgetWarned, () => (pct >= 100 ? 100 : pct >= 80 ? 80 : 0))
416 await run($)
417}
418
419async function setHidden($: EngineInterface, hidden: boolean): Promise<void> {
420 m.isHidden = hidden
421 await update($, isHidden, () => hidden)
422 await run($)
423}
424
425async function openPane($: EngineInterface): Promise<void> {
426 m.isPaneOpen = true
427 m.lastLive = ''
428 await update($, isPaneOpen, () => true)
429 await $.ui.open({ id: PANE, title: PANE_WORDS[cfg.lang].title, rows: 16 })
430 await run($)
431 await draw($)
432}
433
434async function printReceipt($: EngineInterface): Promise<string> {
435 return receipt(await figures($))
436}
437
438/** Starts a pretend ride that crosses the next milestone, so a recording catches every beat. */
439async function startDemo($: EngineInterface): Promise<void> {
440 const now = await $.clock.now()
441 const fare = target()
442 const next = MILESTONES.find(x => x > fare + 0.05)
443 // Ring a milestone near the end when one is in reach; otherwise a plain ride.
444 const total = next !== undefined && next - fare <= 6 ? next - fare + 0.37 : 2.48
445 m.demo = {
446 startedAt: now,
447 startUsd: fare,
448 total,
449 added: 0,
450 phase: 'hired',
451 tool: null,
452 jumps: 0,
453 rung: milestoneAt(fare),
454 lastTrip: m.lastTrip,
455 timer: null,
456 }
457 m.demo.timer = $.clock.every(100, () => void demoStep($))
458 await run($)
459}
460
461/** Moves the demo along its script: stream, wait on a tool, stream, arrive, then put everything back. */
462async function demoStep($: EngineInterface): Promise<void> {
463 const d = m.demo
464 if (!d) return
465 const t = (await $.clock.now()) - d.startedAt
466 const ease = (from: number, to: number, a: number, b: number) =>
467 from + (to - from) * Math.min(1, Math.max(0, (t - a) / (b - a)))
468
469 if (t < DEMO.stream1) {
470 d.phase = 'hired'
471 } else if (t < DEMO.tool) {
472 d.phase = 'hired'
473 d.added = ease(0, d.total * 0.45, DEMO.stream1, DEMO.tool)
474 } else if (t < DEMO.stream2) {
475 d.phase = 'waiting'
476 d.tool = 'Bash'
477 } else if (t < DEMO.end) {
478 d.phase = 'hired'
479 d.tool = null
480 d.added = ease(d.total * 0.45, d.total, DEMO.stream2, DEMO.end)
481 } else if (d.phase !== 'idle') {
482 d.phase = 'idle'
483 d.added = d.total
484 m.lastTrip = units(d.total)
485 const ride: Trip = {
486 fare: d.total,
487 ms: DEMO.end,
488 at: d.startedAt + DEMO.end,
489 prompt: 'demo',
490 model: m.model,
491 jumps: d.jumps,
492 }
493 $.ui.toast(bigTripToast(ride, cfg.lang, cfg.currency, cfg.cnyRate), { timeoutMs: 5000 })
494 } else if (t >= DEMO.done) {
495 // Back to the real meter, as if the demo never ran.
496 d.timer?.cancel()
497 m.demo = null
498 m.lastTrip = d.lastTrip
499 m.shown = units(target())
500 m.samples.length = 0
501 }
502 await run($)
503}
504
505const HELP = {
506 en: [
507 '/taxi print the receipt',
508 '/taxi meter open the live meter pane',
509 '/taxi budget 5 warn at 80% and 100% of $5 this session (off to clear)',
510 '/taxi usd | cny switch currency',
511 '/taxi en | zh switch language',
512 '/taxi hide | show hide or show the status line meter',
513 '/taxi plan 200 your subscription price a month: shows how many times over it pays back (off to clear)',
514 '/taxi share copy a ride summary to paste anywhere',
515 '/taxi demo a pretend 10-second ride for recording a GIF: nothing is billed or recorded',
516 ],
517 zh: [
518 '/taxi 打印小票',
519 '/taxi meter 打开实时计价器面板',
520 '/taxi budget 5 本次会话预算 $5,用到 80% 和 100% 时提醒(off 清除)',
521 '/taxi usd | cny 切换币种',
522 '/taxi en | zh 切换语言',
523 '/taxi hide | show 隐藏或显示状态栏计价器',
524 '/taxi plan 200 填写每月订阅价,显示本月回本了几倍(off 清除)',
525 '/taxi share 复制一段行程总结,方便分享',
526 '/taxi demo 模拟一段 10 秒的行程,方便录 GIF:不计费、不记录',
527 ],
528} as const
529
530export const register: Register = (on, options) => {
531 cfg.lang = options.language === 'zh' ? 'zh' : 'en'
532 cfg.currency = options.currency === 'CNY' ? 'CNY' : 'USD'
533 cfg.cnyRate = typeof options.cnyRate === 'number' && options.cnyRate > 0 ? options.cnyRate : 7.1
534 cfg.hasSound = options.sound !== false
535 cfg.bigTripUsd = typeof options.bigTripUsd === 'number' ? options.bigTripUsd : 0.5
536 cfg.budgetUsd = typeof options.budgetUsd === 'number' && options.budgetUsd > 0 ? options.budgetUsd : 0
537 cfg.planUsd = typeof options.planUsd === 'number' && options.planUsd > 0 ? options.planUsd : 0
538
539 on('session.start', async ($, e, next) => {
540 await $.command.register({
541 name: 'taxi',
542 description:
543 cfg.lang === 'zh' ? '计价器:小票、实时面板、预算、币种' : 'Taxi meter: receipt, live pane, budget, currency',
544 argumentHint: 'meter | demo | share | plan <usd|off> | budget <usd|off> | usd | cny | en | zh | hide | show',
545 immediate: true,
546 })
547 const { cost } = await $.session.usage()
548 m.ledger = cost?.usd ?? 0
549 m.shown = units(m.ledger)
550 // Milestones already behind us (a reload, a resume) do not ring again.
551 const passed = milestoneAt(m.ledger)
552 if (passed > (await read($, milestone))) await update($, milestone, () => passed)
553 await loadMonth($)
554 await credit($, m.ledger)
555 m.model = await $.session.model()
556
557 // A session budget set with /taxi outlives a reload; the configured one is the default.
558 const held = await read($, budget)
559 if (held < 0) await setBudget($, cfg.budgetUsd)
560 else m.budgetUsd = held
561 m.isHidden = await read($, isHidden)
562 m.isPaneOpen = await read($, isPaneOpen)
563 await run($)
564
565 return next(e)
566 })
567
568 on('session.end', async ($, e, next) => {
569 if (e.reason === 'clear') {
570 await update($, trips, () => [])
571 await update($, jumpCount, () => 0)
572 await update($, milestone, () => 0)
573 await update($, history, () => [])
574 await update($, budgetWarned, () => 0)
575 m.ledger = 0
576 m.shown = 0
577 m.ride = null
578 m.lastTrip = null
579 m.steps.clear()
580 await run($)
581 }
582
583 return next(e)
584 })
585
586 on('session.measure', async ($, e, next) => {
587 if (e.cost && e.changed.includes('cost')) await settle($, e.cost.usd)
588 // Rate-limit windows only come with a subscription: say once, ever, what /taxi plan does.
589 const isSubscribed = e.rateLimits.some(r => r.kind === 'five_hour' || r.kind === 'seven_day')
590 if (isSubscribed && cfg.planUsd <= 0 && (await $.store.get('planHinted')) !== true) {
591 await $.store.set('planHinted', true)
592 $.ui.toast(
593 cfg.lang === 'zh'
594 ? '💎 检测到你在用订阅:/taxi plan 200(填你的月费)就能看到本月回本了几倍'
595 : "💎 You're on a subscription: /taxi plan 200 (your monthly price) shows how many times over it pays back",
596 { timeoutMs: 10000 },
597 )
598 }
599
600 return next(e)
601 })
602
603 on('turn.start', async ($, e, next) => {
604 m.ride = { startUsd: target(), startedAt: await $.clock.now(), prompt: clip(e.text), jumps: 0 }
605 await run($)
606
607 return next(e)
608 })
609
610 on('turn.step', async function* ($, e, next) {
611 const key = `${e.agentId ?? 'main'}:${e.turnId}:${e.index}`
612 const step: Step = { model: e.model, chars: 0, quietTokens: 0, lastChunkAt: await $.clock.now(), final: null }
613 m.steps.set(key, step)
614 if (e.agentId === undefined || m.model === '') m.model = e.model
615 await run($)
616
617 try {
618 for await (const chunk of next(e)) {
619 if ((chunk.kind === 'text' || chunk.kind === 'thinking') && chunk.text.length > 0) {
620 step.chars += chunk.text.length
621 step.lastChunkAt = m.now
622 } else if (chunk.kind === 'input') {
623 step.chars += chunk.json.length
624 step.lastChunkAt = m.now
625 } else if (chunk.kind === 'stop') {
626 step.final = chunk.usage ? costOf(chunk.usage.model, chunk.usage) : 0
627 }
628 yield chunk
629 }
630 } finally {
631 // Interrupted before the stop: what was spent is the ledger's to say.
632 if (step.final === null) step.final = 0
633 // The ledger may have counted this response before its stop reached us.
634 $.clock.after(600, () => void resync($))
635 }
636 })
637
638 on('tool.call', async ($, e, next) => {
639 m.tools += 1
640 m.tool = e.tool
641 await run($)
642 try {
643 return await next(e)
644 } finally {
645 m.tools -= 1
646 if (m.tools === 0) m.tool = null
647 await run($)
648 }
649 })
650
651 on('turn.complete', async ($, e, next) => {
652 if (e.agentId !== undefined || m.ride === null) return next(e)
653
654 await resync($)
655 const done = m.ride
656 m.ride = null
657 const at = await $.clock.now()
658 const trip: Trip = {
659 fare: Math.max(0, target() - done.startUsd),
660 ms: at - done.startedAt,
661 at,
662 prompt: done.prompt,
663 model: e.usage?.model ?? m.model,
664 jumps: done.jumps,
665 }
666 m.lastTrip = units(trip.fare)
667 await update($, trips, list => [...list, trip].slice(-200))
668 if (trip.fare >= cfg.bigTripUsd) {
669 $.ui.toast(bigTripToast(trip, cfg.lang, cfg.currency, cfg.cnyRate), { timeoutMs: 6000 })
670 }
671 await run($)
672
673 return next(e)
674 })
675
676 on('command.run', { command: 'taxi' }, async ($, e) => {
677 const [word = '', arg = ''] = e.args.trim().toLowerCase().split(/\s+/)
678 const zh = cfg.lang === 'zh'
679
680 switch (word) {
681 case '':
682 case 'receipt':
683 return { text: await printReceipt($) }
684 case 'meter':
685 case 'pane':
686 await openPane($)
687 return { text: zh ? '🚕 计价器面板已打开' : '🚕 Meter pane opened' }
688 case 'budget': {
689 if (arg === '') {
690 const now = m.budgetUsd > 0 ? moneyUsd(m.budgetUsd, cfg.currency, cfg.cnyRate) : null
691 return { text: now ? (zh ? `本次预算 ${now}` : `Budget this session: ${now}`) : zh ? '未设预算' : 'No budget set' }
692 }
693 if (arg === 'off' || arg === '0') {
694 await setBudget($, 0)
695 return { text: zh ? '预算已清除' : 'Budget cleared' }
696 }
697 const usd = Number(arg.replace(/^[$¥]/, '')) / (arg.startsWith('¥') ? cfg.cnyRate : 1)
698 if (!Number.isFinite(usd) || usd <= 0) {
699 return { text: zh ? '用法:/taxi budget 5(美元)或 /taxi budget off' : 'Usage: /taxi budget 5 (USD) or /taxi budget off' }
700 }
701 await setBudget($, usd)
702 const limit = moneyUsd(usd, cfg.currency, cfg.cnyRate)
703 return { text: zh ? `预算设为 ${limit},用到 80% 和 100% 时提醒` : `Budget set to ${limit}: you'll be warned at 80% and 100%` }
704 }
705 case 'usd':
706 case 'cny':
707 await $.config.set({ key: 'taxi-meter.currency', value: word.toUpperCase() })
708 return { text: zh ? `币种已切换为 ${word.toUpperCase()}` : `Currency switched to ${word.toUpperCase()}` }
709 case 'en':
710 case 'zh':
711 await $.config.set({ key: 'taxi-meter.language', value: word })
712 return { text: word === 'zh' ? '计价器已切换为中文' : 'Meter switched to English' }
713 case 'plan': {
714 if (arg === '') {
715 return {
716 text:
717 cfg.planUsd > 0
718 ? zh
719 ? `订阅 ${moneyUsd(cfg.planUsd, cfg.currency, cfg.cnyRate)}/月,本月回本 ${paybackText(m.monthUsd / cfg.planUsd, 'zh')}`
720 : `Plan ${moneyUsd(cfg.planUsd, cfg.currency, cfg.cnyRate)}/month, ${paybackText(m.monthUsd / cfg.planUsd, 'en')} paid back this month`
721 : zh
722 ? '未设置订阅。用法:/taxi plan 200(每月美元)'
723 : 'No plan set. Usage: /taxi plan 200 (USD a month)',
724 }
725 }
726 const usd = arg === 'off' ? 0 : Number(arg.replace(/^\$/, ''))
727 if (!Number.isFinite(usd) || usd < 0) {
728 return { text: zh ? '用法:/taxi plan 200 或 /taxi plan off' : 'Usage: /taxi plan 200 or /taxi plan off' }
729 }
730 await $.config.set({ key: 'taxi-meter.planUsd', value: usd })
731 if (usd === 0) return { text: zh ? '已关闭回本模式' : 'Payback mode off' }
732 const x = paybackText(m.monthUsd / usd, cfg.lang)
733 return {
734 text: zh
735 ? `订阅设为 $${usd}/月:本月的用量已回本 ${x}`
736 : `Plan set to $${usd}/month: this month's usage has paid back ${x}`,
737 }
738 }
739 case 'share': {
740 const text = shareCard(await figures($))
741 const copied = await $.ui.copy({ text })
742 const note = copied.isCopied
743 ? zh ? '(已复制到剪贴板)' : '(copied to your clipboard)'
744 : zh ? '(没能复制,请手动选中上面的文字)' : "(couldn't copy: select the text above)"
745 return { text: `${text}\n\n${note}` }
746 }
747 case 'demo': {
748 if (m.demo) return { text: zh ? '演示已经在跑了' : 'A demo ride is already running' }
749 if (phase() !== 'idle') {
750 return { text: zh ? '计价器正在载客,等 Claude 这轮结束后再演示' : 'The meter is busy: run the demo once Claude finishes this turn' }
751 }
752 await startDemo($)
753 return {
754 text: zh
755 ? '🎬 演示行程开始:约 14 秒,不计费、不记录,结束后计价器恢复原样'
756 : '🎬 Demo ride started: about 14 seconds, nothing billed or recorded, and the meter goes back to normal after',
757 }
758 }
759 case 'hide':
760 await setHidden($, true)
761 return { text: zh ? '状态栏计价器已隐藏(/taxi show 恢复)' : 'Status line meter hidden (/taxi show to bring it back)' }
762 case 'show':
763 await setHidden($, false)
764 return { text: zh ? '状态栏计价器已显示' : 'Status line meter shown' }
765 default:
766 return { text: HELP[cfg.lang].join('\n') }
767 }
768 })
769
770 // The receipt as a card: the plain text stays what the model reads.
771 on('ui.render', { component: 'CommandOutput', props: { command: 'taxi' } }, async ($, e, next) => {
772 if (e.props.isErrored || !e.props.text.startsWith('🧾')) return next(e)
773 const { Box, Text } = $.ui.resolve(e)
774 const lines = e.props.text.split('\n')
775
776 return (
777 <Box flexDirection="column" borderStyle="round" borderColor="#f5c518" paddingX={1} alignSelf="flex-start">
778 {lines.map((line, i) =>
779 i === 0 ? (
780 <Text bold color="#f5c518">
781 {line}
782 </Text>
783 ) : line.startsWith('─') ? (
784 <Text dimColor>{line}</Text>
785 ) : /^(TOTAL|实收金额)/.test(line) ? (
786 <Text bold>{line}</Text>
787 ) : line.startsWith('≈') || i === lines.length - 1 ? (
788 <Text dimColor italic>
789 {line}
790 </Text>
791 ) : (
792 <Text>{line}</Text>
793 ),
794 )}
795 </Box>
796 )
797 })
798
799 on('ui.close', { id: PANE }, async ($, e, next) => {
800 m.isPaneOpen = false
801 if (e.origin.kind !== 'unload') await update($, isPaneOpen, () => false)
802
803 return next(e)
804 })
805
806 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
807 const { Box, Text, Button } = $.ui.resolve(e)
808 const w = PANE_WORDS[cfg.lang]
809 const now = await read($, live)
810 const limit = await read($, budget)
811 const hidden = await read($, isHidden)
812 const rides = await read($, trips)
813 const points = await read($, history)
814 const cols = Math.max(20, e.props.bodyColumns)
815
816 const fare = money(now.units, cfg.currency)
817 const digits = bigDigits(fare)
818 const isBig = digits !== null && digits[0]!.length <= cols
819 const pct = budgetPct({ units: now.units, budget: limit > 0 ? units(limit) : null })
820 const lamp =
821 now.phase === 'idle'
822 ? { text: cfg.lang === 'zh' ? '空车' : 'FOR HIRE', color: 'green' }
823 : now.phase === 'waiting'
824 ? { text: cfg.lang === 'zh' ? '等候 ⏳' : 'WAITING ⏳', color: 'yellow' }
825 : { text: `${cfg.lang === 'zh' ? '载客' : 'HIRED'} ${now.isLit ? '●' : '○'}`, color: 'red' }
826 const drumColor = pct !== null && pct >= 100 ? 'red' : '#f5c518'
827
828 const barWidth = Math.max(8, Math.min(30, cols - 24))
829 const filled = pct === null ? 0 : Math.min(barWidth, Math.round((pct / 100) * barWidth))
830 const spend = spendBuckets(points, await $.clock.now(), Math.max(10, Math.min(30, cols - 16)), 60000)
831
832 return (
833 <Box flexDirection="column">
834 <Box flexDirection="row" justifyContent="space-between">
835 <Text bold color={lamp.color}>
836 {lamp.text}
837 </Text>
838 <Text dimColor>{w.brand}</Text>
839 </Box>
840 {isBig ? (
841 <Box flexDirection="column" marginY={1}>
842 {digits.map(row => (
843 <Text color={drumColor} bold>
844 {row}
845 </Text>
846 ))}
847 </Box>
848 ) : (
849 <Text color={drumColor} bold>
850 {fare}
851 </Text>
852 )}
853 <Text>
854 {now.trip !== null && now.trip > 0
855 ? `${now.phase === 'idle' ? w.last : w.ride} ${money(now.trip, cfg.currency)}`
856 : ' '}
857 {now.flash > 0 ? ` ▲${(now.flash / 100).toFixed(2)}` : ''}
858 </Text>
859 {now.phase !== 'idle' && (
860 <Text dimColor>
861 {w.time} {duration(now.rideMs)}
862 {now.rate >= 1 ? ` · ${w.rate} ${money(Math.round(now.rate), cfg.currency)}/min` : ''}
863 {now.tool ? ` · ${now.tool}` : ''}
864 </Text>
865 )}
866 <Text>
867 {pct === null ? (
868 <Text dimColor>{w.noBudget}</Text>
869 ) : (
870 <Text color={pct >= 100 ? 'red' : pct >= 80 ? 'yellow' : 'green'}>
871 {w.budget} {'█'.repeat(filled)}
872 {'░'.repeat(barWidth - filled)} {pct}% / {moneyUsd(limit, cfg.currency, cfg.cnyRate)}
873 </Text>
874 )}
875 </Text>
876 {cfg.planUsd > 0 && (
877 <Text>
878 <Text dimColor>{w.payback} </Text>
879 <Text color="cyan" bold>
880 💎 {paybackText(payback() ?? 0, cfg.lang)}
881 </Text>
882 <Text dimColor>
883 {' '}
884 {moneyUsd(m.monthUsd, cfg.currency, cfg.cnyRate)} / {moneyUsd(cfg.planUsd, cfg.currency, cfg.cnyRate)}
885 </Text>
886 </Text>
887 )}
888 <Text>
889 <Text dimColor>{w.spend} </Text>
890 <Text color="#f5c518">{sparkline(spend)}</Text>
891 </Text>
892 <Box flexDirection="column" marginTop={1}>
893 <Text bold>{w.recent}</Text>
894 {rides.length === 0 && <Text dimColor>{w.none}</Text>}
895 {rides
896 .slice(-4)
897 .reverse()
898 .map(t => (
899 <Text wrap="truncate-end">
900 {moneyUsd(t.fare, cfg.currency, cfg.cnyRate).padStart(8)} {t.prompt}
901 </Text>
902 ))}
903 </Box>
904 <Box flexDirection="row" flexWrap="wrap" marginTop={1} gap={1}>
905 <Button key="receipt" hotkey="r" label={w.receipt} onPress={() => $.command.run({ command: 'taxi' })} />
906 <Button
907 key="currency"
908 hotkey="c"
909 label={w.currency}
910 onPress={() =>
911 $.config.set({ key: 'taxi-meter.currency', value: cfg.currency === 'USD' ? 'CNY' : 'USD' })
912 }
913 />
914 <Button key="budget" hotkey="b" label={w.addBudget} onPress={() => setBudget($, Math.max(0, limit) + 5)} />
915 {limit > 0 && <Button key="nobudget" hotkey="n" label={w.clearBudget} onPress={() => setBudget($, 0)} />}
916 <Button key="hide" hotkey="h" label={hidden ? w.show : w.hide} onPress={() => setHidden($, !hidden)} />
917 <Button key="close" role="dismiss" label={w.close} onPress={() => $.ui.close({ id: PANE })} />
918 </Box>
919 </Box>
920 )
921 })
922}
923hooks/meter.ts 442 lines1// The meter's arithmetic and words: no `$`, so the tests can call it bare.
2
3import type { ModelUsage } from 'claude-code'
4
5import type { Trip } from '../types'
6
7export type Lang = 'zh' | 'en'
8export type Currency = 'USD' | 'CNY'
9export type Phase = 'idle' | 'hired' | 'waiting'
10
11/** USD per million tokens, [input, output], first match wins. */
12const PRICES: readonly [RegExp, number, number][] = [
13 [/fable|mythos/, 10, 50],
14 [/opus-5-5|opus-5\.5/, 4, 20],
15 [/opus-4-(0|1)\b|opus-4-1|opus-4-2025|opus-3/, 15, 75],
16 [/opus/, 5, 25],
17 [/sonnet-5/, 2, 10],
18 [/sonnet/, 3, 15],
19 [/haiku-4/, 1, 5],
20 [/haiku-3-5/, 0.8, 4],
21 [/haiku/, 0.25, 1.25],
22]
23
24export function priceOf(model: string): { input: number; output: number } {
25 const id = model.toLowerCase()
26 for (const [re, input, output] of PRICES) {
27 if (re.test(id)) return { input, output }
28 }
29 return { input: 5, output: 25 }
30}
31
32/** What one response cost at list price; the session ledger corrects it. */
33export function costOf(model: string, u: ModelUsage): number {
34 const p = priceOf(model)
35 return (
36 (u.input_tokens * p.input +
37 u.output_tokens * p.output +
38 u.cache_read_input_tokens * p.input * 0.1 +
39 u.cache_creation_input_tokens * p.input * 1.25) /
40 1e6
41 )
42}
43
44/** Output still streaming: about four characters a token. */
45export function streamingCost(model: string, chars: number, quietTokens: number): number {
46 return ((chars / 4 + quietTokens) * priceOf(model).output) / 1e6
47}
48
49/** USD to the display currency's smallest unit (cents or fen): one meter jump. */
50export function toUnits(usd: number, currency: Currency, cnyRate: number): number {
51 const factor = currency === 'CNY' ? cnyRate : 1
52 return Math.floor(usd * factor * 100 + 1e-6)
53}
54
55export function money(units: number, currency: Currency): string {
56 const sign = currency === 'CNY' ? '¥' : '$'
57 return `${sign}${(units / 100).toFixed(2)}`
58}
59
60export function moneyUsd(usd: number, currency: Currency, cnyRate: number): string {
61 return money(toUnits(usd, currency, cnyRate), currency)
62}
63
64/** How many units the meter rolls this tick: a big jump rolls over a few ticks. */
65export function rollStep(gap: number): number {
66 if (gap <= 0) return 0
67 return Math.max(1, Math.ceil(gap / 4))
68}
69
70export const MILESTONES = [0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000]
71
72/** The highest milestone at or under `usd`, 0 when none. */
73export function milestoneAt(usd: number): number {
74 let hit = 0
75 for (const m of MILESTONES) if (usd + 1e-9 >= m) hit = m
76 return hit
77}
78
79const WORDS = {
80 zh: {
81 idle: '空车',
82 hired: '载客',
83 waiting: '等候',
84 trip: '本程',
85 lastTrip: '上一程',
86 perMin: '/分',
87 budget: '预算',
88 over: '超预算',
89 },
90 en: {
91 idle: 'FOR HIRE',
92 hired: 'HIRED',
93 waiting: 'WAITING',
94 trip: 'trip',
95 lastTrip: 'last',
96 perMin: '/min',
97 budget: 'budget',
98 over: 'OVER BUDGET',
99 },
100} as const
101
102export type Face = {
103 phase: Phase
104 /** Units on the drum now. */
105 units: number
106 /** Units the last burst of jumps added, shown for a moment. */
107 flash: number
108 /** This ride's fare so far, or the last ride's when idle, in units. */
109 trip: number | null
110 /** Units per minute over the last minute, while hired. */
111 rate: number
112 /** The tool the meter is waiting on. */
113 tool: string | null
114 /** The hired light: on, off, on... */
115 isLit: boolean
116 /** The fare limit in units, or null for none. */
117 budget: number | null
118 /** This month's API value over the subscription's price, or null off a plan. */
119 payback?: number | null
120}
121
122/** "47%" under 1×, "3.2×" from there. */
123export function paybackText(x: number, lang: Lang): string {
124 if (x < 1) return `${Math.floor(x * 100)}%`
125 const n = x >= 10 ? Math.floor(x).toString() : (Math.floor(x * 10) / 10).toFixed(1)
126 return lang === 'zh' ? `${n} 倍` : `${n}×`
127}
128
129export const PAYBACK_MILESTONES = [1, 2, 3, 5, 10, 20, 50, 100]
130
131/** The highest payback milestone reached, 0 when none. */
132export function paybackAt(x: number): number {
133 let hit = 0
134 for (const m of PAYBACK_MILESTONES) if (x + 1e-9 >= m) hit = m
135 return hit
136}
137
138export function paybackToast(level: number, plan: string, lang: Lang): string {
139 if (level === 1) {
140 return lang === 'zh'
141 ? `🎉 回本了!这个月的用量已经抵得上 ${plan} 的订阅费`
142 : `🎉 Paid back! This month's usage is now worth your ${plan} plan`
143 }
144 return lang === 'zh'
145 ? `💎 回本 ${level} 倍:这个月白赚了 ${level - 1} 份订阅费`
146 : `💎 ${level}× paid back: this month's usage is worth ${level} of your ${plan} plans`
147}
148
149/** How much of the budget the drum shows, in percent; null with no budget. */
150export function budgetPct(f: Pick<Face, 'units' | 'budget'>): number | null {
151 return f.budget === null || f.budget <= 0 ? null : Math.floor((f.units / f.budget) * 100)
152}
153
154/** The status line: one row, steady width for the drum. */
155export function face(f: Face, lang: Lang, currency: Currency): string {
156 const w = WORDS[lang]
157 const label =
158 f.phase === 'idle'
159 ? `🚕 ${w.idle}`
160 : f.phase === 'waiting'
161 ? `🚕 ${w.waiting}⏳`
162 : `🚖 ${w.hired}${f.isLit ? '●' : '○'}`
163 const drum = money(f.units, currency).padStart(7, ' ')
164 const parts = [`${label} ${drum}`]
165 if (f.flash > 0) parts[0] += ` ▲${(f.flash / 100).toFixed(2)}`
166 if (f.trip !== null && f.trip > 0) {
167 parts.push(`${f.phase === 'idle' ? w.lastTrip : w.trip} ${money(f.trip, currency)}`)
168 }
169 if (f.phase === 'hired' && f.rate >= 1) {
170 parts.push(`🔥${money(Math.round(f.rate), currency)}${w.perMin}`)
171 }
172 if (f.phase === 'waiting' && f.tool) parts.push(f.tool)
173 const pct = budgetPct(f)
174 if (pct !== null) {
175 parts.push(pct >= 100 ? `🛑 ${w.over}` : `${pct >= 80 ? '⚠️ ' : ''}${w.budget} ${pct}%`)
176 }
177 if (f.payback !== undefined && f.payback !== null) {
178 parts.push(`💎 ${lang === 'zh' ? '回本 ' : ''}${paybackText(f.payback, lang)}${lang === 'zh' ? '' : ' plan'}`)
179 }
180
181 return parts.join(' · ')
182}
183
184export function milestoneToast(m: number, lang: Lang, currency: Currency, cnyRate: number): string {
185 const fare = moneyUsd(m, currency, cnyRate)
186 const lattes = (m / 5).toFixed(1)
187 return lang === 'zh'
188 ? `💸 叮!车费突破 ${fare},够买 ${lattes} 杯拿铁了`
189 : `💸 Ka-ching! The fare just passed ${fare}: ${lattes} lattes`
190}
191
192export function bigTripToast(t: Trip, lang: Lang, currency: Currency, cnyRate: number): string {
193 const fare = moneyUsd(t.fare, currency, cnyRate)
194 const secs = Math.round(t.ms / 1000)
195 return lang === 'zh'
196 ? `🧾 这一程 ${fare} · ${secs}s · 跳表 ${t.jumps} 次`
197 : `🧾 That ride: ${fare} · ${secs}s · ${t.jumps} jumps`
198}
199
200/** What the money would have bought on the street instead. */
201export function streetValue(usd: number, lang: Lang, cnyRate: number): string {
202 if (lang === 'zh') {
203 // 北京出租车:起步 13 元含 3 公里,之后 2.3 元/公里
204 const cny = usd * cnyRate
205 const km = cny <= 13 ? (cny / 13) * 3 : 3 + (cny - 13) / 2.3
206 return `≈ ${(usd / 5).toFixed(1)} 杯拿铁 · ≈ 北京打车 ${km.toFixed(1)} 公里`
207 }
208 // NYC yellow cab: $3 to get in, $3.50 a mile
209 const miles = usd <= 3 ? 0 : (usd - 3) / 3.5
210 return `≈ ${(usd / 5).toFixed(1)} lattes · ≈ ${miles.toFixed(1)} miles in a NYC cab`
211}
212
213export type ReceiptInput = {
214 lang: Lang
215 currency: Currency
216 cnyRate: number
217 ledger: number
218 trips: readonly Trip[]
219 jumps: number
220 startedAt: number
221 now: number
222 lifetime: number
223 model: string
224 /** API value this month, and the plan's monthly price (0 off a plan). */
225 monthUsd?: number
226 planUsd?: number
227}
228
229function clock(ms: number): string {
230 const d = new Date(ms)
231 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
232}
233
234function minutes(ms: number): string {
235 const m = Math.max(0, Math.round(ms / 60000))
236 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
237}
238
239/** The receipt `/taxi` prints. */
240export function receipt(r: ReceiptInput): string {
241 const $ = (usd: number) => moneyUsd(usd, r.currency, r.cnyRate)
242 const top = r.trips.reduce<Trip | null>((a, t) => (a === null || t.fare > a.fare ? t : a), null)
243 const avg = r.trips.length ? r.trips.reduce((sum, t) => sum + t.fare, 0) / r.trips.length : 0
244 const zh = r.lang === 'zh'
245 const rows: [string, string][] = zh
246 ? [
247 ['车型', r.model || '—'],
248 ['上车', `${clock(r.startedAt)} 已行驶 ${minutes(r.now - r.startedAt)}`],
249 ['里程', `${r.trips.length} 程 · 跳表 ${r.jumps} 次`],
250 ['均价', `${$(avg)} / 程`],
251 ['最贵', top ? `${$(top.fare)}「${top.prompt}」` : '—'],
252 ['累计', `${$(r.lifetime)}(所有会话)`],
253 ...((r.planUsd ?? 0) > 0
254 ? [['本月', `${$(r.monthUsd ?? 0)} / 订阅 ${$(r.planUsd!)} → 回本 ${paybackText((r.monthUsd ?? 0) / r.planUsd!, 'zh')}`] as [string, string]]
255 : []),
256 ]
257 : [
258 ['Car', r.model || '—'],
259 ['Hailed', `${clock(r.startedAt)} riding ${minutes(r.now - r.startedAt)}`],
260 ['Rides', `${r.trips.length} · ${r.jumps} jumps`],
261 ['Average', `${$(avg)} / ride`],
262 ['Priciest', top ? `${$(top.fare)} "${top.prompt}"` : '—'],
263 ['Lifetime', `${$(r.lifetime)} (all sessions)`],
264 ...((r.planUsd ?? 0) > 0
265 ? [['Month', `${$(r.monthUsd ?? 0)} on a ${$(r.planUsd!)} plan → ${paybackText((r.monthUsd ?? 0) / r.planUsd!, 'en')} paid back`] as [string, string]]
266 : []),
267 ]
268 const rule = '─'.repeat(34)
269 const lines = [
270 zh ? '🧾 CLAUDE 出租车 · 乘车凭证' : '🧾 CLAUDE TAXI · RECEIPT',
271 rule,
272 ...rows.map(([k, v]) => `${k.padEnd(zh ? 4 : 9, zh ? ' ' : ' ')} ${v}`),
273 rule,
274 `${zh ? '实收金额' : 'TOTAL'} ${$(r.ledger)}`,
275 streetValue(r.ledger, r.lang, r.cnyRate),
276 ]
277 const recent = r.trips.slice(-5)
278 if (recent.length > 0) {
279 lines.push(rule, zh ? '最近几程' : 'Last rides')
280 for (const t of recent) {
281 lines.push(` ${clock(t.at)} ${$(t.fare).padStart(8)} ${t.prompt}`)
282 }
283 }
284 lines.push(rule, zh ? '谢谢乘坐,请带好随身物品 🙏' : 'Thanks for riding. Mind your belongings 🙏')
285
286 return lines.join('\n')
287}
288
289// Seven-segment glyphs, three rows each, for the pane's drum.
290const SEGMENTS: Record<string, readonly [string, string, string]> = {
291 '0': ['┏━┓', '┃ ┃', '┗━┛'],
292 '1': [' ╻', ' ┃', ' ╹'],
293 '2': ['╺━┓', '┏━┛', '┗━╸'],
294 '3': ['╺━┓', ' ━┫', '╺━┛'],
295 '4': ['╻ ╻', '┗━┫', ' ╹'],
296 '5': ['┏━╸', '┗━┓', '╺━┛'],
297 '6': ['┏━╸', '┣━┓', '┗━┛'],
298 '7': ['╺━┓', ' ┃', ' ╹'],
299 '8': ['┏━┓', '┣━┫', '┗━┛'],
300 '9': ['┏━┓', '┗━┫', '╺━┛'],
301 '.': [' ', ' ', '▪'],
302 '$': ['┏╋╸', '┗╋┓', '╺╋┛'],
303 '¥': ['╲ ╱', '━┳━', '╺╋╸'],
304}
305
306/** A fare in seven-segment digits: three rows, or none when a glyph is missing. */
307export function bigDigits(text: string): string[] | null {
308 const rows = ['', '', '']
309 for (const ch of text) {
310 const g = SEGMENTS[ch]
311 if (!g) return null
312 for (let i = 0; i < 3; i++) rows[i] += (rows[i] ? ' ' : '') + g[i]
313 }
314 return rows
315}
316
317const BARS = '▁▂▃▄▅▆▇█'
318
319/** Spend per bucket as a one-line chart; an empty bucket is the lowest bar. */
320export function sparkline(values: readonly number[]): string {
321 const top = Math.max(...values, 0)
322 return values
323 .map(v => (top <= 0 || v <= 0 ? BARS[0] : BARS[Math.min(7, Math.ceil((v / top) * 7))]))
324 .join('')
325}
326
327/** The ledger's history cut into `count` buckets of `bucketMs`, ending at `now`: spend in each. */
328export function spendBuckets(
329 history: readonly (readonly [number, number])[],
330 now: number,
331 count: number,
332 bucketMs: number,
333): number[] {
334 const at = (t: number) => {
335 let usd = 0
336 for (const [time, value] of history) if (time <= t) usd = value
337 return usd
338 }
339 const out: number[] = []
340 for (let i = count - 1; i >= 0; i--) {
341 const end = now - i * bucketMs
342 out.push(Math.max(0, at(end) - at(end - bucketMs)))
343 }
344 return out
345}
346
347export function duration(ms: number): string {
348 const s = Math.max(0, Math.floor(ms / 1000))
349 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
350}
351
352export const PANE_WORDS = {
353 zh: {
354 title: '🚕 计价器',
355 brand: 'CLAUDE 出租车',
356 ride: '本程',
357 last: '上一程',
358 time: '用时',
359 rate: '速度',
360 budget: '预算',
361 noBudget: '未设预算',
362 spend: '近 30 分钟',
363 payback: '本月回本',
364 recent: '最近几程',
365 none: '还没有乘客',
366 receipt: '小票',
367 currency: '换币种',
368 addBudget: '预算 +$5',
369 clearBudget: '清除预算',
370 hide: '隐藏状态栏',
371 show: '显示状态栏',
372 close: '关闭',
373 },
374 en: {
375 title: '🚕 Meter',
376 brand: 'CLAUDE TAXI',
377 ride: 'ride',
378 last: 'last ride',
379 time: 'time',
380 rate: 'rate',
381 budget: 'budget',
382 noBudget: 'no budget',
383 spend: 'last 30 min',
384 payback: 'plan paid back',
385 recent: 'Recent rides',
386 none: 'No rides yet',
387 receipt: 'Receipt',
388 currency: 'USD⇄CNY',
389 addBudget: 'Budget +$5',
390 clearBudget: 'No budget',
391 hide: 'Hide status',
392 show: 'Show status',
393 close: 'Close',
394 },
395} as const
396
397export function budgetToast(pct: number, budget: string, lang: Lang): string {
398 if (pct >= 100) {
399 return lang === 'zh'
400 ? `🛑 已超出预算 ${budget}。/taxi budget 可以加钱,或者该下车了`
401 : `🛑 Over your ${budget} budget. /taxi budget to top up, or time to get out`
402 }
403 return lang === 'zh'
404 ? `⚠️ 预算 ${budget} 已用 ${pct}%`
405 : `⚠️ ${pct}% of your ${budget} budget is gone`
406}
407
408export type ShareInput = ReceiptInput
409
410export const REPO_URL = 'github.com/gxcsoccer/claude-taxi-meter'
411
412/** A few lines to paste anywhere: the brag (or the confession). */
413export function shareCard(r: ShareInput): string {
414 const $ = (usd: number) => moneyUsd(usd, r.currency, r.cnyRate)
415 const top = r.trips.reduce<Trip | null>((a, t) => (a === null || t.fare > a.fare ? t : a), null)
416 const zh = r.lang === 'zh'
417 const plan = r.planUsd ?? 0
418 const lines = zh
419 ? [
420 '🚕 Claude 出租车 · 今日行程',
421 `⏱ ${minutes(r.now - r.startedAt)} · ${r.trips.length} 程 · 跳表 ${r.jumps} 次`,
422 `💸 车费 ${$(r.ledger)}(${streetValue(r.ledger, 'zh', r.cnyRate)})`,
423 ]
424 : [
425 '🚕 Claude Taxi · today\'s ride',
426 `⏱ ${minutes(r.now - r.startedAt)} · ${r.trips.length} rides · ${r.jumps} jumps`,
427 `💸 Fare ${$(r.ledger)} (${streetValue(r.ledger, 'en', r.cnyRate)})`,
428 ]
429 if (plan > 0) {
430 const x = paybackText((r.monthUsd ?? 0) / plan, r.lang)
431 lines.push(zh ? `💎 本月订阅已回本 ${x}` : `💎 This month's plan: ${x} paid back`)
432 }
433 if (top) lines.push(zh ? `🧾 最贵一程 ${$(top.fare)}「${top.prompt}」` : `🧾 Priciest ride ${$(top.fare)} "${top.prompt}"`)
434 lines.push(REPO_URL)
435 return lines.join('\n')
436}
437
438export function monthKey(ms: number): string {
439 const d = new Date(ms)
440 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
441}
442types/index.d.ts 56 lines1/** One finished turn: a ride, from prompt to answer. */
2export type Trip = {
3 /** What the ride cost, USD. */
4 fare: number
5 /** How long it took, ms. */
6 ms: number
7 /** When it ended, epoch ms. */
8 at: number
9 /** The first words of the prompt that hailed it. */
10 prompt: string
11 /** The model that drove it. */
12 model: string
13 /** How many times the meter jumped during it. */
14 jumps: number
15}
16
17/** What the meter shows now: the pane draws from it. */
18export type Live = {
19 phase: 'idle' | 'hired' | 'waiting'
20 /** Cents (or fen) on the drum. */
21 units: number
22 flash: number
23 /** This ride's fare, or the last ride's while idle, in units. */
24 trip: number | null
25 /** Units a minute over the last minute. */
26 rate: number
27 tool: string | null
28 isLit: boolean
29 /** How long this ride has run, ms; 0 while idle. */
30 rideMs: number
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 'taxi-meter': {
36 trips: Trip[]
37 /** The highest milestone (USD) already rung this session. */
38 milestone: number
39 /** Meter jumps this session. */
40 jumps: number
41 /** This session's fare limit, USD; 0 for none. */
42 budgetUsd: number
43 /** The last budget warning given: 0, 80 or 100 (percent). */
44 budgetWarned: number
45 /** Whether the status line entry is hidden. */
46 isHidden: boolean
47 /** Whether the meter pane is open, so the tick feeds it. */
48 isPaneOpen: boolean
49 /** The meter as drawn, for the pane. */
50 live: Live
51 /** The ledger over time: [epoch ms, USD]. */
52 history: [number, number][]
53 }
54 }
55}
56