SLOPSHOPPER

context-band

Detailed context window breakdown in a band above the prompt

newpanebandcommandtoast
v0.1.0no licenseupdated 2026-10-2413zebras/claude-context-band-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-band
│ ┃ context ✕ › fix the failing auth test and add an audit log call │ ┃ No measurement yet: one comes after the next │ ┃ turn. ⏺ 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 │ │ › /ctx │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · context
No measurement yet: one comes after the next turn.
README

context-band

A Claude Code mod that shows a detailed, live breakdown of your context window in a band directly above the prompt, so you can see what is filling the window, how fast it is growing, and how long until auto-compaction, without running /context.

It is built to sit next to a custom status line rather than repeat it: there is no model name, effort, rate-limit or git information here, only the context window.

What it shows

Summary line (always on)

60k / 200k · autocompact @167k (107k left) · ▂▃▃▄▅ +10k/turn · ~11 turns to compact     p: pane  c: more
  • Fill: input tokens in the window over the model's window size, coloured green → yellow → orange → red as it fills. A leading ~ means the figure is an estimate (before the first response, or right after a compaction).
  • Autocompact: the token count where auto-compaction runs and how far away it is (autocompact off or autocompact due when that applies).
  • Trend: a sparkline of the fill after each of the last 16 turns, each bar coloured by how full the window was, then the average growth per turn and an estimate of the turns left before auto-compaction.

Expanded view (c: more)

60k / 200k · autocompact @167k (107k left) · ▂▃▃▄▅ +10k/turn · ~11 turns to compact     r: exact  p: pane  c: less
████████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
█ System prompt 3k  █ System tools 12k  █ Memory files 4.2k  █ Messages 41k  ░ Free space 107k  ▒ Autocompact buffer 33k  estimated
Deferred MCP tools 45k · System tools 12k
Memory   .claude/CLAUDE.md 1.2k (User) · memory/MEMORY.md 2.1k (AutoMem)
MCP      linear 5.1k 12/40 loaded · neon 2.4k 3/70 loaded
Listings skills 2k 48/52 · agents 1.1k 6 · commands 400 30/31
Cache    read 57k · wrote 2k · uncached 1k · output 2k → 95% hit
Session  $1.50 · last turn +8.2k · 1 compaction, last 162k → 18k (auto)
  • Category bar: one stacked bar across the band, each category in its own colour, with free space (░) and the compaction buffer (▒) in grey, and a legend with every category's tokens.
  • Deferred: tool schemas that load on demand and sit outside the window, on a row of their own.
  • Memory: every CLAUDE.md, rules and auto-memory file in the context, largest first.
  • MCP: tokens per server and how many of its tools are loaded.
  • Listings: what the skill, agent and slash-command listings cost.
  • Cache: the last response's cache read / write / uncached / output tokens and the cache hit rate.
  • Session: cost so far, the last turn's growth, and compaction history.

Side pane (p: pane)

Everything, unabridged, in a pane that docks beside the conversation in fullscreen mode (or opens above the prompt otherwise) and scrolls:

  • a 10×10 grid of the window, one square per 1%, beside a table of every category with its tokens and share
  • the deferred tool schemas
  • the full trend: every measured turn, average and last-turn growth, turns to compaction
  • every memory file with its full path
  • every MCP server with every one of its tools (unloaded tools dimmed)
  • every skill and agent with its listing cost
  • the slash-command listing, cache figures, cost and every compaction

Alerts

A toast when the context window, the 5-hour limit or the 7-day limit crosses 80% and again at 90%, once each; it re-arms when the figure drops back below.

Keys and commands

