SLOPSHOPPER

Context Bar

A status bar above the prompt: context gauge, 5-hour usage, git, turn time and session age, with Context and Usage panels and ten skins

newpanebandguardcommandtoast
v0.1.1MITupdated 2026-10-26eddywong888/claude-context-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-bar
│ ┃ context-bar-context ✕ › fix the failing auth test and add an audit log call │ ┃ 48.7% of 200k used │ ┃ 97k tokens · $0.42 this session ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ The breakdown appears after Claude's next ⏺ Update(src/auth.ts) │ ┃ reply. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ [ Refresh ] ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /contextbar │ ⎿ context-bar: Context bar: expanded · sound on · early compact of │ ⎿ context-bar: Usage: /contextbar show | hide | sound on | sound o │ │ Context ██████░░░┃░░░ 48.7% (97k/200k) 5h ●●○○○ 31% L:0s Age:30m [ Hide ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Context ██████░░░┃░░░ 48.7% (97k/200k) 5h ●●○○○ 31% L:0s Age:30m [ Hide ]
Pane · context-bar-context
48.7% of 200k used 97k tokens · $0.42 this session The breakdown appears after Claude's next reply. [ Refresh ]
Pane · context-bar-usage
Current session (5h) 31% used reset time not reported Pace appears after a few minutes of readings. Fable (weekly) Not reported to mods; see Settings → Usage for the real figure. [ Refresh ] [ Sound: on ] [ Hide the bar ] [ Clear chat Skin ▰▰▰▱▱ [ ✓ meadow ] ▰▰▰▱▱
README

Claude Context Bar

A status bar for Claude Code that sits above the prompt. It shows how full the context window is, how much of your 5-hour usage is gone and when it resets, the git branch, and how long the last turn and the session have run.

The bar in the ember skin

Click either percentage to open a panel with the details and controls.

Install

In Claude Code, run these three commands:

/plugin marketplace add eddywong888/claude-context-bar
/plugin install context-bar@claude-context-bar
/reload-plugins

The bar appears after the reload. If it doesn't show up, restart Claude Code.

A mod is code that runs inside Claude Code on your machine, with the same access Claude Code has. Read the source in hooks/ before installing, as you would with any package.

To update later:

/plugin marketplace update claude-context-bar
/plugin update context-bar@claude-context-bar
/reload-plugins

The bar

PartWhat it means
Context gaugeHow full the context window is, in a gradient from calm to full. The thin line marks 75%. The figure after it is the percent used, then tokens used out of the window, then how much the last turn added. Right after a compaction or /clear the figure is an estimate from /context, marked ~, until Claude's next reply.
⚠ auto-compactShows once the context is 85% of the way to the point where Claude Code compacts on its own. A Compact button appears from 70%.
5h dotsYour 5-hour usage: five dots, one per 20%. The ↻ countdown is the time until it resets, blue at first and amber, then red, as the limit nears. A notice shows when it resets.
Fable gaugeShown only when Claude Code reports a Fable usage window.
⎇ branchThe git branch, ✎ uncommitted files, ↑↓ commits ahead and behind.
L and AgeHow long the last turn took, and how long the session has been open (days past 24 hours, e.g. 1d12h44m).
▶ workingThe tool Claude is running now, and any subagents.
HideFolds the bar into small gauges. Show brings it back.

The panels

Context (click the context %) breaks down what fills the window: system prompt, tools, memory files, skills and messages, with the room left and what loads only when needed. It also shows the model, the auto-compact point, roughly how many turns are left before it, what the session has cost and how many tools ran. Buttons: Refresh, Compact now, Early compact at 90% (compacts just before Claude Code would) and Copy figures.

The Context panel

Usage (click the 5h %) shows each usage window with its reset time and, after a few minutes of readings, when you'd hit the limit at the recent pace. Buttons: Refresh, Sound (a short bell when Claude finishes a reply), Hide the bar and Clear chat. Below them is the skin picker.

The Usage panel with the skin picker

The Fable weekly limit isn't reported to mods, so the panel points to Settings → Usage for it.

Skins

Ten skins change the gauges' colours and glyphs, the bar and panel backgrounds, the text colours, the panel border and the labels: meadow (the default, in the app's own look), ocean, ember, hermes, mono, retro, cyberpunk, latte, blueprint and sumi. Pick one in the Usage panel; the choice is remembered across sessions.

The bar in the blueprint skin

Mods can't change the font, and the app draws its own buttons, so dark skins put the buttons on a light strip to keep them readable.

Commands

CommandWhat it does
/contextbarShows the current settings
/contextbar show / hideShows or folds the bar
/contextbar sound on / offTurns the reply bell on or off
/contextbar early on / offTurns early compaction on or off
/contextbar theme <name>Picks a skin; with no name, lists them

What it runs, reads and sends

Everything stays on your machine. The mod makes no network requests.

WhatWhen and why
Runs /compactOnly when you press Compact now (or c in the Context panel), or when you turn on Early compact at 90%, which is off by default. Early compaction runs /compact once the context is 90% of the way to auto-compaction and Claude is idle.
Runs /clearOnly when you press Clear chat in the Usage panel.
Starts gitgit --no-optional-locks status --porcelain=v2 --branch in the session's folder, for the branch, changed files and ahead/behind counts. It runs when the session starts and after a tool that writes files, at most once every 5 seconds. It never takes git's index lock or changes the repository.
Reads the sessionToken counts, the /context breakdown, cost, usage windows, the model, and the running tools and subagents. The ▶ working line shows the first line of the running tool's input, with anything that looks like a token, key or password masked.
Sends data outOnly to your clipboard, when you press Copy figures (the context figures shown in the panel).
Plays a soundsounds/done.wav, a short bell when Claude finishes a reply, if Sound is on.
Saves settingsSkin, sound, hidden and early-compact choices, in Claude Code's own store for the mod.

Its hooks on tool calls, commands, subagent spawns and compaction pass every event on unchanged and only refresh the figures afterwards. The mod never blocks, changes, approves or denies anything, and it doesn't touch permission modes or settings.

  • The command.run hook for /contextbar answers the mod's own command (show, hide, sound, early, theme). The command.run hooks for /compact and /clear let the command run as typed, then re-read the context size.
  • The agent.spawn hook lets the subagent start unchanged, with its own prompt, tools and permission mode, then updates the list of running subagents shown on the bar.

Develop

claude plugin validate .
claude plugin test .

Edits load with /reload-plugins. The bell is sounds/done.wav, an original sound from claude-castle-mod.

License

MIT

