Two rows under the prompt: model and effort chips in the Anthropic brand blue and orange (a symbol for the level), each turning the theme's warning colour and…

Two rows under the prompt:
With an API key or a cloud provider there is no plan, so there are no limits to show: after the first turn, the second row shows the context bar only.
It reads usage figures only and adds no tokens.
/plugin marketplace add richardmuucyi/rmahwj-mods
/plugin install usage-gauge@rmahwj-mods
/reload-plugins
claude plugin test usage-gaugehooks/register.tsx 639 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, SessionContextBreakdown } from 'claude-code'
3
4import type { Gauge, Git, Limit } from '../types'
5import { clip, layoutOf, parseStatus, showOf, slotsOf } from './git'
6import type { Show } from './git'
7
8// Reads the session's own usage figures, the model each request names and the
9// token counts each turn reports, and (read-only) the state of the git repository the session sits in. Nothing reaches
10// the model: no tokens.
11
12const gauge = atom({ plugin: 'usage-gauge', key: 'gauge' } as const, {
13 startedAt: 0,
14 now: 0,
15 tokensIn: 0,
16 tokensOut: 0,
17 cacheRead: 0,
18})
19
20const REFRESH_MS = 15_000
21
22/** What the person chose to show (the manifest's userConfig); `register` sets it from its options. */
23let show: Show = showOf({})
24
25/**
26 * The model and effort chips use Anthropic's brand accents (blue and orange) with the brand dark for the text. White
27 * text on the orange is 3.1:1, below the 4.5:1 small text needs; the dark is 5.9:1 on it and 6.3:1 on the blue.
28 */
29const CHIP = {
30 model: { bg: '#6a9bcc', fg: '#141413' },
31 effort: { bg: '#d97757', fg: '#141413' },
32}
33/** Claude Code's own symbols for the effort levels, so the level reads here as it does there. One colour; the shape is the level. */
34const GLYPH: Record<string, string> = { low: '○', medium: '◐', high: '●', xhigh: '◉', max: '◈' }
35
36/**
37 * A meter is drawn in Claude Code's own colours, by theme key, so it follows the person's theme: the fill its own usage
38 * bars use, then the theme's warning from 70% and its error from 90%. The cost, speed and token numbers are plain text.
39 */
40const stage = (pct: number) => (pct >= 90 ? 'error' : pct >= 70 ? 'warning' : 'rate_limit_fill')
41
42const MIN_MS = 60_000
43/** The two lifetimes the API offers for a prompt cache entry. */
44const TTL_MS = { '5m': 5 * MIN_MS, '1h': 60 * MIN_MS } as const
45type Ttl = keyof typeof TTL_MS
46
47const isTtl = (v: unknown): v is Ttl => v === '5m' || v === '1h'
48const isOn = (v: string | undefined): boolean => v === '1' || v === 'true'
49
50/**
51 * The lifetime the person chose for the main conversation's cache, in Claude Code's own order of precedence:
52 * FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl setting, ENABLE_PROMPT_CACHING_1H. Anything
53 * but "5m" or "1h" is ignored there, so it is here. Undefined: none chosen, and the plan decides (cacheTtl).
54 */
55async function chosenTtl($: EngineInterface, settings: Record<string, unknown> | undefined): Promise<Ttl | undefined> {
56 if (isOn(await $.env.get('FORCE_PROMPT_CACHING_5M').catch(() => undefined))) return '5m'
57 const env = await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL').catch(() => undefined)
58 if (isTtl(env)) return env
59 if (isTtl(settings?.promptCacheTtl)) return settings.promptCacheTtl
60 if (isOn(await $.env.get('ENABLE_PROMPT_CACHING_1H').catch(() => undefined))) return '1h'
61 return undefined
62}
63
64/**
65 * The lifetime to count down from. Claude Code asks for an hour for the main conversation only on a subscription within
66 * its plan's usage (the plan's 5-hour and weekly windows are reported, and neither is at 100%); an API key, a cloud
67 * provider and usage credits get five minutes. A choice of the person's beats that.
68 */
69function cacheTtl(g: Gauge): Ttl {
70 if (g.ttlChoice) return g.ttlChoice
71 const onPlan = g.fiveHour !== undefined || g.weekly !== undefined
72 const over = (g.fiveHour?.pct ?? 0) >= 100 || (g.weekly?.pct ?? 0) >= 100
73 return onPlan && !over ? '1h' : '5m'
74}
75
76/** Ten cells, each a full 10%: rounded down, so the bar is only full at 100%. */
77function cells(pct: number): [number, number] {
78 const on = Math.max(0, Math.min(10, Math.floor(pct / 10)))
79 return [on, 10 - on]
80}
81
82function num(n: number): string {
83 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`
84 if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}k`
85 return String(n)
86}
87
88function duration(ms: number): string {
89 const m = Math.max(0, Math.floor(ms / 60_000))
90 if (m < 60) return `${m}m`
91 return `${Math.floor(m / 60)}h ${m % 60}m`
92}
93
94const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
95function clock12(d: Date): string {
96 const h = d.getHours()
97 const m = d.getMinutes()
98 return `${h % 12 === 0 ? 12 : h % 12}${m === 0 ? '' : `:${String(m).padStart(2, '0')}`}${h < 12 ? 'am' : 'pm'}`
99}
100const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
101/** "Fri 16 Oct 12pm": the weekly reset, always with its date. */
102function dateLabel(iso: string | undefined): string | undefined {
103 if (!iso) return undefined
104 const at = Date.parse(iso)
105 if (!Number.isFinite(at)) return undefined
106 const d = new Date(at)
107 return `${DAYS[d.getDay()]} ${d.getDate()} ${MONTHS[d.getMonth()]} ${clock12(d)}`
108}
109/** "2:40pm" within the day, "Mon 9am" within the week, else "Oct 20". */
110function resetLabel(iso: string | undefined, now: number): string | undefined {
111 if (!iso) return undefined
112 const at = Date.parse(iso)
113 if (!Number.isFinite(at)) return undefined
114 const d = new Date(at)
115 const ahead = at - now
116 if (ahead < 20 * 3_600_000) return clock12(d)
117 if (ahead < 7 * 86_400_000) return `${DAYS[d.getDay()]} ${clock12(d)}`
118 return `${d.toLocaleString('en-US', { month: 'short' })} ${d.getDate()}`
119}
120
121/** "claude-opus-4-1-20250805" → "opus-4-1" for narrow panes. */
122function shortModel(m: string): string {
123 return m.replace(/^claude-/, '').replace(/-\d{8}$/, '')
124}
125
126/** One spelling for a model however it is named: "claude-opus-5-5-20261001[1m]", "Opus 5.5" and "opus-5-5" are all "opus-5-5". */
127function baseModel(m: string): string {
128 return m
129 .toLowerCase()
130 .trim()
131 .replace(/\[[^\]]*\]$/, '')
132 .replace(/^.*?claude-/, '')
133 .replace(/-\d{8}$/, '')
134 .replace(/[\s.]+/g, '-')
135}
136
137/** The aliases /model takes, and the families each may run on (opusplan plans on Opus and works on Sonnet). */
138const ALIAS: Record<string, string[]> = { opus: ['opus'], sonnet: ['sonnet'], haiku: ['haiku'], fable: ['fable'], opusplan: ['opus', 'sonnet'] }
139
140/** Whether a request on `sent` runs the model the person selected. "default" or an empty selection says nothing, so it matches. */
141function sameModel(selected: string, sent: string | undefined): boolean {
142 if (sent === undefined) return true
143 const a = baseModel(selected)
144 const b = baseModel(sent)
145 if (a === b || a === '' || a === 'default') return true
146 const families = ALIAS[a]
147 return families !== undefined && families.some(f => b === f || b.startsWith(`${f}-`))
148}
149
150/**
151 * The effort the person selected, in Claude Code's order: CLAUDE_CODE_EFFORT_LEVEL, then the selected model's own entry
152 * under modelSettings (what /effort sets for one model), then the top-level effortLevel. Undefined: none set, and the
153 * model's default applies, which this mod cannot know.
154 */
155async function chosenEffort($: EngineInterface, settings: Record<string, unknown> | undefined, model: string | undefined): Promise<string | undefined> {
156 const env = await $.env.get('CLAUDE_CODE_EFFORT_LEVEL').catch(() => undefined)
157 if (env) return env
158 const per = settings?.modelSettings
159 if (model && per && typeof per === 'object') {
160 const exact = Object.keys(per).filter(k => baseModel(k) === baseModel(model))
161 // An alias names one model only when exactly one entry is of its family.
162 const hits = exact.length > 0 ? exact : Object.keys(per).filter(k => sameModel(model, k))
163 const [only] = hits
164 const level = hits.length === 1 && only !== undefined ? (per as Record<string, { effortLevel?: unknown }>)[only]?.effortLevel : undefined
165 if (typeof level === 'string') return level
166 }
167 return typeof settings?.effortLevel === 'string' ? settings.effortLevel : undefined
168}
169
170function effortName(e: unknown): string | undefined {
171 if (typeof e === 'string') return e
172 if (typeof e === 'number') return `${e}`
173 return undefined
174}
175
176/** "● high". A level with no symbol (a token budget, a name this build does not know) is its name alone, never a wrong shape. */
177function effortLabel(effort: string): string {
178 const glyph = GLYPH[effort]
179 return glyph ? `${glyph} ${effort}` : effort
180}
181
182/**
183 * What the next response carries besides the conversation: system prompt, tools, memory files, skills. It is the
184 * breakdown's used rows other than Messages. Without a Messages row the two cannot be told apart, so there is none.
185 */
186function overheadOf(b: SessionContextBreakdown | undefined): number | undefined {
187 if (!b || !b.categories.some(c => c.name === 'Messages')) return undefined
188 const sum = b.categories.reduce((n, c) => (c.kind === 'used' && c.name !== 'Messages' ? n + c.tokens : n), 0)
189 return sum > 0 ? sum : undefined
190}
191
192/**
193 * A compaction leaves the engine with no context figure until the next response. This is the stand-in: the
194 * compaction's own count (the summary and the kept messages) plus the overhead, or nothing when either is missing.
195 * The count alone read 4.5 times low on 2026-10-13: 28,781 against a real 127,965.
196 */
197function afterCompaction(tokensAfter: number | undefined, overhead: number | undefined, win: number): Gauge['context'] {
198 if (tokensAfter === undefined || tokensAfter <= 0 || overhead === undefined || !(win > 0)) return undefined
199 const tokens = tokensAfter + overhead
200 return { pct: Math.min(100, Math.round((tokens / win) * 100)), tokens, window: win, estimated: true }
201}
202
203/** Reads the session's usage figures into the gauge. */
204async function refresh($: EngineInterface): Promise<void> {
205 const u = await $.session.usage()
206 const t = await $.clock.now()
207 const model = await $.session.model().catch(() => undefined)
208 const settings = await $.settings.read().catch(() => undefined)
209 // Before the first request names them: the model and effort the session starts with.
210 const startEffort = await chosenEffort($, settings, model)
211 const ttlChoice = await chosenTtl($, settings)
212 const limit = (kind: string): Limit | undefined => {
213 const r = u.rateLimits.find(x => x.kind === kind)
214 return r ? { pct: r.percentUsed, resetsAt: r.resetsAt } : undefined
215 }
216 await update($, gauge, g => ({
217 ...g,
218 // A /clear starts the session's figures over: what was cached belongs to the conversation that is gone.
219 ...(g.startedAt > 0 && u.startedAt > 0 && u.startedAt !== g.startedAt ? { cacheAt: undefined, cacheModel: undefined } : {}),
220 ttlChoice,
221 startedAt: u.startedAt,
222 now: t,
223 // After it, the chips are what was sent: the selection is compared with them at each request, never drawn over them.
224 model: g.ran ? g.model : model || g.model,
225 effort: g.ran ? g.effort : startEffort || g.effort,
226 // The pick as it stands, for the chips to show between requests.
227 pick: { model: model || undefined, effort: startEffort },
228 // No figure from the engine (a fresh session, a /clear, or a compaction before its next response): the old real
229 // figure is out of date and goes; a compaction's estimate stays until the real one arrives.
230 context:
231 u.context.percent !== undefined
232 ? { pct: u.context.percent, tokens: u.context.tokens, window: u.context.window }
233 : g.context?.estimated
234 ? g.context
235 : undefined,
236 fiveHour: limit('five_hour') ?? g.fiveHour,
237 weekly: limit('seven_day') ?? g.weekly,
238 cost: u.cost?.usd ?? g.cost,
239 }))
240}
241
242/**
243 * The git slots read the repository the session sits in, and only read it. `$.process.run` runs git with the repository's
244 * hooks off, so a plugin must never commit or push: status and fetch are all this file runs, and tests/git.test.tsx holds
245 * every argv to that. `--no-optional-locks` keeps status from taking the index lock, so it never makes the person's own
246 * git command wait. The v2 porcelain format gives the branch, ahead and behind, and one line per file in a single call.
247 */
248const GIT_STATUS = ['git', '--no-optional-locks', '-c', 'core.quotepath=off', 'status', '--porcelain=v2', '--branch', '--untracked-files=normal']
249/** A fetch writes git's remote-tracking refs and nothing in the working tree. It must never stop to ask for a password. */
250const GIT_FETCH = ['git', 'fetch', '--quiet', '--no-recurse-submodules']
251const GIT_FETCH_ENV = { GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' }
252/** How often the remote is asked for news, so that ↓ is current. */
253const FETCH_MS = 5 * MIN_MS
254
255let gitBusy = false
256let fetching = false
257let fetchedAt = 0
258let fetchFailed = false
259let upstream = false
260
261/** Runs `git status` and keeps the counts. A git that did not answer (it timed out) leaves the last counts; one that failed (not a repository) clears them. */
262async function readGit($: EngineInterface): Promise<void> {
263 const r = await $.process.run(GIT_STATUS, { timeoutMs: 15_000 }).catch(() => undefined)
264 if (!r) return
265 const parsed = r.exitCode === 0 ? parseStatus(r.stdout) : undefined
266 upstream = parsed?.upstream === true
267 const git: Git | undefined = parsed && (fetchFailed && parsed.upstream ? { ...parsed, stale: true } : parsed)
268 await update($, gauge, g => ({ ...g, git })).catch(() => undefined)
269}
270
271/** Asks the remote for news, then reads the counts again. A failed fetch (offline, no credentials) makes ↓ a question mark. */
272async function fetchGit($: EngineInterface): Promise<void> {
273 try {
274 const r = await $.process.run(GIT_FETCH, { env: GIT_FETCH_ENV, timeoutMs: 20_000 }).catch(() => undefined)
275 fetchFailed = !r || r.exitCode !== 0
276 await readGit($)
277 } finally {
278 fetching = false
279 }
280}
281
282/** The status now, and a fetch when one is due. Never awaited by a turn: the fetch can take as long as the network does. */
283async function refreshGit($: EngineInterface): Promise<void> {
284 if (!show.git || gitBusy) return
285 gitBusy = true
286 try {
287 await readGit($)
288 const t = await $.clock.now()
289 if (show.fetch && upstream && !fetching && t - fetchedAt >= FETCH_MS) {
290 fetching = true
291 fetchedAt = t
292 void fetchGit($).catch(() => undefined)
293 }
294 } catch {
295 // never in the way of the gauge
296 } finally {
297 gitBusy = false
298 }
299}
300
301type Usage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
302function addUsage(g: Gauge, u: Usage): Gauge {
303 return {
304 ...g,
305 tokensIn: g.tokensIn + u.input_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens,
306 tokensOut: g.tokensOut + u.output_tokens,
307 cacheRead: g.cacheRead + u.cache_read_input_tokens,
308 }
309}
310
311/**
312 * The main conversation's cache after a response. An entry lives from the START of the request that wrote or read it
313 * (not from the end of the reply), and each request that reports cache tokens, a hit or a write, restarts that clock. A
314 * response with none means nothing was cached (a prompt under the model's minimum, or caching off).
315 */
316function withCache(g: Gauge, u: Usage, sentAt: number | undefined, model: string): Gauge {
317 if ((u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0) <= 0) return { ...g, cacheAt: undefined, cacheModel: undefined }
318 if (sentAt === undefined) return g
319 return { ...g, cacheAt: sentAt, cacheModel: model, now: Math.max(g.now, sentAt) }
320}
321
322type Kit = { Box: (p: any) => RenderElement; Text: (p: any) => RenderElement }
323
324/**
325 * "⏳ 52m": how long the main conversation's prompt cache stays warm, counted from the request that last wrote or read
326 * it. The minutes are rounded up, so "1m" lasts to the last second. The value is plain text while there is time, then
327 * drawn in the same theme keys as a meter that fills: warning once 70% of the lifetime is spent, error from 90%, so the
328 * colour is relative to the lifetime (an hour on a plan, five minutes otherwise) and the number carries no note of it.
329 * "cold" in error once it has run out; a dash when nothing is cached (before the first response, after a /clear, a
330 * compaction or a model switch).
331 */
332function cacheChip(ui: Kit, g: Gauge, now: number): RenderElement {
333 const { Text } = ui
334 const glyph = <Text dimColor>{'⏳ '}</Text>
335 if (g.cacheAt === undefined) {
336 return (
337 <Text key="ug-cache">
338 {glyph}
339 <Text dimColor>–</Text>
340 </Text>
341 )
342 }
343 const ttl = cacheTtl(g)
344 const total = TTL_MS[ttl]
345 const left = g.cacheAt + total - Math.max(now, g.cacheAt)
346 if (left <= 0) {
347 return (
348 <Text key="ug-cache">
349 {glyph}
350 <Text color={stage(100)}>cold</Text>
351 </Text>
352 )
353 }
354 const color = stage(((total - left) / total) * 100)
355 const mins = Math.ceil(left / MIN_MS)
356 return (
357 <Text key="ug-cache">
358 {glyph}
359 <Text {...(color === 'rate_limit_fill' ? {} : { color })}>{`${mins}m`}</Text>
360 </Text>
361 )
362}
363
364/**
365 * "⏱ 1h 12m ⏳ 52m": the session's clock and the cache's, one chip, one space between. Either part can be turned off in
366 * /config and the other stands alone. Nothing at all when both are off, or when only the time is on and the session has
367 * not started.
368 */
369function clockChip(ui: Kit, g: Gauge, now: number, withTime: boolean, withCache: boolean): RenderElement | undefined {
370 const { Text } = ui
371 const parts: RenderElement[] = []
372 if (withTime && g.startedAt > 0) {
373 parts.push(
374 <Text key="ug-time">
375 <Text dimColor>⏱ </Text>
376 <Text>{duration(now - g.startedAt)}</Text>
377 </Text>,
378 )
379 }
380 if (withCache) parts.push(cacheChip(ui, g, now))
381 if (parts.length === 0) return undefined
382 return <Text key="ug-clock">{parts.flatMap((p, i) => (i === 0 ? [p] : [<Text key="ug-clock-s">{' '}</Text>, p]))}</Text>
383}
384
385/**
386 * "main ↓2 ±4 ↑1": the branch, then the repository's three questions in three fixed places, so the eye learns where to look. ↓ is what the
387 * remote has that this branch lacks (pull), ± is what waits to be committed, ↑ is what waits to be pushed (a dim ?n
388 * between them counts files git does not track yet, where the pane has room). A zero is dim and a count is lit, so a
389 * clean repository is three dim zeros, never a missing chip. The colours are the theme's: warning for news on the
390 * remote, error for a branch that has diverged or a file in conflict (slotsOf has the rules).
391 */
392function gitChip(ui: Kit, git: Git, withUntracked: boolean, branchRoom: number): RenderElement {
393 const { Text } = ui
394 const parts: RenderElement[] = []
395 // The branch is the one piece of the chip that gives: cut short with … to the room the pane leaves, or left out.
396 const branch = branchRoom > 0 ? clip(git.branch, branchRoom) : ''
397 if (branch !== '') {
398 parts.push(<Text key="ug-git-branch">{branch}</Text>)
399 parts.push(<Text key="ug-git-b">{' '}</Text>)
400 }
401 slotsOf(git, withUntracked).forEach((s, i) => {
402 if (i > 0) parts.push(<Text key={`ug-git-s${i}`}>{' '}</Text>)
403 parts.push(
404 <Text key={`ug-git-${s.key}`} {...(s.color ? { color: s.color } : {})} {...(s.dim ? { dimColor: true } : {})}>
405 {s.text}
406 </Text>,
407 )
408 })
409 return <Text key="ug-git">{parts}</Text>
410}
411
412function meter(ui: Kit, key: string, label: string, l: Limit | undefined, reset: string | undefined): RenderElement {
413 const { Text } = ui
414 if (!l) {
415 return (
416 <Text key={key}>
417 <Text dimColor>{`${label} `}</Text>
418 <Text dimColor>{'▱▱▱▱▱▱▱▱▱▱ –'}</Text>
419 </Text>
420 )
421 }
422 const [on, off] = cells(l.pct)
423 const color = stage(l.pct)
424 return (
425 <Text key={key}>
426 <Text dimColor>{`${label} `}</Text>
427 <Text color={color}>{'▰'.repeat(on)}</Text>
428 <Text dimColor>{'▱'.repeat(off)}</Text>
429 <Text bold color={color}>{` ${l.approx ? '~' : ''}${Math.round(l.pct)}%`}</Text>
430 {l.tokens !== undefined && <Text>{` ${l.approx ? '~' : ''}${num(l.tokens)}`}</Text>}
431 {reset && <Text dimColor>{` ↻ ${reset}`}</Text>}
432 </Text>
433 )
434}
435
436export const register: Register = (on, options) => {
437 show = showOf(options)
438
439 on('session.start', async ($, e, next) => {
440 await refresh($).catch(() => undefined)
441 void refreshGit($)
442 $.clock.every(REFRESH_MS, () => {
443 void refresh($).catch(() => undefined)
444 void refreshGit($)
445 })
446 return next(e)
447 })
448
449 // The main loop's model and effort, and how fast its reply came out.
450 on('turn.step', async function* ($, e, next) {
451 if (e.agentId !== undefined) {
452 // A subagent's request: its tokens are the session's too.
453 const r = yield* next(e)
454 try {
455 const u = r?.usage
456 if (u) void update($, gauge, g => addUsage(g, u)).catch(() => undefined)
457 } catch {
458 // no usage on this step
459 }
460 return r
461 }
462 // What the person had selected as this request went out, to hold the request to.
463 let selected: Gauge['selected']
464 try {
465 const [sel, settings] = await Promise.all([$.session.model().catch(() => undefined), $.settings.read().catch(() => undefined)])
466 // A request with no effort is on a model that takes none, and a token budget is no level: nothing to hold it to.
467 const effort = typeof e.effort !== 'string' ? undefined : await chosenEffort($, settings, sel || e.model)
468 selected = { model: sel || undefined, effort }
469 } catch {
470 selected = undefined
471 }
472 try {
473 const model = e.model
474 const effort = effortName(e.effort)
475 const same = (x: Gauge['selected']) => x?.model === selected?.model && x?.effort === selected?.effort
476 void update($, gauge, g =>
477 g.ran && g.model === model && g.effort === effort && same(g.selected) && (!selected || same(g.pick))
478 ? g
479 : { ...g, model, effort: effort ?? g.effort, ran: true, selected, ...(selected ? { pick: selected } : {}) },
480 ).catch(() => undefined)
481 // A cache belongs to the model that wrote it: a request on another model has none to read, so the clock goes.
482 void update($, gauge, g => (g.cacheModel !== undefined && g.cacheModel !== model ? { ...g, cacheAt: undefined, cacheModel: undefined } : g)).catch(() => undefined)
483 } catch {
484 // never in the way of the request
485 }
486 const sentAt = await $.clock.now().catch(() => undefined)
487 const stream = next(e)
488 let first: number | undefined
489 let piece = await stream.next()
490 while (!piece.done) {
491 if (first === undefined) first = await $.clock.now().catch(() => undefined)
492 yield piece.value
493 piece = await stream.next()
494 }
495 const result = piece.value
496 try {
497 const u = result?.usage
498 if (u) void update($, gauge, g => withCache(addUsage(g, u), u, sentAt, e.model)).catch(() => undefined)
499 const out = result?.usage?.output_tokens
500 const end = await $.clock.now()
501 if (first !== undefined && out && out >= 20 && end - first > 300) {
502 const tps = Math.round(out / ((end - first) / 1000))
503 void update($, gauge, g => ({ ...g, tps })).catch(() => undefined)
504 }
505 } catch {
506 // a step with no usage: keep the last speed
507 }
508 return result
509 })
510
511 // A compaction that stands: until the next response the engine has no context figure, so draw the compaction's own
512 // count plus the overhead (with a "~"), or a dash when either is missing. A skip, a precompute and a subagent's own
513 // compaction change nothing.
514 on('session.compact', async ($, e, next) => {
515 const r = await next(e)
516 if (e.agentId !== undefined || e.trigger === 'precompute' || r.skip !== undefined) return r
517 let context: Gauge['context']
518 try {
519 // 'summary' is a local estimate; 'full' would send one token-count request per tool and memory file.
520 const u = await $.session.usage({ breakdown: 'summary' })
521 context = afterCompaction(r.tokensAfter, overheadOf(u.context.breakdown), u.context.window)
522 } catch {
523 context = undefined
524 }
525 // The conversation is rewritten, so the cache of the old one is no use: a dash until the next response caches the new.
526 await update($, gauge, g => ({ ...g, context, cacheAt: undefined, cacheModel: undefined })).catch(() => undefined)
527 return r
528 }).catch(($, e, next) => next(e))
529
530 // A turn ended: cost, context and limits have moved.
531 on('turn.complete', async ($, e, next) => {
532 const r = await next(e)
533 if (e.agentId === undefined) {
534 await refresh($).catch(() => undefined)
535 // A plan's windows arrive with its first reply, so a finished turn without them means there is no plan. A turn
536 // stopped before any reply proves nothing.
537 if (!e.isAborted) await update($, gauge, g => ({ ...g, noPlan: g.fiveHour === undefined && g.weekly === undefined })).catch(() => undefined)
538 // The turn may have written files or committed: look again, without making the turn wait for git.
539 void refreshGit($)
540 }
541 return r
542 }).catch(($, e, next) => next(e))
543
544 // Two rows under the prompt, in place of the hint line, which stays beneath them.
545 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
546 const { Box, Text } = $.ui.resolve(e)
547 const ui = { Box, Text }
548 const g: Gauge = await read($, gauge)
549 // The surface's own measure. PromptHint's props carry no width (that is the AbovePrompt band's `bodyColumns`), so
550 // reading one here always fell back to wide. A surface that has not measured (no width, or 0) is drawn wide, whatever
551 // the threshold.
552 // With the git slots on the row, the token counts give way to them in a pane too narrow for both (see layoutOf).
553 // `show.git` is checked here as well as in refreshGit: the gauge's state outlives the reload that a change in /config
554 // causes, so a repository read by the module before it can still be in it.
555 const slots = show.git && g.git !== undefined
556 const { narrow: isNarrow, tokens: withTokens, untracked: withUntracked, branch: branchRoom } = layoutOf(e.viewport?.columns, slots, show.tokens)
557 const t = g.now || g.startedAt
558 // The person picked again since the last request (/model, /effort): the new pick, in the plain colours, until a
559 // request is held to it.
560 const pick = g.ran ? g.pick : undefined
561 const newModel = pick?.model !== undefined && g.selected !== undefined && pick.model !== g.selected.model ? pick.model : undefined
562 const newEffort = pick?.effort !== undefined && g.selected?.effort !== undefined && pick.effort !== g.selected.effort ? pick.effort : undefined
563 const model = newModel ?? g.model
564 const effort = newEffort ?? g.effort
565 // A request that did not send what the person selected: that chip in the theme's warning colour, "selected → sent".
566 const selModel = !newModel && g.ran && g.selected?.model !== undefined && !sameModel(g.selected.model, g.model) ? g.selected.model : undefined
567 const selEffort = !newEffort && g.ran && g.selected?.effort !== undefined && g.effort !== undefined && g.selected.effort !== g.effort ? g.selected.effort : undefined
568 const chip = model && (
569 <Text key="ug-chip">
570 {selModel ? (
571 <Text bold color={CHIP.model.fg} backgroundColor="warning">{` ${shortModel(selModel)} → ${shortModel(model)} `}</Text>
572 ) : (
573 <Text bold color={CHIP.model.fg} backgroundColor={CHIP.model.bg}>{` ${isNarrow ? shortModel(model) : model} `}</Text>
574 )}
575 {effort && (
576 <Text bold color={CHIP.effort.fg} backgroundColor={selEffort ? 'warning' : CHIP.effort.bg}>
577 {selEffort ? ` ${effortLabel(selEffort)} → ${effortLabel(effort)} ` : ` ${effortLabel(effort)} `}
578 </Text>
579 )}
580 </Text>
581 )
582 const gap = (k: string) => <Text key={k}>{' '}</Text>
583 // Row 1 is the items the person chose and the pane has room for, three spaces apart: a missing one leaves no hole.
584 const items: RenderElement[] = []
585 if (chip) items.push(chip)
586 const clock = clockChip(ui, g, t, show.time, show.cache)
587 if (clock) items.push(clock)
588 if (show.cost && g.ran && g.cost !== undefined) items.push(<Text key="ug-cost" bold>{`$${g.cost.toFixed(2)}`}</Text>)
589 if (show.speed && g.ran) {
590 items.push(g.tps !== undefined ? <Text key="ug-tps">{`⚡ ${g.tps} t/s`}</Text> : <Text key="ug-tps" dimColor>⚡ – t/s</Text>)
591 }
592 if (withTokens && g.ran) {
593 items.push(
594 <Text key="ug-tokens">
595 <Text dimColor>in </Text>
596 {g.tokensIn > 0 ? <Text>{num(g.tokensIn)}</Text> : <Text dimColor>–</Text>}
597 <Text dimColor> out </Text>
598 {g.tokensIn > 0 ? <Text>{num(g.tokensOut)}</Text> : <Text dimColor>–</Text>}
599 </Text>,
600 )
601 }
602 if (slots && g.git) items.push(gitChip(ui, g.git, withUntracked, branchRoom))
603 const row1 = (
604 <Box key="ug-row1" flexWrap="wrap">
605 {items.flatMap((item, i) => (i === 0 ? [item] : [gap(`ug-g${i}`), item]))}
606 </Box>
607 )
608 // The context meter also shows how many tokens the context holds (it was the session board's "ctx 305K" chip).
609 const ctx: Limit | undefined = g.context ? { pct: g.context.pct, approx: g.context.estimated, tokens: g.context.tokens } : undefined
610 // No plan (an API key): the limit meters would stay empty for good, so row 2 is the context meter alone.
611 const limits = !(g.noPlan && g.fiveHour === undefined && g.weekly === undefined)
612 const row2 = (
613 <Box key="ug-row2" flexWrap="wrap">
614 {meter(ui, 'ug-ctx', isNarrow ? 'ctx' : 'context', ctx, undefined)}
615 {limits && <Text>{isNarrow ? ' ' : ' '}</Text>}
616 {limits && meter(ui, 'ug-5h', isNarrow ? '5h' : '5 hour', g.fiveHour, resetLabel(g.fiveHour?.resetsAt, t))}
617 {limits && <Text>{isNarrow ? ' ' : ' '}</Text>}
618 {limits && meter(ui, 'ug-wk', isNarrow ? 'wk' : 'weekly', g.weekly, isNarrow ? undefined : dateLabel(g.weekly?.resetsAt))}
619 </Box>
620 )
621 const beneath = await next(e)
622 const isEmpty = (n: unknown): boolean => {
623 if (n === null || n === undefined || n === false || n === true) return true
624 if (typeof n === 'string') return n.trim() === ''
625 if (Array.isArray(n)) return n.every(isEmpty)
626 const el = n as { type?: unknown; children?: unknown[] }
627 if (el.type === 'Box' || el.type === 'Text') return (el.children ?? []).every(isEmpty)
628 return false
629 }
630 return (
631 <Box flexDirection="column">
632 {row1}
633 {row2}
634 {!isEmpty(beneath) && beneath}
635 </Box>
636 )
637 }).catch(($, e, next) => next(e))
638}
639hooks/git.ts 156 lines1import type { Git } from '../types'
2
3// The git slots and the options, with no engine in them: what `git status` said as the counts, the counts as the slots,
4// which row-1 items the person chose, and how wide the pane must be for each. register.tsx runs git and draws.
5
6/**
7 * `git status --porcelain=v2 --branch`, read into the branch and the counts. Undefined when the text is not that output
8 * (no `# branch.oid` header, which git prints even in a repository with no commit). `# branch.ab` is printed only while
9 * the branch tracks a remote branch that exists, so its absence is "no upstream". `# branch.head` is the branch's name,
10 * or "(detached)", and then the commit's short hash stands in for the name.
11 */
12export function parseStatus(text: string): Git | undefined {
13 let header = false
14 let oid = ''
15 let head = ''
16 let upstream = false
17 let ahead = 0
18 let behind = 0
19 let changed = 0
20 let conflicts = 0
21 let untracked = 0
22 for (const line of text.split(/\r?\n/)) {
23 if (line.startsWith('# branch.oid ')) {
24 header = true
25 oid = line.slice('# branch.oid '.length).trim()
26 } else if (line.startsWith('# branch.head ')) head = line.slice('# branch.head '.length).trim()
27 else if (line.startsWith('# branch.ab ')) {
28 const m = /^# branch\.ab \+(\d+) -(\d+)/.exec(line)
29 if (m) {
30 upstream = true
31 ahead = Number(m[1])
32 behind = Number(m[2])
33 }
34 } else if (line.startsWith('1 ') || line.startsWith('2 ')) changed++
35 else if (line.startsWith('u ')) conflicts++
36 else if (line.startsWith('? ')) untracked++
37 }
38 if (!header) return undefined
39 const detached = head === '(detached)'
40 const branch = detached ? (oid.startsWith('(') ? '' : oid.slice(0, 7)) : head
41 return { branch, ...(detached ? { detached } : {}), upstream, ahead, behind, changed, conflicts, untracked }
42}
43
44/** A count as drawn: three characters at most. */
45export const cap = (n: number): string => (n > 99 ? '99+' : String(n))
46
47/** A branch name in at most `room` cells: cut short with "…" when it is longer. Nothing when there is no room. */
48export function clip(name: string, room: number): string {
49 const chars = [...name]
50 if (room < 1) return ''
51 return chars.length <= room ? name : chars.slice(0, room - 1).join('') + '…'
52}
53
54/** One slot. No colour and not dim: a count that is lit. */
55export type Slot = {
56 key: 'behind' | 'changed' | 'untracked' | 'ahead'
57 text: string
58 /** The theme key the slot is drawn in. */
59 color?: 'warning' | 'error'
60 dim?: boolean
61}
62
63/**
64 * The slots, left to right: ↓ commits to pull, ± files to commit, ? files git does not know, ↑ commits to push. A zero
65 * is dim, a count is lit. The remote's news is old or missing in three cases, each drawn so it is never read as "none":
66 * "↓?" when the last fetch failed, a dash for ↓ and ↑ when the branch tracks nothing. A branch that is both behind and
67 * ahead has diverged: both arrows in the theme's error. A conflicted file puts ± in error. Untracked files are their own
68 * dim slot, only when there are some, and only where the pane has room for it.
69 */
70export function slotsOf(g: Git, withUntracked: boolean): Slot[] {
71 // A stale ↓ is not known to be above zero, so the branch is not called diverged on its word.
72 const diverged = g.upstream && !g.stale && g.ahead > 0 && g.behind > 0
73 const behind: Slot = !g.upstream
74 ? { key: 'behind', text: '↓–', dim: true }
75 : g.stale
76 ? { key: 'behind', text: '↓?', dim: true }
77 : g.behind === 0
78 ? { key: 'behind', text: '↓0', dim: true }
79 : { key: 'behind', text: `↓${cap(g.behind)}`, color: diverged ? 'error' : 'warning' }
80 const files = g.changed + g.conflicts
81 const changed: Slot =
82 g.conflicts > 0
83 ? { key: 'changed', text: `±${cap(files)}`, color: 'error' }
84 : files === 0
85 ? { key: 'changed', text: '±0', dim: true }
86 : { key: 'changed', text: `±${cap(files)}` }
87 const ahead: Slot = !g.upstream
88 ? { key: 'ahead', text: '↑–', dim: true }
89 : g.ahead === 0
90 ? { key: 'ahead', text: '↑0', dim: true }
91 : { key: 'ahead', text: `↑${cap(g.ahead)}`, ...(diverged ? { color: 'error' as const } : {}) }
92 const untracked: Slot[] = withUntracked && g.untracked > 0 ? [{ key: 'untracked', text: `?${cap(g.untracked)}`, dim: true }] : []
93 return [behind, changed, ...untracked, ahead]
94}
95
96/** Which of the optional row-1 items the person wants, and whether to fetch. Anything but an explicit false is on. */
97export type Show = { time: boolean; cost: boolean; speed: boolean; tokens: boolean; git: boolean; cache: boolean; fetch: boolean }
98
99export function showOf(options: Readonly<Record<string, unknown>>): Show {
100 const on = (v: unknown): boolean => v !== false
101 return {
102 time: on(options.showTime),
103 cost: on(options.showCost),
104 speed: on(options.showSpeed),
105 tokens: on(options.showTokens),
106 git: on(options.showGit),
107 cache: on(options.showCache),
108 fetch: on(options.fetchRemote),
109 }
110}
111
112/**
113 * Below this many columns the rows use short labels and drop the token counts and the weekly reset. The widest wide row,
114 * every item at its longest, is 113 cells (tests/width.test.tsx measures it), and a row wider than the surface wraps a
115 * meter or the cache chip onto a third line. 115 leaves 2 to spare.
116 */
117export const NARROW_BELOW = 115
118
119/**
120 * With the git slots on the row, row 1 at its longest (the clock chip showing "cold", every count at 99+, the model and
121 * effort longest) is this many cells BEFORE the branch name: 80 in a narrow pane, 101 in a wide one, 120 in a wide one
122 * with the token counts. The branch name is the flexible item. It takes the room that is left, 2 cells to spare, up to
123 * BRANCH_MAX, and a pane with less than BRANCH_MIN cells for it draws none. The token counts give way to the git slots:
124 * they wait for TOKENS_FROM columns, the 120 plus the 2 to spare plus a branch of 8 (and its space). The untracked slot
125 * needs no wait: every wide pane has room for it. tests/width.test.tsx measures all of these on the drawn rows.
126 */
127const FIXED_NARROW = 80
128const FIXED_WIDE = 101
129const FIXED_WIDE_TOKENS = 120
130const SPARE = 2
131export const BRANCH_MAX = 16
132export const BRANCH_MIN = 4
133export const TOKENS_FROM = FIXED_WIDE_TOKENS + SPARE + 1 + 8
134
135export type Layout = {
136 narrow: boolean
137 tokens: boolean
138 untracked: boolean
139 /** Cells the branch name may take; 0: none is drawn. */
140 branch: number
141}
142
143/**
144 * What the pane has room for. `columns` is the surface's own measure; none, or 0, is a surface that has not measured and
145 * is drawn in full. `git` is whether the slots are on the row at all: without them nothing gives way, so the thresholds
146 * are the ones the gauge had before the slots (narrow below 115, tokens in every wide row).
147 */
148export function layoutOf(columns: number | undefined, git: boolean, tokens: boolean): Layout {
149 const known = columns !== undefined && columns > 0
150 const narrow = known && columns < NARROW_BELOW
151 const withTokens = tokens && !narrow && (!git || !known || columns >= TOKENS_FROM)
152 const fixed = narrow ? FIXED_NARROW : withTokens ? FIXED_WIDE_TOKENS : FIXED_WIDE
153 const room = known ? Math.min(BRANCH_MAX, columns - SPARE - fixed - 1) : BRANCH_MAX
154 return { narrow, tokens: withTokens, untracked: !narrow, branch: room >= BRANCH_MIN ? room : 0 }
155}
156types/index.d.ts 94 lines1/** One usage limit window as the session reports it. */
2export type Limit = {
3 pct: number
4 resetsAt?: string
5 /** An estimate, drawn with a "~". */
6 approx?: boolean
7 /** Tokens held, drawn after the percent (the context meter only). */
8 tokens?: number
9}
10
11/** The repository the session sits in, as `git status` and the last fetch left it. */
12export type Git = {
13 /** The branch's name; for a detached HEAD, the commit's short hash. Empty: git did not say. */
14 branch: string
15 /** HEAD is on a commit, not on a branch. */
16 detached?: boolean
17 /** The branch tracks a remote branch: ahead and behind mean something. */
18 upstream: boolean
19 /** Commits here that the remote branch lacks. */
20 ahead: number
21 /** Commits on the remote branch that are not here, as of the last fetch. */
22 behind: number
23 /** Tracked files staged or modified; a conflicted file is counted in `conflicts`, not here. */
24 changed: number
25 /** Files with an unresolved merge conflict. */
26 conflicts: number
27 /** Files git neither tracks nor ignores; a folder of them counts as one. */
28 untracked: number
29 /** The last fetch failed: `behind` is old news, drawn as a question mark. */
30 stale?: boolean
31}
32
33/** Everything the two rows draw. */
34export type Gauge = {
35 /** The main loop's model and effort, as its latest request named them. */
36 model?: string
37 effort?: string
38 /**
39 * What the person had selected (/model, /effort, env and settings) when the latest main request went out. A model or
40 * effort the request did not send is drawn on its chip in the theme's warning colour. `effort` is absent when none was
41 * set or the request sent none.
42 */
43 selected?: { model?: string; effort?: string }
44 /**
45 * The selection as the latest refresh (every 15 s, at session start, after each turn) read it. When it differs from
46 * `selected`, the person picked again since the last request, and the chips show the new pick in their plain colours.
47 */
48 pick?: { model?: string; effort?: string }
49 /**
50 * The engine has no figure from a compaction until the next response: `estimated` marks the stand-in, the
51 * compaction's own `tokensAfter` plus the overhead from the context breakdown. The engine's real figure replaces it.
52 */
53 context?: { pct: number; tokens?: number; window: number; estimated?: boolean }
54 fiveHour?: Limit
55 weekly?: Limit
56 /**
57 * A main turn ended with neither window reported: an API key, a cloud provider or a gateway, which has no plan. The two
58 * limit meters are not drawn while it holds and no window has turned up since.
59 */
60 noPlan?: boolean
61 /** US dollars so far, API-price equivalent. */
62 cost?: number
63 startedAt: number
64 now: number
65 /** Output tokens per second of the latest main-loop reply. */
66 tps?: number
67 /** Session totals: input (uncached + cache-written + cache-read), output, and the cache-read part of input. */
68 tokensIn: number
69 tokensOut: number
70 cacheRead: number
71 /** A main-loop request has gone out: before it, cost, speed and tokens stay hidden. */
72 ran?: boolean
73 /**
74 * When the request that last wrote or read the main conversation's prompt cache was sent (`$.clock.now()`
75 * milliseconds). An entry lives from the start of that request. Absent: nothing cached yet, or the entry was
76 * dropped by a /clear, a compaction or a switch of model.
77 */
78 cacheAt?: number
79 /** The model that request named: a cache belongs to one model. */
80 cacheModel?: string
81 /** The lifetime the person chose in env or settings; absent: the plan decides (an hour on a subscription within its usage, else five minutes). */
82 ttlChoice?: '5m' | '1h'
83 /** Absent: not a repository, or git has not answered yet. */
84 git?: Git
85}
86
87declare module 'claude-code' {
88 interface PluginState {
89 'usage-gauge': {
90 gauge: Gauge
91 }
92 }
93}
94