SLOPSHOPPER

session-usage-band

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.

newbandcommandtoastprocesstimer
★ 2v0.11.12MITupdated 2026-10-09HMarzban/claude-mod/plugins/session-usage-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-usage-band
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /usage-band ⎿ session-usage-band: Usage band hidden. /usage-band shows it again. ◷ cache – $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◷ cache – $0.42
README

session-usage-band

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:

The band expanded on the desktop: chips for the cache, cost, context and the 5h and 7d limits, then the Cache, Spend, Context and Limits cards, the project path and branch, and the Collapse and Hide band buttons

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

Reading it

The chip row, labelled: the cache countdown and what a re-warm will cost, the session cost, context toward auto-compaction, the 5-hour limit with its pace, the weekly limit and its reset, and the toggle that opens the cards

ChipShowsTurns amber when
CacheA 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 yetIts last minute: 0:47 left · re-warm ~$0.52
CostThe session's total so farNever
TokensEvery token this conversation usedNever
ContextHow full the conversation is toward auto-compaction (63% full), or of the model window when compaction is offNear compaction: 95% full · compacts in ~8k. Without auto-compaction: 80% (!), 95% (!!)
5hYour 5-hour limit: usage on a bar with a thumb at the fill's end, and the reset80%, or when your pace would fill it before it resets: full in ~40m
7dYour weekly limit, the same way80%

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
PieceShows
PathThe project, home as ~, its folder in bold
BranchThe git branch, or detached at <commit>
Worktreeworktree of <repo> in a linked worktree
ChangesFiles with uncommitted changes, or clean
Ahead / behindCommits 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:

CardShows
CacheTime 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
SpendThe session total, a bar of the token split with its legend (input, output, cache reads), your last message
ContextHow full toward auto-compaction, tokens in context, where compaction runs, room left, the model window
LimitsThe 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.

When the band is narrow

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.

The cache countdown

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.

Reopening an old session

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:

  • from its own memory, which keeps each session's last reply (the newest 50) and the price per token it solved for each model
  • for a session from before the band, from the end of that session's transcript, read once: the last reply's time and the cost record's dollars and tokens

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 –.

What a cold cache costs

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 ~.

  • Writes at 1.25×, as Claude Code counts them. Claude Code's cost ledger prices every cache write at 1.25×, so the band does too and agrees with the cost it shows. Anthropic bills a 1-hour cache write at 2×, so if you pay per token on a 1-hour cache, the real re-warm is up to 1.6× the estimate. Paying per token usually means the 5-minute cache, where 1.25× is exact.
  • After /clear, a resume or a reload, the rate is solved from what the current conversation has spent, not the session's whole ledger.

Your pace on the 5-hour limit

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.

Toasts

A toast speaks where a chip turns amber:

  • the 5-hour limit at 80% and 95%
  • context within 10% of auto-compaction, or at 80% and 95% when auto-compaction is off

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.

Appearance

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.

Commands

CommandEffect
/usage-bandToggle visibility
/usage-band more / lessOpen or close the cards
/usage-band show / hideSet visibility explicitly

Cache lifetime

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 1h
  • FORCE_PROMPT_CACHING_5M=1
  • ENABLE_PROMPT_CACHING_1H=1

Install

claude 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.

Developing

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.

Help make it better

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.

Source 15 files
hooks/register.tsx 442 lines
1// 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}
442
hooks/band.tsx 738 lines
1// 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}
738
hooks/cache.ts 315 lines
1// 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}
315
hooks/format.ts 102 lines
1// 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)
102
hooks/insights.ts 104 lines
1// 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}
104
hooks/palette.ts 139 lines
1// 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
139
hooks/memory.ts 124 lines
1// 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}
124
hooks/workspace.ts 128 lines
1// 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}
128
hooks/icons.ts 119 lines
1// 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}
119
hooks/kit.tsx 61 lines
1// 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>
61
hooks/layout.ts 110 lines
1// 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}
110
hooks/reading.ts 138 lines
1// 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