Context fill, session cost and prompt-cache countdown in the prompt footer, a 5-minute cache warning with Compact or Let it expire, /usage-band to explain the…

Context, prompt cache and cost for Claude Code.
<img width="1648" height="272" alt="Screeshot-2026-10-16 at 19 05 22@2x" src="https://github.com/user-attachments/assets/40dbb58d-5741-429e-8f62-c4d5b3a75795" />
Three numbers in the prompt footer, beside the permission mode and the model: 34% · ~$13.52 · 59m.
| Number | /usage-band explains |
|---|---|
34% context | Tokens used of the auto-compact window (as the app's Context panel counts), and when Handoff is offered. Amber from 50%, red from 80%. |
~$13.52 cost | Session cost at API prices and the last turn's cost. ~ means an estimate on a subscription. |
59m cache | When the warm cache expires (clock time), the TTL, and the last request's hit rate. ≈ means the TTL is assumed until the turn ends; cold means expired or a new model. |
Type /usage-band for what each number means, with current values. The desktop footer draws text only (no Svg, no hover), so it cannot carry tooltips.
Five minutes before a warm cache expires (contexts of 20k tokens or more), you get a toast and a macOS notification, and a row above the prompt offers Compact or Let it expire. On desktop, $.ui.notify has no channel, so the notification goes through terminal-notifier when it is installed. A click on it opens the session that sent it. Without terminal-notifier, the notification goes through osascript, and macOS credits it to Script Editor, so a click opens Script Editor.
Handoff appears above the prompt when context passes 80%. /handoff works at any time: it writes a handoff note, runs /clear, and puts the note (and your draft) in the prompt box.
At the prompt of a terminal Claude Code session:
/plugin install usage-band-plus --marketplace lucasproko/claude-code-mod-cache-cost-bar
Answer y to add the marketplace, then pick the user scope.
From a shell:
claude plugin install usage-band-plus --marketplace lucasproko/claude-code-mod-cache-cost-bar
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with lucasproko/claude-code-mod-cache-cost-bar, then Add plugin.
On macOS, also install terminal-notifier, so a click on the cache notification opens its session:
brew install terminal-notifier
The first notification asks to allow notifications from terminal-notifier. Set its alert style to Banners or Alerts in System Settings > Notifications.
Start a new session afterwards. Settings are in the config menu under usage-band-plus.
claude plugin marketplace update lucasproko-plugins
claude plugin update usage-band-plus@lucasproko-plugins
turn.step usage, not a shell read of the transcript. Subagent requests are ignored.session.measure. A 30s timer moves the reset countdowns and picks up /model switches.[1m] session no longer shows it by mistake.Timer.cancel(). On this build's API, $.clock.every returns a Timer object; the original calls it as a function, which would throw on a second session.start.$.model.fork by default (full cached transcript, the session's model) and falls back to Haiku on an API error. The haiku setting is cheaper and now includes tool calls and file paths./clear (typed or from Handoff), the cache segment and last-turn cost reset./usage-band explains them.All in the config menu (userConfig): placement (footer or band), context thresholds, whether cache and cost show, cache TTL (auto, 1h, 5m), the expiry warning in minutes, the handoff writer (fork or haiku), the context % that highlights Handoff, and handoff confirmation.
auto TTL reads the transcript once per turn with sh, so it is macOS and Linux only. On Windows, set 1h or 5m.
claude plugin validate .
claude plugin test .hooks/register.tsx 683 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register, SessionUsage, Timer } from 'claude-code'
3
4import type { Cache, Usage } from '../types'
5
6const usageAtom = atom({ plugin: 'usage-band-plus', key: 'usage' } as const, null)
7const cacheAtom = atom({ plugin: 'usage-band-plus', key: 'cache' } as const, null)
8const nowAtom = atom({ plugin: 'usage-band-plus', key: 'now' } as const, 0)
9const lastTurnUsdAtom = atom({ plugin: 'usage-band-plus', key: 'lastTurnUsd' } as const, null)
10const handoffArmedUntilAtom = atom({ plugin: 'usage-band-plus', key: 'handoffArmedUntil' } as const, 0)
11const isHandoffBusyAtom = atom({ plugin: 'usage-band-plus', key: 'isHandoffBusy' } as const, false)
12const dismissedCacheAtAtom = atom({ plugin: 'usage-band-plus', key: 'dismissedCacheAt' } as const, null)
13const isCompactingAtom = atom({ plugin: 'usage-band-plus', key: 'isCompacting' } as const, false)
14
15type $ = EngineInterface
16
17const TTL_MS = { '5m': 5 * 60 * 1000, '1h': 60 * 60 * 1000 }
18const USAGE_REFRESH_MS = 30_000
19const SECONDS_COUNTDOWN_MS = 10 * 60 * 1000
20const HANDOFF_CONFIRM_MS = 5000
21// rebuilding a small context is cheap, so the expiry warning skips it
22const NUDGE_MIN_CONTEXT_TOKENS = 20_000
23type Settings = {
24 placement: 'footer' | 'band'
25 contextWarnPercent: number
26 contextCritPercent: number
27 showCache: boolean
28 showCost: boolean
29 cacheTtl: 'auto' | '1h' | '5m'
30 cacheNudgeMinutes: number
31 handoffMode: 'fork' | 'haiku'
32 handoffAtPercent: number
33 confirmHandoff: boolean
34}
35
36const readSettings = (options: PluginOptions): Settings => {
37 const num = (key: string, fallback: number) =>
38 typeof options[key] === 'number' ? (options[key] as number) : fallback
39 const bool = (key: string, fallback: boolean) =>
40 typeof options[key] === 'boolean' ? (options[key] as boolean) : fallback
41 return {
42 placement: options.placement === 'band' ? 'band' : 'footer',
43 contextWarnPercent: num('contextWarnPercent', 50),
44 contextCritPercent: num('contextCritPercent', 80),
45 showCache: bool('showCache', true),
46 showCost: bool('showCost', true),
47 cacheTtl: options.cacheTtl === '1h' || options.cacheTtl === '5m' ? options.cacheTtl : 'auto',
48 cacheNudgeMinutes: num('cacheNudgeMinutes', 5),
49 handoffMode: options.handoffMode === 'haiku' ? 'haiku' : 'fork',
50 handoffAtPercent: num('handoffAtPercent', 80),
51 confirmHandoff: bool('confirmHandoff', true),
52 }
53}
54
55// One id per cache: drops a [1m] suffix, a date and a provider prefix. An alias
56// such as "opus" gives null, because it cannot be compared with an API id.
57const cacheModelKey = (id: string) =>
58 id.toLowerCase().match(/claude-(?:opus|sonnet|haiku|fable)-\d+(?:-\d{1,2}(?!\d))?/)?.[0] ?? null
59
60type Tone = 'normal' | 'warning' | 'error' | 'muted'
61
62// theme keys, so the readout follows the app's light or dark theme
63const TONE_COLOR: Record<Tone, string> = {
64 normal: 'inactive',
65 warning: 'warning',
66 error: 'error',
67 muted: 'subtle',
68}
69
70const tok = (n: number) =>
71 n >= 1e6 ? `${+(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${+(n / 1e3).toFixed(1)}k` : `${Math.round(n)}`
72
73const dur = (sec: number) => {
74 const s = Math.max(0, Math.floor(sec))
75 const d = Math.floor(s / 86400)
76 const h = Math.floor((s % 86400) / 3600)
77 const m = Math.floor((s % 3600) / 60)
78 return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : m ? `${m}m` : `${s}s`
79}
80
81const clock = (sec: number) => {
82 const s = Math.max(0, Math.floor(sec))
83 // whole minutes up to the 1-hour TTL, so a fresh cache reads 60m, not 1h 0m
84 if (s * 1000 >= SECONDS_COUNTDOWN_MS) return s <= 3600 ? `${Math.floor(s / 60)}m` : dur(s)
85 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
86}
87
88const usd = (amount: number) => `$${amount.toFixed(2)}`
89
90const toUsage = (u: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>, model: string | null): Usage => {
91 const window = compactWindowTokens ?? u.context.window
92 const tokens = u.context.tokens ?? null
93 return {
94 model,
95 ctxPercent: tokens !== null && window ? Math.min(100, (tokens / window) * 100) : (u.context.percent ?? null),
96 ctxTokens: tokens,
97 ctxWindow: window,
98 modelWindow: u.context.window,
99 usd: u.cost?.usd ?? null,
100 limits: u.rateLimits.map(l => ({ ...l })),
101 }
102}
103
104async function markNow($: $) {
105 const now = await $.clock.now()
106 await update($, nowAtom, () => now)
107}
108
109async function readModel($: $) {
110 try {
111 return await $.session.model()
112 } catch {
113 return null
114 }
115}
116
117// The plugin API does not report the cache TTL, but each API response in the
118// transcript does. A pure cache hit writes nothing, so the latest response that
119// wrote decides. POSIX only: on Windows this rejects and the default stands.
120async function readTtlFromTranscript($: $): Promise<number | null> {
121 const id = await $.session.id()
122 if (!/^[0-9a-f-]{36}$/.test(id)) return null
123 const r = await $.process.run([
124 'sh',
125 '-c',
126 `tail -n 400 "\${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/projects/*/${id}.jsonl 2>/dev/null | grep '"cache_creation"' | tail -n 30`,
127 ])
128 for (const line of r.stdout.split('\n').reverse()) {
129 let entry: any
130 try {
131 entry = JSON.parse(line)
132 } catch {
133 continue
134 }
135 const written = entry?.message?.usage?.cache_creation
136 if (entry.type !== 'assistant' || entry.isSidechain || !written) continue
137 if (written.ephemeral_1h_input_tokens > 0) return TTL_MS['1h']
138 if (written.ephemeral_5m_input_tokens > 0) return TTL_MS['5m']
139 }
140 return null
141}
142
143type Item = { key: string; value: string; tone: Tone; detail: string }
144
145const ttlLabel = (ttlMs: number) => (ttlMs === TTL_MS['5m'] ? '5-minute' : '1-hour')
146
147const clockTime = (ms: number) => {
148 try {
149 return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
150 } catch {
151 return new Date(ms).toISOString().slice(11, 16)
152 }
153}
154
155// The footer shows three bare numbers; each `detail` says what one means, for
156// /usage-band (the desktop footer draws text only, so it has no tooltips).
157function buildItems(usage: Usage, cache: Cache | null, now: number, lastTurnUsd: number | null): Item[] {
158 const items: Item[] = []
159
160 if (usage.ctxPercent !== null) {
161 const pct = usage.ctxPercent
162 const tone: Tone = pct >= settings.contextCritPercent ? 'error' : pct >= settings.contextWarnPercent ? 'warning' : 'normal'
163 items.push({
164 key: 'context',
165 value: `${Math.round(pct)}%`,
166 tone,
167 detail: [
168 `Context: ${tok(usage.ctxTokens ?? 0)} of ${tok(usage.ctxWindow)} tokens (${Math.round(pct)}%)`,
169 ...(usage.ctxWindow < usage.modelWindow
170 ? [`Measured against the auto-compact window; the model allows ${tok(usage.modelWindow)}.`]
171 : []),
172 settings.handoffAtPercent > 0
173 ? `At ${settings.handoffAtPercent}% a Handoff prompt appears. /handoff works at any time.`
174 : '/handoff starts a fresh session with a handoff note.',
175 ].join('\n'),
176 })
177 }
178
179 if (settings.showCost && usage.usd !== null) {
180 const isEstimate = usage.limits.length > 0
181 items.push({
182 key: 'cost',
183 value: `${isEstimate ? '~' : ''}${usd(usage.usd)}`,
184 tone: 'normal',
185 detail: [
186 `Session cost at API prices: ${usd(usage.usd)}`,
187 ...(lastTurnUsd !== null && lastTurnUsd > 0 ? [`Last turn: +${usd(lastTurnUsd)}`] : []),
188 ...(isEstimate ? ['On a subscription this is an estimate, not a charge.'] : []),
189 ].join('\n'),
190 })
191 }
192
193 if (settings.showCache && cache) {
194 // `now` can lag the request that wrote the cache, so cap at the TTL
195 const leftSec = Math.min(cache.ttlMs, cache.at + cache.ttlMs - now) / 1000
196 const sessionKey = usage.model ? cacheModelKey(usage.model) : null
197 const cacheKey = cacheModelKey(cache.model)
198 const isSwitched = sessionKey !== null && cacheKey !== null && sessionKey !== cacheKey
199 const hitLine = `The last request read ${Math.round(cache.hitPct)}% of its input from cache.`
200 const ttlLine = cache.isTtlKnown
201 ? `${ttlLabel(cache.ttlMs)} cache; each request restarts it.`
202 : `${ttlLabel(cache.ttlMs)} cache assumed until this turn ends; each request restarts it.`
203 if (isSwitched) {
204 items.push({
205 key: 'cache',
206 value: 'cold',
207 tone: 'warning',
208 detail: 'Prompt cache: cold\nYou switched models. Each model has its own cache, so the next message rebuilds it at full input price.',
209 })
210 } else if (leftSec > 0) {
211 items.push({
212 key: 'cache',
213 value: `${cache.isTtlKnown ? '' : '≈'}${clock(leftSec)}`,
214 tone: leftSec * 1000 <= settings.cacheNudgeMinutes * 60 * 1000 ? 'warning' : 'normal',
215 detail: [`Prompt cache: warm until ${clockTime(cache.at + cache.ttlMs)}`, ttlLine, hitLine].join('\n'),
216 })
217 } else {
218 items.push({
219 key: 'cache',
220 value: 'cold',
221 tone: 'muted',
222 detail: `Prompt cache: expired at ${clockTime(cache.at + cache.ttlMs)}\nThe next message rebuilds it at full input price.\n${hitLine}`,
223 })
224 }
225 }
226
227 return items
228}
229
230const DOT = '\u2002·\u2002'
231
232const HANDOFF_PROMPT =
233 'Write a hand-off note so a fresh coding session can continue this work. Cover the goal, what was done (files changed, decisions and why), the current state, open issues, and the next steps. Be specific and brief. Reply with the note only.'
234
235async function writeHandoff($: $, settings: Settings): Promise<string | null | 'empty'> {
236 if (settings.handoffMode === 'fork') {
237 const r = await $.model.fork({ prompt: HANDOFF_PROMPT })
238 if (r.isAnswered) return r.text
239 if (r.reason === 'nothing-to-fork') return 'empty'
240 // a near-full context is when the fork is most likely to fail
241 if (r.reason !== 'api-error') return null
242 }
243 return writeHandoffWithHaiku($)
244}
245
246async function writeHandoffWithHaiku($: $): Promise<string | null | 'empty'> {
247 const messages = await $.session.messages()
248 const transcript = messages
249 .map(m => {
250 const tools = m.toolUses
251 .map(t => {
252 const target = t.input.file_path ?? t.input.path ?? t.input.command
253 return typeof target === 'string' ? `${t.tool}(${target.slice(0, 200)})` : t.tool
254 })
255 .join(', ')
256 const speaker = m.role === 'user' ? 'User' : 'Assistant'
257 return `${speaker}: ${m.text}${tools ? `\n[tools: ${tools}]` : ''}`
258 })
259 .filter(line => line.length > 12)
260 .join('\n\n')
261 .slice(-60000)
262 if (!transcript) return 'empty'
263 const r = await $.model.complete({
264 model: 'haiku',
265 maxTokens: 1500,
266 timeoutMs: 90000,
267 system: 'You write concise hand-off notes so a fresh coding session can continue seamlessly.',
268 prompt: `${HANDOFF_PROMPT}\n\n${transcript}`,
269 })
270 return r.isAnswered ? r.text : null
271}
272
273async function runHandoff($: $, settings: Settings) {
274 if (await read($, isHandoffBusyAtom)) return
275 await update($, isHandoffBusyAtom, () => true)
276 try {
277 $.ui.toast('Writing handoff…')
278 const note = await writeHandoff($, settings)
279 if (note === 'empty') {
280 $.ui.toast('Nothing to hand off yet')
281 return
282 }
283 if (!note) {
284 $.ui.toast('Handoff failed; session kept')
285 return
286 }
287 // /clear empties the prompt box, so keep what the person had typed
288 const draft = (await $.prompt.read()).text.trim()
289 await $.command.run({ command: 'clear' })
290 await resetConversationState($)
291 await $.prompt.fill({
292 text: `Handoff from my previous session:\n\n${note}\n\nContinue from here.${draft ? `\n\n${draft}` : ''}`,
293 })
294 $.ui.toast('New session started. The handoff is in the prompt box; press Enter to send.')
295 } catch {
296 $.ui.toast('Handoff failed; session kept')
297 } finally {
298 await update($, isHandoffBusyAtom, () => false)
299 }
300}
301
302async function pressHandoff($: $, settings: Settings) {
303 if (!settings.confirmHandoff) return runHandoff($, settings)
304 const now = await $.clock.now()
305 if ((await read($, handoffArmedUntilAtom)) > now) {
306 await update($, handoffArmedUntilAtom, () => 0)
307 return runHandoff($, settings)
308 }
309 await update($, handoffArmedUntilAtom, () => now + HANDOFF_CONFIRM_MS)
310 await markNow($)
311}
312
313// module state: a reload runs this file again, so it starts over
314let settings: Settings = readSettings({})
315let fixedTtlMs: number | null = null
316let refreshTimer: Timer | undefined
317let tickTimer: Timer | undefined
318let isTurnRunning = false
319let turnStartUsd: number | null = null
320let nudgedCacheAt: number | null = null
321let hasSuggestedHandoff = false
322let compactWindowTokens: number | null = null
323
324
325// /clear starts a new conversation, but $.state and module state outlive it
326async function resetConversationState($: $) {
327 turnStartUsd = null
328 nudgedCacheAt = null
329 hasSuggestedHandoff = false
330 await update($, cacheAtom, () => null)
331 await update($, lastTurnUsdAtom, () => null)
332}
333
334async function storeUsage($: $, usage: Usage) {
335 await update($, usageAtom, () => usage)
336 const isPastHandoff =
337 settings.handoffAtPercent > 0 && (usage.ctxPercent ?? 0) >= settings.handoffAtPercent
338 if (isPastHandoff && !hasSuggestedHandoff) {
339 hasSuggestedHandoff = true
340 $.ui.toast(`Context is ${Math.round(usage.ctxPercent ?? 0)}% full. Handoff is ready above the prompt, or type /handoff.`, {
341 timeoutMs: 8000,
342 })
343 }
344}
345
346async function refreshUsage($: $) {
347 try {
348 // "summary" estimates locally and sends nothing; only its window is used
349 const u = await $.session.usage({ breakdown: 'summary' })
350 compactWindowTokens = u.context.breakdown?.rawMaxTokens ?? null
351 await storeUsage($, toUsage(u, await readModel($)))
352 await markNow($)
353 } catch {
354 // session not bound yet; the next refresh tries again
355 }
356}
357
358const appleScriptString = (text: string) => `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
359
360// The desktop app's deep link accepts only its own local_ session id, not the CLI's.
361async function readHostSessionId($: $) {
362 try {
363 const hostSessionId = await $.env.get('CLAUDE_CODE_HOST_SESSION_ID')
364 if (hostSessionId && /^local_[A-Za-z0-9-]{1,64}$/.test(hostSessionId)) return hostSessionId
365 } catch {}
366 return null
367}
368
369// The desktop app has no notification channel for plugins ($.ui.notify answers
370// no-surface there). terminal-notifier can open this session on click; an
371// osascript notification belongs to Script Editor, so a click opens that instead.
372// terminal-notifier is started through LaunchServices: run as a child of the
373// Claude process, macOS refuses its notification permission.
374async function sendNotification($: $, title: string, text: string) {
375 try {
376 const sent = await $.ui.notify(text, { title })
377 if (sent.isSent) return
378 } catch {}
379 const hostSessionId = await readHostSessionId($)
380 const clickArgs = hostSessionId
381 ? ['-open', `claude://code/continue?session=${hostSessionId}`, '-group', `usage-band-plus-${hostSessionId}`]
382 : ['-activate', 'com.anthropic.claudefordesktop']
383 try {
384 const r = await $.process.run([
385 'open', '-n', '-g', '-b', 'fr.julienxx.oss.terminal-notifier',
386 '--args', '-title', title, '-message', text, ...clickArgs,
387 ])
388 if (r.exitCode === 0) return
389 } catch {}
390 try {
391 await $.process.run([
392 'osascript',
393 '-e',
394 `display notification ${appleScriptString(text)} with title ${appleScriptString(title)}`,
395 ])
396 } catch {}
397}
398
399const isCacheNudgeWindow = (leftMs: number, usage: Usage) => {
400 const nudgeMs = settings.cacheNudgeMinutes * 60 * 1000
401 return nudgeMs > 0 && leftMs > 0 && leftMs <= nudgeMs && (usage.ctxTokens ?? 0) >= NUDGE_MIN_CONTEXT_TOKENS
402}
403
404async function compactForCache($: $) {
405 if (await read($, isCompactingAtom)) return
406 await update($, isCompactingAtom, () => true)
407 try {
408 const cacheAt = (await read($, cacheAtom))?.at ?? null
409 let result: Awaited<ReturnType<typeof $.session.compact>>
410 try {
411 result = await $.session.compact()
412 } catch {
413 // An SDK-hosted session (the desktop app) rejects $.session.compact:
414 // there, compaction runs only as the /compact command.
415 void $.command
416 .run({ command: 'compact' })
417 .catch(err => $.ui.toast(`Compact failed; the session is unchanged: ${String(err)}`))
418 await update($, dismissedCacheAtAtom, () => cacheAt)
419 return
420 }
421 if ('skip' in result) $.ui.toast(`Compact skipped: ${result.skip}`)
422 else await update($, dismissedCacheAtAtom, () => cacheAt)
423 } catch (err) {
424 $.ui.toast(`Compact failed; the session is unchanged: ${String(err)}`)
425 } finally {
426 await update($, isCompactingAtom, () => false)
427 }
428}
429
430async function tick($: $) {
431 try {
432 const now = await $.clock.now()
433 const cache = await read($, cacheAtom)
434 const usage = await read($, usageAtom)
435 const armedUntil = await read($, handoffArmedUntilAtom)
436 const leftMs = cache ? cache.at + cache.ttlMs - now : -Infinity
437 const isCountingSeconds = leftMs > -1000 && leftMs < SECONDS_COUNTDOWN_MS
438 if (armedUntil && armedUntil <= now) await update($, handoffArmedUntilAtom, () => 0)
439 if (isCountingSeconds || armedUntil) await update($, nowAtom, () => now)
440
441 const isNudgeDue =
442 cache !== null &&
443 usage !== null &&
444 !isTurnRunning &&
445 isCacheNudgeWindow(leftMs, usage) &&
446 nudgedCacheAt !== cache.at
447 if (isNudgeDue) {
448 nudgedCacheAt = cache.at
449 const minutes = Math.max(1, Math.round(leftMs / 60000))
450 const text = `Prompt cache expires in about ${minutes} min. Compact from the prompt, or let it expire.`
451 // macOS often hides a system notification while the app is in front, so toast too
452 $.ui.toast(text, { timeoutMs: 10000 })
453 void sendNotification($, 'Prompt cache', text)
454 }
455 } catch {}
456}
457
458export const register: Register = (on, options) => {
459 settings = readSettings(options)
460 fixedTtlMs = settings.cacheTtl === 'auto' ? null : TTL_MS[settings.cacheTtl]
461
462 on('session.start', async ($, e, next) => {
463 refreshTimer?.cancel()
464 tickTimer?.cancel()
465 hasSuggestedHandoff = false
466 nudgedCacheAt = null
467 // session.measure pushes usage changes; this slow timer moves the reset
468 // countdowns and picks up a /model switch, which measures nothing
469 refreshTimer = $.clock.every(USAGE_REFRESH_MS, () => refreshUsage($))
470 tickTimer = $.clock.every(1000, () => tick($))
471 try {
472 await $.command.register({
473 name: 'handoff',
474 description: 'Write a handoff note, clear the session, and put the note in the prompt box',
475 })
476 await $.command.register({
477 name: 'usage-band',
478 description: 'Explain the footer numbers: context, cost and prompt cache, with current values',
479 })
480 } catch {}
481 await markNow($)
482 await refreshUsage($)
483 return next(e)
484 })
485
486 on('session.end', async ($, e, next) => {
487 if (e.reason === 'clear') {
488 try {
489 await resetConversationState($)
490 } catch {}
491 }
492 return next(e)
493 })
494
495 on('session.measure', async ($, e, next) => {
496 try {
497 await storeUsage($, toUsage(e, await readModel($)))
498 // measure may land after turn.complete, with the turn's final request counted
499 if (e.cost && turnStartUsd !== null) {
500 const turnUsd = e.cost.usd - turnStartUsd
501 await update($, lastTurnUsdAtom, () => turnUsd)
502 }
503 await markNow($)
504 } catch {}
505 return next(e)
506 })
507
508 on('turn.start', async ($, e, next) => {
509 if (e.agentId === undefined) {
510 isTurnRunning = true
511 try {
512 turnStartUsd = (await $.session.usage()).cost?.usd ?? null
513 } catch {
514 turnStartUsd = null
515 }
516 }
517 return next(e)
518 })
519
520 on('turn.step', async function* ($, e, next) {
521 const result = yield* next(e)
522 if (e.agentId !== undefined || !result.usage) return result
523 const u = result.usage
524 const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
525 const at = await $.clock.now()
526 await update($, cacheAtom, (prev: Cache | null) => ({
527 at,
528 model: u.model,
529 hitPct: total ? (u.cache_read_input_tokens / total) * 100 : 0,
530 ttlMs: fixedTtlMs ?? prev?.ttlMs ?? TTL_MS['1h'],
531 isTtlKnown: fixedTtlMs !== null || (prev?.isTtlKnown ?? false),
532 }))
533 return result
534 })
535
536 on('turn.complete', async ($, e, next) => {
537 const result = await next(e)
538 if (e.agentId !== undefined) return result
539 isTurnRunning = false
540 try {
541 const endUsd = (await $.session.usage()).cost?.usd ?? null
542 if (endUsd !== null && turnStartUsd !== null) {
543 const turnUsd = endUsd - turnStartUsd
544 await update($, lastTurnUsdAtom, () => turnUsd)
545 }
546 } catch {}
547 if (fixedTtlMs === null) {
548 try {
549 const ttlMs = await readTtlFromTranscript($)
550 if (ttlMs !== null) {
551 await update($, cacheAtom, (cur: Cache | null) => (cur ? { ...cur, ttlMs, isTtlKnown: true } : cur))
552 }
553 } catch {}
554 }
555 return result
556 })
557
558 on('command.run', { command: 'usage-band' }, async $ => {
559 const usage = await read($, usageAtom)
560 if (usage === null) return { text: 'No usage yet: the footer fills in after the first response.' }
561 const now = await $.clock.now()
562 const items = buildItems(usage, await read($, cacheAtom), now, await read($, lastTurnUsdAtom))
563 const footer = items.map(item => item.value).join(' · ')
564 return { text: [`Footer: ${footer}`, ...items.map(item => item.detail)].join('\n\n') }
565 })
566
567 on('command.run', { command: 'handoff' }, async $ => {
568 void runHandoff($, settings)
569 return { text: 'Writing a handoff note…' }
570 })
571
572 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
573 if (settings.placement !== 'footer') return next(e)
574 const usage = await read($, usageAtom)
575 if (usage === null) return next(e)
576 const now = (await read($, nowAtom)) || (await $.clock.now())
577 const items = buildItems(usage, await read($, cacheAtom), now, await read($, lastTurnUsdAtom))
578 if (!items.length) return next(e)
579
580 const { Text } = $.ui.resolve(e)
581 return (
582 <Text>
583 {items.flatMap((item, index) => {
584 const drawn = (
585 <Text key={`ubp-${item.key}`} color={TONE_COLOR[item.tone]}>
586 {item.value}
587 </Text>
588 )
589 return index
590 ? [
591 <Text key={`ubp-dot-${item.key}`} color="subtle">
592 {DOT}
593 </Text>,
594 drawn,
595 ]
596 : [drawn]
597 })}
598 {e.props.modes.length ? <Text dimColor>{`${DOT}${e.props.modes.join(' & ')}`}</Text> : null}
599 </Text>
600 )
601 })
602
603 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
604 const usage = await read($, usageAtom)
605 if (e.props.hasSurvey || usage === null) return next(e)
606
607 const { Box, Button, Text } = $.ui.resolve(e)
608 const now = (await read($, nowAtom)) || (await $.clock.now())
609 const cache = await read($, cacheAtom)
610
611 const leftMs = cache ? cache.at + cache.ttlMs - now : -Infinity
612 const isExpiring =
613 cache !== null &&
614 !e.props.isWorking &&
615 isCacheNudgeWindow(leftMs, usage) &&
616 (await read($, dismissedCacheAtAtom)) !== cache.at
617 if (isExpiring && cache) {
618 const isCompacting = await read($, isCompactingAtom)
619 return (
620 <Box justifyContent="space-between" alignItems="center">
621 <Text dimColor wrap="truncate-end">
622 <Text color="warning">{`Prompt cache expires in ${clock(leftMs / 1000)}. `}</Text>
623 Compact so the next message rebuilds a smaller cache, or let it expire.
624 </Text>
625 <Box columnGap={1}>
626 <Button
627 key="compact"
628 label={isCompacting ? 'Compacting…' : 'Compact'}
629 variant="primary"
630 onPress={() => compactForCache($)}
631 />
632 <Button
633 key="let-expire"
634 label="Let it expire"
635 variant="secondary"
636 dimColor
637 onPress={() => update($, dismissedCacheAtAtom, () => cache.at)}
638 />
639 </Box>
640 </Box>
641 )
642 }
643
644 const isArmed = (await read($, handoffArmedUntilAtom)) > now
645 const isBusy = await read($, isHandoffBusyAtom)
646 const isSuggested =
647 settings.handoffAtPercent > 0 && (usage.ctxPercent ?? 0) >= settings.handoffAtPercent
648 const items =
649 settings.placement === 'band' ? buildItems(usage, cache, now, await read($, lastTurnUsdAtom)) : []
650 if (!items.length && !isSuggested && !isArmed && !isBusy) return next(e)
651
652 const isProminent = isSuggested || isArmed
653 const label = isBusy ? 'Writing…' : isArmed ? 'Confirm handoff' : 'Handoff'
654 const message = isArmed
655 ? 'Press again to clear this session and start fresh with a handoff note.'
656 : `Context is ${Math.round(usage.ctxPercent ?? 0)}% full. A handoff note carries this work into a fresh session.`
657
658 return (
659 <Box justifyContent="space-between" alignItems="center">
660 {items.length ? (
661 <Text>
662 {items.flatMap((item, index) => {
663 const drawn = <Text color={TONE_COLOR[item.tone]}>{item.value}</Text>
664 return index ? [<Text color="subtle">{DOT}</Text>, drawn] : [drawn]
665 })}
666 </Text>
667 ) : (
668 <Text dimColor wrap="truncate-end">
669 {message}
670 </Text>
671 )}
672 <Button
673 key="handoff"
674 label={label}
675 variant={isProminent ? 'primary' : 'secondary'}
676 dimColor={!isProminent}
677 onPress={() => pressHandoff($, settings)}
678 />
679 </Box>
680 )
681 })
682}
683types/index.d.ts 38 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3// the status line's figures, from session.measure or $.session.usage()
4export type Usage = {
5 model: string | null
6 ctxPercent: number | null
7 ctxTokens: number | null
8 // the compaction window when known, as the app's Context panel measures
9 ctxWindow: number
10 modelWindow: number
11 usd: number | null
12 limits: Limit[]
13}
14
15// the last main-thread API request, from its turn.step result
16export type Cache = {
17 at: number
18 model: string
19 hitPct: number
20 ttlMs: number
21 isTtlKnown: boolean
22}
23
24declare module 'claude-code' {
25 interface PluginState {
26 'usage-band-plus': {
27 usage: Usage | null
28 cache: Cache | null
29 now: number
30 lastTurnUsd: number | null
31 handoffArmedUntil: number
32 isHandoffBusy: boolean
33 dismissedCacheAt: number | null
34 isCompacting: boolean
35 }
36 }
37}
38