SLOPSHOPPER

Status Bar

A session status bar for Claude Code Desktop: your 5-hour and weekly usage limits with time to reset, session and turn clocks, and running subagents…

newbandprocesstimer
v0.1.0MITupdated 2026-10-02sgmonda/statusbar
A shopper browsing a rack in a slop shop
README

Status Bar

A session status bar for Claude Code Desktop (the Code tab). The statusLine setting only runs in the terminal; this plugin draws the same kind of line above the prompt in the desktop app:

SESSION  ████░░░░  ↻ 02h 15m    ·   WEEK  ██████░░  ↻ 3d 04h    ·   AGENTS 2        TOTAL TIME  01:23:05    ·   CURRENT  00:42

And in the terminal, with surfaces: all:

my-repo ▸ fix-login (feat/oauth) · Opus 5.5 (ctx 42%)
SESSION ████░░░░ ↻ 02h 15m  ·  WEEK ██████░░ ↻ 3d 04h  ·  AGENTS 2      TOTAL TIME 01:23:05  ·  CURRENT 00:42
  • Usage limits: your 5-hour session and weekly windows as bars that grow and shrink with the room the line leaves free, 4 to 20 cells (green below 50%, amber below 80%, red above), with the time until each resets. They appear on subscription plans once the first response has reported them.
  • Time, at the right edge: how long the session has run (TOTAL TIME, as HH:MM:SS) and the turn's clock (CURRENT): live while Claude works, the last turn's length, dimmed, between turns.
  • Background activity, only when there is some: running subagents (AGENTS), other background tasks such as shells, monitors and workflows (TASKS), and scheduled wakeups and cron tasks (CRON). Background tasks and wakeups are counted at the end of each turn.
  • Cost (optional): the session's cost in USD, as /cost totals it.
  • In the terminal (with surfaces: all) a row above adds the repository, worktree and branch, the model and the context fill, which Claude Desktop already shows around the prompt; the usage row below reads the same as on the desktop, spaced tighter for a monospaced font.

Figures are drawn in monospace so the clocks do not jitter as they tick: Claude Desktop's font is proportional and no element takes a font, so the bar uses Unicode's mathematical monospace digits there (the terminal is monospaced already).

It refreshes after each turn and whenever a usage window moves a point, as the engine reports it, plus once a minute so countdowns and branch changes stay current. The session and turn clocks tick every second; while a turn runs, subagents are counted every five.

Early access. This plugin is built on Claude Code's function-hooks API, which may change between releases. It needs Claude Code 2.1.286 or later.

Install

From the official directory:

/plugin install statusbar@claude-plugins-official

Or straight from this repository:

/plugin marketplace add sgmonda/statusbar
/plugin install statusbar@sgmonda

Settings

Open the config menu (/config) and find the plugin's rows, or set them under pluginConfigs.statusbar in ~/.claude/settings.json:

SettingValuesDefaultWhat it does
surfacesdesktop, alldesktopdesktop draws the bar only in Claude Desktop; all also draws it in the terminal, above the prompt.
showCosttrue, falsefalseAdds the session's cost.

The bar lives in the band above the prompt: collapse it with ctrl+x ctrl+a.

What it runs, reads and sends

It sends nothing out. The plugin makes no network calls and writes no files. Everything it reads is drawn in the bar on your own screen and kept in the session's memory; it never leaves your machine.

Programs it runs. Only git, to show the repository, worktree and branch in the terminal row: three read-only commands in the session's directory, with --no-optional-locks so they never take a lock, each written out in full in hooks/register.tsx, once when the session starts, after each turn and once a minute:

CommandWhy
git --no-optional-locks rev-parse --path-format=absolute --git-dir --git-common-dir --show-toplevelThe repository's root, and whether the session is in a linked worktree.
git --no-optional-locks symbolic-ref --short -q HEADThe current branch, also on a branch with no commits yet.
git --no-optional-locks rev-parse --short HEADThe short commit hash, shown on a detached HEAD.

What it reads from Claude Code. The session's usage figures (rate limits, context fill, cost), its start time, working directory and model, and the list of running subagents. It does not read the conversation's messages.

Hooks. Each one observes and passes the event on unchanged:

HookWhat it does
session.startTakes the first reading and starts the refresh timers.
session.measureRefreshes the figures when Claude Code reports new usage.
turn.start, turn.completeStart and stop the turn clock; recount subagents.
classic.StopReads the count of background tasks and scheduled wakeups from the Stop event; changes nothing in it and affects no Stop hook of yours.
ui.render (AbovePrompt)Draws the bar in the band above the prompt; draws nothing and passes the band on where the bar is off (surfaces) or a survey holds it.

