A band above the prompt showing the session's context usage, plan quota or cost

A Claude Code mod: a view-aware band above the prompt. An environment row (model, effort, working dot, directory) sits above a readings row (context gauge, plan quota, reset countdown, cost), and the band follows whichever conversation is on screen — the main conversation, or one agent loop's transcript from the tasks list.
From the GitHub marketplace (type in a Claude Code terminal session):
/plugin install context-band --marketplace JinhaoFang/claude-mods
or from a local folder:
/plugin install context-band --marketplace /path/to/mods/context-band
Answer y to add the marketplace, pick the user scope, done — it goes live in the current session.
Two rows:
● running, ○ idle), the session directory in dim colour, and the button group Hide · ⟲ Compact · ⚙ · ▾. In an agent view the row leads with the viewed loop's identity label and shows that loop's model and effort instead./config or in the band's settings areaNo hard-coded login-method check: when the engine reports subscription windows, the band draws the two quota bars — the 5-hour window in the suggestion colour, the 7-day window in remember, told apart by colour, no labels — with ↺ counting down to the nearest reset. When it reports none, the band shows this session's cost instead, with a gateway's spend-limit bar appended when reported.
Open one agent loop's transcript from the tasks list and the band follows the view:
Teammates with a terminal pane of their own run a separate session with a band of its own; nothing here changes for them.
▾ folds the band into a pill, ◂ 44.4% — the on-screen view's percent, with the viewed loop's short label in an agent view; pressing the pill expands againHide removes the band until you bring it backCollapsed and hidden persist across sessions. The /context-band command restores the band, expanded.
⟲ Compact exists in the main conversation only — the engine's compact call has no per-agent form, so agent views draw no compact button at all. The first press turns it into Confirm; the second press compacts; a few seconds without the second press reverts it. A running turn finishes before compaction starts.
The ⚙ button swaps the readings row for the settings area; Done or a second ⚙ closes it.
Warn and Error step by five, Refresh seconds steps by one; every value clampsModel & effort, Reset ↺, Cost — each flips on press/config option applies only until its matching row is changedA hot reload or a session restart closes the settings area; the values you changed stay.
Rows named context-band.* in /config:
| option | default | description |
|---|---|---|
warningThreshold | 60 | Percent where colours turn to warning |
errorThreshold | 85 | Percent where colours turn to error |
refreshSeconds | 7 | Fallback redraw interval; turn ends update the band at once |
showEffort | true | Show the model name and its effort scale |
showCost | true | Show the session cost when no plan quota is reported |
showResetIn | true | Show the nearest plan-window reset countdown |
Each option holds until the matching settings row is changed; the settings value then shadows it, and it stays shadowed until you change the row again.
The main conversation's figures come from the engine-native $.session.usage() — a free local call; turn ends push a redraw, and a fallback clock covers the gaps at refreshSeconds. An agent view's figures are accumulated from that loop's completed turn.step requests, because the engine exposes no per-agent usage — the reasoning is recorded in ADR-0004. The mod spawns no subprocesses and makes no network requests. The cost shown is what this session has spent — account-level totals are not reachable through the plugin API.
▾ collapses the band to the pill; press the pill to expandHide hides the band; /context-band brings it back, expanded⟲ Compact compacts the main conversation, two-step⚙ opens the settings areaclaude plugin uninstall context-bandContributor rules — the mod contract and the inclusion bar — live in CONTRIBUTING.md.
claude plugin validate mods/context-band
claude plugin test mods/context-band
Apache-2.0, under the repository's root LICENSE.
hooks/register.tsx 532 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register, Timer } from 'claude-code'
3
4// Context band: a view-aware band above the prompt (the AbovePrompt site).
5//
6// Layout — an environment row (identity label in agent views, model, effort
7// scale, working dot, dim session directory, shortened path, then the button
8// group Hide · ⟲ Compact · ⚙ · ▾) above a readings row (compaction-window
9// gauge, plan quota bars, reset countdown or session cost). The path is the
10// first element sacrificed; readings never truncate.
11//
12// Render branch order — survey → hidden → collapsed pill → settings open →
13// the full band. The settings area swaps the readings row only; the pill
14// ignores it. /context-band resets only isHidden and isCollapsed.
15//
16// Settings — the `settings` store key holds only explicit values; every
17// effective value is the store's value when set, else the plugin option
18// default (withOverrides), so existing /config setups keep working until a
19// settings row is touched. The pair clamp is directional: raising warning
20// lifts error out of the way, lowering error drops warning with it; warning's
21// ceiling is error's − 5. Refresh seconds re-arm the fallback clock at once —
22// turn ends push a session.measure and redraw anyway, the clock only covers
23// what the engine does not measure. The open/close flag is register-activation
24// scope like the compact arm: a hot reload closes it, a session restart too.
25// The compact confirm timeout rides $.clock.every, one-shot by self-cancel —
26// $.clock.after is not in the public release the repo check runs against.
27//
28// Readings — the gauge measures the compaction window (the summary breakdown,
29// estimated locally, sends nothing), not the model's theoretical limit. The
30// plan form is adaptive, not a login-method check: subscription windows
31// present → quota bars, no cost; otherwise the session cost, with a gateway's
32// spend limit appended when reported. The two subscription windows are told
33// apart by their base colour; past the thresholds the colour turns regardless
34// of the window.
35//
36// Agent views — turn.step fills the per-agent tracker (its writes are wrapped
37// so a refusal never disturbs dispatch; the same catch covers the roster read
38// and the name cache, so a failed agent.list or a refused name write leaves
39// dispatch untouched too); main-loop effort lands in mainEffort. The tracked
40// model decides the window table; identity is name → type → short id, cached
41// before the engine prunes the loop. Compact exists in the main conversation
42// alone, two-step, waiting out a running turn.
43
44const isHidden = atom({ plugin: 'context-band', key: 'isHidden' } as const, false)
45const isCollapsed = atom({ plugin: 'context-band', key: 'isCollapsed' } as const, false)
46const mainEffort = atom({ plugin: 'context-band', key: 'mainEffort' } as const, null)
47const tracker = atom(
48 { plugin: 'context-band', key: 'tracker' } as const,
49 { loops: {}, names: {} },
50)
51const settings = atom(
52 { plugin: 'context-band', key: 'settings' } as const,
53 {} as SettingsValues,
54)
55
56const BAR_CELLS = 12
57const QUOTA_CELLS = 3
58const PATH_CELLS = 12
59const WORKING_DOT_CELLS = 2
60const GROUP_RESERVED_CELLS = 18
61const AGENT_GROUP_RESERVED_CELLS = 7
62const COMPACT_CONFIRM_MS = 5_000
63
64interface ThresholdPair {
65 warnAt: number
66 errorAt: number
67}
68
69interface Config extends ThresholdPair {
70 refreshSeconds: number
71 showEffort: boolean
72 showCost: boolean
73 showResetIn: boolean
74}
75
76interface SettingsValues extends Partial<ThresholdPair> {
77 refreshSeconds?: number
78 showEffort?: boolean
79 showCost?: boolean
80 showResetIn?: boolean
81}
82
83type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
84
85const EFFORT_CELLS: Record<Effort, number> = { low: 1, medium: 2, high: 3, xhigh: 4, max: 5 }
86
87function effortScale(effort: Effort): string {
88 return bar((EFFORT_CELLS[effort] / 5) * 100, 5)
89}
90
91function readConfig(options: PluginOptions): Config {
92 const num = (v: unknown, fallback: number) =>
93 typeof v === 'number' && Number.isFinite(v) ? v : fallback
94 const bool = (v: unknown, fallback: boolean) =>
95 typeof v === 'boolean' ? v : fallback
96 return {
97 warnAt: Math.min(100, Math.max(1, num(options.warningThreshold, 60))),
98 errorAt: Math.min(100, Math.max(1, num(options.errorThreshold, 85))),
99 refreshSeconds: Math.min(600, Math.max(1, num(options.refreshSeconds, 7))),
100 showEffort: bool(options.showEffort, true),
101 showCost: bool(options.showCost, true),
102 showResetIn: bool(options.showResetIn, true),
103 }
104}
105
106function withOverrides(base: Config, s: SettingsValues): Config {
107 return {
108 warnAt: s.warnAt ?? base.warnAt,
109 errorAt: s.errorAt ?? base.errorAt,
110 refreshSeconds: s.refreshSeconds ?? base.refreshSeconds,
111 showEffort: s.showEffort ?? base.showEffort,
112 showCost: s.showCost ?? base.showCost,
113 showResetIn: s.showResetIn ?? base.showResetIn,
114 }
115}
116
117function clamp(n: number, lo: number, hi: number): number {
118 return Math.min(hi, Math.max(lo, n))
119}
120
121function armRefresh($: EngineInterface, seconds: number, previous: Timer | null): Timer {
122 previous?.cancel()
123 return $.clock.every(seconds * 1000, () => $.ui.invalidate('ui.render'))
124}
125
126function formatTokens(n: number): string {
127 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}m`
128 if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
129 return String(n)
130}
131
132function formatReset(iso: string, nowMs: number): string {
133 const ms = new Date(iso).getTime() - nowMs
134 if (!(ms > 0)) return 'now'
135 const m = Math.round(ms / 60_000)
136 if (m < 60) return `${m}m`
137 const h = Math.floor(m / 60)
138 if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
139 return `${Math.floor(h / 24)}d${h % 24}h`
140}
141
142function colorFor(percent: number, config: Config): 'success' | 'warning' | 'error' {
143 if (percent >= config.errorAt) return 'error'
144 if (percent >= config.warnAt) return 'warning'
145 return 'success'
146}
147
148function quotaColor(percent: number, kind: string, config: Config): string {
149 if (percent >= config.errorAt) return 'error'
150 if (percent >= config.warnAt) return 'warning'
151 return kind === 'seven_day' ? 'remember' : 'suggestion'
152}
153
154function bar(percent: number, cells: number): string {
155 const filled = clamp(Math.round((percent / 100) * cells), 0, cells)
156 return '█'.repeat(filled) + '░'.repeat(cells - filled)
157}
158
159function windowFor(model: string | undefined): number {
160 return !!model && /\[1m\]/.test(model) ? 1_000_000 : 200_000
161}
162
163function shortenPath(
164 cwd: string,
165 modelAndScale: string,
166 labelCells: number,
167 bodyColumns: number,
168 reservedCells: number,
169): string {
170 const budget = Math.max(
171 0,
172 bodyColumns - labelCells - WORKING_DOT_CELLS - modelAndScale.length - PATH_CELLS - reservedCells,
173 )
174 if (cwd.length <= budget) return cwd
175 const segments = cwd.split('/').filter(Boolean)
176 const lastTwo = segments.slice(-2).join('/')
177 if (lastTwo.length <= budget) return lastTwo
178 return segments[segments.length - 1] ?? cwd
179}
180
181interface ResolvedView {
182 label: string
183 working: boolean
184 window: number
185 fill: number | undefined
186 model: string | undefined
187 effort: Effort | number | null
188}
189
190async function resolveView(
191 $: EngineInterface,
192 viewed: string | undefined,
193 model: string,
194 contextWindow: number,
195): Promise<ResolvedView | null> {
196 if (viewed === undefined) return null
197 const [tracked, roster] = await Promise.all([read($, tracker), $.agent.list()])
198 const entry = tracked.loops[viewed]
199 const info = roster.find(a => a.id === viewed)
200 const live = info ? info.name || info.type : undefined
201 return {
202 label: live || tracked.names[viewed] || viewed.slice(0, 8),
203 working: info ? info.status === 'running' : false,
204 window: entry && entry.model !== model ? windowFor(entry.model) : contextWindow,
205 fill: entry?.fill,
206 model: entry?.model,
207 effort: entry?.effort ?? null,
208 }
209}
210
211async function tryCompact($: EngineInterface): Promise<boolean> {
212 try {
213 await $.session.compact()
214 return true
215 } catch {
216 return false
217 }
218}
219
220export const register: Register = (on, rawOptions) => {
221 const baseConfig = readConfig(rawOptions)
222
223 let compactArmed = false
224 let confirmTimer: Timer | null = null
225 let compactPending = false
226 let settingsOpen = false
227 let refreshTimer: Timer | null = null
228
229 on('session.start', async ($, e, next) => {
230 await $.command.register({
231 name: 'context-band',
232 description: 'Restore the context band above the prompt, expanded',
233 })
234 const config = withOverrides(baseConfig, await read($, settings))
235 refreshTimer = armRefresh($, config.refreshSeconds, refreshTimer)
236
237 return next(e)
238 })
239
240 on('session.measure', async ($, e, next) => {
241 $.ui.invalidate('ui.render')
242
243 return next(e)
244 })
245
246 on('turn.step', async function* ($, e, next) {
247 const result = yield* next(e)
248 const agentId = e.agentId
249 try {
250 const roster = await $.agent.list()
251 const listed = new Set(roster.map(a => a.id))
252 await update($, tracker, t => {
253 const loops = { ...t.loops }
254 if (agentId && result.usage) {
255 loops[agentId] = {
256 fill:
257 result.usage.input_tokens +
258 result.usage.cache_read_input_tokens +
259 result.usage.cache_creation_input_tokens,
260 model: e.model,
261 effort: e.effort,
262 }
263 }
264 for (const id of Object.keys(loops)) {
265 if (!listed.has(id)) delete loops[id]
266 }
267 const names = { ...t.names }
268 for (const a of roster) {
269 const name = a.name || a.type
270 if (name) names[a.id] = name
271 }
272 return { loops, names }
273 })
274 if (!agentId) await update($, mainEffort, () => e.effort ?? null)
275 } catch {}
276 })
277
278 on('turn.complete', async ($, e, next) => {
279 if (compactPending && e.agentId === undefined) {
280 compactPending = !(await tryCompact($))
281 }
282
283 return next(e)
284 })
285
286 on('command.run', { command: 'context-band' }, async $ => {
287 await update($, isHidden, () => false)
288 await update($, isCollapsed, () => false)
289
290 return { text: 'Context band expanded.' }
291 })
292
293 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
294 if (e.props.hasSurvey || (await read($, isHidden))) {
295 return next(e)
296 }
297
298 const { Box, Text, Button } = $.ui.resolve(e)
299 const viewed = e.props.view.agentId
300 const [usage, model, cwd, mainEffortSeen] = await Promise.all([
301 $.session.usage({ breakdown: 'summary' }),
302 $.session.model(),
303 $.session.cwd(),
304 viewed ? Promise.resolve(null) : read($, mainEffort),
305 ])
306 const { context, rateLimits, cost } = usage
307
308 const bd = context.breakdown
309 let percent = bd ? bd.percentage : (context.percent ?? 0)
310 let window = bd ? bd.rawMaxTokens : context.window
311 let noReading = !bd && context.percent === undefined
312 let label: string | undefined
313 let working = e.props.isWorking
314 let shownModel: string | undefined = model
315 let effort: Effort | number | null = mainEffortSeen
316 const view = await resolveView($, viewed, model, window)
317 if (view) {
318 label = view.label
319 working = view.working
320 window = view.window
321 noReading = view.fill === undefined
322 percent = view.fill === undefined ? 0 : (view.fill / view.window) * 100
323 shownModel = view.model
324 effort = view.effort
325 }
326 let scale = typeof effort === 'string' ? effortScale(effort) : undefined
327
328 if (await read($, isCollapsed)) {
329 const pillLabel = label?.slice(0, 12)
330 return (
331 <Box>
332 <Button
333 key="pill"
334 label={pillLabel ? `◂ ${pillLabel} ${percent.toFixed(1)}%` : `◂ ${percent.toFixed(1)}%`}
335 dimColor={noReading}
336 onPress={() => update($, isCollapsed, () => false)}
337 />
338 </Box>
339 )
340 }
341
342 const config = withOverrides(baseConfig, await read($, settings))
343 if (!config.showEffort) {
344 shownModel = undefined
345 scale = undefined
346 }
347 const modelAndScale = shownModel ? (scale ? `${shownModel} ${scale}` : shownModel) : ''
348 const color = noReading ? 'inactive' : colorFor(percent, config)
349
350 const plans = rateLimits.filter(
351 r => r.kind === 'five_hour' || r.kind === 'seven_day',
352 )
353 const spend = rateLimits.find(r => r.kind === 'spend_limit')
354 const resetsAt = config.showResetIn
355 ? plans.flatMap(p => (p.resetsAt ? [p.resetsAt] : [])).sort()[0]
356 : undefined
357 const resetIn = resetsAt
358 ? ` ↺${formatReset(resetsAt, await $.clock.now())}`
359 : undefined
360
361 const group = [
362 <Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />,
363 ...(e.props.view.agentId === undefined
364 ? [
365 <Button
366 key="compact"
367 label={compactArmed ? 'Confirm' : '⟲ Compact'}
368 onPress={() => {
369 if (!compactArmed) {
370 compactArmed = true
371 confirmTimer = $.clock.every(COMPACT_CONFIRM_MS, () => {
372 confirmTimer?.cancel()
373 confirmTimer = null
374 compactArmed = false
375 $.ui.invalidate('ui.render')
376 })
377 } else {
378 confirmTimer?.cancel()
379 confirmTimer = null
380 compactArmed = false
381 if (e.props.isWorking) {
382 compactPending = true
383 } else {
384 void tryCompact($).then(compacted => {
385 compactPending = !compacted
386 })
387 }
388 }
389 $.ui.invalidate('ui.render')
390 }}
391 />,
392 ]
393 : []),
394 <Button
395 key="settings"
396 label="⚙"
397 onPress={() => {
398 settingsOpen = !settingsOpen
399 $.ui.invalidate('ui.render')
400 }}
401 />,
402 <Button key="collapse" label="▾" onPress={() => update($, isCollapsed, () => true)} />,
403 ]
404
405 const readingsRow = (
406 <Box key="readings">
407 <Text dimColor>Context </Text>
408 <Text color={color}>{bar(percent, BAR_CELLS)} </Text>
409 <Text color={color} bold={!noReading}>
410 {percent.toFixed(1)}%
411 </Text>
412 <Text dimColor> of {formatTokens(window)}</Text>
413 {plans.map(p => (
414 <Text
415 key={p.kind}
416 color={quotaColor(p.percentUsed, p.kind, config)}
417 >{` · ${bar(p.percentUsed, QUOTA_CELLS)} ${p.percentUsed}%`}</Text>
418 ))}
419 {resetIn && <Text dimColor>{resetIn}</Text>}
420 {config.showCost && plans.length === 0 && cost !== undefined && (
421 <Text dimColor>{` · $${cost.usd.toFixed(2)}`}</Text>
422 )}
423 {spend && (
424 <Text color={quotaColor(spend.percentUsed, 'five_hour', config)}>{` · Limit ${bar(spend.percentUsed, QUOTA_CELLS)} ${spend.percentUsed}%`}</Text>
425 )}
426 </Box>
427 )
428
429 const stepPair = (field: 'warnAt' | 'errorAt', delta: number) => {
430 void update($, settings, s => {
431 const warnFrom = s.warnAt ?? baseConfig.warnAt
432 const errorFrom = s.errorAt ?? baseConfig.errorAt
433 const written: SettingsValues = { ...s }
434 if (field === 'warnAt') {
435 const warn = clamp(warnFrom + delta, 1, 100)
436 const error = Math.min(100, Math.max(errorFrom, warn + 5))
437 written.warnAt = Math.min(warn, error - 5)
438 if (error !== errorFrom) written.errorAt = error
439 } else {
440 const error = clamp(errorFrom + delta, 1, 100)
441 const warn = Math.max(1, Math.min(warnFrom, error - 5))
442 written.errorAt = Math.max(error, warn + 5)
443 if (warn !== warnFrom) written.warnAt = warn
444 }
445 return written
446 })
447 }
448 const stepSeconds = (delta: number) => {
449 const seconds = clamp(config.refreshSeconds + delta, 1, 600)
450 void update($, settings, s => ({ ...s, refreshSeconds: seconds }))
451 refreshTimer = armRefresh($, seconds, refreshTimer)
452 }
453
454 const stepper = (
455 label: string,
456 value: string,
457 minusKey: string,
458 plusKey: string,
459 onPress: (delta: number) => void,
460 by = 1,
461 ) => [
462 <Text key={`${minusKey}v`}>{`${label} ${value}`}</Text>,
463 <Button key={minusKey} label="-" onPress={() => onPress(-by)} />,
464 <Button key={plusKey} label="+" onPress={() => onPress(by)} />,
465 ]
466
467 const numericRow = (
468 <Box key="settings-numeric">
469 {[
470 ...stepper('Warn', String(config.warnAt), 'warn-', 'warn+', delta => stepPair('warnAt', delta), 5),
471 ...stepper('Error', String(config.errorAt), 'error-', 'error+', delta => stepPair('errorAt', delta), 5),
472 ...stepper('Refresh', `${config.refreshSeconds}s`, 'seconds-', 'seconds+', stepSeconds),
473 ]}
474 </Box>
475 )
476
477 const flip = (field: 'showEffort' | 'showCost' | 'showResetIn', value: boolean) => {
478 void update($, settings, s => ({ ...s, [field]: !value }))
479 }
480
481 const togglesRow = (
482 <Box key="settings-toggles">
483 {[
484 <Button
485 key="effort"
486 label={`Model & effort ${config.showEffort ? 'on' : 'off'}`}
487 onPress={() => flip('showEffort', config.showEffort)}
488 />,
489 <Button
490 key="reset"
491 label={`Reset ↺ ${config.showResetIn ? 'on' : 'off'}`}
492 onPress={() => flip('showResetIn', config.showResetIn)}
493 />,
494 <Button
495 key="cost"
496 label={`Cost ${config.showCost ? 'on' : 'off'}`}
497 onPress={() => flip('showCost', config.showCost)}
498 />,
499 <Button
500 key="done"
501 label="Done"
502 onPress={() => {
503 settingsOpen = false
504 $.ui.invalidate('ui.render')
505 }}
506 />,
507 ]}
508 </Box>
509 )
510
511 return (
512 <Box flexDirection="column">
513 <Box>
514 {label && <Text>{label} </Text>}
515 {shownModel && <Text>{shownModel} </Text>}
516 {scale && <Text dimColor>{scale} </Text>}
517 <Text color={working ? 'success' : 'inactive'}>{working ? '●' : '○'}{' '}</Text>
518 <Text dimColor>{shortenPath(
519 cwd,
520 modelAndScale,
521 label ? label.length + 1 : 0,
522 e.props.bodyColumns,
523 viewed ? AGENT_GROUP_RESERVED_CELLS : GROUP_RESERVED_CELLS,
524 )}</Text>
525 {group}
526 </Box>
527 {settingsOpen ? [numericRow, togglesRow] : [readingsRow]}
528 </Box>
529 )
530 })
531}
532types/index.d.ts 25 lines1declare module 'claude-code' {
2 interface PluginState {
3 'context-band': {
4 isHidden: boolean
5 isCollapsed: boolean
6 mainEffort: 'low' | 'medium' | 'high' | 'xhigh' | 'max' | null
7 tracker: {
8 loops: Record<
9 string,
10 { fill: number; model: string; effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max' | number }
11 >
12 names: Record<string, string>
13 }
14 settings: {
15 warnAt?: number
16 errorAt?: number
17 refreshSeconds?: number
18 showEffort?: boolean
19 showCost?: boolean
20 showResetIn?: boolean
21 }
22 }
23 }
24}
25