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.


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×)
2.1×. Above 1× your subscription has paid for itself. If counting began after the period started, the row says since when.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./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.4,42 €, 12.10.).~/.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).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.
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.
claude plugin uninstall quanta-costa@quanta-costa
The ledger stays in ~/.claude/quanta-costa/; delete that folder too if you want it gone.
| Setting | What it does | Default |
|---|---|---|
language | en (English) or de (German): labels, help, number and date formats | en |
billingDay | Day of the month your subscription renews (1 to 31). It is on your Anthropic receipt. 0 hides the Period row. | 0 |
currency | USD or EUR | USD |
eurRate | Euros per US dollar, used with EUR | 0.89238 (ECB, 2026-10-09) |
planPrice | Your monthly plan price in your currency, as on your receipt. Turns on the Value row. 0 hides it. | 0 |
openPane | Opens 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.
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.
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.
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.
MIT, see LICENSE. Made by Jörg Wukonig, Wukonig & Partner.
hooks/register.tsx 1145 lines1import { 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}
1145types/index.d.ts 24 lines1export 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