Source 2 files
hooks/register.tsx 1450 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Breakdown, ContextFill, GitInfo, Limit, RunningTool, UsageSample } from '../types'
5
6const fill = atom({ plugin: 'context-bar', key: 'fill' } as const, null)
7const compactAt = atom({ plugin: 'context-bar', key: 'compactAt' } as const, null)
8const limits = atom({ plugin: 'context-bar', key: 'limits' } as const, [])
9const startedAt = atom({ plugin: 'context-bar', key: 'startedAt' } as const, null)
10const now = atom({ plugin: 'context-bar', key: 'now' } as const, 0)
11const running = atom({ plugin: 'context-bar', key: 'running' } as const, [])
12const agents = atom({ plugin: 'context-bar', key: 'agents' } as const, [])
13const lastTurnMs = atom({ plugin: 'context-bar', key: 'lastTurnMs' } as const, null)
14const turnStartedAt = atom({ plugin: 'context-bar', key: 'turnStartedAt' } as const, null)
15const warned = atom({ plugin: 'context-bar', key: 'warned' } as const, false)
16const isHidden = atom({ plugin: 'context-bar', key: 'isHidden' } as const, false)
17const resetNoticed = atom({ plugin: 'context-bar', key: 'resetNoticed' } as const, null)
18const git = atom({ plugin: 'context-bar', key: 'git' } as const, null)
19const gitAt = atom({ plugin: 'context-bar', key: 'gitAt' } as const, null)
20const breakdown = atom({ plugin: 'context-bar', key: 'breakdown' } as const, null)
21const autoCompactOn = atom({ plugin: 'context-bar', key: 'autoCompactOn' } as const, false)
22const modelInUse = atom({ plugin: 'context-bar', key: 'modelInUse' } as const, null)
23const fiveHist = atom({ plugin: 'context-bar', key: 'fiveHist' } as const, [])
24const tokHist = atom({ plugin: 'context-bar', key: 'tokHist' } as const, [])
25const lastGrowth = atom({ plugin: 'context-bar', key: 'lastGrowth' } as const, null)
26const costUsd = atom({ plugin: 'context-bar', key: 'costUsd' } as const, null)
27const toolsTurn = atom({ plugin: 'context-bar', key: 'toolsTurn' } as const, 0)
28const toolsTotal = atom({ plugin: 'context-bar', key: 'toolsTotal' } as const, 0)
29const stepsByModel = atom({ plugin: 'context-bar', key: 'stepsByModel' } as const, {})
30const themeName = atom({ plugin: 'context-bar', key: 'themeName' } as const, 'meadow')
31const soundOn = atom({ plugin: 'context-bar', key: 'soundOn' } as const, true)
32const tickOwner = atom({ plugin: 'context-bar', key: 'tickOwner' } as const, null)
33
34const CONTEXT_PANE = 'context-bar-context'
35const USAGE_PANE = 'context-bar-usage'
36const WIDTH = 12
37/** Cells in the Fable gauge. */
38const SMOOTH = 8
39const COMPACT_WARN = 85
40/** With early compaction on, the conversation compacts at this share of the way to auto-compact. */
41const EARLY_COMPACT_AT = 90
42const ACTIVE = ['pending', 'running', 'waiting']
43/** Tools that can change files, after which the git status is read again. */
44const WRITERS = ['Bash', 'Edit', 'Write', 'NotebookEdit', 'MultiEdit']
45/** Git is read at most this often after tool calls; every turn end reads it regardless. */
46const GIT_EVERY_MS = 5000
47
48export const kilo = (n: number) =>
49  n >= 999_500 ? `${Math.round(n / 100_000) / 10}M` : `${Math.round(n / 1000)}k`
50
51export const pctText = (p: number) => (p < 10 ? p.toFixed(1) : String(Math.round(p * 10) / 10))
52
53export type Palette = {
54  stops: [number, number, number][]
55  track: string
56  trackDots: string
57  notch: string
58  /** The gauges' characters: filled and empty cells, 5h dots, mini cells, the warning line. */
59  glyphs: { fill: string; empty: string; dotOn: string; dotOff: string; miniOn: string; miniOff: string; marker: string }
60  /** How the band's labels (Context, 5h, Fable) are dressed. */
61  deco: (label: string) => string
62  /** Ink for those labels; absent, the surface's dim text. */
63  labelInk?: string
64  /** The skin's surface for the band and panels; absent, the app's own look. */
65  surface?: Surface
66}
67
68/** Background, text inks and panel border that dress the band and panels (mods cannot change the font). */
69export type Surface = {
70  bg: string
71  ink: string
72  faint: string
73  border: 'single' | 'double' | 'round' | 'bold' | 'dashed' | 'classic'
74  borderColor: string
75  /** Labels in italic. */
76  italic?: boolean
77  /** A light tray under the app's own buttons, which keep their light-theme ink; dark skins need one. */
78  tray?: string
79  /** Stand-ins for the app's status colours that read on this backdrop. */
80  tones?: Partial<Record<Tone, string>>
81}
82
83export type Tone = 'suggestion' | 'warning' | 'error' | 'claude'
84
85const PLAIN_GLYPHS = { fill: '█', empty: '░', dotOn: '●', dotOff: '○', miniOn: '▰', miniOff: '▱', marker: '┃' }
86
87export const THEMES: Record<string, Palette> = {
88  // Green to amber to red: the data-semantic default.
89  meadow: {
90    stops: [
91      [0x11, 0x7a, 0x2e],
92      [0xb3, 0x6b, 0x00],
93      [0xb3, 0x1d, 0x28],
94    ],
95    track: '#c9ced6',
96    trackDots: '#9aa1ab',
97    notch: '#8a5a1f',
98    glyphs: PLAIN_GLYPHS,
99    deco: label => label,
100  },
101  // Teal to indigo to magenta: pressure as deepening water, no red at all.
102  ocean: {
103    stops: [
104      [0x0e, 0x74, 0x90],
105      [0x4f, 0x46, 0xe5],
106      [0xbe, 0x18, 0x5d],
107    ],
108    track: '#b6c2cf',
109    trackDots: '#8da0b3',
110    notch: '#1e3a5f',
111    glyphs: PLAIN_GLYPHS,
112    deco: label => label,
113    surface: { bg: '#e8f3f7', ink: '#0b3a4a', faint: '#5b7c8a', border: 'round', borderColor: '#9cc3d3' },
114  },
115  // Old gold to burnt orange to crimson: warm from the first cell.
116  ember: {
117    stops: [
118      [0x9a, 0x7b, 0x0a],
119      [0xc2, 0x41, 0x0c],
120      [0x9f, 0x12, 0x39],
121    ],
122    track: '#cfc8bd',
123    trackDots: '#aaa08f',
124    notch: '#4a3728',
125    glyphs: PLAIN_GLYPHS,
126    deco: label => label,
127    surface: { bg: '#fbefe3', ink: '#3d2416', faint: '#8a6a55', border: 'round', borderColor: '#e0b48f' },
128  },
129  // Green phosphor terminal: one hue, brightening as it fills, prompt-style labels.
130  hermes: {
131    stops: [
132      [0x3f, 0xb8, 0x68],
133      [0x7d, 0xdc, 0x6a],
134      [0xd4, 0xf5, 0x7a],
135    ],
136    track: '#3d5a44',
137    trackDots: '#5f8a68',
138    notch: '#9ef01a',
139    glyphs: { fill: '█', empty: '·', dotOn: '●', dotOff: '·', miniOn: '█', miniOff: '·', marker: '│' },
140    deco: label => `>${label.toLowerCase()}`,
141    labelInk: '#7ddc6a',
142    surface: { bg: '#0c1a10', ink: '#9be564', faint: '#8cc79a', border: 'single', borderColor: '#2fb45a', tray: '#d4eadb', tones: { suggestion: '#7fd8f0', warning: '#f2d16b', error: '#ff7b7b', claude: '#f7a96b' } },
143  },
144  // One ink, no colour: shade carries the reading.
145  mono: {
146    stops: [
147      [0x9a, 0xa0, 0xa6],
148      [0x5f, 0x63, 0x68],
149      [0x20, 0x21, 0x24],
150    ],
151    track: '#d7d9dc',
152    trackDots: '#b9bdc2',
153    notch: '#202124',
154    glyphs: { fill: '▓', empty: '░', dotOn: '◉', dotOff: '○', miniOn: '◼', miniOff: '◻', marker: '│' },
155    deco: label => label,
156    surface: { bg: '#f2f2f3', ink: '#202124', faint: '#6b6f75', border: 'single', borderColor: '#9aa0a6' },
157  },
158  // Amber CRT: bracketed capitals and phosphor orange.
159  retro: {
160    stops: [
161      [0xd9, 0x9a, 0x1e],
162      [0xf2, 0xb1, 0x1a],
163      [0xff, 0x6a, 0x1a],
164    ],
165    track: '#d8cdb8',
166    trackDots: '#b8a98a',
167    notch: '#7a3b00',
168    glyphs: { fill: '█', empty: '.', dotOn: '■', dotOff: '.', miniOn: '█', miniOff: '.', marker: '|' },
169    deco: label => `[${label.toUpperCase()}]`,
170    labelInk: '#f2b11a',
171    surface: { bg: '#1c1306', ink: '#ffb000', faint: '#d6b071', border: 'double', borderColor: '#b36b00', tray: '#f1e2c4', tones: { suggestion: '#ffd27f', warning: '#ffe066', error: '#ff7a5c', claude: '#ffab5c' } },
172  },
173  // Neon on dark glass: cyan into violet into magenta.
174  cyberpunk: {
175    stops: [
176      [0x22, 0xd3, 0xee],
177      [0xa7, 0x7b, 0xff],
178      [0xff, 0x4d, 0xb8],
179    ],
180    track: '#4a4170',
181    trackDots: '#7468a6',
182    notch: '#f72585',
183    glyphs: { fill: '█', empty: '▒', dotOn: '◉', dotOff: '◌', miniOn: '▰', miniOff: '▱', marker: '┊' },
184    deco: label => `▸${label}`,
185    labelInk: '#22d3ee',
186    surface: { bg: '#140c24', ink: '#ece6ff', faint: '#b8aee0', border: 'bold', borderColor: '#f72585', italic: true, tray: '#e2dcf7', tones: { suggestion: '#5ee0f5', warning: '#ffd166', error: '#ff5c9a', claude: '#ffa36b' } },
187  },
188  // A cup filling: short blocks rise to tall ones, milky caramel darkening to espresso.
189  latte: {
190    stops: [
191      [0xb0, 0x83, 0x4a],
192      [0x8b, 0x5a, 0x2b],
193      [0x3b, 0x24, 0x16],
194    ],
195    track: '#e2d6c4',
196    trackDots: '#c4ab8c',
197    notch: '#6f4e37',
198    glyphs: { fill: '▆', empty: '▁', dotOn: '●', dotOff: '○', miniOn: '▆', miniOff: '▁', marker: '│' },
199    deco: label => label,
200    labelInk: '#8b5a2b',
201    surface: { bg: '#f6ede1', ink: '#3b2416', faint: '#8b7058', border: 'round', borderColor: '#c4ab8c', italic: true },
202  },
203  // A drafting sheet: solid cells on a dashed dimension line, labels set between ticks.
204  blueprint: {
205    stops: [
206      [0x02, 0x84, 0xc7],
207      [0x1d, 0x4e, 0xd8],
208      [0x1e, 0x1b, 0x4b],
209    ],
210    track: '#c3d4e6',
211    trackDots: '#7fa3c8',
212    notch: '#0b3d91',
213    glyphs: { fill: '▮', empty: '┄', dotOn: '◆', dotOff: '◇', miniOn: '▮', miniOff: '▯', marker: '╎' },
214    deco: label => `┤${label}├`,
215    labelInk: '#1d4ed8',
216    surface: { bg: '#eaf2fb', ink: '#0b2a5b', faint: '#6a86ab', border: 'dashed', borderColor: '#7fa3c8' },
217  },
218  // A brush stroke: a heavy line over a hairline, slate wash into ink, sealed in vermilion when full.
219  sumi: {
220    stops: [
221      [0x7c, 0x8a, 0x9e],
222      [0x2b, 0x34, 0x45],
223      [0xc0, 0x39, 0x2b],
224    ],
225    track: '#d9dcdf',
226    trackDots: '#aab1ba',
227    notch: '#c0392b',
228    glyphs: { fill: '━', empty: '─', dotOn: '●', dotOff: '◦', miniOn: '━', miniOff: '─', marker: '┃' },
229    deco: label => label.toLowerCase(),
230    labelInk: '#2b3445',
231    surface: { bg: '#f5f3ee', ink: '#1f2430', faint: '#7c8a9e', border: 'single', borderColor: '#c0392b', italic: true },
232  },
233}
234
235export const THEME_NAMES = Object.keys(THEMES)
236
237/** A skin name THEMES holds itself, never an inherited key such as "constructor". */
238export const isTheme = (name: unknown): name is string => typeof name === 'string' && Object.hasOwn(THEMES, name)
239
240const paletteOf = (name: string): Palette => (isTheme(name) ? THEMES[name]! : THEMES['meadow']!)
241
242/** Text props for a skin's plain and faint text; without a surface, the app's own. */
243export const inksOf = (pal: Palette) => {
244  const sf = pal.surface
245  return {
246    plain: { color: sf?.ink },
247    faint: sf === undefined ? { dimColor: true } : { color: sf.faint },
248    /** The band's backdrop. */
249    band: { backgroundColor: sf?.bg, paddingX: sf === undefined ? 0 : 1 },
250    /** A status colour as this skin draws it. */
251    tone: (key: string) => sf?.tones?.[key as Tone] ?? key,
252    /** Under the app's buttons: the skin's tray, if it has one. */
253    tray: { backgroundColor: sf?.tray, paddingX: sf?.tray === undefined ? 0 : 1 },
254    /** A panel's backdrop and border. */
255    pane: { backgroundColor: sf?.bg, borderStyle: sf?.border, borderColor: sf?.borderColor, flexGrow: sf === undefined ? undefined : 1 },
256  }
257}
258
259/** The gauge colour at a position from 0 (calm) to 1 (full), in the palette's ramp. */
260export const gradientAt = (t: number, pal: Palette = THEMES['meadow']!) => {
261  const STOPS = pal.stops
262  const x = Math.min(1, Math.max(0, t)) * (STOPS.length - 1)
263  const i = Math.min(STOPS.length - 2, Math.floor(x))
264  const a = STOPS[i] ?? STOPS[0]!
265  const b = STOPS[i + 1] ?? a
266  const f = x - i
267  const hex = (k: 0 | 1 | 2) => Math.round(a[k] + (b[k] - a[k]) * f).toString(16).padStart(2, '0')
268  return `#${hex(0)}${hex(1)}${hex(2)}`
269}
270
271/** One gradient cell per character of `text`, coloured by its place among `of` cells. */
272export const gradientCells = (text: string, start: number, of: number, pal?: Palette) =>
273  [...text].map((ch, i) => ({ ch, color: gradientAt(of <= 1 ? 0 : (start + i) / (of - 1), pal) }))
274
275const MINI = 5
276
277/** A five-cell gauge: lit cells (▰ for bars, ● for dots) and the unlit rest. */
278export const mini = (percent: number, lit: string, unlit: string) => {
279  const f = Math.min(MINI, Math.max(0, Math.ceil((percent / 100) * MINI)))
280  return { filled: lit.repeat(f), empty: unlit.repeat(MINI - f) }
281}
282
283/** Where the early-warning line sits on the context bar, as a share of the window. */
284export const WARN_AT = 75
285
286
287/**
288 * The context gauge: lit cells in the gradient, the rest as a light stippled
289 * track, and a thin marker set between two cells at `warnPercent`, so it adds
290 * a line rather than taking a cell. The bar shows only the warning line;
291 * the auto-compact point is reported in text.
292 */
293export const trackCells = (percent: number, width: number, warnPercent: number | null, pal: Palette = THEMES['meadow']!) => {
294  const lit = Math.min(width, Math.max(0, Math.ceil(((percent - 1e-9) / 100) * width)))
295  const warn = warnPercent === null ? -1 : Math.min(width, Math.max(0, Math.round((warnPercent / 100) * width)))
296  const out: { ch: string; color: string; dim?: boolean }[] = []
297  for (let i = 0; i <= width; i++) {
298    if (i === warn) out.push({ ch: pal.glyphs.marker, color: pal.notch })
299    if (i === width) break
300    out.push(
301      i < lit
302        ? { ch: pal.glyphs.fill, color: gradientAt(width <= 1 ? 0 : i / (width - 1), pal) }
303        : { ch: pal.glyphs.empty, color: pal.trackDots, dim: true },
304    )
305  }
306  return out
307}
308
309/** The hidden view's five-cell gauge: ▰ lit in the gradient, ▱ unlit. */
310export const miniCells = (percent: number, pal: Palette = THEMES['meadow']!) => {
311  const { filled, empty } = mini(percent, pal.glyphs.miniOn, pal.glyphs.miniOff)
312  return [...gradientCells(filled, 0, MINI, pal), ...[...empty].map(ch => ({ ch, color: pal.track }))]
313}
314
315/** Five dots for the 5h window: lit ones in the gradient, the rest as rings. */
316export const dotCells = (percent: number, pal: Palette = THEMES['meadow']!) => {
317  const { filled, empty } = mini(percent, pal.glyphs.dotOn, pal.glyphs.dotOff)
318  return [...gradientCells(filled, 0, MINI, pal), ...[...empty].map(ch => ({ ch, color: pal.track }))]
319}
320
321export const isFable = (kind: string) => /fable/i.test(kind)
322
323export const turnLength = (ms: number) => {
324  const s = Math.max(0, Math.round(ms / 1000))
325  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
326}
327
328/** Splits "1d11h52m" into number and unit runs, so the units can be drawn bold. */
329export const unitRuns = (text: string) =>
330  (text.match(/\d+|\D+/g) ?? []).map(run => ({ run, unit: !/^\d/.test(run) }))
331
332/** The reset countdown: blue while usage is easy, warning colours as the limit nears. */
333export const countdownColor = (percent: number): Tone =>
334  percent >= 80 ? 'error' : percent >= 50 ? 'warning' : 'suggestion'
335
336/** Session age as "12m", "1h15m" or, past a day, "1d11h52m". */
337export const duration = (ms: number) => {
338  const m = Math.max(0, Math.floor(ms / 60000))
339  if (m < 60) return `${m}m`
340  if (m < 1440) return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
341  return `${Math.floor(m / 1440)}d${String(Math.floor((m % 1440) / 60)).padStart(2, '0')}h${String(m % 60).padStart(2, '0')}m`
342}
343
344/** Whole minutes as "12m", "2h05m" or, past a day, "2d 22h". */
345export const minutesText = (m: number) =>
346  m < 60 ? `${m}m` : m < 24 * 60 ? `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m` : `${Math.floor(m / 1440)}d ${Math.floor((m % 1440) / 60)}h`
347
348/** Time until an ISO reset moment, as "2h05m" or "12m"; null when unknown or past. */
349export const resetIn = (resetsAt: string | undefined, nowMs: number) => {
350  if (resetsAt === undefined || nowMs === 0) return null
351  const at = Date.parse(resetsAt)
352  if (Number.isNaN(at) || at <= nowMs) return null
353  return minutesText(Math.ceil((at - nowMs) / 60000))
354}
355
356/** `claude-opus-5-5` → `Opus 5.5`; anything else is shown as given. */
357export const modelName = (id: string) => {
358  const m = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(id)
359  if (m === null) return id
360  const family = m[1] ?? ''
361  return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${m[2]}.${m[3]}`
362}
363
364/** A rate-limit window's name as the usage panel shows it. */
365export const limitName = (kind: string) =>
366  kind === 'five_hour'
367    ? 'Current session (5h)'
368    : kind === 'seven_day'
369      ? 'Weekly'
370      : kind === 'spend_limit'
371        ? 'Spend limit'
372        : isFable(kind)
373          ? 'Fable (weekly)'
374          : kind.replace(/_/g, ' ')
375
376/** Readings older than this no longer count as the recent pace. */
377const STALE_MS = 10 * 60000
378
379/**
380 * Minutes until the 5h limit is used up at the recent pace, from samples at
381 * least two minutes apart; null when flat, falling or too few samples.
382 */
383export const minutesToLimit = (hist: UsageSample[], nowMs: number): number | null => {
384  const last = hist[hist.length - 1]
385  const first = hist.find(s => last !== undefined && last.at - s.at >= 2 * 60000) ?? hist[0]
386  if (first === undefined || last === undefined || last.at - first.at < 2 * 60000) return null
387  const perMs = (last.percent - first.percent) / (last.at - first.at)
388  if (perMs <= 0 || last.percent >= 100) return null
389  // A pace whose newest reading is old says nothing about now.
390  if (nowMs - last.at > STALE_MS) return null
391  const left = Math.ceil((100 - last.percent) / perMs / 60000) + Math.round((last.at - nowMs) / 60000)
392  return left > 0 ? left : null
393}
394
395/** The forecast line, only when the limit would land before the reset at the recent pace. */
396export const forecast = (hist: UsageSample[], resetsAt: string | undefined, nowMs: number): string | null => {
397  const left = minutesToLimit(hist, nowMs)
398  if (left === null) return null
399  const reset = resetsAt === undefined ? NaN : Date.parse(resetsAt)
400  if (Number.isNaN(reset) || nowMs + left * 60000 >= reset) return null
401  return `limit in ~${minutesText(left)}`
402}
403
404/** Rough turns to the auto-compact point at the average growth of the last turns. */
405export const turnsLeft = (hist: number[], threshold: number | null): number | null => {
406  if (threshold === null || hist.length < 3) return null
407  const first = hist[0]
408  const last = hist[hist.length - 1]
409  if (first === undefined || last === undefined) return null
410  const per = (last - first) / (hist.length - 1)
411  if (per <= 0 || last >= threshold) return null
412  return Math.max(1, Math.round((threshold - last) / per))
413}
414
415/** Share of the way to auto-compaction, or null when it is off or unknown. */
416export const compactPercent = (tokens: number, threshold: number | null) =>
417  threshold === null || threshold <= 0 ? null : Math.min(100, Math.round((tokens / threshold) * 100))
418
419/** Hides what looks like a credential: bearer tokens, key=value secrets, and long token-like runs. */
420export const maskSecrets = (text: string) =>
421  text
422    .replace(/(bearer|token|basic)\s+\S+/gi, '$1 ••••')
423    .replace(/((?:api[_-]?key|token|secret|password|passwd|pwd)\s*[=:]\s*)\S+/gi, '$1••••')
424    .replace(/\b(?:sk|pk|ghp|gho|ghs|xox[abprs]|AKIA)[-_A-Za-z0-9]{8,}/g, '••••')
425    .replace(/[A-Za-z0-9+/_-]{32,}={0,2}/g, '••••')
426
427/** $4.12, or $0.0042 for very small sums. */
428export const usd = (n: number) => (n >= 0.1 ? `$${n.toFixed(2)}` : n > 0 ? `$${n.toFixed(4)}` : '$0')
429
430export const detailOf = (input: Record<string, unknown>) => {
431  for (const key of ['command', 'file_path', 'pattern', 'description', 'url', 'query', 'prompt']) {
432    const v = input[key]
433    if (typeof v === 'string' && v.length > 0) {
434      const line = maskSecrets(v.split('\n')[0] ?? v)
435      return line.length > 40 ? `${line.slice(0, 39)}…` : line
436    }
437  }
438  return undefined
439}
440
441/** Adds a 5h reading to the pace history; a falling one means the window reset, so the pace starts over. */
442async function recordFive($: EngineInterface, at: number, percent: number) {
443  await update($, fiveHist, hist => {
444    const prev = hist[hist.length - 1]
445    if (prev !== undefined && prev.percent > percent) return [{ at, percent }]
446    return [...hist, { at, percent }].slice(-30)
447  })
448}
449
450async function refreshThreshold($: EngineInterface) {
451  const { context } = await $.session.usage({ breakdown: 'summary' })
452  const b = context.breakdown
453  const t = b !== undefined && b.isAutoCompactEnabled ? b.autoCompactThreshold ?? null : null
454  const rows: Breakdown | null =
455    b === undefined
456      ? null
457      : {
458          rows: b.categories.map(c => ({
459            name: c.name,
460            tokens: c.tokens,
461            color: c.color,
462            isDeferred: c.isDeferred,
463            kind: c.kind,
464          })),
465          total: b.totalTokens,
466          max: b.rawMaxTokens,
467          model: b.model,
468        }
469  await Promise.all([update($, compactAt, () => t), update($, breakdown, () => rows)])
470}
471
472/** A 5h reading whose reset moment has passed belongs to the old window: show it as empty. */
473export const clearPassedResets = (list: Limit[], nowMs: number): Limit[] =>
474  list.map(l => (l.kind === 'five_hour' && hasReset(l.resetsAt, nowMs) ? { ...l, percent: 0 } : l))
475
476/**
477 * True from a compaction or /clear until the next reply. Until then the usage
478 * figures are still the last reply's (or absent), so the fill is read from the
479 * /context breakdown instead, which measures the conversation as it is now.
480 */
481let contextShrank = false
482
483/** After a compaction or /clear, wherever it came from: estimate the fill and redraw. */
484async function afterShrink($: EngineInterface) {
485  contextShrank = true
486  await refreshThreshold($).catch(() => {})
487  await refreshUsage($, { checkWindow: false }).catch(() => {})
488}
489
490/** The fill from the usage figures, or from the breakdown while those are stale or absent. */
491export const fillFrom = (
492  context: { tokens?: number; percent?: number; window: number },
493  breakdownTotal: number | null,
494  preferBreakdown: boolean,
495): ContextFill | null => {
496  const pct = (tokens: number) => (context.window > 0 ? Math.round((tokens / context.window) * 1000) / 10 : 0)
497  if (breakdownTotal !== null && (preferBreakdown || context.tokens === undefined)) {
498    return { percent: pct(breakdownTotal), tokens: breakdownTotal, window: context.window, estimated: true }
499  }
500  if (context.tokens === undefined) return null
501  return { percent: context.window > 0 ? pct(context.tokens) : context.percent ?? 0, tokens: context.tokens, window: context.window }
502}
503
504async function refreshUsage($: EngineInterface, opts: { checkWindow: boolean } = { checkWindow: true }) {
505  const [plain, at, prev] = await Promise.all([$.session.usage(), $.clock.now(), read($, fill)])
506  // Ask for the breakdown only when the plain figures cannot be trusted; it costs more.
507  const usage = contextShrank || plain.context.tokens === undefined ? await $.session.usage({ breakdown: 'summary' }).catch(() => plain) : plain
508  const { context } = usage
509  const filled = fillFrom(context, context.breakdown?.totalTokens ?? null, contextShrank)
510  const nextLimits: Limit[] = clearPassedResets(
511    usage.rateLimits.map(l => ({ kind: l.kind, percent: Math.round(l.percentUsed), resetsAt: l.resetsAt })),
512    at,
513  )
514  await Promise.all([
515    update($, fill, () => filled),
516    // A response without rate-limit headers reports none; keep the last reading.
517    update($, limits, prev => (nextLimits.length > 0 ? nextLimits : prev)),
518    update($, startedAt, () => usage.startedAt),
519    update($, now, () => at),
520    // A reading without a cost keeps the last one, as the limits write does.
521    update($, costUsd, prev => usage.cost?.usd ?? prev),
522  ])
523
524  const fiveNow = nextLimits.find(l => l.kind === 'five_hour')
525  if (fiveNow !== undefined) await recordFive($, at, fiveNow.percent)
526
527  await checkReset($, at)
528
529  // A new window (model or setting change) moves the auto-compact point.
530  if (opts.checkWindow && filled !== null && prev !== null && prev.window !== filled.window) await refreshThreshold($)
531
532  const threshold = await read($, compactAt)
533  const pct = filled === null ? null : compactPercent(filled.tokens, threshold)
534  const isHigh = pct !== null && pct >= COMPACT_WARN
535  let crossed = false
536  await update($, warned, was => {
537    crossed = isHigh && !was
538    return isHigh
539  })
540  if (crossed) $.ui.toast(`Context ${pct}% of the way to auto-compact`)
541}
542
543/** Whether a reset moment has passed by `nowMs`. */
544export const hasReset = (resetsAt: string | undefined, nowMs: number) => {
545  if (resetsAt === undefined || nowMs === 0) return false
546  const at = Date.parse(resetsAt)
547  return !Number.isNaN(at) && nowMs >= at
548}
549
550/** Once the 5h window's reset time passes: a toast, once per window, and the gauge back to zero. */
551async function checkReset($: EngineInterface, nowMs: number) {
552  const five = (await read($, limits)).find(l => l.kind === 'five_hour')
553  if (five === undefined || five.resetsAt === undefined || !hasReset(five.resetsAt, nowMs)) return
554  const resetsAt = five.resetsAt
555  let isNew = false
556  await update($, resetNoticed, was => {
557    isNew = was !== resetsAt
558    return resetsAt
559  })
560  // A reading from before the reset is stale: show the new window as empty.
561  await update($, limits, list => clearPassedResets(list, nowMs))
562  if (isNew) $.ui.toast('Your 5-hour usage limit has reset')
563}
564
565/** Picks up the Hide/Show choice, which another session may have changed. */
566async function syncHidden($: EngineInterface) {
567  const stored = await $.store.get('isHidden')
568  await update($, isHidden, () => stored === true)
569}
570
571async function openContext($: EngineInterface) {
572  const [opened] = await Promise.all([
573    $.ui.open({ id: CONTEXT_PANE, title: 'Context', focus: true }),
574    refreshThreshold($).catch(() => {}),
575  ])
576  if (!opened.isPlaced) {
577    // No room for the panel here: say the headline figure instead.
578    const f = await read($, fill)
579    $.ui.toast(f === null ? 'No context reading yet' : `Context ${pctText(f.percent)}% (${kilo(f.tokens)}/${kilo(f.window)})`)
580  }
581}
582
583async function openUsage($: EngineInterface) {
584  const [opened] = await Promise.all([
585    $.ui.open({ id: USAGE_PANE, title: 'Usage', focus: true }),
586    refreshUsage($).catch(() => {}),
587  ])
588  if (!opened.isPlaced) {
589    const [ls, t] = await Promise.all([read($, limits), read($, now)])
590    const five = ls.find(l => l.kind === 'five_hour')
591    const left = five === undefined ? null : resetIn(five.resetsAt, t)
592    $.ui.toast(
593      five === undefined ? 'No usage reading yet' : `5h limit ${five.percent}% used${left !== null ? `, resets in ${left}` : ''}`,
594    )
595  }
596}
597
598/** Rings the completion sound (castle-mod's done.wav) when it is turned on. */
599async function ringDone($: EngineInterface) {
600  try {
601    if (await read($, soundOn)) await $.audio.play({ asset: 'sounds/done.wav' })
602  } catch {
603    // No player on this platform, or the clip could not play.
604  }
605}
606
607async function toggleSound($: EngineInterface) {
608  const enabled = await update($, soundOn, was => !was)
609  await $.store.set('soundOn', enabled).catch(() => {})
610  if (enabled) ringDone($).catch(() => {})
611}
612
613async function syncSound($: EngineInterface) {
614  const stored = await $.store.get('soundOn')
615  await update($, soundOn, () => stored !== false)
616}
617
618async function toggleAutoCompact($: EngineInterface) {
619  const enabled = await update($, autoCompactOn, was => !was)
620  await $.store.set('autoCompactOn', enabled).catch(() => {})
621  $.ui.toast(enabled ? `Will compact at ${EARLY_COMPACT_AT}% of the way to auto-compact` : 'Early compaction off')
622}
623
624async function syncAutoCompact($: EngineInterface) {
625  const stored = await $.store.get('autoCompactOn')
626  await update($, autoCompactOn, () => stored === true)
627}
628
629/** True while early compaction runs, so the session.compact hook leaves the refresh to it. */
630let selfCompacting = false
631
632/** True from scheduling an early compaction until it settles, so overlapping turns schedule one at most. */
633let earlyPending = false
634
635/**
636 * Compacts by running /compact as if typed. $.session.compact is not available
637 * in a desktop (SDK) session, and the command queues until the session is idle.
638 */
639async function runCompact($: EngineInterface) {
640  await $.command.run({ command: 'compact', args: '' })
641}
642
643/**
644 * The panel and bar buttons. While Claude works, /compact waits for the turn
645 * to end and its promise can settle first, so then the toast says it is queued
646 * rather than claiming it is done; the session.compact hook refreshes the figures.
647 */
648async function compactNow($: EngineInterface) {
649  const busy = (await read($, turnStartedAt).catch(() => null)) !== null
650  $.ui.toast(busy ? 'Compact queued: it runs when Claude finishes this turn' : 'Compacting the conversation…')
651  try {
652    await runCompact($)
653    await afterShrink($)
654    if (!busy) $.ui.toast('Compacted')
655  } catch (err) {
656    $.ui.toast(`Compact failed: ${String(err)}`)
657  }
658}
659
660/**
661 * With early compaction on, compacts once the context is nearly at the
662 * auto-compact point. The toggle is turned off before the attempt and turned
663 * back on only when compaction succeeded and actually lowered the context, so
664 * a failing or ineffective compaction cannot retry every turn; and only when
665 * nobody flipped the stored setting while it ran, so a mid-run choice stands.
666 */
667/** How far toward auto-compaction the context is, or null when unknown. */
668async function earlyPercent($: EngineInterface) {
669  const [f, threshold] = await Promise.all([read($, fill), read($, compactAt)])
670  return { pct: f === null ? null : compactPercent(f.tokens, threshold), threshold }
671}
672
673async function maybeCompactEarly($: EngineInterface) {
674  if (earlyPending || !(await read($, autoCompactOn))) return
675  const { pct } = await earlyPercent($)
676  if (pct === null || pct < EARLY_COMPACT_AT) return
677  earlyPending = true
678  await update($, autoCompactOn, () => false)
679  // A command cannot run inside the turn that is ending; start it a moment after.
680  $.clock.after(500, () => {
681    compactEarly($)
682      .catch(() => {})
683      .finally(() => {
684        earlyPending = false
685      })
686  })
687}
688
689async function compactEarly($: EngineInterface) {
690  // A turn begun during the delay would queue /compact behind it, and the check
691  // below would read the figures before it ran: leave it to that turn's end.
692  // Recheck the rest too: the figures may have dropped, the setting been switched off.
693  const { pct, threshold } = await earlyPercent($)
694  const busy = (await read($, turnStartedAt)) !== null
695  const wanted = (await $.store.get('autoCompactOn').catch(() => false)) === true
696  if (busy || !wanted || pct === null || pct < EARLY_COMPACT_AT) {
697    await syncAutoCompact($)
698    return
699  }
700  $.ui.toast(`Context ${pct}% of the way to auto-compact: compacting now`)
701  let helped = false
702  try {
703    // Refresh here rather than in the session.compact hook, which a mod's own call may skip;
704    // the flag keeps the hook from refreshing a second time.
705    selfCompacting = true
706    try {
707      await runCompact($)
708    } finally {
709      selfCompacting = false
710    }
711    contextShrank = true
712    await refreshThreshold($).catch(() => {})
713    await refreshUsage($, { checkWindow: false })
714    const after = await read($, fill)
715    const pctAfter = after === null ? null : compactPercent(after.tokens, threshold)
716    helped = pctAfter !== null && pctAfter < EARLY_COMPACT_AT
717    $.ui.toast(helped ? 'Compacted' : 'Compacted, but the context is still nearly full; early compaction is off')
718  } catch (err) {
719    $.ui.toast(`Compact failed: ${String(err)}; early compaction is off`)
720  }
721  if (helped && (await $.store.get('autoCompactOn').catch(() => true)) !== false) {
722    await update($, autoCompactOn, () => true)
723  } else if (!helped) {
724    await $.store.set('autoCompactOn', false).catch(() => {})
725  }
726}
727
728/** Switches to a named theme and remembers it across sessions. */
729async function setTheme($: EngineInterface, name: string) {
730  await update($, themeName, () => name)
731  await $.store.set('themeName', name).catch(() => {})
732}
733
734async function syncTheme($: EngineInterface) {
735  const stored = await $.store.get('themeName')
736  await update($, themeName, () => (isTheme(stored) ? stored : 'meadow'))
737}
738
739async function toggleHidden($: EngineInterface) {
740  const hidden = await update($, isHidden, was => !was)
741  await $.store.set('isHidden', hidden).catch(() => {})
742}
743
744/** Reads `git status --porcelain=v2 --branch`: the branch, files changed, and commits ahead/behind. */
745export const parseGitStatus = (out: string): GitInfo | null => {
746  let branch: string | null = null
747  let ahead = 0
748  let behind = 0
749  let changed = 0
750  for (const line of out.split('\n')) {
751    if (line.startsWith('# branch.head ')) branch = line.slice('# branch.head '.length).trim()
752    else if (line.startsWith('# branch.ab ')) {
753      const m = /\+(\d+) -(\d+)/.exec(line)
754      if (m !== null) {
755        ahead = Number(m[1])
756        behind = Number(m[2])
757      }
758    } else if (/^[12u?] /.test(line)) changed += 1
759  }
760  if (branch === null) return null
761  return { branch: branch === '(detached)' ? 'detached' : branch, changed, ahead, behind }
762}
763
764async function refreshGit($: EngineInterface) {
765  // --no-optional-locks: never take .git/index.lock, so a git command running alongside never fails.
766  const res = await $.process.run(['git', '--no-optional-locks', 'status', '--porcelain=v2', '--branch'], {
767    timeoutMs: 3000,
768  })
769  const status = res.exitCode === 0 ? parseGitStatus(res.stdout) : null
770  const at = await $.clock.now()
771  await Promise.all([update($, git, () => status), update($, gitAt, () => at)])
772}
773
774/** Reads git after a file-changing tool, at most once per GIT_EVERY_MS. */
775async function refreshGitSoon($: EngineInterface) {
776  const [at, last] = await Promise.all([$.clock.now(), read($, gitAt)])
777  if (last !== null && at - last < GIT_EVERY_MS) return
778  await refreshGit($)
779}
780
781async function refreshAgents($: EngineInterface) {
782  const list = await $.agent.list()
783  const names = list.filter(a => ACTIVE.includes(a.status)).map(a => a.name ?? a.type)
784  await update($, agents, () => names)
785}
786
787async function tick($: EngineInterface, me: string, timer: { cancel: () => void }) {
788  // A reload starts a newer tick; the older one stops itself.
789  if ((await read($, tickOwner)) !== me) return timer.cancel()
790  const at = await $.clock.now()
791  await update($, now, () => at)
792  await checkReset($, at)
793  const five = (await read($, limits)).find(l => l.kind === 'five_hour')
794  if (five !== undefined) await recordFive($, at, five.percent)
795}
796
797async function startTick($: EngineInterface) {
798  const me = `${await $.clock.now()}-${Math.random()}`
799  await update($, tickOwner, () => me)
800  const timer = $.clock.every(30000, () => {
801    tick($, me, timer).catch(() => {})
802  })
803}
804
805export const register: Register = on => {
806  on('session.start', async ($, e, next) => {
807    const result = await next(e)
808    await $.command.register({ name: 'contextbar', description: 'Context bar: show, hide, sound and early compaction' }).catch(() => {})
809    await Promise.all([
810      refreshThreshold($)
811        .then(() => refreshUsage($, { checkWindow: false }))
812        .catch(() => {}),
813      startTick($).catch(() => {}),
814      refreshGit($).catch(() => {}),
815      syncHidden($).catch(() => {}),
816      syncSound($).catch(() => {}),
817      syncAutoCompact($).catch(() => {}),
818      syncTheme($).catch(() => {}),
819    ])
820    return result
821  })
822
823  on('tool.call', async ($, e, next) => {
824    const isMain = e.agentId === undefined
825    const id = e.tool_use_id ?? `${e.tool}-${Math.random()}`
826    if (isMain) {
827      const entry: RunningTool = { id, tool: e.tool, detail: detailOf(e as Record<string, unknown>) }
828      await Promise.allSettled([
829        update($, running, list => [...list, entry]),
830        update($, toolsTurn, n => n + 1),
831        update($, toolsTotal, n => n + 1),
832      ])
833    } else {
834      await update($, toolsTotal, n => n + 1).catch(() => {})
835    }
836    try {
837      return await next(e)
838    } finally {
839      // Work a hook leaves unawaited does not land once it returns, so this is
840      // awaited, kept to what changed: usage for main-loop calls, agents for subagents'.
841      if (isMain) {
842        await Promise.allSettled([
843          update($, running, list => list.filter(r => r.id !== id)),
844          refreshUsage($),
845          WRITERS.includes(e.tool) ? refreshGitSoon($) : Promise.resolve(),
846        ])
847      } else {
848        await Promise.allSettled([
849          refreshAgents($),
850          WRITERS.includes(e.tool) ? refreshGitSoon($) : Promise.resolve(),
851        ])
852      }
853    }
854  })
855
856  on('command.run', { command: 'contextbar' }, async ($, e) => {
857    const want = e.args.trim().toLowerCase().split(/\s+/).join(' ')
858    if (want === 'hide' || want === 'show') {
859      await update($, isHidden, () => want === 'hide')
860      await $.store.set('isHidden', want === 'hide').catch(() => {})
861      return { text: want === 'hide' ? 'Context bar collapsed.' : 'Context bar expanded.' }
862    }
863    if (want === 'sound on' || want === 'sound off') {
864      const enabled = want === 'sound on'
865      await update($, soundOn, () => enabled)
866      await $.store.set('soundOn', enabled).catch(() => {})
867      return { text: enabled ? 'Completion sound on.' : 'Completion sound off.' }
868    }
869    if (want === 'early on' || want === 'early off') {
870      const enabled = want === 'early on'
871      await update($, autoCompactOn, () => enabled)
872      await $.store.set('autoCompactOn', enabled).catch(() => {})
873      return { text: enabled ? `Will compact at ${EARLY_COMPACT_AT}% of the way to auto-compact.` : 'Early compaction off.' }
874    }
875    if (want.startsWith('theme')) {
876      const asked = want.slice('theme'.length).trim()
877      if (isTheme(asked)) {
878        await setTheme($, asked)
879        return { text: `Theme: ${asked}.` }
880      }
881      if (asked === '') {
882        const current = await read($, themeName)
883        return { text: `Theme: ${current}. Themes: ${THEME_NAMES.join(', ')}.` }
884      }
885      return { text: `No theme called “${asked}”. Themes: ${THEME_NAMES.join(', ')}.` }
886    }
887    const [hidden, sound, early] = await Promise.all([read($, isHidden), read($, soundOn), read($, autoCompactOn)])
888    return {
889      text: [
890        ...(want !== '' ? [`Didn't understand “${want}”.`] : []),
891        `Context bar: ${hidden ? 'collapsed' : 'expanded'} · sound ${sound ? 'on' : 'off'} · early compact ${early ? 'on' : 'off'}`,
892        'Usage: /contextbar show | hide | sound on | sound off | early on | early off | theme <name>',
893      ].join('\n'),
894    }
895  })
896
897  on('turn.step', async function* ($, e, next) {
898    if (e.agentId === undefined) {
899      const effort = typeof e.effort === 'number' ? String(e.effort) : e.effort
900      await update($, modelInUse, () => ({ model: e.model, effort })).catch(() => {})
901      await update($, stepsByModel, by => ({ ...by, [e.model]: (by[e.model] ?? 0) + 1 })).catch(() => {})
902    }
903    return yield* next(e)
904  })
905
906  // Any compaction (a panel button, /compact, auto-compaction) refreshes the figures.
907  on('session.compact', async ($, e, next) => {
908    const result = await next(e)
909    if (e.agentId === undefined && !selfCompacting) await afterShrink($)
910    return result
911  })
912
913  // A typed /compact or /clear. Another plugin may pass session.compact on past
914  // this mod's hook, so the command is watched too.
915  on('command.run', { command: 'compact' }, async ($, e, next) => {
916    const result = await next(e)
917    await afterShrink($)
918    return result
919  })
920  on('command.run', { command: 'clear' }, async ($, e, next) => {
921    const result = await next(e)
922    await afterShrink($)
923    return result
924  })
925
926  on('turn.start', async ($, e, next) => {
927    if (e.agentId === undefined) {
928      contextShrank = false
929      const at = await $.clock.now().catch(() => null)
930      if (at !== null) await update($, turnStartedAt, () => at).catch(() => {})
931      await update($, toolsTurn, () => 0).catch(() => {})
932    }
933    return next(e)
934  })
935
936  on('agent.spawn', async ($, e, next) => {
937    const result = await next(e)
938    await refreshAgents($).catch(() => {})
939    return result
940  })
941
942  on('turn.complete', async ($, e, next) => {
943    const result = await next(e)
944    if (e.agentId === undefined) {
945      const at = await $.clock.now().catch(() => null)
946      let begun: number | null = null
947      await update($, turnStartedAt, was => {
948        begun = was
949        return null
950      }).catch(() => {})
951      const ms = at !== null && begun !== null ? at - begun : null
952      await Promise.allSettled([
953        ms !== null ? update($, lastTurnMs, () => ms) : Promise.resolve(null),
954        update($, running, () => []),
955        // The threshold is read fresh here, so refreshUsage need not re-check the window.
956        refreshThreshold($).then(() => refreshUsage($, { checkWindow: false })),
957        refreshGit($),
958        refreshAgents($),
959        syncHidden($),
960        syncSound($),
961        syncTheme($),
962      ])
963      if (e.reason === 'answer') ringDone($).catch(() => {})
964      const f2 = await read($, fill).catch(() => null)
965      if (f2 !== null) {
966        const hist = await update($, tokHist, old => [...old, f2.tokens].slice(-8)).catch(() => [] as number[])
967        const prev = hist[hist.length - 2]
968        await update($, lastGrowth, () => (prev !== undefined ? f2.tokens - prev : null)).catch(() => {})
969      }
970      await syncAutoCompact($).catch(() => {})
971      await maybeCompactEarly($).catch(() => {})
972    } else {
973      await refreshAgents($).catch(() => {})
974    }
975    return result
976  })
977
978  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
979    if (e.props.hasSurvey) return next(e)
980
981    const { Box, Button, Text } = $.ui.resolve(e)
982    const [f, threshold, ls, start, t, tools, turnMs, active, hidden, repo, growth, themed] = await Promise.all([
983      read($, fill),
984      read($, compactAt),
985      read($, limits),
986      read($, startedAt),
987      read($, now),
988      read($, running),
989      read($, lastTurnMs),
990      read($, agents),
991      read($, isHidden),
992      read($, git),
993      read($, lastGrowth),
994      read($, themeName),
995    ])
996    const act = tools[tools.length - 1] ?? null
997
998    const percent = f?.percent ?? 0
999    // Estimated from the breakdown after a compaction, until the next reply.
1000    const approx = f?.estimated === true ? '~' : ''
1001    const toCompact = f === null ? null : compactPercent(f.tokens, threshold)
1002    const elapsed = start === null || t === 0 ? null : duration(t - start)
1003
1004    const showWork = e.props.isWorking || active.length > 0
1005    const five = ls.find(l => l.kind === 'five_hour')
1006    const pal = paletteOf(themed)
1007    const ink = inksOf(pal)
1008
1009    const withUnits = (text: string, prefix: string) =>
1010      unitRuns(text).map((r, i) =>
1011        r.unit ? (
1012          <Text key={`${prefix}${i}`} bold {...ink.plain}>
1013            {r.run}
1014          </Text>
1015        ) : (
1016          r.run
1017        ),
1018      )
1019    const label = (text: string) => (
1020      <Text
1021        color={pal.labelInk ?? pal.surface?.faint}
1022        dimColor={pal.labelInk === undefined && pal.surface === undefined}
1023        italic={pal.surface?.italic}
1024       
1025      >
1026        {pal.deco(text)}
1027      </Text>
1028    )
1029    // The app's buttons keep light-theme ink: on a dark skin they sit on its tray.
1030    const trayed = (button: ReturnType<typeof Button>) => <Box {...ink.tray}>{button}</Box>
1031    const fiveLeft = five === undefined ? null : resetIn(five.resetsAt, t)
1032    const fable = ls.filter(l => isFable(l.kind))
1033
1034    // One cell per glyph, coloured; the gauges are strips of these.
1035    const cells = (list: { ch: string; color: string; dim?: boolean }[], prefix: string) =>
1036      list.map((c, i) => (
1037        <Text key={`${prefix}${i}`} color={c.color} dimColor={c.dim} bold={c.dim !== true}>
1038          {c.ch}
1039        </Text>
1040      ))
1041
1042    const contextGauge = (width: 'full' | 'mini') =>
1043      width === 'mini' ? (
1044        <Box>{cells(miniCells(percent, pal), 'm')}</Box>
1045      ) : (
1046        <Box>{cells(trackCells(percent, WIDTH, WARN_AT, pal), 'c')}</Box>
1047      )
1048
1049    const fiveGroup =
1050      five === undefined ? null : (
1051        <Box key="five" gap={1} alignItems="center">
1052          {label('5h')}
1053          <Box>{cells(dotCells(five.percent, pal), 'd')}</Box>
1054          {trayed(
1055            <Button key="open-usage" plain dimColor={five.percent < 50} label={`${five.percent}%`} onPress={() => openUsage($)} />,
1056          )}
1057          {five.percent >= 80 ? <Text color={ink.tone('error')}>⚠</Text> : null}
1058          {fiveLeft !== null ? (
1059            <Text color={ink.tone(countdownColor(five.percent))} bold>
1060              ↻ {fiveLeft}
1061            </Text>
1062          ) : null}
1063        </Box>
1064      )
1065
1066    const gitGroup = (compact: boolean) =>
1067      repo === null ? null : (
1068        <Box key="git" gap={1}>
1069          <Text color={ink.tone('suggestion')} bold>
1070            ⎇ {repo.branch}
1071            {compact && repo.changed > 0 ? '*' : ''}
1072          </Text>
1073          {!compact && repo.changed > 0 ? (
1074            <Text color={ink.tone('warning')} bold>
1075              ✎{repo.changed}
1076            </Text>
1077          ) : null}
1078          {!compact && repo.ahead > 0 ? <Text {...ink.faint}>↑{repo.ahead}</Text> : null}
1079          {!compact && repo.behind > 0 ? <Text {...ink.faint}>↓{repo.behind}</Text> : null}
1080        </Box>
1081      )
1082
1083    const workGroup = showWork ? (
1084      <Box key="work" gap={2}>
1085        {e.props.isWorking ? (
1086          <Box gap={1}>
1087            <Text color={ink.tone('claude')} bold>
1088              ▶ {act === null ? 'thinking…' : act.tool}
1089            </Text>
1090            {act?.detail !== undefined ? <Text {...ink.faint}>{act.detail}</Text> : null}
1091          </Box>
1092        ) : null}
1093        {active.length > 0 ? (
1094          <Text color={ink.tone('suggestion')}>
1095            ⧉ {active.length}
1096            {hidden ? '' : ` agent${active.length > 1 ? 's' : ''}: ${active.slice(0, 3).join(', ')}${active.length > 3 ? '…' : ''}`}
1097          </Text>
1098        ) : null}
1099      </Box>
1100    ) : null
1101
1102    if (hidden) {
1103      return (
1104        <Box flexWrap="wrap" columnGap={2} alignItems="center" {...ink.band}>
1105          <Box gap={1} alignItems="center">
1106            {contextGauge('mini')}
1107            {trayed(<Button key="open-context" plain label={`${approx}${Math.round(percent)}%`} onPress={() => openContext($)} />)}
1108          </Box>
1109          {fiveGroup}
1110          {gitGroup(true)}
1111          {workGroup}
1112          <Box flexGrow={1} />
1113          {trayed(<Button key="show" label="Show" onPress={() => toggleHidden($)} />)}
1114        </Box>
1115      )
1116    }
1117
1118    return (
1119      <Box flexWrap="wrap" columnGap={2} alignItems="center" {...ink.band}>
1120        <Box gap={1} alignItems="center">
1121          {label('Context')}
1122          {contextGauge('full')}
1123          {trayed(<Button key="open-context" plain label={`${approx}${pctText(percent)}%`} onPress={() => openContext($)} />)}
1124          {f !== null ? (
1125            <Text {...ink.faint}>
1126              ({kilo(f.tokens)}/{kilo(f.window)})
1127            </Text>
1128          ) : null}
1129          {growth !== null && growth > 0 ? <Text {...ink.faint}>+{kilo(growth)}</Text> : null}
1130        </Box>
1131        {toCompact !== null && toCompact >= COMPACT_WARN ? (
1132          <Text color={ink.tone('warning')} bold>
1133            ⚠ auto-compact {toCompact}%
1134          </Text>
1135        ) : null}
1136        {toCompact === null && percent >= 90 ? (
1137          <Text color={ink.tone('error')} bold>
1138            ⚠ nearly full, /compact
1139          </Text>
1140        ) : null}
1141        {fiveGroup}
1142        {fable.map(l => (
1143          <Box key={l.kind} gap={1} alignItems="center">
1144            {label('Fable')}
1145            <Box>{cells(trackCells(l.percent, SMOOTH, null, pal), `f${l.kind}`)}</Box>
1146            <Text {...(l.percent >= 80 ? { color: ink.tone('error') } : l.percent < 50 ? ink.faint : ink.plain)}>
1147              {l.percent}%
1148            </Text>
1149          </Box>
1150        ))}
1151        {gitGroup(false)}
1152        {turnMs !== null || elapsed !== null ? (
1153          <Box gap={1}>
1154            {turnMs !== null ? <Text {...ink.faint}>L:{withUnits(turnLength(turnMs), 'l')}</Text> : null}
1155            {elapsed !== null ? <Text {...ink.faint}>Age:{withUnits(elapsed, 'a')}</Text> : null}
1156          </Box>
1157        ) : null}
1158        {workGroup}
1159        <Box flexGrow={1} />
1160        {toCompact !== null && toCompact >= 70 ? (
1161          trayed(<Button key="bar-compact" label="Compact" onPress={() => compactNow($).catch(() => {})} />)
1162        ) : null}
1163        {trayed(<Button key="hide" label="Hide" onPress={() => toggleHidden($)} />)}
1164      </Box>
1165    )
1166  })
1167
1168  on('ui.render', { component: 'Pane', requestId: CONTEXT_PANE }, async ($, e) => {
1169    const { Box, Button, Text } = $.ui.resolve(e)
1170    const [b, f, threshold, early, inUseModel, hist, nTurn, nTotal, byModel, cost, themed] = await Promise.all([
1171      read($, breakdown),
1172      read($, fill),
1173      read($, compactAt),
1174      read($, autoCompactOn),
1175      read($, modelInUse),
1176      read($, tokHist),
1177      read($, toolsTurn),
1178      read($, toolsTotal),
1179      read($, stepsByModel),
1180      read($, costUsd),
1181      read($, themeName),
1182    ])
1183    const ink = inksOf(paletteOf(themed))
1184    const turns = turnsLeft(hist, threshold)
1185    const max = f?.window ?? b?.max ?? 0
1186    const used = f?.tokens ?? b?.total ?? 0
1187    const share = (n: number) => (max > 0 ? (n / max) * 100 : 0)
1188
1189    const headline = (
1190      <Box flexDirection="column" marginBottom={1}>
1191        <Box alignItems="flex-end" gap={1}>
1192          <Text bold {...ink.plain}>
1193            {f?.estimated === true ? '~' : ''}
1194            {pctText(share(used))}%
1195          </Text>
1196          <Text {...ink.faint}>
1197            of {kilo(max)} used
1198          </Text>
1199        </Box>
1200        <Text {...ink.faint}>
types/index.d.ts 43 lines
1/** `estimated`: read from the /context breakdown, as after a compaction, until the next reply reports real figures. */
2export type ContextFill = { percent: number; tokens: number; window: number; estimated?: boolean }
3export type Limit = { kind: string; percent: number; resetsAt?: string }
4export type GitInfo = { branch: string; changed: number; ahead: number; behind: number }
5export type BreakdownRow = { name: string; tokens: number; color: string; isDeferred: boolean; kind: string }
6export type Breakdown = { rows: BreakdownRow[]; total: number; max: number; model: string }
7export type UsageSample = { at: number; percent: number }
8export type RunningTool = { id: string; tool: string; detail?: string }
9
10declare module 'claude-code' {
11  interface PluginState {
12    'context-bar': {
13      fill: ContextFill | null
14      compactAt: number | null
15      limits: Limit[]
16      startedAt: number | null
17      now: number
18      running: RunningTool[]
19      agents: string[]
20      warned: boolean
21      lastTurnMs: number | null
22      turnStartedAt: number | null
23      tickOwner: string | null
24      soundOn: boolean
25      themeName: string
26      autoCompactOn: boolean
27      modelInUse: { model: string; effort?: string } | null
28      fiveHist: UsageSample[]
29      tokHist: number[]
30      lastGrowth: number | null
31      costUsd: number | null
32      toolsTurn: number
33      toolsTotal: number
34      stepsByModel: Record<string, number>
35      isHidden: boolean
36      resetNoticed: string | null
37      git: GitInfo | null
38      gitAt: number | null
39      breakdown: Breakdown | null
40    }
41  }
42}
43