SLOPSHOPPER

usage-gauge

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…

newspinnerprocesstimer
v0.10.1MITupdated 2026-10-23richardmuucyi/rmahwj-mods/usage-gauge
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-gauge
› 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › claude-opus-5-5 ⏱ 30m ⏳ – context ▰▰▰▰▱▱▱▱▱▱ 49% 97k 5 hour ▰▰▰▱▱▱▱▱▱▱ 31% weekly ▱▱▱▱▱▱▱▱▱▱ – ⟨Claude Code's own drawing⟩

Draws

Prompt hint
claude-opus-5-5 ⏱ 30m ⏳ – context ▰▰▰▰▱▱▱▱▱▱ 49% 97k 5 hour ▰▰▰▱▱▱▱▱▱▱ 31% weekly ▱▱▱▱▱▱▱▱▱▱ – ⟨Claude Code's own drawing⟩
README

usage-gauge

Two rows under the prompt:

  • model and effort, session time, cost, output speed, and tokens in and out;
  • context size, and your 5-hour and weekly limits as 10-cell bars with their reset times.

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.

Install

/plugin marketplace add richardmuucyi/rmahwj-mods
/plugin install usage-gauge@rmahwj-mods
/reload-plugins

Tests

claude plugin test usage-gauge
Source 3 files
hooks/register.tsx 639 lines
1import { 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}
639
hooks/git.ts 156 lines
1import 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}
156
types/index.d.ts 94 lines
1/** 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