Usage strip above the prompt: 5h and 7d limits, session tokens, context window and cost.

A Claude Code plugin marketplace with one mod, usage-band: a band above the prompt showing quota, tokens, context and session cost.
Preview renders:


| Pill | Shows |
|---|---|
| 5h | Percent of the 5-hour quota left. The bar is the quota left, the tick is the time left in the window; shows the time until reset. |
| 7d | Same for the 7-day quota. |
| Tokens up | Input tokens plus cache writes. |
| Tokens down | Output tokens. |
| Cache reads | Tokens read from the prompt cache. |
| Context | Tokens of the last request. Bar and colour by fill: under 60% calm, 60-85% amber, over 85% red. |
| Cost | Session cost in US dollars. |
/clear and on resume.glyphs to unicode.Inside Claude Code:
/plugin marketplace add brooklyn96/claude-usage-band
/plugin install usage-band@claude-usage-band
From a shell:
claude plugin marketplace add brooklyn96/claude-usage-band
claude plugin install usage-band@claude-usage-band
In an open session, run /reload-plugins to pick it up.
Update:
claude plugin update usage-band@claude-usage-band
glyphs: nerd (default) or unicode. Set it through /plugin (configure).
When the row does not fit, the cache pill drops first, then bars shorten and time labels go compact, then every pill wraps onto the next line at full size.
claude plugin validate usage-band
claude plugin test usage-band
Type-check after the mod has loaded, since the engine writes usage-band/.claude-plugin/types on load:
npx -y -p typescript@5.6 tsc -p usage-band --noEmit
A mod runs with your permissions. This one makes no network calls, writes no files and starts no processes.
MIT.
hooks/register.tsx 170 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelUsage, Register, SessionContextUsage, SessionCost, SessionRateLimit } from 'claude-code'
3
4import type { Limit, Tokens } from '../types'
5import { PILL_H, STRIP_BG, alt, isRate, pillSvg, pillsOf, termLayout } from './pills'
6import type { Pill, ValuePill } from './pills'
7
8const limitsAtom = atom({ plugin: 'usage-band', key: 'limits' } as const, [])
9const costAtom = atom({ plugin: 'usage-band', key: 'cost' } as const, null)
10const tokensAtom = atom({ plugin: 'usage-band', key: 'tokens' } as const, null)
11// `tokens` shape: the value changed from a bare percent to the token reading.
12const contextAtom = atom({ plugin: 'usage-band', key: 'context' } as const, null, { shape: 'tokens' })
13const nowAtom = atom({ plugin: 'usage-band', key: 'now' } as const, 0)
14
15/** `context` only when its fill moved (or on a full reading); `tokens` is absent before the first response. */
16async function measured($: EngineInterface, rateLimits: readonly SessionRateLimit[], cost?: SessionCost, context?: SessionContextUsage) {
17 const limits = rateLimits.map(({ kind, percentUsed, resetsAt }): Limit => (resetsAt ? { kind, percentUsed, resetsAt } : { kind, percentUsed }))
18 await update($, limitsAtom, () => limits)
19 await update($, costAtom, () => cost?.usd ?? null)
20 if (context) {
21 const next = context.tokens === undefined ? null : { tokens: context.tokens, window: context.window, percent: context.percent }
22 await update($, contextAtom, () => next)
23 }
24}
25
26async function spent($: EngineInterface, u: ModelUsage | null | undefined) {
27 if (!u) return
28 await update($, tokensAtom, (t): Tokens => ({
29 input: (t?.input ?? 0) + u.input_tokens + u.cache_creation_input_tokens,
30 output: (t?.output ?? 0) + u.output_tokens,
31 cache: (t?.cache ?? 0) + u.cache_read_input_tokens,
32 }))
33}
34
35async function refresh($: EngineInterface) {
36 try {
37 const usage = await $.session.usage()
38 await measured($, usage.rateLimits, usage.cost, usage.context)
39 const now = await $.clock.now()
40 await update($, nowAtom, () => now)
41 } catch {
42 // The engine is shutting down or a hook refused the reading: the next tick tries again.
43 }
44}
45
46/** The /config theme row: `light`, `light-daltonized`, ... draw the light palette; anything else dark. */
47const isLight = ($: EngineInterface) =>
48 $.config.list().then(
49 rows => String(rows.find(row => row.key === 'theme')?.value ?? '').startsWith('light'),
50 () => false,
51 )
52
53// Timers die with the module, and a hot reload raises no session.start: whichever of
54// session.start or the first draw comes first starts the clock for this module.
55let started = false
56function start($: EngineInterface) {
57 if (started) return
58 started = true
59 $.clock.every(30_000, () => void refresh($))
60}
61
62export const register: Register = (on, options) => {
63 const glyphs = options.glyphs === 'unicode' ? 'unicode' : 'nerd'
64
65 on('session.start', async ($, e, next) => {
66 await refresh($)
67 start($)
68 return next(e)
69 })
70
71 on('session.measure', async ($, e, next) => {
72 await measured($, e.rateLimits, e.cost, e.changed.includes('context') ? e.context : undefined)
73 return next(e)
74 })
75
76 // Every priced response, main loop and subagents alike, as it comes back: the totals
77 // move with the cost, not at the turn's end (turn.complete's usage is these summed).
78 on('turn.step', async function* ($, e, next) {
79 const result = yield* next(e)
80 await spent($, result.usage)
81 return result
82 })
83
84 // The compaction summarizer is priced too, and raises no turn.
85 on('session.compact', async ($, e, next) => {
86 const result = await next(e)
87 if ('usage' in result) await spent($, result.usage)
88 return result
89 })
90
91 on('session.end', async ($, e, next) => {
92 // Cost follows the new session's ledger on both, so the token totals and the
93 // context fill start over too: the new session's first measure brings them back.
94 if (e.reason === 'clear' || e.reason === 'resume') {
95 await update($, tokensAtom, () => null)
96 await update($, contextAtom, () => null)
97 }
98 return next(e)
99 })
100
101 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
102 if (!started) {
103 start($)
104 $.clock.after(0, () => void refresh($)) // first reading after a reload; a draw never writes
105 }
106 if (e.props.hasSurvey) return next(e)
107 await read($, nowAtom) // subscribes: the minute timer redraws the band
108 const pills = pillsOf(
109 await read($, limitsAtom),
110 await read($, tokensAtom),
111 await read($, costAtom),
112 await $.clock.now(),
113 await read($, contextAtom),
114 )
115 if (pills.length === 0) return next(e)
116
117 const left = pills.filter(p => p.id !== 'cost')
118 const cost = pills.find((p): p is ValuePill => p.id === 'cost')
119
120 if (e.surface === 'desktop') {
121 const { Box, Svg } = $.ui.resolve(e)
122 const pill = (p: Pill) => {
123 const { source, width } = pillSvg(p)
124 return <Svg key={p.id} source={source} alt={alt(p)} width={width} height={PILL_H} />
125 }
126 return (
127 <Box
128 flexDirection="row"
129 flexWrap="wrap"
130 alignItems="center"
131 backgroundColor={STRIP_BG}
132 borderStyle="round"
133 borderColor={STRIP_BG}
134 paddingX={2}
135 paddingY={1}
136 columnGap={1}
137 rowGap={1}
138 >
139 {left.filter(isRate).map(pill)}
140 {left.some(isRate) && left.some(p => !isRate(p)) ? <Box width={1} /> : null}
141 {left.filter(p => !isRate(p)).map(pill)}
142 <Box flexGrow={1} />
143 {cost ? pill(cost) : null}
144 </Box>
145 )
146 }
147
148 const { Box, Text } = $.ui.resolve(e)
149 const row = termLayout(pills, e.props.bodyColumns, glyphs, await isLight($))
150 const pill = ({ id, runs }: (typeof row.pills)[number]) => (
151 <Box key={id} flexDirection="row" flexShrink={0}>
152 {runs.map((r, i) => (
153 <Text key={String(i)} color={r.fg} backgroundColor={r.bg} bold={r.bold}>
154 {r.s}
155 </Text>
156 ))}
157 </Box>
158 )
159 const costPill = row.pills.find(p => p.id === 'cost')
160 // Wrapped or a tight row, no spacer: the cost pill follows on whichever line has room.
161 return (
162 <Box flexDirection="row" flexWrap={row.wrap ? 'wrap' : 'nowrap'} columnGap={1} width={e.props.bodyColumns}>
163 {row.pills.filter(p => p.id !== 'cost').map(pill)}
164 {costPill && row.spacer ? <Box flexGrow={1} /> : null}
165 {costPill ? pill(costPill) : null}
166 </Box>
167 )
168 })
169}
170hooks/pills.ts 316 lines1import type { ContextUsage, Limit, Tokens } from '../types'
2
3const MIN = 60_000
4const HOUR = 60 * MIN
5const DAY = 24 * HOUR
6const WINDOWS = { five_hour: 5 * HOUR, seven_day: 7 * DAY } as const
7
8/** `percent` is the quota remaining, `timeShare` the share of the window's time remaining. */
9export type RatePill = { id: '5h' | '7d'; percent: number; timeShare: number | null; left: string }
10export type ValuePill = { id: 'in' | 'out' | 'cache' | 'cost'; value: string }
11/** `tokens` is the last response's input side; `percent` is the context window USED (what auto-compact reacts to), unlike the quota pills. */
12export type ContextPill = { id: 'ctx'; tokens: number; percent: number }
13export type Pill = RatePill | ValuePill | ContextPill
14
15export const formatTokens = (n: number): string => {
16 if (n < 1000) return String(Math.round(n))
17 const k = Math.round(n / 100) / 10
18 if (k < 1000) return `${k.toFixed(1)}k`
19 return `${(Math.round(n / 100_000) / 10).toFixed(1)}M`
20}
21
22/** Context pill only: whole thousands, no decimal (`950`, `42k`, `210k`); one decimal of millions from 1M up (`1.0M`, `1.2M`). */
23export const formatCtxTokens = (n: number): string => {
24 if (n < 1000) return String(Math.round(n))
25 if (Math.round(n / 1000) < 1000) return `${Math.round(n / 1000)}k`
26 return `${(Math.round(n / 100_000) / 10).toFixed(1)}M`
27}
28
29export const formatLeft = (ms: number): string => {
30 const minutes = Math.max(0, Math.floor(ms / MIN))
31 const d = Math.floor(minutes / 1440)
32 const h = Math.floor((minutes % 1440) / 60)
33 const m = minutes % 60
34 if (d > 0) return `${d}d ${h}h`
35 if (h > 0) return `${h}h ${m}m`
36 return `${m}m`
37}
38
39/** (resetsAt - now) / window, clamped to 0..1; null when the reset is unknown. */
40export const remainingFraction = (limit: Limit, now: number): number | null => {
41 const window = WINDOWS[limit.kind as keyof typeof WINDOWS]
42 const resets = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt)
43 if (!window || Number.isNaN(resets)) return null
44 return Math.min(1, Math.max(0, (resets - now) / window))
45}
46
47export const pillsOf = (limits: Limit[], tokens: Tokens | null, cost: number | null, now: number, context: ContextUsage | null = null): Pill[] => {
48 const pills: Pill[] = []
49 for (const [kind, id] of [['five_hour', '5h'], ['seven_day', '7d']] as const) {
50 const limit = limits.find(l => l.kind === kind)
51 if (!limit) continue
52 const left = limit.resetsAt === undefined ? '' : formatLeft(Date.parse(limit.resetsAt) - now)
53 pills.push({ id, percent: Math.max(0, Math.round(100 - limit.percentUsed)), timeShare: remainingFraction(limit, now), left })
54 }
55 if (tokens) {
56 pills.push({ id: 'in', value: formatTokens(tokens.input) })
57 pills.push({ id: 'out', value: formatTokens(tokens.output) })
58 pills.push({ id: 'cache', value: formatTokens(tokens.cache) })
59 }
60 if (context !== null) {
61 const percent = context.percent ?? (context.window > 0 ? (context.tokens * 100) / context.window : 0)
62 pills.push({ id: 'ctx', tokens: context.tokens, percent: Math.min(100, Math.max(0, Math.round(percent))) })
63 }
64 if (cost !== null) pills.push({ id: 'cost', value: `$${cost.toFixed(2)}` })
65 return pills
66}
67
68export const isRate = (p: Pill): p is RatePill => p.id === '5h' || p.id === '7d'
69
70/** Context fill step: below 60% calm, 60-85% amber, above 85% red. */
71export const ctxLevel = (percent: number) => (percent > 85 ? 'high' : percent >= 60 ? 'warn' : 'ok')
72
73// Colours sampled from the reference (light theme).
74export const COLORS = {
75 '5h': { bg: '#dce8e4', fg: '#3d8c7d' },
76 '7d': { bg: '#e4e0f3', fg: '#7b5fd3' },
77 in: { bg: '#f4ddd9', fg: '#d0553f' },
78 out: { bg: '#dde9dc', fg: '#4c9a50' },
79 cache: { bg: '#dfe2f7', fg: '#4a5bd6' },
80 cost: { bg: '#f1ebd8', fg: '#c4952a' },
81} as const
82export const CTX_COLORS = {
83 ok: { bg: '#d6eaee', fg: '#26879c' },
84 warn: { bg: '#f7e3cb', fg: '#c46a12' },
85 high: { bg: '#f6d5d8', fg: '#c62f3e' },
86} as const
87export const STRIP_BG = '#f1f0ec'
88const INK = '#2b2b2b'
89const MUTED = '#6f6f6f'
90const FILL = '#8db36a'
91const TRACK = '#d9d9d6'
92const TICK = '#333333'
93const DIVIDER = '#b9c2c0'
94
95export const alt = (p: Pill): string => {
96 if (isRate(p)) {
97 const name = p.id === '5h' ? '5-hour' : '7-day'
98 return `${name} limit ${p.percent}% left${p.left ? `, resets in ${p.left}` : ''}`
99 }
100 if (p.id === 'ctx') return `context ${formatCtxTokens(p.tokens)} tokens (${p.percent}% of window)`
101 return { in: 'Input tokens', out: 'Output tokens', cache: 'Cache read', cost: 'Session cost' }[p.id] + ` ${p.value}`
102}
103
104// Icons: 24-unit stroke paths (Lucide-style), drawn at ICON px.
105const ICONS = {
106 gauge: '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
107 calendar:
108 '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>' +
109 '<text x="12" y="19.6" font-size="10" font-weight="700" text-anchor="middle" stroke="none" fill="currentColor">7</text>',
110 history: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
111 in: '<path d="M12 3v11M7 8l5-5 5 5"/><rect x="3" y="15" width="18" height="6" rx="2"/>',
112 out: '<path d="M12 2v11M7 8l5 5 5-5"/><rect x="3" y="15" width="18" height="6" rx="2"/>',
113 cache:
114 '<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/>' +
115 '<path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>',
116 cost:
117 '<circle cx="12" cy="12" r="10" fill="#f3d98a"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/>',
118 ctx:
119 '<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/>' +
120 '<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/>' +
121 '<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/><path d="M12 5v13"/>',
122} as const
123
124// Sizes measured on the reference at 1x.
125export const PILL_H = 22
126const H = PILL_H
127const FONT = 12.5
128const CHAR = 7.5 // advance of a 12.5px monospace glyph (0.6em)
129const ICON = 14
130const BAR = 46
131const CTX_BAR = 30
132const FAMILY = "ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace"
133
134const icon = (name: keyof typeof ICONS, x: number, size: number, color: string) =>
135 `<g transform="translate(${x} ${(H - size) / 2}) scale(${size / 24})" fill="none" stroke="${color}" ` +
136 `stroke-width="2" stroke-linecap="round" stroke-linejoin="round" color="${color}">${ICONS[name]}</g>`
137
138const text = (s: string, x: number, color: string, bold = false) =>
139 `<text x="${x}" y="${H / 2}" dominant-baseline="central" fill="${color}"${bold ? ' font-weight="700"' : ''}>${s}</text>`
140
141/** One pill as an SVG document and its width in CSS pixels. */
142export const pillSvg = (p: Pill): { source: string; width: number } => {
143 const { bg, fg } = p.id === 'ctx' ? CTX_COLORS[ctxLevel(p.percent)] : COLORS[p.id]
144 const parts: string[] = []
145 let x = 9
146 if (isRate(p)) {
147 parts.push(icon(p.id === '5h' ? 'gauge' : 'calendar', x, ICON, fg))
148 x += ICON + 7
149 parts.push(text(p.id, x, MUTED))
150 x += 2 * CHAR + 8
151 const fill = (BAR * Math.min(100, Math.max(0, p.percent))) / 100
152 const y = (H - 4) / 2
153 parts.push(
154 `<rect x="${x}" y="${y}" width="${BAR}" height="4" rx="2" fill="${TRACK}"/>`,
155 `<rect x="${x}" y="${y}" width="${fill}" height="4" rx="2" fill="${FILL}"/>`,
156 )
157 if (p.timeShare !== null) {
158 const tick = Math.min(BAR - 1, Math.max(1, BAR * p.timeShare))
159 parts.push(`<rect x="${x + tick - 1}" y="${(H - 12) / 2}" width="2" height="12" rx="1" fill="${TICK}"/>`)
160 }
161 x += BAR + 8
162 const pct = `${p.percent}%`
163 parts.push(text(pct, x, INK, true))
164 x += pct.length * CHAR + 9
165 if (p.left) {
166 parts.push(`<line x1="${x}" y1="${(H - 14) / 2}" x2="${x}" y2="${(H + 14) / 2}" stroke="${DIVIDER}" stroke-width="1"/>`)
167 x += 9
168 parts.push(icon('history', x, 13, fg))
169 x += 13 + 6
170 parts.push(text(p.left, x, MUTED))
171 x += p.left.length * CHAR + 11
172 } else x += 2
173 } else if (p.id === 'ctx') {
174 parts.push(icon('ctx', x, ICON, fg))
175 x += ICON + 7
176 const y = (H - 4) / 2
177 parts.push(
178 `<rect x="${x}" y="${y}" width="${CTX_BAR}" height="4" rx="2" fill="${TRACK}"/>`,
179 `<rect x="${x}" y="${y}" width="${(CTX_BAR * p.percent) / 100}" height="4" rx="2" fill="${fg}"/>`,
180 )
181 x += CTX_BAR + 8
182 const figure = formatCtxTokens(p.tokens)
183 parts.push(text(figure, x, INK, true))
184 x += figure.length * CHAR + 10
185 } else {
186 parts.push(icon(p.id, x, ICON, fg))
187 x += ICON + 7.5
188 parts.push(text(p.value, x, INK))
189 x += p.value.length * CHAR + 10
190 }
191 const width = Math.ceil(x)
192 const source =
193 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${H}" viewBox="0 0 ${width} ${H}" ` +
194 `font-family="${FAMILY}" font-size="${FONT}">` +
195 `<rect width="${width}" height="${H}" rx="${H / 2}" fill="${bg}"/>${parts.join('')}</svg>`
196 return { source, width }
197}
198
199// Terminal: each pill a row of coloured runs between two caps drawn in the pill colour.
200export type Run = { s: string; fg?: string; bg?: string; bold?: boolean }
201export type Glyphs = 'nerd' | 'unicode'
202
203const GLYPHS = {
204 // Nerd Font: powerline round caps, Material Design icons (each one cell).
205 nerd: {
206 caps: ['', ''],
207 '5h': '\u{f029a}', // md-gauge
208 '7d': '\u{f00ed}', // md-calendar
209 left: '\u{f02da}', // md-history
210 in: '\u{f011d}', // md-tray_arrow_up
211 out: '\u{f0120}', // md-tray_arrow_down
212 cache: '\u{f0328}', // md-layers
213 cost: '', // fa-coins: md has no currency-usd circle
214 ctx: '\u{f09d1}', // md-brain
215 },
216 // Only glyphs Cascadia Mono has, all single width.
217 unicode: { caps: ['▐', '▌'], '5h': '◔', '7d': '▤', left: '◷', in: '↑', out: '↓', cache: '≡', cost: '◉', ctx: '◐' },
218} as const
219
220export const TERM_PALETTE = {
221 dark: {
222 pill: {
223 '5h': { bg: '#1d3b36', fg: '#5ed0b8' },
224 '7d': { bg: '#2d2850', fg: '#a892ff' },
225 in: { bg: '#47231e', fg: '#ff7e66' },
226 out: { bg: '#1f3d23', fg: '#72d37c' },
227 cache: { bg: '#242c55', fg: '#8796ff' },
228 cost: { bg: '#40331a', fg: '#f2c24e' },
229 },
230 ctx: {
231 ok: { bg: '#14353d', fg: '#4cc3d9' },
232 warn: { bg: '#48300f', fg: '#ff9f43' },
233 high: { bg: '#4d1a22', fg: '#ff5c6c' },
234 },
235 text: '#ececea',
236 muted: '#a9b1af',
237 track: '#4a504e',
238 fill: '#93d36d',
239 tick: '#ffffff',
240 },
241 light: { pill: COLORS, ctx: CTX_COLORS, text: INK, muted: MUTED, track: '#c5c8c4', fill: FILL, tick: TICK },
242} as const
243
244export const cells = (runs: Run[]): number => runs.reduce((n, r) => n + [...r.s].length, 0)
245
246/**
247 * One pill as runs, caps included, at ladder `step` (see termLayout): from step 3
248 * the bars are shorter, from step 4 the time labels compact (`2h40m`, no divider).
249 */
250export const termPill = (p: Pill, glyphs: Glyphs, light: boolean, step = 1): Run[] => {
251 const g = GLYPHS[glyphs]
252 const pal = TERM_PALETTE[light ? 'light' : 'dark']
253 const { bg, fg } = p.id === 'ctx' ? pal.ctx[ctxLevel(p.percent)] : pal.pill[p.id]
254 const runs: Run[] = []
255 const put = (s: string, color: string, extra: Partial<Run> = {}) => {
256 const run = { s, fg: color, bg, ...extra }
257 const last = runs[runs.length - 1]
258 if (last && last.fg === run.fg && last.bg === run.bg && !!last.bold === !!run.bold) last.s += s
259 else runs.push(run)
260 }
261 // Whole cells only: every bar cell is a space on the fill or track background, the tick the one glyph.
262 const bar = (percent: number, n: number, fill: string, tickAt = -1) => {
263 const filled = Math.min(n, Math.max(0, Math.round((percent / 100) * n)))
264 for (let i = 0; i < n; i++) {
265 const cellBg = i < filled ? fill : pal.track
266 if (i === tickAt) put('▎', pal.tick, { bg: cellBg, bold: true })
267 else put(' ', cellBg, { bg: cellBg })
268 }
269 }
270 runs.push({ s: g.caps[0], fg: bg })
271 if (isRate(p)) {
272 const n = step >= 3 ? 4 : 6
273 put(`${g[p.id]} `, fg)
274 put(`${p.id} `, pal.muted)
275 bar(p.percent, n, pal.fill, p.timeShare === null ? -1 : Math.min(n - 1, Math.floor(p.timeShare * n)))
276 put(' ', fg)
277 put(`${p.percent}%`, pal.text, { bold: true })
278 if (p.left && step >= 4) {
279 put(` ${g.left}`, fg)
280 put(p.left.replace(' ', ''), pal.muted)
281 } else if (p.left) {
282 put(' ▏ ', pal.muted)
283 put(`${g.left} `, fg)
284 put(p.left, pal.muted)
285 }
286 } else if (p.id === 'ctx') {
287 put(`${g.ctx} `, fg)
288 bar(p.percent, step >= 3 ? 3 : 4, fg)
289 put(' ', fg)
290 put(formatCtxTokens(p.tokens), pal.text, { bold: true })
291 } else {
292 put(`${g[p.id]} `, fg)
293 put(p.value, pal.text)
294 }
295 runs.push({ s: g.caps[1], fg: bg })
296 return runs
297}
298
299/**
300 * The terminal row at `columns`, the first step that fits: 1 every pill;
301 * 2 without the cache pill; 3 shorter bars (quota 6 -> 4 cells, context
302 * 4 -> 3); 4 compact time labels; 5 every pill, wrapped. Width counts one gap
303 * between pills; the cost pill's spacer (one more gap) shows only when it fits.
304 */
305export const termLayout = (pills: Pill[], columns: number, glyphs: Glyphs, light: boolean) => {
306 const build = (step: number) => {
307 const row = pills.filter(p => step < 2 || step > 4 || p.id !== 'cache').map(p => ({ id: p.id, runs: termPill(p, glyphs, light, step > 4 ? 1 : step) }))
308 return { pills: row, width: row.reduce((n, p) => n + cells(p.runs) + 1, -1), step }
309 }
310 for (let step = 1; step <= 4; step++) {
311 const row = build(step)
312 if (row.width <= columns) return { ...row, wrap: false, spacer: row.pills.some(p => p.id === 'cost') && row.width + 1 <= columns }
313 }
314 return { ...build(5), wrap: true, spacer: false }
315}
316types/index.d.ts 18 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2export type Tokens = { input: number; output: number; cache: number }
3/** The live context reading: the last response's input tokens, its window, and the engine's percent when given. */
4export type ContextUsage = { tokens: number; window: number; percent?: number }
5
6declare module 'claude-code' {
7 interface PluginState {
8 'usage-band': {
9 limits: Limit[]
10 cost: number | null
11 tokens: Tokens | null
12 /** Live context window reading; null before the first response fills it. */
13 context: Shaped<ContextUsage | null>
14 now: number
15 }
16 }
17}
18