SLOPSHOPPER

context-bar

Live, colour-segmented context window bar above the prompt, split into ACTIVE and PASSIVE halves with an auto-compact marker.

newbandrowscommandtoaststatus
v0.1.0MITupdated 2026-10-06mrjk05/modemon/mods/context-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-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 › /context-bar ⎿ context-bar: Context: 97k / 200k · 49% · active. ◆ context 97k / 200k · 49% · active █████████████████████████████████████████████████░┃░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ACTIVE ┃ PASSIVE ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◆ context 97k / 200k · 49% · active █████████████████████████████████████████████████░┃░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ACTIVE ┃ PASSIVE
README

context-bar

A live view of the context window in the band above the prompt. The bar is split into colour-coded segments by category (system prompt, tools, memory files, skills, agents, messages, free space, the auto-compact buffer), using the same colours as /context. A divider at 50% separates the ACTIVE half from the PASSIVE half, and a second mark shows where auto-compaction will run.

◆ context  142k / 200k · 71% · passive                       split estimated
████████████████████████████████████┃██████████████░░░░░░░░░╎▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
               ACTIVE               ┃              PASSIVE   ▲ auto-compact
█ System prompt 3k  █ System tools 12k  █ Memory files 2k  █ Messages 125k
░ Free space 25k  ▒ Autocompact buffer 33k
  • The headline (142k / 200k · 71%) is exact: it comes from the input tokens of the last API response. It turns green, then yellow at 50%, then red at 80%.
  • The category split is an estimate. It comes from $.session.usage({ breakdown: 'summary' }), which is computed locally and makes no network request.
  • Before the first response of a session, or right after a compaction, the headline shows the estimate (~17k / 200k · estimate, no response yet).
  • The bar fits the width of the band. It refreshes after every turn, after a compaction and after /clear.
  • The first time usage passes 50% in a session, a toast says: Past 50%, consider /compact or a fresh session. The toast is re-armed after /compact, an auto-compaction or /clear.
  • Bands narrower than 24 columns show a single line (ctx 142k / 200k · 71% · passive). Short bands drop the legend first, then the label row.
  • The band is shared: other band mods (checklist, project-color, ...) draw beneath it, stacked in one column, so they all show together.

Surfaces

SurfaceBand above the prompt/context-bar cardStatus line
TerminalYesYesctx 71% while the band is hidden
Desktop (Code tab)YesYesctx 71% while the band is hidden
Claude mobile appNo, the app does not raise itYes, inline in the transcript🟡 ctx 71% · passive whenever a phone is attached
VS CodeNoYesSame as mobile
  • Mobile status line: while a phone (or any surface without the band) is attached, the status line shows the percentage with a zone dot: 🟢 under 50%, 🟡 from 50%, 🔴 from 80% (⚪ before the first response). It appears when the phone attaches and goes back to the plain form (or away) when it detaches. The status line is one entry for the session, so the terminal shows the same text while a phone is attached.
  • Context card: /context-bar draws a compact card in its output row: the headline, the segmented bar sized to the transcript's width (with the 50% divider and auto-compact mark), and the four largest categories with their share. It is the way to see the full picture on a phone.
◆ context  142k / 200k · 71% · passive
██████████████████████████┃████████░░░░░╎▒▒▒▒▒▒▒▒▒▒
         ACTIVE           ┃     PASSIVE   ▲ auto-compact
■ Messages 125k · 63%
■ System tools 12k · 6%
■ System prompt 3k · 2%
■ Memory files 2k · 1%
Category split is estimated.

Install

/plugin install context-bar --marketplace mrjk05/modemon

Answer y to add the marketplace, then pick a scope.

Commands

CommandWhat it does
/context-bar or /context-bar showDraws the context card inline (every surface, mobile included). The model reads a one-line summary of it.
/context-bar on / /context-bar offShows or hides the band above the prompt (terminal and desktop).
/context-bar toggleFlips the band.

When the band is hidden, the status line under the prompt shows ctx 71% instead.

Changed in this version: a bare /context-bar used to toggle the band. It now shows the card; use toggle for the old behaviour.

Config

Set these from the /config menu, or under pluginConfigs["context-bar"].options in settings.

