SLOPSHOPPER

quiet-token-bar

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…

newbandcommandtoast
v1.1.0MITupdated 2026-10-07arthurglaizal/quiet-token-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · quiet-token-bar
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /quiet-token-bar ⎿ quiet-token-bar: Context bar hidden. /quiet-token-bar shows it again ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<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%">

Quiet Token Bar

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.

What it shows

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:

  • The bar: what fills the window (system prompt, tools, MCP tools, memory files, skills, messages), then free space and the compaction buffer. Each category has its own pattern, so it reads without color.
  • The fill: tokens used against the real limit, the point where auto-compaction runs, and the matching percentage.
  • Compact: runs /compact for you, so the compaction shows in the conversation as if you had typed it.
  • Limits: your 5-hour session and weekly usage, in that order. Hover for the full label and the reset time. A plan without a session window shows the weekly one alone.
  • Details: the chevron unfolds a legend with each category's tokens and share.
  • Close: hides the bar. Your choice is kept across sessions.

Everything is grey until a figure reaches 70% (orange), then 90% (red).

How to use

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).

ActionHow
Show or hide the bar/quiet-token-bar
Compact nowthe compact button after the percentage
See the detailsthe chevron
See what a segment or a limit ishover it

Limitations

  • Mods are a recent Claude Code feature: you need Claude Code 2.1.287 or later, and an update can change the mod API.
  • Session and weekly limits appear only when you are signed in with a Claude subscription. They are known after the first response; until then the bar shows the last values seen, if they have not reset.
  • If Claude is answering, the compact button's /compact waits until the answer is done.
  • On desktop, widths are estimated from terminal columns, so spacing can shift slightly with the window size.

Install in Claude Code

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

Repository structure

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 ..

Credits

Built on context-bar by Hamza Zafar (MIT), redesigned around a quieter, single-line layout.

Support

If you find my work useful, you can buy me a coffee ☕️

License

MIT — see LICENSE.

Source 2 files
hooks/register.tsx 619 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
619
types/index.d.ts 29 lines
1export 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