Your context window as one quiet line above the prompt: a patterned bar, the fill against the compaction point, a compact button and your session and weekly…

<img src="https://github.com/arthurglaizal/quiet-token-bar/releases/download/v1.1.0/quiet-token-bar.gif" alt="Quiet Token Bar demo: the context bar above the Claude Code prompt" width="100%">
Your context window in one quiet line, grey until it matters.
A Claude Code mod that sits above the prompt and shows how full your context is, your session and weekly limits, and a one-click compact button.
It stays neutral by default. A figure only turns orange, then red, when it gets close to its limit.
The same single line comes in two designs.
Desktop app: the design in the demo above, and the reference. Small grey text, real stripes and dots drawn as SVG, tooltips on hover.
Terminal: the same line drawn with characters, at the terminal's own text size. It looks like this:
Context ▆▆⠿⠿⠿⠿────────────▃ 491k of 967k 51% ≍ │ limit 9% 4% ∨ ×
Tooltips are less reliable in a terminal, so nothing essential depends on them.
From left to right:
/compact for you, so the compaction shows in the conversation as if you had typed it.Everything is grey until a figure reaches 70% (orange), then 90% (red).
Once installed, the bar appears above the prompt in every new session. It refreshes after each turn and after a compaction, from the same breakdown /context draws, estimated locally (no extra API calls).
| Action | How |
|---|---|
| Show or hide the bar | /quiet-token-bar |
| Compact now | the compact button after the percentage |
| See the details | the chevron |
| See what a segment or a limit is | hover it |
/compact waits until the answer is done.This repository is its own plugin marketplace.
claude plugin marketplace add arthurglaizal/quiet-token-bar
claude plugin install quiet-token-bar@arturo-mods
Or, in a Claude Code session:
/plugin marketplace add arthurglaizal/quiet-token-bar
/plugin install quiet-token-bar@arturo-mods
Start a new session and the bar appears above the prompt.
This mod was called Compact Token Bar before 1.1.0. If you installed it under that name, remove it first with claude plugin uninstall compact-token-bar@arturo-mods, then run claude plugin marketplace update arturo-mods and install it as above.
To try it without installing, clone the repository and run:
claude --plugin-dir ./quiet-token-bar
quiet-token-bar/
├── README.md
├── LICENSE
├── .claude-plugin/
│ ├── plugin.json
│ └── marketplace.json
├── hooks/
│ ├── hooks.json
│ └── register.tsx
├── types/index.d.ts
└── tests/quiet-token-bar.test.tsx
Run the checks with claude plugin validate . and claude plugin test ..
Built on context-bar by Hamza Zafar (MIT), redesigned around a quieter, single-line layout.
If you find my work useful, you can buy me a coffee ☕️
MIT — see LICENSE.
hooks/register.tsx 619 lines1// Quiet Token Bar: what is filling my context window?
2// Fork of context-bar (hamzafer/claude-code-mods, MIT). Above the prompt, one quiet line: the title, the
3// window as a stacked bar in a neutral slate ramp, the fill against the compaction point, a compact
4// button, the session and weekly limits, then the details chevron and a close cross. Everything stays
5// grey until a figure gets warm (orange from 70%, red from 90%). On the desktop the pieces are small
6// SVGs (striped categories, dotted messages); in a terminal, glyphs. /quiet-token-bar shows or hides it.
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register } from 'claude-code'
9
10import type { Reading, Slice } from '../types'
11
12const MIN_WIDTH = 20 // narrower than this, the bar is not drawn
13const SPLIT = ' '
14// One slate ramp, dark to light, spread over the used categories; the texture keeps neighbours apart.
15const RAMP = ['#4c5568', '#868fa0'] as const
16// Lower blocks, so the bar is a little shorter than a line of text and has no gaps between neighbours.
17// Used rows are solid, messages (the row that grows) are quiet dots, the buffer a low band.
18const USED = '▆'
19const MESSAGES = { color: '#5d6370', glyph: '⠿', ground: '#26282c' } as const // small dots in quincunx on a barely lighter ground
20const FREE = '#808080' // a mid grey thin line reads as empty on dark and light themes alike
21const BUFFER = '#808080'
22const GLYPH = { free: '─', buffer: '▃' } as const
23// The windows, session first then week; the line shows their figures alone and the hover names them.
24const LIMITS = { five_hour: { label: 'Session limit (5 h)' }, seven_day: { label: 'Weekly limit (7 days)' } } as const
25const ORANGE = '#e08a3c'
26const BAND_CONTROL = 4 // the terminal's own `[-]` at the end of the band's first row, and a space before it
27const TERMINAL_COMPACT = '≍' // two curves pinched toward each other: the terminal's compact button
28const CHEVRON = { closed: '∨', open: '∧' } as const // thin chevrons, the same weight as the close cross: down to open, up to fold
29
30// Held by the host, so the bar survives a hot reload of this file.
31const reading = atom({ plugin: 'quiet-token-bar', key: 'reading' } as const, null as Reading | null)
32const isHidden = atom({ plugin: 'quiet-token-bar', key: 'isHidden' } as const, false)
33const isExpanded = atom({ plugin: 'quiet-token-bar', key: 'isExpanded' } as const, false)
34const isCompacting = atom({ plugin: 'quiet-token-bar', key: 'isCompacting' } as const, false) // while the compact button's compaction runs
35
36export const register: Register = on => {
37 on('session.start', async ($, e, next) => {
38 const r = await next(e)
39 await $.command.register({ name: 'quiet-token-bar', description: 'Show or hide the context window bar above the prompt' }).catch(() => {}) // a name Claude Code already has is refused: start anyway
40 const hidden = (await $.store.get('isHidden').catch(() => undefined)) === true
41 await update($, isHidden, () => hidden)
42 void refresh($).catch(() => {})
43 return r
44 })
45
46 on('turn.complete', async ($, e, next) => {
47 const r = await next(e)
48 if (!e.agentId) await refresh($).catch(() => {}) // a subagent's turn fills its own window, not this one
49 return r
50 })
51
52 on('session.compact', async ($, e, next) => {
53 const r = await next(e)
54 if (!e.agentId && 'messages' in r) void refresh($).catch(() => {}) // a /compact empties the window without a turn ending
55 return r
56 })
57
58 // The limits move with every response, between the turns this plugin refreshes on: follow them here.
59 on('session.measure', async ($, e, next) => {
60 const r = await next(e)
61 if (e.changed.includes('rateLimits')) {
62 const limits = toLimits(e.rateLimits)
63 await update($, reading, cur => (cur ? { ...cur, limits } : cur)).catch(() => {})
64 if (limits.length) await $.store.set('limits', limits).catch(() => {})
65 }
66 return r
67 })
68
69 on('command.run', { command: 'quiet-token-bar' }, async $ => {
70 const hidden = await update($, isHidden, h => !h)
71 await $.store.set('isHidden', hidden).catch(() => {})
72 if (!hidden) await refresh($).catch(() => {})
73 return { text: hidden ? 'Context bar hidden. /quiet-token-bar shows it again' : 'Context bar on' }
74 })
75
76 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
77 const rest = await next(e) // what other mods and Claude Code draw here stays
78 const r = await read($, reading)
79 if (e.props.hasSurvey || (await read($, isHidden)) || !r) return rest
80 const { Box, Text, Button } = $.ui.resolve(e)
81 const inner = e.props.bodyColumns - 2 // no border: the padding takes 2 cells
82 if (inner < MIN_WIDTH) return rest
83 const open = await read($, isExpanded)
84 const compacting = await read($, isCompacting)
85
86 const { head, percent, level } = fill(r)
87 const heat = level >= 0.9 ? 'red' : level >= 0.7 ? ORANGE : undefined // grey until it gets warm
88 const now = await $.clock.now()
89 // A segment grows in proportion to its cells and the glyphs are cut to fit: a font whose glyphs are not
90 // one cell wide (the desktop) then never wraps the bar onto a second line.
91 let at = 0 // the column a segment starts at, to anchor its label on the side with room
92 // The terminal draws the bar in glyphs; the surfaces that have a vector element draw the header and the
93 // bar as one SVG, with small text and real stripes and dots.
94 const Svg = (e.surface === 'terminal' ? undefined : ($.ui.resolve(e) as any).Svg) as ((props: { source: string; alt: string }) => any) | undefined
95 if (Svg) {
96 // One flex row, so nothing overlaps: the title and the bar, then the figures, the compact button, the
97 // limits and the two card buttons, each a box of its own. Only the bar's width is estimated.
98 const drawn = Math.round((inner - 6) * PX_PER_COLUMN) // the chevron and the cross take the last six cells
99 const { context, limits } = figures(r, compacting)
100 const contextWidth = Math.ceil(spanWidth(context)) + 4
101 const limitsWidth = limits.length ? Math.ceil(spanWidth(limits)) + 4 : 0
102 // A little wider than the estimate of the room: the row shrinks it to fit rather than leave a gap after it,
103 // so the bar always ends exactly one GAP before the figures.
104 const rowWidth = Math.max(160, drawn - contextWidth - limitsWidth - Math.round(4 * PX_PER_COLUMN) + 32)
105 const barX = titleWidth()
106 const barPx = Math.max(20, rowWidth - barX) // the bar's width in the drawing, as barRowSvg draws it
107 let from = 0
108 // A button of the card: a stroked icon in the text's grey, centered in its own box, an empty button over
109 // the whole box so the hover area sits right around the icon, and a short tooltip.
110 const iconButton = (b: { key: string; icon: string; tip: string; press: () => void }) => (
111 <Box key={`${b.key}-box`} width={2} height={1} justifyContent="center" alignItems="center">
112 <Svg source={b.icon} alt={b.tip} width={12} height={12} />
113 <Box position="absolute" top={0} left={0} width={2} height={1}>
114 <Button key={b.key} plain onPress={b.press}>
115 {'\u00A0\u00A0'}
116 </Button>
117 </Box>
118 {/* Centered over the button: a box as wide as the tip, its middle on the button's middle, the text
119 centered in it so a proportional font lands in the middle too. */}
120 <Box position="absolute" top={1} left={1 - Math.ceil((b.tip.length + 2) / 2)} width={b.tip.length + 2} justifyContent="center" display="none" hover={{ display: 'flex' }}>
121 <Text color={TIP.name}>{b.tip}</Text>
122 </Box>
123 </Box>
124 )
125 return (
126 <Box flexDirection="column">
127 <Box flexDirection="column" paddingX={1}>
128 <Box flexDirection="row" alignItems="center">
129 <Box flexShrink={1}>
130 <Svg source={barRowSvg(r, rowWidth)} alt={barAlt(r)} />
131 {/* A picture has no hover of its own: empty keyed boxes lie over the bar and reveal the labels.
132 They span the picture as drawn and share it in the drawing's own proportions (the title, then
133 each segment), so they stay on their segment even when the row shrinks the picture. */}
134 <Box position="absolute" top={0} left={0} right={0} flexDirection="row" height={1}>
135 <Box width={0} flexGrow={barX} height={1} />
136 {cells(r, 1000).map((c, i) => {
137 const onRight = from + c.text.length / 2 > 500
138 from += c.text.length
139 return (
140 <Box key={`hit-${i}`} width={0} flexGrow={(c.text.length * barPx) / 1000} height={1}>
141 <Box width={0} flexGrow={1} height={1} overflow="hidden">
142 <Text wrap="wrap">{'\u00A0'.repeat(200)}</Text>
143 </Box>
144 {/* A classic tooltip, floated above the line by the app: the name, tokens and share in soft
145 greys. No pattern: the app draws no picture in a floating tooltip. */}
146 <Box position="absolute" top={1} {...(onRight ? { right: 0 } : { left: 0 })} display="none" hover={{ display: 'flex' }}>
147 <Text wrap="truncate-end">
148 <Text color={TIP.name}>{`${c.slice.name} `}</Text>
149 <Text color={TIP.figure} bold>{tokens(c.slice.tokens)}</Text>
150 <Text color={TIP.dim}>{` ${share(c.slice.tokens, r.window)}`}</Text>
151 </Text>
152 </Box>
153 </Box>
154 )
155 })}
156 </Box>
157 </Box>
158 <Box flexGrow={1} />
159 {/* Left-aligned after exactly one GAP: the space from the bar to the figures is the space between them. */}
160 <Svg source={spansSvg(context, contextWidth + GAP, GAP)} alt={context.map(x => x.text).join(' ')} />
161 <Box marginLeft={1}>
162 {iconButton({ key: 'compact', icon: icon('compact'), tip: 'Compact the conversation now', press: () => void compactNow($) })}
163 </Box>
164 {limits.length > 0 && (
165 <Box key="limits" marginLeft={1}>
166 <Svg source={spansSvg(limits, limitsWidth)} alt={limits.map(x => x.text).join(' ')} />
167 <Box position="absolute" top={1} right={0} display="none" hover={{ display: 'flex' }}>
168 <Text wrap="truncate-end">
169 {r.limits.map((l, i) => (
170 <Text>
171 {i > 0 && <Text color={TIP.dim}>{' '}</Text>}
172 <Text color={TIP.name}>{`${LIMITS[l.kind].label} `}</Text>
173 <Text color={TIP.figure} bold>{`${l.percent}%`}</Text>
174 <Text color={TIP.dim}>{`${resets(l.resetsAt, now)}`}</Text>
175 </Text>
176 ))}
177 </Text>
178 </Box>
179 </Box>
180 )}
181 <Box marginLeft={1}>
182 {iconButton({ key: 'toggle-legend', icon: icon(open ? 'up' : 'down'), tip: open ? 'Hide details' : 'Show details', press: () => void update($, isExpanded, v => !v) })}
183 </Box>
184 <Box marginLeft={1}>
185 {iconButton({ key: 'close', icon: icon('close'), tip: 'Close (/quiet-token-bar brings it back)', press: () => void hide($) })}
186 </Box>
187 </Box>
188 {open && <Svg source={legendSvg(r, drawn)} alt={legendAlt(r)} />}
189 </Box>
190 {rest}
191 </Box>
192 )
193 }
194 // The terminal: the same single line, in glyphs. Every width is known to the cell here, so the bar takes
195 // exactly the room the rest leaves, and every gap is two cells.
196 const pctText = compacting ? 'compacting…' : heat ? ` ${percent}% ` : `${percent}%`
197 const limitTexts = r.limits.map(l => `${l.percent}%`) // bare figures, as in the app
198 const fixed =
199 'Context'.length + 2 + (2 + head.length) + (2 + pctText.length) + (2 + 1) +
200 (r.limits.length ? 2 + '│ limit'.length + limitTexts.reduce((n, t) => n + 2 + t.length, 0) : 0) + (2 + 1) + (2 + 1)
201 // The terminal draws its own `[-]` (fold the band) at the end of the first row: leave it its four cells.
202 const lineBar = Math.max(4, inner - fixed - BAND_CONTROL)
203 return (
204 <Box flexDirection="column">
205 <Box flexDirection="column" paddingX={1}>
206 <Box flexDirection="row">
207 <Text dimColor>Context</Text>
208 <Box marginLeft={2} width={lineBar} flexDirection="row">
209 {cells(r, lineBar).map((c, i) => {
210 const onRight = at + c.text.length / 2 > lineBar / 2
211 at += c.text.length
212 return (
213 <Box key={`seg-${i}`} width={0} flexGrow={c.text.length} height={1}>
214 {/* the glyphs overshoot and are clipped, so no ellipsis ends a short segment */}
215 <Box width={0} flexGrow={1} height={1} overflow="hidden">
216 <Text color={c.color} wrap="wrap">{c.text.repeat(3)}</Text>
217 </Box>
218 <Box position="absolute" top={1} {...(onRight ? { right: 0 } : { left: 0 })} display="none" hover={{ display: 'flex' }}>
219 <Text wrap="truncate-end">
220 <Text color={TIP.name}>{`${c.slice.name} `}</Text>
221 <Text color={TIP.figure} bold>{tokens(c.slice.tokens)}</Text>
222 <Text color={TIP.dim}>{` ${share(c.slice.tokens, r.window)}`}</Text>
223 </Text>
224 </Box>
225 </Box>
226 )
227 })}
228 </Box>
229 <Box marginLeft={2}>
230 <Text dimColor>{head}</Text>
231 </Box>
232 <Box marginLeft={2}>
233 {heat && !compacting ? <Text bold color="black" backgroundColor={heat}>{pctText}</Text> : <Text dimColor>{pctText}</Text>}
234 </Box>
235 <Box marginLeft={2}>
236 <Button key="compact" plain dimColor onPress={() => void compactNow($)}>
237 {TERMINAL_COMPACT}
238 </Button>
239 </Box>
240 {r.limits.length > 0 && (
241 <Box marginLeft={2}>
242 <Text dimColor>│ limit</Text>
243 </Box>
244 )}
245 {r.limits.map(l => {
246 const hot = l.percent >= 90 ? 'red' : l.percent >= 70 ? ORANGE : undefined
247 return (
248 <Box key={`limit-${l.kind}`} marginLeft={2}>
249 <Text dimColor={!hot} color={hot} bold={!!hot}>{`${l.percent}%`}</Text>
250 <Box position="absolute" top={1} right={0} display="none" hover={{ display: 'flex' }}>
251 <Text wrap="truncate-end">
252 <Text color={TIP.name}>{`${LIMITS[l.kind].label} `}</Text>
253 <Text color={TIP.figure} bold>{`${l.percent}%`}</Text>
254 <Text color={TIP.dim}>{resets(l.resetsAt, now)}</Text>
255 </Text>
256 </Box>
257 </Box>
258 )
259 })}
260 <Box marginLeft={2}>
261 <Button key="toggle-legend" plain dimColor onPress={() => void update($, isExpanded, v => !v)}>
262 {open ? CHEVRON.open : CHEVRON.closed}
263 </Button>
264 </Box>
265 <Box marginLeft={2}>
266 <Button key="close" plain dimColor onPress={() => void hide($)}>
267 ×
268 </Button>
269 </Box>
270 </Box>
271 {open &&
272 legend(r, inner).map(line => (
273 <Text wrap="truncate-end">
274 {line.map((s, i) => (
275 <Text>
276 {i > 0 && <Text>{SPLIT}</Text>}
277 <Text color={s.color}>{`${s.glyph} `}</Text>
278 <Text dimColor={s.kind !== 'used'}>{`${s.name} `}</Text>
279 <Text bold={s.kind === 'used'}>{tokens(s.tokens)}</Text>
280 {s.kind === 'used' && <Text dimColor>{` ${share(s.tokens, r.window)}`}</Text>}
281 </Text>
282 ))}
283 </Text>
284 ))}
285 </Box>
286 {rest}
287 </Box>
288 )
289 })
290}
291
292// The desktop card, in CSS pixels: the engine gives columns, not pixels, so the drawing is sized at an
293// estimate of a column's width; a drawing wider than its slot is scaled down to fit.
294const PX_PER_COLUMN = 8.4
295const FONT = 11
296const TITLE_FONT = 13 // the title a little larger than the figures
297const ROW = 20 // the height of the card's line
298const BAR_HEIGHT = 15 // a multiple of the dots' step, so no row of dots is cut
299const DOT_STEP = 8 // dots in quincunx: one high, one low, every 8 px
300const GAP = 12 // the space between the bar and the figures, and between the figures
301const CHAR_WIDTH = 6.1 // an estimate of a character's width at FONT, to leave the figures their room
302const TEXT = '#8b8b90'
303const SWATCH = { width: 24, height: BAR_HEIGHT } // a strip of the bar at its own scale, for the tooltips
304const TIP = { name: '#b9b9be', figure: '#e2e2e6', dim: '#7d7d83' } // the tooltips' soft greys, on the app's dark card
305const FONTS = "-apple-system, 'SF Pro Text', system-ui, sans-serif"
306
307// "#rrggbb" mixed with the dark of the card, t in 0..1 of the way to it.
308function darken(hex: string, t: number) {
309 const dark = [0x1c, 0x1c, 0x1e]
310 return `#${[1, 3, 5].map((k, i) => Math.round(parseInt(hex.slice(k, k + 2), 16) * (1 - t) + dark[i]! * t).toString(16).padStart(2, '0')).join('')}`
311}
312
313const esc = (t: string) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
314
315export function barAlt(r: Reading) {
316 return `Context window: ${tokens(r.total)} of ${tokens(r.window)} used. ${r.slices.filter(s => s.kind === 'used').map(s => `${s.name} ${share(s.tokens, r.window)}`).join(', ')}`
317}
318
319// The fill a slice is drawn with, as a <pattern> under `id`: each used category stripes at its own angle,
320// messages dots, the compaction buffer a hatch. The bar and the legend share it, so they always match.
321export function pattern(r: Reading, s: Slice, id: string, y = 0) {
322 if (s.kind === 'free') return { def: '', fill: FREE }
323 if (s.kind === 'buffer') {
324 return { def: `<pattern id="${id}" width="4" height="4" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="1.2" height="4" fill="${BUFFER}"/></pattern>`, fill: `url(#${id})` }
325 }
326 if (s.name === 'messages') {
327 // Dots in quincunx; the tile starts where the shape does and is as tall as the bar, so no dot is cut.
328 return { def: `<pattern id="${id}" x="0" y="${y}" width="${DOT_STEP}" height="${BAR_HEIGHT}" patternUnits="userSpaceOnUse"><rect width="${DOT_STEP}" height="${BAR_HEIGHT}" fill="${MESSAGES.ground}"/><circle cx="${DOT_STEP / 4}" cy="4" r="0.9" fill="${MESSAGES.color}"/><circle cx="${(DOT_STEP * 3) / 4}" cy="11" r="0.9" fill="${MESSAGES.color}"/></pattern>`, fill: `url(#${id})` }
329 }
330 const angles = [45, 135, 0, 90]
331 const k = r.slices.filter(x => x.kind === 'used' && x.name !== 'messages').indexOf(s)
332 return { def: `<pattern id="${id}" width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(${angles[Math.max(0, k) % angles.length]})"><rect width="5" height="5" fill="${darken(s.color, 0.45)}"/><rect width="2.5" height="5" fill="${s.color}"/></pattern>`, fill: `url(#${id})` }
333}
334
335// The bar alone, `width` by `height`, at (left, y).
336function barParts(r: Reading, width: number, y: number, height: number, left = 0) {
337 const defs: string[] = []
338 const parts: string[] = []
339 let at = 0
340 cells(r, 1000).forEach((c, i) => {
341 const x = left + (at * width) / 1000
342 const w = (c.text.length * width) / 1000
343 at += c.text.length
344 const tip = `<title>${esc(`${c.slice.name} · ${tokens(c.slice.tokens)} · ${share(c.slice.tokens, r.window)}`)}</title>`
345 if (c.kind === 'free') {
346 parts.push(`<g>${tip}<rect x="${x}" y="${y + height / 2 - 0.5}" width="${w}" height="1" fill="${FREE}"/></g>`)
347 return
348 }
349 const p = pattern(r, c.slice, `p${i}`, y)
350 defs.push(p.def)
351 parts.push(`<g>${tip}<rect x="${x}" y="${y}" width="${w}" height="${height}" fill="${p.fill}"/></g>`)
352 })
353 return { defs: defs.join(''), parts: parts.join('') }
354}
355
356// How full the window is, against the real limit: the point where auto-compaction runs, or the whole
357// window when it is off. "371k of 967k", 38%.
358export function fill(r: Reading) {
359 const limit = r.compactsAt ?? r.window
360 const level = r.total / limit
361 return { head: `${tokens(r.total)} of ${tokens(limit)}`, percent: Math.round(level * 100), level }
362}
363
364// The card's button icons, 12 px, stroked in the text's grey: the chevron of the person's own drawing
365// (a 24 grid, scaled by half) pointing down to open and up to fold, and a cross of the same stroke.
366export function icon(kind: 'down' | 'up' | 'close' | 'compact') {
367 // compact: two chevrons closing on a line, the conversation folded down.
368 const d = { down: 'm4 9l8 8l8-8', up: 'm4 15l8-8l8 8', close: 'M6 6l12 12M18 6L6 18', compact: 'M7 3l5 5l5-5M7 21l5-5l5 5M4 12h16' }[kind]
369 return `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24"><path d="${d}" fill="none" stroke="${TEXT}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`
370}
371
372// The figures of the line, as spans with the gap before each: the context's fill, then the limits.
373export function figures(r: Reading, compacting = false) {
374 const { head, percent, level } = fill(r)
375 const heat = (v: number) => (v >= 0.9 ? '#e5534b' : v >= 0.7 ? ORANGE : undefined)
376 const context: Span[] = [
377 { text: head, gap: 0 },
378 compacting ? { text: 'compacting…', gap: GAP } : { text: `${percent}%`, gap: GAP, color: heat(level) }, // the button's work, shown where its result will land
379 ]
380 const limits: Span[] = r.limits.length === 0 ? [] : [
381 { text: '│', gap: 0 },
382 { text: 'limit', gap: 10 },
383 ...r.limits.map(l => ({ text: `${l.percent}%`, gap: 10, color: heat(l.percent / 100) })), // which is which: the hover says
384 ]
385 return { context, limits }
386}
387
388type Span = { text: string; gap: number; color?: string }
389
390export const spanWidth = (ss: Span[]) => ss.reduce((w, x) => w + x.gap + x.text.length * CHAR_WIDTH, 0)
391
392const titleWidth = () => Math.round('Context'.length * CHAR_WIDTH * (TITLE_FONT / FONT) + 14)
393
394const svgOpen = (width: number, height: number) =>
395 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" font-family="${FONTS}" font-size="${FONT}">`
396
397// Spans of small grey text in a box `width` wide. Right-aligned by default: any slack in the estimate becomes
398// space on the left, never an overlap. Left-aligned from `lead` when the space before the text must be exact.
399export function spansSvg(spans: Span[], width: number, lead?: number) {
400 const body = spans
401 .map(x => `<tspan${x.gap ? ` dx="${x.gap}"` : ''} fill="${x.color ?? TEXT}"${x.color ? ' font-weight="600"' : ''}>${esc(x.text)}</tspan>`)
402 .join('')
403 const at = lead === undefined ? `x="${width}" text-anchor="end"` : `x="${lead}"`
404 return `${svgOpen(width, ROW)}<text ${at} y="14" xml:space="preserve">${body}</text></svg>`
405}
406
407// The title and the bar, `width` wide: "Context", then the bar in the rest of the room.
408export function barRowSvg(r: Reading, width: number) {
409 const barX = titleWidth()
410 const { defs, parts } = barParts(r, Math.max(20, width - barX), (ROW - BAR_HEIGHT) / 2, BAR_HEIGHT, barX)
411 return `${svgOpen(width, ROW)}<defs>${defs}</defs><text x="0" y="14" fill="${TEXT}" font-size="${TITLE_FONT}">Context</text>${parts}</svg>`
412}
413
414// The legend alone, `width` wide, its swatches drawn with the bar's own patterns.
415export function legendSvg(r: Reading, width: number) {
416 const { defs, parts, height } = legendParts(r, width, 6)
417 return `${svgOpen(width, height + 6)}<defs>${defs}</defs>${parts}</svg>`
418}
419
420export function legendAlt(r: Reading) {
421 return r.slices.map(s => `${s.name} ${tokens(s.tokens)}`).join(', ')
422}
423
424const LEGEND_LINE = 21 // a swatch as tall as the bar, and a little air
425const CHAR = 6.4 // an estimate of a character's width at FONT, generous so items never overlap
426
427// The legend from `top`: each slice a swatch, its name, tokens and share, packed into lines of `width`.
428function legendParts(r: Reading, width: number, top: number) {
429 const defs: string[] = []
430 const parts: string[] = []
431 let x = 0
432 let line = 0
433 r.slices.forEach((s, i) => {
434 const words = `${s.name} ${tokens(s.tokens)}${s.kind === 'used' ? ` ${share(s.tokens, r.window)}` : ''}`
435 const w = SWATCH.width + 6 + words.length * CHAR
436 if (x > 0 && x + w > width) {
437 x = 0
438 line++
439 }
440 const y = top + line * LEGEND_LINE
441 if (s.kind === 'free') {
442 parts.push(`<rect x="${x}" y="${y + SWATCH.height / 2 - 0.5}" width="${SWATCH.width}" height="1" fill="${FREE}"/>`)
443 } else {
444 const p = pattern(r, s, `l${i}`, y)
445 defs.push(p.def)
446 parts.push(`<rect x="${x}" y="${y}" width="${SWATCH.width}" height="${SWATCH.height}" fill="${p.fill}"/>`) // the same strip as the tooltips'
447 }
448 const isUsed = s.kind === 'used'
449 parts.push(
450 `<text x="${x + SWATCH.width + 6}" y="${y + 11}" xml:space="preserve">` +
451 `<tspan fill="${isUsed ? '#c4c4c9' : TEXT}">${esc(s.name)} </tspan>` +
452 `<tspan fill="${isUsed ? '#e8e8ea' : TEXT}"${isUsed ? ' font-weight="600"' : ''}>${tokens(s.tokens)}</tspan>` +
453 (isUsed ? `<tspan fill="${TEXT}"> ${esc(share(s.tokens, r.window))}</tspan>` : '') + // escaped: "<0.1%" would break the markup
454 `</text>`,
455 )
456 x += w + 18
457 })
458 return { defs: defs.join(''), parts: parts.join(''), height: 8 + (line + 1) * LEGEND_LINE - 6 }
459}
460
461// The compact button: runs /compact as if the person typed it, so the compaction shows in the conversation
462// like any /compact (queued until Claude is idle). The line says "compacting…" until it is done.
463async function compactNow($: EngineInterface) {
464 if (await read($, isCompacting)) return // one at a time
465 await update($, isCompacting, () => true)
466 try {
467 await $.command.run({ command: 'compact', args: '' })
468 } catch {
469 try {
470 void $.ui.toast('Could not run /compact: type it in the prompt instead')
471 } catch {}
472 } finally {
473 await update($, isCompacting, () => false).catch(() => {})
474 await refresh($).catch(() => {})
475 }
476}
477
478// The close button: hides the bar as /quiet-token-bar does, and keeps the choice.
479async function hide($: EngineInterface) {
480 await update($, isHidden, () => true)
481 await $.store.set('isHidden', true).catch(() => {})
482}
483
484// Asks the engine for /context's breakdown, estimated locally (no token-count calls).
485async function refresh($: EngineInterface) {
486 if (await read($, isHidden)) return
487 const usage = await $.session.usage({ breakdown: 'summary' })
488 const b = usage.context.breakdown
489 if (!b || !(b.rawMaxTokens > 0)) return // no window to measure against
490 const fresh = toReading(b, usage.rateLimits)
491 if (fresh.limits.length) await $.store.set('limits', fresh.limits).catch(() => {})
492 else fresh.limits = await lastLimits($) // before this session's first response: the last ones seen, still valid
493 await update($, reading, () => fresh)
494}
495
496// The limits are the account's, not the session's: the last ones seen (kept across sessions) still hold until
497// their window resets, so a new session shows them before its first response.
498async function lastLimits($: EngineInterface): Promise<Reading['limits']> {
499 const kept = (await $.store.get('limits').catch(() => undefined)) as Reading['limits'] | undefined
500 if (!Array.isArray(kept)) return []
501 const now = await $.clock.now()
502 return kept.filter(l => !l.resetsAt || Date.parse(l.resetsAt) > now)
503}
504
505export function toReading(b: {
506 categories: { name: string; tokens: number; color: string; kind: string }[]
507 totalTokens: number
508 rawMaxTokens: number
509 percentage: number
510 autoCompactThreshold?: number
511 isAutoCompactEnabled: boolean
512}, rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[] = []): Reading {
513 const slices: Slice[] = b.categories
514 .filter(c => c.kind !== 'deferred' && c.tokens > 0)
515 .map(c => ({ name: c.name.toLowerCase(), tokens: c.tokens, color: c.color, glyph: '', kind: c.kind as Slice['kind'] }))
516 const order = { used: 0, free: 1, buffer: 2 }
517 slices.sort((x, y) => order[x.kind] - order[y.kind]) // stable: used rows keep /context's order
518 const ramped = slices.filter(c => c.kind === 'used' && c.name !== 'messages').length
519 let next = 0
520 for (const s of slices) {
521 if (s.kind !== 'used') {
522 s.color = s.kind === 'free' ? FREE : BUFFER
523 s.glyph = GLYPH[s.kind]
524 } else if (s.name === 'messages') {
525 s.color = MESSAGES.color
526 s.glyph = MESSAGES.glyph
527 } else {
528 s.color = ramp(zigzag(next, ramped), ramped)
529 s.glyph = USED
530 next++
531 }
532 }
533 return {
534 slices,
535 total: b.totalTokens,
536 window: b.rawMaxTokens,
537 percent: b.percentage,
538 compactsAt: b.isAutoCompactEnabled ? b.autoCompactThreshold : undefined,
539 limits: toLimits(rateLimits),
540 }
541}
542
543// The session and weekly windows the account reports, session first; a plan without one shows the other alone.
544export function toLimits(rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]) {
545 return rateLimits
546 .filter((l): l is typeof l & { kind: keyof typeof LIMITS } => l.kind in LIMITS)
547 .sort((x, y) => (x.kind === 'five_hour' ? -1 : 1) - (y.kind === 'five_hour' ? -1 : 1))
548 .map(l => ({ kind: l.kind, percent: Math.round(l.percentUsed), resetsAt: l.resetsAt }))
549}
550
551// The order the ramp is walked in: dark, light, a bit less dark, a bit less light... so neighbours differ.
552export function zigzag(k: number, n: number) {
553 return k % 2 === 0 ? k / 2 : Math.ceil(n / 2) + (k - 1) / 2
554}
555
556// The i-th of n colors along the slate ramp.
557export function ramp(i: number, n: number) {
558 const t = n <= 1 ? 0.5 : i / (n - 1)
559 const [a, b] = RAMP.map(c => [1, 3, 5].map(k => parseInt(c.slice(k, k + 2), 16)))
560 return `#${a!.map((v, k) => Math.round(v + (b![k]! - v) * t).toString(16).padStart(2, '0')).join('')}`
561}
562
563// " · resets in 2 h 10" for a window's reset time, nothing when it is unknown or past.
564export function resets(at: string | undefined, now: number) {
565 const ms = at ? Date.parse(at) - now : NaN
566 if (!(ms > 0)) return ''
567 const min = Math.round(ms / 60_000)
568 const d = Math.floor(min / 1440)
569 const h = Math.floor((min % 1440) / 60)
570 return ` · resets in ${d > 0 ? `${d} d ${h} h` : h > 0 ? `${h} h ${String(min % 60).padStart(2, '0')}` : `${min} min`}`
571}
572
573// The bar as runs of cells: each slice gets its share of `width`, a used one at least one cell.
574export function cells(r: Reading, width: number) {
575 const sizes = r.slices.map(s => Math.max(s.kind === 'used' ? 1 : 0, Math.round((s.tokens / r.window) * width)))
576 // Rounding leaves the sum a few cells off: free space takes the difference first, then the largest slices.
577 let diff = width - sizes.reduce((a, n) => a + n, 0)
578 const isUsed = (i: number) => r.slices[i]!.kind === 'used'
579 const order = r.slices.map((_, i) => i).sort((a, b) => Number(isUsed(a)) - Number(isUsed(b)) || sizes[b]! - sizes[a]!)
580 for (const i of order) {
581 if (diff === 0) break
582 const size = Math.max(isUsed(i) ? 1 : 0, sizes[i]! + diff)
583 diff -= size - sizes[i]!
584 sizes[i] = size
585 }
586 return r.slices.map((s, i) => ({ color: s.color, kind: s.kind, slice: s, text: s.glyph.repeat(sizes[i]!) })).filter(c => c.text !== '')
587}
588
589// The legend, packed into lines no wider than `width`.
590export function legend(r: Reading, width: number) {
591 const lines: Slice[][] = [[]]
592 let used = 0
593 for (const s of r.slices) {
594 const size = 2 + s.name.length + 1 + tokens(s.tokens).length + (s.kind === 'used' ? 1 + share(s.tokens, r.window).length : 0)
595 const line = lines.at(-1)!
596 if (line.length > 0 && used + SPLIT.length + size > width) {
597 lines.push([s])
598 used = size
599 } else {
600 used += (line.length > 0 ? SPLIT.length : 0) + size
601 line.push(s)
602 }
603 }
604 return lines.filter(l => l.length > 0)
605}
606
607export function tokens(n: number) {
608 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`
609 if (n >= 10_000) return `${Math.round(n / 1000)}k`
610 if (n >= 1000) return `${+(n / 1000).toFixed(1)}k`
611 return String(n)
612}
613
614export function share(n: number, window: number) {
615 const p = (n / window) * 100
616 if (p > 0 && p < 0.1) return '<0.1%'
617 return `${p >= 10 ? Math.round(p) : +p.toFixed(1)}%`
618}
619types/index.d.ts 29 lines1export type Slice = {
2 name: string
3 tokens: number
4 color: string
5 glyph: string // what the slice is drawn with
6 kind: 'used' | 'free' | 'buffer'
7}
8
9export type Limit = {
10 kind: 'five_hour' | 'seven_day' // the session window and the weekly one
11 percent: number // used, 0 to 100
12 resetsAt?: string // ISO 8601
13}
14
15export type Reading = {
16 slices: Slice[]
17 total: number // tokens in use
18 window: number // the window measured against
19 percent: number
20 compactsAt?: number // where auto-compaction runs, when it is on
21 limits: Limit[] // the rate-limit windows the last response reported; empty off a subscription
22}
23
24declare module 'claude-code' {
25 interface PluginState {
26 'quiet-token-bar': { reading: Reading | null; isHidden: boolean; isExpanded: boolean; isCompacting: boolean }
27 }
28}
29