SLOPSHOPPER

quanta-costa

Quanta Costa: your Claude subscription period, weekly limit with a pace forecast, session cost and plan value, as bars right above the Claude Code prompt.

newpanebandguardcommandtoast
v1.6.1MITupdated 2026-10-13wukonigcom/quanta-costa
A shopper browsing a rack in a slop shop
README

🧾 Quanta Costa: Claude Code usage limits and session cost, right above the prompt

License: MIT Claude Code plugin

Quanta Costa: Claude Code usage limits, pace forecast and session cost, shown right above the prompt

How much of your Claude plan is left, and what is this session costing you? Quanta Costa is a Claude Code plugin that answers both at a glance. It shows your billing period, your weekly usage limit with a pace forecast, the cost of the current session in tokens and dollars or euros, and whether your plan pays off, as a compact band right above the prompt. It works in the terminal and in the Claude desktop app.

🧾 Period   ████░░░░░░░░  1.4 of 4.3 weeks gone · 2.9 wk left · renews in 20 days
   Week     ██████░░░░░░  50% used · 50% free · on track (resets Oct 12)
   Session  3.3M tokens · $4.95 · last prompt $0.07 · 85k tokens   [Help]
   Value    ████████████  $412.30 API value vs $200.00 plan (2.1×)

Features

  • Weekly limit bar for Claude Pro and Max: how much is used, how much is free, when it resets. Green, yellow from 70%, red from 90%. Without a weekly limit it shows a spend limit or the 5-hour window instead.
  • Pace forecast: know before you hit the limit. "on track" if your week lasts until the reset, otherwise "runs out Fri 14:00".
  • Plan value: the API value of all your sessions this period against your plan price, e.g. 2.1×. Above 1× your subscription has paid for itself. If counting began after the period started, the row says since when.
  • Billing period bar: how many weeks of the month you paid for are gone and how many are left.
  • Session cost in tokens and in dollars or euros, plus the cost of your last prompt. Updates live after every tool call.
  • 5-hour limit warning: the short-term window only shows up when it passes 80%, with the time it frees up again.
  • Notices, each shown once: when the week passes 80% and 90% (with the pace forecast), when a fresh week starts, and at the end of each billing period a look back at its API value against your plan.
  • Built-in help: press h or click Help, and it explains every number, including whether the costs are real. Its last line shows the active settings and the version. More in the FAQ with examples.
  • Also on your phone: send /quanta-costa in a session you follow in the Claude mobile app, and Claude answers with the numbers in one line. The mobile app shows no band and no pane, so Quanta Costa hands Claude the numbers to reply with. In the terminal and the desktop app, /quanta-costa opens the view in a pane and prints the same line.
  • English or German: switch the language with one setting, numbers and dates follow (4,42 €, 12.10.).
  • Private by design: no network calls. All numbers come from Claude Code itself; the Value row keeps a small local record of cost readings in ~/.claude/quanta-costa/, shared by all your sessions, with a backup copy written every ten minutes, so even a computer crash costs at most the last minutes. Besides those files it only reads its own entry in Claude's settings file (see FAQ).

Install

In a shell, in one line, with your settings:

claude plugin install quanta-costa --marketplace wukonigcom/quanta-costa --config billingDay=29 --config currency=EUR --config planPrice=180

Put in your own billing day, currency (USD or EUR) and monthly plan price, or leave the --config parts out and set them later (see Settings).

Or inside Claude Code:

/plugin marketplace add wukonigcom/quanta-costa
/plugin install quanta-costa@quanta-costa

Then start a new Claude Code session. Claude desktop app: quit it completely (from the tray icon, closing the window is not enough) and open it again. A plugin installed for the user scope runs there too, in the Code tab.

Check: the band appears above the prompt. Press h: the last line of the help shows your settings and the version.

Update

claude plugin marketplace update quanta-costa
claude plugin update quanta-costa@quanta-costa

Then start a new session, or quit and reopen the desktop app. /reload-plugins alone can keep the old version running.

Uninstall

claude plugin uninstall quanta-costa@quanta-costa

The ledger stays in ~/.claude/quanta-costa/; delete that folder too if you want it gone.

Settings

SettingWhat it doesDefault
languageen (English) or de (German): labels, help, number and date formatsen
billingDayDay of the month your subscription renews (1 to 31). It is on your Anthropic receipt. 0 hides the Period row.0
currencyUSD or EURUSD
eurRateEuros per US dollar, used with EUR0.89238 (ECB, 2026-10-09)
planPriceYour monthly plan price in your currency, as on your receipt. Turns on the Value row. 0 hides it.0
openPaneOpens the Quanta Costa pane beside the chat at every start. false keeps it closed; /quanta-costa still opens it.true

Change them later with /plugin configure quanta-costa in Claude Code or claude plugin configure quanta-costa in a shell.

FAQ

The short answers are here. The full FAQ covers every feature with worked examples: Period, Week and pace, the 5-hour limit, Session, Value, notices, the phone pane, language, currency, settings and troubleshooting.

Are the costs real? On a Claude subscription (Pro or Max): no, they are hypothetical. Quanta Costa shows what the session would cost at API list prices, the same figure /cost totals. You do not pay it on top; it only counts against your weekly allowance. You only really pay for extra usage beyond your limit, if you turned it on. On an API key, Claude Code reports no subscription limits, and the help says the costs are real.

What are the weekly limit and the 5-hour limit? Claude plans meter usage in two windows: a weekly allowance and a short 5-hour window. Quanta Costa shows the weekly one as a bar and only warns about the 5-hour one when it gets tight, because that is the one that usually stops you.

How does the pace forecast work? It extrapolates your usage so far in the current week: if you keep going at the same rate, when would you hit 100%? If that is after the reset, you are on track. It waits until a few hours of the week have passed, so it does not guess from too little data.

How is the plan value calculated? Each run of a session (one Claude Code process with Quanta Costa loaded) records its first and its highest API-equivalent cost reading for the current billing period (or calendar month without billingDay) in a small file on your machine (~/.claude/quanta-costa/value.json), shared by all sessions. The Value row adds up what every run added and divides by planPrice. A resumed session counts with every run and never twice, whether Claude Code carries its cost over or starts from zero. If counting began after the period started, the row says since when. Only sessions where Quanta Costa ran are counted, so the real value is usually higher.

Value looked too high before 1.6.1. Versions 1.5.2 to 1.6.0 could count a session's cost twice when several sessions wrote the shared file at the same moment. 1.6.1 fixes it; the FAQ says how to start clean.

My settings do not show up (only two rows, dollars instead of euros). Claude Code hands a plugin the options stored under the name it loaded the plugin by. The Claude desktop app loads a plugin installed from a local folder as quanta-costa@inline, while claude plugin configure stores the options as quanta-costa@<marketplace>, so only defaults arrive. Since 1.3.2 Quanta Costa notices this and reads its own entry from ~/.claude/settings.json (pluginConfigs, under any of its names). The last line of the help shows the active settings and the version, so you can check that they arrived.

Why is there no Period row? Claude Code does not know your billing date. Set billingDay once and the row appears.

Why do the tokens start at zero mid-session? Tokens are summed from the moment the plugin loads. The dollar or euro amount is Claude Code's own total for the session, the same as /cost; a resumed session usually carries its earlier cost over.

Does it send my data anywhere? No. It reads Claude Code's own usage figures through the plugin API and draws them. The value ledger stays in a local file. If Claude Code hands over no settings, it reads its own pluginConfigs entry from Claude's settings file and nothing else. Nothing leaves your machine.

How is it different from ccusage? ccusage analyses Claude Code's log files after the fact, from the command line. Quanta Costa lives inside Claude Code and shows the live numbers while you work.

Requirements

Claude Code 2.1.293 or newer. Quanta Costa uses Claude Code's function-hooks plugin API, which is in early access and may change between releases.

Deutsch

Quanta Costa zeigt in Claude Code direkt über dem Prompt, wie viel von deinem Claude-Abo noch frei ist (Woche und Abrechnungsperiode als Balken), ob deine Woche bei diesem Tempo reicht, was die laufende Sitzung in Tokens und Euro kosten würde und ob sich dein Abo lohnt. Mit Abo sind die Kosten theoretisch: Sie zählen nur gegen dein Wochenkontingent. Installation wie oben. Für die deutsche Oberfläche language auf de stellen, für Euro currency auf EUR, billingDay auf deinen Abrechnungstag und planPrice auf deinen Abo-Preis.

