Session cost and prompt-cache expiry in the composer footer; on the terminal, a usage band above the prompt and the directory and branch in the footer

A Claude Code mod that puts the session cost and the prompt-cache expiry in the composer footer:
$1.23 · TTL ends 11:19 AM
$.session.usage().cost, refreshed after each prompt, tool call and model response (session.measure).turn.step, no agentId) plus the TTL. It turns warning at 4 minutes left and error at 2, then becomes cold ~$X.XX in error: the estimated cost of re-writing the cache, i.e. the last request's input + cache read + cache write + output tokens × input price × write multiplier (1.25× for 5m, 2× for 1h)./resume, --resume/--continue, a fork, or any reset of the mod's state (the desktop app letting an idle conversation's process go), the timer starts from the conversation's last main-thread request: the time its prompt or tool result was written to the transcript. The mod reads the transcript file for this, since $.session.messages() carries no timestamps, and finds it by session id under $CLAUDE_CONFIG_DIR (or ~/.claude)/projects/. It reads the last 3 MiB with tail -c ($.process.run), whatever the file's size; on Windows (OS=Windows_NT), where there is no tail, Windows PowerShell seeks and copies the same bytes. Where no process runs (a managed plugin denies it), it uses $.fs.read, which takes files up to 4 MiB; past that, resume falls back to the engine's seconds_since_last_response, which is when the response finished, a little after the send.session.end (clear or resume: the mod watches for the new session id for up to 5 seconds) and from the footer's next drawing (a session id the view wasn't refreshed for). So the mod doesn't need the classic.* events, which a managed plugin above it (such as cc-plugin-sec-default on a Team or Enterprise plan) can stop reaching user plugins. Without classic.SessionStart, the fallback to seconds_since_last_response is lost. Without CwdChanged, the directory and branch refresh at the next turn or Bash call. Without PostModelSwitch, prices come from INPUT_PRICES, and the model refreshes on config.set or the next request./plugin install composer-meter --marketplace ioctlLR/composer-meter
On the terminal it draws differently (see below): a usage band above the prompt, and the directory and branch in the footer.
▰▰▰▱▱▱▱▱ 37% context $1.23 session Opus 5.5 · high TTL ends 11:19 AM
focus & … ~/src/app ⎇ main
Set in /config, and stored in ~/.claude/settings.json under pluginConfigs["composer-meter@<source>"].options: composer-meter@inline when loaded with --plugin-dir, composer-meter@<marketplace> when installed. A value saved there overrides the defaults below.
cacheTtl: auto (default), 5m or 1h. The engine doesn't expose the session's cache TTL, so auto assumes 1h when the API reports rate-limit windows (a subscription) and 5m otherwise.bandUsage (terminal band): cost (default) shows the session's cost beside the context meter; limits shows the 5h and 7d plan meters in its place.Claude Code's own price catalog is compiled into the binary (the /model picker's $X/$Y per Mtok) and reaches a mod only through PostModelSwitch's estimated_cache_write_usd. After a switch the mod divides that back to the new model's base input price and prefers it (managed modelPricing included). Otherwise it falls back to INPUT_PRICES in hooks/register.tsx, list prices as of 2026-10, maintained by hand: the Models API (GET /v1/models) carries no pricing and needs credentials. The switch's cache_ttl is not used for the countdown. Fast mode and organisation-configured pricing aren't modelled.
The mod hooks ui.render on SessionMode, the mode-label slot in the composer footer.
Desktop app. The slot sits between the permission mode and the model name. The app runs the returned tree through a text-only flattener (shapeTree in the Code tab's bundle):
Text, span, b (a Text's props only when every value is a primitive), and Buttons with key, label and a press handlerBox and div become inline runs joined with spacesSvg and every other element, which reduce to their label/text/source string or nothing; this applies with or without isInteractivewhite-space: nowrap; text-overflow: ellipsis and a fixed width, so long text is cut off with an ellipsiswarning, error, …), #hex, or rgb()/hsl(); anything else becomes currentColor, so CSS gradients can't sneak inThe desktop renders Svg only in AbovePrompt and Pane.
Terminal (TUI). Ported from skins-plus, my fork of hellosverre's skins: its band and footer, without the Compact button.
AbovePrompt: one row of block meters (▰▱, eighths) for context fill (warning from 80%, error from 95%) and the session cost (or, under bandUsage: limits, a meter per plan limit in its place); then the main loop's model by name (Opus 5.5, derived from the id: the engine exposes only the id, and the /config model row only aliases) and effort, and the cache expiry piece above. Colors are skins-plus's Dracula (accent #bd93f9, text #f8f8f2, muted #6272a4), with a deepened variant on a light Claude Code theme. Another plugin's band, and a survey, keep their place. The desktop keeps the engine's band, since it shows its own context and model.SessionMode: the engine's mode labels, then ~/path ⎇ branch (or detached <hash>), read from .git/HEAD (worktrees and submodules followed) with no git process. The branch shows whole; the path gives way from its left, down to its last directory, within half the terminal's width. Refreshed at each turn's start and end, after main-thread Bash calls and on CwdChanged./config holds until the next main-thread request reports what it really sent (turn.step's effort), so a downgrade for the model shows.If skins-plus is also enabled, both draw a band; turn its band off with /skin band off.
claude plugin validate .
claude plugin test .
claude --plugin-dir .
tsc -p . type-checks once the engine has loaded the mod and written .claude-plugin/types/ (git-ignored).
hooks/register.tsx 560 lines1import { atom, read, update } from 'claude-code'
2import type {
3 ConfigRow,
4 EngineInterface,
5 Register,
6 SessionContextUsage,
7 SessionCost,
8 SessionRateLimit,
9 Timer,
10} from 'claude-code'
11
12import type { CacheMark, EnginePrice, ModelSnap, Piece, PlaceSnap, Tone, UsageSnap, View } from '../types'
13import { BAND_DARK, BAND_LIGHT, metersOf, modelName, usageSnap } from './band'
14import { fitPlace, gitDirOf, headLabel, homePath, parentOf, slashed } from './place'
15import { lastRequestIn } from './transcript'
16import { widthOf } from './width'
17
18const view = atom({ plugin: 'composer-meter', key: 'view' } as const, { cost: null, cache: null })
19const mark = atom({ plugin: 'composer-meter', key: 'mark' } as const, null)
20const isSubscription = atom({ plugin: 'composer-meter', key: 'isSubscription' } as const, false)
21const usage = atom({ plugin: 'composer-meter', key: 'usage' } as const, { context: null, limits: [] } as UsageSnap)
22const model = atom({ plugin: 'composer-meter', key: 'model' } as const, { model: '', effort: null } as ModelSnap)
23const place = atom({ plugin: 'composer-meter', key: 'place' } as const, { path: '', head: null } as PlaceSnap)
24const price = atom({ plugin: 'composer-meter', key: 'price' } as const, null as EnginePrice | null)
25const isLight = atom({ plugin: 'composer-meter', key: 'isLight' } as const, false)
26// The session the whole view was last refreshed for, its transcript looked in included;
27// reset with the rest of the state, so a reset or a new session id refreshes it all again.
28const refreshedFor = atom({ plugin: 'composer-meter', key: 'refreshedFor' } as const, null as string | null)
29
30const MINUTE = 60_000
31const WARN_MS = 4 * MINUTE
32const DANGER_MS = 2 * MINUTE
33
34/** Base input $/MTok by model id, most specific first (list prices, 2026-10). */
35// The fallback: the engine's own catalog reaches a mod only on a model switch (EnginePrice).
36const INPUT_PRICES: readonly [RegExp, number][] = [
37 [/fable-5|mythos-5/, 10],
38 [/opus-5-5/, 4],
39 [/opus-5|opus-4-[5-9]/, 5],
40 [/opus-4|opus-3/, 15],
41 [/sonnet-5/, 2],
42 [/sonnet/, 3],
43 [/haiku-5-5/, 0.1],
44 [/haiku-4-5/, 1],
45 [/haiku-3-5/, 0.8],
46 [/haiku/, 0.25],
47]
48
49const LONG_PROMPT = 100_000
50
51const writeMultiplier = (ttlMs: number) => (ttlMs > 5 * MINUTE ? 2 : 1.25)
52
53const bareId = (id: string) => id.toLowerCase().replace(/\[.*\]$/, '')
54
55export function inputPrice(model: string, tokens: number, engine: EnginePrice | null = null): number {
56 // The engine's figure holds for its model, on the same side of the long-prompt tier.
57 if (engine && bareId(engine.model) === bareId(model) && engine.isLong === tokens > LONG_PROMPT) return engine.perMTok
58 // Haiku 5.5 doubles past a 100K-token prompt.
59 if (/haiku-5-5/.test(model) && tokens > LONG_PROMPT) return 0.5
60 return INPUT_PRICES.find(([re]) => re.test(model))?.[1] ?? 5
61}
62
63export function coldWriteUsd(m: CacheMark, ttlMs: number, engine: EnginePrice | null = null): number {
64 return (m.tokens / 1e6) * inputPrice(m.model, m.tokens, engine) * writeMultiplier(ttlMs)
65}
66
67// A model switch reports what re-caching its context costs on the new model, from the
68// engine's catalog or the managed pricing: the base input price, with the write multiplier
69// for the TTL it assumed divided out. Null when the engine did not know the model.
70export function enginePrice(e: {
71 to_model: string
72 context_tokens: number
73 cache_ttl: '5m' | '1h'
74 estimated_cache_write_usd: number
75 pricing: 'configured' | 'catalog' | 'default'
76}): EnginePrice | null {
77 if (e.pricing === 'default' || e.context_tokens <= 0 || !(e.estimated_cache_write_usd > 0)) return null
78 const multiplier = writeMultiplier(e.cache_ttl === '1h' ? 60 * MINUTE : 5 * MINUTE)
79 const perMTok = (e.estimated_cache_write_usd / e.context_tokens) * 1e6 / multiplier
80 return { model: e.to_model, perMTok, isLong: e.context_tokens > LONG_PROMPT }
81}
82
83const usd = (n: number) => (n < 10 ? `$${n.toFixed(2)}` : `$${n.toFixed(1)}`)
84
85export const clock = (ms: number) =>
86 new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
87
88export const costPiece = (cost: SessionCost | undefined): Piece | null =>
89 cost ? { text: usd(cost.usd), tone: 'normal' } : null
90
91export function cachePiece(
92 m: CacheMark | null,
93 ttlMs: number,
94 now: number,
95 engine: EnginePrice | null = null,
96): Piece | null {
97 if (!m) return null
98 const expiresAt = m.startedAt + ttlMs
99 const left = expiresAt - now
100 if (left <= 0) return { text: `cold ~${usd(coldWriteUsd(m, ttlMs, engine))}`, tone: 'error' }
101 const tone: Tone = left <= DANGER_MS ? 'error' : left <= WARN_MS ? 'warning' : 'normal'
102 return { text: `TTL ends ${clock(expiresAt)}`, tone }
103}
104
105// The piece changes only at three times: 4m and 2m before expiry, and at it. The next one
106// still ahead of `now`, or null once the cache is cold.
107export function nextChangeAt(m: CacheMark, ttlMs: number, now: number): number | null {
108 const expiresAt = m.startedAt + ttlMs
109 return [expiresAt - WARN_MS, expiresAt - DANGER_MS, expiresAt].find(at => at > now) ?? null
110}
111
112const color = (tone: Tone) => (tone === 'normal' ? undefined : tone)
113
114const same = (a: View, b: View) => JSON.stringify(a) === JSON.stringify(b)
115
116let ttlOption = 'auto'
117
118async function ttlMs($: EngineInterface): Promise<number> {
119 if (ttlOption === '5m') return 5 * MINUTE
120 if (ttlOption === '1h') return 60 * MINUTE
121 return (await read($, isSubscription)) ? 60 * MINUTE : 5 * MINUTE
122}
123
124async function setView($: EngineInterface, patch: Partial<View>): Promise<void> {
125 await update($, view, prev => {
126 const next = { ...prev, ...patch }
127 return same(prev, next) ? prev : next
128 })
129}
130
131type Measured = { context: SessionContextUsage; rateLimits: readonly SessionRateLimit[]; cost?: SessionCost }
132
133async function showMeter($: EngineInterface, { context, rateLimits, cost }: Measured): Promise<void> {
134 // Rate-limit windows are reported only on a subscription, which caches for 1h.
135 if (rateLimits.length > 0 && !(await read($, isSubscription))) {
136 await update($, isSubscription, () => true)
137 await refreshCache($)
138 }
139 await setView($, { cost: costPiece(cost) })
140 const snap = usageSnap(context, rateLimits)
141 await update($, usage, prev => (JSON.stringify(prev) === JSON.stringify(snap) ? prev : snap))
142}
143
144// Runs after a gating hook's next(): never throws, so a failed read cannot fail the prompt or tool call.
145async function refreshMeter($: EngineInterface): Promise<void> {
146 try {
147 await showMeter($, await $.session.usage())
148 } catch {}
149}
150
151const EFFORT_ROW = /effort/i
152
153// Claude Code's theme names say light or dark in them: `light`, `light-daltonized`, ...
154async function refreshTheme($: EngineInterface): Promise<void> {
155 try {
156 const theme = (await $.config.list()).find(row => row.key === 'theme')?.value
157 const light = typeof theme === 'string' && theme.includes('light')
158 await update($, isLight, () => light)
159 } catch {}
160}
161
162// The effort /config holds, until the next main-thread request says what it really sent.
163async function configEffort($: EngineInterface): Promise<string | null> {
164 const rows = await $.config.list().catch(() => [] as ConfigRow[])
165 const value = rows.find(row => EFFORT_ROW.test(row.key) || EFFORT_ROW.test(row.label))?.value
166 return typeof value === 'string' && value !== '' ? value : null
167}
168
169// A host that cannot say keeps the last value: the model line is a nicety.
170async function refreshModel($: EngineInterface): Promise<void> {
171 try {
172 const id = await $.session.model().catch(() => null)
173 const effort = await configEffort($)
174 await update($, model, snap => ({ model: id ?? snap.model, effort: effort ?? snap.effort }))
175 } catch {}
176}
177
178// Walks up from `cwd` to the first `.git` and reads its HEAD; null outside a repository.
179// Reads the files git keeps, so no git process runs.
180async function headOf($: EngineInterface, cwd: string): Promise<string | null> {
181 for (let at: string | null = slashed(cwd); at !== null; ) {
182 const dir: string = at
183 at = parentOf(dir)
184 const dotGit = `${dir === '/' ? '' : dir}/.git`
185 const stat = await $.fs.stat(dotGit).catch(() => null)
186 if (stat === null) continue
187
188 const gitDir = stat.kind === 'dir' ? dotGit : stat.kind === 'file' ? gitDirOf(await $.fs.read(dotGit), dir) : null
189 const head = gitDir === null ? null : await $.fs.read(`${gitDir}/HEAD`).catch(() => null)
190 return head === null ? null : headLabel(head)
191 }
192 return null
193}
194
195// The directory and branch move on a /cd, a checkout or a worktree switch; a failed read keeps the last.
196async function refreshPlace($: EngineInterface): Promise<void> {
197 try {
198 const cwd = await $.session.cwd()
199 const home = (await $.env.get('HOME').catch(() => undefined)) ?? (await $.env.get('USERPROFILE').catch(() => undefined))
200 const next: PlaceSnap = { path: homePath(cwd, home ?? undefined), head: await headOf($, cwd).catch(() => null) }
201 await update($, place, snap => (snap.path === next.path && snap.head === next.head ? snap : next))
202 } catch {}
203}
204
205// How much of a transcript's end `tail` hands over: under the 4 MiB a process's stdout carries.
206const TAIL_BYTES = 3 * 1024 * 1024
207
208// Windows has no `tail`. Windows PowerShell, there on every Windows, seeks to the same point
209// and copies the bytes out unchanged. The path comes in through the environment, so nothing
210// is quoted, and the file is opened shared for writing, since the engine holds it open.
211const WINDOWS_TAIL = [
212 "$ErrorActionPreference = 'Stop'",
213 "$f = [IO.File]::Open($env:COMPOSER_METER_TRANSCRIPT, 'Open', 'Read', 'ReadWrite')",
214 `try { [void]$f.Seek([Math]::Max(0, $f.Length - ${TAIL_BYTES}), 'Begin'); $o = [Console]::OpenStandardOutput(); $f.CopyTo($o); $o.Flush() } finally { $f.Close() }`,
215].join('; ')
216
217const tailOf = async ($: EngineInterface, path: string) =>
218 (await $.env.get('OS').catch(() => undefined)) === 'Windows_NT'
219 ? $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', WINDOWS_TAIL], {
220 env: { COMPOSER_METER_TRANSCRIPT: path },
221 })
222 : $.process.run(['tail', '-c', String(TAIL_BYTES), path])
223
224// The transcript's last 3 MiB, whatever its size: the last request is at the end, and the
225// line the cut lands in does not parse and is skipped. Where no process runs (a managed
226// plugin denies it), `$.fs.read`, which takes up to 4 MiB.
227async function readTranscript($: EngineInterface, path: string): Promise<string | null> {
228 const tail = await tailOf($, path).catch(() => null)
229 if (tail !== null && tail.exitCode === 0) return tail.stdout
230 return $.fs.read(path).catch(() => null)
231}
232
233// A resumed or forked conversation's last request, from its transcript; where that cannot be
234// read, from the engine's report of its last response (a little later than the send).
235async function resumedMark(
236 $: EngineInterface,
237 e: { transcript_path?: string; model?: string; seconds_since_last_response?: number; context_tokens?: number },
238): Promise<CacheMark | null> {
239 const text = e.transcript_path ? await readTranscript($, e.transcript_path) : null
240 const fromFile = text === null ? null : lastRequestIn(text)
241 if (fromFile) return fromFile
242
243 const seconds = e.seconds_since_last_response
244 const tokens = e.context_tokens
245 if (seconds === undefined || !tokens) return null
246 const model = e.model ?? (await $.session.model().catch(() => ''))
247 return { startedAt: (await $.clock.now()) - seconds * 1000, model, tokens }
248}
249
250const slug = (path: string) => path.replace(/[^a-zA-Z0-9]/g, '-')
251
252async function exists($: EngineInterface, path: string): Promise<boolean> {
253 return (await $.fs.stat(path).catch(() => null))?.kind === 'file'
254}
255
256// The session's transcript, <config dir>/projects/<its directory, slugged>/<id>.jsonl. A long
257// directory's slug is cut and hashed, so past the two guesses every project folder is tried.
258async function transcriptOf($: EngineInterface, id: string): Promise<string | null> {
259 const home = (await $.env.get('HOME').catch(() => null)) || (await $.env.get('USERPROFILE').catch(() => null))
260 const config = (await $.env.get('CLAUDE_CONFIG_DIR').catch(() => null)) || (home ? `${slashed(home)}/.claude` : null)
261 if (!config) return null
262
263 const projects = `${slashed(config)}/projects`
264 const guesses = [await $.session.root().catch(() => null), await $.session.cwd().catch(() => null)]
265 for (const dir of new Set(guesses.filter((d): d is string => d !== null).map(slug))) {
266 if (await exists($, `${projects}/${dir}/${id}.jsonl`)) return `${projects}/${dir}/${id}.jsonl`
267 }
268 for (const entry of await $.fs.list(projects).catch(() => [])) {
269 if (entry.kind === 'dir' && (await exists($, `${projects}/${entry.name}/${id}.jsonl`))) {
270 return `${projects}/${entry.name}/${id}.jsonl`
271 }
272 }
273 return null
274}
275
276// A state reset (the desktop letting an idle session's process go, a reload) empties the
277// mark while the cost refreshes at once; the transcript says when the last request went out.
278// Never over a mark a request set meanwhile.
279async function seedMark($: EngineInterface, id: string): Promise<void> {
280 if ((await read($, mark)) !== null) return
281 const path = await transcriptOf($, id)
282 const text = path === null ? null : await readTranscript($, path)
283 const found = text === null ? null : lastRequestIn(text)
284 if (found) await update($, mark, prev => prev ?? found)
285}
286
287let refreshing: Promise<void> | null = null
288
289// A start and a drawing beside it can both ask before either records the session: one run
290// serves both, so the transcript is read once.
291function refreshAll($: EngineInterface): Promise<void> {
292 refreshing ??= refreshEverything($).finally(() => {
293 refreshing = null
294 })
295 return refreshing
296}
297
298async function refreshEverything($: EngineInterface): Promise<void> {
299 const id = await $.session.id().catch(() => null)
300 if (id !== null) {
301 await update($, refreshedFor, () => id)
302 await seedMark($, id).catch(() => {})
303 }
304 await refreshMeter($)
305 await refreshModel($)
306 await refreshPlace($)
307 await refreshTheme($)
308 await refreshCache($)
309}
310
311let changeTimer: Timer | null = null
312
313// Draws the cache piece, and sets a timer for its next change; a new mark, TTL or price
314// draws it again and moves the timer.
315async function refreshCache($: EngineInterface): Promise<void> {
316 const m = await read($, mark)
317 const ttl = await ttlMs($)
318 const now = await $.clock.now()
319 await setView($, { cache: cachePiece(m, ttl, now, await read($, price)) })
320
321 changeTimer?.cancel()
322 const at = m === null ? null : nextChangeAt(m, ttl, now)
323 changeTimer = at === null ? null : $.clock.after(at - now, () => void refreshCache($).catch(() => {}))
324}
325
326// /clear, /resume and /branch reset the state without a session.start, and the
327// classic.SessionStart that reports them may not reach a user plugin (a managed one above
328// can end its chain). A session id the view was not refreshed for says one happened.
329async function catchReset($: EngineInterface): Promise<void> {
330 const id = await $.session.id().catch(() => null)
331 if (id !== null && (await read($, refreshedFor)) !== id) await refreshAll($)
332}
333
334// From a drawing: a check started straight from one did not finish under test, so a timer
335// starts it.
336function laterCatchReset($: EngineInterface): void {
337 $.clock.after(0, () => void catchReset($).catch(() => {}))
338}
339
340const END_RETRY_MS = 250
341const END_TRIES = 20
342
343// After a /clear or a resume ends `ended`, watches for the session that takes its place:
344// four times a second, for five seconds at most.
345function afterEnd($: EngineInterface, ended: string, tries = END_TRIES): void {
346 $.clock.after(END_RETRY_MS, () => {
347 void (async () => {
348 const id = await $.session.id().catch(() => null)
349 if (id !== null && id !== ended) await catchReset($)
350 else if (tries > 1) afterEnd($, ended, tries - 1)
351 })().catch(() => {})
352 })
353}
354
355export const register: Register = (on, options) => {
356 ttlOption = String(options.cacheTtl ?? 'auto')
357 // The terminal band shows the session's cost in place of the 5h and 7d meters.
358 const bandShowsCost = (options.bandUsage ?? 'cost') === 'cost'
359
360 on('session.start', async ($, e, next) => {
361 const started = await next(e)
362 await refreshAll($)
363 return started
364 })
365
366 // Where it arrives, refreshes at once after a reset, and dates a resumed or forked
367 // conversation's cache from its last request: from the engine's report of its last
368 // response when the transcript cannot be read. Without it, session.end and the next
369 // drawing catch the reset (catchReset).
370 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
371 const result = await next(e)
372 if (e.source !== 'clear') {
373 const resumed = await resumedMark($, e).catch(() => null)
374 await update($, mark, () => resumed)
375 }
376 await refreshAll($)
377 return result
378 })
379
380 on('prompt.submit', async ($, e, next) => {
381 const result = await next(e)
382 await refreshMeter($)
383 return result
384 })
385
386 on('turn.start', async ($, e, next) => {
387 await refreshPlace($)
388 return next(e)
389 })
390
391 on('turn.complete', async ($, e, next) => {
392 if (e.agentId === undefined) await refreshPlace($)
393 return next(e)
394 })
395
396 on('tool.call', async ($, e, next) => {
397 const result = await next(e)
398 await refreshMeter($)
399 // A command may check out another branch or change directory.
400 if (e.agentId === undefined && e.tool === 'Bash') await refreshPlace($)
401 return result
402 })
403
404 on('classic.CwdChanged', async ($, e, next) => {
405 const result = await next(e)
406 await refreshPlace($)
407 return result
408 })
409
410 on('config.set', async ($, e, next) => {
411 const result = await next(e)
412 if (e.key === 'model' || EFFORT_ROW.test(e.key)) await refreshModel($)
413 if (e.key === 'theme') await refreshTheme($)
414 return result
415 })
416
417 on('command.run', { command: 'effort' }, async ($, e, next) => {
418 const result = await next(e)
419 await refreshModel($)
420 return result
421 })
422
423 // Only the price is taken: the TTL stays ours, since the switch's cache_ttl is not
424 // known to match what the requests write.
425 on('classic.PostModelSwitch', async ($, e, next) => {
426 const result = await next(e)
427 await refreshModel($)
428 const learned = enginePrice(e)
429 if (learned) {
430 await update($, price, () => learned)
431 await refreshCache($)
432 }
433 return result
434 })
435
436 on('session.measure', async ($, e, next) => {
437 await showMeter($, e)
438 return next(e)
439 })
440
441 // The cache lifetime runs from the start of each main-thread request that reads or writes it.
442 // The request also says the effort it really sent, after any downgrade for its model.
443 on('turn.step', async function* ($, e, next) {
444 if (!e.agentId) {
445 const effort = e.effort === undefined ? null : String(e.effort)
446 await update($, model, snap => (snap.effort === effort ? snap : { ...snap, effort }))
447 }
448 const startedAt = await $.clock.now()
449 const result = yield* next(e)
450 if (!e.agentId && result.usage) {
451 const u = result.usage
452 const tokens =
453 u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens
454 await update($, mark, () => ({ startedAt, model: u.model, tokens }))
455 await refreshCache($)
456 }
457 return result
458 })
459
460 on('session.end', async ($, e, next) => {
461 if (e.reason === 'clear') {
462 await update($, mark, () => null)
463 await refreshCache($)
464 }
465 if (e.reason === 'clear' || e.reason === 'resume') afterEnd($, e.sessionId)
466 return next(e)
467 })
468
469 // Terminal: the band above the prompt holds the meters, model and effort, and cache expiry.
470 // The desktop draws its own context and model, so it keeps the engine's band.
471 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
472 laterCatchReset($)
473 if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
474
475 const { cost, cache } = await read($, view)
476 const meters = metersOf(await read($, usage), !bandShowsCost)
477 const { model: id, effort } = await read($, model)
478 const shownCost = bandShowsCost ? cost : null
479 if (meters.length === 0 && shownCost === null && id === '' && cache === null) return next(e)
480
481 const { Box, Text } = $.ui.resolve(e)
482 const band = (await read($, isLight)) ? BAND_LIGHT : BAND_DARK
483 const theirs = await next(e)
484
485 return (
486 <Box flexDirection="column">
487 <Box flexDirection="row" columnGap={3}>
488 {meters.map(m => (
489 <Text key={m.label} color={band.muted}>
490 <Text color={m.tone === 'normal' ? band.accent : m.tone}>{m.bar}</Text>
491 {` ${m.percent}% ${m.label}`}
492 </Text>
493 ))}
494 {shownCost && (
495 <Text key="cost" color={band.muted}>
496 <Text color={band.fg}>{shownCost.text}</Text> session
497 </Text>
498 )}
499 {id !== '' && (
500 <Text key="model" color={band.muted}>
501 <Text color={band.fg}>{modelName(id)}</Text>
502 {effort === null ? '' : ` · ${effort}`}
503 </Text>
504 )}
505 {cache && (
506 <Text key="cache" color={color(cache.tone) ?? band.muted}>
507 {cache.text}
508 </Text>
509 )}
510 </Box>
511 {theirs}
512 </Box>
513 )
514 })
515
516 // A reset redraws what read the state, so a drawing also looks for one; the refresh it
517 // starts draws again when done.
518 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
519 laterCatchReset($)
520 if (e.surface === 'terminal') {
521 // The footer's right: the engine's mode labels, then the directory and branch. The
522 // branch shows whole; the path gives way from its left, within half the width.
523 const where = await read($, place)
524 if (where.path === '') return next(e)
525
526 const { Box, Text } = $.ui.resolve(e)
527 const modes = e.props.modes.join(' & ')
528 const budget = Math.floor((e.viewport?.columns ?? 80) / 2) - (modes === '' ? 0 : widthOf(modes) + 2)
529
530 return (
531 <Box flexDirection="row" gap={2}>
532 {modes === '' ? null : await next(e)}
533 <Text dimColor wrap="truncate-start">
534 {fitPlace(where, '⎇', budget)}
535 </Text>
536 </Box>
537 )
538 }
539
540 // The desktop flattens this slot to one line of inline text: Text and plain strings only.
541 const { cost = null, cache = null } = await read($, view)
542 const pieces = [cost, cache].filter((p): p is Piece => p !== null)
543 if (pieces.length === 0) return next(e)
544
545 const { Box, Text } = $.ui.resolve(e)
546
547 return (
548 <Box flexDirection="row">
549 {e.props.modes.length > 0 && <Text dimColor>{e.props.modes.join(' & ')} · </Text>}
550 {pieces.map((p, i) => (
551 <Text key={`p${i}`} color={color(p.tone)} dimColor={p.tone === 'normal'}>
552 {i > 0 ? ' · ' : ''}
553 {p.text}
554 </Text>
555 ))}
556 </Box>
557 )
558 })
559}
560hooks/band.ts 65 lines1import type { SessionContextUsage, SessionRateLimit } from 'claude-code'
2
3import type { Tone, UsageSnap } from '../types'
4
5// The terminal's band above the prompt: block meters for the context window and each
6// plan limit, as ▰▰▰▱▱▱▱▱ 37% context.
7
8export type Meter = { label: string; percent: number; bar: string; tone: Tone }
9
10const CELLS = 8
11
12// `five_hour` reads as `5h`, `seven_day` as `7d`; any other kind as its own name.
13export function limitLabel(kind: string): string {
14 const name = kind.toLowerCase()
15
16 if (name.includes('five') || name.includes('5h') || name.includes('5_h')) return '5h'
17 if (name.includes('seven') || name.includes('7d') || name.includes('week')) return '7d'
18 return name.replace(/_/g, ' ')
19}
20
21export const usageSnap = (context: SessionContextUsage, rateLimits: readonly SessionRateLimit[]): UsageSnap => ({
22 context: context.percent ?? null,
23 limits: rateLimits.map(limit => ({ label: limitLabel(limit.kind), percent: limit.percentUsed })),
24})
25
26// The plan's own windows, which the session's cost can stand in for.
27const PLAN_WINDOWS = new Set(['5h', '7d'])
28
29// Plain while there is room; warning from 80%, error from 95%.
30export const meterTone = (percent: number): Tone => (percent >= 95 ? 'error' : percent >= 80 ? 'warning' : 'normal')
31
32export function metersOf(usage: UsageSnap, showPlan: boolean): Meter[] {
33 return [
34 ...(usage.context === null ? [] : [{ label: 'context', percent: usage.context }]),
35 ...usage.limits.filter(limit => showPlan || !PLAN_WINDOWS.has(limit.label)),
36 ].map(({ label, percent }) => {
37 const p = Math.max(0, Math.min(100, Math.round(percent)))
38 const filled = Math.round((p / 100) * CELLS)
39 return { label, percent: p, bar: '▰'.repeat(filled) + '▱'.repeat(CELLS - filled), tone: meterTone(p) }
40 })
41}
42
43const FAMILY = 'opus|sonnet|haiku|fable|mythos'
44const NEW_ID = new RegExp(`claude-(${FAMILY})-(\\d+)(?:-(\\d{1,2}))?(?!\\d)`)
45const OLD_ID = new RegExp(`claude-(\\d+)(?:-(\\d{1,2}))?-(${FAMILY})`)
46
47const titled = (family: string) => family[0]!.toUpperCase() + family.slice(1)
48
49// The model as a person reads it: `claude-opus-5-5[1m]` as `Opus 5.5`. The engine gives the
50// id alone. Bedrock and Vertex prefixes, date suffixes and the context-window tag drop; an
51// id that is not Claude's shows as given.
52export function modelName(id: string): string {
53 const lower = id.toLowerCase()
54 const modern = NEW_ID.exec(lower)
55 if (modern) return `${titled(modern[1]!)} ${modern[2]}${modern[3] ? `.${modern[3]}` : ''}`
56 const legacy = OLD_ID.exec(lower)
57 if (legacy) return `${titled(legacy[3]!)} ${legacy[1]}${legacy[2] ? `.${legacy[2]}` : ''}`
58 return id
59}
60
61// skins-plus's Dracula colours, its light variant deepened to read on white.
62export type BandColors = { accent: string; fg: string; muted: string }
63export const BAND_DARK: BandColors = { accent: '#bd93f9', fg: '#f8f8f2', muted: '#6272a4' }
64export const BAND_LIGHT: BandColors = { accent: '#685189', fg: '#1f1f1f', muted: '#6b6b6b' }
65hooks/place.ts 77 lines1import type { PlaceSnap } from '../types'
2import { widthOf } from './width'
3
4// Where the session is, for the footer's right: its directory and git's HEAD. register.tsx
5// reads them from the files git keeps, so no git process runs.
6
7export const slashed = (path: string): string => path.replace(/\\/g, '/').replace(/(.)\/+$/, '$1')
8
9// The directory, with the home directory as `~`.
10export function homePath(cwd: string, home: string | undefined): string {
11 const path = slashed(cwd)
12 const root = home === undefined || home === '' ? '' : slashed(home)
13
14 if (root === '' || root === '/') {
15 return path
16 }
17
18 return path === root ? '~' : path.startsWith(`${root}/`) ? `~${path.slice(root.length)}` : path
19}
20
21// HEAD's text as a label: the branch whole, or `detached` and the commit's short hash.
22export function headLabel(head: string): string | null {
23 const text = head.trim()
24 const ref = /^ref:\s*(\S+)$/.exec(text)?.[1]
25
26 if (ref !== undefined) {
27 return ref.replace(/^refs\/heads\//, '')
28 }
29
30 return /^[0-9a-f]{7,}$/i.test(text) ? `detached ${text.slice(0, 7)}` : null
31}
32
33// A worktree's or submodule's `.git` is a file naming the real git directory, absolute
34// or relative to where the file is.
35export function gitDirOf(pointer: string, dir: string): string | null {
36 const target = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim()
37
38 if (target === undefined || target === '') {
39 return null
40 }
41
42 return /^([a-z]:)?\//i.test(slashed(target)) ? slashed(target) : `${dir}/${slashed(target)}`
43}
44
45// The directory above, or null at the top: `/`, or a drive (`C:`).
46export const parentOf = (dir: string): string | null => {
47 const cut = dir.lastIndexOf('/')
48
49 return cut < 0 || dir === '/' ? null : cut === 0 ? '/' : dir.slice(0, cut)
50}
51
52// The place in `budget` cells: the branch whole, the path cut from its left at a
53// directory boundary, never past its last directory.
54export function fitPlace(place: PlaceSnap, glyph: string, budget: number): string {
55 const tail = place.head === null ? '' : ` ${glyph} ${place.head}`
56 const room = budget - widthOf(tail)
57 const parts = place.path.split('/')
58
59 if (widthOf(place.path) <= room || parts.length === 1) {
60 return place.path + tail
61 }
62
63 let kept = parts.at(-1) ?? ''
64
65 for (let i = parts.length - 2; i >= 0; i--) {
66 const wider = `${parts[i]}/${kept}`
67
68 if (widthOf(`…/${wider}`) > room) {
69 break
70 }
71
72 kept = wider
73 }
74
75 return `…/${kept}${tail}`
76}
77hooks/transcript.ts 58 lines1import type { CacheMark } from '../types'
2
3// A resumed conversation's cache is as old as its last main-thread request. The transcript
4// file is the only place a mod can read when that was: `$.session.messages()` carries no
5// timestamps.
6
7type Entry = {
8 type?: unknown
9 isSidechain?: unknown
10 timestamp?: unknown
11 message?: { model?: unknown; usage?: Record<string, unknown> }
12}
13
14const count = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
15
16// The last main-thread response with usage, dated from when its request was sent: the
17// main-thread `user` entry (prompt or tool result) written just before it, else the
18// response's own timestamp. Sidechain lines and lines that do not parse are skipped.
19export function lastRequestIn(jsonl: string): CacheMark | null {
20 let sentAt: number | null = null
21 let last: CacheMark | null = null
22
23 for (const line of jsonl.split('\n')) {
24 if (!line.includes('"user"') && !line.includes('"assistant"')) continue
25
26 let entry: Entry
27 try {
28 entry = JSON.parse(line)
29 } catch {
30 continue
31 }
32 if (entry.isSidechain === true) continue
33
34 const at = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : NaN
35 if (Number.isNaN(at)) continue
36
37 if (entry.type === 'user') {
38 sentAt = at
39 continue
40 }
41 if (entry.type !== 'assistant') continue
42
43 const usage = entry.message?.usage
44 const model = entry.message?.model
45 // Claude Code writes `<synthetic>` replies of its own, with no request behind them.
46 if (!usage || typeof model !== 'string' || model === '' || model.startsWith('<')) continue
47
48 const tokens =
49 count(usage.input_tokens) +
50 count(usage.cache_read_input_tokens) +
51 count(usage.cache_creation_input_tokens) +
52 count(usage.output_tokens)
53 if (tokens > 0) last = { startedAt: sentAt ?? at, model, tokens }
54 }
55
56 return last
57}
58hooks/width.ts 35 lines1// Terminal cells a character takes: wide East Asian characters and emoji take two,
2// combining marks, zero-width joiners and skin-tone modifiers none.
3const WIDE: readonly (readonly [number, number])[] = [
4 [0x1100, 0x115f],
5 [0x2e80, 0x303e],
6 [0x3041, 0x33ff],
7 [0x3400, 0x4dbf],
8 [0x4e00, 0x9fff],
9 [0xa000, 0xa4cf],
10 [0xa960, 0xa97f],
11 [0xac00, 0xd7a3],
12 [0xf900, 0xfaff],
13 [0xfe10, 0xfe19],
14 [0xfe30, 0xfe6f],
15 [0xff00, 0xff60],
16 [0xffe0, 0xffe6],
17 [0x1f300, 0x1f64f],
18 [0x1f680, 0x1f6ff],
19 [0x1f900, 0x1f9ff],
20 [0x1fa70, 0x1faff],
21 [0x20000, 0x3fffd],
22]
23
24const charWidth = (char: string): number => {
25 const code = char.codePointAt(0) ?? 0
26
27 if (/\p{Mn}|\p{Me}|\u200d|[\ufe00-\ufe0f]|\p{Emoji_Modifier}/u.test(char)) {
28 return 0
29 }
30
31 return WIDE.some(([from, to]) => code >= from && code <= to) ? 2 : 1
32}
33
34export const widthOf = (text: string): number => [...text].reduce((sum, char) => sum + charWidth(char), 0)
35types/index.d.ts 40 lines1export type Tone = 'normal' | 'warning' | 'error'
2
3export type Piece = { text: string; tone: Tone }
4
5/** The last main-thread request: when it started and what a cold re-cache would write. */
6export type CacheMark = { startedAt: number; model: string; tokens: number }
7
8export type View = { cost: Piece | null; cache: Piece | null }
9
10/** Context fill and each plan limit spent, in percent, for the terminal's band. */
11export type UsageSnap = { context: number | null; limits: { label: string; percent: number }[] }
12
13/** The main loop's model, and its effort (null until known, or for a model without effort). */
14export type ModelSnap = { model: string; effort: string | null }
15
16/**
17 * Where the session is, for the terminal footer's right: its directory (home as `~`) and
18 * git's HEAD as a label, null outside a repository. `path` is '' until first read.
19 */
20export type PlaceSnap = { path: string; head: string | null }
21
22/** The engine's base input $/MTok for a model, learned from a model switch; `isLong` past 100K tokens. */
23export type EnginePrice = { model: string; perMTok: number; isLong: boolean }
24
25declare module 'claude-code' {
26 interface PluginState {
27 'composer-meter': {
28 view: View
29 mark: CacheMark | null
30 isSubscription: boolean
31 usage: UsageSnap
32 model: ModelSnap
33 place: PlaceSnap
34 price: EnginePrice | null
35 isLight: boolean
36 refreshedFor: string | null
37 }
38 }
39}
40