SLOPSHOPPER

taxi-meter

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

newpanerowsguardcommandtoast
v0.1.0MITupdated 2026-10-06gxcsoccer/claude-taxi-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · taxi-meter
│ ┃ taxi-meter ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ FOR HIRE CLAUDE TAXI │ taxi-meter │ │ ┃ ⏺ Read(src/auth.ts) │ 💎 You're on a subscription: /taxi plan │ │ ┃ ┏╋╸ ┏━┓ ┏━┓ ┏━┓ ⎿ Read 6 lines │ 200 (your monthly price) shows how many │ │ ┃ ┗╋┓ ┃ ┃ ┃ ┃ ┃ ┃ ⏺ Update(src/auth.ts) │ times over it pays back │ │ ┃ ╺╋┛ ┗━┛ ▪ ┗━┛ ┗━┛ ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ no budget │ ┃ last 30 min ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Recent rides ✻ Worked for 42s · done 4:20 PM │ ┃ No rides yet │ ┃ › /taxi │ ┃ [ Receipt ] [ USD⇄CNY ] [ Budget +$5 ] [ Hid ╭────────────────────────────────────────────────────────────────── │ │ 🧾 CLAUDE TAXI · RECEIPT │ │ ────────────────────────────────── │ │ Car claude-opus-5-5 │ │ Hailed 01:23 riding 30m │ │ Rides 0 · 0 jumps │ │ Average $0.00 / ride │ │ Priciest — │ │ Lifetime $0.42 (all sessions) │ │ ────────────────────────────────── │ │ TOTAL $0.42 │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ taxi-meter: 🚕 FOR HIRE $0.42

Draws

Pane · taxi-meter
FOR HIRE CLAUDE TAXI ┏╋╸ ┏━┓ ┏━┓ ┏━┓ ┗╋┓ ┃ ┃ ┃ ┃ ┃ ┃ ╺╋┛ ┗━┛ ▪ ┗━┛ ┗━┛ no budget last 30 min ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█ Recent rides No rides yet [ Receipt ] [ USD⇄CNY ] [ Budget +$5 ] [ Hide status ] [ Clo
Command output
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 🧾 CLAUDE TAXI · RECEIPT │ │ ────────────────────────────────── │ │ Car claude-opus-5-5 │ │ Hailed 01:23 riding 30m │ │ Rides 0 · 0 jumps │ │ Average $0.00 / ride │ │ Priciest — │ │ Lifetime $0.42 (all sessions) │ │ ────────────────────────────────── │ │ TOTAL $0.42 │ │ ≈ 0.1 lattes · ≈ 0.0 miles in a NYC cab │ │ ────────────────────────────────── │ │ Thanks for riding. Mind your belongings 🙏 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

🚕 Claude Taxi Meter

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.

Claude Taxi Meter demo: the fare ticks up in the status line, the meter pane shows big LED digits, and a Ka-ching toast fires at $0.50

Install

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.

What you get

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
  • Ticks as tokens stream, then settles on the session's real total, the same number /cost shows.
  • Ka-ching 💸 at $0.50, $1, $2, $5, $10… with a toast and a cash-register sound.
  • A receipt toast 🧾 after any turn that costs $0.50 or more.

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.

Commands

CommandWhat it does
/taxiPrint the receipt
/taxi meterOpen the live meter pane
/taxi demoA pretend 14-second ride, for trying it out or recording a GIF. Nothing is billed or recorded
/taxi shareCopy a ride summary to the clipboard
/taxi plan 200Set your subscription's monthly price (USD) to see payback; off clears it
/taxi budget 5Set this session's fare limit (USD); off clears it
/taxi usd · cnySwitch currency
/taxi en · zhSwitch language
/taxi hide · showHide or show the status line meter

/taxi runs immediately, even while Claude is still working.

Options

Set them in /config, or under pluginConfigs["taxi-meter"].options in settings.json.

OptionDefault
languageenen (FOR HIRE / HIRED / WAITING) or zh (空车 / 载客 / 等候)
currencyUSDUSD or CNY
cnyRate7.1CNY per USD
soundtrueKa-ching at milestones (macOS)
bigTripUsd0.5Toast a receipt for turns at least this expensive
budgetUsd0Default fare limit per session; 0 for none
planUsd0Subscription price per month; turns on payback mode. 0 for API billing

How it works

Claude Code mods are plugins that run inside Claude Code itself, hooked into its events. The meter uses a handful:

HookWhat the meter does with it
turn.stepReads 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.measureSettles on the session's real cost, the figure /cost shows
ui.statusDraws the meter in the status line
ui.renderDraws the meter pane and the receipt card
command.runAnswers /taxi

The live estimate only fills the gap while a response is streaming. The total always lands on the real number.

FAQ

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.

Development

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.

License

MIT

Source 3 files
hooks/register.tsx 923 lines
1import { 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}
923
hooks/meter.ts 442 lines
1// 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}
442
types/index.d.ts 56 lines
1/** 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