SLOPSHOPPER

usage-mod

A live usage band above the prompt for this Claude Code session: cost, tokens, cache, context, rate limits, turns, tools. Desktop and terminal.

newbandspinnerguardcommandtoast
v0.6.1MITupdated 2026-10-07jasmo13/usage-mod/plugins/usage-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-mod
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ usage-mod │ ● usage-mod: usage-mod: usage service gave no limits (HTTP 0); asking a│ Usage band hidden; /usage-mod shows it │ ⏺ Read(src/auth.ts) │ again. │ ⎿ Read 6 lines ╰────────────────────────────────────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /usage-mod ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Usage mod

A Claude Code plugin that shows a live usage band above the prompt. It works in the Claude desktop app's Code tab and in the terminal.

What it shows

The top of the band has three meters, each an orange bar with its percentage. They're named and worded as in the app's usage panel:

MeterShowsExample
Context windowHow full this chat's context is, and how much room is left before it's compacted75k until auto-compact · 64%
Session limitHow much of your plan's 5-hour limit you've used, and the time left until it resetsResets in 2 hr 37 min · 13%
Weekly · all modelsHow much of your weekly limit you've used, and the day and time it resets, in your local timeResets Wed 4:00 AM · 12%

The model next to the band's name, and the Context window's size and auto-compact point, follow a switch with /model or the model picker straight away, before the new model's first reply. After any slash command (/autocompact, /config, /fast, /compact, /clear and the rest) the band reads the model, cost, context, window and limits again as soon as the command finishes, and a change in a settings file shows straight away too. Other open chats, desktop or terminal, follow at once too: a slash command, a setting changed in /config or Copy JSON in one chat tells the others to read everything again. Cost, context, the model and the window are also read every second, so a change made any other way shows within a second.

Reset times are rounded to the nearest minute, and the time left for the session limit counts a partial minute as a whole one, as the app does. The meters, and the details under them, share the band's full width, out to its right edge. When the band is too narrow for the full wording beside a meter's name, the wording moves to a line under the bar, and on very narrow bands it shortens ("2 hr 37 min", "Weekly").

Choose Show details to see more:

  • Context window: what's taking up the context. The largest categories (messages, tools, MCP tools, skills, system prompt and so on) get their own rows, the rest are added together as Other, and the compaction buffer and free space follow. Each category has its own color on the desktop and in the terminal.
  • Tokens: input, output, cache writes and cache reads for the whole chat, and how much was served from cache.
  • Activity: turns, tool calls, the most-used tools, and tokens for the current or last turn.

The details are hidden until you choose Show details. When the band is short, as in a small fullscreen terminal, the sections list fewer rows, and the band scrolls if it still doesn't fit.

The band appears in every chat as soon as it opens. It reads the chat's history, so a chat you come back to shows its full totals before you send anything. If a chat that has already cost something has no history to read, the details end with a note saying the counts start from when the plugin loaded. A new chat has nothing earlier to count, so it shows no note.

If other plugins also draw bands above the prompt, such as always-read-claudemd, theirs are shown too, above this one, with a line between them. With no other band, there's no line. This band gives up the rows theirs take, so the prompt stays where it was.

Matching the app's panel

The band's figures are meant to match the app's usage panel exactly:

  • Context breakdown: counted the way the panel counts it, not estimated. Counting exactly asks Anthropic's token-count service, so the band recounts when the chat opens, after each turn or compaction, and when you open the details; otherwise at most every 30 seconds, and only while the details are open.
  • Percentages: rounded to the nearest whole number.
  • Token counts: one decimal place at most, and none when it's a zero: "134.5k", "15.8k", "33k", "62.4M".
  • Costs: always to the cent: "$0.26", "$123.40".

Where the limits come from

When you're signed in with a Claude account, the band asks Anthropic's usage service for your current limits, the same figures the app shows. It does this every 15 seconds, and again about 5 seconds after each reply. Open chats share one answer, so having several open doesn't multiply the requests, and every open chat shows a new answer the moment any of them gets it: a reply in one chat moves the limits in all of them.

The limits also arrive with each of Claude's replies, and the band shows whichever reading is newest. If the usage service's last answer is more than a minute old and a reply has brought a newer reading since, the band shows the reply's. While the service isn't answering, the band asks less often: after 30 seconds, then a minute, and so on up to every 5 minutes.

A new chat shows the last reading it saw until fresh numbers arrive. Without a Claude account login (for example, signed in with an API key or another token), there's no usage service to ask, and the limit meters show only what replies report, which may be nothing.

Using it

  • ⋯ (or m) opens the band's menu. In the terminal the button reads ...:
  • Show details / Hide details (d)
  • Copy JSON (c) copies everything the band knows about this chat. It checks everything again first, then copies once all of it is current: the model, cost and context, your limits straight from Anthropic's usage service, an exact count of the context, and the chat's history if that's still loading. So it can take a moment. It opens with band: what the band shows, under the band's own names (contextWindow, sessionLimit, weeklyLimit, compactionBuffer, freeSpace and so on), as whole numbers rather than "60.8k". Its percentages are the band's, including the Context window's. The raw figures follow, with the limits under the same names, and the turns include the prompt that started each one.
  • Show status line / Hide status line (s), in the terminal only: a line of its own under the hint line below the prompt with what the band shows, so it can stand in for the band when the band is hidden. For example: $0.26 · 61.1k tokens · Context window: 20% (205.9k until auto-compact) · Session limit: 16% (resets in 1 hr 56 min) · Weekly limit: 12% (resets Wed 4:00 AM). The cost is in orange, the dots and the notes in parentheses in gray. In a narrower terminal it condenses to fit, a step at a time: shorter notes (205.9k left, 1 hr 56 min, Wed 4:00 AM), then shorter names (Context, Session, Weekly), then no notes, then no token count. It's off until you choose it. The desktop app doesn't draw that line, so its menu leaves this out.
  • Hide band (h)
  • /usage-mod shows or hides the band, saying which in a notification.

Every choice here is kept for every chat, new or old, until you change it again. Chats already open, whether desktop chats or other terminals, follow the change at once. A fresh install shows the band with its details hidden and, in the terminal, no status line.

Installing

This repository is its own plugin marketplace, so two commands install it, whether or not you've added a marketplace before. In a terminal:

claude plugin marketplace add jasmo13/usage-mod
claude plugin install usage-mod@usage-mod

Then open a new chat, or restart the desktop app; the band shows above the prompt. You need to be able to read this repository on GitHub: while it's private, that means being signed in to GitHub as someone with access, the same as for git clone.

To try it from a local copy in the terminal without installing:

claude --plugin-dir path/to/usage-mod/plugins/usage-mod

The plugin uses Claude Code's function-hook plugin API, and it was built and tested on Claude Code 2.1.288.

Updating

Releases come from main. After a new version is merged, update with:

claude plugin marketplace update usage-mod
claude plugin update usage-mod@usage-mod

Then reopen your chats or restart the app.

Developing

The plugin lives in plugins/usage-mod/, so only its own files are installed. The repository root holds the marketplace (.claude-plugin/marketplace.json), this README and the license.

PathContents
hooks/register.tsxHooks: collecting usage, reading history, checking limits, drawing the band
hooks/collect.tsPure functions that turn events and transcripts into usage totals
hooks/views.tsxThe band's layout for the desktop app and the terminal
types/index.d.tsTypes for the values the plugin keeps between reloads
tests/register.test.tsTests
.claude-plugin/plugin.jsonThe plugin's manifest and version

Before opening a pull request, run these from plugins/usage-mod/:

claude plugin test .
npx -p typescript tsc -p .
claude plugin validate .

In the same pull request:

  • Update this README whenever a change adds a feature or changes what the band shows or how it behaves.
  • To release, bump version in plugins/usage-mod/.claude-plugin/plugin.json.

License

MIT

