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

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.
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:
| Meter | Shows | Example |
|---|---|---|
| Context window | How full this chat's context is, and how much room is left before it's compacted | 75k until auto-compact · 64% |
| Session limit | How much of your plan's 5-hour limit you've used, and the time left until it resets | Resets in 2 hr 37 min · 13% |
| Weekly · all models | How much of your weekly limit you've used, and the day and time it resets, in your local time | Resets 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:
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.
The band's figures are meant to match the app's usage panel exactly:
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.
m) opens the band's menu. In the terminal the button reads ...:d)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.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.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.
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.
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.
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.
| Path | Contents |
|---|---|
hooks/register.tsx | Hooks: collecting usage, reading history, checking limits, drawing the band |
hooks/collect.ts | Pure functions that turn events and transcripts into usage totals |
hooks/views.tsx | The band's layout for the desktop app and the terminal |
types/index.d.ts | Types for the values the plugin keeps between reloads |
tests/register.test.ts | Tests |
.claude-plugin/plugin.json | The 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:
version in plugins/usage-mod/.claude-plugin/plugin.json.hooks/register.tsx 929 lines1import { 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}
929hooks/collect.ts 355 lines1import 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, ' ')
355hooks/views.tsx 831 lines1import 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}
831types/index.d.ts 120 lines1export 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