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

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.

Click either percentage to open a panel with the details and controls.
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
| Part | What it means |
|---|---|
| Context gauge | How 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-compact | Shows 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 dots | Your 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 gauge | Shown only when Claude Code reports a Fable usage window. |
| ⎇ branch | The git branch, ✎ uncommitted files, ↑↓ commits ahead and behind. |
| L and Age | How long the last turn took, and how long the session has been open (days past 24 hours, e.g. 1d12h44m). |
| ▶ working | The tool Claude is running now, and any subagents. |
| Hide | Folds the bar into small gauges. Show brings it back. |
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.

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 Fable weekly limit isn't reported to mods, so the panel points to Settings → Usage for it.
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.

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.
| Command | What it does |
|---|---|
/contextbar | Shows the current settings |
/contextbar show / hide | Shows or folds the bar |
/contextbar sound on / off | Turns the reply bell on or off |
/contextbar early on / off | Turns early compaction on or off |
/contextbar theme <name> | Picks a skin; with no name, lists them |
Everything stays on your machine. The mod makes no network requests.
| What | When and why |
|---|---|
Runs /compact | Only 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 /clear | Only when you press Clear chat in the Usage panel. |
Starts git | git --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 session | Token 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 out | Only to your clipboard, when you press Copy figures (the context figures shown in the panel). |
| Plays a sound | sounds/done.wav, a short bell when Claude finishes a reply, if Sound is on. |
| Saves settings | Skin, 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.
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.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.claude plugin validate .
claude plugin test .
Edits load with /reload-plugins. The bell is sounds/done.wav, an original sound from claude-castle-mod.
hooks/register.tsx 1450 lines1import { 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 lines1/** `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