Source 4 files
hooks/register.tsx 929 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionUsage } from 'claude-code'
3
4import type { Backfill, Breakdown, Measure, RateLimitRow, RunningTool } from '../types'
5import {
6  addCompaction,
7  addRequest,
8  addToolCall,
9  emptyModel,
10  finishTurn,
11  liveLimits,
12  parseUsage,
13  projectSlug,
14  shortModel,
15  startTurn,
16  sumTokens,
17  tokensOf,
18  transcriptFolder,
19} from './collect'
20import { band, bandFigures, copiedMeasure, rowsOf, statusLine } from './views'
21
22/** Bumped when the transcript parser changes, so history is read again. */
23const BACKFILL_VERSION = 2
24
25const usageA = atom({ plugin: 'usage-mod', key: 'usage' } as const, emptyModel())
26const measureA = atom({ plugin: 'usage-mod', key: 'measure' } as const, null)
27const breakdownA = atom({ plugin: 'usage-mod', key: 'breakdown' } as const, null)
28const expandedA = atom({ plugin: 'usage-mod', key: 'isExpanded' } as const, false)
29const hiddenA = atom({ plugin: 'usage-mod', key: 'isHidden' } as const, false)
30const menuA = atom({ plugin: 'usage-mod', key: 'isMenuOpen' } as const, false)
31const runningA = atom({ plugin: 'usage-mod', key: 'running' } as const, [])
32const backfillA = atom({ plugin: 'usage-mod', key: 'backfill' } as const, null)
33const modelA = atom({ plugin: 'usage-mod', key: 'model' } as const, '')
34const statusA = atom({ plugin: 'usage-mod', key: 'isStatusShown' } as const, false)
35
36type $ = EngineInterface
37
38const toMeasure = (
39  at: number,
40  u: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'> & { startedAt?: number },
41  previous: Measure | null,
42): Measure => ({
43  at,
44  startedAt: u.startedAt ?? previous?.startedAt,
45  costUsd: u.cost?.usd ?? previous?.costUsd,
46  contextTokens: u.context.tokens,
47  contextWindow: u.context.window,
48  contextPercent: u.context.percent,
49  // The usage service's reading wins while it is fresh; otherwise a reply's, and
50  // before this chat's first reply the last one kept (this chat's, or another's).
51  rateLimits:
52    isReplyNewest(at) && u.rateLimits.length > 0
53      ? u.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt }))
54      : liveLimits(previous?.rateLimits ?? [], at),
55})
56
57/** The store key holding whether the terminal's status line carries the usage too: chosen from the band's menu, kept for every chat. */
58const STATUS_KEY = 'statusLine'
59/** The store keys holding whether the details are shown and the band hidden: chosen from the menu or /usage-mod, kept for every chat. */
60const DETAILS_KEY = 'details'
61const HIDDEN_KEY = 'hidden'
62/** Said when the band is hidden, from the menu or /usage-mod. */
63const HIDDEN_TOAST = 'Usage band hidden; /usage-mod shows it again.'
64/** The store key holding the last rate-limit reading, which belongs to the account rather than one chat. */
65const LIMITS_KEY = 'rateLimits'
66let savedLimits = ''
67// Whether this load has looked for a kept reading yet.
68let hasRecalled = false
69
70/** Keeps the latest reading for the next chat to start from; written only when it changes. */
71const rememberLimits = async ($: $, rows: readonly Pick<SessionUsage['rateLimits'][number], 'kind' | 'percentUsed' | 'resetsAt'>[]) => {
72  if (rows.length === 0) return
73  const value = rows.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt }))
74  const text = JSON.stringify(value)
75  if (text === savedLimits) return
76  savedLimits = text
77  await $.store.set(LIMITS_KEY, value).catch(() => undefined)
78}
79
80/** A new chat has no reading until its first reply: show the last one kept, if its window is still open. */
81const recallLimits = async ($: $) => {
82  const m = await read($, measureA)
83  if (!m || m.rateLimits.length > 0) return
84  const kept = await $.store.get(LIMITS_KEY).catch(() => undefined)
85  if (!Array.isArray(kept)) return
86  const rows = liveLimits(kept as RateLimitRow[], await $.clock.now())
87  if (rows.length > 0) await update($, measureA, was => (was && was.rateLimits.length === 0 ? { ...was, rateLimits: rows } : was))
88}
89
90/**
91 * The usage service /usage reads: the account's session and weekly limits as
92 * they stand, rather than as the last reply reported them.
93 */
94const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
95/** How often an open band asks it, and how long one chat's answer serves every other (so many open chats still ask about four times a minute). */
96const POLL_MS = 15_000
97const SHARE_MS = 14_000
98/** After a turn, ask again sooner, but never more than this often. */
99const MIN_POLL_MS = 5_000
100const SERVICE_KEY = 'serviceLimits'
101/** When it stops answering, ask less often, doubling up to this. */
102const MAX_POLL_MS = 300_000
103/** A service reading older than this gives way to a newer one from a reply. */
104const STALE_MS = 60_000
105// Whether the service has answered this load, and when the reading shown was taken.
106let fromService = false
107let serviceAt = 0
108// When this load last asked (or took another chat's answer), and how long until it asks again.
109let polledAt = 0
110let pollGap = POLL_MS
111// Replies' last reading, and when it changed: 0 while it is the one found at load, whose age is unknown.
112let replyText: string | undefined
113let replyAt = 0
114
115/** Notes a reading from replies, so a newer one can take over from a stale service reading. */
116const noteReply = (rows: readonly RateLimitRow[], now: number) => {
117  if (rows.length === 0) return
118  const text = JSON.stringify(rows.map(l => [l.kind, l.percentUsed, l.resetsAt]))
119  if (replyText !== undefined && text !== replyText) replyAt = now
120  replyText = text
121}
122
123/** Whether replies' reading is the one to show: no service answer, or a stale one with a newer reply since. */
124const isReplyNewest = (now: number) => !fromService || (now - serviceAt > STALE_MS && replyAt > serviceAt)
125
126/**
127 * Brings the session and weekly limit meters up to the usage service's figures: from
128 * another chat's answer under a minute old, else by asking it with the
129 * session's own login (held by the engine; none for an API key or a
130 * third-party provider, and then replies' readings stand).
131 */
132const pollLimits = async ($: $, isAfterTurn = false, isNow = false) => {
133  const now = await $.clock.now()
134  // Asked for now (a copy): straight to the service, whatever the pace.
135  if (!isNow && now - polledAt < (isAfterTurn && pollGap === POLL_MS ? MIN_POLL_MS : pollGap)) return
136  polledAt = now
137  let rows: RateLimitRow[] | undefined
138  let takenAt = now
139  if (!isAfterTurn && !isNow) {
140    const shared = await sharedAnswer($)
141    if (shared && now - shared.at < SHARE_MS) {
142      rows = shared.rateLimits
143      takenAt = shared.at
144    }
145  }
146  if (!rows) {
147    const auth = await $.session.authorize().catch(() => null)
148    if (!auth) return
149    const failed = (why: string) => {
150      pollGap = Math.min(pollGap * 2, MAX_POLL_MS)
151      $.ui.log(`usage-mod: usage service ${why}; asking again in ${pollGap / 1000}s`, { to: 'debug' })
152    }
153    try {
154      const res = await $.http.fetch(USAGE_URL, { auth: auth.handle, headers: { 'anthropic-beta': 'oauth-2025-04-20' } })
155      rows = res.ok ? parseUsage(res.text) : []
156      if (rows.length === 0) return failed(`gave no limits (HTTP ${res.status})`)
157    } catch (error) {
158      return failed(`unavailable (${String(error).slice(0, 120)})`)
159    }
160    pollGap = POLL_MS
161    await $.store.set(SERVICE_KEY, { at: now, rateLimits: rows }).catch(() => undefined)
162  }
163  await showService($, rows, takenAt, now)
164}
165
166/** The usage service's last answer, kept by whichever chat asked: when it was taken and the limits it gave. */
167const sharedAnswer = async ($: $) => {
168  const kept = (await $.store.get(SERVICE_KEY).catch(() => undefined)) as { at?: number; rateLimits?: RateLimitRow[] } | undefined
169  return kept?.at !== undefined && Array.isArray(kept.rateLimits) ? { at: kept.at, rateLimits: kept.rateLimits } : undefined
170}
171
172/** Shows the usage service's figures, taken at `takenAt` by this chat or another. */
173const showService = async ($: $, rows: readonly RateLimitRow[], takenAt: number, now: number) => {
174  fromService = true
175  serviceAt = takenAt
176  const live = liveLimits(rows, now)
177  await update($, measureA, was => (was ? { ...was, rateLimits: live } : was))
178  await rememberLimits($, live)
179}
180
181/**
182 * Takes another chat's answer from the usage service the moment it is kept, rather than at this
183 * chat's next ask: the limits are the account's, so a reply in any chat moves them in all.
184 */
185const adoptShared = async ($: $) => {
186  const shared = await sharedAnswer($)
187  if (!shared || (fromService && shared.at <= serviceAt)) return
188  const now = await $.clock.now()
189  if (now - shared.at > STALE_MS) return
190  polledAt = now
191  await showService($, shared.rateLimits, shared.at, now)
192}
193
194/**
195 * What one chat changes that every chat shows (a slash command such as /autocompact or /login, a setting,
196 * a Copy JSON's fresh reading), noted in the store, whose files every chat watches: the others read
197 * everything again at once. A chat passes over its own notes, and reading again never writes one.
198 */
199const NUDGE_KEY = 'changed'
200let nudgeSeen = 0
201const nudgeOthers = async ($: $) => {
202  const [at, by] = await Promise.all([$.clock.now(), $.session.id()])
203  nudgeSeen = Math.max(nudgeSeen, at)
204  await $.store.set(NUDGE_KEY, { at, by }).catch(() => undefined)
205}
206
207// Where the transcript is, once a settings-hook event has said; a guess until then.
208let transcriptPath: string | undefined
209// When the context breakdown was last counted, whether a count is running, and whether another is owed once it ends.
210let breakdownAt = 0
211let isCounting = false
212let isOwed = false
213// The count running now, and the history being read, for a copy to wait on.
214let counting: Promise<void> | undefined
215let backfilling: Promise<void> | undefined
216// Whether a copy is checking everything before it copies.
217let isCopying = false
218// The refresh timer, and when it last fired: a draw that finds it stale starts it again.
219let ticker: { cancel: () => void } | undefined
220let tickedAt = 0
221let isTicking = false
222
223const refreshMeasure = async ($: $) => {
224  const [u, now, prev] = await Promise.all([$.session.usage(), $.clock.now(), read($, measureA)])
225  noteReply(u.rateLimits, now)
226  await update($, measureA, () => toMeasure(now, u, prev))
227  if (isReplyNewest(now)) await rememberLimits($, u.rateLimits)
228  if (!hasRecalled) {
229    hasRecalled = true
230    await recallLimits($).catch(() => undefined)
231  }
232  return u
233}
234
235/**
236 * The context breakdown is counted exactly, as the app's panel and /context
237 * count it: the quick estimate gets the total right but splits it between
238 * categories loosely. An exact count asks the token-count service, so it runs
239 * when something changed (a load, a turn, a compaction, the details opening)
240 * and otherwise at most every half minute while the details are open.
241 */
242const BREAKDOWN_MS = 30_000
243
244const refreshBreakdown = async ($: $, isForced = false) => {
245  const now = await $.clock.now()
246  if (isCounting) {
247    isOwed ||= isForced
248    return
249  }
250  if (!isForced && now - breakdownAt < BREAKDOWN_MS) return
251  isCounting = true
252  breakdownAt = now
253  counting = countBreakdown($, now)
254  await counting
255}
256
257const countBreakdown = async ($: $, now: number) => {
258  try {
259    let detail: 'summary' | 'full' = 'full'
260    let u = await $.session.usage({ breakdown: 'full' }).catch(() => undefined)
261    if (!u?.context.breakdown) {
262      // The exact count failed (offline, say): the estimate beats an empty section.
263      detail = 'summary'
264      u = await $.session.usage({ breakdown: 'summary' })
265    }
266    const b = u.context.breakdown
267    if (!b) return
268    const next: Breakdown = {
269      at: now,
270      detail,
271      model: b.model,
272      totalTokens: b.totalTokens,
273      rawMaxTokens: b.rawMaxTokens,
274      percentage: b.percentage,
275      autoCompactThreshold: b.autoCompactThreshold,
276      isAutoCompactEnabled: b.isAutoCompactEnabled,
277      categories: b.categories.map(c => ({ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind })),
278      memoryFiles: b.memoryFiles.map(f => ({ path: f.path, type: f.type, tokens: f.tokens })),
279      mcpTools: b.mcpTools.map(t => ({ name: t.name, serverName: t.serverName, tokens: t.tokens, isLoaded: t.isLoaded })),
280      skills: (b.skills?.skillFrontmatter ?? []).map(s => ({ name: s.name, tokens: s.tokens })),
281    }
282    await update($, breakdownA, () => next)
283  } catch (error) {
284    $.ui.log(`usage-mod: context breakdown unavailable (${String(error)})`, { to: 'debug' })
285  } finally {
286    isCounting = false
287    counting = undefined
288    if (isOwed) {
289      isOwed = false
290      void refreshBreakdown($, true)
291    }
292  }
293}
294
295/**
296 * The main loop's model, as `/model` shows it: read when the session starts, on a switch, and
297 * every tick, so a switch shows before the next reply rather than with it. The window
298 * and where compaction starts can change with the model (200k, 1M), so a new one counts the
299 * context again, details shown or not: the Context window meter reads them too.
300 */
301const syncModel = async ($: $) => {
302  const m = await $.session.model().catch(() => undefined)
303  if (!m || shortModel(m) === shortModel(await read($, modelA))) return
304  await update($, modelA, () => m)
305  await refreshMeasure($).catch(() => undefined)
306  void refreshBreakdown($, true)
307}
308
309/**
310 * Brings the model, cost, context and limits up to the moment, and the context breakdown
311 * while the details show it: called every tick and on every request, tool result and turn.
312 */
313const syncLive = async ($: $) => {
314  await syncModel($)
315  await refreshMeasure($).catch(() => undefined)
316  await pollLimits($).catch(() => undefined)
317  if (await read($, expandedA)) await refreshBreakdown($)
318}
319
320/**
321 * Everything the band shows, read again before a copy, each from where it comes: the model, cost and
322 * context, the limits from the usage service itself, the context counted exactly (after any count already
323 * running, which may predate the press), and the history, should it still be loading.
324 */
325const checkAll = async ($: $) => {
326  const asked = await $.clock.now()
327  await syncModel($).catch(() => undefined)
328  await refreshMeasure($).catch(() => undefined)
329  await pollLimits($, false, true).catch(() => undefined)
330  // Done once a count begun since the press has finished, whoever began it.
331  for (;;) {
332    while (counting) await counting
333    if (breakdownAt >= asked) break
334    await refreshBreakdown($, true)
335  }
336  await backfilling
337}
338
339/** How often the band redraws and reads cost, context, the model and the window (the limits keep their own pace). */
340const TICK_MS = 1000
341
342/**
343 * The choices made in any chat, kept for every chat: a fresh install shows the band, its details tucked away,
344 * and no status line. Read again each tick and whenever another chat writes the store, so a chat already open
345 * (the desktop keeps several) follows a change made in another.
346 */
347const syncChoices = async ($: $) => {
348  const chosen = async (key: string) => (await $.store.get(key).catch(() => undefined)) === true
349  const [isStatusShown, isExpanded, isHidden] = await Promise.all([chosen(STATUS_KEY), chosen(DETAILS_KEY), chosen(HIDDEN_KEY)])
350  // Written only on a change, so the band draws again only when one came from elsewhere.
351  if ((await read($, statusA)) !== isStatusShown) await update($, statusA, () => isStatusShown)
352  if ((await read($, hiddenA)) !== isHidden) await update($, hiddenA, () => isHidden)
353  if ((await read($, expandedA)) !== isExpanded) {
354    await update($, expandedA, () => isExpanded)
355    if (isExpanded) void refreshBreakdown($, true)
356  }
357}
358
359/**
360 * The window and where compaction starts, by the quick local estimate: counted exactly again when
361 * either moved, however it was moved (/autocompact, a model or a setting changed where no hook hears it).
362 */
363const checkWindow = async ($: $) => {
364  if (isCounting) return
365  const b = (await $.session.usage({ breakdown: 'summary' }).catch(() => undefined))?.context.breakdown
366  const shown = await read($, breakdownA)
367  if (!b || !shown) return
368  const moved =
369    b.rawMaxTokens !== shown.rawMaxTokens || b.autoCompactThreshold !== shown.autoCompactThreshold || b.isAutoCompactEnabled !== shown.isAutoCompactEnabled
370  if (moved) await refreshBreakdown($, true)
371}
372
373/**
374 * The store changed, in this chat or another: the choices read again, another chat's answer from the
375 * usage service taken, and, on another chat's note, everything read again. One at a time; a change
376 * heard meanwhile runs it once more after.
377 */
378let isHearing = false
379let isHeardAgain = false
380const hearStore = async ($: $) => {
381  if (isHearing) return void (isHeardAgain = true)
382  isHearing = true
383  try {
384    do {
385      isHeardAgain = false
386      await syncChoices($)
387      await adoptShared($)
388      const note = (await $.store.get(NUDGE_KEY).catch(() => undefined)) as { at?: number; by?: string } | undefined
389      if (note?.at === undefined || note.at <= nudgeSeen) continue
390      nudgeSeen = note.at
391      if (note.by === (await $.session.id())) continue
392      await syncModel($)
393      await refreshMeasure($).catch(() => undefined)
394      await checkWindow($)
395      $.ui.invalidate('ui.render')
396    } while (isHeardAgain)
397  } finally {
398    isHearing = false
399  }
400}
401
402/**
403 * Keeps the band in step with the chat whether or not anything is happening
404 * (durations, countdowns, cost and context all move between events). Replaces
405 * any timer already running, so a new chat or a restart never runs two.
406 */
407const startTicker = async ($: $) => {
408  ticker?.cancel()
409  isTicking = false
410  tickedAt = await $.clock.now()
411  ticker = $.clock.every(TICK_MS, () => {
412    // A tick still running when the next comes (a slow read) lets that one pass rather than pile up.
413    if (isTicking) return
414    isTicking = true
415    void (async () => {
416      tickedAt = await $.clock.now()
417      await syncChoices($)
418      // A hidden band with the status line off has nothing to keep fresh; the line alone still counts down.
419      if ((await read($, hiddenA)) && !(await read($, statusA))) return
420      await syncLive($)
421      await checkWindow($)
422      $.ui.invalidate('ui.render')
423    })()
424      .catch(error => $.ui.log(`usage-mod: refresh failed (${String(error)})`, { to: 'debug' }))
425      .finally(() => (isTicking = false))
426  })
427}
428
429/** Starts the timer again when it has gone quiet: called while drawing. */
430const ensureTicker = async ($: $, now: number) => {
431  if (!ticker || now - tickedAt > TICK_MS * 3) await startTicker($)
432}
433
434/** Claude Code's configuration directory: ~/.claude unless CLAUDE_CONFIG_DIR moves it. */
435const configDir = async ($: $) =>
436  (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '~'}/.claude`
437
438/** Whether a path is one of the store's files, written with either slash and in any case, as Windows allows. */
439const isStoreFile = (path: string) => /\/plugins\/store\/usage-mod_[^/]*\.json$/.test(path.replace(/\\/g, '/').toLowerCase())
440
441/**
442 * The store's files, one per place the plugin was installed from (usage-mod_<source>-<hash>.json),
443 * for the session to watch: a choice written in any chat reaches every other at once, rather
444 * than on the next tick. A fresh install has none until something is kept, so one is kept first.
445 */
446const findStoreFiles = async ($: $) => {
447  const dir = `${await configDir($)}/plugins/store`
448  const list = async () =>
449    (await $.fs.list(dir).catch(() => [])).filter(f => f.name.startsWith('usage-mod_') && f.name.endsWith('.json')).map(f => `${dir}/${f.name}`)
450  let files = await list()
451  if (files.length === 0) {
452    await $.store.set(HIDDEN_KEY, (await $.store.get(HIDDEN_KEY)) === true)
453    files = await list()
454  }
455  return files
456}
457
458/** Finds this session's transcript: the path a settings hook was given, else ~/.claude/projects/<slug>/<id>.jsonl. */
459const findTranscript = async ($: $, sessionId: string) => {
460  if (transcriptPath && (await $.fs.exists(transcriptPath))) return transcriptPath
461  const projects = `${await configDir($)}/projects`
462  for (const dir of [await $.session.root(), await $.session.cwd()]) {
463    const guess = `${projects}/${projectSlug(dir)}/${sessionId}.jsonl`
464    if (await $.fs.exists(guess)) return guess
465  }
466  try {
467    for (const entry of await $.fs.list(projects)) {
468      if (entry.kind !== 'dir') continue
469      const guess = `${projects}/${entry.name}/${sessionId}.jsonl`
470      if (await $.fs.exists(guess)) return guess
471    }
472  } catch {
473    // no projects folder
474  }
475  return undefined
476}
477
478/** The engine refuses a `$.fs.read` over 4 MiB; a long chat's transcript is past it. */
479const READ_LIMIT = 4 * 1024 * 1024
480
481/**
482 * Hands each line of a text file to `onLine`. A file under the read limit is
483 * read at once; a larger one is streamed through a child that prints it
484 * (PowerShell on Windows, given the path in its environment so nothing needs
485 * quoting; `cat` elsewhere), so it is never held whole.
486 */
487const readLines =async ($: $, path: string, onLine: (line: string) => void) => {
488  const { size } = await $.fs.stat(path)
489  if (size < READ_LIMIT) {
490    for (const line of (await $.fs.read(path)).split('\n')) onLine(line)
491    return
492  }
493  const isWindows = (await $.env.get('OS')) === 'Windows_NT'
494  const reader = $.process.spawn(
495    isWindows
496      ? {
497          argv: [
498            'powershell.exe',
499            '-NoProfile',
500            '-NonInteractive',
501            '-Command',
502            '[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false); [Console]::Out.Write([IO.File]::ReadAllText($env:USAGE_MOD_FILE))',
503          ],
504          env: { USAGE_MOD_FILE: path },
505        }
506      : { argv: ['cat', path] },
507  )
508  let rest = ''
509  let errors = ''
510  for await (const { stream, text } of reader) {
511    if (stream === 'stderr') {
512      errors += text
513      continue
514    }
515    const lines = (rest + text).split('\n')
516    rest = lines.pop() ?? ''
517    for (const line of lines) onLine(line)
518  }
519  onLine(rest)
520  const { code } = await reader.result
521  if (code !== 0) throw new Error(errors.trim().slice(0, 120) || `reader exited with ${code}`)
522}
523
524/** Reads what happened before this mod started counting, once per session. */
525const backfill = async ($: $) => {
526  const was = await read($, backfillA)
527  if (was && was.status !== 'pending' && was.version === BACKFILL_VERSION) return
528  const sessionId = await $.session.id()
529  const liveSince = was?.liveSince ?? (await $.clock.now())
530  const set = (b: Backfill) => update($, backfillA, () => b)
531  await set({ status: 'pending', sessionId, liveSince, version: BACKFILL_VERSION })
532  const path = await findTranscript($, sessionId)
533  if (!path) {
534    // A new chat has no transcript until its first message, and nothing before the mod to count.
535    const m = await read($, measureA)
536    if (!m?.costUsd) return void (await set({ status: 'done', sessionId, liveSince, version: BACKFILL_VERSION }))
537    await set({ status: 'unavailable', sessionId, liveSince, version: BACKFILL_VERSION, note: 'History: transcript not found; counting from when the mod loaded.' })
538    return
539  }
540  const main = transcriptFolder({ before: liveSince })
541  try {
542    await readLines($, path, main.line)
543  } catch (error) {
544    await set({
545      status: 'unavailable',
546      sessionId,
547      liveSince,
548      version: BACKFILL_VERSION,
549      note: `History: transcript could not be read (${String(error).slice(0, 80)}); counting from when the mod loaded.`,
550    })
551    return
552  }
553  let model = main.done(emptyModel())
554  let skipped = 0
555  const subDir = `${path.replace(/\.jsonl$/, '')}/subagents`
556  try {
557    if (await $.fs.exists(subDir)) {
558      for (const entry of await $.fs.list(subDir)) {
559        if (!entry.name.endsWith('.jsonl')) continue
560        try {
561          const agentId = entry.name.replace(/^agent-/, '').replace(/\.jsonl$/, '')
562          const sub = transcriptFolder({ before: liveSince, agentId })
563          await readLines($, `${subDir}/${entry.name}`, sub.line)
564          model = sub.done(model)
565        } catch {
566          skipped += 1
567        }
568      }
569    }
570  } catch {
571    // no subagent transcripts
572  }
573  // Merge: history first, then whatever the live hooks counted meanwhile.
574  await update($, usageA, live => {
575    let merged = model
576    for (const r of live.requests.filter(r => !r.isBackfill)) merged = addRequest(merged, r)
577    const liveTurns = live.turns.filter(t => !t.isBackfill)
578    merged = { ...merged, turns: [...merged.turns, ...liveTurns].slice(-150) }
579    for (const [tool, s] of Object.entries(live.byTool)) {
580      const had = merged.byTool[tool]
581      merged = {
582        ...merged,
583        byTool: {
584          ...merged.byTool,
585          [tool]: had
586            ? {
587                calls: had.calls + s.calls,
588                errors: had.errors + s.errors,
589                denied: had.denied + s.denied,
590                totalMs: had.totalMs + s.totalMs,
591                maxMs: Math.max(had.maxMs, s.maxMs),
592                timed: had.timed + s.timed,
593              }
594            : s,
595        },
596      }
597    }
598    return { ...merged, compactions: [...merged.compactions, ...live.compactions] }
599  })
600  await set({
601    status: skipped ? 'partial' : 'done',
602    sessionId,
603    liveSince,
604    version: BACKFILL_VERSION,
605    note: skipped ? `History: ${skipped} subagent transcript(s) could not be read.` : undefined,
606  })
607}
608
609export const register: Register = on => {
610  on('session.start', async ($, e, next) => {
611    const result = await next(e)
612    await $.command.register({
613      name: 'usage-mod',
614      description: 'Show or hide the usage band above the prompt',
615    })
616    const bf = await read($, backfillA)
617    if (!bf || bf.version !== BACKFILL_VERSION) {
618      // First load, or history read by an older parser: count everything again from the transcript.
619      const liveSince = await $.clock.now()
620      await update($, usageA, () => emptyModel())
621      await update($, backfillA, () => ({ status: 'pending', liveSince, version: BACKFILL_VERSION }) as Backfill)
622      await update($, menuA, () => false)
623    }
624    await syncChoices($)
625    await syncModel($)
626    await refreshMeasure($).catch(() => undefined)
627    // Work that may take a while runs off the session's start.
628    $.clock.after(50, () => {
629      backfilling = backfill($).catch(error => $.ui.log(`usage-mod: history not loaded (${String(error)})`, { to: 'debug' }))
630      void refreshBreakdown($, true)
631    })
632    await startTicker($)
633    return result
634  })
635
636  on('command.run', { command: 'usage-mod' }, async $ => {
637    const isHidden = await update($, hiddenA, was => !was)
638    await $.store.set(HIDDEN_KEY, isHidden).catch(() => undefined)
639    // Shown again, the band keeps its details as they were chosen, with the menu closed.
640    await update($, menuA, () => false)
641    if (!isHidden) {
642      void refreshMeasure($).then(() => refreshBreakdown($, true))
643      await ensureTicker($, await $.clock.now())
644    }
645    // Said as a notification, as the menu says it, rather than as a line in the transcript.
646    $.ui.toast(isHidden ? HIDDEN_TOAST : 'Usage band shown.')
647    return {}
648  })
649
650  // Every settings-hook event names the transcript; the first one tells us where history lives.
651  on('classic.UserPromptSubmit', ($, e, next) => {
652    transcriptPath = e.transcript_path || transcriptPath
653    return next(e)
654  })
655  on('classic.Stop', ($, e, next) => {
656    transcriptPath = e.transcript_path || transcriptPath
657    return next(e)
658  })
659  // The store's files are watched for the session, so a choice made in another chat shows here at once.
660  on('classic.SessionStart', async ($, e, next) => {
661    transcriptPath = e.transcript_path || transcriptPath
662    const result = await next(e)
663    const files = await findStoreFiles($).catch(() => [])
664    return files.length > 0 ? { ...result, watchPaths: [...(result.watchPaths ?? []), ...files] } : result
665  })
666  on('classic.FileChanged', ($, e, next) => {
667    // Known by name, so a hot reload (which keeps the watch but forgets everything here) still hears it.
668    if (isStoreFile(e.file_path)) void hearStore($).catch(() => undefined)
669    return next(e)
670  })
671  // /model, the picker or the SDK: the band names the new model at once, not after its first reply.
672  on('classic.PostModelSwitch', ($, e, next) => {
673    void syncModel($).catch(() => undefined)
674    return next(e)
675  })
676  // Every slash command, once it has run: many move what the band shows (/model, /fast, /autocompact,
677  // /config, /compact, /clear, /mcp, /reload-plugins, /init, /output-style, /login), and new ones come
678  // with new releases, so the model, cost, context, its breakdown and the limits are all read again.
679  on('command.run', async ($, e, next) => {
680    const result = await next(e)
681    void (async () => {
682      await nudgeOthers($)
683      await syncModel($)
684      await refreshMeasure($)
685      await pollLimits($, true)
686      await refreshBreakdown($, true)
687    })().catch(() => undefined)
688    return result
689  })
690  // A setting that moves the window or where compaction starts (auto-compact in /config, a settings file
691  // edited) is counted again once the change is in, so the meter follows it before the next reply. One set
692  // here reaches the other chats by a note; an edited settings file reaches each chat by itself.
693  on('config.set', ($, e, next) => {
694    $.clock.after(10, () => {
695      void refreshBreakdown($, true)
696      void nudgeOthers($)
697    })
698    return next(e)
699  })
700  on('classic.ConfigChange', ($, e, next) => {
701    $.clock.after(10, () => void refreshBreakdown($, true))
702    return next(e)
703  })
704
705  on('turn.start', async ($, e, next) => {
706    const [now, u] = await Promise.all([$.clock.now(), $.session.usage().catch(() => undefined)])
707    await update($, usageA, m =>
708      startTurn(m, {
709        id: e.turnId,
710        startedAt: now,
711        prompt: e.text.replace(/\s+/g, ' ').trim().slice(0, 120),
712        requests: 0,
713        tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
714        tools: 0,
715        costAtStart: u?.cost?.usd,
716      }),
717    )
718    void syncLive($)
719    return next(e)
720  })
721
722  on('turn.step', async function* ($, e, next) {
723    const result = yield* next(e)
724    if (result.usage) {
725      const usage = result.usage
726      const now = await $.clock.now()
727      await update($, usageA, m =>
728        addRequest(m, {
729          t: now,
730          model: usage.model,
731          agentId: e.agentId,
732          tokens: tokensOf(usage),
733          stop: result.stopReason,
734        }),
735      )
736      if (!e.agentId) await update($, modelA, () => usage.model)
737      void syncLive($)
738    }
739    return result
740  })
741
742  on('turn.complete', async ($, e, next) => {
743    const result = await next(e)
744    if (e.agentId !== undefined) return result
745    const u = await refreshMeasure($).catch(() => undefined)
746    const after = u?.cost?.usd
747    await update($, usageA, m => {
748      const turn = m.turns.find(t => t.id === e.turnId)
749      const costUsd = after !== undefined && turn?.costAtStart !== undefined ? Math.max(0, after - turn.costAtStart) : undefined
750      return finishTurn(m, e.turnId, { durationMs: e.durationMs, reason: e.reason, costUsd })
751    })
752    $.clock.after(10, () => {
753      void syncLive($).then(() => pollLimits($, true))
754      void refreshBreakdown($, true)
755    })
756    return result
757  })
758
759  on('tool.call', async ($, e, next) => {
760    const since = await $.clock.now()
761    const running: RunningTool = { id: e.tool_use_id, tool: String(e.tool), since, agentId: e.agentId }
762    await update($, runningA, list => [...list.filter(r => r.id !== running.id), running])
763    let outcome: { isError?: boolean; isDenied?: boolean } = { isError: true }
764    try {
765      const ran = await next(e)
766      outcome = { isError: ran.isError === true, isDenied: ran.deny !== undefined }
767      return ran
768    } finally {
769      const ms = (await $.clock.now()) - since
770      await update($, runningA, list => list.filter(r => r.id !== running.id))
771      await update($, usageA, m => addToolCall(m, running.tool, { ms, ...outcome }))
772      void syncLive($)
773    }
774  })
775
776  on('session.measure', async ($, e, next) => {
777    const now = await $.clock.now()
778    noteReply(e.rateLimits, now)
779    await update($, measureA, prev => toMeasure(now, e, prev))
780    if (isReplyNewest(now)) await rememberLimits($, e.rateLimits)
781    return next(e)
782  })
783
784  on('session.compact', async ($, e, next) => {
785    const result = await next(e)
786    if (result.skip === undefined && e.trigger !== 'precompute') {
787      const now = await $.clock.now()
788      const usage = result.usage
789      await update($, usageA, m => {
790        const withRow = addCompaction(m, { t: now, trigger: e.trigger, before: result.tokensBefore, after: result.tokensAfter })
791        return usage ? addRequest(withRow, { t: now, model: 'compaction', agentId: e.agentId, tokens: tokensOf(usage), stop: null }) : withRow
792      })
793      $.clock.after(10, () => void refreshBreakdown($, true))
794    }
795    return result
796  })
797
798  on('session.end', async ($, e, next) => {
799    if (e.reason === 'clear') {
800      // A /clear starts the count over, as /cost does.
801      const now = await $.clock.now()
802      await update($, usageA, () => emptyModel())
803      await update($, breakdownA, () => null)
804      await update($, runningA, () => [])
805      await update($, backfillA, () => ({ status: 'done', liveSince: now }) as Backfill)
806      transcriptPath = undefined
807    }
808    return next(e)
809  })
810
811  // The status line: a line of its own under the hint line below the prompt, which only the terminal draws.
812  // ($.ui.status would pin it among the engine's notices, under a warning sign.)
813  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
814    if (e.surface !== 'terminal' || !(await read($, statusA))) return next(e)
815    const [usage, measure, breakdown, now] = await Promise.all([read($, usageA), read($, measureA), read($, breakdownA), $.clock.now()])
816    const T = $.ui.resolve(e)
817    // The engine's own line stays as it draws it, its pills live; the terminal draws it first, whatever the order here.
818    const hint = await next(e)
819    // The line sits two cells in from each edge; below that it condenses rather than cut off.
820    const width = e.viewport ? e.viewport.columns - 4 : undefined
821    return (
822      <T.Box flexDirection="column">
823        {hint}
824        {statusLine(T, usage, measure, breakdown, now, width)}
825      </T.Box>
826    )
827  })
828
829  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
830    if (e.props.hasSurvey || (await read($, hiddenA))) return next(e)
831    const [usage, measure, breakdown, isExpanded, isMenuOpen, isStatusShown, running, backfillState, model, now] = await Promise.all([
832      read($, usageA),
833      read($, measureA),
834      read($, breakdownA),
835      read($, expandedA),
836      read($, menuA),
837      read($, statusA),
838      read($, runningA),
839      read($, backfillA),
840      read($, modelA),
841      $.clock.now(),
842    ])
843    await ensureTicker($, now)
844    const surface = e.surface
845    const T = $.ui.resolve(e)
846    // The slot holds one tree, so the bands of the plugins beneath this one are drawn above it, then a
847    // blank row and a rule, rather than replaced; this band keeps the rows they leave.
848    const others = await next(e)
849    const otherRows = rowsOf(others)
850    const own = band({
851      T,
852      Svg: surface !== 'terminal' && 'Svg' in T ? T.Svg : undefined,
853      cols: e.props.bodyColumns,
854      maxRows: otherRows === 0 ? e.props.maxRows : Math.max(1, e.props.maxRows - otherRows - 2),
855      now,
856      usage: usage,
857      measure: measure,
858      breakdown: breakdown,
859      running: running,
860      backfill: backfillState,
861      model: model,
862      isExpanded,
863      isMenuOpen,
864      isTerminal: surface === 'terminal',
865      isStatusShown,
866      onMenu: () => update($, menuA, was => !was),
867      onExpand: async () => {
868        await update($, menuA, () => false)
869        const isOpen = await update($, expandedA, was => !was)
870        await $.store.set(DETAILS_KEY, isOpen).catch(() => undefined)
871        if (isOpen) void refreshBreakdown($, true)
872      },
873      onStatus: async () => {
874        await update($, menuA, () => false)
875        const isShown = await update($, statusA, was => !was)
876        await $.store.set(STATUS_KEY, isShown).catch(() => undefined)
877      },
878      onHide: async () => {
879        await update($, menuA, () => false)
880        await update($, hiddenA, () => true)
881        await $.store.set(HIDDEN_KEY, true).catch(() => undefined)
882        $.ui.toast(HIDDEN_TOAST)
883      },
884      onCopy: async () => {
885        await update($, menuA, () => false)
886        // A second press while the first is still checking copies nothing more.
887        if (isCopying) return
888        isCopying = true
889        try {
890          await checkAll($)
891        } finally {
892          isCopying = false
893        }
894        // What the copy just read fresh, the other chats read too.
895        void nudgeOthers($)
896        const [u, m, b, running, model, now] = await Promise.all([
897          read($, usageA),
898          read($, measureA),
899          read($, breakdownA),
900          read($, runningA),
901          read($, modelA),
902          $.clock.now(),
903        ])
904        const figures = bandFigures({ now, usage: u, measure: m, breakdown: b, running, model })
905        const text = JSON.stringify(
906          { generatedAt: new Date(now).toISOString(), sessionId: await $.session.id(), band: figures, measure: copiedMeasure(m, b), ...u, totals: { ...u.totals, all: sumTokens(u.totals) }, breakdown: b },
907          null,
908          2,
909        )
910        const copied = await $.ui.copy({ text, surface })
911        $.ui.toast(copied.isCopied ? 'Usage JSON copied.' : `Could not copy: ${copied.reason}`)
912      },
913    })
914    if (otherRows === 0) return own
915    return (
916      <T.Box flexDirection="column">
917        {others}
918        {/* A rule wider than any band, clipped to one row, so it never wraps or ends in an ellipsis. */}
919        <T.Box key="rule" marginTop={1} height={1} overflow="hidden">
920          <T.Box width={1000} flexShrink={0}>
921            <T.Text dimColor>{'─'.repeat(500)}</T.Text>
922          </T.Box>
923        </T.Box>
924        {own}
925      </T.Box>
926    )
927  })
928}
929
hooks/collect.ts 355 lines
1import type {
2  Bucket,
3  CompactionRow,
4  RateLimitRow,
5  RequestRow,
6  ToolStat,
7  Tokens,
8  TurnRow,
9  UsageModel,
10} from '../types'
11
12const MAX_REQUESTS = 400
13const MAX_TURNS = 150
14
15const zeroTokens = (): Tokens => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 })
16const zeroBucket =(): Bucket => ({ ...zeroTokens(), requests: 0 })
17
18export const emptyModel = (): UsageModel => ({
19  totals: zeroBucket(),
20  byModel: {},
21  byAgent: {},
22  byTool: {},
23  requests: [],
24  turns: [],
25  compactions: [],
26})
27
28type ApiUsage = {
29  input_tokens?: number | null
30  output_tokens?: number | null
31  cache_read_input_tokens?: number | null
32  cache_creation_input_tokens?: number | null
33}
34
35export const tokensOf = (u: ApiUsage): Tokens => ({
36  input: u.input_tokens ?? 0,
37  output: u.output_tokens ?? 0,
38  cacheRead: u.cache_read_input_tokens ?? 0,
39  cacheWrite: u.cache_creation_input_tokens ?? 0,
40})
41
42const addTokens =<T extends Tokens>(a: T, b: Tokens): T => ({
43  ...a,
44  input: a.input + b.input,
45  output: a.output + b.output,
46  cacheRead: a.cacheRead + b.cacheRead,
47  cacheWrite: a.cacheWrite + b.cacheWrite,
48})
49
50export const sumTokens = (t: Tokens) => t.input + t.output + t.cacheRead + t.cacheWrite
51const promptTokens =(t: Tokens) => t.input + t.cacheRead + t.cacheWrite
52
53/** Share of prompt tokens served from the cache, 0..1. */
54export const cacheHitRate = (t: Tokens) => {
55  const all = promptTokens(t)
56  return all === 0 ? 0 : t.cacheRead / all
57}
58
59const bump = (map: Record<string, Bucket>, key: string, t: Tokens) => ({
60  ...map,
61  [key]: { ...addTokens(map[key] ?? zeroBucket(), t), requests: (map[key]?.requests ?? 0) + 1 },
62})
63
64/** Folds one model request into the model; the open turn (last, no duration) gets it too. */
65export const addRequest = (m: UsageModel, row: RequestRow): UsageModel => {
66  const turns = m.turns.slice()
67  const open = turns.at(-1)
68  if (open && open.durationMs === undefined && !row.isBackfill) {
69    turns[turns.length - 1] = {
70      ...open,
71      requests: open.requests + 1,
72      tokens: addTokens(open.tokens, row.tokens),
73    }
74  }
75
76  return {
77    ...m,
78    totals: { ...addTokens(m.totals, row.tokens), requests: m.totals.requests + 1 },
79    byModel: bump(m.byModel, row.model || 'unknown', row.tokens),
80    byAgent: bump(m.byAgent, row.agentId ?? 'main', row.tokens),
81    requests: [...m.requests, row].sort((a, b) => a.t - b.t).slice(-MAX_REQUESTS),
82    turns,
83  }
84}
85
86export const startTurn = (m: UsageModel, turn: TurnRow): UsageModel => ({
87  ...m,
88  turns: [...m.turns, turn].slice(-MAX_TURNS),
89})
90
91export const finishTurn = (
92  m: UsageModel,
93  id: string,
94  fields: { durationMs: number; reason: string; costUsd?: number },
95): UsageModel => ({
96  ...m,
97  turns: m.turns.map(t => (t.id === id ? { ...t, ...fields } : t)),
98})
99
100const zeroTool = (): ToolStat => ({ calls: 0, errors: 0, denied: 0, totalMs: 0, maxMs: 0, timed: 0 })
101
102export const addToolCall = (
103  m: UsageModel,
104  tool: string,
105  outcome: { ms?: number; isError?: boolean; isDenied?: boolean },
106): UsageModel => {
107  const was = m.byTool[tool] ?? zeroTool()
108  const ms = outcome.ms
109  const turns = m.turns.slice()
110  const open = turns.at(-1)
111  if (open && open.durationMs === undefined && ms !== undefined) {
112    turns[turns.length - 1] = { ...open, tools: open.tools + 1 }
113  }
114
115  return {
116    ...m,
117    turns,
118    byTool: {
119      ...m.byTool,
120      [tool]: {
121        calls: was.calls + 1,
122        errors: was.errors + (outcome.isError ? 1 : 0),
123        denied: was.denied + (outcome.isDenied ? 1 : 0),
124        totalMs: was.totalMs + (ms ?? 0),
125        maxMs: Math.max(was.maxMs, ms ?? 0),
126        timed: was.timed + (ms === undefined ? 0 : 1),
127      },
128    },
129  }
130}
131
132/** The rate-limit readings whose window has not reset yet; a reset one no longer says anything. */
133export const liveLimits = (rows: readonly RateLimitRow[], now: number) =>
134  rows.filter(l => l.resetsAt === undefined || !(Date.parse(l.resetsAt) <= now))
135
136/** The windows the band draws a meter for. */
137const LIMIT_KINDS = ['five_hour', 'seven_day'] as const
138
139/**
140 * Reads the usage service's answer (`{ five_hour: { utilization, resets_at }, ... }`)
141 * as rate-limit rows: the windows the band draws, each with a percentage; an
142 * answer of any other shape gives none.
143 */
144export const parseUsage = (text: string): RateLimitRow[] => {
145  let body: unknown
146  try {
147    body = JSON.parse(text)
148  } catch {
149    return []
150  }
151  if (!body || typeof body !== 'object') return []
152  const rows: RateLimitRow[] = []
153  for (const kind of LIMIT_KINDS) {
154    const w = (body as Record<string, { utilization?: unknown; resets_at?: unknown } | null | undefined>)[kind]
155    if (!w || typeof w.utilization !== 'number' || !Number.isFinite(w.utilization)) continue
156    const reset = typeof w.resets_at === 'string' ? Date.parse(w.resets_at) : NaN
157    rows.push({
158      kind,
159      percentUsed: Math.round(w.utilization * 10) / 10,
160      resetsAt: Number.isFinite(reset) ? new Date(reset).toISOString() : undefined,
161    })
162  }
163  return rows
164}
165
166export const addCompaction = (m: UsageModel, row: CompactionRow): UsageModel => ({
167  ...m,
168  compactions: [...m.compactions, row].slice(-50),
169})
170
171/* ---------- transcript backfill ---------- */
172
173type Row = {
174  type?: string
175  timestamp?: string
176  isMeta?: boolean
177  isSidechain?: boolean
178  isCompactSummary?: boolean
179  uuid?: string
180  message?: {
181    id?: string
182    model?: string
183    role?: string
184    stop_reason?: string | null
185    usage?: ApiUsage
186    content?: unknown
187  }
188}
189
190type ToolUseBlock = { type: 'tool_use'; name: string; id: string }
191type ToolResultBlock = { type: 'tool_result'; tool_use_id: string; is_error?: boolean }
192
193const isToolUse = (b: unknown): b is ToolUseBlock =>
194  typeof b === 'object' && b !== null && (b as { type?: string }).type === 'tool_use'
195const isToolResult = (b: unknown): b is ToolResultBlock =>
196  typeof b === 'object' && b !== null && (b as { type?: string }).type === 'tool_result'
197
198/** Engine-written text a prompt row can carry: command echoes, reminders, notices. */
199const SYSTEM_TEXT = /^\s*<(local-command|command-name|command-message|command-args|system-reminder|task-notification)/
200
201/** The person's own words in a user row; reminders the app prepends are dropped. */
202const promptText = (content: unknown): string | undefined => {
203  if (typeof content === 'string') return SYSTEM_TEXT.test(content) ? undefined : content
204  if (!Array.isArray(content)) return undefined
205  if (content.some(isToolResult)) return undefined
206  const text = content
207    .filter((b): b is { type: 'text'; text: string } => (b as { type?: string })?.type === 'text')
208    .map(b => b.text)
209    .filter(t => !SYSTEM_TEXT.test(t))
210    .join(' ')
211  return text.trim() || undefined
212}
213
214/**
215 * Folds a transcript JSONL file into the model one line at a time, so a file
216 * read in pieces is never held whole: each assistant API message once (a
217 * message is written as one row per content block, all carrying the same
218 * usage), tool uses and their error results, and user prompts as turns. Rows
219 * at or after `before` (ms) are left to the live hooks. `done` adds it all to
220 * the model.
221 */
222export const transcriptFolder = (options: { before: number; agentId?: string }) => {
223  const seen = new Set<string>()
224  const toolNames = new Map<string, string>()
225  const toolTimes: number[] = []
226  const errored = new Set<string>()
227  const prompts: TurnRow[] = []
228  const reqs: RequestRow[] = []
229
230  const line = (text: string) => {
231    if (!text.trim()) return
232    let row: Row
233    try {
234      row = JSON.parse(text) as Row
235    } catch {
236      return
237    }
238    const t = row.timestamp ? Date.parse(row.timestamp) : NaN
239    if (!Number.isFinite(t) || t >= options.before) return
240    const msg = row.message
241    if (!msg) return
242
243    if (row.type === 'assistant' && msg.usage) {
244      const id = msg.id ?? row.uuid ?? `${t}`
245      if (Array.isArray(msg.content)) {
246        for (const b of msg.content) {
247          if (!isToolUse(b) || toolNames.has(b.id)) continue
248          toolNames.set(b.id, b.name)
249          if (!options.agentId) toolTimes.push(t)
250        }
251      }
252      if (seen.has(id)) return
253      seen.add(id)
254      if (msg.model === '<synthetic>') return
255      reqs.push({
256        t,
257        model: msg.model ?? 'unknown',
258        agentId: options.agentId,
259        tokens: tokensOf(msg.usage),
260        stop: msg.stop_reason ?? null,
261        isBackfill: true,
262      })
263    } else if (row.type === 'user') {
264      if (Array.isArray(msg.content)) {
265        for (const b of msg.content) if (isToolResult(b) && b.is_error) errored.add(b.tool_use_id)
266      }
267      if (options.agentId || row.isMeta || row.isSidechain || row.isCompactSummary) return
268      const text = promptText(msg.content)
269      if (text) {
270        prompts.push({
271          id: row.uuid ?? `bf-${t}`,
272          startedAt: t,
273          prompt: text.replace(/\s+/g, ' ').trim().slice(0, 120),
274          requests: 0,
275          tokens: zeroTokens(),
276          tools: 0,
277          isBackfill: true,
278        })
279      }
280    }
281  }
282
283  const done = (m: UsageModel): UsageModel => {
284    let model = m
285    for (const r of reqs) model = addRequest(model, r)
286    for (const [id, name] of toolNames) model = addToolCall(model, name, { isError: errored.has(id) })
287
288    if (prompts.length > 0) {
289      // Assign each backfilled request to the prompt it followed; a turn's
290      // duration is up to its last request.
291      const filled = prompts.map((p, i) => {
292        const end = prompts[i + 1]?.startedAt ?? options.before
293        const mine = reqs.filter(r => r.t >= p.startedAt && r.t < end)
294        const tokens = mine.reduce((acc, r) => addTokens(acc, r.tokens), zeroTokens())
295        const last = mine.at(-1)?.t ?? p.startedAt
296        const tools = toolTimes.filter(x => x >= p.startedAt && x < end).length
297        return {
298          ...p,
299          requests: mine.length,
300          tokens,
301          tools,
302          durationMs: Math.max(0, last - p.startedAt),
303          reason: 'answer',
304        }
305      })
306      model = { ...model, turns: [...filled, ...model.turns].sort((a, b) => a.startedAt - b.startedAt).slice(-MAX_TURNS) }
307    }
308
309    return model
310  }
311
312  return { line, done }
313}
314
315/** The folder name Claude Code keeps a project's transcripts under. */
316export const projectSlug = (cwd: string) => cwd.replace(/[^A-Za-z0-9]/g, '-')
317
318/* ---------- formatting ---------- */
319
320/** One decimal, a whole one dropped: "134.5k", "33k", "62.4M", as the app writes them. */
321const oneDecimal = (n: number) => n.toFixed(1).replace(/\.0$/, '')
322
323/** Tokens as the app writes them, one place at most and none when it's a zero: "33k", "15.8k", "20.6M". */
324export const fmtTokens = (n: number) => {
325  if (n >= 999_950_000) return `${oneDecimal(n / 1e9)}B`
326  if (n >= 999_950) return `${oneDecimal(n / 1e6)}M`
327  if (n >= 999.5) return `${oneDecimal(n / 1e3)}k`
328  return `${Math.round(n)}`
329}
330
331export const fmtUsd = (n: number | undefined) =>
332  // Always to the cent, never a fraction of one.
333  n === undefined ? '—' : `$${n.toFixed(2)}`
334
335/** A duration in whole units: "850ms", "4s", "2m 5s", "1h 3m". */
336export const fmtMs = (ms: number) => {
337  if (ms < 999.5) return `${Math.round(ms)}ms`
338  const s = Math.round(ms / 1000)
339  if (s < 60) return `${s}s`
340  const m = Math.floor(s / 60)
341  if (m < 60) return `${m}m ${s % 60}s`
342  return `${Math.floor(m / 60)}h ${m % 60}m`
343}
344
345export const fmtPct = (n: number) => `${n >= 10 || n === 0 ? Math.round(n) : n.toFixed(1)}%`
346
347export const shortModel = (id: string) =>
348  id
349    .replace(/^claude-/, '')
350    .replace(/-\d{8}$/, '')
351    .replace(/\[.*\]$/, '')
352
353export const rateLabel = (kind: string) =>
354  kind === 'five_hour' ? 'Session' : kind === 'seven_day' ? 'Weekly' : kind === 'spend_limit' ? 'Spend' : kind.replace(/_/g, ' ')
355
hooks/views.tsx 831 lines
1import type { BoxProps, ButtonProps, ElementConstructor, RenderElement, RenderNode, SvgProps, TextProps } from 'claude-code'
2
3import type { Backfill, Breakdown, Measure, RunningTool, Tokens, UsageModel } from '../types'
4import { cacheHitRate, fmtMs, fmtPct, fmtTokens, fmtUsd, rateLabel, shortModel, sumTokens } from './collect'
5
6/* ---------- what the band is drawn from ---------- */
7
8export type Base = {
9  Box: ElementConstructor<BoxProps>
10  Text: ElementConstructor<TextProps>
11  Button: ElementConstructor<ButtonProps>
12}
13
14export type Ctx = {
15  T: Base
16  /** Present on the desktop: bars and sparklines are drawn as small vectors. */
17  Svg?: ElementConstructor<SvgProps>
18  cols: number
19  maxRows: number
20  now: number
21  usage: UsageModel
22  measure: Measure | null
23  breakdown: Breakdown | null
24  running: RunningTool[]
25  backfill: Backfill | null
26  model: string
27  isExpanded: boolean
28  isMenuOpen: boolean
29  /** The terminal has a status line under the prompt; the desktop draws none for plugins. */
30  isTerminal: boolean
31  isStatusShown: boolean
32  onMenu: () => unknown
33  onExpand: () => unknown
34  onStatus: () => unknown
35  onHide: () => unknown
36  onCopy: () => unknown
37}
38
39/* ---------- palette ---------- */
40
41/**
42 * A color in both forms: `hex`, mid-tones that read on light and dark
43 * backgrounds alike, drawn on every surface so each category keeps a color of
44 * its own (the terminal's theme has too few keys, and they repeat); `key`, a
45 * theme key, for the translucent track and buffer the terminal cannot draw.
46 */
47type Paint = { hex: string; key: string }
48
49const P = {
50  ember: { hex: '#D97757', key: 'claude' },
51  ochre: { hex: '#C9973B', key: 'warning' },
52  sage: { hex: '#5E9E7A', key: 'success' },
53  slate: { hex: '#6B8FD6', key: 'suggestion' },
54  heather: { hex: '#9A84D0', key: 'permission' },
55  teal: { hex: '#3E9BA6', key: 'suggestion' },
56  sand: { hex: '#B5A486', key: 'inactive' },
57  rose: { hex: '#C77DA0', key: 'permission' },
58  stone: { hex: '#8A8F98', key: 'inactive' },
59  red: { hex: '#D9574F', key: 'error' },
60} as const satisfies Record<string, Paint>
61
62const TRACK: Paint = { hex: 'rgba(128,128,128,0.22)', key: 'subtle' }
63const TICK_HEX = 'rgba(128,128,128,0.85)'
64const BUFFER: Paint = { hex: 'rgba(128,128,128,0.5)', key: 'inactive' }
65
66const KINDS = [
67  { key: 'input', label: 'Input', paint: P.slate },
68  { key: 'cacheWrite', label: 'Cache write', paint: P.ochre },
69  { key: 'cacheRead', label: 'Cache read', paint: P.sage },
70  { key: 'output', label: 'Output', paint: P.ember },
71] as const
72
73/** /context's categories: a short name and a color of this palette each. */
74const CATEGORY: Record<string, { name: string; paint: Paint }> = {
75  Messages: { name: 'Messages', paint: P.heather },
76  'System tools': { name: 'Tools', paint: P.sand },
77  'MCP tools': { name: 'MCP', paint: P.teal },
78  Skills: { name: 'Skills', paint: P.ochre },
79  'System prompt': { name: 'System', paint: P.slate },
80  'Memory files': { name: 'Memory', paint: P.sage },
81  'Custom agents': { name: 'Agents', paint: P.rose },
82  'MCP server instructions': { name: 'MCP notes', paint: P.teal },
83}
84const SPARE = [P.rose, P.teal, P.slate, P.sage, P.ochre]
85
86const color = (ctx: Ctx, p: Paint) => (ctx.Svg || p.hex.startsWith('#') ? p.hex : p.key)
87
88/* ---------- layout ---------- */
89
90const GAP = 3
91/**
92 * `total` cells shared by `n` columns with `gap` between them, every cell used:
93 * the cells a share leaves over go one each to the last columns, so the band runs to its right edge.
94 */
95const split = (total: number, n: number, gap: number, least: number) => {
96  const room = total - gap * (n - 1)
97  const each = Math.max(least, Math.floor(room / n))
98  const over = Math.max(0, room - each * n)
99  return Array.from({ length: n }, (_, i) => each + (i >= n - over ? 1 : 0))
100}
101/** Desktop CSS pixels per terminal cell, for sizing a vector to its column. */
102const PX = 8
103
104/* ---------- formatting ---------- */
105
106/**
107 * When a limit resets, as the app's panel writes it, longest first for the row
108 * to pick from: the time left for the session limit ("Resets in 2 hr 37 min"),
109 * the local day and time for the weekly ones ("Resets Wed 4:00 AM").
110 */
111const fmtReset = (kind: string, iso: string | undefined, now: number): string[] => {
112  // To the nearest minute, as the panel does: the service gives 09:59:59.965 for a 10:00 reset.
113  const at = iso === undefined ? NaN : Math.round(Date.parse(iso) / 60_000) * 60_000
114  if (!Number.isFinite(at)) return []
115  if (at <= now) return ['Resetting']
116  if (kind === 'five_hour') {
117    // Partial minutes count up, as the panel does: 2 hr 16 min 30 s left reads "2 hr 17 min".
118    const mins = Math.max(1, Math.ceil((at - now) / 60_000))
119    const h = Math.floor(mins / 60)
120    const m = mins % 60
121    const span = h === 0 ? `${m} min` : m === 0 ? `${h} hr` : `${h} hr ${m} min`
122    return [`Resets in ${span}`, span, h === 0 ? `${m}m` : `${h}h ${`${m}`.padStart(2, '0')}m`]
123  }
124  const when = new Date(at).toLocaleString('en-US', { weekday: 'short', hour: 'numeric', minute: '2-digit' })
125  return [`Resets ${when}`, when]
126}
127
128/** Each limit's name as the app's panel gives it, then shorter ones for tight rows. */
129const LIMIT_NAMES: Record<string, string[]> = {
130  five_hour: ['Session limit', 'Session', '5h'],
131  seven_day: ['Weekly · all models', 'Weekly', '7d'],
132}
133
134const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
135
136/**
137 * The context against its window: the breakdown's, which is the compaction
138 * window where one is set (300k of a 1M model) and the model's limit where not.
139 * `left` counts to where compaction runs, `tick` marks it on the window.
140 */
141const compaction = (m: Measure | null, b: Breakdown | null) => {
142  if (!b || !b.rawMaxTokens) return undefined
143  const window = b.rawMaxTokens
144  const tokens = m?.contextTokens ?? b.totalTokens
145  const at = b.isAutoCompactEnabled ? b.autoCompactThreshold : undefined
146  return {
147    window,
148    tokens,
149    at,
150    pct: (tokens / window) * 100,
151    left: Math.max(0, (at ?? window) - tokens),
152    tick: at ? at / window : undefined,
153  }
154}
155
156const sessionStart = (ctx: Pick<Ctx, 'measure' | 'usage'>) => ctx.measure?.startedAt ?? ctx.usage.requests[0]?.t ?? ctx.usage.turns[0]?.startedAt
157
158/* ---------- vectors (desktop) ---------- */
159
160type Segment = { key: string; value: number; paint: Paint }
161
162const svgDoc = (w: number, h: number, body: string) =>
163  `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${body}</svg>`
164
165/**
166 * A capsule bar: the segments as separate rounded pills with a hairline gap
167 * between them, on a translucent track; `tick` marks a fraction of the whole.
168 */
169const capsuleSvg = (segments: readonly Segment[], total: number, w: number, h: number, tick?: number) => {
170  const r = h / 2
171  const shown = segments.filter(s => s.value > 0)
172  const gap = shown.length > 1 ? 2 : 0
173  let x = 0
174  const pills = shown.map((s, i) => {
175    const width = Math.max(h, (s.value / Math.max(total, 1)) * w)
176    const drawn = Math.max(1, Math.min(w - x, width - (i < shown.length - 1 ? gap : 0)))
177    const rect = `<rect x="${x.toFixed(1)}" y="0" width="${drawn.toFixed(1)}" height="${h}" rx="${Math.min(r, drawn / 2).toFixed(1)}" fill="${s.paint.hex}"/>`
178    x += width
179    return rect
180  })
181  const track = `<rect x="0" y="0" width="${w}" height="${h}" rx="${r}" fill="${TRACK.hex}"/>`
182  const mark =
183    tick !== undefined && tick > 0 && tick < 1
184      ? `<rect x="${(tick * w - 0.75).toFixed(1)}" y="0" width="1.5" height="${h}" fill="${TICK_HEX}"/>`
185      : ''
186  return svgDoc(w, h, track + pills.join('') + mark)
187}
188
189/** Splits `width` cells among the segments by value; any value shows as at least one cell. */
190const allot = (segments: readonly Segment[], total: number, width: number) => {
191  const cells = segments.map(s => ({ ...s, n: total > 0 && s.value > 0 ? Math.max(1, Math.round((s.value / total) * width)) : 0 }))
192  const over = cells.reduce((a, c) => a + c.n, 0) - width
193  if (over > 0) {
194    const biggest = cells.reduce((a, c) => (c.n > a.n ? c : a), cells[0]!)
195    biggest.n = Math.max(1, biggest.n - over)
196  }
197  return cells
198}
199
200const bar = (
201  ctx: Ctx,
202  key: string,
203  segments: readonly Segment[],
204  total: number,
205  cells: number,
206  opts: { height?: number; tick?: number; alt: string },
207) => {
208  const { Box, Text } = ctx.T
209  const S = ctx.Svg
210  if (S) {
211    const w = cells * PX
212    const tall = opts.height ?? 6
213    return (
214      <Box key={key} width={cells} height={1} flexShrink={0} alignItems="center">
215        <S key={`${key}-svg`} source={capsuleSvg(segments, total, w, tall, opts.tick)} alt={opts.alt} width={w} height={tall} />
216      </Box>
217    )
218  }
219  // The terminal: one glyph per cell in its segment's theme color, the tick as a taller mark.
220  const glyphs: { ch: string; color: string }[] = []
221  for (const c of allot(segments, total, cells)) for (let i = 0; i < c.n; i++) glyphs.push({ ch: '━', color: color(ctx, c.paint) })
222  while (glyphs.length < cells) glyphs.push({ ch: '━', color: TRACK.key })
223  glyphs.length = cells
224  if (opts.tick !== undefined && opts.tick > 0 && opts.tick < 1) glyphs[Math.min(cells - 1, Math.round(opts.tick * cells))] = { ch: '╋', color: 'inactive' }
225  const runs = glyphs.reduce<{ ch: string; color: string; n: number }[]>((acc, g) => {
226    const last = acc.at(-1)
227    if (last && last.ch === g.ch && last.color === g.color) last.n += 1
228    else acc.push({ ...g, n: 1 })
229    return acc
230  }, [])
231  return (
232    <Box key={key} flexDirection="row" width={cells} flexShrink={0}>
233      {runs.map((r, i) => (
234        <Text key={`${key}-${i}`} color={r.color}>
235          {r.ch.repeat(r.n)}
236        </Text>
237      ))}
238    </Box>
239  )
240}
241
242/* ---------- rows ---------- */
243
244const keep = (items: readonly (RenderNode | null)[]) => items.filter((x): x is RenderNode => x !== null)
245
246/** A swatch, a name at the left; an amount and a share right-aligned in fixed columns. */
247const row = (ctx: Ctx, key: string, width: number, name: string, amount: RenderNode, opts: { paint?: Paint; share?: string; isDim?: boolean } = {}) => {
248  const { Box, Text } = ctx.T
249  return (
250    <Box key={key} flexDirection="row" width={width}>
251      <Box flexGrow={1} flexShrink={1}>
252        <Text wrap="truncate">
253          {opts.paint ? <Text color={color(ctx, opts.paint)}>■ </Text> : null}
254          <Text dimColor={opts.isDim}>{name}</Text>
255        </Text>
256      </Box>
257      <Box flexShrink={0} justifyContent="flex-end" paddingLeft={1}>
258        <Text>{amount}</Text>
259      </Box>
260      {opts.share !== undefined ? (
261        <Box width={6} flexShrink={0} justifyContent="flex-end">
262          <Text bold>{opts.share}</Text>
263        </Box>
264      ) : null}
265    </Box>
266  )
267}
268
269/** A section: a bold heading with a faint aside at its right, then as many of its rows as `limit` leaves room for. */
270const section = (
271  ctx: Ctx,
272  key: string,
273  title: string,
274  aside: string | undefined,
275  width: number,
276  limit: number,
277  rows: readonly (RenderNode | null)[],
278) => {
279  const { Box, Text } = ctx.T
280  return (
281    <Box key={key} flexDirection="column" width={width} flexShrink={0}>
282      <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
283        <Box flexShrink={0}>
284          <Text bold>{title}</Text>
285        </Box>
286        {aside ? (
287          <Text dimColor wrap="truncate">
288            {aside}
289          </Text>
290        ) : null}
291      </Box>
292      {keep(rows).slice(0, Math.max(0, limit - 1))}
293    </Box>
294  )
295}
296
297const share = (n: number, total: number) => {
298  if (total <= 0) return '—'
299  const p = (n / total) * 100
300  return p >= 99.95 ? '100%' : `${p.toFixed(1)}%`
301}
302
303const wholePct = (p: number | undefined) => (p === undefined ? '—' : `${Math.round(p)}%`)
304
305/* ---------- the meters: always shown ---------- */
306
307/**
308 * One meter as the usage panel draws it: the name at the left, a detail and the
309 * percentage at the right, a thin bar the full width beneath.
310 */
311type Meter = {
312  key: string
313  /** Longest first; the band takes the longest that fits. */
314  labels: readonly string[]
315  details: readonly string[]
316  pct: number | undefined
317  tick?: number
318  alt: string
319}
320
321const fitsBeside = (label: string, detail: string, pct: string, width: number) =>
322  label.length + (detail ? detail.length + 2 : 0) + pct.length + 2 <= width
323
324/** Whether the meter's full name and detail sit side by side at `width`, as on the app's panel. */
325const fitsInline = (m: Meter, width: number) => fitsBeside(m.labels[0] ?? '', m.details[0] ?? '', wholePct(m.pct), width)
326
327/**
328 * The longest label and detail that fit `width`. Beside each other: a readable
329 * name first, then the detail; the last-resort label ("5h") only when nothing
330 * else fits. With the detail on a line of its own: each the longest that fits.
331 */
332const fit = (m: Meter, width: number, isBelow: boolean) => {
333  const pct = wholePct(m.pct)
334  const names = m.labels.length > 1 ? m.labels.slice(0, -1) : m.labels
335  const tiny = m.labels.length > 1 ? m.labels.slice(-1) : []
336  if (isBelow) {
337    const label = [...names, ...tiny].find(l => fitsBeside(l, '', pct, width)) ?? m.labels.at(-1) ?? ''
338    return { label, detail: m.details.find(d => d.length <= width) ?? '', pct }
339  }
340  const tries = [names, tiny].flatMap(group => [
341    ...group.flatMap(label => m.details.map(detail => ({ label, detail }))),
342    ...group.map(label => ({ label, detail: '' })),
343  ])
344  const chosen = tries.find(t => fitsBeside(t.label, t.detail, pct, width)) ?? { label: m.labels.at(-1) ?? '', detail: '' }
345  return { ...chosen, pct }
346}
347
348const meter = (ctx: Ctx, m: Meter, width: number, isBelow: boolean) => {
349  const { Box, Text } = ctx.T
350  const { label, detail, pct } = fit(m, width, isBelow)
351  const p = m.pct ?? 0
352  return (
353    <Box key={m.key} flexDirection="column" width={width} flexShrink={0}>
354      <Box flexDirection="row" justifyContent="space-between" columnGap={1}>
355        <Text wrap="truncate">{label}</Text>
356        <Text>
357          <Text dimColor>{detail && !isBelow ? `${detail}  ` : ''}</Text>
358          <Text bold>
359            {pct}
360          </Text>
361        </Text>
362      </Box>
363      {bar(ctx, `${m.key}-bar`, [{ key: 'used', value: p, paint: P.ember }], 100, width, {
364        height: 5,
365        tick: m.tick,
366        alt: m.alt,
367      })}
368      {isBelow && detail ? (
369        <Text dimColor wrap="truncate">
370          {detail}
371        </Text>
372      ) : null}
373    </Box>
374  )
375}
376
377const meters = (ctx: Ctx): Meter[] => {
378  const m = ctx.measure
379  const c = compaction(ctx.measure, ctx.breakdown)
380  const out: Meter[] = [
381    c
382      ? {
383          key: 'context',
384          labels: ['Context window', 'Context'],
385          details: c.at ? [`${fmtTokens(c.left)} until auto-compact`, `${fmtTokens(c.left)} left`, fmtTokens(c.left)] : [`${fmtTokens(c.left)} left`, fmtTokens(c.left)],
386          pct: c.pct,
387          tick: c.tick,
388          alt: `Context ${wholePct(c.pct)} of ${fmtTokens(c.window)}, ${fmtTokens(c.left)} tokens ${c.at ? 'until compaction' : 'left'}`,
389        }
390      : {
391          // Before the breakdown arrives: the status line's figures.
392          key: 'context',
393          labels: ['Context window', 'Context'],
394          details: m?.contextTokens === undefined ? [] : [fmtTokens(m.contextTokens)],
395          pct: m?.contextPercent,
396          alt: `Context ${m?.contextPercent ?? 0}% full`,
397        },
398  ]
399  for (const l of m?.rateLimits ?? []) {
400    const labels = LIMIT_NAMES[l.kind] ?? [`${rateLabel(l.kind)} limit`, rateLabel(l.kind)]
401    out.push({
402      key: `rl-${l.kind}`,
403      labels,
404      details: fmtReset(l.kind, l.resetsAt, ctx.now),
405      pct: l.percentUsed,
406      alt: `${labels[0]} ${wholePct(l.percentUsed)} used`,
407    })
408  }
409  return out
410}
411
412const SESSION_W = 24
413
414/** Cost and time over tokens and cache, at the left of the meters; shorter forms where narrow. */
415const sessionBlock = (ctx: Ctx, width: number) => {
416  const { Box, Text } = ctx.T
417  const t = ctx.usage.totals
418  const started = sessionStart(ctx)
419  const cost = fmtUsd(ctx.measure?.costUsd)
420  const time = started ? fmtMs(Math.max(0, ctx.now - started)) : ''
421  const tokens = fmtTokens(sumTokens(t))
422  const cached = t.requests ? ` · ${fmtPct(cacheHitRate(t) * 100)} cached` : ''
423  const tail = [` tokens${cached}`, ' tokens', ' tok', ''].find(x => tokens.length + x.length <= width) ?? ''
424  return (
425    <Box key="session" flexDirection="column" width={width} flexShrink={0}>
426      <Text wrap="truncate">
427        <Text bold color={color(ctx, P.ember)}>
428          {cost}
429        </Text>
430        <Text dimColor>{time && cost.length + 2 + time.length <= width ? `  ${time}` : ''}</Text>
431      </Text>
432      <Text wrap="truncate">
433        <Text bold>{tokens}</Text>
434        <Text dimColor>{tail}</Text>
435      </Text>
436    </Box>
437  )
438}
439
440const menuOptions = (ctx: Ctx) => [
441  { key: 'copy', label: 'Copy JSON', hotkey: 'c', onPress: ctx.onCopy },
442  { key: 'details', label: ctx.isExpanded ? 'Hide details' : 'Show details', hotkey: 'd', onPress: ctx.onExpand },
443  ...(ctx.isTerminal
444    ? [{ key: 'status', label: ctx.isStatusShown ? 'Hide status line' : 'Show status line', hotkey: 's', onPress: ctx.onStatus }]
445    : []),
446  { key: 'hide', label: 'Hide band', hotkey: 'h', onPress: ctx.onHide },
447]
448
449/** "opus-5-5" as "Opus 5.5". */
450const modelName = (id: string) =>
451  shortModel(id).replace(/^([a-z]+)-(\d+)-(\d+)$/, (_, name: string, major: string, minor: string) => `${name[0]!.toUpperCase()}${name.slice(1)} ${major}.${minor}`)
452
453/** The title line: the band's name and model at the left, ⋯ and its open options at the right. */
454const titleRow = (ctx: Ctx) => {
455  const { Box, Text, Button } = ctx.T
456  return (
457    <Box key="title" flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={2} marginBottom={1}>
458      <Text wrap="truncate">
459        <Text bold>Session usage</Text>
460        <Text dimColor>{ctx.model ? `  ${modelName(ctx.model)}` : ''}</Text>
461      </Text>
462      <Box flexDirection="row" flexShrink={0} columnGap={1}>
463        {ctx.isMenuOpen
464          ? menuOptions(ctx).map(o => <Button key={o.key} label={o.label} hotkey={o.hotkey} onPress={() => o.onPress()} />)
465          : null}
466        {/* "⋯" is drawn one column wide by some terminals and laid out as two, which leaves a gap: plain dots there. */}
467        <Button key="menu" label={ctx.Svg ? '⋯' : '...'} hotkey="m" variant={ctx.isMenuOpen ? 'primary' : undefined} onPress={() => ctx.onMenu()} />
468      </Box>
469    </Box>
470  )
471}
472
473/**
474 * The session, then the meters sharing the rest of the width. Where a meter's
475 * full name and detail ("Session limit", "Resets in 2 hr 37 min") will not sit
476 * side by side, every meter takes its detail onto a line under its bar, given
477 * the row to spare: the rows the head takes are returned with it.
478 */
479const headRow = (ctx: Ctx, canGrow: boolean) => {
480  const { Box } = ctx.T
481  const all = meters(ctx)
482  const widths = split(ctx.cols - SESSION_W - GAP, all.length, GAP, 10)
483  const isBelow = canGrow && all.some((m, i) => m.details.length > 0 && !fitsInline(m, widths[i]!))
484  return {
485    rows: isBelow ? 3 : 2,
486    node: (
487      <Box key="head" flexDirection="row" columnGap={GAP}>
488        {sessionBlock(ctx, SESSION_W)}
489        {all.map((m, i) => meter(ctx, m, widths[i]!, isBelow))}
490      </Box>
491    ),
492  }
493}
494
495/* ---------- the detail: shown with Show details ---------- */
496
497/**
498 * What fills the window: the largest categories named, the rest as Other, the
499 * compaction reserve and what is free. Where rows are short the smaller
500 * categories fold into Other first, then the reserve goes, then more fold.
501 */
502const windowSection = (ctx: Ctx, width: number, limit: number) => {
503  const b = ctx.breakdown
504  const c = compaction(ctx.measure, ctx.breakdown)
505  if (!b || !c) return section(ctx, 'window', 'Context window', undefined, width, limit, [row(ctx, 'w-wait', width, 'Counted after the next reply', '', { isDim: true })])
506  const used = b.categories.filter(x => x.kind === 'used' && x.tokens > 0).sort((x, y) => y.tokens - x.tokens)
507  const reserve = b.categories.filter(x => x.kind === 'buffer').reduce((a, x) => a + x.tokens, 0)
508  const free = b.categories.filter(x => x.kind === 'free').reduce((a, x) => a + x.tokens, 0)
509  const room = limit - 2
510  let shown = Math.min(4, used.length)
511  let hasReserve = reserve > 0
512  const need = () => shown + (used.length > shown ? 1 : 0) + (hasReserve ? 1 : 0) + 1
513  while (need() > room && shown > 2) shown -= 1
514  if (need() > room) hasReserve = false
515  while (need() > room && shown > 1) shown -= 1
516  const named = used.slice(0, shown).map((x, i) => ({
517    key: x.name,
518    name: CATEGORY[x.name]?.name ?? x.name,
519    value: x.tokens,
520    paint: CATEGORY[x.name]?.paint ?? SPARE[i % SPARE.length]!,
521  }))
522  const rest = used.slice(shown).reduce((a, x) => a + x.tokens, 0)
523  const parts = rest > 0 ? [...named, { key: 'other', name: 'Other', value: rest, paint: P.stone }] : named
524  // The meter above gives the percentage.
525  return section(ctx, 'window', 'Context window', `${fmtTokens(c.tokens)} / ${fmtTokens(c.window)}`, width, limit, [
526    bar(ctx, 'window-bar', parts, c.window, width, { height: 5, tick: c.tick, alt: 'What fills the context window' }),
527    ...parts.map(p => row(ctx, `w-${p.key}`, width, p.name, fmtTokens(p.value), { paint: p.paint, share: share(p.value, c.window) })),
528    hasReserve ? row(ctx, 'w-reserve', width, 'Compaction buffer', fmtTokens(reserve), { paint: BUFFER, share: share(reserve, c.window), isDim: true }) : null,
529    row(ctx, 'w-free', width, 'Free space', fmtTokens(free), { paint: TRACK, share: share(free, c.window), isDim: true }),
530  ])
531}
532
533const tokenSection = (ctx: Ctx, width: number, limit: number) => {
534  const t: Tokens = ctx.usage.totals
535  const all = sumTokens(t)
536  return section(ctx, 'tokens', 'Tokens', `${fmtPct(cacheHitRate(t) * 100)} from cache`, width, limit, [
537    bar(ctx, 'token-bar', KINDS.map(k => ({ key: k.key, value: t[k.key], paint: k.paint })), all, width, { height: 5, alt: 'Tokens by kind' }),
538    ...KINDS.map(k => row(ctx, `k-${k.key}`, width, k.label, fmtTokens(t[k.key]), { paint: k.paint, share: share(t[k.key], all) })),
539  ])
540}
541
542/** The session's counts, then the running turn or else the last one. */
543const activitySection = (ctx: Ctx, width: number, limit: number) => {
544  const { Text } = ctx.T
545  const u = ctx.usage
546  const tools = Object.entries(u.byTool).sort((a, b) => b[1].calls - a[1].calls)
547  const calls = tools.reduce((a, [, s]) => a + s.calls, 0)
548  const errors = tools.reduce((a, [, s]) => a + s.errors, 0)
549  const agents = Object.keys(u.byAgent).filter(a => a !== 'main').length
550  const open = u.turns.at(-1)?.durationMs === undefined ? u.turns.at(-1) : undefined
551  const turn = open ?? u.turns.filter(t => t.durationMs !== undefined).at(-1)
552  const running = ctx.running.at(-1)
553  const which = open ? 'This turn' : 'Last turn'
554  return section(ctx, 'activity', 'Activity', plural(u.turns.length, 'turn'), width, limit, [
555    row(ctx, 'a-req', width, 'Requests', `${u.totals.requests}`),
556    row(
557      ctx,
558      'a-tools',
559      width,
560      'Tool calls',
561      <Text>
562        {`${calls}`}
563        {errors ? <Text color={color(ctx, P.red)}>{` · ${errors} failed`}</Text> : null}
564      </Text>,
565    ),
566    calls ? row(ctx, 'a-top', width, 'Most used', tools.slice(0, 3).map(([n]) => n.replace(/^mcp__/, '')).join(', '), { isDim: false }) : null,
567    agents ? row(ctx, 'a-agents', width, 'Subagents', `${agents}`) : null,
568    turn
569      ? row(ctx, 'a-turn', width, which, `${fmtMs(open ? ctx.now - open.startedAt : (turn.durationMs ?? 0))} · ${plural(turn.requests, 'request')}`)
570      : null,
571    turn
572      ? row(
573          ctx,
574          'a-turn-cost',
575          width,
576          'Turn tokens',
577          <Text>
578            {fmtTokens(sumTokens(turn.tokens))}
579            {turn.costUsd !== undefined ? <Text color={color(ctx, P.ember)}>{` · ${fmtUsd(turn.costUsd)}`}</Text> : null}
580          </Text>,
581        )
582      : null,
583    running ? row(ctx, 'a-run', width, 'Running', <Text color={color(ctx, P.slate)}>{`${running.tool} ${fmtMs(ctx.now - running.since)}`}</Text>) : null,
584  ])
585}
586
587const historyNote = (ctx: Ctx) => {
588  const { Text } = ctx.T
589  const bf = ctx.backfill
590  return bf && bf.status !== 'done' && bf.note ? (
591    <Text key="note" dimColor wrap="truncate">
592      {bf.note}
593    </Text>
594  ) : null
595}
596
597/* ---------- Copy JSON ---------- */
598
599/** A limit's name for the copy: "sessionLimit", "weeklyLimit". */
600const limitKey =(kind: string) => {
601  const [first = '', ...rest] = `${rateLabel(kind)} limit`.toLowerCase().split(/\s+/)
602  return first + rest.map(w => `${w[0]!.toUpperCase()}${w.slice(1)}`).join('')
603}
604
605/** A percentage to one place, as the details write their shares. */
606const pct1 = (n: number, total: number) => (total > 0 ? Math.round((n / total) * 1000) / 10 : 0)
607
608/**
609 * What the band shows, under the band's own names and from the same helpers it draws with,
610 * so the copy and the band never disagree: numbers kept whole, not written as "60.8k".
611 */
612export const bandFigures = (d: Pick<Ctx, 'now' | 'usage' | 'measure' | 'breakdown' | 'running' | 'model'>) => {
613  const { usage: u, measure: m, breakdown: b } = d
614  const c = compaction(m, b)
615  const started = sessionStart(d)
616  const t = u.totals
617  const all = sumTokens(t)
618  const used = (b?.categories ?? []).filter(x => x.kind === 'used' && x.tokens > 0).sort((x, y) => y.tokens - x.tokens)
619  const sum = (kind: string) => (b?.categories ?? []).filter(x => x.kind === kind).reduce((a, x) => a + x.tokens, 0)
620  const tools = Object.entries(u.byTool).sort((x, y) => y[1].calls - x[1].calls)
621  const open = u.turns.at(-1)?.durationMs === undefined ? u.turns.at(-1) : undefined
622  const turn = open ?? u.turns.filter(x => x.durationMs !== undefined).at(-1)
623  const running = d.running.at(-1)
624  const limits = Object.fromEntries(
625    (m?.rateLimits ?? []).map(l => [
626      limitKey(l.kind),
627      { percent: l.percentUsed, resetsAt: l.resetsAt ?? null, resets: fmtReset(l.kind, l.resetsAt, d.now)[0] ?? null },
628    ]),
629  )
630  return {
631    model: d.model ? modelName(d.model) : null,
632    costUsd: m?.costUsd ?? null,
633    durationMs: started === undefined ? null : Math.max(0, d.now - started),
634    tokens: all,
635    contextWindow: c
636      ? {
637          percent: Math.round(c.pct * 10) / 10,
638          tokens: c.tokens,
639          window: c.window,
640          autoCompactAt: c.at ?? null,
641          [c.at ? 'untilAutoCompact' : 'left']: c.left,
642          categories: used.map(x => ({ name: CATEGORY[x.name]?.name ?? x.name, tokens: x.tokens, percent: pct1(x.tokens, c.window) })),
643          compactionBuffer: { tokens: sum('buffer'), percent: pct1(sum('buffer'), c.window) },
644          freeSpace: { tokens: sum('free'), percent: pct1(sum('free'), c.window) },
645        }
646      : { percent: m?.contextPercent ?? null, tokens: m?.contextTokens ?? null },
647    ...limits,
648    tokenKinds: {
649      fromCachePercent: Math.round(cacheHitRate(t) * 1000) / 10,
650      ...Object.fromEntries(KINDS.map(k => [k.key, { tokens: t[k.key], percent: pct1(t[k.key], all) }])),
651    },
652    activity: {
653      turns: u.turns.length,
654      requests: t.requests,
655      toolCalls: tools.reduce((a, [, x]) => a + x.calls, 0),
656      failed: tools.reduce((a, [, x]) => a + x.errors, 0),
657      mostUsed: tools.slice(0, 3).map(([n]) => n.replace(/^mcp__/, '')),
658      subagents: Object.keys(u.byAgent).filter(a => a !== 'main').length,
659      [open ? 'thisTurn' : 'lastTurn']: turn
660        ? {
661            durationMs: open ? d.now - open.startedAt : (turn.durationMs ?? 0),
662            requests: turn.requests,
663            tokens: sumTokens(turn.tokens),
664            costUsd: turn.costUsd ?? null,
665          }
666        : null,
667      running: running ? { tool: running.tool, ms: d.now - running.since } : null,
668    },
669  }
670}
671
672/**
673 * The measure as the copy gives it: the limits under the band's names, and the context against
674 * the window the band's meter shows, so its percentage is the band's.
675 */
676export const copiedMeasure = (m: Measure | null, b: Breakdown | null) => {
677  if (!m) return null
678  const c = compaction(m, b)
679  return {
680    ...m,
681    ...(c ? { contextTokens: c.tokens, contextWindow: c.window, contextPercent: Math.round(c.pct * 10) / 10 } : {}),
682    rateLimits: m.rateLimits.map(({ kind, ...l }) => ({ limit: limitKey(kind), ...l })),
683  }
684}
685
686/* ---------- the band ---------- */
687
688/** One line for when the band has almost no room. */
689const summaryLine = (ctx: Ctx) => {
690  const { Text } = ctx.T
691  const m = ctx.measure
692  const comp = compaction(ctx.measure, ctx.breakdown)
693  return (
694    <Text wrap="truncate">
695      <Text bold color={color(ctx, P.ember)}>
696        {fmtUsd(m?.costUsd)}
697      </Text>
698      {`   ${fmtTokens(sumTokens(ctx.usage.totals))} tok`}
699      {comp ? `   context ${wholePct(comp.pct)} of ${fmtTokens(comp.window)}` : m?.contextPercent !== undefined ? `   context ${fmtPct(m.contextPercent)}` : ''}
700      {(m?.rateLimits ?? []).map(l => `   ${rateLabel(l.kind)} ${wholePct(l.percentUsed)}`).join('')}
701    </Text>
702  )
703}
704
705const COL_GAP = 4
706
707/**
708 * About how many rows a tree takes: a Text or a string one, a Box its height when it sets one, else its
709 * children stacked or side by side, with its margins and padding. Nothing drawn is none. Used for the
710 * bands of other plugins drawn above this one, so this band gives them their rows.
711 */
712export const rowsOf = (node: unknown): number => {
713  if (node === null || node === undefined || typeof node === 'boolean' || node === '') return 0
714  if (typeof node === 'string' || typeof node === 'number') return 1
715  if (Array.isArray(node)) return node.reduce((n: number, child) => n + rowsOf(child), 0)
716  if (typeof node !== 'object') return 0
717  const { type, props = {}, children = [] } = node as { type?: string; props?: Record<string, unknown>; children?: unknown[] }
718  // The engine's own answer beneath every band, { type: 'engine' }: it draws nothing here.
719  if (type === 'engine') return 0
720  if (type !== 'Box') return 1
721  const num = (key: string) => (typeof props[key] === 'number' ? (props[key] as number) : 0)
722  const kids = children.flat()
723  const inner =
724    typeof props.height === 'number'
725      ? props.height
726      : props.flexDirection === 'row'
727        ? kids.reduce((n: number, child) => Math.max(n, rowsOf(child)), 0)
728        : kids.reduce((n: number, child) => n + rowsOf(child), 0)
729  if (inner === 0) return 0
730  return inner + num('marginTop') + num('marginBottom') + num('paddingTop') + num('paddingBottom') + 2 * (num('marginY') + num('paddingY'))
731}
732
733export const band = (ctx: Ctx): RenderElement => {
734  const { Box } = ctx.T
735  if (ctx.maxRows < 4) return <Box flexDirection="column">{summaryLine(ctx)}</Box>
736
737  // The title and its blank line, then the meters; the details get the rows left.
738  let head = headRow(ctx, ctx.maxRows >= 5)
739  // Asked for, the details always show: the meters give up their row beneath first,
740  // then the band runs taller than its rows and the engine scrolls it.
741  if (ctx.isExpanded && ctx.maxRows - 2 - head.rows < 5) head = headRow(ctx, false)
742  const kept: RenderNode[] = [titleRow(ctx), head.node]
743  let left = ctx.maxRows - 2 - head.rows
744  if (ctx.isExpanded) {
745    left = Math.max(left, 5)
746    const inner = ctx.cols
747    // Three sections side by side where they have room for their rows, else two with Activity beneath.
748    const isWide = Math.floor((inner - COL_GAP * 2) / 3) >= 26
749    const colW = split(inner, isWide ? 3 : 2, COL_GAP, 24)
750    const limit = Math.min(9, left - 1)
751    const sections = [windowSection(ctx, colW[0]!, limit), tokenSection(ctx, colW[1]!, limit)]
752    if (isWide) sections.push(activitySection(ctx, colW[2]!, limit))
753    kept.push(
754      <Box key="sections" flexDirection="row" columnGap={COL_GAP} marginTop={1}>
755        {sections}
756      </Box>,
757    )
758    left -= 1 + limit
759    if (!isWide && left >= 3) {
760      const below = Math.min(8, left - 1)
761      kept.push(
762        <Box key="activity" marginTop={1}>
763          {activitySection(ctx, inner, below)}
764        </Box>,
765      )
766      left -= 1 + below
767    }
768    const note = historyNote(ctx)
769    if (note && left >= 1) kept.push(note)
770  }
771  return <Box flexDirection="column">{kept}</Box>
772}
773
774/**
775 * The status line's parts, the cost first: what the band shows, on one line,
776 * for a terminal that keeps the line and hides the band. A part's note goes in parentheses after it.
777 * `level` condenses it for a narrow terminal: 0 is everything in full; 1 shortens the notes;
778 * 2 shortens the names as well; 3 drops the notes; 4 drops the tokens too.
779 */
780const statusParts = (u: UsageModel, m: Measure | null, b: Breakdown | null, now: number, level = 0) => {
781  const parts: { text: string; note?: string }[] = [
782    // Written as the band writes it.
783    { text: fmtUsd(m?.costUsd) },
784  ]
785  if (level < 4) parts.push({ text: `${fmtTokens(sumTokens(u.totals))} tokens` })
786  const named = (full: string, short: string, pct: string) => `${level < 2 ? full : short}: ${pct}`
787  const noted = (full: string | undefined, short: string | undefined) => (level >= 3 ? undefined : level === 0 ? full : short ?? full)
788  // Against the same window as the band's Context window meter, once the breakdown has counted it.
789  const c = compaction(m, b)
790  if (c) {
791    const left = `${fmtTokens(c.left)} left`
792    parts.push({ text: named('Context window', 'Context', wholePct(c.pct)), note: noted(c.at ? `${fmtTokens(c.left)} until auto-compact` : left, left) })
793  } else if (m?.contextPercent !== undefined) parts.push({ text: named('Context window', 'Context', wholePct(m.contextPercent)) })
794  for (const l of m?.rateLimits ?? []) {
795    const [reset, span] = fmtReset(l.kind, l.resetsAt, now)
796    const lower = reset && `${reset[0]!.toLowerCase()}${reset.slice(1)}`
797    // Named in full; the band's "Weekly · all models" would read as two parts between the line's dots.
798    parts.push({ text: named(`${rateLabel(l.kind)} limit`, rateLabel(l.kind), wholePct(l.percentUsed)), note: noted(lower, span ?? lower) })
799  }
800  return parts
801}
802
803const joinParts = (parts: { text: string; note?: string }[]) => parts.map(p => (p.note ? `${p.text} (${p.note})` : p.text)).join(' · ')
804
805/** The fullest wording that fits `width` cells; the most condensed, cut at the edge, where none does. */
806const fitParts = (u: UsageModel, m: Measure | null, b: Breakdown | null, now: number, width = Infinity) => {
807  let parts = statusParts(u, m, b, now)
808  for (let level = 1; level <= 4 && joinParts(parts).length > width; level++) parts = statusParts(u, m, b, now, level)
809  return parts
810}
811
812/** The one-line summary the status line shows, condensed to fit `width` cells when given. */
813export const statusText = (u: UsageModel, m: Measure | null, b: Breakdown | null, now: number, width?: number) =>
814  joinParts(fitParts(u, m, b, now, width))
815
816/** The same summary drawn for the terminal: the cost in the band's orange, the dots and notes gray, the rest in the text color. */
817export const statusLine = (T: Base, u: UsageModel, m: Measure | null, b: Breakdown | null, now: number, width?: number) => {
818  const { Text } = T
819  const [cost, ...rest] = fitParts(u, m, b, now, width)
820  return (
821    <Text wrap="truncate">
822      <Text color={P.ember.hex}>{cost!.text}</Text>
823      {rest.map((p, i) => [
824        <Text key={`dot${i}`} dimColor>{' · '}</Text>,
825        p.text,
826        p.note ? <Text key={`note${i}`} dimColor>{` (${p.note})`}</Text> : null,
827      ])}
828    </Text>
829  )
830}
831
types/index.d.ts 120 lines
1export type Tokens = {
2  input: number
3  output: number
4  cacheRead: number
5  cacheWrite: number
6}
7
8export type Bucket = Tokens & { requests: number }
9
10export type RequestRow = {
11  t: number
12  model: string
13  agentId?: string
14  tokens: Tokens
15  stop?: string | null
16  isBackfill?: boolean
17}
18
19export type TurnRow = {
20  id: string
21  startedAt: number
22  prompt: string
23  durationMs?: number
24  requests: number
25  tokens: Tokens
26  tools: number
27  costUsd?: number
28  costAtStart?: number
29  reason?: string
30  isBackfill?: boolean
31}
32
33export type ToolStat = {
34  calls: number
35  errors: number
36  denied: number
37  totalMs: number
38  maxMs: number
39  timed: number
40}
41
42export type CompactionRow = {
43  t: number
44  trigger: string
45  before?: number
46  after?: number
47}
48
49export type RateLimitRow = { kind: string; percentUsed: number; resetsAt?: string }
50
51export type Measure = {
52  at: number
53  startedAt?: number
54  costUsd?: number
55  contextTokens?: number
56  contextWindow: number
57  contextPercent?: number
58  rateLimits: RateLimitRow[]
59}
60
61export type BreakdownCategory = {
62  name: string
63  tokens: number
64  color: string
65  kind: string
66}
67
68export type Breakdown = {
69  at: number
70  detail: 'summary' | 'full'
71  model: string
72  totalTokens: number
73  rawMaxTokens: number
74  percentage: number
75  autoCompactThreshold?: number
76  isAutoCompactEnabled: boolean
77  categories: BreakdownCategory[]
78  memoryFiles: { path: string; type: string; tokens: number }[]
79  mcpTools: { name: string; serverName: string; tokens: number; isLoaded: boolean }[]
80  skills: { name: string; tokens: number }[]
81}
82
83export type RunningTool = { id: string; tool: string; since: number; agentId?: string }
84
85export type Backfill = {
86  status: 'pending' | 'done' | 'partial' | 'unavailable'
87  note?: string
88  sessionId?: string
89  liveSince: number
90  /** The parser version that read the history; an older one is read again. */
91  version?: number
92}
93
94export type UsageModel = {
95  totals: Bucket
96  byModel: Record<string, Bucket>
97  byAgent: Record<string, Bucket>
98  byTool: Record<string, ToolStat>
99  requests: RequestRow[]
100  turns: TurnRow[]
101  compactions: CompactionRow[]
102}
103
104declare module 'claude-code' {
105  interface PluginState {
106    'usage-mod': {
107      usage: UsageModel
108      measure: Measure | null
109      breakdown: Breakdown | null
110      isExpanded: boolean
111      isHidden: boolean
112      isMenuOpen: boolean
113      isStatusShown: boolean
114      running: RunningTool[]
115      backfill: Backfill | null
116      model: string
117    }
118  }
119}
120