SLOPSHOPPER

session-stats

Shows the model, context fill and rate-limit usage above the prompt, with a pane that breaks the context window down.

newpanebandguardcommandprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-stats
│ ┃ Usage ✕ › fix the failing auth test and add an audit log call │ ┃ Usage Opus 5.5 [ Refresh (r) ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ Context ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 49% 97k ⎿ Read 6 lines │ ┃ Session limit ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 31% ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ This session ⏺ Bash(bun test) │ ┃ Cost $0.42 Turns 42s Cache hit 93% ⎿ 3 pass, 1 fail │ ┃ Opus 5.5 100% │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Breakdown │ ┃ Input 2k ✻ Worked for 42s · done 4:20 PM │ ┃ Output 1k │ ┃ Cache read 91k › /session-stats │ ┃ Cache write 4k ⎿ session-stats: Session stats pane opened. │ ┃ │ ┃ Context window │ ┃ No context breakdown yet. │ Opus 5.5 ◼◼◼◼◼ 49% 5h 31% − ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Opus 5.5 ◼◼◼◼◼ 49% 5h 31% − ⟨Claude Code's own drawing⟩
Pane · Usage
Usage Opus 5.5 [ Refresh (r) ] Context ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 49% 97k / 200k Session limit ◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ 31% This session Cost $0.42 Turns 42s Cache hit 93% Opus 5.5 100% Breakdown Input 2k Output 1k Cache read 91k Cache write 4k Context window No context breakdown yet.
README

session-stats

A Claude Code mod that keeps the model, context fill and rate-limit usage in view, so you can drop a statusline script for them.

Mods run code inside Claude Code on your machine. Read the source before you install one — this one is a single file, hooks/register.tsx, and only reads the session's own figures. Its only shell commands are read-only git calls for the branch and worktree, and it makes no network calls.

The band

Shown above the prompt:

Sonnet 5.5  ◼◼◼◼◼ 12%  5h 8% (34m)  W 13% (4d 2h)  ⎇ main  +
  • The model, as /model shows it, then an icon for the reasoning effort: ○ low, ◐ medium, ● high, ◉ xhigh, ◈ max (a number if the effort is a token budget). A /model switch shows at once and clears the effort icon until the new model's first turn; one made elsewhere (the Alt+P picker, a fallback, the IDE) shows from the next turn. The effort is read from each request, so it appears after the first turn; /effort <level> shows at once, while a level picked from a menu shows from the next turn. Models without an effort setting show no icon.
  • A five-square bar and percentage for the context window.
  • Each rate-limit window (5h, W) with its percentage and the time until it resets. Windows appear once the first response has reported them, and only on a subscription.
  • After ⎇, the linked git worktree's name when the session is in one, the git branch otherwise. It follows the session into and out of worktrees, is read again after each turn, and is shortened to fit the terminal width. Outside a git repository it is left out.
  • Bars and percentages are green, turn yellow at 60% and red at 85%.
  • The button on the right shows + while the pane is closed and − while it is open. Press it to open or close the pane.

The band is hidden while a survey is up.

The pane

Open it with the + button or /session-stats.

  • The model, context fill and each rate-limit window as a 20-square bar, with reset countdowns. Session (5h) and weekly windows show as Session limit and Weekly · all models; a per-model weekly window the engine reports, such as Fable, shows as Weekly · <model>, and as W <model> in the band.
  • This session: cost, turn time, cache hit and each model's share of tokens.
  • Breakdown: input, output, cache read and cache write tokens.
  • Context window: by category (system prompt, tools, messages, free space), largest first, estimated locally.

The session figures add up the turns finished since the mod loaded. A reload keeps them, /clear does not reset them, and "Turns" is the wall-clock time of finished turns, not the API's own time.

r refreshes. Esc hands the keyboard back and closes the pane.

Freshness

The figures are pushed by the engine after each turn and whenever a rate-limit window moves a whole point, and refreshed straight after /model and /effort. Reset countdowns tick once a minute.

Install

/plugin marketplace add cjmellor/mella-marketplace
/plugin install session-stats@mella-marketplace
/reload-plugins

To try it from a checkout without installing:

claude --plugin-dir plugins/session-stats

Develop

claude plugin validate plugins/session-stats
claude plugin test plugins/session-stats

validate does not check what the band draws: an invalid render tree is dropped by the engine and the band simply does not appear. If it goes missing after an edit, run Claude Code with --debug and look for a ui.render (AbovePrompt): a hook returned a tree that does not validate line.

Limits

  • It cannot draw in the status line or hide it. Keep your statusline for the directory, branch and PR, or move those into another mod.
  • Only one hook can draw the band. This mod asks the hooks beneath it for their tree and stacks its own row on top, so it composes with git-diff only when it loads first. A mod that draws without asking replaces this one.
  • Mods get no event for a model or effort change, only for the /model and /effort commands. A level /effort refuses or lowers for the model still shows as typed until the next turn corrects it.
  • The pane is a snapshot taken when it opens (press r to refresh).
  • A hot reload of the mod closes an open pane.
  • The terminal draws a button under the pointer as an inverted block. That comes from the engine and cannot be styled.
Source 2 files
hooks/register.tsx 507 lines
1import type { EngineInterface, Register, SessionUsage, TurnUsage } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4import type { Category, Snapshot, Totals } from '../types'
5
6const emptyTotals: Totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, turnMs: 0, byModel: {} }
7
8const snapshot = atom({ plugin: 'session-stats', key: 'snapshot' } as const, null)
9const breakdown = atom({ plugin: 'session-stats', key: 'breakdown' } as const, [] as Category[])
10const isOpen = atom({ plugin: 'session-stats', key: 'isOpen' } as const, false)
11const now = atom({ plugin: 'session-stats', key: 'now' } as const, 0)
12const totals = atom({ plugin: 'session-stats', key: 'totals' } as const, emptyTotals)
13const effort = atom({ plugin: 'session-stats', key: 'effort' } as const, null)
14const location = atom({ plugin: 'session-stats', key: 'location' } as const, null)
15
16const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
17const EFFORT_ICONS: Record<(typeof EFFORT_LEVELS)[number], string> = { low: '○', medium: '◐', high: '●', xhigh: '◉', max: '◈' }
18
19export const effortIcon = (level: string | number | null): string =>
20  level === null ? '' : (EFFORT_ICONS[String(level) as keyof typeof EFFORT_ICONS] ?? String(level))
21
22export const parseEffort = (args: string): string | null => {
23  const level = args.trim().toLowerCase()
24
25  return (EFFORT_LEVELS as readonly string[]).includes(level) ? level : null
26}
27
28const BAR_CELLS = 5
29const PANE_BAR_CELLS = 20
30const PANE = 'usage'
31const MAX_PANE_ROWS = 30
32const TICK_MS = 60_000
33const LOCATION_OVERHEAD = 4
34const BAND_RESERVE = 2
35
36type Level = 'green' | 'yellow' | 'red'
37
38let ticker: { cancel: () => void } | undefined
39export const prettyModel = (id: string): string => {
40  const match = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?!\d)/.exec(id)
41
42  if (!match) {
43    return id
44  }
45
46  const [, family = '', major, minor] = match
47
48  return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${minor ? `${major}.${minor}` : major}`
49}
50
51export const levelColor = (percent: number): Level => (percent >= 85 ? 'red' : percent >= 60 ? 'yellow' : 'green')
52
53const capitalise = (word: string): string => `${word.charAt(0).toUpperCase()}${word.slice(1)}`
54
55const windowModel = (kind: string): string | undefined => /^seven_day_(.+)$/.exec(kind)?.[1]
56
57export const limitLabel = (kind: string): string => {
58  const model = windowModel(kind)
59
60  return model ? `W ${capitalise(model)}` : ({ five_hour: '5h', seven_day: 'W', spend_limit: 'Spend' })[kind] ?? kind
61}
62
63export const limitTitle = (kind: string): string => {
64  const model = windowModel(kind)
65
66  return model
67    ? `Weekly · ${capitalise(model)}`
68    : ({ five_hour: 'Session limit', seven_day: 'Weekly · all models', spend_limit: 'Spend limit' })[kind] ?? kind
69}
70
71export const formatDuration = (ms: number): string => {
72  const seconds = Math.round(ms / 1000)
73  const hours = Math.floor(seconds / 3600)
74  const minutes = Math.floor((seconds % 3600) / 60)
75
76  if (hours > 0) {
77    return `${hours}h ${minutes}m`
78  }
79
80  return minutes > 0 ? `${minutes}m ${seconds % 60}s` : `${seconds}s`
81}
82
83export const addTurn = (current: Totals, usage: TurnUsage, durationMs: number): Totals => ({
84  input: current.input + usage.input_tokens,
85  output: current.output + usage.output_tokens,
86  cacheRead: current.cacheRead + usage.cache_read_input_tokens,
87  cacheWrite: current.cacheWrite + usage.cache_creation_input_tokens,
88  turnMs: current.turnMs + durationMs,
89  byModel: {
90    ...current.byModel,
91    [usage.model]:
92      (current.byModel[usage.model] ?? 0) +
93      usage.input_tokens +
94      usage.output_tokens +
95      usage.cache_read_input_tokens +
96      usage.cache_creation_input_tokens,
97  },
98})
99
100export const cacheHit = (current: Totals): number | null => {
101  const prompt = current.input + current.cacheRead + current.cacheWrite
102
103  return prompt === 0 ? null : Math.round((current.cacheRead / prompt) * 100)
104}
105
106export const modelShares = (current: Totals): { model: string; percent: number }[] => {
107  const sum = Object.values(current.byModel).reduce((acc, tokens) => acc + tokens, 0)
108
109  return Object.entries(current.byModel)
110    .map(([model, tokens]) => ({ model, percent: sum === 0 ? 0 : Math.round((tokens / sum) * 100) }))
111    .filter(share => share.percent > 0)
112    .sort((a, b) => b.percent - a.percent)
113}
114
115export const fillCells = (percent: number, cells = BAR_CELLS): number =>
116  Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
117
118export const countdown = (resetsAt: string | undefined, at: number): string => {
119  const minutes = Math.ceil((Date.parse(resetsAt ?? '') - at) / 60_000)
120
121  if (!Number.isFinite(minutes) || minutes <= 0) {
122    return ''
123  }
124
125  const days = Math.floor(minutes / 1440)
126  const hours = Math.floor((minutes % 1440) / 60)
127
128  if (days > 0) {
129    return `${days}d ${hours}h`
130  }
131
132  return hours > 0 ? `${hours}h ${minutes % 60}m` : `${minutes}m`
133}
134
135export const tokensLabel = (tokens: number): string =>
136  tokens >= 1_000_000 ? `${(tokens / 1_000_000).toFixed(1)}M` : tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : `${tokens}`
137
138export const toSnapshot = (model: string, usage: SessionUsage): Snapshot => ({
139  model,
140  percent: usage.context.percent ?? null,
141  tokens: usage.context.tokens ?? null,
142  window: usage.context.window,
143  limits: usage.rateLimits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt })),
144  usd: usage.cost?.usd ?? null,
145})
146
147export const parseLocation = (revParse: string, branch: string, head: string): string | null => {
148  const [toplevel, gitDir, commonDir] = revParse.split('\n').map(line => line.trim())
149
150  if (!toplevel || !gitDir || !commonDir) {
151    return null
152  }
153
154  const worktree = gitDir === commonDir ? null : toplevel.split('/').filter(Boolean).pop()
155
156  return worktree || branch.trim() || head.trim() || null
157}
158
159export const fitLocation = (name: string | null, room: number): string => {
160  const left = room - LOCATION_OVERHEAD
161
162  if (!name || left < 1) {
163    return ''
164  }
165
166  return name.length > left ? `${name.slice(0, left - 1)}…` : name
167}
168
169export const bandColumns = (current: Snapshot, icon: string, at: number): number => {
170  const percent = current.percent === null ? '–' : `${current.percent}%`
171  const limits = current.limits.reduce((sum, limit) => {
172    const left = countdown(limit.resetsAt, at)
173
174    return sum + 2 + limitLabel(limit.kind).length + 1 + `${Math.round(limit.percentUsed)}%`.length + (left ? left.length + 3 : 0)
175  }, 0)
176
177  return prettyModel(current.model).length + (icon ? icon.length + 1 : 0) + 2 + BAR_CELLS + 1 + percent.length + limits + 3
178}
179
180async function readLocation($: EngineInterface): Promise<string | null> {
181  const top = await $.process.run(['git', 'rev-parse', '--path-format=absolute', '--show-toplevel', '--git-dir', '--git-common-dir'])
182
183  if (top.exitCode !== 0) {
184    return null
185  }
186
187  const named = await $.process.run(['git', 'branch', '--show-current'])
188  const detached = named.exitCode === 0 && !named.stdout.trim() ? await $.process.run(['git', 'rev-parse', '--short', 'HEAD']) : null
189
190  return parseLocation(top.stdout, named.exitCode === 0 ? named.stdout : '', detached?.stdout ?? '')
191}
192
193async function refreshLocation($: EngineInterface) {
194  const next = await readLocation($).catch(() => null)
195
196  if ((await read($, location)) !== next) {
197    await update($, location, () => next)
198  }
199}
200
201async function refresh($: EngineInterface) {
202  const usage = await $.session.usage()
203  const model = await $.session.model()
204  await update($, snapshot, () => toSnapshot(model, usage))
205  await update($, now, () => Date.now())
206}
207
208async function tick($: EngineInterface) {
209  await update($, now, () => Date.now())
210}
211
212async function loadBreakdown($: EngineInterface) {
213  await refresh($)
214  const { context } = await $.session.usage({ breakdown: 'summary' })
215  const categories = (context.breakdown?.categories ?? []).map(({ name, tokens, kind }) => ({ name, tokens, kind }))
216  await update($, breakdown, () => categories)
217}
218
219async function openPane($: EngineInterface) {
220  await loadBreakdown($)
221  const current = await read($, snapshot)
222  const lines = (await read($, breakdown)).length + (current?.limits.length ?? 0) + 20
223  await $.ui.open({ id: PANE, title: 'Usage', focus: true, closeOnEscape: true, rows: Math.min(lines, MAX_PANE_ROWS) })
224  await update($, isOpen, () => true)
225}
226
227async function togglePane($: EngineInterface) {
228  const panes = await $.ui.panes()
229
230  if (panes.some(pane => pane.id === PANE)) {
231    await $.ui.close({ id: PANE })
232    await update($, isOpen, () => false)
233
234    return
235  }
236
237  await openPane($)
238}
239
240type TextElement = ReturnType<EngineInterface['ui']['resolve']>['Text']
241
242function squares(Text: TextElement, percent: number | null, cells: number) {
243  const filled = percent === null ? 0 : fillCells(percent, cells)
244  const color = percent === null ? undefined : levelColor(percent)
245
246  return [...Array(cells).keys()].map(index => (
247    <Text color={index < filled ? color : undefined} dimColor={index >= filled}>◼</Text>
248  ))
249}
250
251const RESEED_MS = 500
252
253async function seed($: EngineInterface) {
254  await update($, isOpen, () => false)
255  await refresh($)
256  await refreshLocation($)
257  ticker?.cancel()
258  ticker = await $.clock.every(TICK_MS, () => tick($))
259}
260
261export const register: Register = on => {
262  on('session.start', async ($, e, next) => {
263    await seed($)
264    await $.command
265      .register({ name: 'session-stats', description: 'Show model, effort and usage in a pane' })
266      .catch(() => undefined)
267
268    return next(e)
269  })
270
271  // /clear resets this plugin's state after session.end and fires no session.start, so setup reruns from a timer.
272  on('session.end', async ($, e, next) => {
273    if (e.reason === 'clear') {
274      $.clock.after(RESEED_MS, () => {
275        void seed($)
276      })
277    }
278
279    return next(e)
280  })
281
282  on('command.run', { command: 'session-stats' }, async $ => {
283    await openPane($)
284
285    return { text: 'Session stats pane opened.' }
286  })
287
288  on('command.run', { command: 'effort' }, async ($, e, next) => {
289    const result = await next(e)
290    const level = parseEffort(e.args)
291
292    if (level) {
293      await update($, effort, () => level)
294    }
295
296    return result
297  })
298
299  on('command.run', { command: 'model' }, async ($, e, next) => {
300    const before = (await read($, snapshot))?.model
301    const result = await next(e)
302    await refresh($).catch(() => undefined)
303
304    if ((await read($, snapshot))?.model !== before) {
305      await update($, effort, () => null)
306    }
307
308    return result
309  })
310
311  on('ui.close', async ($, e, next) => {
312    const closed = await next(e)
313
314    if (e.id === PANE) {
315      await update($, isOpen, () => false)
316    }
317
318    return closed
319  })
320
321  on('session.measure', async ($, e, next) => {
322    await refresh($)
323
324    return next(e)
325  })
326
327  on('turn.step', async function* ($, e, next) {
328    if (!e.agentId) {
329      await update($, effort, () => e.effort ?? null)
330    }
331
332    yield* next(e)
333  })
334
335  on('turn.complete', async ($, e, next) => {
336    if (e.usage) {
337      const usage = e.usage
338      await update($, totals, current => addTurn(current, usage, e.durationMs))
339    }
340
341    await refresh($)
342    await refreshLocation($)
343
344    return next(e)
345  })
346
347  on('tool.call', { tool: ['EnterWorktree', 'ExitWorktree'] }, async ($, e, next) => {
348    const result = await next(e)
349
350    $.clock.after(0, () => {
351      void refreshLocation($).catch(() => undefined)
352    })
353
354    return result
355  })
356
357  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
358    const other = await next(e)
359    const current = await read($, snapshot)
360
361    if (e.props.hasSurvey || current === null) {
362      return other
363    }
364
365    const { Box, Button, Text } = $.ui.resolve(e)
366    const at = await read($, now)
367    const paneIsOpen = await read($, isOpen)
368    const icon = effortIcon(await read($, effort))
369    const shown = fitLocation(await read($, location), e.props.bodyColumns - bandColumns(current, icon, at) - BAND_RESERVE)
370
371    const band = (
372      <Box>
373        <Text bold>{prettyModel(current.model)}</Text>
374        {icon && <Text>{` ${icon}`}</Text>}
375        <Text>  </Text>
376        {squares(Text, current.percent, BAR_CELLS)}
377        <Text color={current.percent === null ? undefined : levelColor(current.percent)} dimColor={current.percent === null}>
378          {` ${current.percent === null ? '–' : `${current.percent}%`}`}
379        </Text>
380        {current.limits.map(limit => {
381          const left = countdown(limit.resetsAt, at)
382
383          return (
384            <Box>
385              <Text dimColor>  {limitLabel(limit.kind)} </Text>
386              <Text color={levelColor(limit.percentUsed)}>{Math.round(limit.percentUsed)}%</Text>
387              {left && <Text dimColor> ({left})</Text>}
388            </Box>
389          )
390        })}
391        {shown ? <Text dimColor>{`  ⎇ ${shown}`}</Text> : null}
392        <Text>  </Text>
393        <Button key="toggle" label={paneIsOpen ? '−' : '+'} plain dimColor onPress={() => togglePane($)} />
394      </Box>
395    )
396
397    return other ? (
398      <Box flexDirection="column">
399        {band}
400        {other}
401      </Box>
402    ) : (
403      band
404    )
405  })
406
407  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
408    const { Box, Button, Text } = $.ui.resolve(e)
409    const current = await read($, snapshot)
410    const categories = await read($, breakdown)
411    const spent = await read($, totals)
412    const at = await read($, now)
413    const level = await read($, effort)
414
415    if (current === null) {
416      return <Text dimColor>No usage reading yet.</Text>
417    }
418
419    const shown = categories
420      .filter(category => category.kind !== 'deferred')
421      .sort((a, b) => Number(a.kind !== 'used') - Number(b.kind !== 'used') || b.tokens - a.tokens)
422    const nameWidth = Math.max(0, ...shown.map(category => category.name.length))
423    const tokenWidth = Math.max(0, ...shown.map(category => tokensLabel(category.tokens).length))
424    const hit = cacheHit(spent)
425    const shares = modelShares(spent)
426    const titleWidth = Math.max(8, ...current.limits.map(limit => limitTitle(limit.kind).length)) + 2
427    const tokenRows: [string, number][] = [
428      ['Input', spent.input],
429      ['Output', spent.output],
430      ['Cache read', spent.cacheRead],
431      ['Cache write', spent.cacheWrite],
432    ]
433
434    return (
435      <Box flexDirection="column">
436        <Box>
437          <Text bold>Usage  </Text>
438          <Text dimColor>{prettyModel(current.model)}  </Text>
439          {level !== null && <Text>{`${effortIcon(level)} ${level}  `}</Text>}
440          <Button key="refresh" label="Refresh (r)" hotkey="r" onPress={() => loadBreakdown($)} />
441        </Box>
442        <Text> </Text>
443        <Box>
444          <Text dimColor>{'Context'.padEnd(titleWidth)}</Text>
445          {squares(Text, current.percent, PANE_BAR_CELLS)}
446          <Text color={current.percent === null ? undefined : levelColor(current.percent)}>
447            {` ${current.percent === null ? '–' : `${current.percent}%`}`}
448          </Text>
449          <Text dimColor>
450            {current.tokens === null ? '' : `  ${tokensLabel(current.tokens)} / ${tokensLabel(current.window)}`}
451          </Text>
452        </Box>
453        {current.limits.map(limit => {
454          const left = countdown(limit.resetsAt, at)
455
456          return (
457            <Box>
458              <Text dimColor>{limitTitle(limit.kind).padEnd(titleWidth)}</Text>
459              {squares(Text, limit.percentUsed, PANE_BAR_CELLS)}
460              <Text color={levelColor(limit.percentUsed)}>{` ${Math.round(limit.percentUsed)}%`}</Text>
461              {left && <Text dimColor>  resets in {left}</Text>}
462            </Box>
463          )
464        })}
465        <Text> </Text>
466        <Text bold>This session</Text>
467        <Box>
468          {current.usd !== null && <Text dimColor>Cost </Text>}
469          {current.usd !== null && <Text>{`$${current.usd.toFixed(2)}`}  </Text>}
470          <Text dimColor>Turns </Text>
471          <Text>{formatDuration(spent.turnMs)}  </Text>
472          {hit !== null && <Text dimColor>Cache hit </Text>}
473          {hit !== null && <Text>{hit}%</Text>}
474        </Box>
475        {shares.length > 0 && (
476          <Box>
477            {shares.map(share => (
478              <Box>
479                <Text dimColor>{prettyModel(share.model)} </Text>
480                <Text>{share.percent}%  </Text>
481              </Box>
482            ))}
483          </Box>
484        )}
485        <Text> </Text>
486        <Text bold>Breakdown</Text>
487        {tokenRows.map(([label, tokens]) => (
488          <Box>
489            <Text dimColor>{label.padEnd(12)}</Text>
490            <Text>{tokensLabel(tokens)}</Text>
491          </Box>
492        ))}
493        <Text> </Text>
494        <Text bold>Context window</Text>
495        {shown.length === 0 && <Text dimColor>No context breakdown yet.</Text>}
496        {shown.map(category => (
497          <Box>
498            <Text dimColor={category.kind !== 'used'}>{category.name.padEnd(nameWidth)}  </Text>
499            <Text dimColor>{tokensLabel(category.tokens).padStart(tokenWidth)}  </Text>
500            <Text dimColor>{`${((category.tokens / current.window) * 100).toFixed(1)}%`}</Text>
501          </Box>
502        ))}
503      </Box>
504    )
505  })
506}
507
types/index.d.ts 33 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2export type Snapshot = {
3  model: string
4  percent: number | null
5  tokens: number | null
6  window: number
7  limits: Limit[]
8  usd: number | null
9}
10export type Totals = {
11  input: number
12  output: number
13  cacheRead: number
14  cacheWrite: number
15  turnMs: number
16  byModel: Record<string, number>
17}
18export type Category ={ name: string; tokens: number; kind: 'used' | 'free' | 'buffer' | 'deferred' }
19
20declare module 'claude-code' {
21  interface PluginState {
22    'session-stats': {
23      snapshot: Snapshot | null
24      breakdown: Category[]
25      isOpen: boolean
26      now: number
27      totals: Totals
28      effort: string | number | null
29      location: string | null
30    }
31  }
32}
33