SLOPSHOPPER

composer-meter

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

newbandspinnerguardpromptprocess
v0.1.0MITupdated 2026-10-23ioctlLR/composer-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · composer-meter
› 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 ▰▰▰▰▱▱▱▱ 49% context $0.42 session Opus 5.5 ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⟨Claude Code's own drawing⟩ /work/app

Draws

Band
▰▰▰▰▱▱▱▱ 49% context $0.42 session Opus 5.5 ⟨Claude Code's own drawing⟩
README

composer-meter

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
  • Cost is $.session.usage().cost, refreshed after each prompt, tool call and model response (session.measure).
  • Cache expiry is the start of the last main-thread request (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).
  • Subagent requests use their own caches and don't reset the timer.
  • On /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.
  • Nothing polls. The cache piece changes only at 4 minutes left, 2 left and expiry, so a timer is set for the next of those. A reset is caught from 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.

Install

/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

Options

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.

Prices

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.

Where it draws, and what each surface allows

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):

  • kept: Text, span, b (a Text's props only when every value is a primitive), and Buttons with key, label and a press handler
  • collapsed: Box and div become inline runs joined with spaces
  • dropped: Svg and every other element, which reduce to their label/text/source string or nothing; this applies with or without isInteractive
  • drawn with CSS white-space: nowrap; text-overflow: ellipsis and a fixed width, so long text is cut off with an ellipsis
  • colors must be a theme key (warning, error, …), #hex, or rgb()/hsl(); anything else becomes currentColor, so CSS gradients can't sneak in

The 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.
  • Effort is what /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.

Development

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

Source 6 files
hooks/register.tsx 560 lines
1import { 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}
560
hooks/band.ts 65 lines
1import 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' }
65
hooks/place.ts 77 lines
1import 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}
77
hooks/transcript.ts 58 lines
1import 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}
58
hooks/width.ts 35 lines
1// 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)
35
types/index.d.ts 40 lines
1export 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