DoHow
Show more / lessctrl+x c from the prompt (after the one-time setup below), or ctrl+x tab then c, or click c: more, or /ctx
Open the side panectrl+x tab then p, or click p: pane, or /ctx pane
Count tokens exactlyctrl+x tab then r (expanded view or pane), or /ctx full
Collapse the band entirelyctrl+x ctrl+a (Claude Code's own band toggle)
Close the panectrl+x x
Leave the band or paneesc

ctrl+x tab focuses the band; while it has focus, its letter hotkeys work and the arrow keys scroll.

Counts are estimated by default, computed locally at no cost after every turn. Exact counting asks the token-count API once per tool and memory file, as /context does, so it is on demand only; the legend says which you are looking at.

One-time setup for ctrl+x c

A mod cannot define a keyboard shortcut of its own, so the band's toggle borrows a built-in action, app:cycleDiffBase, which is otherwise only used while the diff sidebar is open. Add this line to the Chat context in ~/.claude/keybindings.json (create the file if you have none):

{
  "$schema": "https://www.schemastore.org/claude-code-keybindings.json",
  "bindings": [
    {
      "context": "Chat",
      "bindings": {
        "ctrl+x c": "app:cycleDiffBase"
      }
    }
  ]
}

While the diff sidebar is open, ctrl+x c goes to the sidebar instead of the band. ctrl+c cannot be used in the chord: Claude Code reserves it for interrupt and exit.

Install

At the prompt of a Claude Code terminal session:

/plugin install context-band --marketplace 13zebras/claude-context-band-mod

Answer y to add the marketplace, then pick a scope (the user scope loads it in every session). The band appears above the prompt straight away.

From a shell:

claude plugin install context-band --marketplace 13zebras/claude-context-band-mod

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with 13zebras/claude-context-band-mod, then Add plugin.

To remove it, from a shell: claude plugin uninstall context-band.

Requirements and notes

  • A Claude Code build with mods (function hooks). Mods are early access and their API can change between releases; this was written against Claude Code 2.1.309.
  • Draws in the terminal and the desktop app (the band is only raised there); the side pane draws on every surface.
  • The figures are always the main conversation's, also while a subagent's transcript is on screen (the band then says main session).
  • The trend needs two turns before it shows; it restarts after /clear.

Development

hooks/register.tsx   state and hooks: measuring, alerts, compactions, /ctx, render hooks
hooks/draw.tsx       the band and the pane
hooks/shared.ts      formatting, colours, the snapshot taken from $.session.usage()
types/index.d.ts     the $.state contract
tests/band.test.tsx  tests

Run from the repository folder:

claude plugin validate .
claude plugin test .

To work on it live, start a session with claude --plugin-dir <this folder>: saving a file reloads the mod. Once Claude Code has loaded it, .claude-plugin/types/ holds the API's type declarations and tsc -p . type-checks the mod.

Source 4 files
hooks/register.tsx 195 lines
1import { atom, read, update } from 'claude-code'
2import type {
3  ContextBreakdownDetail,
4  EngineInterface,
5  Register,
6  SessionMeasureInput,
7} from 'claude-code'
8
9import { drawBand, drawPane } from './draw'
10import { toSnapshot } from './shared'
11
12// Change a tag when its value's fields change: it declines what an earlier
13// version stored, which outlives a reload
14const snapshot = atom({ plugin: 'context-band', key: 'snapshot' } as const, null, {
15  shape: 'snapshot-2',
16})
17const history = atom({ plugin: 'context-band', key: 'history' } as const, [], {
18  shape: 'history-1',
19})
20const compactions = atom({ plugin: 'context-band', key: 'compactions' } as const, [], {
21  shape: 'compactions-1',
22})
23const isExpanded = atom({ plugin: 'context-band', key: 'isExpanded' } as const, false)
24const isCounting = atom({ plugin: 'context-band', key: 'isCounting' } as const, false)
25const alerted = atom({ plugin: 'context-band', key: 'alerted' } as const, [])
26
27const PANE = 'context'
28const MAX_POINTS = 40
29const THRESHOLDS = [80, 90]
30const LIMIT_NAMES: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
31
32// `summary` estimates locally; `full` sends token-count requests as /context does
33async function refresh($: EngineInterface, detail: ContextBreakdownDetail) {
34  const usage = await $.session.usage({ breakdown: detail }).catch(() => null)
35  const measured = usage && toSnapshot(usage, detail === 'full')
36  if (measured) await update($, snapshot, () => measured)
37}
38
39async function recount($: EngineInterface) {
40  await update($, isCounting, () => true)
41  try {
42    await refresh($, 'full')
43  } finally {
44    await update($, isCounting, () => false)
45  }
46}
47
48async function toggle($: EngineInterface) {
49  const value = await update($, isExpanded, open => !open)
50  await $.store.set('isExpanded', value)
51}
52
53async function openPane($: EngineInterface) {
54  await $.ui.open({ id: PANE, title: 'Context' })
55}
56
57// Toasts once when context or a rate limit crosses 80% or 90%, and re-arms
58// once it drops back below
59async function alert($: EngineInterface, e: SessionMeasureInput) {
60  const readings = [
61    { name: 'Context', percent: e.context.percent },
62    ...e.rateLimits.map(limit => ({
63      name: `${LIMIT_NAMES[limit.kind] ?? limit.kind} limit`,
64      percent: limit.percentUsed,
65    })),
66  ]
67  const before = await read($, alerted)
68  const crossed: string[] = []
69
70  for (const { name, percent = 0 } of readings) {
71    const keys = THRESHOLDS.filter(t => percent >= t).map(t => `${name} ${t}`)
72    crossed.push(...keys)
73    if (keys.some(key => !before.includes(key))) {
74      $.ui.toast(`${name} at ${Math.round(percent)}%`)
75    }
76  }
77
78  await update($, alerted, () => crossed)
79}
80
81export const register: Register = on => {
82  on('session.start', async ($, e, next) => {
83    await $.command.register({
84      name: 'ctx',
85      description: 'Context band: show more or less; "full" counts exactly; "pane" opens the side pane',
86      argumentHint: '[full|pane]',
87      immediate: true,
88    })
89    const started = await next(e)
90
91    const stored = await $.store.get('isExpanded')
92    if (typeof stored === 'boolean') await update($, isExpanded, () => stored)
93    await refresh($, 'summary')
94
95    return started
96  })
97
98  on('session.measure', async ($, e, next) => {
99    await alert($, e)
100
101    const { tokens, window } = e.context
102    if (e.changed.includes('context') && tokens !== undefined) {
103      await update($, history, points => [...points, { tokens, window }].slice(-MAX_POINTS))
104    }
105    if (e.changed.includes('context') || e.changed.includes('cost')) {
106      await refresh($, 'summary')
107    }
108
109    return next(e)
110  })
111
112  on('session.compact', async ($, e, next) => {
113    const result = await next(e)
114    if (result.skip !== undefined || e.agentId !== undefined || e.trigger === 'precompute') {
115      return result
116    }
117
118    const compaction = {
119      trigger: e.trigger,
120      before: result.tokensBefore,
121      after: result.tokensAfter,
122    }
123    await update($, compactions, list => [...list, compaction].slice(-20))
124    await refresh($, 'summary')
125
126    return result
127  }).catch(($, e, next) => next(e)) // a failed record never blocks the compaction
128
129  on('session.end', async ($, e, next) => {
130    if (e.reason === 'clear') {
131      await update($, snapshot, () => null)
132      await update($, history, () => [])
133      await update($, compactions, () => [])
134      await update($, alerted, () => [])
135    }
136
137    return next(e)
138  })
139
140  on('command.run', { command: 'ctx' }, async ($, e) => {
141    const arg = e.args.trim()
142    if (arg === 'full') await recount($)
143    else if (arg === 'pane') await openPane($)
144    else await toggle($)
145
146    return {}
147  })
148
149  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
150    const snap = await read($, snapshot)
151    const points = await read($, history)
152    const compacted = await read($, compactions)
153    const expanded = await read($, isExpanded)
154    const counting = await read($, isCounting)
155
156    if (e.props.hasSurvey || snap === null) {
157      return next(e)
158    }
159
160    return drawBand($.ui.resolve(e), {
161      snap,
162      points,
163      compacted,
164      isCounting: counting,
165      isExpanded: expanded,
166      isAgentView: e.props.view.agentId !== undefined,
167      columns: e.props.bodyColumns,
168      recount: () => recount($),
169      toggle: () => toggle($),
170      openPane: () => openPane($),
171    })
172  })
173
174  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
175    const snap = await read($, snapshot)
176    const points = await read($, history)
177    const compacted = await read($, compactions)
178    const counting = await read($, isCounting)
179    const { Text } = $.ui.resolve(e)
180
181    if (snap === null) {
182      return <Text dimColor>No measurement yet: one comes after the next turn.</Text>
183    }
184
185    return drawPane($.ui.resolve(e), {
186      snap,
187      points,
188      compacted,
189      isCounting: counting,
190      columns: e.props.bodyColumns,
191      recount: () => recount($),
192    })
193  })
194}
195
hooks/draw.tsx 435 lines
1import type { ElementTable } from 'claude-code'
2
3import type { Compaction, Point, Snapshot } from '../types'
4import {
5  RED,
6  cells,
7  chunk,
8  fmt,
9  joinRuns,
10  outlook,
11  paintCategories,
12  pctColor,
13  shortPath,
14  signed,
15  sparkline,
16} from './shared'
17import type { Entry, Run } from './shared'
18
19type Ui = Pick<ElementTable, 'Box' | 'Button' | 'Text'>
20
21export type View = {
22  snap: Snapshot
23  points: Point[]
24  compacted: Compaction[]
25  isCounting: boolean
26  columns: number
27  recount: () => void
28}
29
30export type BandView = View & {
31  isExpanded: boolean
32  isAgentView: boolean
33  toggle: () => void
34  openPane: () => void
35}
36
37const SPARK_POINTS = 16
38const GRID = 10
39
40// The engine action the toggle borrows, so a chord bound to it in
41// keybindings.json presses the toggle from the prompt
42const TOGGLE_ACTION = 'app:cycleDiffBase'
43
44const paint = ({ Text }: Ui, runs: Run[]) =>
45  runs.map(run => <Text color={run.color}>{run.text}</Text>)
46
47const label = ({ Text }: Ui, text: string) => <Text dimColor>{text.padEnd(9)}</Text>
48
49const percent = (share: number) => `${(share * 100).toFixed(1)}%`
50
51function entries({ Text }: Ui, list: Entry[]) {
52  if (list.length === 0) return <Text dimColor>none</Text>
53
54  return list.map((entry, i) => (
55    <Text dimColor={entry.isDim}>
56      {i > 0 && <Text dimColor> · </Text>}
57      <Text dimColor>{entry.name} </Text>
58      {fmt(entry.tokens)}
59      {entry.note !== undefined && <Text dimColor> {entry.note}</Text>}
60    </Text>
61  ))
62}
63
64function fill({ Text }: Ui, snap: Snapshot, current: number) {
65  return (
66    <Text>
67      <Text color={pctColor((current / snap.window) * 100)}>
68        {snap.tokens === undefined ? '~' : ''}
69        {fmt(current)}
70      </Text>
71      <Text dimColor> / {fmt(snap.window)}</Text>
72    </Text>
73  )
74}
75
76function autocompact({ Text }: Ui, snap: Snapshot, left: number | undefined) {
77  if (snap.compactAt === undefined || left === undefined) {
78    return <Text dimColor> · autocompact off</Text>
79  }
80  if (left <= 0) return <Text color={RED}> · autocompact due</Text>
81
82  return (
83    <Text dimColor>
84      {' '}
85      · autocompact @{fmt(snap.compactAt)} ({fmt(left)} left)
86    </Text>
87  )
88}
89
90function cacheLine(ui: Ui, snap: Snapshot) {
91  const { Text } = ui
92  const { cache } = snap
93  if (cache === undefined) return <Text dimColor>no response yet</Text>
94
95  const hit = (cache.read / Math.max(1, cache.read + cache.written + cache.uncached)) * 100
96
97  return (
98    <Text>
99      {entries(ui, [
100        { name: 'read', tokens: cache.read },
101        { name: 'wrote', tokens: cache.written },
102        { name: 'uncached', tokens: cache.uncached },
103        { name: 'output', tokens: cache.output },
104      ])}
105      <Text dimColor> → </Text>
106      <Text color={pctColor(100 - hit)}>{Math.round(hit)}% hit</Text>
107    </Text>
108  )
109}
110
111function compaction({ Text }: Ui, item: Compaction) {
112  return (
113    <Text>
114      {item.before === undefined ? '?' : fmt(item.before)} →{' '}
115      {item.after === undefined ? '?' : fmt(item.after)}
116      <Text dimColor> ({item.trigger})</Text>
117    </Text>
118  )
119}
120
121function sessionLine(ui: Ui, v: View, last: number | undefined) {
122  const { Text } = ui
123  const latest = v.compacted.at(-1)
124
125  return (
126    <Text>
127      {v.snap.cost !== undefined && <Text>${v.snap.cost.toFixed(2)} · </Text>}
128      {last !== undefined && (
129        <Text>
130          <Text dimColor>last turn </Text>
131          {signed(last)}
132          <Text dimColor> · </Text>
133        </Text>
134      )}
135      {latest === undefined ? (
136        <Text dimColor>no compactions yet</Text>
137      ) : (
138        <Text>
139          {v.compacted.length}
140          <Text dimColor> {v.compacted.length === 1 ? 'compaction' : 'compactions'}, last </Text>
141          {compaction(ui, latest)}
142        </Text>
143      )}
144    </Text>
145  )
146}
147
148export function drawBand(ui: Ui, v: BandView) {
149  const { Box, Button, Text } = ui
150  const { snap, points } = v
151  const { current, left, last, average, turnsLeft } = outlook(snap, points)
152  const recent = points.slice(-SPARK_POINTS)
153
154  const summary = (
155    <Box flexDirection="row" justifyContent="space-between">
156      <Box flexShrink={1}>
157        <Text wrap="truncate-end">
158          {fill(ui, snap, current)}
159          {autocompact(ui, snap, left)}
160          {recent.length > 1 && <Text dimColor> · </Text>}
161          {recent.length > 1 && paint(ui, sparkline(recent))}
162          {average !== undefined && <Text dimColor> {signed(average)}/turn</Text>}
163          {turnsLeft !== undefined && <Text dimColor> · ~{turnsLeft} turns to compact</Text>}
164          {v.isAgentView && <Text dimColor> · main session</Text>}
165        </Text>
166      </Box>
167      <Box flexDirection="row" gap={2} paddingLeft={1}>
168        {v.isExpanded && (
169          <Button
170            key="recount"
171            label={v.isCounting ? 'counting…' : 'exact'}
172            hotkey="r"
173            plain
174            dimColor
175            onPress={v.recount}
176          />
177        )}
178        <Button key="pane" label="pane" hotkey="p" plain dimColor onPress={v.openPane} />
179        <Button
180          key="toggle"
181          label={v.isExpanded ? 'less' : 'more'}
182          hotkey="c"
183          action={TOGGLE_ACTION}
184          plain
185          dimColor
186          onPress={v.toggle}
187        />
188      </Box>
189    </Box>
190  )
191
192  // A blank row on top sets the band apart from the transcript above it
193  if (!v.isExpanded) {
194    return (
195      <Box flexDirection="column" paddingTop={1}>
196        {summary}
197      </Box>
198    )
199  }
200
201  const segments = paintCategories(snap.categories)
202  const shown = segments.filter(s => s.kind !== 'deferred')
203  const deferred = segments.filter(s => s.kind === 'deferred')
204  const { skills, commands, agents } = snap
205
206  return (
207    <Box flexDirection="column" paddingTop={1}>
208      {summary}
209      <Text>{paint(ui, joinRuns(cells(segments, Math.max(10, v.columns))))}</Text>
210      <Text>
211        {shown.map(segment => (
212          <Text>
213            <Text color={segment.color}>{segment.text}</Text>
214            <Text dimColor> {segment.name} </Text>
215            {fmt(segment.tokens)}
216            {'  '}
217          </Text>
218        ))}
219        <Text dimColor>{snap.isExact ? 'exact counts' : 'estimated'}</Text>
220      </Text>
221      {deferred.length > 0 && (
222        <Text wrap="truncate-end">
223          {label(ui, 'Deferred')}
224          {entries(ui, deferred)}
225        </Text>
226      )}
227      <Text wrap="truncate-end">
228        {label(ui, 'Memory')}
229        {entries(
230          ui,
231          snap.memoryFiles.map(file => ({
232            name: shortPath(file.path),
233            tokens: file.tokens,
234            note: `(${file.type})`,
235          })),
236        )}
237      </Text>
238      <Text wrap="truncate-end">
239        {label(ui, 'MCP')}
240        {entries(
241          ui,
242          snap.mcpServers.map(server => ({
243            name: server.name,
244            tokens: server.tokens,
245            note: `${server.loaded}/${server.total} loaded`,
246          })),
247        )}
248      </Text>
249      <Text wrap="truncate-end">
250        {label(ui, 'Listings')}
251        {entries(ui, [
252          ...(skills
253            ? [{ name: 'skills', tokens: skills.tokens, note: `${skills.included}/${skills.total}` }]
254            : []),
255          { name: 'agents', tokens: agents.tokens, note: `${agents.total}` },
256          ...(commands
257            ? [
258                {
259                  name: 'commands',
260                  tokens: commands.tokens,
261                  note: `${commands.included}/${commands.total}`,
262                },
263              ]
264            : []),
265        ])}
266      </Text>
267      <Text wrap="truncate-end">
268        {label(ui, 'Cache')}
269        {cacheLine(ui, snap)}
270      </Text>
271      <Text wrap="truncate-end">
272        {label(ui, 'Session')}
273        {sessionLine(ui, v, last)}
274      </Text>
275    </Box>
276  )
277}
278
279// The whole breakdown, unabridged: the pane scrolls where the band cannot
280export function drawPane(ui: Ui, v: View) {
281  const { Box, Button, Text } = ui
282  const { snap, points, compacted } = v
283  const { current, left, last, average, turnsLeft } = outlook(snap, points)
284  const segments = paintCategories(snap.categories)
285  const shown = segments.filter(s => s.kind !== 'deferred')
286  const deferred = segments.filter(s => s.kind === 'deferred')
287  const total = shown.reduce((n, s) => n + s.tokens, 0) || 1
288  const grid = chunk(cells(segments, GRID * GRID), GRID)
289  const nameWidth = Math.max(0, ...shown.map(s => s.name.length))
290  const isWide = v.columns >= GRID * 2 + nameWidth + 20
291  const loadedTools = snap.mcpServers.reduce((n, server) => n + server.loaded, 0)
292  const allTools = snap.mcpServers.reduce((n, server) => n + server.total, 0)
293  const { skills, commands, agents } = snap
294
295  const section = (title: string, note: string | undefined, body: JSX.Element) => (
296    <Box flexDirection="column">
297      <Text>
298        <Text weight="bold">{title}</Text>
299        {note !== undefined && <Text dimColor> {note}</Text>}
300      </Text>
301      {body}
302    </Box>
303  )
304
305  return (
306    <Box flexDirection="column" gap={1}>
307      <Box flexDirection="row" justifyContent="space-between">
308        <Box flexShrink={1}>
309          <Text>
310            {fill(ui, snap, current)}
311            <Text dimColor> ({Math.round((current / snap.window) * 100)}%)</Text>
312            {autocompact(ui, snap, left)}
313            <Text dimColor> · {snap.isExact ? 'exact counts' : 'estimated'}</Text>
314          </Text>
315        </Box>
316        <Box paddingLeft={1}>
317          <Button
318            key="recount"
319            label={v.isCounting ? 'counting…' : 'exact'}
320            hotkey="r"
321            plain
322            dimColor
323            onPress={v.recount}
324          />
325        </Box>
326      </Box>
327
328      <Box flexDirection={isWide ? 'row' : 'column'} gap={isWide ? 3 : 1}>
329        <Box flexDirection="column">
330          {grid.map(row => (
331            <Text>{paint(ui, joinRuns(row.map(cell => ({ ...cell, text: `${cell.text} ` }))))}</Text>
332          ))}
333        </Box>
334        <Box flexDirection="column">
335          {shown.map(segment => (
336            <Text>
337              <Text color={segment.color}>{segment.text}</Text> {segment.name.padEnd(nameWidth)}{' '}
338              {fmt(segment.tokens).padStart(6)}
339              <Text dimColor> {percent(segment.tokens / total).padStart(6)}</Text>
340            </Text>
341          ))}
342        </Box>
343      </Box>
344
345      {deferred.length > 0 &&
346        section('Deferred', 'tool schemas loaded on demand, outside the window', (
347          <Text>{entries(ui, deferred)}</Text>
348        ))}
349
350      {section('Trend', `${points.length} turns measured`, (
351        <Text>
352          {points.length > 1 ? paint(ui, sparkline(points)) : <Text dimColor>needs two turns</Text>}
353          {average !== undefined && <Text dimColor> {signed(average)}/turn</Text>}
354          {last !== undefined && <Text dimColor> · last turn {signed(last)}</Text>}
355          {turnsLeft !== undefined && <Text dimColor> · ~{turnsLeft} turns to compact</Text>}
356        </Text>
357      ))}
358
359      {section('Memory files', `${snap.memoryFiles.length}`, (
360        <Box flexDirection="column">
361          {snap.memoryFiles.map(file => (
362            <Box flexDirection="row" justifyContent="space-between">
363              <Box flexShrink={1}>
364                <Text wrap="truncate-start">
365                  <Text dimColor>{file.type.padEnd(8)}</Text>
366                  {file.path}
367                </Text>
368              </Box>
369              <Text> {fmt(file.tokens)}</Text>
370            </Box>
371          ))}
372        </Box>
373      ))}
374
375      {section('MCP servers', `${loadedTools}/${allTools} tools loaded`, (
376        <Box flexDirection="column">
377          {snap.mcpServers.map(server => (
378            <Box flexDirection="column">
379              <Text>
380                {server.name}
381                <Text dimColor>
382                  {' '}
383                  {fmt(server.tokens)} · {server.loaded}/{server.total} loaded
384                </Text>
385              </Text>
386              <Box paddingLeft={2}>
387                <Text>
388                  {entries(
389                    ui,
390                    server.tools.map(tool => ({ ...tool, isDim: !tool.isLoaded })),
391                  )}
392                </Text>
393              </Box>
394            </Box>
395          ))}
396        </Box>
397      ))}
398
399      {skills &&
400        section('Skills', `${fmt(skills.tokens)} · ${skills.included}/${skills.total} listed`, (
401          <Text>{entries(ui, skills.items)}</Text>
402        ))}
403
404      {section('Agents', `${fmt(agents.tokens)} · ${agents.total}`, (
405        <Text>{entries(ui, agents.items)}</Text>
406      ))}
407
408      {commands && (
409        <Text>
410          <Text weight="bold">Commands</Text>
411          <Text dimColor>
412            {' '}
413            {fmt(commands.tokens)} · {commands.included}/{commands.total} listed
414          </Text>
415        </Text>
416      )}
417
418      {section('Cache', undefined, cacheLine(ui, snap))}
419
420      {section('Session', undefined, (
421        <Box flexDirection="column">
422          {sessionLine(ui, v, last)}
423          {compacted.length > 1 &&
424            compacted.map((item, i) => (
425              <Text>
426                <Text dimColor>{`#${i + 1}`.padEnd(4)}</Text>
427                {compaction(ui, item)}
428              </Text>
429            ))}
430        </Box>
431      ))}
432    </Box>
433  )
434}
435
hooks/shared.ts 209 lines
1import type { SessionUsage } from 'claude-code'
2
3import type { Category, Item, McpServer, Point, Snapshot } from '../types'
4
5const SPARKS = '▁▂▃▄▅▆▇█'
6
7// The palette of statusline-command.sh, so the band matches the status line
8export const RED = '#ff0014'
9const ORANGE = '#ff6400'
10const YELLOW = 'yellow'
11const YELLOW_GREEN = '#78d200'
12const GREEN = '#28c800'
13const OLIVE = '#64aa32'
14const BLUE = '#3c82ff'
15const DARK_BLUE = '#3c5aff'
16const PURPLE = '#be28ff'
17const MAGENTA = '#ff00c8'
18const CYAN = '#00b4c8'
19const BROWN = '#a0785f'
20const GRAY = '#78787d'
21const DARK_GRAY = '#5a5a5a'
22
23const CATEGORY_COLORS = [BLUE, PURPLE, MAGENTA, ORANGE, YELLOW, OLIVE, CYAN, BROWN, DARK_BLUE]
24
25export type Run = { text: string; color: string }
26export type Segment = Category & Run
27export type Entry = Item & { note?: string; isDim?: boolean }
28
29// Same thresholds as pct_color in the status line
30export function pctColor(percent: number): string {
31  if (percent >= 90) return RED
32  if (percent >= 70) return ORANGE
33  if (percent >= 50) return YELLOW
34  if (percent >= 40) return YELLOW_GREEN
35  return GREEN
36}
37
38export function fmt(tokens: number): string {
39  const n = Math.abs(tokens)
40  const sign = tokens < 0 ? '-' : ''
41  if (n < 1000) return `${sign}${Math.round(n)}`
42  if (n < 100_000) return `${sign}${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`
43  if (n < 1_000_000) return `${sign}${Math.round(n / 1000)}k`
44  return `${sign}${(n / 1_000_000).toFixed(2).replace(/\.?0+$/, '')}M`
45}
46
47export const signed = (tokens: number) => `${tokens >= 0 ? '+' : ''}${fmt(tokens)}`
48export const shortPath = (path: string) => path.split('/').slice(-2).join('/')
49const byTokens = (a: { tokens: number }, b: { tokens: number }) => b.tokens - a.tokens
50
51// Joins neighbouring cells of one colour into a single Text run
52export function joinRuns(cells: Run[]): Run[] {
53  const out: Run[] = []
54  for (const cell of cells) {
55    const last = out.at(-1)
56    if (last?.color === cell.color) last.text += cell.text
57    else out.push({ ...cell })
58  }
59  return out
60}
61
62export function chunk<T>(list: T[], size: number): T[][] {
63  return Array.from({ length: Math.ceil(list.length / size) }, (_, i) =>
64    list.slice(i * size, (i + 1) * size),
65  )
66}
67
68export function toSnapshot(usage: SessionUsage, isExact: boolean): Snapshot | null {
69  const { context, cost } = usage
70  const breakdown = context.breakdown
71  if (!breakdown) return null
72
73  const servers = new Map<string, McpServer>()
74  for (const tool of breakdown.mcpTools) {
75    const server = servers.get(tool.serverName) ?? {
76      name: tool.serverName,
77      tokens: 0,
78      loaded: 0,
79      total: 0,
80      tools: [],
81    }
82    server.total += 1
83    if (tool.isLoaded) {
84      server.loaded += 1
85      server.tokens += tool.tokens
86    }
87    server.tools.push({
88      name: tool.name.replace(/^mcp__.+?__/, ''),
89      tokens: tool.tokens,
90      isLoaded: tool.isLoaded,
91    })
92    servers.set(tool.serverName, server)
93  }
94  for (const server of servers.values()) server.tools.sort(byTokens)
95
96  const { skills, slashCommands, apiUsage, agents } = breakdown
97
98  return {
99    tokens: context.tokens,
100    window: context.window,
101    estimated: breakdown.totalTokens,
102    compactAt: breakdown.isAutoCompactEnabled ? breakdown.autoCompactThreshold : undefined,
103    categories: breakdown.categories.map(({ name, tokens, kind }) => ({ name, tokens, kind })),
104    memoryFiles: breakdown.memoryFiles
105      .map(({ path, type, tokens }) => ({ path, type, tokens }))
106      .sort(byTokens),
107    mcpServers: [...servers.values()].sort(byTokens),
108    agents: {
109      tokens: agents.reduce((n, agent) => n + agent.tokens, 0),
110      included: agents.length,
111      total: agents.length,
112      items: agents.map(agent => ({ name: agent.agentType, tokens: agent.tokens })).sort(byTokens),
113    },
114    skills: skills && {
115      tokens: skills.tokens,
116      included: skills.includedSkills,
117      total: skills.totalSkills,
118      items: skills.skillFrontmatter
119        .map(skill => ({ name: skill.name, tokens: skill.tokens }))
120        .sort(byTokens),
121    },
122    commands: slashCommands && {
123      tokens: slashCommands.tokens,
124      included: slashCommands.includedCommands,
125      total: slashCommands.totalCommands,
126      items: [],
127    },
128    cache: apiUsage
129      ? {
130          read: apiUsage.cache_read_input_tokens,
131          written: apiUsage.cache_creation_input_tokens,
132          uncached: apiUsage.input_tokens,
133          output: apiUsage.output_tokens,
134        }
135      : undefined,
136    cost: cost?.usd,
137    isExact,
138  }
139}
140
141// Growth per turn since the last compaction: the last turn's, and the
142// average of the last five
143export function trend(points: Point[]) {
144  const deltas = points.slice(1).map((point, i) => point.tokens - (points[i]?.tokens ?? 0))
145  const sinceCompaction = deltas.slice(deltas.findLastIndex(d => d < 0) + 1).slice(-5)
146  const average = sinceCompaction.length
147    ? sinceCompaction.reduce((n, d) => n + d, 0) / sinceCompaction.length
148    : undefined
149
150  return { last: deltas.at(-1), average }
151}
152
153// The summary's figures: the live fill, and how far auto-compaction is
154export function outlook(snap: Snapshot, points: Point[]) {
155  const current = snap.tokens ?? snap.estimated
156  const left = snap.compactAt === undefined ? undefined : snap.compactAt - current
157  const { last, average } = trend(points)
158  const turnsLeft =
159    left !== undefined && left > 0 && average !== undefined && average > 0
160      ? Math.ceil(left / average)
161      : undefined
162
163  return { current, left, last, average, turnsLeft }
164}
165
166export function sparkline(points: Point[]): Run[] {
167  return joinRuns(
168    points.map(({ tokens, window }) => {
169      const percent = (tokens / window) * 100
170      const level = Math.min(SPARKS.length - 1, Math.floor((percent / 100) * SPARKS.length))
171
172      return { text: SPARKS.charAt(level), color: pctColor(percent) }
173    }),
174  )
175}
176
177export function paintCategories(categories: Category[]): Segment[] {
178  let used = 0
179
180  return categories.map(category => {
181    if (category.kind === 'used') {
182      const color = CATEGORY_COLORS[used++ % CATEGORY_COLORS.length] ?? BLUE
183      return { ...category, text: '█', color }
184    }
185    if (category.kind === 'free') return { ...category, text: '░', color: DARK_GRAY }
186    if (category.kind === 'buffer') return { ...category, text: '▒', color: GRAY }
187    return { ...category, text: '·', color: DARK_GRAY }
188  })
189}
190
191// One cell per `count`th of the window; deferred tool schemas sit outside it
192export function cells(segments: Segment[], count: number): Run[] {
193  const shown = segments.filter(s => s.kind !== 'deferred')
194  const total = shown.reduce((n, s) => n + s.tokens, 0) || 1
195  let tokens = 0
196  let filled = 0
197
198  return shown.flatMap(segment => {
199    tokens += segment.tokens
200    const end = Math.round((tokens / total) * count)
201    const run = Array.from({ length: end - filled }, () => ({
202      text: segment.text,
203      color: segment.color,
204    }))
205    filled = end
206    return run
207  })
208}
209
types/index.d.ts 44 lines
1export type CategoryKind = 'used' | 'free' | 'buffer' | 'deferred'
2export type Category = { name: string; tokens: number; kind: CategoryKind }
3export type Item = { name: string; tokens: number }
4export type MemoryFile = { path: string; type: string; tokens: number }
5export type McpTool = { name: string; tokens: number; isLoaded: boolean }
6export type McpServer = { name: string; tokens: number; loaded: number; total: number; tools: McpTool[] }
7export type Listing = { tokens: number; included: number; total: number; items: Item[] }
8export type Cache = { read: number; written: number; uncached: number; output: number }
9
10// One measurement of the context window, distilled from $.session.usage()
11export type Snapshot = {
12  tokens?: number
13  window: number
14  estimated: number
15  compactAt?: number
16  categories: Category[]
17  memoryFiles: MemoryFile[]
18  mcpServers: McpServer[]
19  agents: Listing
20  skills?: Listing
21  commands?: Listing
22  cache?: Cache
23  cost?: number
24  isExact: boolean
25}
26
27// The context fill after one main-thread turn
28export type Point = { tokens: number; window: number }
29
30export type Compaction = { trigger: string; before?: number; after?: number }
31
32declare module 'claude-code' {
33  interface PluginState {
34    'context-band': {
35      snapshot: Shaped<Snapshot | null>
36      history: Shaped<Point[]>
37      compactions: Shaped<Compaction[]>
38      isExpanded: boolean
39      isCounting: boolean
40      alerted: string[]
41    }
42  }
43}
44