SLOPSHOPPER

Context Band

How full the context window is, as a one-line bar above the prompt, so you know when to /compact or /clear.

newbandcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-band
› 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 › /ctx ⎿ context-band: Context Band: 49% · 97k/200k Context ▰▰▰▰▰▱▱▱▱▱ 49% · 97k/200k ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Context ▰▰▰▰▰▱▱▱▱▱ 49% · 97k/200k ⟨Claude Code's own drawing⟩
README

Context Band

How full the context window is, as a one-line bar above the prompt, so you know when to /compact or /clear.

Context ▰▰▰▰▰▰▰▰▱▱ 82% · 164k/200k · time to /compact
  • Updates after every reply from the figures the status line reads; no /context needed.
  • Teal while calm, amber from 50% used, red from 75% used. Both thresholds are options.
  • One toast when a reply crosses into red: /compact to summarize, /clear to start fresh.
  • Sits beside the usage torch when the terminal is wide, on its own line when it is narrow.
  • /ctx prints the figures; /ctx demo calm|warm|hot previews a level, /ctx demo off ends it.

The percentage is the last reply's input tokens against the model's full window, as the status line counts it. /context measures against the compaction window, which can be smaller, so its percentage can read a little higher.

Source 4 files
src/register.tsx 150 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage } from 'claude-code'
3
4import type { ContextDemo, ContextLevel, ContextView } from '../types'
5import { DEFAULT_THRESHOLDS, levelFor, percentOf } from './logic.ts'
6import type { Thresholds } from './logic.ts'
7import * as text from './text.ts'
8
9const demo = atom({ plugin: 'context-band', key: 'demo' } as const, null)
10const view = atom({ plugin: 'context-band', key: 'view' } as const, null)
11
12const BAR_CELLS = 10
13// Calm teal, warming amber, red once it is time to compact.
14const BAR_COLORS: Record<ContextLevel, string> = { calm: '#5DCAA5', warm: '#EF9F27', hot: '#D85A30' }
15const DEMOS: readonly ContextDemo[] = ['calm', 'warm', 'hot']
16const DEMO_PERCENT: Record<ContextLevel, number> = { calm: 24, warm: 58, hot: 86 }
17const DEMO_WINDOW = 200_000
18// From this width the band sits beside the one beneath it; narrower, it goes on a line of its own.
19const SIDE_BY_SIDE_COLUMNS = 110
20
21// Module state: a reload starts it over, the host keeps what the drawing reads in $.state.
22let thresholds: Thresholds = DEFAULT_THRESHOLDS
23// The last real reading; null before the live window's first reply.
24let real: { tokens: number; window: number } | null = null
25// Whether the last reading was hot: crossing into hot raises the toast once.
26let wasHot = false
27
28function readingOf(context: SessionContextUsage) {
29  if (context.tokens === undefined) return null
30  return { tokens: context.tokens, window: context.window }
31}
32
33function shownView(window: number): ContextView {
34  if (real === null) return { level: 'calm', percent: 0, tokens: 0, window, isPending: true }
35  const percent = percentOf(real.tokens, real.window)
36  return { level: levelFor(percent, thresholds), percent, tokens: real.tokens, window: real.window }
37}
38
39// Draws the real reading unless a demo holds the band; crossing into hot says what to do.
40async function showReal($: EngineInterface, context: SessionContextUsage, mayToast: boolean) {
41  real = readingOf(context)
42  const next = shownView(context.window)
43  const isHot = next.isPending !== true && next.level === 'hot'
44  if (mayToast && isHot && !wasHot) $.ui.toast(text.HOT_TOAST(next.percent))
45  wasHot = isHot
46  if ((await read($, demo)) !== null) return
47  await update($, view, () => next)
48}
49
50const barOf = (percent: number): string => {
51  const lit = Math.round((Math.min(100, Math.max(0, percent)) / 100) * BAR_CELLS)
52  return '▰'.repeat(lit) + '▱'.repeat(BAR_CELLS - lit)
53}
54
55export const register: Register = (on, options) => {
56  const { warnAtPercent, compactAtPercent } = options
57  thresholds = {
58    warnAtPercent: typeof warnAtPercent === 'number' ? warnAtPercent : DEFAULT_THRESHOLDS.warnAtPercent,
59    compactAtPercent: typeof compactAtPercent === 'number' ? compactAtPercent : DEFAULT_THRESHOLDS.compactAtPercent,
60  }
61
62  on('session.start', async ($, e, next) => {
63    await $.command.register({
64      name: 'ctx',
65      description: text.COMMAND_DESCRIPTION,
66      argumentHint: text.COMMAND_HINT,
67    })
68    const result = await next(e)
69    // No toast on a start or reload: only a reply that crosses the line raises it.
70    await showReal($, (await $.session.usage()).context, false)
71    return result
72  })
73
74  on('session.measure', async ($, e, next) => {
75    if (e.changed.includes('context')) await showReal($, e.context, true)
76    return next(e)
77  })
78
79  on('command.run', { command: 'ctx' }, async ($, e) => {
80    const [sub, arg] = e.args.trim().split(/\s+/)
81
82    if (sub === 'demo' && arg === 'off') {
83      await update($, demo, () => null)
84      await update($, view, () => shownView(real?.window ?? DEMO_WINDOW))
85      return { text: text.DEMO_OFF }
86    }
87
88    // A bare /ctx reports the figures, like /ctx status.
89    if (sub === undefined || sub === '' || sub === 'status') {
90      const v = await read($, view)
91      if (v === null || v.isPending === true) return { text: text.NO_READING }
92      return { text: `Context Band: ${text.fillLine(v.percent, v.tokens, v.window)}` }
93    }
94
95    const chosen = DEMOS.find(d => d === arg)
96    if (sub === 'demo' && chosen !== undefined) {
97      const percent = DEMO_PERCENT[chosen]
98      const tokens = Math.round((percent / 100) * DEMO_WINDOW)
99      await update($, demo, () => chosen)
100      await update($, view, () => ({ level: chosen, percent, tokens, window: DEMO_WINDOW }))
101      return { text: text.DEMO_ON(chosen) }
102    }
103
104    return { text: text.USAGE }
105  })
106
107  // The band draws its line and keeps what the plugins beneath draw beside it, so it shares the
108  // spot above the prompt (with usage-torch, say) instead of taking it.
109  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
110    if (e.props.hasSurvey) return next(e)
111    const v = await read($, view)
112    if (v === null) return next(e)
113    const below = await next(e)
114
115    const fill = v.isPending === true ? text.WAITING : text.fillLine(v.percent, v.tokens, v.window)
116
117    if (e.surface !== 'terminal') {
118      const { Box, Text } = $.ui.resolve(e)
119      const head = v.isPending === true ? text.LABEL : text.oneLine(v.percent)
120      return (
121        <Box flexDirection="column">
122          <Text dimColor>{`${head} · ${fill}`}</Text>
123          {below}
124        </Box>
125      )
126    }
127
128    const { Box, Text } = $.ui.resolve(e)
129    const isWide = e.props.bodyColumns >= SIDE_BY_SIDE_COLUMNS
130    const color = BAR_COLORS[v.level]
131    const line = (
132      <Box flexDirection="row" gap={1} flexShrink={0}>
133        <Text color={color} bold>
134          {text.LABEL}
135        </Text>
136        {v.isPending !== true && <Text color={color}>{barOf(v.percent)}</Text>}
137        <Text dimColor>{fill}</Text>
138        {v.isPending !== true && v.level === 'hot' && <Text color={color}>{`· ${text.HOT_HINT}`}</Text>}
139      </Box>
140    )
141
142    return (
143      <Box flexDirection={isWide ? 'row' : 'column'} columnGap={3}>
144        {line}
145        {below}
146      </Box>
147    )
148  })
149}
150
src/logic.ts 31 lines
1// Pure rules: fill to level, token counts to short text.
2
3export type Level = 'calm' | 'warm' | 'hot'
4
5export type Thresholds = { warnAtPercent: number; compactAtPercent: number }
6
7export const DEFAULT_THRESHOLDS: Thresholds = { warnAtPercent: 50, compactAtPercent: 75 }
8
9/** The level for a window's fill: hot from the compact threshold, warm from the warn one. */
10export function levelFor(percent: number, t: Thresholds = DEFAULT_THRESHOLDS): Level {
11  if (percent >= t.compactAtPercent) return 'hot'
12  if (percent >= t.warnAtPercent) return 'warm'
13  return 'calm'
14}
15
16/** The fill as a whole percent, 0 to 100. */
17export function percentOf(tokens: number, window: number): number {
18  if (window <= 0) return 0
19  return Math.min(100, Math.max(0, Math.round((tokens / window) * 100)))
20}
21
22/** A token count in a few characters: 950, 12k, 124k, 1.2M. */
23export function formatTokens(n: number): string {
24  if (n >= 1_000_000) {
25    const m = Math.round(n / 100_000) / 10
26    return `${Number.isInteger(m) ? m : m.toFixed(1)}M`
27  }
28  if (n >= 1000) return `${Math.round(n / 1000)}k`
29  return String(Math.max(0, Math.round(n)))
30}
31
src/text.ts 24 lines
1// Every string the mod shows. English only; no em dashes (a test holds this).
2
3import { formatTokens } from './logic.ts'
4
5export const LABEL = 'Context'
6
7/** The figures in one line: the fill and the tokens against the window. */
8export const fillLine = (percent: number, tokens: number, window: number): string =>
9  `${percent}% · ${formatTokens(tokens)}/${formatTokens(window)}`
10
11export const HOT_HINT = 'time to /compact'
12export const WAITING = 'fills after the first reply'
13export const oneLine = (percent: number): string => `Context ${percent}%`
14
15export const HOT_TOAST = (percent: number): string =>
16  `Context ${percent}% full: /compact to summarize or /clear to start fresh`
17
18export const COMMAND_DESCRIPTION = 'Show Context Band figures, or simulate a level'
19export const COMMAND_HINT = '[status|demo calm|warm|hot|off]'
20export const DEMO_ON = (level: string): string => `Context Band demo: ${level}. Real figures are ignored until /ctx demo off.`
21export const DEMO_OFF = 'Context Band demo off.'
22export const USAGE = 'Usage: /ctx status, /ctx demo calm|warm|hot, /ctx demo off'
23export const NO_READING = 'Context Band has no reading yet. It arrives with the first reply.'
24
types/index.d.ts 26 lines
1export type ContextLevel = 'calm' | 'warm' | 'hot'
2
3export type ContextDemo = ContextLevel
4
5/** What the band shows: how much of the window the last reply was answered over. */
6export type ContextView = {
7  level: ContextLevel
8  /** Whole percent of the window in use, 0 to 100. */
9  percent: number
10  /** Input tokens of the last reply. */
11  tokens: number
12  /** The model's context window, in tokens. */
13  window: number
14  /** True before the live window's first reply (a new session, right after /clear or /compact). */
15  isPending?: boolean
16}
17
18declare module 'claude-code' {
19  interface PluginState {
20    'context-band': {
21      demo: ContextDemo | null
22      view: ContextView | null
23    }
24  }
25}
26