The manifest's types field names types/index.d.ts, the contract of the values the plugin keeps in the session's state. Claude Code's claude plugin validate holds the hooks module to it.

Development

claude plugin validate .
claude plugin test .
claude --plugin-dir .

To load it in Claude Desktop while you work on it, add the folder to env in ~/.claude/settings.json:

{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/this/repo" } }

Type-check with tsc -p . after running /plugin-types in a session (it writes the API declarations to .claude/types).

The pure logic (bars, countdowns, clocks, git place, widths) lives in hooks/format.ts; hooks/register.tsx holds the hooks and the drawing, and types/index.d.ts the contract of the values the plugin keeps in $.state.

License

MIT

Source 3 files
hooks/register.tsx 284 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ProcessRunResult, Register, RenderElement, Timer } from 'claude-code'
3
4import type { Limit, Snapshot } from '../types'
5import {
6  barCellsFor,
7  barOf,
8  clockOf,
9  hmsOf,
10  labelOf,
11  levelColor,
12  modelLabel,
13  mono,
14  placeOf,
15  remainingOf,
16  sortLimits,
17  usdOf,
18  widthOf,
19  type GitFacts,
20} from './format'
21
22const snapshot = atom({ plugin: 'statusbar', key: 'snapshot' } as const, null)
23const activity = atom({ plugin: 'statusbar', key: 'activity' } as const, { agents: 0, tasks: 0, crons: 0 })
24const turnStartedAt = atom({ plugin: 'statusbar', key: 'turnStartedAt' } as const, null)
25const lastTurnMs = atom({ plugin: 'statusbar', key: 'lastTurnMs' } as const, 0)
26
27/** Reset countdowns move by the minute, and a branch can change outside a turn. */
28const TICK_MS = 60_000
29/** The session and turn clocks redraw every second. */
30const CLOCK_TICK_MS = 1_000
31/** While a turn runs, the running subagents are counted every few seconds. */
32const AGENTS_TICK_MS = 5_000
33
34/**
35 * The room around separators, bars and clocks. A proportional font (the desktop)
36 * draws spaces narrow, so it takes more of them; a monospaced one (the terminal)
37 * gives each a whole cell, so it takes fewer.
38 */
39const SPACING = {
40  proportional: { separator: '   ·   ', air: ' ', minGap: 4 },
41  monospaced: { separator: '  ·  ', air: '', minGap: 2 },
42} as const
43
44/**
45 * Where the session runs, as git sees it. Three read-only git commands, each
46 * written out in full: the repository's directories, the branch, the short sha.
47 */
48const git = async ($: EngineInterface, cwd: string): Promise<GitFacts | null> => {
49  const init = { cwd, timeoutMs: 5_000 }
50  const settle = (run: Promise<ProcessRunResult>) => run.catch(() => null)
51
52  const dirs = await settle(
53    $.process.run(
54      ['git', '--no-optional-locks', 'rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir', '--show-toplevel'],
55      init,
56    ),
57  )
58  if (dirs === null || dirs.exitCode !== 0) return null
59
60  const [gitDir, commonDir, topLevel] = dirs.stdout.trim().split('\n')
61  // symbolic-ref answers on a branch with no commits yet; rev-parse covers a detached HEAD.
62  const [branch, sha] = await Promise.all([
63    settle($.process.run(['git', '--no-optional-locks', 'symbolic-ref', '--short', '-q', 'HEAD'], init)),
64    settle($.process.run(['git', '--no-optional-locks', 'rev-parse', '--short', 'HEAD'], init)),
65  ])
66  const ok = (r: ProcessRunResult | null) => (r?.exitCode === 0 ? r.stdout.trim() || undefined : undefined)
67
68  return { gitDir, commonDir, topLevel, branch: ok(branch), shortSha: ok(sha) }
69}
70
71const refresh = async ($: EngineInterface) => {
72  const cwd = await $.session.cwd()
73  const [facts, model, usage] = await Promise.all([
74    git($, cwd),
75    $.session.model().catch(() => undefined),
76    $.session.usage(),
77  ])
78
79  const limits: Limit[] = usage.rateLimits.map(limit => ({
80    kind: limit.kind,
81    percentUsed: limit.percentUsed,
82    resetsAt: limit.resetsAt ? Date.parse(limit.resetsAt) : undefined,
83  }))
84
85  const next: Snapshot = {
86    place: placeOf(cwd, facts),
87    model: model ? modelLabel(model) : undefined,
88    contextPercent: usage.context.percent,
89    costUsd: usage.cost?.usd,
90    limits: sortLimits(limits),
91    startedAt: usage.startedAt,
92  }
93  await update($, snapshot, () => next)
94}
95
96const countAgents = async ($: EngineInterface) => {
97  const agents = (await $.agent.list()).filter(agent => agent.status === 'running').length
98  await update($, activity, current => (current.agents === agents ? current : { ...current, agents }))
99}
100
101export const register: Register = (on, options) => {
102  const everywhere = options.surfaces === 'all'
103  const showCost = options.showCost === true
104
105  // The running turn's agent count; a reload drops it with the module, and the next turn starts its own.
106  let agentsTimer: Timer | undefined
107  const stopAgentsTimer = () => {
108    agentsTimer?.cancel()
109    agentsTimer = undefined
110  }
111
112  on('session.start', async ($, e, next) => {
113    const result = await next(e)
114    await update($, turnStartedAt, () => null)
115    await refresh($)
116    $.clock.every(TICK_MS, () => void refresh($).then(() => countAgents($)))
117    $.clock.every(CLOCK_TICK_MS, () => $.ui.invalidate('ui.render'))
118    return result
119  })
120
121  // Pushed by the engine after each turn and whenever a rate-limit window moves a point.
122  on('session.measure', async ($, e, next) => {
123    const result = await next(e)
124    await refresh($)
125    return result
126  })
127
128  on('turn.start', async ($, e, next) => {
129    const result = await next(e)
130    // A subagent's turn starts inside the main one: the main turn's clock keeps running.
131    if ((await read($, turnStartedAt)) === null) {
132      const startedAt = await $.clock.now()
133      await update($, turnStartedAt, () => startedAt)
134      stopAgentsTimer()
135      agentsTimer = $.clock.every(AGENTS_TICK_MS, () => void countAgents($))
136    }
137    return result
138  })
139
140  on('turn.complete', async ($, e, next) => {
141    const result = await next(e)
142    if (e.agentId === undefined) {
143      stopAgentsTimer()
144      await update($, lastTurnMs, () => e.durationMs)
145      await update($, turnStartedAt, () => null)
146    }
147    await countAgents($)
148    return result
149  })
150
151  // The settings Stop hook's input is the one place that lists background work and scheduled wakeups.
152  on('classic.Stop', async ($, e, next) => {
153    const tasks = (e.background_tasks ?? []).filter(task => task.type !== 'subagent').length
154    const crons = e.session_crons?.length ?? 0
155    await update($, activity, current => ({ ...current, tasks, crons }))
156    return next(e)
157  })
158
159  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
160    if (e.props.hasSurvey || (!everywhere && e.surface !== 'desktop')) return next(e)
161
162    const snap = await read($, snapshot)
163    if (snap === null) return next(e)
164
165    const [work, turnStart, lastTurn, now] = await Promise.all([
166      read($, activity),
167      read($, turnStartedAt),
168      read($, lastTurnMs),
169      $.clock.now(),
170    ])
171
172    const { Box, Text } = $.ui.resolve(e)
173    const { place } = snap
174    // Desktop already shows the repo, branch, model and context fill around the prompt.
175    const showSessionFacts = e.surface !== 'desktop'
176
177    // The terminal is monospaced already; elsewhere figures switch to monospace glyphs so they do not jitter.
178    const figures = e.surface === 'terminal' ? (text: string) => text : mono
179    const label = (text: string) => <Text bold dimColor>{text} </Text>
180    const spacing = e.surface === 'terminal' ? SPACING.monospaced : SPACING.proportional
181    // Bars and clocks get air on each side, beyond the label's own space.
182    const AIR = spacing.air
183    const fact = (key: string, name: string, value: string, { isDim = false, isClock = false } = {}) => (
184      <Text key={key}>
185        {label(name)}
186        {isClock ? AIR : null}
187        <Text dimColor={isDim}>{figures(value)}</Text>
188        {isClock ? AIR : null}
189      </Text>
190    )
191
192    const head = place.isRepo ? (
193      <Text>
194        <Text bold color="warning">{place.name}</Text>
195        {place.worktree ? <Text dimColor> ▸ </Text> : null}
196        {place.worktree ? <Text color="claude">{place.worktree}</Text> : null}
197        {place.branch ? <Text dimColor> (</Text> : null}
198        {place.branch ? <Text color="suggestion">{place.branch}</Text> : null}
199        {place.branch ? <Text dimColor>)</Text> : null}
200      </Text>
201    ) : (
202      <Text bold color="warning">{place.name}</Text>
203    )
204
205    const limit = (l: Limit, cells: number) => {
206      const [filled, empty] = barOf(l.percentUsed, cells)
207      const left = remainingOf(l.resetsAt, now)
208      return (
209        <Text key={l.kind}>
210          {label(labelOf(l.kind))}
211          {AIR}
212          <Text color={levelColor(l.percentUsed)}>{filled}</Text>
213          <Text dimColor>{empty}</Text>
214          {AIR}
215          {left ? <Text dimColor> ↻ </Text> : null}
216          {left ? <Text dimColor>{figures(left)}</Text> : null}
217          {left ? AIR : null}
218        </Text>
219      )
220    }
221
222    const facts = showSessionFacts ? (
223      <Text key="facts">
224        {head}
225        {snap.model ? <Text dimColor> · </Text> : null}
226        {snap.model ? <Text>{snap.model}</Text> : null}
227        {snap.contextPercent !== undefined ? (
228          <Text dimColor> (ctx {Math.round(snap.contextPercent)}%)</Text>
229        ) : null}
230      </Text>
231    ) : null
232    const isTurnRunning = e.props.isWorking && turnStart !== null
233
234    const leftOf = (cells: number) => [
235      ...snap.limits.map(l => limit(l, cells)),
236      work.agents > 0 ? fact('agents', 'AGENTS', String(work.agents)) : null,
237      work.tasks > 0 ? fact('tasks', 'TASKS', String(work.tasks)) : null,
238      work.crons > 0 ? fact('crons', 'CRON', String(work.crons)) : null,
239      showCost && snap.costUsd !== undefined ? fact('cost', 'COST', usdOf(snap.costUsd)) : null,
240    ]
241    // The clocks sit at the right edge, apart from the figures that change by the turn.
242    const right = [
243      fact('time', 'TOTAL TIME', hmsOf(now - snap.startedAt), { isClock: true }),
244      // Between turns, the last one's length, dimmed: it is not running.
245      isTurnRunning
246        ? fact('turn', 'CURRENT', clockOf(now - turnStart), { isClock: true })
247        : fact('turn', 'CURRENT', clockOf(lastTurn), { isDim: true, isClock: true }),
248    ]
249
250    const joined = (side: string, segments: (RenderElement | null)[]) =>
251      segments
252        .filter(s => s !== null)
253        .flatMap((segment, i) =>
254          i === 0 ? [segment] : [<Text key={`${side}-sep-${i}`} dimColor>{spacing.separator}</Text>, segment],
255        )
256
257    // The bars take what the line leaves free, so it fits on one row as long as it can.
258    const fixed = widthOf(joined('left', leftOf(0))) + widthOf(joined('right', right)) + spacing.minGap
259    const cells = barCellsFor(e.props.bodyColumns, fixed, snap.limits.length)
260
261    const usageRow = (
262      <Box key="usage" flexDirection="row" flexWrap="wrap" justifyContent="space-between" width="100%">
263        <Box key="left" flexDirection="row" flexWrap="wrap">
264          {joined('left', leftOf(cells))}
265        </Box>
266        <Box key="right" flexDirection="row">
267          {joined('right', right)}
268        </Box>
269      </Box>
270    )
271
272    // Where the session facts show (the terminal), they take a row of their own above,
273    // so the usage row reads the same on every surface and keeps the whole width.
274    return facts === null ? (
275      usageRow
276    ) : (
277      <Box flexDirection="column" width="100%">
278        {facts}
279        {usageRow}
280      </Box>
281    )
282  })
283}
284
hooks/format.ts 156 lines
1// Pure helpers: no `$`, so the tests exercise them directly.
2
3import type { RenderNode } from 'claude-code'
4
5import type { Limit, Place } from '../types'
6
7export const BAR_CELLS = 6
8/** The narrowest a bar draws while still telling levels apart, and the widest worth giving it. */
9export const MIN_BAR_CELLS = 4
10export const MAX_BAR_CELLS = 20
11
12const pad = (n: number) => String(n).padStart(2, '0')
13
14// Unicode's mathematical monospace digits and small letters: the one way to draw
15// fixed-width figures where a surface's own font is proportional (the desktop),
16// since no element takes a font.
17const MONO_DIGIT_ZERO = 0x1d7f6
18const MONO_SMALL_A = 0x1d68a
19
20/** `02h 15m` → `𝟶𝟸𝚑 𝟷𝟻𝚖`: digits and a-z in monospace glyphs; anything else as is. */
21export const mono = (text: string): string =>
22  text.replace(/[0-9a-z]/g, c =>
23    String.fromCodePoint(
24      c <= '9' ? MONO_DIGIT_ZERO + (c.charCodeAt(0) - 48) : MONO_SMALL_A + (c.charCodeAt(0) - 97),
25    ),
26  )
27
28const LIMIT_LABELS: Record<string, string> = {
29  five_hour: 'SESSION',
30  seven_day: 'WEEK',
31  spend_limit: 'SPEND',
32}
33
34export const labelOf = (kind: string): string =>
35  LIMIT_LABELS[kind] ?? kind.replace(/_/g, ' ').toUpperCase()
36
37/** Theme key for a usage level: the same thresholds the terminal status line uses. */
38export const levelColor = (percent: number): 'success' | 'warning' | 'error' =>
39  percent >= 80 ? 'error' : percent >= 50 ? 'warning' : 'success'
40
41/** Cells a tree's text takes, one per code point: the monospace glyphs and the bar blocks are one each. */
42export const widthOf = (node: RenderNode | readonly RenderNode[] | null | undefined | false): number => {
43  if (node === null || node === undefined || node === false) return 0
44  if (typeof node === 'string' || typeof node === 'number') return [...String(node)].length
45  if (Array.isArray(node)) return node.reduce((sum: number, child) => sum + widthOf(child), 0)
46  return 'children' in node && node.children ? widthOf(node.children) : 0
47}
48
49/**
50 * How wide each of `bars` bars draws when the rest of the line takes `fixed` of
51 * `columns` cells: the free room shared out, within MIN and MAX; BAR_CELLS when
52 * the surface has not said how wide it is.
53 */
54export const barCellsFor = (columns: number | undefined, fixed: number, bars: number): number => {
55  if (bars === 0 || columns === undefined || !Number.isFinite(columns) || columns <= 0) return BAR_CELLS
56  const share = Math.floor((columns - fixed) / bars)
57  return Math.min(MAX_BAR_CELLS, Math.max(MIN_BAR_CELLS, share))
58}
59
60/** `[filled, empty]` block runs for a 0-100 percentage, `cells` wide. */
61export const barOf = (percent: number, cells = BAR_CELLS): [string, string] => {
62  const filled = Math.min(cells, Math.max(0, Math.round((percent / 100) * cells)))
63  return ['█'.repeat(filled), '░'.repeat(cells - filled)]
64}
65
66/** `3d 04h`, `02h 15m` or `07m` until `resetsAt`; undefined once it has passed. */
67export const remainingOf = (resetsAt: number | undefined, now: number): string | undefined => {
68  if (resetsAt === undefined) return undefined
69  const seconds = Math.floor((resetsAt - now) / 1000)
70  if (seconds <= 0) return undefined
71  const d = Math.floor(seconds / 86400)
72  const h = Math.floor((seconds % 86400) / 3600)
73  const m = Math.floor((seconds % 3600) / 60)
74  if (d > 0) return `${d}d ${pad(h)}h`
75  if (h > 0) return `${pad(h)}h ${pad(m)}m`
76  return `${pad(m)}m`
77}
78
79/** Session windows first, then the week, then anything else the engine reports. */
80export const sortLimits = (limits: readonly Limit[]): Limit[] => {
81  const rank = (kind: string) => {
82    const i = Object.keys(LIMIT_LABELS).indexOf(kind)
83    return i === -1 ? Number.MAX_SAFE_INTEGER : i
84  }
85  return [...limits].sort((a, b) => rank(a.kind) - rank(b.kind))
86}
87
88const trimSlashes = (path: string) => path.replace(/\/+$/, '')
89
90export const baseName = (path: string): string => trimSlashes(path).split('/').pop() ?? path
91
92export const dirName = (path: string): string => {
93  const trimmed = trimSlashes(path)
94  const i = trimmed.lastIndexOf('/')
95  return i <= 0 ? '/' : trimmed.slice(0, i)
96}
97
98/** What `git rev-parse` and friends answered, raw; any field may be missing. */
99export type GitFacts = {
100  gitDir?: string
101  commonDir?: string
102  topLevel?: string
103  branch?: string
104  shortSha?: string
105}
106
107/**
108 * The bar's head: `<repo> ▸ <worktree> (<branch>)`, `<repo> (<branch>)`, or
109 * `<parent>/<dir>` outside a repository. Mirrors the terminal status line.
110 */
111export const placeOf = (cwd: string, git: GitFacts | null): Place => {
112  if (git === null || git.gitDir === undefined) {
113    return { name: `${baseName(dirName(cwd))}/${baseName(cwd)}`, isRepo: false }
114  }
115
116  const branch = git.branch ?? (git.shortSha ? `@${git.shortSha}` : undefined)
117  const isWorktree = git.commonDir !== undefined && git.commonDir !== git.gitDir
118
119  if (isWorktree) {
120    const worktree = baseName(git.gitDir)
121    return {
122      name: baseName(dirName(git.commonDir!)),
123      isRepo: true,
124      worktree,
125      branch: branch === worktree ? undefined : branch,
126    }
127  }
128
129  return { name: baseName(git.topLevel ?? cwd), isRepo: true, branch }
130}
131
132/** `claude-opus-5-5` → `Opus 5.5`; anything already human-readable passes through. */
133export const modelLabel = (model: string): string => {
134  const match = /^claude-([a-z]+)-(\d+)(?:-(\d+))?(?:-\d{8})?(\[1m\])?$/.exec(model)
135  if (!match) return model
136  const [, family, major, minor, longContext] = match
137  const name = family!.charAt(0).toUpperCase() + family!.slice(1)
138  return `${name} ${major}${minor ? `.${minor}` : ''}${longContext ? ' (1M)' : ''}`
139}
140
141/** `01:23:05`: how long the session has run, to the second (hours grow past 99). */
142export const hmsOf = (ms: number): string => {
143  const seconds = Math.floor(Math.max(0, ms) / 1000)
144  return `${pad(Math.floor(seconds / 3600))}:${pad(Math.floor((seconds % 3600) / 60))}:${pad(seconds % 60)}`
145}
146
147/** `00:42` or `01:02:03`: a running turn's clock, to the second. */
148export const clockOf = (ms: number): string => {
149  const seconds = Math.floor(Math.max(0, ms) / 1000)
150  const h = Math.floor(seconds / 3600)
151  const mmss = `${pad(Math.floor((seconds % 3600) / 60))}:${pad(seconds % 60)}`
152  return h > 0 ? `${pad(h)}:${mmss}` : mmss
153}
154
155export const usdOf = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
156
types/index.d.ts 56 lines
1/** Where the session runs, as git sees it. */
2export type Place = {
3  /** The repository's name, or `<parent>/<dir>` outside one. */
4  name: string;
5  /** True inside a git working copy. */
6  isRepo: boolean;
7  /** The linked worktree's name, when the session runs in one. */
8  worktree?: string;
9  /** The branch, or `@<short sha>` on a detached HEAD. */
10  branch?: string;
11};
12
13/** One rate-limit window as the bar draws it. */
14export type Limit = {
15  /** `five_hour`, `seven_day` or a gateway's `spend_limit`. */
16  kind: string;
17  /** 0 to 100 (past 100 on an exceeded spend limit). */
18  percentUsed: number;
19  /** When the window resets, in epoch milliseconds. */
20  resetsAt?: number;
21};
22
23/** Everything the bar draws, refreshed from the engine's measurements and git. */
24export type Snapshot = {
25  place: Place;
26  model?: string;
27  contextPercent?: number;
28  costUsd?: number;
29  limits: Limit[];
30  /** When the session began, in epoch milliseconds. */
31  startedAt: number;
32};
33
34/** Work that keeps going beside the conversation. */
35export type Activity = {
36  /** Subagents running now, foreground or background. */
37  agents: number;
38  /** Background tasks other than subagents (shells, monitors, workflows), as of the last turn's end. */
39  tasks: number;
40  /** Scheduled wakeups and cron tasks (CronCreate, ScheduleWakeup, /loop), as of the last turn's end. */
41  crons: number;
42};
43
44declare module 'claude-code' {
45  interface PluginState {
46    'statusbar': {
47      snapshot: Snapshot | null;
48      activity: Activity;
49      /** When the running main turn began, in epoch milliseconds; null between turns. */
50      turnStartedAt: number | null;
51      /** How long the last finished main turn took, in milliseconds; 0 before the first. */
52      lastTurnMs: number;
53    };
54  }
55}
56