License

MIT, see LICENSE. Made by Jörg Wukonig, Wukonig & Partner.

Source 2 files
hooks/register.tsx 1145 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { Limit, Snapshot } from '../types'
5
6const EMPTY: Snapshot = {
7  limits: [],
8  usd: null,
9  baseUsd: 0,
10  tokens: 0,
11  baseTokens: 0,
12  othersUsd: 0,
13  ownFirst: null,
14  ownTop: 0,
15  since: null,
16  now: 0,
17}
18const num = (v: unknown, fallback = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback)
19
20// The stored snapshot may come from an older version (state survives a reload or an update
21// mid-session) and lack fields. Fill every one, so no sum turns into NaN.
22export const normalize = (s: Partial<Snapshot> | null | undefined): Snapshot => ({
23  limits: Array.isArray(s?.limits) ? s.limits : [],
24  usd: typeof s?.usd === 'number' && Number.isFinite(s.usd) ? s.usd : null,
25  baseUsd: num(s?.baseUsd),
26  tokens: num(s?.tokens),
27  baseTokens: num(s?.baseTokens),
28  othersUsd: num(s?.othersUsd),
29  ownFirst: typeof s?.ownFirst === 'number' && Number.isFinite(s.ownFirst) ? s.ownFirst : null,
30  ownTop: num(s?.ownTop),
31  since: typeof s?.since === 'number' && Number.isFinite(s.since) ? s.since : null,
32  now: num(s?.now),
33})
34
35const snap = atom({ plugin: 'quanta-costa', key: 'snap' } as const, EMPTY)
36const isHelpOpen = atom({ plugin: 'quanta-costa', key: 'isHelpOpen' } as const, false)
37
38const BAR = 12
39const LOGO = '🧾' // "Il conto, per favore!"
40const DAY = 86_400_000
41export const VERSION = '1.6.1'
42const PLUGIN = 'quanta-costa'
43// The same view in a pane, opened with /quanta-costa: panes show on every surface, the iPhone app included.
44export const PANE = 'quanta-costa'
45// "/quanta-costa" on its own, as a prompt that was not run as a command.
46export const isPaneRequest = (text: string) => /^\s*\/quanta-costa\s*$/i.test(text)
47// What Claude reads beside such a prompt, so that its reply carries the numbers.
48export const phoneReply = (line: string) =>
49  'The person typed /quanta-costa, most likely on their phone, where the Quanta Costa plugin ' +
50  'cannot show its band or pane. Reply with exactly this line and nothing else:\n' +
51  line
52const DEFAULT_EUR_RATE = 0.89238
53
54export type Lang = 'en' | 'de'
55const LOCALE: Record<Lang, string> = { en: 'en-US', de: 'de-AT' }
56
57const toLimit = (l: SessionRateLimit): Limit => ({
58  kind: l.kind,
59  percentUsed: l.percentUsed,
60  resetsAt: l.resetsAt,
61})
62
63// The one limit we show as "Week": weekly, else a gateway spend limit, else the 5-hour window.
64export const pickPeriod = (limits: Limit[]): Limit | undefined =>
65  limits.find(l => l.kind === 'seven_day') ??
66  limits.find(l => l.kind === 'spend_limit') ??
67  limits.find(l => l.kind === 'five_hour') ??
68  limits[0]
69
70export const bar = (percent: number): string => {
71  const filled = Math.max(0, Math.min(BAR, Math.round((percent / 100) * BAR)))
72  return '█'.repeat(filled) + '░'.repeat(BAR - filled)
73}
74
75const dec = (n: number, digits: number, lang: Lang) => {
76  try {
77    return new Intl.NumberFormat(LOCALE[lang], {
78      minimumFractionDigits: digits,
79      maximumFractionDigits: digits,
80    }).format(n)
81  } catch {
82    const t = n.toFixed(digits)
83    return lang === 'de' ? t.replace('.', ',') : t
84  }
85}
86
87export const money = (usd: number | null, currency: string, eurRate: number, lang: Lang = 'en'): string => {
88  if (usd === null) return '?'
89  const isEur = currency === 'EUR'
90  const value = dec(isEur ? usd * eurRate : usd, 2, lang)
91  const sign = isEur ? '€' : '$'
92  return lang === 'de' ? `${value} ${sign}` : `${sign}${value}`
93}
94
95export const tokenText = (n: number, lang: Lang = 'en'): string => {
96  if (n >= 1_000_000) return lang === 'de' ? `${dec(n / 1_000_000, 1, lang)} Mio` : `${dec(n / 1_000_000, 1, lang)}M`
97  if (n >= 1_000) return lang === 'de' ? `${Math.round(n / 1_000)} Tsd.` : `${Math.round(n / 1_000)}k`
98  return String(n)
99}
100
101export const duration = (ms: number, lang: Lang = 'en'): string => {
102  const minutes = Math.max(0, Math.round(ms / 60_000))
103  const days = Math.floor(minutes / 1440)
104  const hours = Math.floor((minutes % 1440) / 60)
105  const rest = minutes % 60
106  if (lang === 'de') {
107    if (days > 0) return `${days} T ${hours} h`
108    if (hours > 0) return `${hours} h ${rest} min`
109    return `${rest} min`
110  }
111  if (days > 0) return `${days}d ${hours}h`
112  if (hours > 0) return `${hours}h ${rest}m`
113  return `${rest}m`
114}
115
116export const weeks = (days: number, lang: Lang = 'en') => dec(days / 7, 1, lang)
117
118const pct = (n: number, lang: Lang) => (lang === 'de' ? `${Math.round(n)} %` : `${Math.round(n)}%`)
119
120// Calendar date (year, 0-based month, day) in the given time zone; undefined = this machine's.
121const localDate = (ms: number, timeZone?: string) => {
122  try {
123    const parts = new Intl.DateTimeFormat('en-CA', {
124      timeZone,
125      year: 'numeric',
126      month: '2-digit',
127      day: '2-digit',
128    }).formatToParts(new Date(ms))
129    const get = (type: string) => Number(parts.find(p => p.type === type)?.value)
130    return { y: get('year'), m: get('month') - 1, d: get('day') }
131  } catch {
132    const t = new Date(ms)
133    return { y: t.getFullYear(), m: t.getMonth(), d: t.getDate() }
134  }
135}
136
137// The billing day in a given month; a day the month lacks (the 31st in November) falls on its last day.
138const renewalIn = (y: number, m: number, day: number) => {
139  const first = new Date(Date.UTC(y, m, 1))
140  const year = first.getUTCFullYear()
141  const month = first.getUTCMonth()
142  const last = new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
143  return Date.UTC(year, month, Math.min(day, last))
144}
145
146export const billingPeriod = (nowMs: number, day: number, timeZone?: string) => {
147  const { y, m, d } = localDate(nowMs, timeZone)
148  const today = Date.UTC(y, m, d)
149  const thisMonth = renewalIn(y, m, day)
150  const start = today >= thisMonth ? thisMonth : renewalIn(y, m - 1, day)
151  const end = today >= thisMonth ? renewalIn(y, m + 1, day) : thisMonth
152  const daysTotal = Math.round((end - start) / DAY)
153  const daysGone = Math.round((today - start) / DAY)
154  return {
155    start,
156    end,
157    daysTotal,
158    daysGone,
159    daysLeft: daysTotal - daysGone,
160    percentGone: (daysGone / daysTotal) * 100,
161  }
162}
163
164// "Oct 12" or "12.10.": a date-only value (UTC midnight) or an instant shown in local time.
165const shortDate = (ms: number, lang: Lang, timeZone?: string) => {
166  try {
167    const options: Intl.DateTimeFormatOptions =
168      lang === 'de' ? { timeZone, day: '2-digit', month: '2-digit' } : { timeZone, month: 'short', day: 'numeric' }
169    return new Intl.DateTimeFormat(LOCALE[lang], options).format(new Date(ms))
170  } catch {
171    return new Date(ms).toISOString().slice(5, 10)
172  }
173}
174
175// "Fri 14:00" or "Fr 14:00" in local time.
176const weekdayTime = (ms: number, lang: Lang, timeZone?: string) => {
177  try {
178    const parts = new Intl.DateTimeFormat(LOCALE[lang], {
179      timeZone,
180      weekday: 'short',
181      hour: '2-digit',
182      minute: '2-digit',
183      hourCycle: 'h23',
184    }).formatToParts(new Date(ms))
185    const get = (type: string) => parts.find(p => p.type === type)?.value ?? ''
186    return `${get('weekday').replace('.', '')} ${get('hour')}:${get('minute')}`
187  } catch {
188    return new Date(ms).toISOString().slice(11, 16)
189  }
190}
191
192// Extrapolates the weekly usage so far: null while too early to tell, else whether it
193// lasts until the reset or when it would run out.
194export const pace = (limit: Limit, nowMs: number): { isOnTrack: boolean; runsOutAt: number } | null => {
195  if (limit.kind !== 'seven_day' || !limit.resetsAt) return null
196  const resetsAt = Date.parse(limit.resetsAt)
197  const start = resetsAt - 7 * DAY
198  const elapsed = nowMs - start
199  if (elapsed < 3 * 3_600_000 || limit.percentUsed < 2) return null
200  const runsOutAt = start + (elapsed * 100) / limit.percentUsed
201  return { isOnTrack: runsOutAt >= resetsAt, runsOutAt }
202}
203
204// The period a session's cost is booked to: the billing period, or the calendar month without a billing day.
205export const periodKey = (nowMs: number, billingDay: number, timeZone?: string) => {
206  if (billingDay >= 1 && billingDay <= 31) {
207    return new Date(billingPeriod(nowMs, billingDay, timeZone).start).toISOString().slice(0, 10)
208  }
209  const { y, m } = localDate(nowMs, timeZone)
210  return new Date(Date.UTC(y, m, 1)).toISOString().slice(0, 10)
211}
212
213type Ledger = Record<string, Record<string, number>>
214const LEDGER = 'ledger'
215const KEEP_PERIODS = 3
216
217// Claude's config folder: CLAUDE_CONFIG_DIR, else ~/.claude.
218// Never throws: without access to the environment the plugin falls back to its own store.
219const configDir = async ($: EngineInterface): Promise<string | undefined> => {
220  try {
221    const custom = await $.env.get('CLAUDE_CONFIG_DIR')
222    if (custom) return custom
223    const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
224    return home ? `${home}/.claude` : undefined
225  } catch {
226    return undefined
227  }
228}
229
230// ledger.json: the notices shown, and the amounts versions before 1.6.1 booked per session (kept as
231// they are, never raised again) with the last reading each was booked at.
232type Shared = { ledger?: Ledger; notices?: Notices; runs?: Runs }
233
234// Every file comes in two copies: the file with every booking, its backup at most every ten minutes.
235// A crash can leave a file just written as zero bytes; the older copy has long reached the disk by
236// then, and loading merges whatever of both is readable, so at most the last minutes are lost.
237const SHARED_FILE = 'ledger.json'
238const BACKUP_FILE = 'ledger.backup.json'
239const VALUE_FILE = 'value.json'
240const VALUE_BACKUP_FILE = 'value.backup.json'
241const BACKUP_EVERY = 10 * 60_000
242const backedUpAt: Record<string, number> = {}
243
244const readJson = async ($: EngineInterface, path: string): Promise<Record<string, unknown> | null> => {
245  try {
246    const text = await $.fs.read(path)
247    if (typeof text !== 'string') return null
248    const data = JSON.parse(text) as unknown
249    return data !== null && typeof data === 'object' ? (data as Record<string, unknown>) : null
250  } catch {
251    return null
252  }
253}
254
255const writeWithBackup = async ($: EngineInterface, file: string, backup: string, data: unknown) => {
256  const dir = await configDir($)
257  if (!dir) return
258  const text = JSON.stringify(data, null, 1)
259  await $.fs.write(`${dir}/quanta-costa/${file}`, text).catch(() => undefined)
260  const now = await $.clock.now()
261  if (now - (backedUpAt[file] ?? 0) < BACKUP_EVERY) return
262  backedUpAt[file] = now
263  await $.fs.write(`${dir}/quanta-costa/${backup}`, text).catch(() => undefined)
264}
265
266const loadShared = async ($: EngineInterface): Promise<Shared> => {
267  const dir = await configDir($)
268  if (!dir) return {}
269  const main = (await readJson($, `${dir}/quanta-costa/${SHARED_FILE}`)) as Shared | null
270  const backup = (await readJson($, `${dir}/quanta-costa/${BACKUP_FILE}`)) as Shared | null
271  if (!main && !backup) return {}
272  return {
273    ledger: mergeLedgers(main?.ledger, backup?.ledger),
274    notices: mergeNotices(main?.notices ?? {}, backup?.notices ?? {}),
275    // The last readings belong with the ledger they were booked into: the newer copy's.
276    runs: main?.runs ?? backup?.runs,
277  }
278}
279
280const saveShared = ($: EngineInterface, data: Shared) => writeWithBackup($, SHARED_FILE, BACKUP_FILE, data)
281
282// The last cost reading booked per session by versions before 1.6.1.
283type Runs = Record<string, number>
284
285// The store is only a fallback for the shared files. An unclean shutdown can leave it unreadable
286// (zero bytes), which must never stop the booking: a broken store reads as empty, a failed write is skipped.
287const storeGet = async ($: EngineInterface, key: string): Promise<unknown> => {
288  try {
289    return await $.store.get(key)
290  } catch {
291    return undefined
292  }
293}
294
295const storeSet = async ($: EngineInterface, key: string, value: unknown) => {
296  try {
297    await $.store.set(key, value)
298  } catch {
299    // the shared file still has it
300  }
301}
302
303// Two ledgers as one: per period and session the larger amount (a session's cost only grows).
304export const mergeLedgers = (a: Ledger = {}, b: Ledger = {}): Ledger => {
305  const out: Ledger = {}
306  for (const source of [a, b]) {
307    for (const [period, sessions] of Object.entries(source ?? {})) {
308      const target = (out[period] ??= {})
309      for (const [sid, usd] of Object.entries(sessions ?? {})) target[sid] = Math.max(num(target[sid]), num(usd))
310    }
311  }
312  return out
313}
314
315// The amounts booked before 1.6.1: those in ledger.json, plus any session only an old
316// per-name store knows (versions before 1.5.0 kept them there). A store never raises an amount.
317const legacyLedger = async ($: EngineInterface, shared: Shared): Promise<Ledger> => {
318  const out = mergeLedgers(shared.ledger)
319  const stored = ((await storeGet($, LEDGER)) ?? {}) as Ledger
320  for (const [period, sessions] of Object.entries(stored ?? {})) {
321    for (const [sid, usd] of Object.entries(sessions ?? {})) {
322      const target = (out[period] ??= {})
323      if (!(sid in target)) target[sid] = num(usd)
324    }
325  }
326  return out
327}
328
329// value.json, since 1.6.1: per period, session and run (one Claude Code process with Quanta Costa
330// loaded) the first and the highest cost reading there. A run's cost is the difference: whatever
331// Claude Code's counter carried into the run (a resumed session's earlier runs) was counted by those
332// runs already. Only the run itself ever raises its entry, and copies merge by the higher reading,
333// so a stale copy that another session writes back can delay a number, but never count it twice.
334type RunEntry = [number, number]
335type Counts = Record<string, Record<string, Record<string, RunEntry>>>
336// When counting began in a period. Unknown when the period holds amounts from before 1.6.1.
337type Since = Record<string, number>
338type ValueDoc = { since: Since; counts: Counts }
339const VALUE = 'value'
340
341const isEntry = (e: unknown): e is RunEntry =>
342  Array.isArray(e) && e.length === 2 && e.every(v => typeof v === 'number' && Number.isFinite(v))
343
344// Two copies as one: per run the entry with the higher reading.
345export const mergeCounts = (a: Counts = {}, b: Counts = {}): Counts => {
346  const out: Counts = {}
347  for (const source of [a, b]) {
348    for (const [period, sessions] of Object.entries(source ?? {})) {
349      for (const [sid, runs] of Object.entries(sessions ?? {})) {
350        for (const [run, entry] of Object.entries(runs ?? {})) {
351          if (!isEntry(entry)) continue
352          const target = ((out[period] ??= {})[sid] ??= {})
353          const had = target[run]
354          if (!had || entry[1] > had[1] || (entry[1] === had[1] && entry[0] > had[0])) target[run] = [entry[0], entry[1]]
355        }
356      }
357    }
358  }
359  return out
360}
361
362// Two copies as one: per period the earlier start.
363export const mergeSince = (a: Since = {}, b: Since = {}): Since => {
364  const out: Since = {}
365  for (const source of [a, b]) {
366    for (const [period, ms] of Object.entries(source ?? {})) {
367      if (typeof ms === 'number' && Number.isFinite(ms)) out[period] = Math.min(out[period] ?? ms, ms)
368    }
369  }
370  return out
371}
372
373// A period's value: the amounts from before 1.6.1 and every run's cost, except the run left out.
374export const periodValue = (legacy: Record<string, number> = {}, counts: Counts[string] = {}, skip?: { sid: string; run: string }) => {
375  let usd = Object.values(legacy).reduce((sum, v) => sum + num(v), 0)
376  for (const [sid, runs] of Object.entries(counts)) {
377    for (const [run, entry] of Object.entries(runs ?? {})) {
378      if (skip && skip.sid === sid && skip.run === run) continue
379      if (isEntry(entry)) usd += Math.max(0, entry[1] - entry[0])
380    }
381  }
382  return usd
383}
384
385// A run's id starts with its start time, so the latest run of a session is known.
386const newRunId = (now: number) => `${now.toString(36)}-${Math.random().toString(36).slice(2, 8)}`
387const runStart = (id: string) => {
388  const start = parseInt(id.split('-')[0] ?? '', 36)
389  return Number.isFinite(start) ? start : 0
390}
391
392// The session's last reading before this run: the highest reading of its latest other run, else
393// the last reading a version before 1.6.1 booked for it.
394export const readingBefore = (counts: Counts, oldRuns: Runs | undefined, sid: string, run: string): number | undefined => {
395  let latest: { start: number; top: number } | undefined
396  for (const sessions of Object.values(counts)) {
397    for (const [id, entry] of Object.entries(sessions[sid] ?? {})) {
398      if (id === run || !isEntry(entry)) continue
399      const start = runStart(id)
400      if (!latest || start > latest.start || (start === latest.start && entry[1] > latest.top)) latest = { start, top: entry[1] }
401    }
402  }
403  if (latest) return latest.top
404  const old = oldRuns?.[sid]
405  return typeof old === 'number' && Number.isFinite(old) ? old : undefined
406}
407
408// This run's entry for a period after a new reading. `carried`: the run's entry in the period
409// before, `before`: the session's last reading before this run.
410export const nextEntry = (entry: RunEntry | undefined, carried: RunEntry | undefined, before: number | undefined, reading: number): RunEntry => {
411  if (!entry) {
412    // Into a new period: on from where the run left the last one.
413    if (carried) return [carried[1], Math.max(carried[1], reading)]
414    // A new run. A reading at or above the session's last one carries the earlier runs' cost
415    // over, counted there already; a lower one is a counter that started from zero.
416    return before !== undefined && reading >= before ? [before, reading] : [reading, reading]
417  }
418  // Started at zero and only then got the earlier runs' cost carried over.
419  if (entry[0] === 0 && entry[1] === 0 && before !== undefined && before > 0 && reading >= before) return [before, reading]
420  return [entry[0], Math.max(entry[1], reading)]
421}
422
423const latestPeriods = <T,>(byPeriod: Record<string, T>): Record<string, T> => {
424  const out: Record<string, T> = {}
425  for (const key of Object.keys(byPeriod).sort().slice(-KEEP_PERIODS)) out[key] = byPeriod[key] as T
426  return out
427}
428
429const loadValue = async ($: EngineInterface): Promise<ValueDoc> => {
430  const dir = await configDir($)
431  const copies = [(await storeGet($, VALUE)) as Partial<ValueDoc> | undefined]
432  if (dir) {
433    copies.push(
434      ((await readJson($, `${dir}/quanta-costa/${VALUE_FILE}`)) ?? undefined) as Partial<ValueDoc> | undefined,
435      ((await readJson($, `${dir}/quanta-costa/${VALUE_BACKUP_FILE}`)) ?? undefined) as Partial<ValueDoc> | undefined,
436    )
437  }
438  let counts: Counts = {}
439  let since: Since = {}
440  for (const copy of copies) {
441    if (!copy || typeof copy !== 'object') continue
442    counts = mergeCounts(counts, copy.counts)
443    since = mergeSince(since, copy.since)
444  }
445  return { counts, since }
446}
447
448type Run = { sid: string; id: string; periods: Record<string, RunEntry>; since: Since }
449const NO_RUN: Run = { sid: '', id: '', periods: {}, since: {} }
450// This process's run. Kept here, not in the plugin state: bookings that overlap at the start must
451// see the same run, and the state hands each event its own copy until that event is done.
452let currentRun: Run = NO_RUN
453
454// Books this run's reading to the current period and returns what the period's other runs and
455// the amounts from before 1.6.1 add up to. Without a reading nothing is booked.
456const book = async ($: EngineInterface, usd: number | null, key: string) => {
457  const sid = await $.session.id()
458  const now = await $.clock.now()
459  const shared = await loadShared($)
460  const legacy = await legacyLedger($, shared)
461  const doc = await loadValue($)
462  let run: Run = currentRun.sid === sid && currentRun.id ? currentRun : { sid, id: newRunId(now), periods: {}, since: {} }
463  if (usd !== null) {
464    const before = readingBefore(doc.counts, shared.runs, sid, run.id)
465    const earlier = Object.keys(run.periods).filter(k => k < key).sort()
466    const carried = earlier.length > 0 ? run.periods[earlier[earlier.length - 1] as string] : undefined
467    run = { ...run, periods: latestPeriods({ ...run.periods, [key]: nextEntry(run.periods[key], carried, before, usd) }) }
468  }
469  const mine: Counts = {}
470  for (const [period, entry] of Object.entries(run.periods)) mine[period] = { [sid]: { [run.id]: entry } }
471  const counts = latestPeriods(mergeCounts(doc.counts, mine))
472  const since = mergeSince(doc.since, run.since)
473  // Counting began with this booking, or with the period itself when an earlier period was counted.
474  const isLegacyPeriod = Object.keys(legacy[key] ?? {}).length > 0
475  const wasCounting =
476    Object.keys(counts).some(k => k < key) || Object.entries(legacy).some(([k, sessions]) => k < key && Object.keys(sessions ?? {}).length > 0)
477  if (usd !== null && since[key] === undefined && !isLegacyPeriod) since[key] = wasCounting ? Date.parse(`${key}T00:00:00Z`) : now
478  run = { ...run, since: latestPeriods(since) }
479  currentRun = run
480  const next: ValueDoc = { since: latestPeriods(since), counts }
481  await storeSet($, VALUE, next)
482  await writeWithBackup($, VALUE_FILE, VALUE_BACKUP_FILE, next)
483  const own = run.periods[key]
484  return {
485    othersUsd: periodValue(legacy[key], counts[key], { sid, run: run.id }),
486    ownFirst: own ? own[0] : null,
487    ownTop: own ? own[1] : 0,
488    since: since[key] ?? null,
489  }
490}
491
492// One booking at a time: two at once, at the very start, would each begin a run of their own.
493let booking: Promise<unknown> = Promise.resolve()
494const oneAtATime = <T,>(task: () => Promise<T>): Promise<T> => {
495  const next = booking.then(task, task)
496  booking = next.catch(() => undefined)
497  return next
498}
499
500// With a plan price set: book this session's cost and learn what the period's others cost.
501const bookPeriod = ($: EngineInterface, planPrice: number, billingDay: number) =>
502  oneAtATime(async () => {
503    if (planPrice <= 0) return
504    const { usd } = normalize(await read($, snap))
505    const now = await $.clock.now()
506    const booked = await book($, usd, periodKey(now, billingDay))
507    await update($, snap, s => ({ ...normalize(s), ...booked }))
508  })
509
510// A period's whole value, for the look back once it has ended.
511const valueOf = async ($: EngineInterface, key: string) => {
512  const legacy = await legacyLedger($, await loadShared($))
513  const doc = await loadValue($)
514  if (!legacy[key] && !doc.counts[key]) return null
515  return periodValue(legacy[key], doc.counts[key])
516}
517
518type Texts = {
519  names: Record<string, string>
520  period: string
521  session: string
522  value: string
523  periodLine: (gone: string, total: string, left: string, days: number) => string
524  weekLine: (used: string, free: string) => string
525  onTrack: string
526  runsOut: (when: string) => string
527  resets: (date: string) => string
528  noReading: string
529  sessionMain: (tokens: string, cost: string) => string
530  sessionLast: (cost: string, tokens: string) => string
531  help: string
532  closeHelp: string
533  valueLine: (value: string, plan: string, since?: string) => string
534  shortWarn: (percent: string, freeIn?: string) => string
535  helpTitle: string
536  helpPeriod: (day: number, start: string, end: string) => string
537  helpPeriodUnset: string
538  helpWeek: string
539  helpFive: string
540  helpHypothetical: string
541  helpReal: string
542  helpTokens: string
543  helpLast: string
544  helpPace: string
545  helpValue: string
546  helpEuro: (rate: string) => string
547  helpNotices: string
548  commandDescription: string
549  helpActive: (billingDay: string, currency: string, plan: string) => string
550  warnWeek: (percent: string, resets: string, runsOut?: string) => string
551  freshWeek: (free: string) => string
552  review: (start: string, end: string, value: string, ratio?: string) => string
553}
554
555const TEXTS: Record<Lang, Texts> = {
556  en: {
557    names: { seven_day: 'Week', spend_limit: 'Spend', five_hour: '5 hours' },
558    period: 'Period',
559    session: 'Session',
560    value: 'Value',
561    periodLine: (gone, total, left, days) =>
562      `${gone} of ${total} weeks gone · ${left} wk left · renews in ${days} ${days === 1 ? 'day' : 'days'}`,
563    weekLine: (used, free) => `${used} used · ${free} free`,
564    onTrack: '· on track',
565    runsOut: when => `· runs out ${when}`,
566    resets: date => `(resets ${date})`,
567    noReading: 'Week: no reading yet, it appears with the next reply.',
568    sessionMain: (tokens, cost) => `${tokens} tokens · ${cost}`,
569    sessionLast: (cost, tokens) => `· last prompt ${cost} · ${tokens} tokens`,
570    help: 'Help',
571    closeHelp: 'Close help',
572    valueLine: (value, plan, since) => `${value} API value${since ? ` since ${since}` : ''} vs ${plan} plan`,
573    shortWarn: (percent, freeIn) => `5-hour limit ${percent} used` + (freeIn ? ` · resets in ${freeIn}` : ''),
574    helpTitle: 'Quanta Costa: what the numbers mean',
575    helpPeriod: (day, start, end) =>
576      `Period: your Claude subscription renews on day ${day} of each month, this period runs ${start} to ${end}. The top bar shows how many weeks of this paid month are gone and how many are left. Every new week brings a fresh weekly allowance.`,
577    helpPeriodUnset:
578      'Period: set your billing day in the settings (billingDay, it is on your Anthropic receipt). A bar on top then shows how much of your paid month is gone.',
579    helpWeek:
580      'Week: your plan has a weekly allowance. The bar shows how much of it is used. When it is full, you wait for the reset.',
581    helpFive:
582      '5-hour limit: there is also a short 5-hour window. It only shows up here, as a warning, once it passes 80%.',
583    helpHypothetical:
584      'Costs are hypothetical: this is what the session would cost at API list prices. On a subscription you do not pay this on top, it only counts against your weekly allowance. You only really pay for extra usage beyond your limit, if you turned it on.',
585    helpReal:
586      'No subscription limits reported: this session probably runs on an API key. Then the costs are real and billed.',
587    helpTokens:
588      "Tokens: everything the model read and wrote, cache included, counted since Quanta Costa started. The cost is Claude Code's own figure for this session, as in /cost; a resumed session can carry its earlier cost over.",
589    helpLast: 'Last prompt: everything since your last prompt.',
590    helpPace:
591      'Pace: Quanta Costa extrapolates your usage so far this week. "on track" means it lasts until the reset, otherwise it shows when it would run out.',
592    helpValue:
593      'Value: the API value of all sessions in this period, counted while Quanta Costa ran, against your plan price. Above 1× your plan has paid for itself. "since" shows the day Quanta Costa started counting, when that was after the period began. A resumed session never counts twice.',
594    helpEuro: rate => `Euro: converted at $1 = €${rate} (setting eurRate).`,
595    helpNotices:
596      'Notices: Quanta Costa tells you once when the week passes 80% and 90%, when a fresh week starts, and at the end of each period how much it was worth.',
597    commandDescription: 'Show Quanta Costa: usage limits, pace and session cost',
598    helpActive: (billingDay, currency, plan) =>
599      `Active settings: language en · billing day ${billingDay} · ${currency} · plan ${plan} · version ${VERSION}`,
600    warnWeek: (percent, resets, runsOut) =>
601      `Quanta Costa: ${percent} of your weekly limit used, it resets ${resets}.` +
602      (runsOut ? ` At this pace it runs out ${runsOut}.` : ''),
603    freshWeek: free => `Quanta Costa: fresh week, your weekly limit has reset (${free} free).`,
604    review: (start, end, value, ratio) =>
605      `Quanta Costa: last period ${start} to ${end}: ${value} API value` + (ratio ? `, ${ratio}× your plan.` : '.'),
606  },
607  de: {
608    names: { seven_day: 'Woche', spend_limit: 'Limit', five_hour: '5 Std.' },
609    period: 'Periode',
610    session: 'Sitzung',
611    value: 'Wert',
612    periodLine: (gone, total, left, days) =>
613      `${gone} v. ${total} Wochen vorbei · ${left} W offen · noch ${days} ${days === 1 ? 'Tag' : 'Tage'}`,
614    weekLine: (used, free) => `${used} verbraucht · ${free} frei`,
615    onTrack: '· im Plan',
616    runsOut: when => `· leer ab ${when}`,
617    resets: date => `(neu ab ${date})`,
618    noReading: 'Woche: noch keine Messung, kommt mit der nächsten Antwort.',
619    sessionMain: (tokens, cost) => `${tokens} Tokens · ${cost}`,
620    sessionLast: (cost, tokens) => `· zuletzt ${cost} · ${tokens} Tokens`,
621    help: 'Hilfe',
622    closeHelp: 'Hilfe zu',
623    valueLine: (value, plan, since) => `${value} API-Wert${since ? ` seit ${since}` : ''} vs. ${plan} Abo`,
624    shortWarn: (percent, freeIn) => `Kurzzeit-Bremse ${percent} voll` + (freeIn ? ` · frei in ${freeIn}` : ''),
625    helpTitle: 'Quanta Costa: was die Zahlen bedeuten',
626    helpPeriod: (day, start, end) =>
627      `Periode: Dein Claude-Abo wird jeden Monat am ${day}. abgebucht, aktuell ${start} bis ${end}. Der obere Balken zeigt, wie viele Wochen dieses bezahlten Monats schon vorbei sind und wie viele noch offen. Jede neue Woche bringt ein frisches Wochenkontingent. W = Wochen.`,
628    helpPeriodUnset:
629      'Periode: Trag in den Einstellungen deinen Abrechnungstag ein (billingDay, steht auf deiner Anthropic-Rechnung). Dann zeigt ein Balken ganz oben, wie viel von deinem bezahlten Monat vorbei ist.',
630    helpWeek:
631      'Woche: Dein Abo hat ein Kontingent pro Woche. Der Balken zeigt, wie viel davon schon weg ist. Ist er voll, geht erst nach dem Reset wieder etwas.',
632    helpFive:
633      'Kurzzeit-Bremse: Zusätzlich gibt es ein 5-Stunden-Fenster. Es erscheint hier nur als Warnung, wenn es über 80 % liegt.',
634    helpHypothetical:
635      'Kosten sind theoretisch: So viel würde die Sitzung zum API-Listenpreis kosten. Mit deinem Abo zahlst du das nicht extra, es zählt nur gegen dein Wochenkontingent. Echt bezahlt wird nur Extra-Nutzung über das Limit hinaus, falls du sie aktiviert hast.',
636    helpReal:
637      'Keine Abo-Limits gemeldet: Vermutlich läuft die Sitzung über einen API-Schlüssel. Dann sind die Kosten echt und werden abgerechnet.',
638    helpTokens:
639      'Tokens: alles, was das Modell gelesen und geschrieben hat, inklusive Cache. Gezählt ab dem Start von Quanta Costa. Der Betrag ist Claude Codes eigene Zahl für diese Sitzung, wie bei /cost; eine fortgesetzte Sitzung kann ihre früheren Kosten mitbringen.',
640    helpLast: 'Zuletzt: alles seit deinem letzten Prompt.',
641    helpPace:
642      'Tempo: Quanta Costa rechnet deinen bisherigen Wochenverbrauch hoch. „im Plan" heißt, es reicht bis zum Reset. Sonst steht da, wann das Kontingent leer wäre.',
643    helpValue:
644      'Wert: der API-Wert aller Sitzungen dieser Periode, gezählt solange Quanta Costa lief, im Vergleich zu deinem Abo-Preis. Über 1× hat sich dein Abo bezahlt gemacht. „seit" nennt den Tag, ab dem Quanta Costa zählt, wenn das nach Periodenbeginn war. Eine fortgesetzte Sitzung zählt nie doppelt.',
645    helpEuro: rate => `Euro: umgerechnet mit 1 $ = ${rate} € (Einstellung eurRate).`,
646    helpNotices:
647      'Hinweise: Quanta Costa meldet sich je einmal, wenn die Woche 80 % und 90 % erreicht, wenn eine neue Woche startet und am Ende jeder Periode mit ihrem Wert.',
648    commandDescription: 'Quanta Costa anzeigen: Limits, Tempo und Sitzungskosten',
649    helpActive: (billingDay, currency, plan) =>
650      `Aktive Einstellungen: Sprache de · Abrechnungstag ${billingDay} · ${currency} · Abo ${plan} · Version ${VERSION}`,
651    warnWeek: (percent, resets, runsOut) =>
652      `Quanta Costa: ${percent} deines Wochenlimits verbraucht, neu ab ${resets}.` +
653      (runsOut ? ` Bei diesem Tempo leer ab ${runsOut}.` : ''),
654    freshWeek: free => `Quanta Costa: neue Woche, dein Wochenlimit ist zurückgesetzt (${free} frei).`,
655    review: (start, end, value, ratio) =>
656      `Quanta Costa: letzte Periode ${start} bis ${end}: ${value} API-Wert` + (ratio ? `, ${ratio}× dein Abo.` : '.'),
657  },
658}
659
660type Notices = { week?: number; level?: number; seenPeriod?: string }
661type NoticeOptions = { lang: Lang; planPrice: number; billingDay: number; currency: string; eurRate: number }
662const NOTICES = 'notices'
663
664// The notices already shown, from the store and the shared file: the later week, its higher level.
665export const mergeNotices = (a: Notices = {}, b: Notices = {}): Notices => {
666  const weekA = num(a.week)
667  const weekB = num(b.week)
668  const later = weekB > weekA ? b : a
669  const sameWeek = weekA > 0 && weekB > 0 && Math.abs(weekA - weekB) < DAY
670  const out: Notices = {}
671  if (later.week !== undefined) out.week = later.week
672  const level = sameWeek ? Math.max(num(a.level), num(b.level)) : num(later.level)
673  if (later.level !== undefined || sameWeek) out.level = level
674  const seen = [a.seenPeriod, b.seenPeriod].filter((v): v is string => typeof v === 'string').sort()
675  if (seen.length > 0) out.seenPeriod = seen[seen.length - 1]
676  return out
677}
678
679// Two readings belong to the same week when their reset times lie within a day of each other.
680export const isSameWeek = (a: number, b: number) => Math.abs(a - b) < DAY
681
682// Shows each notice once (remembered across sessions): 80% and 90% of the week, a fresh week,
683// and a look back at the period that just ended.
684const notify = async ($: EngineInterface, o: NoticeOptions) => {
685  const t = TEXTS[o.lang]
686  const s = normalize(await read($, snap))
687  const now = s.now || (await $.clock.now())
688  const shared = await loadShared($)
689  const stored = mergeNotices(((await storeGet($, NOTICES)) ?? {}) as Notices, shared.notices)
690  const next: Notices = { ...stored }
691  const toasts: string[] = []
692
693  const week = s.limits.find(l => l.kind === 'seven_day')
694  if (week?.resetsAt) {
695    const resetsAt = Date.parse(week.resetsAt)
696    const known = typeof stored.week === 'number' ? stored.week : null
697    const isKnownWeek = known !== null && isSameWeek(known, resetsAt)
698    if (known !== null && !isKnownWeek && resetsAt > known) {
699      toasts.push(t.freshWeek(pct(Math.max(0, 100 - week.percentUsed), o.lang)))
700    }
701    const level = week.percentUsed >= 90 ? 90 : week.percentUsed >= 80 ? 80 : 0
702    const shownLevel = isKnownWeek ? num(stored.level) : 0
703    if (level > shownLevel) {
704      const forecast = pace(week, now)
705      toasts.push(
706        t.warnWeek(
707          pct(week.percentUsed, o.lang),
708          shortDate(resetsAt, o.lang),
709          forecast && !forecast.isOnTrack ? weekdayTime(forecast.runsOutAt, o.lang) : undefined,
710        ),
711      )
712    }
713    next.week = resetsAt
714    next.level = Math.max(level, shownLevel)
715  }
716
717  if (o.planPrice > 0) {
718    const key = periodKey(now, o.billingDay)
719    const seen = stored.seenPeriod
720    if (seen && seen < key) {
721      const usd = await valueOf($, seen)
722      if (usd !== null) {
723        const planUsd = o.currency === 'EUR' ? o.planPrice / o.eurRate : o.planPrice
724        toasts.push(
725          t.review(
726            shortDate(Date.parse(`${seen}T00:00:00Z`), o.lang, 'UTC'),
727            shortDate(Date.parse(`${key}T00:00:00Z`), o.lang, 'UTC'),
728            money(usd, o.currency, o.eurRate, o.lang),
729            dec(usd / planUsd, 1, o.lang),
730          ),
731        )
732      }
733    }
734    next.seenPeriod = key
735  }
736
737  if (JSON.stringify(next) !== JSON.stringify(stored)) {
738    await storeSet($, NOTICES, next)
739    await saveShared($, { ...(await loadShared($)), notices: next })
740  }
741  for (const text of toasts) $.ui.toast(text, { timeoutMs: 12_000 })
742}
743
744const colorFor = (percentUsed: number) =>
745  percentUsed >= 90 ? 'error' : percentUsed >= 70 ? 'warning' : 'success'
746
747const refresh = async ($: EngineInterface) => {
748  const usage = await $.session.usage()
749  const now = await $.clock.now()
750  await update($, snap, s => ({
751    ...normalize(s),
752    limits: usage.rateLimits.length > 0 ? usage.rateLimits.map(toLimit) : normalize(s).limits,
753    usd: usage.cost?.usd ?? normalize(s).usd,
754    now,
755  }))
756}
757
758type Settings = {
759  billingDay: number
760  currency: 'USD' | 'EUR'
761  eurRate: number
762  planPrice: number
763  lang: Lang
764  openPane: boolean
765}
766
767const asNumber = (v: unknown): number | undefined => {
768  if (typeof v === 'number' && Number.isFinite(v)) return v
769  if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v)
770  return undefined
771}
772
773export const toSettings = (o: Record<string, unknown>): Settings => ({
774  billingDay: Math.round(asNumber(o.billingDay) ?? 0),
775  currency: o.currency === 'EUR' ? 'EUR' : 'USD',
776  eurRate: asNumber(o.eurRate) ?? DEFAULT_EUR_RATE,
777  planPrice: asNumber(o.planPrice) ?? 0,
778  lang: o.language === 'de' ? 'de' : 'en',
779  // On unless switched off: the pane opens beside the chat at every start.
780  openPane: !(o.openPane === false || o.openPane === 'false' || o.openPane === 'off'),
781})
782
783export const isAllDefault = (c: Settings) =>
784  c.billingDay === 0 &&
785  c.currency === 'USD' &&
786  c.planPrice === 0 &&
787  c.lang === 'en' &&
788  c.eurRate === DEFAULT_EUR_RATE &&
789  c.openPane
790
791// Claude Code hands a plugin the options stored under the name it loaded the plugin by. The desktop
792// app loads a plugin installed from a local folder as "quanta-costa@inline", while `claude plugin
793// configure` stores the options as "quanta-costa@<marketplace>", so only defaults arrive. In that case
794// look up this plugin's own entry in the settings file, under any of its names. Nothing else is read.
795export const findSavedOptions = async ($: EngineInterface): Promise<Record<string, unknown> | null> => {
796  const dir = await configDir($)
797  if (!dir) return null
798  const text = await $.fs.read(`${dir}/settings.json`)
799  if (typeof text !== 'string') return null
800  const parsed = JSON.parse(text) as { pluginConfigs?: Record<string, { options?: Record<string, unknown> }> }
801  const configs = parsed.pluginConfigs ?? {}
802  const keys = Object.keys(configs).filter(k => k === PLUGIN || k.startsWith(`${PLUGIN}@`))
803  for (const key of keys) {
804    const found = configs[key]?.options
805    if (found && Object.keys(found).length > 0) return found
806  }
807  return null
808}
809
810// When counting began in this period, worth saying only when that was after the period started.
811export const countedSince = (since: number | null, now: number, billingDay: number) => {
812  if (since === null) return null
813  const key = periodKey(now, billingDay)
814  if (periodKey(since, billingDay) !== key) return null
815  const { y, m, d } = localDate(since)
816  return new Date(Date.UTC(y, m, d)).toISOString().slice(0, 10) > key ? since : null
817}
818
819// Everything the view shows, worked out once for the band, the pane and the text summary.
820export const figures = (s: Snapshot, cfg: Settings, now: number) => {
821  const { billingDay, currency, eurRate, planPrice } = cfg
822  const week = pickPeriod(s.limits)
823  const short = s.limits.find(l => l.kind === 'five_hour')
824  // The other runs, plus this run's cost up to the live reading.
825  const ownUsd = s.ownFirst === null ? 0 : Math.max(0, Math.max(s.ownTop, s.usd ?? 0) - s.ownFirst)
826  const periodUsd = s.othersUsd + ownUsd
827  const planUsd = currency === 'EUR' ? planPrice / eurRate : planPrice
828  return {
829    week,
830    short,
831    isShortTight: short !== undefined && short !== week && short.percentUsed >= 80,
832    isSubscription: s.limits.length > 0,
833    lastUsd: s.usd === null ? null : Math.max(0, s.usd - s.baseUsd),
834    lastTokens: Math.max(0, s.tokens - s.baseTokens),
835    period: billingDay >= 1 && billingDay <= 31 ? billingPeriod(now, billingDay) : null,
836    forecast: week ? pace(week, now) : null,
837    periodUsd,
838    planUsd,
839    valueRatio: planPrice > 0 ? periodUsd / planUsd : null,
840    since: countedSince(s.since, now, billingDay),
841  }
842}
843
844// The view as one line of text, the answer where no pane can be drawn, such as the phone.
845// One line, because Claude Code shows a dropped prompt's reason without line breaks.
846export const summaryText = (s: Snapshot, cfg: Settings, now: number): string => {
847  const { currency, eurRate, lang } = cfg
848  const t = TEXTS[lang]
849  const f = figures(s, cfg, now)
850  const lines = [`${LOGO} Quanta Costa`]
851  if (f.period) {
852    const { daysGone, daysTotal, daysLeft } = f.period
853    lines.push(`${t.period}: ${t.periodLine(weeks(daysGone, lang), weeks(daysTotal, lang), weeks(daysLeft, lang), daysLeft)}`)
854  }
855  if (f.week) {
856    const free = pct(Math.max(0, 100 - Math.round(f.week.percentUsed)), lang)
857    const parts = [`${t.names[f.week.kind] ?? f.week.kind}: ${t.weekLine(pct(f.week.percentUsed, lang), free)}`]
858    if (f.forecast) parts.push(f.forecast.isOnTrack ? t.onTrack : t.runsOut(weekdayTime(f.forecast.runsOutAt, lang)))
859    if (f.week.resetsAt) parts.push(t.resets(shortDate(Date.parse(f.week.resetsAt), lang)))
860    lines.push(parts.join(' '))
861  } else {
862    lines.push(t.noReading)
863  }
864  const cost = money(s.usd, currency, eurRate, lang)
865  const last = t.sessionLast(money(f.lastUsd, currency, eurRate, lang), tokenText(f.lastTokens, lang))
866  lines.push(`${t.session}: ${t.sessionMain(tokenText(s.tokens, lang), cost)} ${last}`)
867  if (f.valueRatio !== null) {
868    const since = f.since !== null ? shortDate(f.since, lang) : undefined
869    const value = t.valueLine(money(f.periodUsd, currency, eurRate, lang), money(f.planUsd, currency, eurRate, lang), since)
870    lines.push(`${t.value}: ${value} (${dec(f.valueRatio, 1, lang)}×)`)
871  }
872  if (f.isShortTight && f.short) {
873    const freeIn = f.short.resetsAt ? duration(Date.parse(f.short.resetsAt) - now, lang) : undefined
874    lines.push(t.shortWarn(pct(f.short.percentUsed, lang), freeIn))
875  }
876  return lines.join(' | ')
877}
878
879// /quanta-costa: opens the pane where one can be drawn and answers with the view as text,
880// which every surface shows, the phone included.
881const showView = async ($: EngineInterface, cfg: Settings) => {
882  await $.ui.open({ id: PANE, title: 'Quanta Costa' }).catch(() => undefined)
883  const s = normalize(await read($, snap))
884  return summaryText(s, cfg, s.now || (await $.clock.now()))
885}
886
887export const register: Register = (on, options) => {
888  let cfg = toSettings(options as Record<string, unknown>)
889  const noticeOptions = (): NoticeOptions => ({
890    lang: cfg.lang,
891    planPrice: cfg.planPrice,
892    billingDay: cfg.billingDay,
893    currency: cfg.currency,
894    eurRate: cfg.eurRate,
895  })
896
897  on('session.start', async ($, e, next) => {
898    if (isAllDefault(cfg)) {
899      const saved = await findSavedOptions($).catch(() => null)
900      if (saved) {
901        cfg = toSettings(saved)
902        $.ui.invalidate('ui.render')
903      }
904    }
905    await $.command
906      .register({ name: PANE, description: TEXTS[cfg.lang].commandDescription })
907      .catch(() => undefined)
908    if (cfg.openPane) await $.ui.open({ id: PANE, title: 'Quanta Costa' }).catch(() => undefined)
909    await refresh($).catch(() => undefined)
910    // Loaded mid-session: "last prompt" starts at zero, not at the whole session.
911    await update($, snap, s => {
912      const f = normalize(s)
913      return f.baseUsd === 0 ? { ...f, baseUsd: f.usd ?? 0 } : f
914    })
915    await bookPeriod($, cfg.planPrice, cfg.billingDay).catch(() => undefined)
916    await notify($, noticeOptions()).catch(() => undefined)
917    $.clock.every(60_000, () => {
918      void $.clock.now().then(now => update($, snap, s => ({ ...normalize(s), now })))
919    })
920    return next(e)
921  })
922
923  // A new prompt starts a new "last prompt" count.
924  on('prompt.submit', async ($, e, next) => {
925    // The Claude mobile app sends "/quanta-costa" as plain text instead of running the command,
926    // and draws no pane, toast or dropped prompt's reason. What it does show is Claude's reply:
927    // so the numbers go along as context, and Claude answers with them.
928    let event = e
929    if (isPaneRequest(e.text)) {
930      const s = normalize(await read($, snap))
931      const line = summaryText(s, cfg, s.now || (await $.clock.now()))
932      event = { ...e, context: [...(e.context ?? []), phoneReply(line)] }
933    }
934    const usage = await $.session.usage()
935    await update($, snap, s => ({
936      ...normalize(s),
937      usd: usage.cost?.usd ?? normalize(s).usd,
938      baseUsd: usage.cost?.usd ?? normalize(s).usd ?? 0,
939      baseTokens: normalize(s).tokens,
940    }))
941    return next(event)
942  })
943
944  // Refresh the cost after every tool call so the number moves live.
945  on('tool.call', async ($, e, next) => {
946    const result = await next(e)
947    await refresh($).catch(() => undefined)
948    return result
949  })
950
951  // Sum the tokens of every turn, subagents included.
952  on('turn.complete', async ($, e, next) => {
953    const u = e.usage
954    if (u) {
955      const sum =
956        u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
957      await update($, snap, s => {
958        const f = normalize(s)
959        return { ...f, tokens: f.tokens + sum }
960      })
961    }
962    return next(e)
963  })
964
965  on('session.measure', async ($, e, next) => {
966    const now = await $.clock.now()
967    await update($, snap, s => ({
968      ...normalize(s),
969      limits: e.rateLimits.length > 0 ? e.rateLimits.map(toLimit) : normalize(s).limits,
970      usd: e.cost?.usd ?? normalize(s).usd,
971      now,
972    }))
973    await bookPeriod($, cfg.planPrice, cfg.billingDay).catch(() => undefined)
974    await notify($, noticeOptions()).catch(() => undefined)
975    return next(e)
976  })
977
978  on('session.end', async ($, e, next) => {
979    if (e.reason === 'clear') await update($, snap, () => EMPTY)
980    return next(e)
981  })
982
983  on('command.run', { command: PANE }, async $ => ({ text: await showView($, cfg) }))
984
985  on('ui.render', { component: ['AbovePrompt', 'Pane'] }, async ($, e, next) => {
986    if (e.component === 'Pane' && e.requestId !== PANE) return next(e)
987    if (e.component === 'AbovePrompt' && e.props.hasSurvey) return next(e)
988
989    const { Box, Button, Text } = $.ui.resolve(e)
990    const { billingDay, currency, eurRate, planPrice, lang } = cfg
991    const t = TEXTS[lang]
992    const s = normalize(await read($, snap))
993    const isOpen = await read($, isHelpOpen)
994    const now = s.now || (await $.clock.now())
995
996    const { week, short, isShortTight, isSubscription, lastUsd, lastTokens, period, forecast, periodUsd, planUsd, valueRatio, since } =
997      figures(s, cfg, now)
998
999    const off = lang === 'de' ? 'aus' : 'off'
1000
1001    // "Title: body" on one line, the title in bold.
1002    const helpItem = (text: string, color?: 'success' | 'warning') => {
1003      const cut = text.indexOf(': ')
1004      if (cut <= 0) return <Text>{text}</Text>
1005      const title = text.slice(0, cut + 1)
1006      const body = ` ${text.slice(cut + 2)}`
1007      return color ? (
1008        <Text>
1009          <Text bold color={color}>
1010            {title}
1011          </Text>
1012          {body}
1013        </Text>
1014      ) : (
1015        <Text>
1016          <Text bold>{title}</Text>
1017          {body}
1018        </Text>
1019      )
1020    }
1021
1022    // A blank line before each topic, so the help stays easy to read.
1023    const spaced = (items: (ReturnType<typeof helpItem> | null)[]) =>
1024      items.flatMap(item => (item === null ? [] : [<Text>{' '}</Text>, item]))
1025
1026    const label = (text: string) => (
1027      <Box width={8} flexShrink={0}>
1028        <Text bold>{text}</Text>
1029      </Box>
1030    )
1031
1032    return (
1033      <Box>
1034        <Box key="logo" width={3} flexShrink={0}>
1035          <Text>{LOGO}</Text>
1036        </Box>
1037        <Box flexDirection="column">
1038        {period ? (
1039          <Box key="period" gap={1}>
1040            {label(t.period)}
1041            <Text color="suggestion">{bar(period.percentGone)}</Text>
1042            <Text>
1043              {t.periodLine(
1044                weeks(period.daysGone, lang),
1045                weeks(period.daysTotal, lang),
1046                weeks(period.daysLeft, lang),
1047                period.daysLeft,
1048              )}
1049            </Text>
1050          </Box>
1051        ) : null}
1052
1053        {week ? (
1054          <Box key="week" gap={1}>
1055            {label(t.names[week.kind] ?? week.kind)}
1056            <Text color={colorFor(week.percentUsed)}>{bar(week.percentUsed)}</Text>
1057            <Text>
1058              {t.weekLine(pct(week.percentUsed, lang), pct(Math.max(0, 100 - Math.round(week.percentUsed)), lang))}
1059            </Text>
1060            {forecast ? (
1061              <Text color={forecast.isOnTrack ? 'success' : 'warning'}>
1062                {forecast.isOnTrack ? t.onTrack : t.runsOut(weekdayTime(forecast.runsOutAt, lang))}
1063              </Text>
1064            ) : null}
1065            {week.resetsAt ? <Text>{t.resets(shortDate(Date.parse(week.resetsAt), lang))}</Text> : null}
1066          </Box>
1067        ) : (
1068          <Text dimColor>{t.noReading}</Text>
1069        )}
1070
1071        <Box key="session" gap={1}>
1072          {label(t.session)}
1073          <Text>{t.sessionMain(tokenText(s.tokens, lang), money(s.usd, currency, eurRate, lang))}</Text>
1074          <Text dimColor>{t.sessionLast(money(lastUsd, currency, eurRate, lang), tokenText(lastTokens, lang))}</Text>
1075          <Button
1076            key="help"
1077            hotkey="h"
1078            label={isOpen ? t.closeHelp : t.help}
1079            onPress={() => update($, isHelpOpen, open => !open)}
1080          />
1081        </Box>
1082
1083        {valueRatio !== null ? (
1084          <Box key="value" gap={1}>
1085            {label(t.value)}
1086            <Text color={valueRatio >= 1 ? 'success' : 'suggestion'}>{bar(valueRatio * 100)}</Text>
1087            <Text>
1088              {t.valueLine(
1089                money(periodUsd, currency, eurRate, lang),
1090                money(planUsd, currency, eurRate, lang),
1091                since !== null ? shortDate(since, lang) : undefined,
1092              )}
1093            </Text>
1094            {valueRatio >= 1 ? (
1095              <Text color="success" bold>{`(${dec(valueRatio, 1, lang)}×)`}</Text>
1096            ) : (
1097              <Text>{`(${dec(valueRatio, 1, lang)}×)`}</Text>
1098            )}
1099          </Box>
1100        ) : null}
1101
1102        {isShortTight && short ? (
1103          <Text color="warning">
1104            {t.shortWarn(
1105              pct(short.percentUsed, lang),
1106              short.resetsAt ? duration(Date.parse(short.resetsAt) - now, lang) : undefined,
1107            )}
1108          </Text>
1109        ) : null}
1110
1111        {isOpen ? (
1112          <Box key="helpbox" flexDirection="column" borderStyle="round" borderDimColor paddingX={2} paddingY={1} marginTop={1}>
1113            <Text bold>{t.helpTitle}</Text>
1114            {spaced([
1115              helpItem(
1116                period
1117                  ? t.helpPeriod(billingDay, shortDate(period.start, lang, 'UTC'), shortDate(period.end, lang, 'UTC'))
1118                  : t.helpPeriodUnset,
1119              ),
1120              helpItem(t.helpWeek),
1121              helpItem(t.helpFive),
1122              isSubscription ? helpItem(t.helpHypothetical, 'success') : helpItem(t.helpReal, 'warning'),
1123              helpItem(t.helpTokens),
1124              helpItem(t.helpLast),
1125              helpItem(t.helpPace),
1126              helpItem(t.helpNotices),
1127              valueRatio !== null ? helpItem(t.helpValue) : null,
1128              currency === 'EUR' ? helpItem(t.helpEuro(dec(eurRate, 5, lang))) : null,
1129            ])}
1130            <Text>{' '}</Text>
1131            <Text dimColor>
1132              {t.helpActive(
1133                billingDay >= 1 && billingDay <= 31 ? String(billingDay) : off,
1134                currency,
1135                planPrice > 0 ? money(planUsd, currency, eurRate, lang) : off,
1136              )}
1137            </Text>
1138          </Box>
1139        ) : null}
1140        </Box>
1141      </Box>
1142    )
1143  })
1144}
1145
types/index.d.ts 24 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Snapshot = {
4  limits: Limit[]
5  usd: number | null
6  baseUsd: number
7  tokens: number
8  baseTokens: number
9  // What the period's other runs (and amounts from before 1.6.1) add up to.
10  othersUsd: number
11  // This run's first and highest reading in the period: its cost is the difference.
12  ownFirst: number | null
13  ownTop: number
14  // When counting began in this period, if known.
15  since: number | null
16  now: number
17}
18
19declare module 'claude-code' {
20  interface PluginState {
21    'quanta-costa': { snap: Snapshot; isHelpOpen: boolean }
22  }
23}
24