See what your next message costs: a live band above the prompt with the prompt-cache countdown, the re-warm price, session cost, context and your 5h/7d limits.

One calm row above the Claude Code prompt that answers: is my cache still warm, what is this session costing, and am I close to a limit?
◷ cache 52m $3.19 Σ 225k ◔ ██░░░░ 76k / 200k 5h ░░░░░░ 4% │ ↻ 3h 00m 7d ██░░░░ 30% │ ↻ 2d 19h ▿
On the desktop app's Code tab the glyphs are small icons, and the bars are drawn as SVG meters:

See the changelog for what changed in each version. Found something off? Open an issue.

| Chip | Shows | Turns amber when |
|---|---|---|
| Cache | A battery that drains as the cache ages, and the time until it goes cold; cache warm while Claude is working, cache warming before a new conversation's first reply, cache – when the band loaded mid-conversation and hasn't measured it yet | Its last minute: 0:47 left · re-warm ~$0.52 |
| Cost | The session's total so far | Never |
| Tokens | Every token this conversation used | Never |
| Context | How full the conversation is toward auto-compaction (63% full), or of the model window when compaction is off | Near compaction: 95% full · compacts in ~8k. Without auto-compaction: 80% (!), 95% (!!) |
| 5h | Your 5-hour limit: usage on a bar with a thumb at the fill's end, and the reset | 80%, or when your pace would fill it before it resets: full in ~40m |
| 7d | Your weekly limit, the same way | 80% |
A bar carries one number, the one beside it, with a thumb where its fill ends. Pace is in words, on the chip when it matters and in the Limits card always. Once a window's reset time has passed, its chip says reset until the next reply brings a fresh reading.
Amber means act soon. The 5h and 7d chips are tinted green and purple so you can tell them apart; that's a label, not a warning. Nothing is ever red: a cold cache or a full meter is a price, not an error. Colour is never the only signal, since escalation always adds words or ! / !!.
Hover any chip for a one-line explanation. ▿ opens the expanded view. It starts with a line that says where you are:
~/workspace/claude-mod · on main 3 changed · ↑2 ↓1
| Piece | Shows |
|---|---|
| Path | The project, home as ~, its folder in bold |
| Branch | The git branch, or detached at <commit> |
| Worktree | worktree of <repo> in a linked worktree |
| Changes | Files with uncommitted changes, or clean |
| Ahead / behind | Commits to push (↑) and to pull (↓), only when there are any |
On the desktop each piece has an icon and a hover explanation. As the line narrows, the path and branch shorten and the extras drop, least important first. When the band is too short to give it a row of its own, it moves to the footer, beside the buttons, in place of the hint. Outside a repository, or if git is missing or slow, it shows the path alone. git is read when the session starts, after each of your messages and when you open the cards, never while the band draws.
Then come four cards with every fact labelled:
| Card | Shows |
|---|---|
| Cache | Time left on a bar, what a re-warm would cost if it went cold, what the cache has saved, the hit rate, how long it lasts idle, unexpected rebuilds |
| Spend | The session total, a bar of the token split with its legend (input, output, cache reads), your last message |
| Context | How full toward auto-compaction, tokens in context, where compaction runs, room left, the model window |
| Limits | The window closest to its limit, then each window (5h, 7d, a gateway's spend limit) with its bar and value, its reset and its pace in words: on pace for ~50% or full before reset |
The cards sit four across when they fit, else two by two, each line sharing its width equally; on the desktop each has a visible border. A card's title and headline share its first line, and when the band is short of rows each card drops its bar first (the chips already show it), then its least important facts, so the view never scrolls the buttons away. Expanding never changes the chip row; only the toggle's icon turns from ▿ to ▵. Below the cards, Collapse (key c) closes them and Hide band (key h) hides the band; /usage-band brings it back.
The row stays on one line. As it narrows, pieces give way in this order: the tokens chip, the 7d reset time, the 5h reset time, the context meter, a calm 7d chip, the limit bars, long wording, a calm context chip, then a calm 5h chip. An amber chip keeps its words longest; its reset time is the very last thing to go. Below about 55 columns, with several chips amber at once, the end of the row is clipped rather than wrapped.
Claude Code caches the conversation server-side. While the cache is warm, re-reading the conversation costs a tenth of the normal input price or less, depending on the model (a twentieth on Opus 5.5 and Sonnet 5.5). It stays warm for a lifetime (5 minutes or an hour) counted from the last request. Go idle past that, and the next message rebuilds the whole conversation at the cache-write price.
The band never suggests sending a message to keep the cache warm. That would mean burning tokens to avoid burning tokens.
A compaction or a model switch rebuilds the cache on purpose, so neither counts as an unexpected rebuild.
A session the band hasn't seen a reply in yet, because you reopened it or the band reloaded, still says what the cache is doing. It recalls when the session's last reply was and what a token costs on its model:
Past the cache's lifetime it shows cache cold · next message ~$2.34, the cost of writing the whole context to the cache again. Within it, the countdown runs from the last reply. With no price known for the model yet, it names the tokens instead. With nothing to recall, it stays at cache –.
No pricing table is reachable from a mod, so the rate is solved from the session's own bill. Each kind of token costs a fixed multiple of base input (a cache write 1.25×, output 5×, a cache read 0.1×, or 0.05× on Opus 5.5 and Sonnet 5.5 and 0.025× on Fable and Mythos 5.1, per Anthropic's pricing), which leaves one unknown:
cost = r × (uncached + 1.25×written + read multiple×read + 5×output)
Solve for r, then price the re-warm as a cache write of the whole conversation. After a compaction, it prices the summary instead. It's always shown with ~.
/clear, a resume or a reload, the rate is solved from what the current conversation has spent, not the session's whole ledger.The band records your 5-hour usage as it moves. Once it has at least 10 minutes and a 2-point rise to go on, it projects when you'd hit 100%, using the last 30 minutes. If that's before the limit resets, the chip says so. The limit is shared across all your Claude use, so the pace includes your other sessions and devices, and /clear keeps it. The projection hides once its newest reading is more than 15 minutes old.
A toast speaks where a chip turns amber:
Each fires once per crossing. The 5h chip also turns amber when your pace would fill it before it resets; that has no toast, since the projection moves with every reading.
CC_BAND_APPEARANCE=dark # default: filled pills tuned for dark themes
CC_BAND_APPEARANCE=light # filled pills tuned for light themes
CC_BAND_APPEARANCE=plain # no backgrounds; every colour a theme key
NO_COLOR forces plain. plain has no hover cards and no SVG icons, since the expanded cards carry the same facts.
| Command | Effect |
|---|---|
/usage-band | Toggle visibility |
/usage-band more / less | Open or close the cards |
/usage-band show / hide | Set visibility explicitly |
The default lifetime depends on billing: an hour on a subscription within plan usage, five minutes on usage credits or an API key. A mod can't read which applies, so the band assumes an hour and says · assumed. If it then sees the cache rebuild after a gap longer than five minutes, with the same model, it corrects itself to 5m.
To remove the guess, set one of:
CLAUDE_CODE_PROMPT_CACHE_TTL=5m or 1hFORCE_PROMPT_CACHING_5M=1ENABLE_PROMPT_CACHING_1H=1claude plugin marketplace add HMarzban/claude-mod
claude plugin install session-usage-band@hossein-mods
It needs Claude Code with mods (function-hooks plugins), and was tested on 2.1.295 in the terminal and the desktop app's bundled 2.1.289. It draws in the terminal and the desktop app's Code tab, which are the surfaces with a band above the prompt. WSL sessions don't load plugins. The repository README covers updating and uninstalling.
claude plugin validate plugins/session-usage-band
claude plugin test plugins/session-usage-band
npx -y -p typescript@5 tsc -p plugins/session-usage-band
CONTRIBUTING.md has the full loop and the rules the code follows.
Only hooks/register.tsx touches the engine ($). It reads a snapshot for hooks/band.tsx, a pure drawing function. The cache model, insights, memory, formatting, workspace and palettes are plain modules. The tests drive the band through the engine's test kit, and test the plain modules directly.
Seen a wrong number, a chip that wraps, or something you'd want the band to show? Report a bug or suggest a feature; a screenshot of the band helps most. Pull requests are welcome too.
hooks/register.tsx 442 lines1// The hooks: the one place that touches `$`. Each reads what the engine knows
2// into the pure modules, and ui.render hands drawBand a snapshot of them.
3
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register, Timer } from 'claude-code'
6import { drawBand } from './band'
7import {
8 cache,
9 cacheView,
10 msLeft,
11 noteCompaction,
12 noteConversationStart,
13 noteLedger,
14 noteLoad,
15 noteRecall,
16 modelName,
17 noteBilledModel,
18 notePriceModel,
19 pricedModel,
20 pinTtl,
21 ratePerToken,
22 recordResponse,
23 resetCache,
24 resetConversation,
25 resolveTtl,
26} from './cache'
27import { COMPACT_NEAR, SEVERE_AT, WARN_AT, contextUsed, fmtCountdown, fmtEta, fmtTokens } from './format'
28import {
29 fiveHourEtaMs,
30 insights,
31 noteFiveHour,
32 noteTurnEnd,
33 noteTurnStart,
34 escalate,
35 resetConversationInsights,
36 resetInsights,
37} from './insights'
38import { DARK, resolvePalette } from './palette'
39import type { Palette } from './palette'
40import {
41 RATES_KEY,
42 READ_LIMIT,
43 SESSIONS_KEY,
44 asRates,
45 asSessions,
46 lastReplyAt,
47 lastReplyModel,
48 rateFromTranscript,
49 rememberReply,
50 transcriptPath,
51} from './memory'
52import { GIT_DIRS_ARGV, GIT_STATUS_ARGV, homeRelative, parseGitState, splitPath } from './workspace'
53import type { Workspace } from './workspace'
54
55const isHidden = atom({ plugin: 'session-usage-band', key: 'isHidden' } as const, false)
56const isExpanded = atom({ plugin: 'session-usage-band', key: 'isExpanded' } as const, false)
57
58const FIVE_HOUR = 'five_hour'
59const SEVEN_DAY = 'seven_day'
60
61/** How much of a transcript's end to read: room for a long last reply. */
62const TAIL_BYTES = 1024 * 1024
63
64/** Long enough for a large repository's status or a transcript's tail,
65 * short enough that a hung command never holds a read open for long. */
66const PROCESS_TIMEOUT_MS = 3000
67
68/** What /usage-band answers. */
69const REPLY = {
70 shown: 'Usage band shown.',
71 shownFirst: 'Usage band shown. /usage-band more shows every fact.',
72 hidden: 'Usage band hidden. /usage-band shows it again.',
73 expanded: 'Usage band expanded.',
74 collapsed: 'Usage band collapsed.',
75 usage: 'Usage: /usage-band [more | less | show | hide]',
76} as const
77
78/** Everything the band keeps between hooks, in one place. A reload starts it
79 * over with the module; session.start resets the rest. */
80const band: {
81 palette: Readonly<Palette>
82 /** Where auto-compaction runs, as the context breakdown last said; read
83 * after each turn, not on every redraw. Undefined when off or unknown. */
84 compactAt: number | undefined
85 /** The breakdown said auto-compaction is off. */
86 autoCompactOff: boolean
87 /** Where the session is: its project, home-relative, and git there. Read
88 * between redraws, never while drawing, since git takes a process. */
89 workspace: Workspace | undefined
90 /** Reads begun, so one that ends after a newer one never overwrites it. */
91 reads: number
92 /** What the band last drew, so the timer repaints only when it would change. */
93 lastPaintKey: string
94 /** Each toast's level reached, so it speaks once per crossing. */
95 warned: Map<string, number>
96 tick: Timer | undefined
97} = {
98 palette: DARK,
99 compactAt: undefined,
100 autoCompactOff: false,
101 workspace: undefined,
102 reads: 0,
103 lastPaintKey: '',
104 warned: new Map(),
105 tick: undefined,
106}
107
108/** A transcript's end, where its last reply and cost record are: its last
109 * megabyte by `tail`, whatever its size; failing that, the whole file if it
110 * is small enough to read. */
111const transcriptEnd = async ($: EngineInterface, path: string): Promise<string | undefined> => {
112 const tail = await $.process
113 .run(['tail', '-c', String(TAIL_BYTES), path], { timeoutMs: PROCESS_TIMEOUT_MS })
114 .catch(() => undefined)
115 if (tail?.exitCode === 0) return tail.stdout
116 const stat = await $.fs.stat(path).catch(() => undefined)
117 if (stat === undefined || stat.size > READ_LIMIT) return undefined
118 const text = await $.fs.read(path).catch(() => undefined)
119 return typeof text === 'string' ? text : undefined
120}
121
122/** Recalls when this session last had a reply, and what a token costs on
123 * its model: the band's own memory first; for a session from before the
124 * band, its transcript's last reply and cost record, if it is small enough
125 * to read. Read once at load, and only those two facts kept. Never throws:
126 * unknown stays unknown. */
127const recallLastReply = async ($: EngineInterface): Promise<void> => {
128 try {
129 const id = await $.session.id()
130 const model = modelName(await $.session.model())
131 const rates = asRates(await $.store.get(RATES_KEY))
132 let lastAt = asSessions(await $.store.get(SESSIONS_KEY))[id]?.lastAt
133 let rate = rates[model] ?? null
134 if (lastAt === undefined || rate === null) {
135 const home = await $.env.get('HOME')
136 const transcript = home ? await transcriptEnd($, transcriptPath(home, await $.session.root(), id)) : undefined
137 if (transcript !== undefined) {
138 lastAt ??= lastReplyAt(transcript)
139 // /model may name an alias; the last reply names the model it was billed under.
140 const billed = lastReplyModel(transcript)
141 if (billed !== undefined) noteBilledModel(billed)
142 rate ??= billed === undefined ? rateFromTranscript(transcript, model) : (rates[billed] ?? rateFromTranscript(transcript, billed))
143 }
144 }
145 if (lastAt !== undefined) noteRecall(lastAt, rate)
146 } catch {
147 // nothing to recall
148 }
149}
150
151/** Remembers this session's last reply and the rate its bill solves to, for
152 * when it is reopened or the band reloads. */
153const rememberTurn = async ($: EngineInterface, costNow: number | undefined): Promise<void> => {
154 if (cache.requests === 0) return
155 try {
156 const id = await $.session.id()
157 await $.store.set(SESSIONS_KEY, rememberReply(asSessions(await $.store.get(SESSIONS_KEY)), id, cache.lastAt))
158 const rate = ratePerToken(costNow)
159 if (rate !== null) await $.store.set(RATES_KEY, { ...asRates(await $.store.get(RATES_KEY)), [pricedModel() ?? modelName(await $.session.model())]: rate })
160 } catch {
161 // memory is a convenience; the band works without it
162 }
163}
164
165/** Reads the project and git into `band.workspace`, then redraws. It never throws:
166 * outside a repository, or with git missing or slow, the band shows the
167 * path alone. Callers don't wait on it. */
168const readWorkspace = async ($: EngineInterface): Promise<void> => {
169 const mine = ++band.reads
170 try {
171 const root = await $.session.root()
172 const home = await $.env.get('HOME')
173 const run = (argv: readonly string[]): Promise<string | undefined> =>
174 $.process.run(argv, { cwd: root, timeoutMs: PROCESS_TIMEOUT_MS }).then(
175 r => (r.exitCode === 0 ? r.stdout : undefined),
176 () => undefined,
177 )
178 const [status, dirs, repo] = await Promise.all([
179 run(GIT_STATUS_ARGV),
180 run(GIT_DIRS_ARGV),
181 $.session.repo().catch(() => null),
182 ])
183 if (mine !== band.reads) return
184 const path = homeRelative(root, home)
185 const last = band.workspace
186 band.workspace = {
187 path,
188 // A status that failed or timed out keeps the last good reading of the
189 // same project, so a slow repository doesn't flicker to the path alone.
190 git: status === undefined ? (last?.path === path ? last.git : undefined) : parseGitState(status, dirs ?? ''),
191 // A linked worktree's main repository, by its folder's name.
192 repoName: repo === null ? undefined : splitPath(repo.root).name,
193 }
194 $.ui.invalidate('ui.render')
195 } catch {
196 // keep the last reading
197 }
198}
199
200/** What the session has cost so far, if the host keeps a ledger; undefined
201 * when it has none, or the read fails. */
202const ledgerUsd = async ($: EngineInterface): Promise<number | undefined> =>
203 (await $.session.usage().catch(() => undefined))?.cost?.usd
204
205type BandCommand = 'toggle' | 'more' | 'less' | 'show' | 'hide'
206
207const parseCommand = (args: string): BandCommand | undefined => {
208 const word = args.trim().toLowerCase()
209 if (word === '') return 'toggle'
210 return word === 'more' || word === 'less' || word === 'show' || word === 'hide' ? word : undefined
211}
212
213
214export const register: Register = on => {
215
216 on('session.start', async ($, e, next) => {
217 resetCache()
218 resetInsights()
219 band.warned.clear()
220 band.lastPaintKey = ''
221 band.workspace = undefined
222 band.reads++ // any read still out began before this load
223 notePriceModel(await $.session.model().catch(() => undefined))
224 noteLoad(await ledgerUsd($))
225 void readWorkspace($)
226 // Loaded mid-conversation, the band has seen no reply: recall the last.
227 if (!cache.knownFresh) await recallLastReply($)
228
229 band.palette = resolvePalette((await $.env.get('CC_BAND_APPEARANCE'))?.toLowerCase(), await $.env.get('NO_COLOR'))
230 const pinned = resolveTtl({
231 force5m: await $.env.get('FORCE_PROMPT_CACHING_5M'),
232 chosen: await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'),
233 enable1h: await $.env.get('ENABLE_PROMPT_CACHING_1H'),
234 })
235 if (pinned !== undefined) pinTtl(pinned)
236
237 // One timer, repainting only when the drawing would differ: every minute
238 // (the battery, the reset countdowns, the pace tick), and every second of
239 // the cache's last ten minutes, when its countdown shows seconds.
240 band.tick?.cancel()
241 band.tick = $.clock.every(1000, () => {
242 void (async () => {
243 const now = await $.clock.now()
244 const left = msLeft(now)
245 const eta = fiveHourEtaMs(now)
246 const key = `${Math.floor(now / 60_000)}|${fmtCountdown(left)}|${left > 0}|${eta === null ? '-' : fmtEta(eta)}`
247 if (key !== band.lastPaintKey) {
248 band.lastPaintKey = key
249 $.ui.invalidate('ui.render')
250 }
251 })().catch(() => undefined)
252 })
253
254 $.command.register({
255 name: 'usage-band',
256 description: 'Show, hide, expand or collapse the session usage band',
257 })
258 return next(e)
259 })
260
261 // /clear and resume end the conversation but not the process, and no
262 // session.start follows, so the next conversation starts from here.
263 on('session.end', async ($, e, next) => {
264 resetConversation((await ledgerUsd($)) ?? 0)
265 // The context warning is this conversation's; the 5-hour one is the
266 // account's, and /clear changes nothing about it.
267 resetConversationInsights()
268 band.warned.delete('context')
269 band.lastPaintKey = ''
270 // A resume may be another project.
271 void readWorkspace($)
272 $.ui.invalidate('ui.render')
273 return next(e)
274 })
275
276 on('turn.start', async ($, e, next) => {
277 // /model may have switched what the session's tokens are priced at.
278 notePriceModel(await $.session.model().catch(() => undefined))
279 const cost = await ledgerUsd($)
280 noteTurnStart(e.turnId, cost)
281 if (cost !== undefined) noteConversationStart(cost)
282 return next(e)
283 })
284
285 // Main-loop turns only: a subagent's run is part of the turn that spawned it.
286 on('turn.complete', async ($, e, next) => {
287 const result = await next(e)
288 if (e.agentId === undefined) {
289 const cost = await ledgerUsd($)
290 noteTurnEnd(e.turnId, cost)
291 void rememberTurn($, cost)
292 // A turn may have switched branch, committed or moved the session.
293 void readWorkspace($)
294 $.ui.invalidate('ui.render')
295 }
296 return result
297 })
298
299 on('turn.step', async function* ($, e, next) {
300 // The cache's TTL runs from when the request is sent, not when its reply ends.
301 const sentAt = await $.clock.now()
302 const result = yield* next(e)
303 const isMain = e.agentId === undefined
304 if (result?.usage) {
305 if (isMain && result.usage.model) noteBilledModel(result.usage.model)
306 recordResponse(result.usage, sentAt, isMain, result.usage.model)
307 const cost = await ledgerUsd($)
308 if (cost !== undefined) noteLedger(cost)
309 $.ui.invalidate('ui.render')
310 }
311 if (isMain && result?.stopReason === 'compaction') noteCompaction(undefined)
312 return result
313 })
314
315 // A compaction of the main conversation rebuilds the cache on purpose.
316 on('session.compact', async ($, e, next) => {
317 const result = await next(e)
318 if (e.agentId === undefined && e.trigger !== 'precompute' && result.skip === undefined) {
319 noteCompaction(result.tokensAfter)
320 $.ui.invalidate('ui.render')
321 }
322 return result
323 }).catch(($, e, next) => next(e)) // a failure here must never stop a compaction
324
325 on('session.measure', async ($, e, next) => {
326 const now = await $.clock.now()
327 const note = (key: string, frac: number, levels: readonly number[], text: (pct: number) => string): void => {
328 const { level, speak } = escalate(band.warned.get(key) ?? 0, frac, levels)
329 band.warned.set(key, level)
330 if (speak) $.ui.toast(text(Math.round(frac * 100)))
331 }
332
333 for (const limit of e.rateLimits) {
334 if (limit.kind !== FIVE_HOUR) continue
335 noteFiveHour(now, limit.percentUsed, limit.resetsAt)
336 note(FIVE_HOUR, limit.percentUsed / 100, [WARN_AT, SEVERE_AT], pct => `You've used ${pct}% of your 5-hour limit.`)
337 }
338
339 // Local and token-free, but it can fail; the last answer stands until a new one.
340 try {
341 const breakdown = (await $.session.usage({ breakdown: 'summary' })).context.breakdown
342 if (breakdown !== undefined) {
343 // On without a threshold says nothing about where: leave it unknown.
344 band.autoCompactOff = !breakdown.isAutoCompactEnabled
345 band.compactAt = breakdown.isAutoCompactEnabled ? breakdown.autoCompactThreshold : undefined
346 }
347 } catch {
348 // keep the last known setting
349 }
350
351 // The context toast speaks where the context pill turns amber.
352 const used = contextUsed(e.context)
353 if (used !== undefined) {
354 const at = band.compactAt
355 if (at !== undefined) {
356 note('context', used / at, [COMPACT_NEAR], () => `Auto-compaction in ~${fmtTokens(Math.max(0, at - used))} tokens.`)
357 } else {
358 const off = band.autoCompactOff
359 note('context', used / e.context.window, [WARN_AT, SEVERE_AT], pct =>
360 off
361 ? `Context is ${pct}% full and auto-compaction is off, so the conversation will run out of room.`
362 : `Context is ${pct}% full.`,
363 )
364 }
365 }
366
367 $.ui.invalidate('ui.render')
368 return next(e)
369 })
370
371 on('command.run', { command: 'usage-band' }, async ($, e, next) => {
372 const command = parseCommand(e.args)
373 switch (command) {
374 case 'more':
375 case 'less':
376 await update($, isExpanded, () => command === 'more')
377 await update($, isHidden, () => false)
378 if (command === 'more') void readWorkspace($)
379 return { text: command === 'more' ? REPLY.expanded : REPLY.collapsed }
380 case 'show':
381 case 'hide':
382 await update($, isHidden, () => command === 'hide')
383 return { text: command === 'hide' ? REPLY.hidden : REPLY.shown }
384 case 'toggle': {
385 const wasHidden = await read($, isHidden)
386 await update($, isHidden, () => !wasHidden)
387 return { text: wasHidden ? REPLY.shownFirst : REPLY.hidden }
388 }
389 case undefined:
390 return { text: REPLY.usage }
391 }
392 })
393
394 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
395 if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
396
397 const usage = await $.session.usage()
398 const now = await $.clock.now()
399 const five = usage.rateLimits.find(l => l.kind === FIVE_HOUR)
400 const seven = usage.rateLimits.find(l => l.kind === SEVEN_DAY)
401 if (usage.cost !== undefined) noteLedger(usage.cost.usd)
402 const contextTokens = contextUsed(usage.context) ?? 0
403
404 return drawBand(
405 $.ui.resolve(e),
406 {
407 surface: e.surface,
408 columns: e.props.bodyColumns,
409 maxRows: e.props.maxRows,
410 isWorking: e.props.isWorking,
411 expanded: await read($, isExpanded),
412 palette: band.palette,
413 now,
414 cache: cacheView(now, usage.cost?.usd, contextTokens),
415 costUsd: usage.cost?.usd ?? 0,
416 lastTurnUsd: insights.lastTurnUsd,
417 context: {
418 tokens: usage.context.tokens,
419 window: usage.context.window,
420 percent: usage.context.percent,
421 compactAt: band.compactAt,
422 },
423 fiveHour: five ? { percentUsed: five.percentUsed, resetsAt: five.resetsAt, etaMs: fiveHourEtaMs(now) } : undefined,
424 sevenDay: seven ? { percentUsed: seven.percentUsed, resetsAt: seven.resetsAt } : undefined,
425 workspace: band.workspace,
426 otherLimits: usage.rateLimits
427 .filter(l => l.kind !== FIVE_HOUR && l.kind !== SEVEN_DAY)
428 .map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
429 },
430 {
431 toggleExpanded: async () => {
432 // Opening reads git, so the cards never show a stale branch.
433 if (await update($, isExpanded, current => !current)) void readWorkspace($)
434 },
435 hide: async () => {
436 await update($, isHidden, () => true)
437 },
438 },
439 )
440 })
441}
442hooks/band.tsx 738 lines1// The band, drawn: a pure function of the snapshot register.tsx reads.
2// It never sees `$`, so everything it shows arrives in the snapshot.
3
4import type { ElementTable, RenderChildren, RenderElement } from 'claude-code'
5import {
6 clamp01,
7 fmtCost,
8 fmtEstimate,
9 fmtAgo,
10 fmtEta,
11 fmtSmallCost,
12 fmtTokens,
13 resetIn,
14 severityMark,
15} from './format'
16import type { Icon } from './icons'
17import { makeKit } from './kit'
18import {
19 CARD_TEXT,
20 CHIP_BAR,
21 GIVES_WAY,
22 HOTKEY_MARK,
23 ROW_SLACK,
24 SHORT_BELOW,
25 cellsOf,
26 keeps,
27 squeezeToFit,
28} from './layout'
29import type { BarSize, Piece } from './layout'
30import { BARE } from './palette'
31import type { Palette } from './palette'
32import {
33 cacheCharge,
34 cacheCopy,
35 cacheMood,
36 contextReading,
37 hasReset as resetPassed,
38 limitTone as toneOf,
39 reWarmEstimate,
40 windowGone as goneOf,
41} from './reading'
42import type { Tone } from './reading'
43import type { BandActions, BandSnapshot, LimitReading } from './snapshot'
44import { drawStrip } from './strip'
45
46
47// ---- limits ---------------------------------------------------------------
48
49type LimitKey = '5h' | '7d'
50type Tint = Readonly<{ bg: string; fg: string; accent: string }>
51type LimitSpec = Readonly<{
52 icon: Icon
53 windowMs: number
54 title: string
55 calm: Piece
56 reset: Piece
57 amberReset: Piece
58 tint: (p: Readonly<Palette>) => Tint
59}>
60
61/** The two limit chips: one shape, their own window, tint and steps. */
62const LIMITS: Readonly<Record<LimitKey, LimitSpec>> = {
63 '5h': {
64 icon: 'five',
65 windowMs: 5 * 3600_000,
66 title: '5-hour limit',
67 calm: 'calmFive',
68 reset: 'fiveReset',
69 amberReset: 'amberFiveReset',
70 tint: p => ({ bg: p.fiveBg, fg: p.fiveFg, accent: p.fiveAccent }),
71 },
72 '7d': {
73 icon: 'week',
74 windowMs: 7 * 24 * 3600_000,
75 title: 'Weekly limit',
76 calm: 'calmWeek',
77 reset: 'weekReset',
78 amberReset: 'amberWeekReset',
79 tint: p => ({ bg: p.weekBg, fg: p.weekFg, accent: p.weekAccent }),
80 },
81}
82
83// ---- drawing --------------------------------------------------------------
84
85/** The expanded view's cards, in the order they are drawn. */
86type CardName = 'cache' | 'spend' | 'context' | 'limits'
87
88type PillSpec = Readonly<{
89 key: string
90 tone: Tone
91 body: RenderChildren[]
92 /** The one-line explanation shown while the pill is hovered. */
93 hover: string
94 /** The terminal battery paints its own background in its Texts. */
95 paintsOwnBg?: boolean
96 bg?: string
97}>
98
99export const drawBand = (el: ElementTable, snap: BandSnapshot, act: BandActions): RenderElement => {
100 const kit = makeKit(el, snap)
101 const { Box, Button, Text, Svg, palette, measure, onTone, hoverCard, gap, icon } = kit
102 const c = snap.cache
103 // A pill carries its own foreground and background, never one of each. Its
104 // card is a child, so the engine counts the pointer on the card as on the
105 // pill and reading it keeps the pill hovered. The card has no key: a keyed
106 // Box is its own hover scope, and a hidden one could never be hovered. One
107 // line, since a collapsed band is one row. Plain has no background to cover
108 // the row with, so no cards; the expanded line says it all. A pill never
109 // shrinks: the squeeze drops pieces instead, so its text never wraps.
110 const pill = ({ key, tone, body, hover, paintsOwnBg, bg }: PillSpec, anchor: 'left' | 'right') => {
111 if (!palette.filled) {
112 const fg = onTone(tone, palette.value)
113 return (
114 <Box key={key} flexShrink={0}>
115 <Text color={fg}>[</Text>
116 {body}
117 <Text color={fg}>]</Text>
118 </Box>
119 )
120 }
121 const fill = paintsOwnBg ? {} : { backgroundColor: onTone(tone, bg ?? palette.surface, palette.amberBg), paddingX: 1 }
122 return (
123 <Box key={key} flexShrink={0} {...fill}>
124 {body}
125 {hoverCard(hover, anchor)}
126 </Box>
127 )
128 }
129
130 /** A bar: `frac` filled, with a thumb where the fill ends, so the eye finds
131 * the number's place on it at once. `label` names it for a reader, and
132 * `reads` says whether the fill is what's used or what's left. A stretched bar has no width of its
133 * own: drawn wider than any slot, the slot caps it, so it spans its card. */
134 const meter = (
135 label: string,
136 frac: number,
137 tone: Tone,
138 accent: string,
139 size: BarSize = CHIP_BAR,
140 stretch = false,
141 reads: 'used' | 'left' = 'used',
142 ) => {
143 const fill = onTone(tone, accent)
144 if (Svg) {
145 // Never name a local `h`: JSX compiles to the global h().
146 const tall = 8
147 // Twice the estimate, so the slot always caps it; corners in kind, so
148 // they round true at the scale it lands on.
149 const k = stretch ? 2 : 1
150 const width = size.px * k
151 // A sliver under 6px reads as a dot or nothing: any use shows as a nub.
152 // No clipPath: ids are document-wide where Svgs share a page, so a
153 // rounded fill draws its own ends.
154 const fillWidth = frac > 0 ? Math.max(6 * k, Math.round(clamp01(frac) * width)) : 0
155 const source =
156 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${tall}" viewBox="0 0 ${width} ${tall}"${stretch ? ' preserveAspectRatio="none"' : ''}>` +
157 `<rect x="${0.5 * k}" y="1.5" width="${width - k}" height="5" rx="${2.5 * k}" ry="2.5" fill="${palette.meterTrack}" stroke="${palette.trackStroke}"${stretch ? ' vector-effect="non-scaling-stroke"' : ''}/>` +
158 (fillWidth > 0
159 ? `<rect class="fill" y="1" width="${fillWidth}" height="6" rx="${3 * k}" ry="3" fill="${fill}"/>` +
160 `<rect class="thumb" x="${Math.min(width - 2 * k, fillWidth - k)}" y="0" width="${2 * k}" height="${tall}" rx="${k}" ry="1" fill="${palette.value}"/>`
161 : '') +
162 '</svg>'
163 const alt = `${label} ${Math.round(clamp01(frac) * 100)}% ${reads}`
164 return stretch ? (
165 <Svg key="meter" source={source} alt={alt} height={tall} />
166 ) : (
167 <Svg key="meter" source={source} alt={alt} width={width} height={tall} />
168 )
169 }
170 // Two glyphs only: partial blocks jitter across fonts and read as noise to
171 // a screen reader. The number beside a meter carries the value.
172 const filled = Math.round(clamp01(frac) * size.cells)
173 return (
174 <Text key="meter" color={fill}>
175 {'█'.repeat(filled)}
176 {filled < size.cells ? <Text key="track" color={palette.meterTrack}>{'░'.repeat(size.cells - filled)}</Text> : null}
177 </Text>
178 )
179 }
180
181 // ---- what the pills say, whatever the squeeze ----------------------------
182 const mood = cacheMood(c)
183 const copy = cacheCopy(c, mood, snap.isWorking)
184 const estimate = reWarmEstimate(c)
185 const cacheTone: Tone = mood === 'expiring' ? 'amber' : 'calm'
186 const charge = cacheCharge(c, mood, snap.isWorking)
187
188 const tokenBreakdown = `input ${fmtTokens(c.tokens.sent)} · output ${fmtTokens(c.tokens.back)} · cache reads ${fmtTokens(c.tokens.cached)}`
189 const tokenTotal = c.tokens.sent + c.tokens.back + c.tokens.cached
190
191 const ctx = snap.context
192 const {
193 known: hasContext,
194 used: ctxUsed,
195 frac: ctxFrac,
196 pct: ctxPct,
197 toCompact,
198 nearCompact,
199 tone: ctxTone,
200 } = contextReading(ctx)
201
202 // ---- the cache pill ----------------------------------------------------
203 const batteryIcon = (): RenderChildren => {
204 if (!Svg) return null
205 const outline = onTone(cacheTone, palette.label)
206 const fill = onTone(cacheTone, palette.warm)
207 const width = Math.round(clamp01(charge) * 15 * 10) / 10
208 const source =
209 '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="12" viewBox="0 0 22 12">' +
210 `<rect x="0.75" y="0.75" width="18.5" height="10.5" rx="3" fill="none" stroke="${outline}" stroke-width="1.5"/>` +
211 `<rect x="19.75" y="4" width="1.75" height="4" rx="0.8" fill="${outline}"/>` +
212 `<rect class="charge" x="2.5" y="2.5" width="${width}" height="7" rx="1.5" fill="${fill}"/>` +
213 '</svg>'
214 const alt = copy.alt
215 return <Svg key="battery" source={source} alt={alt} width={22} height={12} />
216 }
217
218 // On a text surface the battery is the pill itself: its charge painted as
219 // the background of the leading characters, draining right to left.
220 const textBattery = (text: string): RenderChildren[] => {
221 const chars = [...` ${text} `]
222 const cut = Math.round(clamp01(charge) * chars.length)
223 const fg = onTone(cacheTone, palette.value)
224 return [
225 cut > 0 ? (
226 <Text key="bf" color={fg} backgroundColor={onTone(cacheTone, palette.batteryFill, palette.batteryAmber)}>
227 {chars.slice(0, cut).join('')}
228 </Text>
229 ) : null,
230 cut < chars.length ? (
231 <Text key="be" color={fg} backgroundColor={onTone(cacheTone, palette.surface, palette.amberBg)}>
232 {chars.slice(cut).join('')}
233 </Text>
234 ) : null,
235 ]
236 }
237
238 const cachePill = (short: boolean): PillSpec => {
239 const text = copy.pill(short)
240 if (Svg) {
241 // A normal rounded pill led by a draining battery icon; the text-surface
242 // battery would paint a square-cornered block here.
243 return {
244 key: 'cache',
245 tone: cacheTone,
246 body: [batteryIcon(), <Text key="c" color={onTone(cacheTone, palette.value)}>{` ${text}`}</Text>],
247 hover: copy.hover,
248 }
249 }
250 if (palette.filled) return { key: 'cache', tone: cacheTone, body: textBattery(`◷ ${text}`), hover: copy.hover, paintsOwnBg: true }
251 const dot = mood === 'warm' || mood === 'expiring' ? palette.warm : palette.cold
252 return {
253 key: 'cache',
254 tone: cacheTone,
255 body: [
256 cacheTone === 'amber' ? null : <Text key="dot" color={dot}>{'● '}</Text>,
257 <Text key="c" color={onTone(cacheTone, palette.value)}>
258 {cacheTone === 'amber' ? `◷ ${text}` : text}
259 </Text>,
260 ],
261 hover: copy.hover,
262 }
263 }
264
265 // ---- a limit chip ------------------------------------------------------
266 // Its usage on a bar and the reset; a pace that fills it early speaks in
267 // words. A window whose reset has passed shows as reset: its last reading
268 // is from before it.
269 const limitChip = (key: LimitKey, reading: LimitReading, pace: string, tone: Tone, squeeze: number): PillSpec => {
270 const spec = LIMITS[key]
271 const tint = spec.tint(palette)
272 const r = resetIn(reading.resetsAt, snap.now)
273 if (r?.kind === 'passed') {
274 return {
275 key,
276 tone: 'calm',
277 bg: tint.bg,
278 body: [...icon(spec.icon, tint.accent), <Text key="l" color={tint.fg}>{`${key} reset`}</Text>],
279 hover: `${spec.title} has reset; it updates after your next message`,
280 }
281 }
282 const fg = onTone(tone, tint.fg)
283 const accent = onTone(tone, tint.accent)
284 const frac = clamp01(reading.percentUsed / 100)
285 const bar = keeps(squeeze, 'limitBars') ? [gap('g-bar'), meter(key, frac, tone, tint.accent)] : []
286 const reset =
287 r !== undefined && keeps(squeeze, tone === 'amber' ? spec.amberReset : spec.reset)
288 ? [<Text key="d" color={palette.label}>{' │ '}</Text>, ...icon('reset', accent), <Text key="r" color={fg}>{r.text}</Text>]
289 : []
290 return {
291 key,
292 tone,
293 bg: tint.bg,
294 body: [
295 ...icon(spec.icon, accent),
296 <Text key="l" color={fg}>
297 {key}
298 </Text>,
299 ...bar,
300 <Text key="v" color={fg} bold>
301 {` ${Math.round(reading.percentUsed)}%${severityMark(frac)}${pace}`}
302 </Text>,
303 ...reset,
304 ],
305 hover:
306 r === undefined
307 ? `Your ${spec.title.toLowerCase()}, across all your Claude use`
308 : `${spec.title} across all your Claude use; resets in ${r.text}`,
309 }
310 }
311
312 const windowGone = (reading: LimitReading, windowMs: number | undefined) => goneOf(reading, windowMs, snap.now)
313 const hasReset = (reading: LimitReading) => resetPassed(reading, snap.now)
314 const limitTone = (reading: LimitReading, etaMs: number | null = null) => toneOf(reading, snap.now, etaMs)
315
316 // ---- the row, at a given squeeze ---------------------------------------
317 const buildPills = (squeeze: number): PillSpec[] => {
318 const short = snap.columns < SHORT_BELOW || !keeps(squeeze, 'shortWording')
319 const pills: PillSpec[] = [
320 cachePill(short),
321 {
322 key: 'cost',
323 tone: 'calm',
324 body: [...icon('cost', palette.coin), <Text key="v" color={palette.value} bold>{fmtCost(snap.costUsd)}</Text>],
325 hover:
326 snap.lastTurnUsd === null
327 ? 'What this session has cost so far'
328 : `${fmtSmallCost(snap.lastTurnUsd)} spent during your last message, subagents included`,
329 },
330 ]
331
332 if (c.requests > 0 && keeps(squeeze, 'tokens')) {
333 pills.push({
334 key: 'tokens',
335 tone: 'calm',
336 body: [...icon('tokens', palette.label), <Text key="v" color={palette.value}>{fmtTokens(tokenTotal)}</Text>],
337 hover: tokenBreakdown,
338 })
339 }
340
341 if (hasContext && (ctxTone === 'amber' || keeps(squeeze, 'calmContext'))) {
342 const amount =
343 ctx.compactAt !== undefined
344 ? keeps(squeeze, 'shortWording')
345 ? `${ctxPct} full`
346 : ctxPct
347 : !keeps(squeeze, 'shortWording') || ctx.tokens === undefined
348 ? ctxPct
349 : `${fmtTokens(ctx.tokens)} / ${fmtTokens(ctx.window)}`
350 const mark = ctx.compactAt === undefined ? severityMark(ctxFrac) : ''
351 const countdown = nearCompact && toCompact !== undefined ? ` · compacts in ~${fmtTokens(toCompact)}` : ''
352 pills.push({
353 key: 'ctx',
354 tone: ctxTone,
355 body: [
356 ...icon('context', onTone(ctxTone, palette.label)),
357 ...(keeps(squeeze, 'contextMeter')
358 ? [meter('context', ctxFrac, ctxTone, palette.meterFill), gap('g-bar')]
359 : []),
360 <Text key="v" color={onTone(ctxTone, palette.value)}>
361 {`${amount}${mark}${countdown}`}
362 </Text>,
363 ],
364 hover:
365 ctx.compactAt === undefined || toCompact === undefined
366 ? 'Conversation fill; near full, older turns get summarized'
367 : `Full toward auto-compaction at ${fmtTokens(ctx.compactAt)}; ${fmtTokens(toCompact)} to go`,
368 })
369 }
370
371 if (snap.fiveHour) {
372 const eta = snap.fiveHour.etaMs
373 const tone = limitTone(snap.fiveHour, eta)
374 if (tone === 'amber' || keeps(squeeze, LIMITS['5h'].calm)) {
375 const pace = eta === null ? '' : short ? ` ${fmtEta(eta)}` : ` full in ${fmtEta(eta)}`
376 pills.push(limitChip('5h', snap.fiveHour, pace, tone, squeeze))
377 }
378 }
379
380 if (snap.sevenDay) {
381 const tone = limitTone(snap.sevenDay)
382 if (tone === 'amber' || keeps(squeeze, LIMITS['7d'].calm)) pills.push(limitChip('7d', snap.sevenDay, '', tone, squeeze))
383 }
384 return pills
385 }
386
387 const rowOf = (pills: PillSpec[]): RenderElement => (
388 <Box key="row" flexDirection="row" flexWrap="nowrap" overflow="hidden" columnGap={1}>
389 {pills.map((spec, i) => pill(spec, i === pills.length - 1 ? 'right' : 'left'))}
390 <Box flexGrow={1} />
391 <Box flexShrink={0}>
392 {/* A Button holds text alone, so its icon is a glyph. The outlined
393 triangles are measured centred in the line, within half a pixel,
394 and wider than tall like a disclosure icon; arrowhead chevrons sit
395 5 px low. On the desktop in a native frame like Collapse's, in the
396 terminal bare. */}
397 <Button
398 key="more"
399 label={snap.expanded ? '▵' : '▿'}
400 {...(Svg ? { variant: 'secondary' as const } : { plain: true as const, dimColor: true })}
401 onPress={act.toggleExpanded}
402 />
403 </Box>
404 </Box>
405 )
406
407 const row = squeezeToFit(squeeze => rowOf(buildPills(squeeze)), GIVES_WAY.length, snap.columns - ROW_SLACK, measure)
408
409 // ---- the expanded view: four cards, every fact labelled ----------------
410 // Built only when open: a closed band draws the row alone.
411 const expandedView = (): RenderChildren[] => {
412 /** A label and its value at either end of a line; `swatch` keys a legend. */
413 const factRow = (label: string, value: string, swatch?: string) => (
414 <Box key={`fact:${label}`} flexDirection="row" justifyContent="space-between" columnGap={2}>
415 <Text color={palette.label}>
416 {swatch === undefined ? null : <Text key="sw" color={swatch}>{'■ '}</Text>}
417 {label}
418 </Text>
419 <Text color={palette.cardValue}>{value}</Text>
420 </Box>
421 )
422 const note = (text: string) => (
423 <Text key="note" color={palette.label} wrap="wrap">
424 {text}
425 </Text>
426 )
427
428 // The grid: as many cards to a line as hold their text, else two, else one.
429 // Each line shares its width equally (a zero basis, grown alike), so the
430 // cards align whatever their text and a line never wraps one away. A
431 // desktop card has a visible edge; a terminal card is a fill, its border
432 // would cost two columns. Lines keep a row of air between them.
433 const bordered = Svg !== undefined || !palette.filled
434 const edge = bordered ? 2 : 0
435 const minCard = Math.ceil(CARD_TEXT * measure.text) + 2 + edge
436 // The cards present, which the grid lays out.
437 const present: readonly CardName[] = [
438 'cache',
439 'spend',
440 ...(hasContext ? (['context'] as const) : []),
441 ...(snap.fiveHour || snap.sevenDay || snap.otherLimits.length > 0 ? (['limits'] as const) : []),
442 ]
443 const cardCount = present.length
444 const fits = (n: number) => n * minCard + (n - 1) <= snap.columns
445 const perLine = [cardCount, Math.ceil(cardCount / 2)].find(fits) ?? 1
446 const lineCount = Math.ceil(cardCount / perLine)
447 const lineGap = 1
448 // The rows a card's body may take: the band's, less the chip row, the
449 // buttons, the row of air above each line of cards and the buttons and,
450 // when it shows, the workspace strip, shared by the lines, less a card's
451 // edge and its header. A taller band would scroll, hiding the buttons.
452 const bodyFor = (strip: number) =>
453 Math.floor((snap.maxRows - 4 - strip - lineGap * (lineCount - 1)) / lineCount) - edge - 1
454 // The strip heads the view when every card still keeps a fact of its own;
455 // short of that row it takes the footer's, in place of the hint.
456 const stripPlace: 'top' | 'footer' | undefined =
457 snap.workspace === undefined ? undefined : bodyFor(1) >= 1 ? 'top' : 'footer'
458 const bodyRows = Math.max(1, bodyFor(stripPlace === 'top' ? 1 : 0))
459 const inner = Math.max(4, Math.floor((snap.columns - (perLine - 1)) / perLine) - 2 - edge)
460 const cardBar: BarSize = { px: inner * measure.pxPerCell, cells: inner }
461 /** As much of a card's body as the band has rows for, its facts listed
462 * most important first. Its bar repeats a chip's, so it shows only when
463 * every fact fits beside it; short of rows, a fact wins. */
464 const fitBody = (bar: RenderChildren, body: RenderChildren[]): RenderChildren[] => {
465 const facts = body.filter(part => part !== null && part !== undefined)
466 return bar !== null && facts.length < bodyRows ? [bar, ...facts] : facts.slice(0, bodyRows)
467 }
468 // Each card's mark: the chips' own icons, so the band speaks one language.
469 const cardIcon: Readonly<Record<CardName, readonly [Icon, string]>> = {
470 cache: ['cache', palette.warm],
471 spend: ['cost', palette.coin],
472 context: ['context', palette.label],
473 limits: ['limits', LIMITS['5h'].tint(palette).accent],
474 }
475 /** A card: its title and headline on one line, then its bar and body. */
476 const card = (
477 name: CardName,
478 title: string,
479 head: Readonly<{ text: string; tone?: Tone }>,
480 bar: RenderChildren,
481 body: RenderChildren[],
482 ) => (
483 <Box
484 key={`card:${name}`}
485 flexDirection="column"
486 flexGrow={1}
487 width={0}
488 minWidth={0}
489 paddingX={1}
490 {...(palette.filled ? { backgroundColor: palette.cardBg } : {})}
491 {...(bordered ? { borderStyle: 'round', borderColor: palette.cardBorder } : {})}
492 >
493 <Box key="head" flexDirection="row" justifyContent="space-between" columnGap={1}>
494 <Box key="title" flexDirection="row" flexShrink={0}>
495 {/* Desktop alone: a terminal title stays plain text. */}
496 {Svg ? icon(...cardIcon[name]) : null}
497 <Text color={palette.label}>{title.toUpperCase()}</Text>
498 </Box>
499 <Text color={onTone(head.tone ?? 'calm', palette.value)} bold wrap="truncate-end">
500 {head.text}
501 </Text>
502 </Box>
503 {fitBody(bar, body)}
504 </Box>
505 )
506
507 /** A bar split into parts, each its share of the whole, in its own colour. */
508 const splitBar = (label: string, parts: ReadonlyArray<readonly [number, string]>) => {
509 const total = parts.reduce((sum, [n]) => sum + n, 0)
510 if (total <= 0) return null
511 if (Svg) {
512 // Drawn wider than any card and capped by it, like a stretched meter.
513 // No clipPath, whose id would be page-wide: the end segments draw their
514 // own rounded ends, as paths, the inner ones square.
515 const width = cardBar.px * 2
516 const R = 6
517 const shown = parts.filter(([n]) => n > 0)
518 let x = 0
519 const segments = shown.map(([n, color], i) => {
520 const x0 = x
521 const x1 = x + (n / total) * width
522 x = x1
523 const f = (v: number) => v.toFixed(1)
524 const first = i === 0
525 const last = i === shown.length - 1
526 if (x1 - x0 < 2 * R || (!first && !last)) {
527 return `<rect x="${f(x0)}" y="1" width="${f(x1 - x0)}" height="6"${first && last ? ` rx="${R}" ry="3"` : ''} fill="${color}"/>`
528 }
529 if (first && last) return `<rect x="${f(x0)}" y="1" width="${f(x1 - x0)}" height="6" rx="${R}" ry="3" fill="${color}"/>`
530 return first
531 ? `<path d="M${f(x0 + R)} 1H${f(x1)}V7H${f(x0 + R)}A${R} 3 0 0 1 ${f(x0 + R)} 1Z" fill="${color}"/>`
532 : `<path d="M${f(x0)} 1H${f(x1 - R)}A${R} 3 0 0 1 ${f(x1 - R)} 7H${f(x0)}Z" fill="${color}"/>`
533 })
534 const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="8" viewBox="0 0 ${width} 8" preserveAspectRatio="none">${segments.join('')}</svg>`
535 return <Svg key="split" source={source} alt={label} height={8} />
536 }
537 const cells = cardBar.cells
538 let used = 0
539 return (
540 <Text key="split">
541 {parts.map(([n, color], i) => {
542 const count = i === parts.length - 1 ? cells - used : Math.round((n / total) * cells)
543 used += count
544 return (
545 <Text key={`s${i}`} color={color}>
546 {'█'.repeat(Math.max(0, count))}
547 </Text>
548 )
549 })}
550 </Text>
551 )
552 }
553
554 const measured = c.requests > 0
555 // Measured, or recalled from the session's last reply: time and price known.
556 const known = measured || c.recalled
557 const cacheView = card(
558 'cache',
559 'Cache',
560 {
561 text: copy.head,
562 tone: cacheTone,
563 },
564 known ? meter('cache', charge, cacheTone, palette.warm, cardBar, true, 'left') : null,
565 [
566 copy.note === undefined ? null : note(copy.note),
567 known ? factRow(mood === 'cold' ? 'next message' : 're-warm if cold', estimate) : null,
568 c.recalled && c.idleMs !== null ? factRow('idle for', fmtAgo(c.idleMs)) : null,
569 c.misses > 0 ? factRow('unexpected rebuilds', String(c.misses)) : null,
570 measured && c.savedUsd !== null ? factRow('saved by cache', fmtEstimate(c.savedUsd)) : null,
571 measured && c.hitRatio !== null ? factRow('hit rate', `${Math.round(c.hitRatio * 100)}%`) : null,
572 // Inference only ever moves an assumed hour to 5m, so an unpinned hour is the guess.
573 factRow('expires', `${c.ttl} idle${!c.ttlPinned && c.ttl === '1h' ? ' · assumed' : ''}`),
574 ],
575 )
576
577 // Cache reads in the warm colour: cheap, and often most of the bar.
578 const tokenParts: ReadonlyArray<readonly [string, number, string]> = [
579 ['input', c.tokens.sent, palette.meterFill],
580 ['output', c.tokens.back, palette.coin],
581 ['cache reads', c.tokens.cached, palette.warm],
582 ]
583 const spendView = card(
584 'spend',
585 'Spend',
586 { text: fmtCost(snap.costUsd) },
587 measured ? splitBar('token split: input, output, cache reads', tokenParts.map(([, n, color]) => [n, color] as const)) : null,
588 [
589 snap.lastTurnUsd !== null ? factRow('last message', fmtSmallCost(snap.lastTurnUsd)) : null,
590 ...(measured ? tokenParts.map(([label, n, color]) => factRow(label, fmtTokens(n), color)) : [note('Breakdown counts from your next message.')]),
591 ],
592 )
593
594 const contextView = hasContext
595 ? card(
596 'context',
597 'Context',
598 { text: `${ctxPct} full${ctx.compactAt === undefined ? severityMark(ctxFrac) : ''}`, tone: ctxTone },
599 meter('context', ctxFrac, ctxTone, palette.meterFill, cardBar, true),
600 [
601 toCompact !== undefined ? factRow('room left', `~${fmtTokens(toCompact)}`) : null,
602 ctx.compactAt !== undefined ? factRow('auto-compacts at', fmtTokens(ctx.compactAt)) : null,
603 factRow('in context', fmtTokens(ctxUsed)),
604 factRow('model window', fmtTokens(ctx.window)),
605 ],
606 )
607 : null
608
609 /** A window of the Limits card. `etaMs` is the measured pace the 5h chip
610 * shows, when there is one; the card says the same. */
611 type Window = Readonly<{
612 name: string
613 reading: LimitReading
614 windowMs: number | undefined
615 accent: string
616 etaMs: number | null
617 }>
618 const windows: Window[] = [
619 ...(snap.fiveHour
620 ? [{ name: '5h', reading: snap.fiveHour, windowMs: LIMITS['5h'].windowMs, accent: LIMITS['5h'].tint(palette).accent, etaMs: snap.fiveHour.etaMs }]
621 : []),
622 ...(snap.sevenDay ? [{ name: '7d', reading: snap.sevenDay, windowMs: LIMITS['7d'].windowMs, accent: LIMITS['7d'].tint(palette).accent, etaMs: null }] : []),
623 ...snap.otherLimits.map(limit => ({
624 name: limit.kind === 'spend_limit' ? 'spend' : limit.kind.replace(/_/g, ' '),
625 reading: limit,
626 windowMs: undefined,
627 accent: palette.meterFill,
628 etaMs: null,
629 })),
630 ]
631 const live = windows.filter(w => !hasReset(w.reading))
632 const valueOf = (reading: LimitReading) => `${Math.round(reading.percentUsed)}%${severityMark(clamp01(reading.percentUsed / 100))}`
633
634 /** One window: its name, bar and value on a line, then its reset and pace in words. */
635 const limitRows = ({ name, reading, windowMs, accent, etaMs }: Window): readonly [RenderChildren, RenderChildren] => {
636 const r = resetIn(reading.resetsAt, snap.now)
637 if (r?.kind === 'passed') return [factRow(name, 'reset'), null]
638 const frac = clamp01(reading.percentUsed / 100)
639 const tone = limitTone(reading, etaMs)
640 const gone = windowGone(reading, windowMs)
641 const value = valueOf(reading)
642 const room = Math.max(4, inner - [...name].length - [...value].length - 2)
643 // A measured pace, as the chip says it; else where the window's average
644 // rate ends it. Too early to say, it waits.
645 const projected = gone === undefined || gone < 0.05 ? undefined : reading.percentUsed / gone
646 const pace =
647 etaMs !== null
648 ? ` · full in ${fmtEta(etaMs)}`
649 : projected === undefined
650 ? ''
651 : projected >= 100
652 ? ' · full before reset'
653 : ` · on pace for ~${Math.round(projected)}%`
654 return [
655 <Box key={`fact:${name}`} flexDirection="row" columnGap={1}>
656 <Text color={palette.label}>{name}</Text>
657 <Box key="bar" flexGrow={1} width={0} minWidth={0}>
658 {meter(name, frac, tone, accent, { px: room * measure.pxPerCell, cells: room }, true)}
659 </Box>
660 <Text color={onTone(tone, palette.cardValue)}>{value}</Text>
661 </Box>,
662 r === undefined ? null : (
663 <Box key={`fact:${name} pace`}>
664 <Text color={palette.label} wrap="truncate-end">{`resets ${r.text}${pace}`}</Text>
665 </Box>
666 ),
667 ]
668 }
669 /** Every window's rows; short of rows, each window's bar row before any
670 * pace line. */
671 const limitLines = (): RenderChildren[] => {
672 const rows = windows.map(limitRows)
673 const lines = rows.flat().filter(part => part !== null)
674 return lines.length <= bodyRows ? lines : [...rows.map(([bar]) => bar), ...rows.map(([, pace]) => pace)]
675 }
676 // The headline is the window closest to its limit.
677 const worst = live.reduce<Window | undefined>((top, w) => (top === undefined || w.reading.percentUsed > top.reading.percentUsed ? w : top), undefined)
678 const limitsView =
679 windows.length > 0
680 ? card(
681 'limits',
682 'Limits',
683 {
684 text: worst === undefined ? 'all reset' : `${worst.name} ${valueOf(worst.reading)}`,
685 tone: worst === undefined ? 'calm' : limitTone(worst.reading, worst.etaMs),
686 },
687 // Each window's bar is in its own row, so the card has none apart.
688 null,
689 limitLines(),
690 )
691 : null
692
693 const cardViews = [cacheView, spendView, contextView, limitsView].filter(view => view !== null)
694
695 const buttons = [
696 <Button key="collapse" label="Collapse" variant="secondary" hotkey="c" onPress={act.toggleExpanded} />,
697 <Button key="hide" label="Hide band" variant="secondary" hotkey="h" onPress={act.hide} />,
698 ]
699 let strip: RenderElement | null = null
700 if (stripPlace !== undefined && snap.workspace !== undefined) {
701 // In the footer it shares the line with the buttons and their hotkey marks.
702 const room =
703 snap.columns - ROW_SLACK - (stripPlace === 'footer' ? cellsOf(buttons, measure) + 2 * HOTKEY_MARK + 2 : 0)
704 strip = drawStrip(kit, snap.workspace, stripPlace, edge, room)
705 }
706
707 return [
708 stripPlace === 'top' ? strip : null,
709 <Box key="cards" flexDirection="column" rowGap={lineGap} marginTop={stripPlace === 'top' ? 0 : 1}>
710 {Array.from({ length: lineCount }, (_, i) => (
711 <Box key={`cards:${i}`} flexDirection="row" columnGap={1}>
712 {cardViews.slice(i * perLine, (i + 1) * perLine)}
713 </Box>
714 ))}
715 </Box>,
716 <Box key="actions" flexDirection="row" columnGap={1} marginTop={1}>
717 {stripPlace === 'footer' ? (
718 strip
719 ) : (
720 <Box key="hint" flexDirection="row">
721 {Svg ? icon('info', BARE.icon) : null}
722 <Text color={BARE.label}>Bring it back with /usage-band</Text>
723 </Box>
724 )}
725 <Box flexGrow={1} />
726 {buttons}
727 </Box>,
728 ]
729 }
730
731 return (
732 <Box flexDirection="column">
733 {row}
734 {snap.expanded ? expandedView() : null}
735 </Box>
736 )
737}
738hooks/cache.ts 315 lines1// The prompt cache, as the band models it from each response's token counts.
2
3import type { ModelUsage } from 'claude-code'
4import type { BandSnapshot } from './snapshot'
5
6type CacheView = BandSnapshot['cache']
7
8export type Ttl = '5m' | '1h'
9export const TTL_MS: Readonly<Record<Ttl, number>> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
10
11
12// A response that read this much less than the cache held, both in tokens and
13// as a share of it, missed the cache.
14const MISS_MIN_TOKENS = 2000
15const MISS_MIN_SHARE = 0.05
16
17type CacheState = {
18 ttl: Ttl
19 /** Set by the environment, so never inferred. */
20 ttlPinned: boolean
21 /** Main-loop requests this conversation. */
22 requests: number
23 // Every token since the conversation began, subagents included.
24 read: number
25 written: number
26 uncached: number
27 output: number
28 /** Requests that missed a cache that should have been warm. */
29 misses: number
30 /** When the last main-loop request was sent. */
31 lastAt: number
32 /** The last main-loop request's whole window, its response included. */
33 window: number
34 /** What that request left in the cache; the response is only written on
35 * the next request, never read. */
36 cached: number
37 /** The next request rebuilds the cache on purpose (a compaction). */
38 rebuilding: boolean
39 /** The model the last main-loop request ran on; a switch rebuilds the cache. */
40 model: string | undefined
41 /** The ledger when this conversation's first turn began, so spend from
42 * before it (a /clear, a resume, a reload) can't inflate the rate the
43 * re-warm price is solved from. */
44 costBase: number
45 baselined: boolean
46 /** The conversation is known to start here (a new session, a /clear), so
47 * its cache is warming; after a reload mid-conversation it is unmeasured. */
48 knownFresh: boolean
49 /** Before this band's first reply: when the conversation's last reply was,
50 * and the base rate per token on its model, as recalled. */
51 recall: Readonly<{ lastAt: number; rate: number | null }> | undefined
52 /** The model /model has in force, as it names it: perhaps an alias. */
53 priceModel: string | undefined
54 /** The model the main loop's last reply was billed under, as the API names
55 * it. Where known, the tokens are priced at it. */
56 billedModel: string | undefined
57}
58
59const INITIAL: Readonly<CacheState> = {
60 ttl: '1h',
61 ttlPinned: false,
62 requests: 0,
63 read: 0,
64 written: 0,
65 uncached: 0,
66 output: 0,
67 misses: 0,
68 lastAt: 0,
69 window: 0,
70 cached: 0,
71 rebuilding: false,
72 model: undefined,
73 costBase: 0,
74 baselined: false,
75 knownFresh: false,
76 recall: undefined,
77 priceModel: undefined,
78 billedModel: undefined,
79}
80
81const state: CacheState = { ...INITIAL }
82
83/** The model, read-only: only this module's functions change it. */
84export const cache: Readonly<CacheState> = state
85
86export const resetCache = (): void => {
87 Object.assign(state, INITIAL)
88}
89
90/** A new conversation in the same process (/clear, resume): the billing mode
91 * and its TTL carry over, everything measured starts again. The baseline is
92 * provisional until the next turn starts and takes the ledger then. */
93export const resetConversation = (costNow: number): void => {
94 const { ttl, ttlPinned, priceModel, billedModel } = state
95 Object.assign(state, INITIAL, { ttl, ttlPinned, priceModel, billedModel, costBase: costNow, knownFresh: true })
96}
97
98/** What the band recalls of the conversation's last reply, before its own. */
99export const noteRecall = (lastAt: number, rate: number | null): void => {
100 state.recall = { lastAt, rate }
101}
102
103/** Whether the cache stands as recalled: no reply seen yet, a conversation
104 * that didn't start here, and something to recall. */
105const isRecalled = (): boolean => state.requests === 0 && !state.knownFresh && state.recall !== undefined
106
107/** At load: a ledger that has spent nothing is a new conversation. */
108export const noteLoad = (costNow: number | undefined): void => {
109 state.knownFresh = !costNow
110}
111
112/** The TTL the environment pins, if it pins one. */
113export const resolveTtl = (env: {
114 force5m: string | undefined
115 chosen: string | undefined
116 enable1h: string | undefined
117}): Ttl | undefined => {
118 if (env.force5m === '1') return '5m'
119 if (env.chosen === '5m' || env.chosen === '1h') return env.chosen
120 if (env.enable1h === '1') return '1h'
121 return undefined
122}
123
124export const pinTtl = (ttl: Ttl): void => {
125 state.ttl = ttl
126 state.ttlPinned = true
127}
128
129/** A compaction replaces the conversation with a summary: the next request
130 * rebuilds the cache on purpose, at the summary's size. */
131export const noteCompaction = (sizeAfter: number | undefined): void => {
132 state.rebuilding = true
133 if (sizeAfter !== undefined) {
134 state.window = sizeAfter
135 state.cached = 0
136 }
137}
138
139/** The engine may reset the ledger on /clear: once it reads below the
140 * baseline, it counts this conversation alone, so the baseline is 0. */
141export const noteLedger = (costNow: number): void => {
142 if (costNow < state.costBase) state.costBase = 0
143}
144
145/** The ledger as this conversation's first turn starts: its baseline. */
146export const noteConversationStart = (costNow: number): void => {
147 if (state.baselined) return
148 state.costBase = costNow
149 state.baselined = true
150}
151
152export const recordResponse = (
153 usage: Pick<ModelUsage, 'input_tokens' | 'output_tokens' | 'cache_read_input_tokens' | 'cache_creation_input_tokens'>,
154 sentAt: number,
155 isMain: boolean,
156 model: string | undefined,
157): void => {
158 const hit = usage.cache_read_input_tokens
159 const written = usage.cache_creation_input_tokens
160 const fresh = usage.input_tokens
161
162 // The session's bill includes every subagent, so their tokens count toward
163 // the totals the rate is solved from. Their prefixes are their own, though:
164 // they say nothing about the main conversation's cache or its countdown.
165 state.read += hit
166 state.written += written
167 state.uncached += fresh
168 state.output += usage.output_tokens
169 if (!isMain) return
170
171 const prefix = state.cached
172 const gap = state.requests > 0 ? sentAt - state.lastAt : 0
173 // Another model has its own cache: reading nothing after a switch is no miss.
174 const switched = state.model !== undefined && model !== undefined && model !== state.model
175
176 if (prefix > 0 && !state.rebuilding && !switched && gap <= TTL_MS[state.ttl]) {
177 const shortfall = prefix - hit
178 if (shortfall >= MISS_MIN_TOKENS && shortfall > prefix * MISS_MIN_SHARE) {
179 // An assumed hour that read nothing after five idle minutes was five.
180 if (!state.ttlPinned && state.ttl === '1h' && hit === 0 && gap > TTL_MS['5m']) {
181 state.ttl = '5m'
182 } else {
183 state.misses += 1
184 }
185 }
186 }
187
188 state.requests += 1
189 state.window = fresh + hit + written + usage.output_tokens
190 state.cached = hit + written
191 state.lastAt = sentAt
192 state.rebuilding = false
193 state.model = model ?? state.model
194}
195
196// The price of each kind of token against base input. Writes and output hold
197// one ratio across Anthropic's models; reads do not. Claude Code's own ledger
198// prices every cache write at 1.25×, whatever its lifetime, so the band does
199// too, to agree with the cost it shows.
200const WRITE_MULT = 1.25
201const OUTPUT_MULT = 5
202/** A cache read against base input, by model; 0.1× elsewhere. Per Anthropic's
203 * list prices as of 2026-10: Opus 5.5 reads at $0.20 on $4.00 input, Sonnet
204 * 5.5 at $0.10 on $2.00, Fable and Mythos 5.1 at $0.25 on $10.00. */
205const READ_MULT_BY_MODEL: Readonly<Record<string, number>> = {
206 'claude-opus-5-5': 0.05,
207 'claude-sonnet-5-5': 0.05,
208 'claude-fable-5-1': 0.025,
209 'claude-mythos-5-1': 0.025,
210}
211const DEFAULT_READ_MULT = 0.1
212
213/** The model a name bills as. `/model` names a 1M context window with a
214 * suffix, `claude-opus-5-5[1m]`, and the cost record names the model alone. */
215export const modelName = (model: string): string => model.replace(/\[[^\]]*\]$/, '')
216
217/** What a cache read costs against base input on `model`. */
218export const readMultiplier = (model: string | undefined): number =>
219 (model === undefined ? undefined : READ_MULT_BY_MODEL[modelName(model)]) ?? DEFAULT_READ_MULT
220
221export const notePriceModel = (model: string | undefined): void => {
222 state.priceModel = model
223}
224
225export const noteBilledModel = (model: string): void => {
226 state.billedModel = model
227}
228
229/** The model the tokens are priced at: the one replies are billed under, else
230 * what /model names, which may be an alias such as `opus[1m]`. */
231export const pricedModel = (): string | undefined =>
232 state.billedModel ?? (state.priceModel === undefined ? undefined : modelName(state.priceModel))
233
234/** A cache read's price against input, on the model in force. */
235export const readShare = (): number => readMultiplier(pricedModel())
236
237/** Tokens weighted by their price against base input on `model`: the one
238 * unknown left is the base rate itself. */
239export const weightedTokens = (
240 t: Readonly<{ uncached: number; written: number; read: number; output: number }>,
241 model: string | undefined,
242): number => t.uncached + WRITE_MULT * t.written + readMultiplier(model) * t.read + OUTPUT_MULT * t.output
243
244/** The base rate per token, in dollars.
245 *
246 * No pricing table is available to a mod, so the rate is solved from the
247 * session's own bill, which leaves one unknown:
248 *
249 * cost = r * (uncached + 1.25*written + read multiplier*read + 5*output)
250 *
251 * It self-calibrates to whatever model and plan are in force, and it is an
252 * estimate on top of an estimate (the session cost is itself computed at list
253 * price), so what it prices is always shown with a "~". Call noteLedger first. */
254export const ratePerToken = (sessionCost: number | undefined): number | null => {
255 if (!sessionCost || sessionCost <= 0) return null
256 const weighted = weightedTokens(state, pricedModel())
257 if (weighted <= 0) return null
258 const billed = sessionCost - state.costBase
259 if (billed <= 0) return null
260 const rate = billed / weighted
261 return Number.isFinite(rate) && rate > 0 ? rate : null
262}
263
264/** What writing `tokens` to the cache costs at a base `rate` per token. */
265export const reWarmAt = (rate: number, tokens: number): number => rate * WRITE_MULT * tokens
266
267/** What a cold cache would cost to rebuild: a cache write of the whole window. */
268export const reWarmUsd = (sessionCost: number | undefined): number | null => {
269 const rate = ratePerToken(sessionCost)
270 return rate === null ? null : reWarmAt(rate, state.window)
271}
272
273/** What reading from the cache saved against paying full input price for
274 * the same tokens: the rest of the base rate on every cache read. */
275export const savedUsd = (sessionCost: number | undefined): number | null => {
276 const rate = ratePerToken(sessionCost)
277 return rate === null || state.read <= 0 ? null : rate * (1 - readShare()) * state.read
278}
279
280export const hitRatio = (): number | null => {
281 const total = state.read + state.written + state.uncached
282 return total > 0 ? state.read / total : null
283}
284
285/** The cache's time left: from the last reply this band saw, else the one
286 * it recalls; a full lifetime before either. */
287export const msLeft = (now: number): number => {
288 const from = state.requests > 0 ? state.lastAt : isRecalled() ? state.recall?.lastAt : undefined
289 return from === undefined ? TTL_MS[state.ttl] : Math.max(0, from + TTL_MS[state.ttl] - now)
290}
291
292/** The cache as the band shows it, at `now`: measured once a reply has been
293 * seen, recalled before that. `contextTokens` is the context as it stands,
294 * which a recalled cold cache would rebuild. */
295export const cacheView = (now: number, sessionCost: number | undefined, contextTokens: number): CacheView => {
296 const recalled = isRecalled()
297 const rate = state.recall?.rate ?? null
298 return {
299 requests: state.requests,
300 msLeft: msLeft(now),
301 ttl: state.ttl,
302 ttlPinned: state.ttlPinned,
303 window: recalled ? contextTokens : state.window,
304 hitRatio: hitRatio(),
305 misses: state.misses,
306 reWarmUsd: recalled ? (rate === null ? null : reWarmAt(rate, contextTokens)) : reWarmUsd(sessionCost),
307 recalled,
308 idleMs: recalled && state.recall !== undefined ? now - state.recall.lastAt : null,
309 savedUsd: savedUsd(sessionCost),
310 readShare: readShare(),
311 fresh: state.knownFresh,
312 tokens: { sent: state.uncached + state.written, back: state.output, cached: state.read },
313 }
314}
315hooks/format.ts 102 lines1// Numbers, times and escalation marks, as the band words them.
2
3/** The cache's last minute: the one span where acting changes the bill. */
4export const SOON_MS = 60_000
5
6/** At this share a pill turns amber and gains `!`, and a toast speaks. */
7export const WARN_AT = 0.8
8/** At this share the mark becomes `!!`, and a toast speaks again. */
9export const SEVERE_AT = 0.95
10/** Within this share of the compaction point, context counts down to it. */
11export const COMPACT_NEAR = 0.9
12
13export const clamp01 = (n: number): number => (n < 0 ? 0 : n > 1 ? 1 : n)
14
15export const fmtTokens = (n: number): string => {
16 const v = Math.max(0, Math.round(n))
17 // Each unit from where the one below would round up to it: 9,999 is 10k,
18 // not 10.0k; 999,999 is 1.0M, not 1000k.
19 if (v >= 999_500) return `${(v / 1_000_000).toFixed(1)}M`
20 if (v >= 9_950) return `${Math.round(v / 1000)}k`
21 if (v >= 1_000) return `${(v / 1000).toFixed(1)}k`
22 return String(v)
23}
24
25/** Whole dollars from $1000, and from what would round to it. */
26const WHOLE_FROM = 999.995
27
28export const fmtCost = (usd: number): string => (usd >= WHOLE_FROM ? `$${Math.round(usd)}` : `$${usd.toFixed(2)}`)
29
30/** A small figure honestly: under a cent is not $0.00. */
31export const fmtSmallCost = (usd: number): string => (usd < 0.01 ? '<$0.01' : fmtCost(usd))
32
33/** An estimate is always marked as one. */
34export const fmtEstimate = (usd: number): string =>
35 usd < 0.01 ? '~<$0.01' : usd >= WHOLE_FROM ? `~$${Math.round(usd)}` : `~$${usd.toFixed(2)}`
36
37/** From an hour, `1h 05m`; from ten minutes, whole minutes; below, `M:SS`.
38 * The countdown is still for most of its life and ticks only when ticking
39 * means something. */
40export const fmtCountdown = (ms: number): string => {
41 const secs = Math.max(0, Math.round(ms / 1000))
42 if (secs >= 3600) {
43 const hours = Math.floor(secs / 3600)
44 return `${hours}h ${String(Math.floor((secs % 3600) / 60)).padStart(2, '0')}m`
45 }
46 if (secs >= 600) return `${Math.floor(secs / 60)}m`
47 return `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`
48}
49
50/** How far off a reset is: the time left, or that it has already passed. */
51export type ResetIn = { kind: 'in'; text: string } | { kind: 'passed' }
52
53/** When `iso` comes, from `now`; undefined without a readable time. */
54/** A span coarsely, to the minute: `2d 4h`, `3h 05m`, `12m`, `<1m`. */
55const fmtSpan = (ms: number): string => {
56 const mins = Math.floor(Math.max(0, ms) / 60_000)
57 const hours = Math.floor(mins / 60)
58 const days = Math.floor(hours / 24)
59 if (days) return `${days}d ${hours % 24}h`
60 if (hours) return `${hours}h ${String(mins % 60).padStart(2, '0')}m`
61 return mins > 0 ? `${mins}m` : '<1m'
62}
63
64export const resetIn = (iso: string | undefined, now: number): ResetIn | undefined => {
65 if (!iso) return undefined
66 const at = Date.parse(iso)
67 if (Number.isNaN(at)) return undefined
68 const secs = Math.floor((at - now) / 1000)
69 return secs <= 0 ? { kind: 'passed' } : { kind: 'in', text: fmtSpan(secs * 1000) }
70}
71
72/** The words for escalation: colour is never the only signal. */
73export const severityMark = (frac: number): string => (frac >= SEVERE_AT ? '!!' : frac >= WARN_AT ? '!' : '')
74
75/** A projection, never a countdown: 5-minute steps under an hour, 15 from one. */
76export const fmtEta = (ms: number): string => {
77 const mins = Math.max(0, ms) / 60_000
78 if (mins < 57.5) return `~${Math.max(5, Math.round(mins / 5) * 5)}m`
79 const quarter = Math.round(mins / 15) * 15
80 const hours = Math.floor(quarter / 60)
81 const rest = quarter % 60
82 return rest ? `~${hours}h ${rest}m` : `~${hours}h`
83}
84
85/** `text` at most `max` characters, cut in the middle: a branch keeps both
86 * its prefix and its end, where names differ. */
87export const clipMiddle = (text: string, max: number): string => {
88 const chars = [...text]
89 if (chars.length <= max) return text
90 if (max <= 1) return '…'.slice(0, max)
91 const head = Math.ceil((max - 1) / 2)
92 return `${chars.slice(0, head).join('')}…${chars.slice(chars.length - (max - 1 - head)).join('')}`
93}
94
95/** How long ago, coarsely: `2d 4h`, `3h 05m`, `12m`; under a minute is `now`. */
96export const fmtAgo = (ms: number): string => (ms < 60_000 ? 'now' : fmtSpan(ms))
97
98/** The context in use: its token count, else its percent of the window;
99 * undefined when the engine reports neither. */
100export const contextUsed = (ctx: Readonly<{ tokens?: number; percent?: number; window: number }>): number | undefined =>
101 ctx.tokens ?? (ctx.percent === undefined ? undefined : (ctx.percent / 100) * ctx.window)
102hooks/insights.ts 104 lines1// What the band can tell you that the engine's own figures don't: what your
2// last message cost, and when your pace fills the 5-hour limit.
3
4type Sample = { t: number; pct: number }
5
6const WINDOW_MS = 30 * 60_000
7const MIN_SPAN_MS = 10 * 60_000
8const MIN_RISE = 2
9const STALE_MS = 15 * 60_000
10// resetsAt readings of one window can differ by a few seconds.
11const SAME_RESET_MS = 60_000
12
13type InsightState = {
14 turnStartCost: Map<string, number>
15 samples: Sample[]
16 resetsAt: string | undefined
17}
18
19const state: InsightState = {
20 turnStartCost: new Map(),
21 samples: [],
22 resetsAt: undefined,
23}
24
25const turns: { lastTurnUsd: number | null } = { lastTurnUsd: null }
26
27/** What the last main-loop turn cost, read-only: set by noteTurnEnd. */
28export const insights: Readonly<typeof turns> = turns
29
30/** A new conversation (/clear, resume): its turns start over, but the
31 * 5-hour window is account-wide, so its pace carries on. */
32export const resetConversationInsights = (): void => {
33 state.turnStartCost.clear()
34 turns.lastTurnUsd = null
35}
36
37export const resetInsights = (): void => {
38 resetConversationInsights()
39 state.samples = []
40 state.resetsAt = undefined
41}
42
43export const noteTurnStart = (turnId: string, costUsd: number | undefined): void => {
44 if (costUsd !== undefined) state.turnStartCost.set(turnId, costUsd)
45}
46
47/** Everything the ledger rose by during the turn: its tool calls and
48 * subagents, and any background work that ran meanwhile. */
49export const noteTurnEnd = (turnId: string, costUsd: number | undefined): void => {
50 const start = state.turnStartCost.get(turnId)
51 state.turnStartCost.delete(turnId)
52 if (start === undefined || costUsd === undefined) return
53 const spent = costUsd - start
54 turns.lastTurnUsd = spent > 0 ? spent : null
55}
56
57const sameReset = (a: string | undefined, b: string | undefined): boolean => {
58 if (a === b) return true
59 if (a === undefined || b === undefined) return false
60 const da = Date.parse(a)
61 const db = Date.parse(b)
62 return !Number.isNaN(da) && !Number.isNaN(db) && Math.abs(da - db) < SAME_RESET_MS
63}
64
65export const noteFiveHour = (now: number, percentUsed: number, resetsAt: string | undefined): void => {
66 const last = state.samples[state.samples.length - 1]
67 if (!sameReset(resetsAt, state.resetsAt) || (last !== undefined && percentUsed < last.pct)) {
68 state.samples = []
69 }
70 state.resetsAt = resetsAt
71 state.samples.push({ t: now, pct: percentUsed })
72 state.samples = state.samples.filter(s => now - s.t <= WINDOW_MS)
73}
74
75/** Time until your pace fills the 5-hour window, or null while the
76 * evidence is thin, stale, or the window resets first. */
77export const fiveHourEtaMs = (now: number): number | null => {
78 const first = state.samples[0]
79 const last = state.samples[state.samples.length - 1]
80 if (first === undefined || last === undefined || last.pct >= 100) return null
81 const span = last.t - first.t
82 const rise = last.pct - first.pct
83 if (span < MIN_SPAN_MS || rise < MIN_RISE || now - last.t > STALE_MS) return null
84 const tFull = last.t + ((100 - last.pct) * span) / rise
85 if (state.resetsAt !== undefined) {
86 const resetAt = Date.parse(state.resetsAt)
87 if (!Number.isNaN(resetAt) && tFull >= resetAt) return null
88 }
89 return Math.max(0, tFull - now)
90}
91
92/** A toast re-arms once its figure falls back below this share. */
93export const TOAST_REARM_BELOW = 0.75
94
95/** Whether a toast speaks, and the level to remember: it speaks once per
96 * threshold crossed (at the highest reached), holds while the figure stays
97 * up, and re-arms, back to level 0, once the figure falls under the re-arm
98 * share. `prev` is the level remembered from before. */
99export const escalate = (prev: number, frac: number, levels: readonly number[]): Readonly<{ level: number; speak: boolean }> => {
100 const level = levels.filter(at => frac >= at).length
101 if (level > prev) return { level, speak: true }
102 return { level: frac < TOAST_REARM_BELOW ? 0 : prev, speak: false }
103}
104hooks/palette.ts 139 lines1// A filled pill needs its foreground and background from one source: a theme
2// key resolves against the user's theme, a hex does not, and mixing them makes
3// a pill that is legible on one theme and blank on the other. Nothing in the
4// API reports whether the theme is light or dark, so the palette is declared
5// rather than guessed: CC_BAND_APPEARANCE = dark | light | plain.
6export type Palette = {
7 filled: boolean
8 surface: string
9 value: string
10 label: string
11 /** A warm cache: the battery's charge, and the dot in plain appearance. */
12 warm: string
13 /** A cold cache's dot in plain appearance. */
14 cold: string
15 amberBg: string
16 amberFg: string
17 meterTrack: string
18 meterFill: string
19 /** The expanded view's cards: fill, edge, and their values. */
20 cardBg: string
21 cardBorder: string
22 cardValue: string
23 /** Hover explanations, a step above the cards. */
24 tooltipBg: string
25 /** A bar track's edge, so the track reads against any ground. */
26 trackStroke: string
27 /** The cache battery's charge, calm and in its last minute. */
28 batteryFill: string
29 batteryAmber: string
30 /** The cost pill's coin. */
31 coin: string
32 /** The 5-hour and 7-day chips' tints: background, text, and bar and icons. */
33 fiveBg: string
34 fiveFg: string
35 fiveAccent: string
36 weekBg: string
37 weekFg: string
38 weekAccent: string
39}
40
41export const DARK: Readonly<Palette> = {
42 filled: true,
43 surface: '#2b2b33',
44 value: '#ececf2',
45 label: '#9a9aa4',
46 warm: '#7fcf8a',
47 cold: '#6f6f7a',
48 amberBg: '#3a2f17',
49 amberFg: '#f0c969',
50 meterTrack: '#45454f',
51 meterFill: '#b8b8c2',
52 cardBg: '#2a2a31',
53 cardBorder: '#76767f',
54 cardValue: '#c4c4cc',
55 tooltipBg: '#303037',
56 trackStroke: '#7c7c86',
57 batteryFill: '#24402b',
58 batteryAmber: '#5a4719',
59 coin: '#c9a54a',
60 fiveBg: '#1e3324',
61 fiveFg: '#cfe8d3',
62 fiveAccent: '#7fcf8a',
63 weekBg: '#2a2540',
64 weekFg: '#d9d3f5',
65 weekAccent: '#a99cf0',
66}
67
68export const LIGHT: Readonly<Palette> = {
69 filled: true,
70 surface: '#ededf2',
71 value: '#1d1d22',
72 label: '#63636e',
73 warm: '#2f8a45',
74 cold: '#a0a0aa',
75 amberBg: '#fbeccd',
76 amberFg: '#7a4e06',
77 meterTrack: '#d6d6de',
78 meterFill: '#55555f',
79 cardBg: '#f4f4f7',
80 cardBorder: '#77777f',
81 cardValue: '#3a3a42',
82 tooltipBg: '#ffffff',
83 trackStroke: '#77777f',
84 batteryFill: '#cfe8d3',
85 batteryAmber: '#f3d9a0',
86 coin: '#9a7414',
87 fiveBg: '#dff0e0',
88 fiveFg: '#1f5c2e',
89 fiveAccent: '#2f8a45',
90 weekBg: '#e8e4fa',
91 weekFg: '#3c3489',
92 weekAccent: '#6b5fd3',
93}
94
95// No backgrounds at all: every colour is a theme key, so it follows whatever
96// theme the user has. The safe fallback, and what NO_COLOR terminals want.
97export const PLAIN: Readonly<Palette> = {
98 filled: false,
99 surface: '',
100 value: 'text',
101 label: 'subtle',
102 warm: 'success',
103 cold: 'subtle',
104 amberBg: '',
105 amberFg: 'warning',
106 meterTrack: 'subtle',
107 meterFill: 'text',
108 cardBg: '',
109 cardBorder: 'subtle',
110 cardValue: 'text',
111 tooltipBg: '',
112 trackStroke: 'subtle',
113 batteryFill: '',
114 batteryAmber: '',
115 coin: 'warning',
116 fiveBg: '',
117 fiveFg: 'text',
118 fiveAccent: 'success',
119 weekBg: '',
120 weekFg: 'text',
121 weekAccent: 'text',
122}
123
124/** The palette CC_BAND_APPEARANCE names; NO_COLOR forces plain. */
125export const resolvePalette = (appearance: string | undefined, noColor: string | undefined): Readonly<Palette> =>
126 noColor ? PLAIN : appearance === 'light' ? LIGHT : appearance === 'plain' ? PLAIN : DARK
127
128/** Colours for what sits on the band's bare ground, which is the host's and
129 * may be dark or light whatever the palette says. Text there takes theme
130 * keys; an icon needs hex, so these hold 3:1 on dark and light grounds
131 * alike. The branch icon is the band's one blue: it means git, never a
132 * status. */
133export const BARE = {
134 value: 'text',
135 label: 'subtle',
136 icon: '#80808a',
137 branch: '#5c85d6',
138} as const
139hooks/memory.ts 124 lines1// What the band remembers across sessions, in the plugin's own store: when
2// each session last had a reply, and what a token costs on each model. With
3// them a reopened session, or a reload, says whether its cache is cold and
4// what the next message costs, before any reply of its own.
5
6import { modelName, weightedTokens } from './cache'
7import { stripTrailingSlashes } from './workspace'
8
9/** Store keys. */
10export const SESSIONS_KEY = 'sessions'
11export const RATES_KEY = 'rates'
12
13/** Sessions kept, newest reply first; the rest are forgotten. */
14export const MAX_SESSIONS = 50
15
16/** Each session's last main-loop reply, by session id. */
17export type Sessions = Readonly<Record<string, Readonly<{ lastAt: number }>>>
18
19/** The base input rate per token, solved from a session's own bill, by model. */
20export type Rates = Readonly<Record<string, number>>
21
22const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
23
24/** The store's sessions, keeping only well-formed entries: it is data, not trusted. */
25export const asSessions = (v: unknown): Sessions =>
26 isRecord(v)
27 ? Object.fromEntries(
28 Object.entries(v).filter(
29 (entry): entry is [string, { lastAt: number }] =>
30 isRecord(entry[1]) && typeof entry[1].lastAt === 'number' && Number.isFinite(entry[1].lastAt),
31 ),
32 )
33 : {}
34
35/** The store's rates, keeping only positive finite numbers. */
36export const asRates = (v: unknown): Rates =>
37 isRecord(v)
38 ? Object.fromEntries(
39 Object.entries(v).filter((entry): entry is [string, number] => typeof entry[1] === 'number' && Number.isFinite(entry[1]) && entry[1] > 0),
40 )
41 : {}
42
43/** `sessions` with `id`'s reply at `lastAt`, the oldest dropped past MAX_SESSIONS. */
44export const rememberReply = (sessions: Sessions, id: string, lastAt: number): Sessions =>
45 Object.fromEntries(
46 Object.entries({ ...sessions, [id]: { lastAt } })
47 .sort(([, a], [, b]) => b.lastAt - a.lastAt)
48 .slice(0, MAX_SESSIONS),
49 )
50
51/** Where Claude Code keeps a session's transcript: its project folder named
52 * for the project root, every character but a letter or digit a dash. */
53export const transcriptPath = (home: string, root: string, id: string): string =>
54 `${stripTrailingSlashes(home)}/.claude/projects/${root.replace(/[^a-zA-Z0-9]/g, '-')}/${id}.jsonl`
55
56/** The most a mod may read in one go; a larger transcript stays unread. */
57export const READ_LIMIT = 4 * 1024 * 1024
58
59const parsed = (line: string): Record<string, unknown> | undefined => {
60 try {
61 const v: unknown = JSON.parse(line)
62 return isRecord(v) ? v : undefined
63 } catch {
64 return undefined
65 }
66}
67
68/** A transcript's lines from its end, parsed, the ones that name `marker`:
69 * a cheap text test first, so most lines are never parsed. */
70function* fromEnd(transcript: string, marker: string): Generator<Record<string, unknown>> {
71 const lines = transcript.split('\n')
72 for (let i = lines.length - 1; i >= 0; i--) {
73 const line = lines[i] ?? ''
74 if (!line.includes(marker)) continue
75 const entry = parsed(line)
76 if (entry !== undefined) yield entry
77 }
78}
79
80/** When a transcript's last assistant reply was. Its file time won't do:
81 * Claude Code writes a cost line each time it opens a session. */
82export const lastReplyAt = (transcript: string): number | undefined => {
83 for (const entry of fromEnd(transcript, '"assistant"')) {
84 if (entry.type !== 'assistant' || typeof entry.timestamp !== 'string') continue
85 const at = Date.parse(entry.timestamp)
86 if (Number.isFinite(at)) return at
87 }
88 return undefined
89}
90
91/** The model the transcript's last reply was billed under, as the API names it. */
92export const lastReplyModel = (transcript: string): string | undefined => {
93 for (const entry of fromEnd(transcript, '"assistant"')) {
94 if (entry.type !== 'assistant' || !isRecord(entry.message)) continue
95 const model = entry.message.model
96 if (typeof model === 'string' && model !== '') return model
97 }
98 return undefined
99}
100
101/** The base rate per token on `model`, solved from the transcript's last
102 * cost record: its dollars over its weighted tokens. */
103export const rateFromTranscript = (transcript: string, model: string): number | null => {
104 for (const entry of fromEnd(transcript, '"cost-state"')) {
105 if (entry.type !== 'cost-state') continue
106 const usage = entry.modelUsage
107 const m = isRecord(usage) ? usage[modelName(model)] : undefined
108 if (!isRecord(m)) return null
109 const n = (k: string) => (typeof m[k] === 'number' ? (m[k] as number) : 0)
110 const weighted = weightedTokens(
111 {
112 uncached: n('inputTokens'),
113 written: n('cacheCreationInputTokens'),
114 read: n('cacheReadInputTokens'),
115 output: n('outputTokens'),
116 },
117 model,
118 )
119 const rate = weighted > 0 ? n('costUSD') / weighted : 0
120 return Number.isFinite(rate) && rate > 0 ? rate : null
121 }
122 return null
123}
124hooks/workspace.ts 128 lines1// The workspace: git state from porcelain v2, and the folder path, as the band words them.
2
3export type GitState = Readonly<{
4 /** The branch, or undefined when HEAD is detached. */
5 branch: string | undefined
6 /** The short commit (7 chars), for a detached HEAD or a branch with no commits yet (undefined then). */
7 commit: string | undefined
8 /** The linked worktree's folder name; undefined in the main working tree. */
9 worktree: string | undefined
10 /** Files with uncommitted changes, staged, unstaged or untracked, each counted once. */
11 changed: number
12 /** Commits ahead of / behind the upstream; undefined when there is no upstream. */
13 ahead: number | undefined
14 behind: number | undefined
15}>
16
17/** Where the session is: its project, home-relative, git there, and, for a
18 * linked worktree, its main repository's folder name. */
19export type Workspace = Readonly<{ path: string; git: GitState | undefined; repoName: string | undefined }>
20
21/** Status without the index lock, so the band never blocks a commit, and
22 * without a repository's own fsmonitor, a program its config could name.
23 * Untracked files follow the user's own setting. */
24export const GIT_STATUS_ARGV: readonly string[] = [
25 'git',
26 '-c',
27 'core.fsmonitor=false',
28 '--no-optional-locks',
29 'status',
30 '--porcelain=v2',
31 '--branch',
32]
33
34/** Three lines: git-dir, common-dir, toplevel. */
35export const GIT_DIRS_ARGV: readonly string[] = [
36 'git',
37 'rev-parse',
38 '--path-format=absolute',
39 '--git-dir',
40 '--git-common-dir',
41 '--show-toplevel',
42]
43
44const lines = (text: string): string[] => text.split(/\r?\n/).map(line => line.replace(/\r$/, ''))
45
46/** A path without its trailing slashes. */
47export const stripTrailingSlashes = (p: string): string => p.replace(/\/+$/, '')
48
49/** The same, keeping a lone `/`, which is a root, not a slash. */
50const trimSlash = (p: string): string => (p.length > 1 ? stripTrailingSlashes(p) : p)
51
52const basename = (p: string): string => trimSlash(p).split('/').pop() ?? ''
53
54/** Porcelain v2 entries that are changes: ordinary, renamed, unmerged, untracked. */
55const CHANGED = /^[12u?] /
56
57const isAbsolute = (p: string): boolean => /^(\/|[A-Za-z]:[\\/])/.test(p)
58
59/** The worktree's folder name when git-dir and common-dir differ; else
60 * undefined. Git before 2.31 echoes the unknown `--path-format` back and
61 * answers relative paths, which can differ in the main tree, so only three
62 * absolute lines count. */
63const worktreeName = (dirs: string): string | undefined => {
64 const found = lines(dirs).filter(line => line !== '')
65 if (found.length !== 3 || !found.every(isAbsolute)) return undefined
66 const [gitDir, commonDir, top] = found
67 if (!gitDir || !commonDir || !top) return undefined
68 return trimSlash(gitDir) === trimSlash(commonDir) ? undefined : basename(top) || undefined
69}
70
71/** Undefined without a `# branch.head` line: not a repo, or not porcelain v2. */
72export const parseGitState = (status: string, dirs: string): GitState | undefined => {
73 let head: string | undefined
74 let oid: string | undefined
75 let ahead: number | undefined
76 let behind: number | undefined
77 let changed = 0
78 for (const line of lines(status)) {
79 if (line.startsWith('# branch.head ')) head = line.slice('# branch.head '.length)
80 else if (line.startsWith('# branch.oid ')) oid = line.slice('# branch.oid '.length)
81 else if (line.startsWith('# branch.ab ')) {
82 const m = /^# branch\.ab \+(\d+) -(\d+)$/.exec(line)
83 if (m) {
84 ahead = Number(m[1])
85 behind = Number(m[2])
86 }
87 } else if (CHANGED.test(line)) changed++
88 }
89 if (head === undefined) return undefined
90 return {
91 branch: head === '(detached)' ? undefined : head,
92 commit: oid && oid !== '(initial)' ? oid.slice(0, 7) : undefined,
93 worktree: worktreeName(dirs),
94 changed,
95 ahead,
96 behind,
97 }
98}
99
100/** `~` for home and `~/…` under it; anything else unchanged. */
101export const homeRelative = (path: string, home: string | undefined): string => {
102 const homeDir = home === undefined ? '' : stripTrailingSlashes(home)
103 if (!homeDir) return path
104 if (path === homeDir || path === `${homeDir}/`) return '~'
105 return path.startsWith(`${homeDir}/`) ? `~${path.slice(homeDir.length)}` : path
106}
107
108/** The parent keeps its trailing slash; a root is all name. */
109export const splitPath = (path: string): Readonly<{ parent: string; name: string }> => {
110 const p = trimSlash(path)
111 const cut = p.lastIndexOf('/')
112 if (p === '/' || cut < 0) return { parent: '', name: p }
113 return { parent: p.slice(0, cut + 1), name: p.slice(cut + 1) }
114}
115
116/** One plain line for a screen reader or a hover. */
117export const gitSummary = (g: GitState): string => {
118 const parts = [
119 g.branch !== undefined ? `branch ${g.branch}` : g.commit ? `detached at ${g.commit}` : 'detached',
120 ]
121 if (g.branch !== undefined && !g.commit) parts.push('no commits yet')
122 if (g.worktree) parts.push(`worktree ${g.worktree}`)
123 parts.push(g.changed ? `${g.changed} changed` : 'clean')
124 if (g.ahead) parts.push(`${g.ahead} ahead`)
125 if (g.behind) parts.push(`${g.behind} behind`)
126 return parts.join(', ')
127}
128hooks/icons.ts 119 lines1// The band's icons: one-colour SVG bodies for the desktop, the glyphs that
2// stand in for them elsewhere, and their names for a reader.
3
4/** An icon's side, in px. */
5export const ICON_PX = 16
6
7export type Icon =
8 | 'cost'
9 | 'tokens'
10 | 'context'
11 | 'five'
12 | 'week'
13 | 'reset'
14 | 'cache'
15 | 'limits'
16 | 'info'
17 | 'folder'
18 | 'branch'
19 | 'commit'
20 | 'worktree'
21 | 'changes'
22 | 'ahead'
23 | 'behind'
24
25/** The 5-hour gauge, which also heads the Limits card. */
26const GAUGE = (color: string): string =>
27 `<path d="M2.5 11.5a5.5 5.5 0 1 1 11 0" fill="none" stroke="${color}" stroke-width="1.4" stroke-linecap="round"/>` +
28 `<path d="M8 11.5l2.6-3.4" stroke="${color}" stroke-width="1.4" stroke-linecap="round"/>`
29
30/** Desktop icons, as SVG bodies drawn in one colour. */
31export const ICON_PATHS: Readonly<Record<Icon, (color: string) => string>> = {
32 cost: color =>
33 `<circle cx="8" cy="8" r="6.5" fill="none" stroke="${color}" stroke-width="1.4"/>` +
34 `<path d="M9.6 5.6c-.4-.5-1-.8-1.7-.8-1 0-1.7.6-1.7 1.3 0 1.7 3.6 1 3.6 2.9 0 .8-.8 1.4-1.9 1.4-.8 0-1.5-.3-1.9-.9M8 3.9v1M8 10.4v1.4" fill="none" stroke="${color}" stroke-width="1.2" stroke-linecap="round"/>`,
35 tokens: color =>
36 `<rect x="2.5" y="3" width="11" height="2.4" rx="1.2" fill="${color}"/>` +
37 `<rect x="2.5" y="6.8" width="11" height="2.4" rx="1.2" fill="${color}" opacity=".75"/>` +
38 `<rect x="2.5" y="10.6" width="11" height="2.4" rx="1.2" fill="${color}" opacity=".5"/>`,
39 context: color =>
40 `<rect x="2.5" y="2.5" width="11" height="11" rx="2.5" fill="none" stroke="${color}" stroke-width="1.4"/>` +
41 `<path d="M5.2 6h5.6M5.2 8.5h5.6M5.2 11h3.2" stroke="${color}" stroke-width="1.2" stroke-linecap="round"/>`,
42 five: GAUGE,
43 limits: GAUGE,
44 // A bolt: the cache is what makes a warm reply fast and cheap.
45 cache: color =>
46 `<path d="M9.2 1.8 3.6 9h3.9l-.8 5.2L12.4 7H8.5z" fill="none" stroke="${color}" stroke-width="1.3" stroke-linejoin="round"/>`,
47 info: color =>
48 `<circle cx="8" cy="8" r="6.5" fill="none" stroke="${color}" stroke-width="1.4"/>` +
49 `<path d="M8 7.3v4" stroke="${color}" stroke-width="1.4" stroke-linecap="round"/>` +
50 `<circle cx="8" cy="4.9" r=".9" fill="${color}"/>`,
51 folder: color =>
52 `<path d="M2.5 4.5A1.5 1.5 0 0 1 4 3h2.3l1.5 1.6H12a1.5 1.5 0 0 1 1.5 1.5v5.4A1.5 1.5 0 0 1 12 13H4a1.5 1.5 0 0 1-1.5-1.5z" fill="none" stroke="${color}" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>`,
53 branch: color =>
54 `<circle cx="4.75" cy="11.75" r="1.75" fill="none" stroke="${color}" stroke-width="1.4"/>` +
55 `<circle cx="11.25" cy="4.25" r="1.75" fill="none" stroke="${color}" stroke-width="1.4"/>` +
56 `<path d="M4.75 2.5V10M11.25 6c0 3-2.3 5.1-4.75 5.6" fill="none" stroke="${color}" stroke-width="1.4" stroke-linecap="round"/>`,
57 commit: color =>
58 `<circle cx="8" cy="8" r="2.6" fill="none" stroke="${color}" stroke-width="1.4"/>` +
59 `<path d="M2.5 8h2.9M10.6 8h2.9" fill="none" stroke="${color}" stroke-width="1.4" stroke-linecap="round"/>`,
60 // Rounded squares, where the branch has circles, so the two stay apart at 16px.
61 worktree: color =>
62 `<path d="M4 2.5v7.75a1.5 1.5 0 0 0 1.5 1.5H9M4 5h5" fill="none" stroke="${color}" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>` +
63 `<rect x="9" y="3.25" width="4.5" height="3.5" rx="1.2" fill="none" stroke="${color}" stroke-width="1.4"/>` +
64 `<rect x="9" y="10" width="4.5" height="3.5" rx="1.2" fill="none" stroke="${color}" stroke-width="1.4"/>`,
65 changes: color => `<path d="M8 2.5v7M4.5 6h7M4.5 13h7" fill="none" stroke="${color}" stroke-width="1.4" stroke-linecap="round"/>`,
66 ahead: color =>
67 `<path d="M8 13V3.5M4.5 7 8 3.5 11.5 7" fill="none" stroke="${color}" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>`,
68 behind: color =>
69 `<path d="M8 3v9.5M4.5 9 8 12.5 11.5 9" fill="none" stroke="${color}" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>`,
70 week: color =>
71 `<rect x="2.5" y="3.5" width="11" height="10" rx="2" fill="none" stroke="${color}" stroke-width="1.4"/>` +
72 `<path d="M2.5 6.5h11M5.5 2v3M10.5 2v3" stroke="${color}" stroke-width="1.4" stroke-linecap="round"/>`,
73 reset: color =>
74 `<circle cx="8" cy="8" r="5.6" fill="none" stroke="${color}" stroke-width="1.4"/>` +
75 `<path d="M8 5v3l2 1.4" stroke="${color}" stroke-width="1.4" stroke-linecap="round"/>`,
76}
77
78/** What stands in for an icon where there is no Svg; the cost keeps its `$`. */
79export const GLYPH: Readonly<Record<Icon, string>> = {
80 cost: '',
81 tokens: 'Σ',
82 context: '◔',
83 five: '',
84 week: '',
85 reset: '↻',
86 cache: '',
87 limits: '',
88 info: '',
89 // The text strip says these in words.
90 folder: '',
91 branch: '',
92 commit: '',
93 worktree: '',
94 changes: '',
95 ahead: '',
96 behind: '',
97}
98
99/** An icon's name for a reader that cannot see it. */
100export const ALT: Readonly<Record<Icon, string>> = {
101 cost: 'cost',
102 tokens: 'tokens',
103 context: 'context',
104 five: 'five-hour',
105 week: 'week',
106 reset: 'resets',
107 cache: 'cache',
108 limits: 'limits',
109 info: 'info',
110 // Each reads as a phrase with the text after it: "HEAD detached at a1b2c3d".
111 folder: 'folder',
112 branch: 'branch',
113 commit: 'HEAD',
114 worktree: 'linked',
115 changes: 'uncommitted',
116 ahead: 'ahead',
117 behind: 'behind',
118}
119hooks/kit.tsx 61 lines1// The drawing kit: what every part of the band draws with, made once per
2// draw from the surface's element table and the snapshot. JSX compiles to the
3// global h(), so nothing here, or anywhere, is named h.
4
5import type { ElementTable, RenderChildren } from 'claude-code'
6import { ALT, GLYPH, ICON_PATHS, ICON_PX } from './icons'
7import type { Icon } from './icons'
8import { DESKTOP, TERMINAL } from './layout'
9import type { Tone } from './reading'
10import type { BandSnapshot } from './snapshot'
11
12export const makeKit = (el: ElementTable, snap: BandSnapshot) => {
13 const { Box, Button, Text } = el
14 const palette = snap.palette
15 const measure = snap.surface === 'desktop' ? DESKTOP : TERMINAL
16 // Svg draws on the desktop alone (other surfaces hold the element but drop
17 // it), and its markup takes hex: theme keys can't reach inside it, so plain
18 // appearance keeps text.
19 const Svg = snap.surface === 'desktop' && palette.filled && 'Svg' in el ? el.Svg : undefined
20 const onTone = (tone: Tone, calm: string, amber: string = palette.amberFg) => (tone === 'amber' ? amber : calm)
21
22 /** A one-line explanation shown while its keyed parent is hovered. It has
23 * no key: a keyed Box is its own hover scope, and a hidden one could never
24 * be hovered. Plain has no background to cover the row with, so none. */
25 const hoverCard = (text: string, anchor: 'left' | 'right'): RenderChildren =>
26 palette.filled ? (
27 <Box
28 position="absolute"
29 top={0}
30 {...(anchor === 'left' ? { left: 0 } : { right: 0 })}
31 width={Math.min(text.length + 2, snap.columns)}
32 display="none"
33 hover={{ display: 'flex' }}
34 backgroundColor={palette.tooltipBg}
35 paddingX={1}
36 >
37 <Text color={palette.value} wrap="truncate-end">
38 {text}
39 </Text>
40 </Box>
41 ) : null
42
43
44 /** A column of air. The desktop drops a string child that is only spaces,
45 * so there the gap is an empty Box; a text surface keeps its space. */
46 const gap = (key: string): RenderChildren => (Svg ? <Box key={key} width={1} flexShrink={0} /> : ' ')
47
48 /** An icon and the gap after it: an Svg on desktop, a glyph elsewhere. */
49 const icon = (name: Icon, color: string, alt: string = ALT[name]): RenderChildren[] => {
50 if (Svg) {
51 const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${ICON_PX}" height="${ICON_PX}" viewBox="0 0 16 16">${ICON_PATHS[name](color)}</svg>`
52 return [<Svg key={`i-${name}`} source={source} alt={alt} width={ICON_PX} height={ICON_PX} />, gap(`g-${name}`)]
53 }
54 return GLYPH[name] ? [<Text key={`i-${name}`} color={color}>{`${GLYPH[name]} `}</Text>] : []
55 }
56
57 return { Box, Button, Text, Svg, palette, measure, columns: snap.columns, onTone, hoverCard, gap, icon }
58}
59
60export type Kit = ReturnType<typeof makeKit>
61hooks/layout.ts 110 lines1// How the band measures and fits a line: what gives way as it narrows, and
2// how many columns a drawn tree takes on each surface.
3
4import type { RenderChildren, RenderElement } from 'claude-code'
5
6/** What the row gives up as it narrows, least important first. The row is
7 * measured after each step; an amber piece holds out until the end. */
8export const GIVES_WAY = [
9 'tokens',
10 'weekReset',
11 'fiveReset',
12 'contextMeter',
13 'calmWeek',
14 'limitBars',
15 'shortWording',
16 'calmContext',
17 'calmFive',
18 'amberWeekReset',
19 'amberFiveReset',
20] as const
21export type Piece = (typeof GIVES_WAY)[number]
22
23/** For a give-way order: whether a line squeezed `squeeze` steps still keeps `piece`. */
24export const keepsIn =
25 <P extends string>(order: readonly P[]) =>
26 (squeeze: number, piece: P): boolean =>
27 squeeze <= order.indexOf(piece)
28
29export const keeps = keepsIn<Piece>(GIVES_WAY)
30
31/** What the workspace strip gives up as it narrows, least important first.
32 * The path, the branch and the change count's word shorten; the extras go
33 * whole, and the path's hover card still says everything. Uncommitted
34 * changes never go: they are what a narrow line must still say. */
35export const STRIP_GIVES_WAY = [
36 'clean',
37 'parent',
38 'changedWord',
39 'branchLong',
40 'worktreeOf',
41 'aheadBehind',
42 'branchShort',
43 'worktree',
44 'nameLong',
45 'nameShort',
46] as const
47export const stripKeeps = keepsIn<(typeof STRIP_GIVES_WAY)[number]>(STRIP_GIVES_WAY)
48
49/** Below this, the cache and context wording turns short whatever the squeeze. */
50export const SHORT_BELOW = 68
51export const METER_CELLS = 6
52export const METER_PX = 44
53/** The longest line a card holds unwrapped, in characters:
54 * `resets 2d 19h · full before reset`. */
55export const CARD_TEXT = 33
56/** Columns a framed Button's padding and edges take beyond its label. */
57export const BUTTON_CHROME = 3
58/** Columns a Button's hotkey mark takes beside its label. */
59export const HOTKEY_MARK = 2
60/** Room the band keeps free, so a row measured a little short never wraps. */
61export const ROW_SLACK = 4
62
63/** A bar's length: px on desktop, cells elsewhere. */
64export type BarSize = Readonly<{ px: number; cells: number }>
65export const CHIP_BAR: BarSize = { px: METER_PX, cells: METER_CELLS }
66
67/** How a surface lays text out against its bodyColumns: the terminal one cell
68 * a character; the desktop's proportional font runs about three quarters of
69 * a column, at roughly 10px a column. */
70export type Measure = Readonly<{ text: number; pxPerCell: number }>
71export const TERMINAL: Measure = { text: 1, pxPerCell: 8 }
72export const DESKTOP: Measure = { text: 0.75, pxPerCell: 10 }
73
74export const isList = (n: RenderChildren): n is readonly RenderChildren[] => Array.isArray(n)
75
76/** Columns a drawn tree takes: text, padding, gaps and Button labels. Hidden
77 * cards take none; an Svg takes its width in columns, rounded up. */
78export const cellsOf = (n: RenderChildren, m: Measure): number => {
79 if (n === null || n === undefined || typeof n === 'boolean') return 0
80 if (typeof n === 'string' || typeof n === 'number') return [...String(n)].length * m.text
81 if (isList(n)) return n.reduce((sum: number, k: RenderChildren) => sum + cellsOf(k, m), 0)
82 switch (n.type) {
83 case 'Button':
84 // A framed button's chrome: its padding and edges either side.
85 return [...(n.props.label ?? '')].length * m.text + (n.props.variant === undefined ? 0 : BUTTON_CHROME)
86 case 'Svg':
87 return Math.ceil((n.props.width ?? 64) / m.pxPerCell)
88 case 'Box':
89 case 'Text': {
90 if (n.props?.position === 'absolute') return 0
91 if (n.type === 'Box' && typeof n.props?.width === 'number') return n.props.width
92 const kids = (n.children ?? []).filter(k => k !== null && k !== undefined)
93 const pad = typeof n.props?.paddingX === 'number' ? 2 * n.props.paddingX : 0
94 const gap = typeof n.props?.columnGap === 'number' ? n.props.columnGap * Math.max(0, kids.length - 1) : 0
95 const own = kids.reduce((sum: number, k) => sum + cellsOf(k, m), 0) + pad + gap
96 return n.type === 'Box' && typeof n.props?.minWidth === 'number' ? Math.max(n.props.minWidth, own) : own
97 }
98 default:
99 return 0
100 }
101}
102
103/** The first squeeze at which `build` fits `room` columns, or the last tried:
104 * a line drops its pieces in the order its give-way table names them. */
105export const squeezeToFit = (build: (squeeze: number) => RenderElement, steps: number, room: number, m: Measure): RenderElement => {
106 let line = build(0)
107 for (let squeeze = 1; squeeze <= steps && cellsOf(line, m) > room; squeeze++) line = build(squeeze)
108 return line
109}
110hooks/reading.ts 138 lines1// What the band reads off its snapshot before it draws anything: the cache's
2// mood and every word about it, the context's fill, a limit's tone and pace.
3// Pure, so each can be checked without mounting the band.
4
5import { TTL_MS } from './cache'
6import { COMPACT_NEAR, SOON_MS, WARN_AT, clamp01, contextUsed, fmtCountdown, fmtEstimate, fmtTokens, resetIn } from './format'
7import type { BandSnapshot, LimitReading } from './snapshot'
8
9export type Tone = 'calm' | 'amber'
10
11// ---- the cache --------------------------------------------------------------
12
13type Cache = BandSnapshot['cache']
14
15/** Unmeasured: loaded mid-conversation, before its next reply. */
16export type CacheMood = 'unmeasured' | 'warming' | 'warm' | 'expiring' | 'cold'
17
18export const cacheMood = (c: Cache): CacheMood =>
19 c.requests === 0 && !c.recalled
20 ? c.fresh
21 ? 'warming'
22 : 'unmeasured'
23 : c.msLeft <= 0
24 ? 'cold'
25 : c.msLeft <= SOON_MS
26 ? 'expiring'
27 : 'warm'
28
29/** The battery's charge: the share of the lifetime left, full while Claude
30 * works, empty when there is nothing to count. */
31export const cacheCharge = (c: Cache, mood: CacheMood, isWorking: boolean): number =>
32 mood === 'unmeasured' || mood === 'warming' || mood === 'cold' ? 0 : mood === 'warm' && isWorking ? 1 : c.msLeft / TTL_MS[c.ttl]
33
34/** What the next message costs to rebuild the cache: dollars when a rate is
35 * known, else the tokens it writes. */
36export const reWarmEstimate = (c: Cache): string => (c.reWarmUsd !== null ? fmtEstimate(c.reWarmUsd) : `${fmtTokens(c.window)} tokens`)
37
38/** Every word the band says about the cache, for one mood: the pill, its
39 * hover, the card's headline and note, and the battery's name. One table,
40 * so a mood's wording changes in one place. */
41export type CacheCopy = Readonly<{
42 pill: (short: boolean) => string
43 hover: string
44 head: string
45 note: string | undefined
46 alt: string
47}>
48
49export const cacheCopy = (c: Cache, mood: CacheMood, isWorking: boolean): CacheCopy => {
50 const estimate = reWarmEstimate(c)
51 // What a warm read costs against input on the model in force: 5% on Opus 5.5.
52 const readPct = `${+(c.readShare * 100).toFixed(1)}%`
53 const left = fmtCountdown(c.msLeft)
54 const charge = cacheCharge(c, mood, isWorking)
55 const battery = charge <= 0 ? 'cache battery empty' : `cache battery ${Math.round(clamp01(charge) * 100)}% left`
56 const warmHover = `Warm cache bills input at ${readPct}; expires ${c.ttl} after a reply`
57 switch (mood) {
58 case 'unmeasured':
59 return {
60 pill: () => 'cache –',
61 hover: "Not measured since the band loaded; the countdown starts with Claude's next reply",
62 head: 'Not measured yet',
63 note: "Countdown starts with Claude's next reply.",
64 alt: 'cache not measured yet',
65 }
66 case 'warming':
67 return {
68 pill: () => 'cache warming',
69 hover: `Your first message builds the cache; after that it bills input at ${readPct}`,
70 head: 'Warming',
71 note: 'First message builds the cache.',
72 alt: 'cache warming',
73 }
74 case 'expiring':
75 return {
76 pill: short => (short ? `${left} ${estimate}` : `${left} left · re-warm ${estimate}`),
77 hover: warmHover,
78 head: `${left} left`,
79 note: undefined,
80 alt: battery,
81 }
82 case 'cold':
83 return {
84 // Cold is a price, not an error: neutral, no hue, no alarm.
85 pill: short => (short ? `cold ${estimate}` : `cache cold · next message ${estimate}`),
86 hover: `Cold: next message rebuilds ${fmtTokens(c.window)} tokens${c.reWarmUsd === null ? '' : ` (${fmtEstimate(c.reWarmUsd)})`}`,
87 head: 'Cold',
88 note: undefined,
89 alt: battery,
90 }
91 case 'warm':
92 return {
93 // Mid-turn every step restarts the TTL, so a countdown would only bounce.
94 pill: () => (isWorking ? 'cache warm' : `cache ${left}`),
95 hover: warmHover,
96 head: isWorking ? 'Warm' : `${left} left`,
97 note: undefined,
98 alt: battery,
99 }
100 }
101}
102
103// ---- the context ------------------------------------------------------------
104
105/** The context's fill, measured toward auto-compaction when it is known:
106 * full means compacting, and no tick is needed to show where. */
107export const contextReading = (ctx: BandSnapshot['context']) => {
108 const used = contextUsed(ctx) ?? 0
109 const frac = clamp01(used / (ctx.compactAt ?? ctx.window))
110 const nearCompact = ctx.compactAt !== undefined && frac >= COMPACT_NEAR
111 const tone: Tone = nearCompact || (ctx.compactAt === undefined && frac >= WARN_AT) ? 'amber' : 'calm'
112 return {
113 known: ctx.percent !== undefined || ctx.tokens !== undefined,
114 used,
115 frac,
116 pct: `${Math.round(frac * 100)}%`,
117 toCompact: ctx.compactAt === undefined ? undefined : Math.max(0, ctx.compactAt - used),
118 nearCompact,
119 tone,
120 }
121}
122
123// ---- the limits -------------------------------------------------------------
124
125/** A window past its reset: its last reading is from before it. */
126export const hasReset = (reading: LimitReading, now: number): boolean => resetIn(reading.resetsAt, now)?.kind === 'passed'
127
128/** The share of a window gone, from its length and its reset. */
129export const windowGone = (reading: LimitReading, windowMs: number | undefined, now: number): number | undefined => {
130 const at = reading.resetsAt === undefined ? NaN : Date.parse(reading.resetsAt)
131 return windowMs === undefined || Number.isNaN(at) || at <= now ? undefined : clamp01(1 - (at - now) / windowMs)
132}
133
134/** A limit's tone, the one rule chip and card share: amber at WARN_AT, or
135 * when a pace (`etaMs`) would fill it before it resets; calm once reset. */
136export const limitTone = (reading: LimitReading, now: number, etaMs: number | null = null): Tone =>
137 !hasReset(reading, now) && (clamp01(reading.percentUsed / 100) >= WARN_AT || etaMs !== null) ? 'amber' : 'calm'
138