SLOPSHOPPER

limit-lens

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

panebandcommandprocessnetworktimer
v0.4.4MITupdated 2026-10-26cristi71000/limit-lens
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · limit-lens
│ ┃ Limits ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ Sessions in this 5-hour window ⏺ Read(src/auth.ts) │ ┃ Reading the session logs… ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Projects ⎿ Added 2 lines, removed 1 line │ ┃ Reading the session logs… ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Prompt cache, this session │ ┃ No requests yet ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Context window, this session ✻ Worked for 42s · done 4:20 PM │ ┃ 49% full: 97k of 200k tokens │ › /limits │ │ ◆ 5h ███░░░░░░░ 31% this session — [ Details ] ⌄ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◆ 5h ███░░░░░░░ 31% this session — [ Details ] ⌄ ⟨Claude Code's own drawing⟩
Pane · Limits
Sessions in this 5-hour window Reading the session logs… Projects Reading the session logs… Prompt cache, this session No requests yet Context window, this session 49% full: 97k of 200k tokens
README

limit-lens

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

Install

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.

What it shows

  • Limits (on a Pro or Max subscription): your account's 5-hour and weekly usage with time to reset, and this session's share of each. A ┃ 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.
  • If all on: the same work priced on the newest Opus, Sonnet, Haiku and Fable models, as a share of each limit window. On API billing, the panel shows dollar cost instead.
  • Models: which models answered this session's requests, and how many each.
  • One line by default: the panel shows each window's meter, this session's part and the forecast on a single line. The ⌄ in its top-right corner expands it to the tables above, and ⌃ collapses it again; Details (d) opens the side pane.
  • Forecast: where each window is heading at its pace so far, either limit in ~1h 20m or ≈70% at reset.
  • Details pane (/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.

Settings

In the plugin's settings (/config in a terminal session):

  • Start collapsed (on): show the panel as one line until you expand it.
  • Show forecast (on).
  • On-pace band (5): how many points either side of the time marker still count as on pace (yellow).

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.

Good to know

  • Claude Code reports your account's limit usage only with each model response, in whole percentages. The account figures can trail the app's own usage panel by a point or two, and the panel says how old they are.
  • The per-model comparison assumes your limits are drawn down in proportion to API prices. Anthropic does not publish how each model counts against them, so treat those figures as estimates.
  • Nothing in the panel makes model calls or uses your limits.

What it reads, runs and fetches

Everything stays on your machine. limit-lens sends no data anywhere: its one network request carries nothing from your machine or your sessions.

  • Claude Code's own figures: this session's token usage, model and the limit readings Claude Code reports with each response.
  • Your local session logs: the files under ~/.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.
  • Two environment variables: HOME and CLAUDE_CONFIG_DIR, read only to find the folder above. They are paths, not credentials, and are never sent anywhere.
  • One program, 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.
  • One web page, fetched: a plain GET, with no body, parameters or credentials, of Anthropic's public pricing page 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.
  • Stored on your machine: the last prices it read, in the plugin's own store, so new sessions start priced.
  • The /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.

Licence

MIT, see LICENSE.

Development

claude plugin validate .
claude plugin test .
Source 7 files
hooks/register.tsx 813 lines
1import { 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}
813
hooks/cache.ts 46 lines
1import 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}
46
hooks/limits.ts 194 lines
1import 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}
194
hooks/palette.ts 75 lines
1import { 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}
75
hooks/pricing.ts 189 lines
1import 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}
189
hooks/sessions.ts 294 lines
1import 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}
294
types/index.d.ts 77 lines
1/** 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