FieldDefaultMeaning
legendtrueShow the per-category legend under the bar.
warnAtHalftrueShow a toast once per session when usage passes 50%.
statusLineautoWhen to pin ctx 71% under the prompt. auto pins it while the band is hidden or a surface without the band is attached (mobile, VS Code: 🟡 ctx 71% · passive), always pins it all the time, off never pins it.

Limitations

  • The category split is the engine's local estimate (the summary breakdown), so the segments may not add up exactly to the headline number.
  • The bar's scale is the compaction window that the breakdown reports. In the rare setups where that window is smaller than the model's window, the bar and the headline percentage measure against different totals.
  • A marker drawn on top of a segment covers that segment's cell, so a category that gets only one cell can be hidden by the 50% divider or the auto-compact mark.
  • On narrow bands the auto-compact label is shortened to a bare ▲.
  • Mobile has no band above the prompt, so there is no always-on bar there: the status line and the /context-bar card stand in for it. The card is drawn with coloured text blocks, not an SVG, so it looks the same on every surface; how evenly the blocks line up depends on the app's font.
  • The card shows the latest measurement whenever its row is redrawn, not a frozen copy from when the command ran.
  • Built against Claude Code 2.1.290. The mods API is in early access and may change between releases.
Source 3 files
hooks/register.tsx 291 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderNode } from 'claude-code'
3
4import {
5  WARNING,
6  barCells,
7  cardText,
8  formatTokens,
9  headlineRuns,
10  labelCells,
11  legendItems,
12  packLegend,
13  parseCommand,
14  richStatusText,
15  shareOf,
16  shouldWarn,
17  statusText,
18  toRuns,
19  toSnapshot,
20  topCategories,
21} from './lib'
22import type { Run } from './lib'
23
24const COMMAND = 'context-bar'
25const snapshot = atom({ plugin: 'context-bar', key: 'snapshot' } as const, null)
26const isHidden = atom({ plugin: 'context-bar', key: 'isHidden' } as const, false)
27const hasWarned = atom({ plugin: 'context-bar', key: 'hasWarned' } as const, false)
28
29/** A Text's style props from a run, leaving out the ones it does not set. */
30function style(run: Run): { color?: string; bold?: boolean; dimColor?: boolean } {
31  return {
32    ...(run.color !== undefined && { color: run.color }),
33    ...(run.bold && { bold: true }),
34    ...(run.dim && { dimColor: true }),
35  }
36}
37
38/**
39 * Whether a tree from beneath draws nothing: absent, empty strings and empty
40 * Boxes or Texts, or the engine's own band while no survey holds it (the
41 * band draws only when none does).
42 */
43function drawsNothing(node: RenderNode | undefined): boolean {
44  if (node === undefined) return true
45  if (typeof node === 'string') return node === ''
46  if (node.type === 'engine') return true
47  if (node.type === 'Box' || node.type === 'Text') return (node.children ?? []).every(drawsNothing)
48
49  return false
50}
51
52type Settings = { warnAtHalf: boolean; statusMode: string }
53
54/** Surfaces that never raise the band: mobile, VS Code. */
55function isBandless(surface: string): boolean {
56  return surface !== 'terminal' && surface !== 'desktop'
57}
58
59/**
60 * Pins the status entry when the band cannot be seen: hidden, or a surface
61 * without it attached (mobile), where it reads `🟡 ctx 71% · passive`.
62 */
63async function syncStatus($: EngineInterface, settings: Settings): Promise<void> {
64  if (settings.statusMode === 'off') return $.ui.status(undefined)
65  const bandless = (await $.session.surfaces()).some(isBandless)
66  const show = settings.statusMode === 'always' || bandless || (await read($, isHidden))
67  const s = await read($, snapshot)
68  $.ui.status(show ? (bandless ? richStatusText(s) : statusText(s)) : undefined)
69}
70
71/** Re-measures the window (the free, local `summary` breakdown) and warns once past 50%. */
72async function refresh($: EngineInterface, settings: Settings): Promise<void> {
73  try {
74    const { context } = await $.session.usage({ breakdown: 'summary' })
75    const measured = toSnapshot(context)
76    await update($, snapshot, () => measured)
77
78    let fire = false
79    await update($, hasWarned, warned => {
80      fire = settings.warnAtHalf && shouldWarn(measured.percent, warned)
81      return warned || fire
82    })
83    if (fire) $.ui.toast(WARNING, { timeoutMs: 8000 })
84
85    await syncStatus($, settings)
86  } catch {
87    // A failed measurement leaves the last drawing in place.
88  }
89}
90
91/** A fresh conversation: forget the old figures and re-arm the warning. */
92async function rearm($: EngineInterface): Promise<void> {
93  await update($, snapshot, () => null)
94  await update($, hasWarned, () => false)
95}
96
97export const register: Register = (on, options) => {
98  const showLegend = options.legend !== false
99  const settings: Settings = {
100    warnAtHalf: options.warnAtHalf !== false,
101    statusMode: typeof options.statusLine === 'string' ? options.statusLine : 'auto',
102  }
103
104  on('session.start', async ($, e, next) => {
105    await $.command.register({
106      name: COMMAND,
107      description: 'Show a context window card; on|off shows or hides the bar above the prompt',
108      argumentHint: '[show|on|off|toggle]',
109    })
110    await refresh($, settings)
111
112    return next(e)
113  })
114
115  on('session.measure', async ($, e, next) => {
116    const result = await next(e)
117    if (e.changed.includes('context')) await refresh($, settings)
118
119    return result
120  })
121
122  on('session.compact', async ($, e, next) => {
123    const result = await next(e)
124    const isMainCompaction = e.agentId === undefined && e.trigger !== 'precompute'
125    if (isMainCompaction && result.skip === undefined) {
126      await update($, hasWarned, () => false)
127      await refresh($, settings)
128    }
129
130    return result
131  }).catch(($, e, next) => next(e))
132
133  on('session.end', async ($, e, next) => {
134    if (e.reason === 'clear') await rearm($)
135
136    return next(e)
137  })
138
139  // A phone (or another bandless surface) joining or leaving moves the status entry.
140  on('session.attach', async ($, e, next) => {
141    const result = await next(e)
142    await syncStatus($, settings).catch(() => undefined)
143
144    return result
145  })
146
147  on('session.detach', async ($, e, next) => {
148    const result = await next(e)
149    if (e.reason === 'detach') await syncStatus($, settings).catch(() => undefined)
150
151    return result
152  })
153
154  // A /clear raises no session.start; the settings hook event carries it instead.
155  on('classic.SessionStart', async ($, e, next) => {
156    const result = await next(e)
157    if (e.source === 'clear') {
158      await rearm($)
159      await refresh($, settings)
160    }
161
162    return result
163  }).catch(($, e, next) => next(e))
164
165  on('command.run', { command: COMMAND }, async ($, e) => {
166    const action = parseCommand(e.args)
167    if (action === 'show') {
168      await refresh($, settings)
169
170      return { text: cardText(await read($, snapshot)) }
171    }
172    if (action === 'unknown') return { text: 'Usage: /context-bar [show|on|off|toggle]' }
173
174    const hidden = await update($, isHidden, was => (action === 'on' ? false : action === 'off' ? true : !was))
175    await syncStatus($, settings)
176
177    return { text: hidden ? 'Context bar hidden.' : 'Context bar shown.' }
178  })
179
180  // `/context-bar` (or `show`) draws a compact card in its output row, on every surface.
181  on('ui.render', { component: 'CommandOutput', props: { command: COMMAND } }, async ($, e, next) => {
182    const s = await read($, snapshot)
183    if (e.props.isErrored || parseCommand(e.props.args) !== 'show' || s === null) return next(e)
184
185    const { Box, Text } = $.ui.resolve(e)
186    const width = Math.min(100, Math.max(16, (e.viewport?.columns ?? 60) - 2))
187    const row = (runs: Run[]) => (
188      <Box flexDirection="row">
189        {runs.map(run => (
190          <Text wrap="truncate-end" {...style(run)}>
191            {run.text}
192          </Text>
193        ))}
194      </Box>
195    )
196
197    return (
198      <Box flexDirection="column">
199        <Box flexDirection="row" flexWrap="wrap">
200          <Text color="claude" bold>
201            ◆ context{'  '}
202          </Text>
203          {headlineRuns(s).map(run => (
204            <Text {...style(run)}>{run.text}</Text>
205          ))}
206        </Box>
207        {row(toRuns(barCells(s, width)))}
208        {width >= 24 && row(toRuns(labelCells(s, width)))}
209        {topCategories(s, 4).map(seg => (
210          <Text wrap="truncate-end">
211            <Text color={seg.color}>■</Text>
212            <Text> {seg.name} </Text>
213            <Text bold>{formatTokens(seg.tokens)}</Text>
214            <Text dimColor> · {shareOf(s, seg.tokens)}%</Text>
215          </Text>
216        ))}
217        {s.segments.length > 0 && <Text dimColor>Category split is estimated.</Text>}
218      </Box>
219    )
220  })
221
222  // The band is one instance for every plugin: draw ours, then stack what is beneath under it.
223  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
224    const hidden = await read($, isHidden)
225    const s = await read($, snapshot)
226    const below = await next(e)
227    if (e.props.hasSurvey || hidden || s === null) return below
228
229    const { Box, Text } = $.ui.resolve(e)
230    const width = Math.max(1, e.props.bodyColumns)
231    const head = headlineRuns(s).map(run => <Text {...style(run)}>{run.text}</Text>)
232
233    const stack = (mine: RenderElement): RenderElement =>
234      drawsNothing(below) ? mine : (
235        <Box flexDirection="column">
236          {mine}
237          {below}
238        </Box>
239      )
240
241    if (width < 24) {
242      return stack(
243        <Box flexDirection="row">
244          <Text color="claude" bold>
245            ctx{' '}
246          </Text>
247          {head}
248        </Box>,
249      )
250    }
251
252    const row = (runs: Run[]) => (
253      <Box flexDirection="row">
254        {runs.map(run => (
255          <Text wrap="truncate-end" {...style(run)}>
256            {run.text}
257          </Text>
258        ))}
259      </Box>
260    )
261    const legendRoom = Math.max(0, e.props.maxRows - 3)
262    const legend = showLegend ? packLegend(legendItems(s), width).slice(0, legendRoom) : []
263
264    return stack(
265      <Box flexDirection="column">
266        <Box flexDirection="row" justifyContent="space-between">
267          <Box flexDirection="row">
268            <Text color="claude" bold>
269              ◆ context{'  '}
270            </Text>
271            {head}
272          </Box>
273          {s.segments.length > 0 && width >= 64 && <Text dimColor>split estimated</Text>}
274        </Box>
275        {row(toRuns(barCells(s, width)))}
276        {e.props.maxRows >= 3 && row(toRuns(labelCells(s, width)))}
277        {legend.map(line => (
278          <Box flexDirection="row" columnGap={2}>
279            {line.map(item => (
280              <Text>
281                <Text color={item.color}>{item.glyph}</Text>
282                <Text dimColor> {item.label}</Text>
283              </Text>
284            ))}
285          </Box>
286        ))}
287      </Box>,
288    )
289  })
290}
291
hooks/lib.ts 327 lines
1import type { ContextCategory, SessionContextUsage } from 'claude-code'
2
3import type { ContextBarSegment, ContextBarSnapshot } from '../types'
4
5/** One character cell of a drawn row. */
6export type Cell = { ch: string; color?: string; bold?: boolean; dim?: boolean }
7
8/** Consecutive cells of one style, drawn as one Text. */
9export type Run = { text: string; color?: string; bold?: boolean; dim?: boolean }
10
11export type Zone = 'active' | 'passive'
12
13export type LegendItem = { glyph: string; color: string; label: string }
14
15export const HALF = 50
16export const WARNING = 'Past 50%, consider /compact or a fresh session'
17
18const GLYPH = { used: '█', free: '░', buffer: '▒' } as const
19const DIVIDER: Cell = { ch: '┃', color: 'text', bold: true }
20const COMPACT_MARK: Cell = { ch: '╎', color: 'error', bold: true }
21
22/** 850, 8.5k, 142k, 1.2M. */
23export function formatTokens(n: number): string {
24  if (n < 1000) return String(Math.round(n))
25  if (n < 10_000) return `${trim(n / 1000)}k`
26  if (n < 1_000_000) return `${Math.round(n / 1000)}k`
27  return `${trim(n / 1_000_000)}M`
28}
29
30function trim(n: number): string {
31  return n.toFixed(1).replace(/\.0$/, '')
32}
33
34/** Used categories in the engine's order, then free space, then the compaction buffer. */
35export function orderSegments(categories: readonly ContextCategory[]): ContextBarSegment[] {
36  const shown = categories.filter(c => c.kind !== 'deferred' && c.tokens > 0)
37  const pick = (kind: ContextBarSegment['kind']) =>
38    shown
39      .filter(c => c.kind === kind)
40      .map(c => ({ name: c.name, tokens: c.tokens, color: c.color, kind }))
41
42  return [...pick('used'), ...pick('free'), ...pick('buffer')]
43}
44
45/** Reduces `$.session.usage({ breakdown })`'s context to what the band draws. */
46export function toSnapshot(context: SessionContextUsage): ContextBarSnapshot {
47  const b = context.breakdown
48
49  return {
50    tokens: context.tokens ?? null,
51    percent: context.percent ?? null,
52    window: context.window,
53    scale: b?.rawMaxTokens || context.window,
54    estimatedTokens: b?.totalTokens ?? null,
55    autoCompactAt: b?.isAutoCompactEnabled ? (b.autoCompactThreshold ?? null) : null,
56    segments: b ? orderSegments(b.categories) : [],
57  }
58}
59
60/** The fill to judge zones by: exact when a response reported it, else the estimate. */
61export function fillPercent(s: ContextBarSnapshot): number | null {
62  if (s.percent !== null) return s.percent
63  if (s.estimatedTokens === null || s.scale <= 0) return null
64
65  return Math.round((s.estimatedTokens / s.scale) * 100)
66}
67
68export function zoneOf(percent: number): Zone {
69  return percent < HALF ? 'active' : 'passive'
70}
71
72export function zoneColor(percent: number): string {
73  if (percent < HALF) return 'success'
74  if (percent < 80) return 'warning'
75
76  return 'error'
77}
78
79export function shouldWarn(percent: number | null, hasWarned: boolean): boolean {
80  return percent !== null && percent >= HALF && !hasWarned
81}
82
83export function statusText(s: ContextBarSnapshot | null): string {
84  return s?.percent != null ? `ctx ${s.percent}%` : 'ctx --'
85}
86
87/** 🟢 under 50%, 🟡 under 80%, 🔴 from there; ⚪ before the first response. */
88export function zoneEmoji(percent: number | null): string {
89  if (percent === null) return '⚪'
90  if (percent < HALF) return '🟢'
91  if (percent < 80) return '🟡'
92
93  return '🔴'
94}
95
96/** The status entry for surfaces without the band (mobile): `🟡 ctx 71% · passive`. */
97export function richStatusText(s: ContextBarSnapshot | null): string {
98  const percent = s?.percent ?? null
99  if (percent === null) return `${zoneEmoji(null)} ctx --`
100
101  return `${zoneEmoji(percent)} ctx ${percent}% · ${zoneOf(percent)}`
102}
103
104/** What `/context-bar <args>` asks for. */
105export type CommandAction = 'show' | 'on' | 'off' | 'toggle' | 'unknown'
106
107export function parseCommand(args: string): CommandAction {
108  const arg = args.trim().toLowerCase()
109  if (arg === '' || arg === 'show' || arg === 'card') return 'show'
110  if (arg === 'on' || arg === 'off' || arg === 'toggle') return arg
111
112  return 'unknown'
113}
114
115/** The biggest content categories, largest first (free space and the buffer left out). */
116export function topCategories(s: ContextBarSnapshot, count: number): ContextBarSegment[] {
117  return s.segments
118    .filter(seg => seg.kind === 'used')
119    .sort((a, b) => b.tokens - a.tokens)
120    .slice(0, count)
121}
122
123/** A category's share of the window, as a whole percentage. */
124export function shareOf(s: ContextBarSnapshot, tokens: number): number {
125  return s.scale > 0 ? Math.round((tokens / s.scale) * 100) : 0
126}
127
128/** The card as plain text: what the model reads from the command's row. */
129export function cardText(s: ContextBarSnapshot | null): string {
130  if (s === null) return 'Context: not measured yet.'
131  const head = headlineRuns(s)
132    .map(r => r.text)
133    .join('')
134  const top = topCategories(s, 4).map(seg => `${seg.name} ${formatTokens(seg.tokens)}`)
135
136  return top.length > 0 ? `Context: ${head}. Largest (estimated): ${top.join(', ')}.` : `Context: ${head}.`
137}
138
139/**
140 * Splits `cells` across `weights` by largest remainder, then gives every
141 * positive weight at least one cell, taken from the widest share.
142 */
143export function apportion(weights: readonly number[], cells: number): number[] {
144  const clean = weights.map(w => (Number.isFinite(w) && w > 0 ? w : 0))
145  const total = clean.reduce((a, b) => a + b, 0)
146  if (total <= 0 || cells <= 0) return clean.map(() => 0)
147
148  const exact = clean.map(w => (w / total) * cells)
149  const out = exact.map(Math.floor)
150  let left = cells - out.reduce((a, b) => a + b, 0)
151  const byRemainder = exact
152    .map((x, i) => ({ i, r: x - Math.floor(x) }))
153    .sort((a, b) => b.r - a.r)
154  for (const { i } of byRemainder) {
155    if (left <= 0) break
156    out[i] = (out[i] ?? 0) + 1
157    left -= 1
158  }
159
160  clean.forEach((w, i) => {
161    if (w === 0 || out[i] !== 0) return
162    const widest = out.reduce((best, n, j) => (n > (out[best] ?? 0) ? j : best), 0)
163    if ((out[widest] ?? 0) > 1) {
164      out[widest] = (out[widest] ?? 0) - 1
165      out[i] = 1
166    }
167  })
168
169  return out
170}
171
172/** The cell a fraction of the bar lands on. */
173export function markerIndex(fraction: number, width: number): number {
174  return Math.min(width - 1, Math.max(0, Math.round(fraction * width)))
175}
176
177export function halfIndex(width: number): number {
178  return Math.floor(width / 2)
179}
180
181export function compactIndex(s: ContextBarSnapshot, width: number): number | null {
182  if (s.autoCompactAt === null || s.scale <= 0) return null
183
184  return markerIndex(s.autoCompactAt / s.scale, width)
185}
186
187/** The segments to draw: the breakdown's, or a plain used/free split when there is none. */
188function drawnSegments(s: ContextBarSnapshot): ContextBarSegment[] {
189  if (s.segments.length > 0) return s.segments
190  if (s.tokens === null) return [{ name: 'Free space', tokens: s.scale, color: 'inactive', kind: 'free' }]
191
192  return [
193    { name: 'Used', tokens: s.tokens, color: 'claude', kind: 'used' },
194    { name: 'Free space', tokens: Math.max(0, s.window - s.tokens), color: 'inactive', kind: 'free' },
195  ]
196}
197
198/** The bar: one cell per column, coloured by category, with the 50% and auto-compact marks. */
199export function barCells(s: ContextBarSnapshot, width: number): Cell[] {
200  const segments = drawnSegments(s)
201  const shares = apportion(
202    segments.map(seg => seg.tokens),
203    width,
204  )
205  const cells: Cell[] = []
206  segments.forEach((seg, i) => {
207    for (let n = 0; n < (shares[i] ?? 0); n++) cells.push({ ch: GLYPH[seg.kind], color: seg.color })
208  })
209  while (cells.length < width) cells.push({ ch: GLYPH.free, color: 'inactive' })
210
211  const compact = compactIndex(s, width)
212  if (compact !== null) cells[compact] = COMPACT_MARK
213  cells[halfIndex(width)] = DIVIDER
214
215  return cells.slice(0, width)
216}
217
218/** Writes `text` at `start` if every cell it needs, and one on each side, is blank. */
219function place(cells: Cell[], start: number, text: string, style: Omit<Cell, 'ch'>): boolean {
220  const end = start + text.length
221  if (start < 0 || end > cells.length) return false
222  for (let i = Math.max(0, start - 1); i < Math.min(cells.length, end + 1); i++) {
223    if (cells[i]?.ch !== ' ') return false
224  }
225  ;[...text].forEach((ch, i) => (cells[start + i] = { ch, ...style }))
226
227  return true
228}
229
230/** The row under the bar: ACTIVE and PASSIVE centred in their halves, and the auto-compact label. */
231export function labelCells(s: ContextBarSnapshot, width: number): Cell[] {
232  const cells: Cell[] = Array.from({ length: width }, () => ({ ch: ' ' }))
233  const mid = halfIndex(width)
234  const percent = fillPercent(s)
235  const zone = percent === null ? null : zoneOf(percent)
236  const style = (z: Zone, color: string) => (zone === z ? { color, bold: true } : { color, dim: true })
237
238  cells[mid] = DIVIDER
239  const active = width >= 40 ? 'ACTIVE' : 'A'
240  const passive = width >= 40 ? 'PASSIVE' : 'P'
241  place(cells, Math.floor((mid - active.length) / 2), active, style('active', 'success'))
242
243  const rightStart = mid + 1 + Math.floor((width - mid - 1 - passive.length) / 2)
244  if (!place(cells, rightStart, passive, style('passive', 'warning'))) {
245    place(cells, mid + 2, passive, style('passive', 'warning'))
246  }
247
248  const compact = compactIndex(s, width)
249  if (compact !== null && compact !== mid) {
250    // The label right of its mark, else left of it, else the bare arrow.
251    const mark = { color: 'error' }
252    if (!place(cells, compact, '▲ auto-compact', mark) && !place(cells, compact - 13, 'auto-compact ▲', mark)) {
253      place(cells, compact, '▲', mark)
254    }
255  }
256
257  return cells
258}
259
260/** Merges consecutive cells of the same style. */
261export function toRuns(cells: readonly Cell[]): Run[] {
262  const runs: Run[] = []
263  for (const { ch, ...style } of cells) {
264    const last = runs[runs.length - 1]
265    if (last && last.color === style.color && last.bold === style.bold && last.dim === style.dim) {
266      last.text += ch
267    } else {
268      runs.push({ text: ch, ...style })
269    }
270  }
271
272  return runs
273}
274
275/** `142k / 200k · 71% · passive`, or the estimate before the first response. */
276export function headlineRuns(s: ContextBarSnapshot): Run[] {
277  const sep: Run = { text: ' · ', dim: true }
278  if (s.tokens !== null && s.percent !== null) {
279    const color = zoneColor(s.percent)
280
281    return [
282      { text: `${formatTokens(s.tokens)} / ${formatTokens(s.window)}`, bold: true },
283      sep,
284      { text: `${s.percent}%`, color, bold: true },
285      sep,
286      { text: zoneOf(s.percent), color },
287    ]
288  }
289  if (s.estimatedTokens !== null) {
290    return [
291      { text: `~${formatTokens(s.estimatedTokens)} / ${formatTokens(s.window)}`, bold: true },
292      sep,
293      { text: 'estimate, no response yet', dim: true },
294    ]
295  }
296
297  return [{ text: 'waiting for the first response', dim: true }]
298}
299
300export function legendItems(s: ContextBarSnapshot): LegendItem[] {
301  return s.segments.map(seg => ({
302    glyph: GLYPH[seg.kind],
303    color: seg.color,
304    label: `${seg.name} ${formatTokens(seg.tokens)}`,
305  }))
306}
307
308/** Packs legend items into lines of at most `width` cells, two spaces apart. */
309export function packLegend(items: readonly LegendItem[], width: number): LegendItem[][] {
310  const lines: LegendItem[][] = []
311  let line: LegendItem[] = []
312  let used = 0
313  for (const item of items) {
314    const size = item.label.length + 2
315    if (line.length > 0 && used + 2 + size > width) {
316      lines.push(line)
317      line = []
318      used = 0
319    }
320    used += (line.length > 0 ? 2 : 0) + size
321    line.push(item)
322  }
323  if (line.length > 0) lines.push(line)
324
325  return lines
326}
327
types/index.d.ts 39 lines
1/** What a bar segment stands for: content in the window, free room, or the compaction reserve. */
2export type ContextBarSegmentKind = 'used' | 'free' | 'buffer'
3
4/** One category of the context window, as the bar draws it. */
5export type ContextBarSegment = {
6  name: string
7  tokens: number
8  /** A theme key (or raw colour) from the engine's /context breakdown. */
9  color: string
10  kind: ContextBarSegmentKind
11}
12
13/** The context window as the band last measured it. */
14export type ContextBarSnapshot = {
15  /** Exact input tokens of the last API response; null before the first one. */
16  tokens: number | null
17  /** `tokens` over the model's window as a whole percentage; null before the first response. */
18  percent: number | null
19  /** The model's context window, in tokens. */
20  window: number
21  /** The window the categories fill (the compaction window), in tokens. */
22  scale: number
23  /** The estimated total of the categories, used while `tokens` is null. */
24  estimatedTokens: number | null
25  /** Token count at which auto-compaction runs; null when it is off. */
26  autoCompactAt: number | null
27  segments: ContextBarSegment[]
28}
29
30declare module 'claude-code' {
31  interface PluginState {
32    'context-bar': {
33      snapshot: ContextBarSnapshot | null
34      isHidden: boolean
35      hasWarned: boolean
36    }
37  }
38}
39