A band above the prompt showing this session's share of your usage limits, its API-equivalent cost, and what the same work would cost on each Claude model

A Claude Code mod that puts a small panel above the prompt box showing how much of your usage limits this session is using, and what the same work would have cost on each Claude model.
◆ Limits 5h week If all on 5h week
This session 3% 0.6% ○ Fable 5.1 11.1% 2.2%
Account ██░░░░░░ 23% ████░░░░ 40.6% ● Opus 5.5 4.4% 0.9%
Resets in 2h 10m 3d 4h ○ Sonnet 5.5 2.2% 0.4%
○ Haiku 5.5 0.1% <0.1%
Models ████████████ ● Opus 5.5 2 req ○ Haiku 5.5 1 req
At the prompt of a Claude Code terminal session:
/plugin install limit-lens --marketplace cristi71000/limit-lens
Answer y to add the marketplace, then press Enter to install for your user. From a shell instead:
claude plugin install limit-lens --marketplace cristi71000/limit-lens
In the Claude desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with cristi71000/limit-lens, then Add plugin.
┃ on each meter marks how much of the window has gone by: usage more than 5 points under it shows green, within 5 either way yellow, more than 5 over red. This session's share is the window's usage times this session's part of all the work your Claude Code sessions on this machine did in that window, so the sessions' figures add up to the account's. Usage from elsewhere (claude.ai, another device) cannot be placed, so it is shared out in the same proportions.d) opens the side pane.limit in ~1h 20m or ≈70% at reset./limits, or Details): every local session's part of the 5-hour window, each project's part of the week, this session's prompt-cache hit rate and full re-reads (with what they cost over cache hits), and how full its context window is.In the plugin's settings (/config in a terminal session):
Prices are read from Anthropic's pricing page and refreshed in the background, so new models are priced and compared as soon as they are listed. The panel refreshes every 10 seconds.
Everything stays on your machine. limit-lens sends no data anywhere: its one network request carries nothing from your machine or your sessions.
~/.claude/projects/ (or $CLAUDE_CONFIG_DIR/projects/), read for each response's token counts and model, each session's title and the folder it ran in. This is how it splits the account's usage across your sessions and projects. Only those fields are kept, in memory; message text is never stored, shown or sent.HOME and CLAUDE_CONFIG_DIR, read only to find the folder above. They are paths, not credentials, and are never sent anywhere.tail: the only command it runs, always as tail -c +N <log>, where <log> is one of the session logs above and N the byte it stopped at last time. This reads only what each log gained since the last check (every 10 seconds) instead of the whole file. It runs nothing else and takes no commands from anywhere.https://platform.claude.com/docs/en/about-claude/pricing.md. It is fetched once every 12 hours, or every 30 minutes while a model you use is not yet priced, and only its "Model pricing" table is read. platform.claude.com is the only host the plugin contacts./limits command: limit-lens adds /limits and answers it itself by opening its Details pane. It does not see or change any other command.Privacy policy: PRIVACY.md.
MIT, see LICENSE.
claude plugin validate .
claude plugin test .hooks/register.tsx 813 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Breakdown, BreakdownRow, LimitTrack, ModelTally, PriceState, RequestUsage } from '../types'
5import { cacheHealth } from './cache'
6import {
7 WINDOWS,
8 formatDuration,
9 formatPercent,
10 formatResetIn,
11 elapsedFraction,
12 forecast,
13 formatForecast,
14 PACE_SLACK,
15 meterRuns,
16 meterSvg,
17 METER_SIZE,
18 paceColor,
19 sessionDraw,
20 shareOf,
21 windowStart,
22 trackLimits,
23} from './limits'
24import { modelColor, shareBar } from './palette'
25import { BUILT_IN, parsePricingPage, priceKey, priceRequest, toRequest } from './pricing'
26import { makeScanner } from './sessions'
27import type { SessionWork } from './sessions'
28
29const BAR_CELLS = 12
30const METER_CELLS = 10
31/** How many requests' token counts a session keeps; the oldest go first. */
32const MAX_REQUESTS = 5000
33/** Prices older than this are fetched again. */
34const STALE_MS = 12 * 60 * 60_000
35/** A failed fetch, or one a new model prompted, is not tried again sooner than this. */
36const RETRY_MS = 30 * 60_000
37/** How often the background price check runs. */
38const CHECK_MS = 5 * 60_000
39/** How often the band re-reads the session's figures and redraws, so countdowns keep moving while idle. */
40const POLL_MS = 10_000
41/** The `$.store` key the last good prices are kept under, across sessions. */
42const PRICES_KEY = 'prices'
43
44const usd = atom({ plugin: 'limit-lens', key: 'usd' } as const, null)
45const models = atom({ plugin: 'limit-lens', key: 'models' } as const, [])
46const requests = atom({ plugin: 'limit-lens', key: 'requests' } as const, [])
47const prices = atom({ plugin: 'limit-lens', key: 'prices' } as const, BUILT_IN)
48const limits = atom({ plugin: 'limit-lens', key: 'limits' } as const, [])
49const shares = atom({ plugin: 'limit-lens', key: 'shares' } as const, {})
50const expanded = atom({ plugin: 'limit-lens', key: 'expanded' } as const, null)
51const breakdown = atom({ plugin: 'limit-lens', key: 'breakdown' } as const, { sessions: [], projects: [] })
52
53/** The details pane's id, and the command that opens it. */
54const PANE = 'limit-lens'
55const COMMAND = 'limits'
56
57/** A folder without a trailing slash, so the same folder is one key however a log spelled it. */
58const folderKey = (cwd: string): string => (cwd.length > 1 ? cwd.replace(/\/+$/, '') : cwd)
59
60/**
61 * A short name for each folder: its own name, with as many parent folders as it takes to tell it
62 * apart from the others (`work/api`, `personal/api`), or the whole path where nothing shorter does.
63 */
64export const folderLabels = (folders: string[]): Map<string, string> => {
65 const parts = new Map(folders.map(folder => [folder, folder.split('/').filter(Boolean)]))
66 const tail = (segments: string[], depth: number) => segments.slice(-depth).join('/')
67 const labels = new Map<string, string>()
68 for (const [folder, segments] of parts) {
69 let depth = 1
70 const clashes = () =>
71 [...parts].some(([other, theirs]) => other !== folder && tail(theirs, depth) === tail(segments, depth))
72 while (depth < segments.length && clashes()) {
73 depth += 1
74 }
75 labels.set(folder, clashes() ? folder : tail(segments, depth) || folder)
76 }
77
78 return labels
79}
80
81/**
82 * Every session's work in the 5-hour window, and the week's grouped by project folder: by its whole
83 * path, so two projects that share a folder name stay apart, each named as `folderLabels` tells them.
84 */
85export const toBreakdown = (fiveHour: SessionWork[], week: SessionWork[]): Breakdown => {
86 const folders = [...new Set([...fiveHour, ...week].flatMap(work => (work.cwd ? [folderKey(work.cwd)] : [])))]
87 const labels = folderLabels(folders)
88 const labelOf = (cwd: string) => labels.get(folderKey(cwd)) ?? cwd
89
90 // A session as the details pane names it: its title, else its folder, else the start of its id.
91 const sessionName = (work: SessionWork): string =>
92 work.title ?? (work.cwd ? `${labelOf(work.cwd)} session` : work.owner.slice(0, 8))
93
94 const projects = new Map<string, BreakdownRow>()
95 for (const work of week) {
96 const key = work.cwd ? folderKey(work.cwd) : ''
97 const name = work.cwd ? labelOf(work.cwd) : 'other'
98 const held = projects.get(key) ?? { name, cost: 0, isOwn: false }
99 projects.set(key, { name, cost: held.cost + work.cost, isOwn: held.isOwn || work.isOwn })
100 }
101
102 return {
103 sessions: fiveHour.map(work => ({ name: sessionName(work), cost: work.cost, isOwn: work.isOwn })),
104 projects: [...projects.values()].sort((a, b) => b.cost - a.cost),
105 }
106}
107
108/** Folds new readings in: each window's figure, reset time and when it was read. */
109const takeReadings = async ($: EngineInterface, readings: Parameters<typeof trackLimits>[1], now: number) => {
110 const tracks = trackLimits(await read($, limits), readings, now)
111 await update($, limits, () => tracks)
112}
113
114/** `claude-opus-5-5[1m]` → `Opus 5.5 (1M)`, `opus-6` → `Opus 6`; anything unrecognised stays as given. */
115export const modelLabel = (id: string): string => {
116 const isLong = /\[1m\]$/i.test(id)
117 const bare = id.replace(/\[1m\]$/i, '')
118 const match = /^(?:claude-)?([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?$/i.exec(bare)
119 const family = match?.[1]
120 const name = family
121 ? `${family[0]!.toUpperCase()}${family.slice(1).toLowerCase()} ${match[2]}${match[3] ? `.${match[3]}` : ''}`
122 : bare.replace(/^claude-/, '')
123
124 return isLong ? `${name} (1M)` : name
125}
126
127export const formatUsd = (value: number | null): string => {
128 if (value === null) {
129 return '—'
130 }
131
132 return value > 0 && value < 0.01 ? `$${value.toFixed(4)}` : `$${value.toFixed(2)}`
133}
134
135const tally = (list: ModelTally[], model: string): ModelTally[] =>
136 list.some(m => m.model === model)
137 ? list.map(m => (m.model === model ? { ...m, requests: m.requests + 1 } : m))
138 : [...list, { model, requests: 1 }]
139
140/**
141 * Every request priced at the current table: `actual` on the models that ran
142 * them, `ratios` how each comparison model prices the same work relative to
143 * that. Requests whose model has no price yet are counted apart, in `unpriced`.
144 */
145export const compare = (list: RequestUsage[], table: PriceState) => {
146 let actual = 0
147 let priced = 0
148 const totals = table.candidates.map(() => 0)
149 const unpriced = new Map<string, number>()
150
151 for (const request of list) {
152 const cost = priceRequest(table.models, request.model, request)
153
154 if (cost === null) {
155 unpriced.set(request.model, (unpriced.get(request.model) ?? 0) + 1)
156 continue
157 }
158
159 actual += cost
160 priced += 1
161 table.candidates.forEach((key, i) => {
162 totals[i]! += priceRequest(table.models, key, request) ?? 0
163 })
164 }
165
166 return {
167 priced,
168 unpriced: [...unpriced.keys()],
169 unpricedCount: [...unpriced.values()].reduce((n, c) => n + c, 0),
170 ratios: actual > 0 ? totals.map(total => total / actual) : [],
171 }
172}
173
174/** Fetches the pricing page and, when it reads, puts its table in use and keeps it for later sessions. */
175const refreshPrices = async ($: EngineInterface) => {
176 const now = await $.clock.now()
177
178 try {
179 // Anthropic's pricing page as markdown: its "Model pricing" table is what the mod reads.
180 const { ok, status, text } = await $.http.fetch('https://platform.claude.com/docs/en/about-claude/pricing.md')
181 const table = ok ? parsePricingPage(text) : null
182
183 if (!table) {
184 throw new Error(ok ? 'no readable pricing table' : `HTTP ${status}`)
185 }
186
187 const fresh: PriceState = { source: 'page', fetchedAt: now, ...table }
188 await update($, prices, () => fresh)
189 await $.store.set(PRICES_KEY, fresh)
190 } catch (error) {
191 const failure = error instanceof Error ? error.message : String(error)
192 await update($, prices, held => ({ ...held, failedAt: now, failure }))
193 }
194}
195
196/** Whether to fetch now: the prices are old, or a model answered that they don't price, and no try failed lately. */
197export const isRefreshDue = (table: PriceState, list: RequestUsage[], now: number): boolean => {
198 const isRecentFailure = table.failedAt !== undefined && now - table.failedAt < RETRY_MS
199 const age = table.fetchedAt === null ? Infinity : now - table.fetchedAt
200 const isMissingModel = list.some(r => table.models[priceKey(r.model)] === undefined)
201
202 return !isRecentFailure && (age > STALE_MS || (isMissingModel && age > RETRY_MS))
203}
204
205export const register: Register = (on, options) => {
206 // The settings: whether the panel starts as one line, whether it forecasts, and how wide the on-pace band is.
207 const startCollapsed = options.startCollapsed !== false
208 const showForecast = options.showForecast !== false
209 const paceBand = typeof options.paceBand === 'number' && options.paceBand >= 0 ? options.paceBand : PACE_SLACK
210
211 on('session.start', async ($, e, next) => {
212 const result = await next(e)
213 const usage = await $.session.usage()
214 const { cost, rateLimits } = usage
215 const now = await $.clock.now()
216
217 if (cost) {
218 await update($, usd, () => cost.usd)
219 }
220 await takeReadings($, rateLimits, now)
221
222 // Start from the last good prices any session fetched, then keep them current in the background.
223 const kept = (await $.store.get(PRICES_KEY)) as PriceState | undefined
224 if (kept?.source === 'page' && (kept.fetchedAt ?? 0) > ((await read($, prices)).fetchedAt ?? 0)) {
225 await update($, prices, () => kept)
226 }
227
228 let isFetching = false
229 const check = async () => {
230 if (isFetching || !isRefreshDue(await read($, prices), await read($, requests), await $.clock.now())) {
231 return
232 }
233 isFetching = true
234 try {
235 await refreshPrices($)
236 } finally {
237 isFetching = false
238 }
239 }
240 $.clock.after(1000, () => void check())
241 $.clock.every(CHECK_MS, () => void check())
242
243 // Every session's log on this machine, read a piece at a time, to tell this session's share of each window.
244 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
245 const scanner = makeScanner(
246 {
247 list: path => $.fs.list(path),
248 exists: path => $.fs.exists(path),
249 // `tail -c +N` prints a log from byte N on, so each pass reads only what was added since the last.
250 readFrom: async (path, offset) => {
251 const { exitCode, stdout } = await $.process.run(['tail', '-c', `+${offset + 1}`, path], { timeoutMs: 10_000 })
252
253 return exitCode === 0 ? stdout : null
254 },
255 },
256 `${configDir}/projects`,
257 await $.session.id(),
258 )
259
260 let isMeasuring = false
261 const measureShares = async () => {
262 if (isMeasuring) {
263 return
264 }
265 isMeasuring = true
266 try {
267 const at = await $.clock.now()
268 const tracks = await read($, limits)
269 // Each window from its start: the reset time less its length, or when tracking began.
270 const starts = WINDOWS.flatMap(w => {
271 const track = tracks.find(t => t.kind === w.kind)
272
273 return track ? [{ kind: w.kind, from: windowStart(track, w.lengthMs) ?? track.since }] : []
274 })
275 if (starts.length === 0) {
276 return
277 }
278
279 const earliest = Math.min(...starts.map(s => s.from))
280 if (!(await scanner.refresh(earliest))) {
281 return
282 }
283
284 const table = await read($, prices)
285 const measured: Record<string, number> = {}
286 for (const { kind, from } of starts) {
287 const { own, all } = scanner.workSince(table.models, from)
288 measured[kind] = shareOf(own, all)
289 }
290 await update($, shares, () => measured)
291
292 const fromOf = (kind: string) => starts.find(s => s.kind === kind)?.from
293 const fiveFrom = fromOf('five_hour')
294 const weekFrom = fromOf('seven_day')
295 await update($, breakdown, () =>
296 toBreakdown(
297 fiveFrom === undefined ? [] : scanner.workBySession(table.models, fiveFrom),
298 weekFrom === undefined ? [] : scanner.workBySession(table.models, weekFrom),
299 ),
300 )
301 scanner.forget(Math.min(earliest, at))
302 } finally {
303 isMeasuring = false
304 }
305 }
306
307 // Local work only, no network and no model calls: this session's shares, then a redraw for the countdowns.
308 const poll = async () => {
309 await measureShares()
310 $.ui.invalidate('ui.render')
311 }
312 $.clock.every(POLL_MS, () => void poll())
313
314 // `/limits` opens the details pane. Last, and on its own: the panel works without it.
315 try {
316 await $.command.register({ name: COMMAND, description: 'Show limit-lens details: sessions, projects, cache, context' })
317 } catch {
318 // The pane stays reachable from the panel's Details button.
319 }
320
321 return result
322 })
323
324 on('session.measure', async ($, e, next) => {
325 const { cost, rateLimits } = e
326
327 if (cost) {
328 await update($, usd, () => cost.usd)
329 }
330 // Every measurement carries the readings to a tenth of a point; `changed` only
331 // names rateLimits on a whole-point move, which would hide smaller draws.
332 if (rateLimits.length > 0) {
333 await takeReadings($, rateLimits, await $.clock.now())
334 }
335
336 return next(e)
337 })
338
339 // Every request, the main loop's and each subagent's, reports the model that answered it.
340 on('turn.step', async function* ($, e, next) {
341 const result = yield* next(e)
342
343 if (result.usage) {
344 const { model } = result.usage
345 const request = toRequest(model, result.usage, await $.clock.now(), e.agentId === undefined)
346 await update($, models, list => tally(list, model))
347 await update($, requests, list => [...list, request].slice(-MAX_REQUESTS))
348 }
349
350 return result
351 })
352
353 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
354 if (e.props.hasSurvey) {
355 return next(e)
356 }
357
358 const elements = $.ui.resolve(e)
359 const { Box, Button, Text } = elements
360 // The band is shared: draw the panel and keep what other plugins and the engine draw there.
361 const others = await next(e)
362 const panelTree = await (async () => {
363 // Only the remote surfaces (the desktop, the editor, mobile) have Svg; the terminal does not.
364 const Svg = e.surface !== 'terminal' && 'Svg' in elements ? elements.Svg : undefined
365 const cost = await read($, usd)
366 const used = await read($, models)
367 const table$ = await read($, prices)
368 const comparison = compare(await read($, requests), table$)
369 const candidates = table$.candidates
370 const tracked = await read($, limits)
371 const windows = WINDOWS.flatMap(w => {
372 const track = tracked.find(t => t.kind === w.kind)
373
374 return track ? [{ ...w, track }] : []
375 })
376 const isSubscription = windows.length > 0
377 const now = await $.clock.now()
378 const current = modelLabel(await $.session.model())
379 const shown: ModelTally[] = used.some(m => modelLabel(m.model) === current)
380 ? used
381 : [{ model: current, requests: 0 }, ...used]
382 const bar = shareBar(used, BAR_CELLS)
383
384 // A table is a row of columns, so each column lines up whatever the font; every cell is one line.
385 const cell = (key: string, content: RenderChildren) => (
386 <Box key={key} flexDirection="row">
387 {content}
388 </Box>
389 )
390 const column = (key: string, cells: RenderChildren[]) => (
391 <Box key={key} flexDirection="column">
392 {cells}
393 </Box>
394 )
395 const table = (key: string, columns: RenderChildren[]) => (
396 <Box key={key} flexDirection="row" columnGap={2}>
397 {columns}
398 </Box>
399 )
400 const header = (text: string) => <Text dimColor>{text}</Text>
401 const label = (text: string) => <Text dimColor>{text}</Text>
402
403 const modelsRow = (
404 <Box key="models" flexDirection="row" flexWrap="wrap">
405 {label('Models ')}
406 {bar.map(segment => (
407 <Text key={`bar-${segment.model}`} color={modelColor(segment.model)}>
408 {'█'.repeat(segment.cells)}
409 </Text>
410 ))}
411 {shown.map(m => {
412 const name = modelLabel(m.model)
413 const isCurrent = name === current
414 const color = modelColor(m.model)
415
416 return (
417 <Box key={`model-${m.model}`} flexDirection="row">
418 <Text>{' '}</Text>
419 <Text color={color}>{isCurrent ? '● ' : '○ '}</Text>
420 <Text bold={isCurrent} color={color}>
421 {name}
422 </Text>
423 {m.requests > 0 ? <Text dimColor>{` ${m.requests} req`}</Text> : null}
424 </Box>
425 )
426 })}
427 </Box>
428 )
429
430 // One row per comparison model; `values` gives its cells, `undefined` until a request is priced.
431 const whatIfTable = (headers: string[], values: (i: number) => string[] | undefined) =>
432 table('what-if', [
433 column('what-if-names', [
434 cell('what-if-title', header('If all on')),
435 ...candidates.map(model => {
436 const name = modelLabel(model)
437 const isCurrent = name === current
438
439 return cell(
440 `what-if-name-${model}`,
441 [
442 <Text key="dot" color={modelColor(model)}>
443 {isCurrent ? '● ' : '○ '}
444 </Text>,
445 <Text key="name" bold={isCurrent} color={modelColor(model)}>
446 {name}
447 </Text>,
448 ],
449 )
450 }),
451 ]),
452 ...headers.map((title, col) =>
453 column(`what-if-col-${col}`, [
454 cell(`what-if-head-${col}`, header(title)),
455 ...candidates.map((model, i) =>
456 cell(`what-if-${model}-${col}`, <Text>{values(i)?.[col] ?? '—'}</Text>),
457 ),
458 ]),
459 ),
460 ])
461
462 // Only when a model has no price yet: what the comparison leaves out for want of one.
463 const unpricedNames = comparison.unpriced.map(modelLabel)
464 const unpricedRow =
465 unpricedNames.length > 0 ? (
466 <Box key="unpriced" flexDirection="row" flexWrap="wrap">
467 <Text color="warning">
468 {`${unpricedNames.join(', ')} not priced yet: comparison covers ${comparison.priced} of ${
469 comparison.priced + comparison.unpricedCount
470 } requests`}
471 </Text>
472 </Box>
473 ) : null
474
475 // The controls: expand or collapse the panel, and open the details pane.
476 const isOpen = (await read($, expanded)) ?? !startCollapsed
477 // The panel's own control, in its top-right corner: ⌄ to expand it, ⌃ to collapse it.
478 const toggle = (
479 <Box key="corner">
480 <Button key="toggle" label={isOpen ? '⌃' : '⌄'} plain dimColor onPress={() => update($, expanded, () => !isOpen)} />
481 </Box>
482 )
483 const controls = (
484 <Box key="controls" flexDirection="row">
485 <Button key="details" label="Details" hotkey="d" dimColor onPress={() => void $.ui.open({ id: PANE, title: 'Limits' })} />
486 </Box>
487 )
488 /** The panel with the toggle in its top-right corner: the body takes the width the toggle leaves. */
489 const framed = (body: RenderChildren) => (
490 <Box flexDirection="row" alignItems="flex-start" columnGap={1}>
491 <Box key="frame" flexGrow={1}>
492 {body}
493 </Box>
494 {toggle}
495 </Box>
496 )
497
498 const panel = (left: RenderChildren, right: RenderChildren) =>
499 framed(
500 <Box key="body" flexDirection="column">
501 <Box key="tables" flexDirection="row" flexWrap="wrap" columnGap={6}>
502 {left}
503 {right}
504 </Box>
505 <Box key="footer" flexDirection="row" flexWrap="wrap" columnGap={3} marginTop={1}>
506 {modelsRow}
507 {controls}
508 </Box>
509 {unpricedRow}
510 </Box>,
511 )
512
513 const mark = [
514 <Text key="mark" color="claude">
515 ◆{' '}
516 </Text>,
517 ]
518
519 if (isSubscription) {
520 const known = await read($, shares)
521 const draws = windows.map(w => sessionDraw(w.track, known[w.kind]))
522 // How old the account figure is: it only moves when this session hears back from the API.
523 const readAt = Math.max(...windows.map(w => w.track.readAt ?? w.track.since))
524 const readingAge = now - readAt >= 60_000 ? formatDuration(now - readAt) : undefined
525 const views = windows.map((w, i) => {
526 const percent = w.track.percentUsed
527 const elapsed = elapsedFraction(w.track.resetsAt, w.lengthMs, now)
528 const course = showForecast ? forecast(percent, elapsed, w.lengthMs) : undefined
529
530 return { ...w, percent, elapsed, color: paceColor(percent, elapsed, paceBand), draw: draws[i], course }
531 })
532
533 // The account's meter: the marker stands for the time gone by, so usage left of it is under pace.
534 // Where the surface draws SVG it is a thin drawn line; in the terminal it takes one cell's place.
535 const meter = (v: (typeof views)[number]): RenderChildren[] => [
536 ...(Svg
537 ? [
538 <Svg
539 key="meter"
540 source={meterSvg(v.percent, v.elapsed, v.color)}
541 alt={`${formatPercent(v.percent)} used${
542 v.elapsed === undefined ? '' : `, ${formatPercent(v.elapsed * 100)} of the window gone`
543 }`}
544 width={METER_SIZE.width}
545 height={METER_SIZE.height}
546 />,
547 ]
548 : meterRuns(v.percent, v.elapsed, METER_CELLS).map((run, i) =>
549 run.kind === 'marker' ? (
550 <Text key={`run-${i}`} bold color="text">
551 {run.text}
552 </Text>
553 ) : run.kind === 'filled' ? (
554 <Text key={`run-${i}`} color={v.color}>
555 {run.text}
556 </Text>
557 ) : (
558 <Text key={`run-${i}`} dimColor>
559 {run.text}
560 </Text>
561 ),
562 )),
563 <Text key="percent" bold color={v.color}>{` ${formatPercent(v.percent)}`}</Text>,
564 ]
565 const sessionText = (v: (typeof views)[number]) => (v.draw === undefined ? '—' : formatPercent(v.draw))
566 const courseColor = (v: (typeof views)[number]) => (v.course?.kind === 'limit' ? 'error' : undefined)
567
568 if (!isOpen) {
569 // A row per window, in columns so the rows line up: the label, the meter, this session's
570 // part, and where the window is heading; the controls beside them.
571 return framed(
572 <Box key="body" flexDirection="row" alignItems="flex-start" columnGap={3}>
573 {table('compact', [
574 column(
575 'compact-labels',
576 views.map((v, i) => cell(`compact-label-${v.kind}`, [...(i === 0 ? mark : [<Text key="gap">{' '}</Text>]), label(v.label)])),
577 ),
578 column(
579 'compact-meters',
580 views.map(v => cell(`compact-meter-${v.kind}`, meter(v))),
581 ),
582 column(
583 'compact-sessions',
584 views.map(v =>
585 cell(`compact-session-${v.kind}`, [
586 <Text key="label" dimColor>
587 {'this session '}
588 </Text>,
589 <Text key="value" bold color="claude">
590 {sessionText(v)}
591 </Text>,
592 ]),
593 ),
594 ),
595 ...(showForecast
596 ? [
597 column(
598 'compact-forecasts',
599 views.map(v =>
600 cell(
601 `compact-forecast-${v.kind}`,
602 <Text dimColor={!courseColor(v)} color={courseColor(v)}>
603 {v.course ? formatForecast(v.course) : ''}
604 </Text>,
605 ),
606 ),
607 ),
608 ]
609 : []),
610 ])}
611 {controls}
612 </Box>,
613 )
614 }
615
616 const limitsTable = table('limits', [
617 column('limits-labels', [
618 cell('limits-title', [
619 ...mark,
620 <Text key="title" bold>
621 Limits
622 </Text>,
623 ]),
624 cell('limits-session-label', label('This session')),
625 cell('limits-account-label', label(readingAge ? `Account (${readingAge} ago)` : 'Account')),
626 cell('limits-reset-label', label('Resets in')),
627 ...(showForecast ? [cell('limits-forecast-label', label('Forecast'))] : []),
628 ]),
629 ...views.map(v =>
630 column(`limits-${v.kind}`, [
631 cell(`limits-head-${v.kind}`, header(v.label)),
632 cell(
633 `session-${v.kind}`,
634 <Text bold color="claude">
635 {sessionText(v)}
636 </Text>,
637 ),
638 cell(`account-${v.kind}`, meter(v)),
639 cell(`reset-${v.kind}`, <Text dimColor>{formatResetIn(v.track.resetsAt, now) ?? '—'}</Text>),
640 ...(showForecast
641 ? [
642 cell(
643 `forecast-${v.kind}`,
644 <Text dimColor={!courseColor(v)} color={courseColor(v)}>
645 {v.course ? formatForecast(v.course) : '—'}
646 </Text>,
647 ),
648 ]
649 : []),
650 ]),
651 ),
652 ])
653
654 return panel(
655 limitsTable,
656 whatIfTable(
657 windows.map(w => w.label),
658 i =>
659 comparison.ratios.length > 0
660 ? draws.map(d => (d === undefined ? '—' : formatPercent(d * (comparison.ratios[i] ?? 0))))
661 : undefined,
662 ),
663 )
664 }
665
666 if (!isOpen) {
667 return framed(
668 <Box key="body" flexDirection="row" flexWrap="wrap" columnGap={3}>
669 <Box key="compact" flexDirection="row">
670 {mark}
671 <Text dimColor>{'This session '}</Text>
672 <Text bold color="claude">
673 {formatUsd(cost)}
674 </Text>
675 </Box>
676 {controls}
677 </Box>,
678 )
679 }
680
681 const costTable = table('cost', [
682 column('cost-labels', [
683 cell('cost-title', [
684 ...mark,
685 <Text key="title" bold>
686 Cost
687 </Text>,
688 ]),
689 cell('cost-session-label', label('This session')),
690 ]),
691 column('cost-values', [
692 cell('cost-head', header('API price')),
693 cell(
694 'session-cost',
695 <Text bold color="claude">
696 {formatUsd(cost)}
697 </Text>,
698 ),
699 ]),
700 ])
701
702 return panel(
703 costTable,
704 whatIfTable(['≈ cost'], i =>
705 cost !== null && comparison.ratios.length > 0 ? [formatUsd(cost * (comparison.ratios[i] ?? 0))] : undefined,
706 ),
707 )
708 })()
709
710 return (
711 <Box flexDirection="column">
712 <Box key="limit-lens">{panelTree}</Box>
713 <Box key="others">{others}</Box>
714 </Box>
715 )
716 })
717
718 on('command.run', { command: COMMAND }, async $ => {
719 await $.ui.open({ id: PANE, title: 'Limits' })
720
721 return {}
722 })
723
724 // The details pane: what the panel has no room for.
725 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
726 const { Box, Text } = $.ui.resolve(e)
727 const now = await $.clock.now()
728 const tracked = await read($, limits)
729 const table$ = await read($, prices)
730 const parts = await read($, breakdown)
731 const { context } = await $.session.usage()
732 const cache = cacheHealth(await read($, requests), table$.models, now)
733 const cost = await read($, usd)
734
735 const heading = (key: string, text: string) => (
736 <Box key={key} marginTop={1}>
737 <Text bold>{text}</Text>
738 </Box>
739 )
740 const note = (key: string, text: string) => (
741 <Box key={key}>
742 <Text dimColor>{text}</Text>
743 </Box>
744 )
745
746 // A list's rows: each one's part of the window's account figure, or of the session's cost off a subscription.
747 const rows = (key: string, list: BreakdownRow[], percentUsed: number | undefined) => {
748 const total = list.reduce((sum, row) => sum + row.cost, 0)
749
750 return (
751 <Box key={key} flexDirection="row" columnGap={3}>
752 <Box key={`${key}-names`} flexDirection="column">
753 {list.map((row, i) => (
754 <Box key={`${key}-name-${i}`} flexDirection="row">
755 <Text color={row.isOwn ? 'claude' : undefined}>{row.isOwn ? '● ' : '○ '}</Text>
756 <Text bold={row.isOwn}>{row.name}</Text>
757 </Box>
758 ))}
759 </Box>
760 <Box key={`${key}-values`} flexDirection="column">
761 {list.map((row, i) => (
762 <Box key={`${key}-value-${i}`}>
763 <Text>
764 {percentUsed === undefined
765 ? formatUsd(row.cost)
766 : formatPercent(total > 0 ? (percentUsed * row.cost) / total : 0)}
767 </Text>
768 </Box>
769 ))}
770 </Box>
771 </Box>
772 )
773 }
774
775 const fiveHour = tracked.find(t => t.kind === 'five_hour')?.percentUsed
776 const week = tracked.find(t => t.kind === 'seven_day')?.percentUsed
777 const contextColor = (context.percent ?? 0) >= 90 ? 'error' : (context.percent ?? 0) >= 80 ? 'warning' : undefined
778
779 return (
780 <Box flexDirection="column">
781 {heading('sessions-head', fiveHour === undefined ? 'Sessions' : 'Sessions in this 5-hour window')}
782 {parts.sessions.length > 0 ? rows('sessions', parts.sessions, fiveHour) : note('sessions-none', 'Reading the session logs…')}
783 {heading('projects-head', week === undefined ? 'Projects' : 'Projects this week')}
784 {parts.projects.length > 0 ? rows('projects', parts.projects, week) : note('projects-none', 'Reading the session logs…')}
785 {heading('cache-head', 'Prompt cache, this session')}
786 {cache ? (
787 <Box key="cache" flexDirection="column">
788 <Text>{`Hit rate ${formatPercent(cache.hitRate * 100)} over the last requests`}</Text>
789 <Text color={cache.reReads > 0 ? 'warning' : undefined} dimColor={cache.reReads === 0}>
790 {cache.reReads === 0
791 ? 'No full re-reads in the last hour'
792 : `${cache.reReads} full re-read${cache.reReads === 1 ? '' : 's'} in the last hour, ≈${formatUsd(cache.extraUsd)} more than cache hits${
793 cache.lastReReadAt === undefined ? '' : ` (last ${formatDuration(now - cache.lastReReadAt) ?? 'just now'} ago)`
794 }`}
795 </Text>
796 </Box>
797 ) : (
798 note('cache-none', 'No requests yet')
799 )}
800 {heading('context-head', 'Context window, this session')}
801 <Box key="context">
802 <Text color={contextColor}>
803 {context.percent === undefined
804 ? `${Math.round(context.window / 1000)}k tokens, fill not reported yet`
805 : `${formatPercent(context.percent)} full: ${Math.round((context.tokens ?? 0) / 1000)}k of ${Math.round(context.window / 1000)}k tokens`}
806 </Text>
807 </Box>
808 {fiveHour === undefined && cost !== null ? note('cost-note', `This session so far: ${formatUsd(cost)} at API prices`) : null}
809 </Box>
810 )
811 })
812}
813hooks/cache.ts 46 lines1import type { Price, RequestUsage } from '../types'
2import { priceRequest } from './pricing'
3
4/** How many of the latest requests the hit rate is taken over. */
5const RECENT = 20
6/** A request that writes at least this many tokens, most of its prompt, re-read the conversation in full. */
7const RE_READ_TOKENS = 20_000
8
9/**
10 * How well this session's prompt cache is holding. The hit rate is the share
11 * of the latest requests' prompts the cache served. A full re-read is a request
12 * that had to write most of a large prompt afresh: after a pause longer than
13 * the cache keeps, or a change early in the conversation. `extraUsd` is what
14 * the last hour's re-reads cost over what cache hits would have.
15 */
16export type CacheHealth = { hitRate: number; reReads: number; extraUsd: number; lastReReadAt?: number }
17
18const written = (r: RequestUsage) => r.cacheWrite + (r.cacheWrite1h ?? 0)
19const promptOf = (r: RequestUsage) => r.input + r.cacheRead + written(r)
20
21export const isReRead = (r: RequestUsage): boolean => written(r) >= RE_READ_TOKENS && written(r) >= promptOf(r) / 2
22
23export const cacheHealth = (requests: readonly RequestUsage[], models: Record<string, Price>, now: number): CacheHealth | undefined => {
24 const recent = requests.slice(-RECENT)
25 const prompt = recent.reduce((sum, r) => sum + promptOf(r), 0)
26
27 if (recent.length === 0 || prompt === 0) {
28 return undefined
29 }
30
31 const lastHour = requests.filter(r => r.at !== undefined && r.at >= now - 60 * 60_000 && isReRead(r))
32 // What each re-read would have cost had the cache served what it wrote.
33 const extraUsd = lastHour.reduce((sum, r) => {
34 const asHit = { ...r, cacheRead: r.cacheRead + written(r), cacheWrite: 0, cacheWrite1h: 0 }
35
36 return sum + ((priceRequest(models, r.model, r) ?? 0) - (priceRequest(models, r.model, asHit) ?? 0))
37 }, 0)
38
39 return {
40 hitRate: recent.reduce((sum, r) => sum + r.cacheRead, 0) / prompt,
41 reReads: lastHour.length,
42 extraUsd,
43 lastReReadAt: lastHour[lastHour.length - 1]?.at,
44 }
45}
46hooks/limits.ts 194 lines1import type { SessionRateLimit } from 'claude-code'
2
3import type { LimitTrack } from '../types'
4
5/** The subscription windows the band shows, in display order. */
6export const WINDOWS = [
7 { kind: 'five_hour', label: '5h', lengthMs: 5 * 60 * 60_000 },
8 { kind: 'seven_day', label: 'week', lengthMs: 7 * 24 * 60 * 60_000 },
9] as const
10
11/** Folds the latest readings into the tracks: each window's figure, its reset time, and when it was read. */
12export const trackLimits = (tracks: LimitTrack[], readings: readonly SessionRateLimit[], now: number): LimitTrack[] => {
13 const next = [...tracks]
14
15 for (const reading of readings) {
16 if (!WINDOWS.some(w => w.kind === reading.kind)) {
17 continue
18 }
19
20 const at = next.findIndex(t => t.kind === reading.kind)
21 const { kind, percentUsed, resetsAt } = reading
22 const track = { kind, percentUsed, resetsAt, since: next[at]?.since ?? now, readAt: now }
23
24 if (at < 0) {
25 next.push(track)
26 } else {
27 next[at] = track
28 }
29 }
30
31 return next
32}
33
34/** When a window began: its reset time less its length; undefined when the reset time is unknown. */
35export const windowStart = (track: LimitTrack, lengthMs: number): number | undefined => {
36 const resets = track.resetsAt === undefined ? NaN : Date.parse(track.resetsAt)
37
38 return Number.isFinite(resets) ? resets - lengthMs : undefined
39}
40
41/**
42 * This session's part of the window: the account's usage times this session's
43 * share of all the work done on this machine in the window. Shares of all the
44 * sessions add up to one, so their parts add up to the account's usage, and no
45 * part is ever more than it. Undefined until the share is known.
46 */
47export const sessionDraw = (track: LimitTrack, share: number | undefined): number | undefined =>
48 share === undefined ? undefined : track.percentUsed * Math.min(1, Math.max(0, share))
49
50/**
51 * A share from the work done in a window: this session's against every
52 * session's on this machine. Work no local session did (claude.ai, another
53 * device) cannot be placed, so it is shared out in the same proportions.
54 */
55export const shareOf = (own: number, all: number): number => (all > 0 ? own / all : 0)
56
57/** `23.46` → `23.5%`; a sliver above zero → `<0.1%`. */
58export const formatPercent = (value: number): string =>
59 value > 0 && value < 0.05 ? '<0.1%' : `${Number(value.toFixed(1))}%`
60
61/** A span of time: `3d 4h`, `2h 10m`, `35m`; undefined when not a positive number of minutes. */
62export const formatDuration = (ms: number): string | undefined => {
63 const minutes = Math.round(ms / 60_000)
64
65 if (!Number.isFinite(minutes) || minutes <= 0) {
66 return undefined
67 }
68
69 const days = Math.floor(minutes / 1440)
70 const hours = Math.floor((minutes % 1440) / 60)
71
72 if (days > 0) {
73 return `${days}d ${hours}h`
74 }
75
76 return hours > 0 ? `${hours}h ${minutes % 60}m` : `${minutes}m`
77}
78
79/** Time until `resetsAt`; undefined when unknown or past. */
80export const formatResetIn = (resetsAt: string | undefined, now: number): string | undefined =>
81 resetsAt === undefined ? undefined : formatDuration(Date.parse(resetsAt) - now)
82
83/** The meter's colour when the window's timing is unknown: calm under half, a warning past it, an alarm near the limit. */
84export const levelColor = (percent: number): string => (percent >= 80 ? 'error' : percent >= 50 ? 'warning' : 'success')
85
86/** How far through its window the account is, 0 to 1, from when the window resets; undefined when that is unknown. */
87export const elapsedFraction = (resetsAt: string | undefined, lengthMs: number, now: number): number | undefined => {
88 const at = resetsAt === undefined ? NaN : Date.parse(resetsAt)
89
90 return Number.isFinite(at) ? Math.min(1, Math.max(0, 1 - (at - now) / lengthMs)) : undefined
91}
92
93/** How many points either side of an even pace still read as on pace, unless the settings say otherwise. */
94export const PACE_SLACK = 5
95
96/**
97 * The meter's colour by pace: usage against the share of the window gone by.
98 * More than `slack` points under is green, within it either way yellow, more
99 * than it over red. Without the window's timing, the plain level decides.
100 */
101export const paceColor = (percent: number, elapsed: number | undefined, slack = PACE_SLACK): string => {
102 if (elapsed === undefined) {
103 return levelColor(percent)
104 }
105
106 const ahead = percent - elapsed * 100
107
108 return ahead < -slack ? 'success' : ahead > slack ? 'error' : 'warning'
109}
110
111/** Where the window is heading at the pace so far: the limit before the reset, or the share used by then. */
112export type Forecast = { kind: 'limit'; inMs: number } | { kind: 'reset'; percent: number }
113
114/**
115 * The window's course at its average pace so far: usage over the share of the
116 * window gone by. Too early in a window (under 5% of it) or with nothing used,
117 * there is no pace to go by.
118 */
119export const forecast = (percent: number, elapsed: number | undefined, lengthMs: number): Forecast | undefined => {
120 if (elapsed === undefined || elapsed < 0.05 || percent <= 0) {
121 return undefined
122 }
123
124 const perMs = percent / (elapsed * lengthMs)
125 const toLimit = (100 - percent) / perMs
126 const toReset = (1 - elapsed) * lengthMs
127
128 return toLimit < toReset ? { kind: 'limit', inMs: Math.max(0, toLimit) } : { kind: 'reset', percent: percent / elapsed }
129}
130
131/** `limit in ~1h 20m`, `limit any minute`, `≈70% at reset`. */
132export const formatForecast = (course: Forecast): string =>
133 course.kind === 'limit'
134 ? course.inMs < 60_000
135 ? 'limit any minute'
136 : `limit in ~${formatDuration(course.inMs)}`
137 : `≈${Math.round(course.percent)}% at reset`
138
139/** One run of the meter: filled cells, empty cells, or the marker for the time gone by. */
140export type MeterRun = { kind: 'filled' | 'empty' | 'marker'; text: string }
141
142/**
143 * The meter as runs of text cells, for the terminal. The marker takes the place
144 * of the cell the time gone by falls in, so the meter keeps its width: after
145 * 2.5 of 5 hours, the sixth of ten cells, which starts at the halfway point.
146 */
147export const meterRuns = (percent: number, elapsed: number | undefined, cells: number): MeterRun[] => {
148 const filled = Math.min(cells, Math.max(0, Math.round((percent / 100) * cells)))
149 const marker = elapsed === undefined ? -1 : Math.min(cells - 1, Math.floor(elapsed * cells))
150 const runs: MeterRun[] = []
151
152 for (let i = 0; i < cells; i++) {
153 const kind = i === marker ? 'marker' : i < filled ? 'filled' : 'empty'
154 const text = kind === 'marker' ? '┃' : kind === 'filled' ? '█' : '░'
155 const last = runs[runs.length - 1]
156 if (last?.kind === kind) {
157 last.text += text
158 } else {
159 runs.push({ kind, text })
160 }
161 }
162
163 return runs
164}
165
166/** The pace colours as the drawn meter paints them: an image takes no theme, so these suit light and dark alike. */
167const METER_HEX: Record<string, string> = { success: '#43a047', warning: '#d99a0b', error: '#e5534b' }
168
169/** The drawn meter's size, in CSS pixels: the marker stands 3 pixels proud of the bar at each end. */
170export const METER_SIZE = { width: 104, height: 16, bar: 10 }
171
172/**
173 * The meter drawn as SVG, for the surfaces that draw it: a bar filled to the
174 * share used, and a thin line, a little taller than the bar, at the share of
175 * the window gone by. The line is light with a dark edge, to show on either theme.
176 */
177export const meterSvg = (percent: number, elapsed: number | undefined, color: string): string => {
178 const { width, height, bar } = METER_SIZE
179 const top = (height - bar) / 2
180 const fill = (Math.min(100, Math.max(0, percent)) / 100) * width
181 const line =
182 elapsed === undefined
183 ? ''
184 : `<rect x="${Math.min(width - 2, Math.max(0, elapsed * width - 1)).toFixed(1)}" y="0.5" width="2" height="${height - 1}" fill="#f2f2f2" stroke="#1b1b1b" stroke-width="0.75"/>`
185
186 return (
187 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
188 `<rect x="0" y="${top}" width="${width}" height="${bar}" rx="2" fill="#8b8b8b" fill-opacity="0.3"/>` +
189 `<rect x="0" y="${top}" width="${fill.toFixed(1)}" height="${bar}" rx="2" fill="${METER_HEX[color] ?? '#8b8b8b'}"/>` +
190 line +
191 `</svg>`
192 )
193}
194hooks/palette.ts 75 lines1import { priceKey } from './pricing'
2
3// One hue per family, mid-tones that read on light and dark themes; older versions a shade muted.
4const SHADES: Record<string, string> = {
5 'fable-5-1': '#a274e8',
6 'fable-5': '#8c68c2',
7 'opus-5-5': '#e2774c',
8 'opus-5': '#cc7552',
9 'opus-4-8': '#b9785e',
10 'opus-4-7': '#b9785e',
11 'opus-4-6': '#b9785e',
12 'sonnet-5-5': '#3f9ee6',
13 'sonnet-5': '#4c8fc8',
14 'sonnet-4-6': '#5d86ab',
15 'haiku-5-5': '#22b58a',
16 'haiku-4-5': '#4c9c84',
17}
18
19const FAMILIES: Record<string, string> = {
20 fable: '#9a70d8',
21 opus: '#c97a5a',
22 sonnet: '#4f8fc4',
23 haiku: '#3fa386',
24}
25
26// Hues for families the mod has never heard of, picked by the family's name so each keeps its colour.
27const SPARE = ['#d4a72c', '#d0609a', '#5fb0b7', '#8fa63a', '#b07fd0']
28
29/** The model's own colour: its version's shade, else its family's, else a spare hue for a new family. */
30export const modelColor = (id: string): string => {
31 const key = priceKey(id)
32 const family = /^([a-z]+)-\d/.exec(key)?.[1]
33
34 if (SHADES[key] ?? (family && FAMILIES[family])) {
35 return SHADES[key] ?? FAMILIES[family!]!
36 }
37 if (!family) {
38 return 'subtle'
39 }
40
41 const hash = [...family].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7)
42
43 return SPARE[hash % SPARE.length]!
44}
45
46/**
47 * Splits `width` cells between models by their share of requests: every model
48 * that answered gets at least one, and the cells always add up to `width`.
49 */
50export const shareBar = (list: { model: string; requests: number }[], width: number): { model: string; cells: number }[] => {
51 const answered = list.filter(m => m.requests > 0)
52 const total = answered.reduce((sum, m) => sum + m.requests, 0)
53
54 if (total === 0 || answered.length > width) {
55 return []
56 }
57
58 const exact = answered.map(m => (m.requests / total) * width)
59 const cells = exact.map(x => Math.max(1, Math.floor(x)))
60 let spare = width - cells.reduce((sum, c) => sum + c, 0)
61
62 // Hand out what's left by largest remainder, or take back from the widest.
63 const byRemainder = exact.map((x, i) => ({ i, r: x - Math.floor(x) })).sort((a, b) => b.r - a.r)
64 for (let k = 0; spare > 0; k = (k + 1) % byRemainder.length, spare--) {
65 cells[byRemainder[k]!.i]! += 1
66 }
67 while (spare < 0) {
68 const widest = cells.indexOf(Math.max(...cells))
69 cells[widest]! -= 1
70 spare++
71 }
72
73 return answered.map((m, i) => ({ model: m.model, cells: cells[i]! }))
74}
75hooks/pricing.ts 189 lines1import type { ModelUsage } from 'claude-code'
2
3import type { Price, PriceState, Rates, RequestUsage } from '../types'
4
5const haiku55: Price = {
6 input: 0.1,
7 cacheWrite: 0.125,
8 cacheWrite1h: 0.2,
9 cacheRead: 0.01,
10 output: 0.5,
11 long: { input: 0.5, cacheWrite: 0.625, cacheWrite1h: 1, cacheRead: 0.05, output: 2.5 },
12 longAbove: 100_000,
13}
14
15/** The page's table as read on 2026-10-16: used until a fetch succeeds, and whenever none can. */
16export const BUILT_IN: PriceState = {
17 source: 'built-in',
18 fetchedAt: null,
19 models: {
20 'fable-5-1': { input: 10, cacheWrite: 12.5, cacheWrite1h: 20, cacheRead: 0.25, output: 50 },
21 'fable-5': { input: 10, cacheWrite: 12.5, cacheWrite1h: 20, cacheRead: 1, output: 50 },
22 'opus-5-5': { input: 4, cacheWrite: 5, cacheWrite1h: 8, cacheRead: 0.2, output: 20 },
23 'opus-5': { input: 5, cacheWrite: 6.25, cacheWrite1h: 10, cacheRead: 0.5, output: 25 },
24 'opus-4-8': { input: 5, cacheWrite: 6.25, cacheWrite1h: 10, cacheRead: 0.5, output: 25 },
25 'opus-4-7': { input: 5, cacheWrite: 6.25, cacheWrite1h: 10, cacheRead: 0.5, output: 25 },
26 'opus-4-6': { input: 5, cacheWrite: 6.25, cacheWrite1h: 10, cacheRead: 0.5, output: 25 },
27 'sonnet-5-5': { input: 2, cacheWrite: 2.5, cacheWrite1h: 4, cacheRead: 0.1, output: 10 },
28 'sonnet-5': { input: 2, cacheWrite: 2.5, cacheWrite1h: 4, cacheRead: 0.2, output: 10 },
29 'sonnet-4-6': { input: 3, cacheWrite: 3.75, cacheWrite1h: 6, cacheRead: 0.3, output: 15 },
30 'haiku-5-5': haiku55,
31 'haiku-4-5': { input: 1, cacheWrite: 1.25, cacheWrite1h: 2, cacheRead: 0.1, output: 5 },
32 },
33 candidates: ['fable-5-1', 'opus-5-5', 'sonnet-5-5', 'haiku-5-5'],
34}
35
36/** `claude-opus-5-5[1m]` and `claude-haiku-4-5-20251001` → `opus-5-5`, `haiku-4-5`. */
37export const priceKey = (id: string): string =>
38 id
39 .toLowerCase()
40 .replace(/\[1m\]$/, '')
41 .replace(/^claude-/, '')
42 .replace(/-\d{8}$/, '')
43
44/**
45 * One request's token counts, as the mod keeps them for pricing later. The
46 * engine reports cache writes as one number: the main loop writes for an hour,
47 * a subagent for five minutes, as their session logs show.
48 */
49export const toRequest = (model: string, usage: ModelUsage, at: number, isMainLoop: boolean): RequestUsage => ({
50 model,
51 input: usage.input_tokens,
52 output: usage.output_tokens,
53 cacheRead: usage.cache_read_input_tokens,
54 cacheWrite: isMainLoop ? 0 : usage.cache_creation_input_tokens,
55 cacheWrite1h: isMainLoop ? usage.cache_creation_input_tokens : 0,
56 at,
57})
58
59/** What one request's tokens cost on `model` at the table's rates, or null for a model it has no price for. */
60export const priceRequest = (models: Record<string, Price>, model: string, request: RequestUsage): number | null => {
61 const price = models[priceKey(model)]
62
63 if (!price) {
64 return null
65 }
66
67 const prompt = request.input + request.cacheRead + request.cacheWrite + (request.cacheWrite1h ?? 0)
68 const rates: Rates = price.long && price.longAbove !== undefined && prompt > price.longAbove ? price.long : price
69
70 return (
71 (request.input * rates.input +
72 request.cacheWrite * rates.cacheWrite +
73 (request.cacheWrite1h ?? 0) * (rates.cacheWrite1h ?? rates.input * 2) +
74 request.cacheRead * rates.cacheRead +
75 request.output * rates.output) /
76 1_000_000
77 )
78}
79
80const money = (cell: string): number | null => {
81 const match = /\$\s*([\d,]*\.?\d+)/.exec(cell)
82 const value = match ? Number(match[1]!.replace(/,/g, '')) : NaN
83
84 return Number.isFinite(value) ? value : null
85}
86
87const cells = (line: string): string[] =>
88 line
89 .trim()
90 .replace(/^\||\|$/g, '')
91 .split('|')
92 .map(c => c.trim())
93
94const versionOf = (key: string): number[] => key.split('-').slice(1).map(Number)
95
96const isNewer = (a: string, b: string): boolean => {
97 const [x, y] = [versionOf(a), versionOf(b)]
98 for (let i = 0; i < Math.max(x.length, y.length); i++) {
99 if ((x[i] ?? 0) !== (y[i] ?? 0)) {
100 return (x[i] ?? 0) > (y[i] ?? 0)
101 }
102 }
103
104 return false
105}
106
107/**
108 * Reads the "Model pricing" table off the pricing page. Columns are found by
109 * their headings, so a reordered or widened table still reads. The comparison
110 * models are the newest generally available model of each family, priciest
111 * first. Null when the page holds no table the mod can read.
112 */
113export const parsePricingPage = (page: string): Pick<PriceState, 'models' | 'candidates'> | null => {
114 const lines = page.split('\n')
115 const section = lines.findIndex(l => /^#+\s*Model pricing\s*$/i.test(l.trim()))
116 const start = lines.findIndex((l, i) => i > section && l.trim().startsWith('|'))
117
118 if (section < 0 || start < 0) {
119 return null
120 }
121
122 const rows: string[][] = []
123 for (let i = start; i < lines.length && lines[i]!.trim().startsWith('|'); i++) {
124 rows.push(cells(lines[i]!))
125 }
126
127 const [head = [], , ...body] = rows
128 const col = (pattern: RegExp) => head.findIndex(h => pattern.test(h))
129 const at = {
130 model: col(/model/i),
131 input: col(/base input/i),
132 cacheWrite: col(/5m cache write/i),
133 cacheWrite1h: col(/1h cache write/i),
134 cacheRead: col(/cache hit/i),
135 output: col(/output/i),
136 }
137
138 if (Object.values(at).some(i => i < 0)) {
139 return null
140 }
141
142 const models: Record<string, Price> = {}
143 const available: string[] = []
144
145 for (const row of body) {
146 const raw = row[at.model] ?? ''
147 const name = /^Claude\s+([A-Za-z]+)\s+(\d+(?:\.\d+)*)/.exec(raw.replace(/\[[^\]]*\]\([^)]*\)/g, ''))
148 const input = money(row[at.input] ?? '')
149 const cacheWrite = money(row[at.cacheWrite] ?? '')
150 const cacheWrite1h = money(row[at.cacheWrite1h] ?? '')
151 const cacheRead = money(row[at.cacheRead] ?? '')
152 const output = money(row[at.output] ?? '')
153
154 if (!name || input === null || cacheWrite === null || cacheWrite1h === null || cacheRead === null || output === null) {
155 continue
156 }
157
158 const key = `${name[1]!.toLowerCase()}-${name[2]!.replace(/\./g, '-')}`
159 const rates: Rates = { input, cacheWrite, cacheWrite1h, cacheRead, output }
160 const tier = /prompts\s+(up to|over)\s+([\d,]+)\s+tokens/i.exec(raw)
161 const held = models[key]
162
163 if (tier?.[1]?.toLowerCase() === 'over') {
164 if (held) {
165 models[key] = { ...held, long: rates, longAbove: Number(tier[2]!.replace(/,/g, '')) }
166 }
167 } else {
168 models[key] = { ...rates, ...(held?.long ? { long: held.long, longAbove: held.longAbove } : {}) }
169 }
170
171 if (!/limited availability|retired/i.test(raw) && !available.includes(key)) {
172 available.push(key)
173 }
174 }
175
176 const newest = new Map<string, string>()
177 for (const key of available) {
178 const family = key.split('-')[0]!
179 const current = newest.get(family)
180 if (current === undefined || isNewer(key, current)) {
181 newest.set(family, key)
182 }
183 }
184
185 const candidates = [...newest.values()].sort((a, b) => (models[b]?.output ?? 0) - (models[a]?.output ?? 0))
186
187 return Object.keys(models).length >= 3 && candidates.length > 0 ? { models, candidates } : null
188}
189hooks/sessions.ts 294 lines1import type { FsEntry } from 'claude-code'
2
3import type { Price, RequestUsage } from '../types'
4import { priceRequest } from './pricing'
5
6/** How much of a log one read takes; the rest is read on the next pass. */
7const CHUNK_BYTES = 4_000_000
8
9/**
10 * Where a log has been read to. `isSkipping` while the reader is inside a row longer than one read:
11 * such rows hold pasted or tool content, never a response's usage, so they are passed over whole.
12 */
13type Log = { offset: number; size: number; isSkipping: boolean }
14
15/** One response from the logs, and the session it belongs to. */
16type Response = { request: RequestUsage; owner: string }
17
18/**
19 * How the scanner reaches the logs: list a folder, check one exists, and read a
20 * log from a byte offset on (the rest of the file, or null when it cannot be read).
21 */
22export type LogAccess = {
23 list: (path: string) => Promise<FsEntry[]>
24 exists: (path: string) => Promise<boolean>
25 readFrom: (path: string, offset: number) => Promise<string | null>
26}
27
28/** What a session's log says about it: its title as last set, and the folder it runs in. */
29export type SessionInfo = { title?: string; cwd?: string }
30
31/** A session's work in a window, at the table's rates, with what its log says about it. */
32export type SessionWork = SessionInfo & { owner: string; cost: number; isOwn: boolean }
33
34/**
35 * What a log line says about its session: a title the session was given
36 * (`custom-title` rows), and the folder it runs in (`cwd`, on most rows).
37 */
38export const parseLogMeta = (line: string): SessionInfo | null => {
39 if (!line.includes('"customTitle"') && !line.includes('"cwd"')) {
40 return null
41 }
42
43 try {
44 const row = JSON.parse(line) as { type?: string; customTitle?: unknown; cwd?: unknown }
45 const title = row.type === 'custom-title' && typeof row.customTitle === 'string' ? row.customTitle : undefined
46 const cwd = typeof row.cwd === 'string' ? row.cwd : undefined
47
48 return title || cwd ? { title, cwd } : null
49 } catch {
50 return null
51 }
52}
53
54/**
55 * One line of a session log, when it records a model's response: the logs
56 * write a response once per content block, so `id` tells repeats apart.
57 */
58export const parseLogLine = (line: string): { id: string; request: RequestUsage } | null => {
59 if (!line.includes('"usage"')) {
60 return null
61 }
62
63 try {
64 const row = JSON.parse(line) as {
65 type?: string
66 timestamp?: string
67 message?: {
68 id?: string
69 model?: string
70 usage?: {
71 input_tokens?: number
72 output_tokens?: number
73 cache_read_input_tokens?: number
74 cache_creation_input_tokens?: number
75 cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number }
76 }
77 }
78 }
79 const message = row.message
80 const usage = message?.usage
81 const at = Date.parse(row.timestamp ?? '')
82
83 if (row.type !== 'assistant' || !message?.id || !message.model || !usage || !Number.isFinite(at)) {
84 return null
85 }
86
87 const split = usage.cache_creation
88 const written = usage.cache_creation_input_tokens ?? 0
89
90 return {
91 id: message.id,
92 request: {
93 model: message.model,
94 input: usage.input_tokens ?? 0,
95 output: usage.output_tokens ?? 0,
96 cacheRead: usage.cache_read_input_tokens ?? 0,
97 cacheWrite: split ? (split.ephemeral_5m_input_tokens ?? 0) : written,
98 cacheWrite1h: split ? (split.ephemeral_1h_input_tokens ?? 0) : 0,
99 at,
100 },
101 }
102 } catch {
103 return null
104 }
105}
106
107/**
108 * Reads every Claude Code session's log on this machine, this session's own
109 * included, a piece at a time: each log is read from where the last pass
110 * stopped, so a long session is read once. A response is counted once however
111 * many logs hold it (a conversation that moved to a new session leaves copies
112 * behind), and belongs to this session (`ownId`) whenever one of its copies does.
113 */
114export const makeScanner = (access: LogAccess, projectsDir: string, ownId: string) => {
115 const logs = new Map<string, Log>()
116 const responses = new Map<string, Response>()
117 const infos = new Map<string, SessionInfo>()
118 const encoder = new TextEncoder()
119
120 /** The text from `offset` on, up to a chunk; null when the file cannot be read. */
121 const readFrom = async (path: string, offset: number): Promise<string | null> => {
122 try {
123 const text = await access.readFrom(path, offset)
124
125 return text === null ? null : text.slice(0, CHUNK_BYTES)
126 } catch {
127 return null
128 }
129 }
130
131 /** Reads what the log added since the last pass; false when it could not be read. */
132 const readLog = async (path: string, size: number, owner: string): Promise<boolean> => {
133 const log = logs.get(path) ?? { offset: 0, size: 0, isSkipping: false }
134 logs.set(path, log)
135
136 if (size < log.offset) {
137 // Rewritten from the start: read it afresh.
138 log.offset = 0
139 log.isSkipping = false
140 }
141
142 while (log.offset < size) {
143 const text = await readFrom(path, log.offset)
144
145 if (text === null) {
146 return false
147 }
148
149 const read = encoder.encode(text).length
150 if (read === 0) {
151 break
152 }
153 // The read stopped short of the end the listing saw: the log goes on past it.
154 const goesOn = log.offset + read < size
155
156 // Inside an over-long row: pass over the rest of it, up to its newline.
157 let from = 0
158 if (log.isSkipping) {
159 const stop = text.indexOf('\n')
160 if (stop < 0) {
161 // All of it is the over-long row: none of it needs reading again.
162 log.offset += read
163 if (!goesOn) {
164 break
165 }
166 continue
167 }
168 from = stop + 1
169 log.isSkipping = false
170 }
171
172 const end = text.lastIndexOf('\n')
173 if (end < from) {
174 // No whole row after `from`. One that starts later in this read is read again from its start.
175 const head = encoder.encode(text.slice(0, from)).length
176 if (head > 0) {
177 log.offset += head
178 if (goesOn) {
179 continue
180 }
181 break
182 }
183 // One still being written waits for the next pass; one that fills a whole read is longer
184 // than a read can take, and is passed over.
185 if (!goesOn) {
186 break
187 }
188 log.offset += read
189 log.isSkipping = true
190 continue
191 }
192
193 for (const line of text.slice(from, end).split('\n')) {
194 // A subagent's log speaks for its session only through its responses; a folder is read once.
195 const wantsMeta = !path.includes('/subagents/') && (line.includes('"custom-title"') || !infos.get(owner)?.cwd)
196 const meta = wantsMeta ? parseLogMeta(line) : null
197 if (meta) {
198 const held = infos.get(owner) ?? {}
199 infos.set(owner, { title: meta.title ?? held.title, cwd: held.cwd ?? meta.cwd })
200 }
201
202 const parsed = parseLogLine(line)
203 const held = parsed && responses.get(parsed.id)
204 if (parsed && (!held || (owner === ownId && held.owner !== ownId))) {
205 responses.set(parsed.id, { request: parsed.request, owner })
206 }
207 }
208 // Only whole rows are taken; a row still being written waits for the next pass.
209 log.offset += encoder.encode(text.slice(0, end + 1)).length
210 }
211 log.size = size
212
213 return true
214 }
215
216 /**
217 * Brings every log changed since `after` up to date. False when the logs
218 * could not all be read: then nobody's share can be told.
219 */
220 const refresh = async (after: number): Promise<boolean> => {
221 try {
222 let isWhole = true
223 for (const project of await access.list(projectsDir)) {
224 if (project.kind !== 'dir') {
225 continue
226 }
227
228 const dir = `${projectsDir}/${project.name}`
229 for (const entry of await access.list(dir)) {
230 if (entry.kind === 'file' && entry.name.endsWith('.jsonl') && entry.mtimeMs >= after) {
231 isWhole = (await readLog(`${dir}/${entry.name}`, entry.size, entry.name.replace(/\.jsonl$/, ''))) && isWhole
232 }
233 // A folder's own time does not move when a subagent log in it grows: check each log's.
234 if (entry.kind === 'dir') {
235 const subagents = `${dir}/${entry.name}/subagents`
236 if (await access.exists(subagents)) {
237 for (const log of await access.list(subagents)) {
238 if (log.kind === 'file' && log.name.endsWith('.jsonl') && log.mtimeMs >= after) {
239 isWhole = (await readLog(`${subagents}/${log.name}`, log.size, entry.name)) && isWhole
240 }
241 }
242 }
243 }
244 }
245 }
246
247 return isWhole
248 } catch {
249 return false
250 }
251 }
252
253 /** What this session's responses since `from` cost, and every session's, at the table's rates. */
254 const workSince = (models: Record<string, Price>, from: number): { own: number; all: number } => {
255 let own = 0
256 let all = 0
257 for (const { request, owner } of responses.values()) {
258 if (request.at >= from) {
259 const cost = priceRequest(models, request.model, request) ?? 0
260 all += cost
261 own += owner === ownId ? cost : 0
262 }
263 }
264
265 return { own, all }
266 }
267
268 /** Each session's work since `from`, priciest first: this session's, and every other one on this machine. */
269 const workBySession = (models: Record<string, Price>, from: number): SessionWork[] => {
270 const costs = new Map<string, number>()
271 for (const { request, owner } of responses.values()) {
272 if (request.at >= from) {
273 costs.set(owner, (costs.get(owner) ?? 0) + (priceRequest(models, request.model, request) ?? 0))
274 }
275 }
276
277 return [...costs]
278 .filter(([, cost]) => cost > 0)
279 .map(([owner, cost]) => ({ owner, cost, isOwn: owner === ownId, ...infos.get(owner) }))
280 .sort((a, b) => b.cost - a.cost)
281 }
282
283 /** Drops responses older than any window can ask about. */
284 const forget = (before: number) => {
285 for (const [id, { request }] of responses) {
286 if (request.at < before) {
287 responses.delete(id)
288 }
289 }
290 }
291
292 return { refresh, workSince, workBySession, forget }
293}
294types/index.d.ts 77 lines1/** One model that answered this session, and how many requests it served. */
2export type ModelTally = { model: string; requests: number }
3
4/**
5 * One request's token counts and the model that answered it, kept so it can be
6 * priced at any table's rates: cache writes split by lifetime (the main loop
7 * writes for an hour, subagents for five minutes), `at` when it answered.
8 */
9export type RequestUsage = {
10 model: string
11 input: number
12 output: number
13 cacheRead: number
14 cacheWrite: number
15 cacheWrite1h: number
16 at: number
17}
18
19/** US dollars per million tokens: `cacheWrite` the 5-minute write rate, `cacheWrite1h` the 1-hour one. */
20export type Rates = { input: number; cacheWrite: number; cacheWrite1h: number; cacheRead: number; output: number }
21
22/** A model's rates, and the dearer card a prompt over `longAbove` tokens pays. */
23export type Price = Rates & { long?: Rates; longAbove?: number }
24
25/**
26 * The prices in use: read off Anthropic's pricing page (`page`, at `fetchedAt`)
27 * or the table the mod ships with (`built-in`). `candidates` are the price keys
28 * the band compares against; `failedAt` marks the last fetch that did not work.
29 */
30export type PriceState = {
31 source: 'page' | 'built-in'
32 fetchedAt: number | null
33 models: Record<string, Price>
34 candidates: string[]
35 failedAt?: number
36 /** Why that fetch did not work: an HTTP status, or the page no longer holding a readable table. */
37 failure?: string
38}
39
40/** One subscription limit window (`five_hour`, `seven_day`) as last read. */
41export type LimitTrack = {
42 kind: string
43 percentUsed: number
44 resetsAt?: string
45 /** When tracking began, in `$.clock.now()`'s milliseconds. */
46 since: number
47 /** When the last reading arrived: the account figure is as of then. */
48 readAt: number
49}
50
51/** One session's or one project's part of a window: what its work cost, and whether this session is in it. */
52export type BreakdownRow = { name: string; cost: number; isOwn: boolean }
53
54/** The details pane's lists, priciest first, each with the window's account figure to share out. */
55export type Breakdown = {
56 sessions: BreakdownRow[]
57 projects: BreakdownRow[]
58}
59
60declare module 'claude-code' {
61 interface PluginState {
62 'limit-lens': {
63 usd: number | null
64 models: ModelTally[]
65 requests: RequestUsage[]
66 prices: PriceState
67 limits: LimitTrack[]
68 /** This session's share of each window's work on this machine, by window kind, once the logs are read. */
69 shares: Record<string, number>
70 /** Whether the panel is expanded; null until the person toggles it, when the settings decide. */
71 expanded: boolean | null
72 /** Every local session's work in the 5-hour window, and every project's in the week, for the details pane. */
73 breakdown: Breakdown
74 }
75 }
76}
77