SLOPSHOPPER

context-window

A context-window pane: colour-coded bar and a colour legend for every category

newpanecommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-window
│ ┃ context-window ✕ › fix the failing auth test and add an audit log call │ ┃ 49% used 97k / 200k tokens │ ┃ ⏺ 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-window │ ⎿ context-window: Context-window pane closed. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ context-window: ctx 49% 97k/200k

Draws

Pane · context-window
49% used 97k / 200k tokens
README

claude-code-mods

Three Claude Code mods for keeping an eye on how much room you have left and what you spend: your context window, your plan usage limits, and a receipt for each turn. They work in the terminal and in the Claude desktop app's Code tab.

ModWhere it showsWhat it shows
context-windowA pane beside the conversationA colour-coded bar of what fills the context window, with a legend of every category (tokens and share), the autocompact buffer, and a warning at 50%
plan-usageA band above the prompt5-hour and weekly limit meters with a pace marker, time until reset, an ahead/under-pace label, and the session's cost
turn-receiptA card under each turn's answerWhat the turn cost, its tokens by category, duration, model, the tools that ran, and how much tool output went back to the model

context-window and plan-usage also add a short entry to the status line.

Install

In a Claude Code terminal session:

/plugin install context-window --marketplace oakker-myo/claude-code-mods
/plugin install plan-usage --marketplace oakker-myo/claude-code-mods
/plugin install turn-receipt --marketplace oakker-myo/claude-code-mods

Answer y to add the marketplace, then pick a scope (the user scope loads them in every session). Installed at the user scope from a terminal, they also load in the desktop app's Code tab.

Or from a shell:

claude plugin marketplace add oakker-myo/claude-code-mods
claude plugin install context-window@claude-code-mods --scope user
claude plugin install plan-usage@claude-code-mods --scope user
claude plugin install turn-receipt@claude-code-mods --scope user

Usage

CommandEffect
/context-windowOpen or close the context-window pane
/plan-usageShow or hide the plan-usage band
/turn-receiptShow or hide the receipts under each turn

context-window

  • Bar: one coloured section per category in the window. The amber hatched section is the autocompact buffer: space Claude Code keeps free so it can summarise the conversation when the window fills; autocompact starts where it begins. Faint grey is free space.
  • Legend: a swatch, name, token count and share of the window for every category. Tool schemas that load on demand sit outside the window and are summed in a note underneath.
  • Warning: a notification when the window passes 50%, suggesting /compact.
  • Pane title: carries the live percentage, e.g. Context window · 11%.

plan-usage

  • Meters: one per limit window (5-hour, weekly), green below 70%, amber to 90%, red above.
  • Pace marker │: how much of the window's time has passed. Usage left of it is under pace.
  • ↻: time until the window resets. Countdowns refresh every minute.
  • Notifications at 80% and 95% of a limit.

Plan meters appear on Pro and Max plans after the first reply of a session.

turn-receipt

A small card under the answer that closes each turn:

  • Top line: what the turn cost (the session's cost before and after, so subagents are included), total tokens, duration, model, and how much the turn added to the context window.
  • Bar and legend: the turn's tokens by category: cache read (earlier context reused from the prompt cache), cache write (context newly cached), input (uncached input), output (what Claude wrote) and subagents.
  • Tools: a small table of the tools that ran, with their calls and the tool output fed back to the model. That output is an estimate, at about four characters a token, and it is part of the input and cache-write tokens above, not added to them.
  Tools              calls   output fed back
  Read                   2   ██████████ ≈261
  Bash                   3   ███████    ≈182
  + 2 more (ExitPlanMode, ToolSearch) · 2 calls  ≈457
  ─────────────────────────────────────────────
  7 calls · ≈900 tokens fed back

The four tools with the most output get a row; the rest, and Claude's own bookkeeping tools (ToolSearch, plan mode, todo lists), fold into one "+ more" line. A turn with fewer than three calls and under 1k tokens of output shows one line instead, e.g. Tools Bash ×2 · ≈180 fed back.

A turn that ends on a tool call with no closing text has no answer to hang a receipt under, so it shows none.

Requirements

Claude Code with plugin hooks modules (2.1.293 or later).

Development

Each mod is a plugin folder: .claude-plugin/plugin.json, a hooks module in hooks/, and its state contract in types/. The repository root holds the marketplace file, .claude-plugin/marketplace.json.

claude plugin validate .
claude plugin validate context-window
claude plugin test context-window
claude plugin test plan-usage
claude plugin test turn-receipt

To try a change without installing, start a session with claude --plugin-dir ./context-window --plugin-dir ./plan-usage --plugin-dir ./turn-receipt. If you installed from a clone of this folder (claude plugin marketplace add <folder>), Claude Code installs from the clone's last commit, so a change reaches your sessions once it is committed:

git commit -am "…"
claude plugin update <mod>@claude-code-mods

then run /reload-plugins in a running session.

See CHANGELOG.md for release notes.

License

MIT

Source 3 files
hooks/register.tsx 172 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Category, Snapshot } from '../types'
5import { GLYPH, compact, crossed, pct, rows, split, tone } from './format'
6
7const PANE = 'context-window'
8const snapshot = atom({ plugin: 'context-window', key: 'snapshot' } as const, null)
9const paneOpen = atom({ plugin: 'context-window', key: 'paneOpen' } as const, false)
10
11const MARKS = [50]
12const ORDER: Record<Category['kind'], number> = { used: 0, buffer: 1, free: 2, deferred: 3 }
13
14function title(snap: Snapshot | null): string {
15  return snap?.percent === undefined ? 'Context window' : `Context window · ${snap.percent}%`
16}
17
18// The engine's pane list can miss a pane a remote surface (the desktop app)
19// is showing, so the mod keeps its own record: set on open, cleared by ui.close.
20async function isOpen($: EngineInterface): Promise<boolean> {
21  return (await read($, paneOpen)) || (await $.ui.panes()).some(p => p.id === PANE)
22}
23
24async function openPane($: EngineInterface, snap: Snapshot | null): Promise<void> {
25  await update($, paneOpen, () => true)
26  await $.ui.open({ id: PANE, title: title(snap) })
27}
28
29async function measure($: EngineInterface): Promise<Snapshot> {
30  const usage = await $.session.usage({ breakdown: 'summary' })
31  const categories = (usage.context.breakdown?.categories ?? [])
32    .map(c => ({ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind }))
33    .sort((a, b) => ORDER[a.kind] - ORDER[b.kind])
34  const fresh: Snapshot = {
35    tokens: usage.context.tokens,
36    window: usage.context.window,
37    percent: usage.context.percent,
38    categories,
39  }
40  await update($, snapshot, () => fresh)
41  $.ui.status(
42    fresh.percent === undefined
43      ? undefined
44      : `ctx ${fresh.percent}% ${compact(fresh.tokens ?? 0)}/${compact(fresh.window)}`,
45  )
46  // Opening an open pane retitles it; a closed one stays closed.
47  if (await read($, paneOpen)) await $.ui.open({ id: PANE, title: title(fresh) })
48
49  return fresh
50}
51
52export const register: Register = on => {
53  on('session.start', async ($, e, next) => {
54    const ran = await next(e)
55    await $.command.register({
56      name: 'context-window',
57      description: 'Open or close the context-window pane',
58    })
59    const fresh = await measure($)
60    await openPane($, fresh)
61
62    return ran
63  })
64
65  on('session.measure', async ($, e, next) => {
66    if (e.changed.includes('context')) {
67      const before = await read($, snapshot)
68      const fresh = await measure($)
69      if (crossed(before?.percent, fresh.percent, MARKS) !== undefined) {
70        $.ui.toast(`Context window ${fresh.percent}% full; /compact frees room`)
71      }
72    }
73
74    return next(e)
75  })
76
77  on('command.run', { command: 'context-window' }, async $ => {
78    if (await isOpen($)) {
79      await update($, paneOpen, () => false)
80      await $.ui.close({ id: PANE })
81      return { text: 'Context-window pane closed.' }
82    }
83    await openPane($, await read($, snapshot))
84
85    return { text: 'Context-window pane opened.' }
86  })
87
88  on('ui.close', { id: PANE }, async ($, e, next) => {
89    await update($, paneOpen, () => false)
90
91    return next(e)
92  })
93
94  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
95    const { Box, Text } = $.ui.resolve(e)
96    const snap = await read($, snapshot)
97    if (snap === null) {
98      return <Text dimColor>Measuring…</Text>
99    }
100
101    const percent = snap.percent
102    const list = rows(snap.categories)
103    const deferred = snap.categories.filter(c => c.kind === 'deferred').reduce((s, c) => s + c.tokens, 0)
104    const cols = e.props.bodyColumns
105    // Block glyphs can draw wider than a cell on some fonts: leave slack, and
106    // draw the bar as one truncating line so it can never wrap onto a second.
107    const width = Math.max(10, Math.floor((cols - 2) * 0.9))
108    const rowWidth = Math.min(cols, 44)
109
110    const bar = (
111      <Box flexDirection="row" overflow="hidden">
112        <Text wrap="truncate-end">
113          {split(
114            list.map(c => c.tokens),
115            width,
116            list.map(c => c.kind === 'used'),
117          ).map((cells, i) => {
118            const c = list[i]!
119            return cells > 0 ? (
120              <Text color={c.hex} dimColor={c.kind === 'free'}>
121                {GLYPH[c.kind].repeat(cells)}
122              </Text>
123            ) : null
124          })}
125        </Text>
126      </Box>
127    )
128
129    const legend = list.map(c => (
130      <Box key={c.name} width={rowWidth} flexDirection="row">
131        <Text color={c.hex} dimColor={c.kind === 'free'}>
132          {GLYPH[c.kind].repeat(2)}{'  '}
133        </Text>
134        <Box flexGrow={1}>
135          <Text dimColor={c.kind !== 'used'} wrap="truncate">
136            {c.name}
137          </Text>
138        </Box>
139        <Text bold={c.kind === 'used'} dimColor={c.kind !== 'used'}>
140          {compact(c.tokens).padStart(5)}
141        </Text>
142        <Text dimColor>{pct(c.share).padStart(7)}</Text>
143      </Box>
144    ))
145
146    return (
147      <Box flexDirection="column" paddingX={1}>
148        <Text>
149          <Text bold color={percent === undefined ? 'inactive' : tone(percent)}>
150            {percent === undefined ? 'Waiting for first reply' : `${percent}% used`}
151          </Text>
152          <Text dimColor>
153            {'  '}
154            {snap.tokens === undefined ? '—' : compact(snap.tokens)} / {compact(snap.window)} tokens
155          </Text>
156        </Text>
157        <Box marginY={1}>{bar}</Box>
158        <Box flexDirection="row" flexWrap="wrap" columnGap={3}>
159          {legend}
160        </Box>
161        {deferred > 0 ? (
162          <Box marginTop={1}>
163            <Text dimColor italic>
164              + {compact(deferred)} of tool schemas load on demand and sit outside the window
165            </Text>
166          </Box>
167        ) : null}
168      </Box>
169    )
170  })
171}
172
hooks/format.ts 74 lines
1import type { Category } from '../types'
2
3/** Mid-tone categorical colours that read on light and dark backgrounds alike. */
4export const PALETTE = ['#d97757', '#6c8cf5', '#3fb3a8', '#b77ee0', '#9b7653', '#5fae5f', '#e06c8a', '#4aa3df']
5export const TRACK = '#8b949e'
6export const BUFFER = '#e0a43a'
7
8export const GLYPH: Record<Category['kind'], string> = {
9  used: '█',
10  buffer: '▒',
11  free: '░',
12  deferred: '·',
13}
14
15export type Row = Category & { hex: string; share: number }
16
17/** The in-window rows in drawing order, each with its colour and share of the window. */
18export function rows(categories: Category[]): Row[] {
19  const inWindow = categories.filter(c => c.kind !== 'deferred' && c.tokens > 0)
20  const total = inWindow.reduce((s, c) => s + c.tokens, 0) || 1
21  let used = 0
22  return inWindow.map(c => ({
23    ...c,
24    hex: c.kind === 'used' ? PALETTE[used++ % PALETTE.length]! : c.kind === 'buffer' ? BUFFER : TRACK,
25    share: (c.tokens / total) * 100,
26  }))
27}
28
29export function compact(n: number): string {
30  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
31  if (n >= 1_000) return `${Math.round(n / 1_000)}k`
32  return String(n)
33}
34
35export function pct(share: number): string {
36  return share >= 10 ? `${Math.round(share)}%` : share >= 0.1 ? `${share.toFixed(1)}%` : '<0.1%'
37}
38
39export function tone(percent: number): 'success' | 'warning' | 'error' {
40  return percent >= 90 ? 'error' : percent >= 70 ? 'warning' : 'success'
41}
42
43/**
44 * Splits `width` units across `sizes` in proportion (largest remainder),
45 * giving each index in `atLeast` a floor of `min` so small rows stay visible.
46 */
47export function split(sizes: number[], width: number, atLeast: boolean[], min = 1): number[] {
48  const total = sizes.reduce((s, n) => s + n, 0)
49  if (total === 0 || width <= 0) return sizes.map(() => 0)
50
51  const exact = sizes.map(n => (n / total) * width)
52  const out = exact.map((x, i) => Math.max(atLeast[i] ? min : 0, Math.floor(x)))
53  let left = width - out.reduce((s, n) => s + n, 0)
54
55  const order = exact.map((x, i) => ({ i, rem: x - Math.floor(x) })).sort((a, b) => b.rem - a.rem)
56  for (let k = 0; left > 0 && k < order.length; k++, left--) out[order[k]!.i]! += 1
57
58  while (left < 0) {
59    const big = out.indexOf(Math.max(...out))
60    out[big]! -= 1
61    left += 1
62  }
63  return out
64}
65
66export function crossed(
67  before: number | undefined,
68  after: number | undefined,
69  marks: number[],
70): number | undefined {
71  if (after === undefined) return undefined
72  return [...marks].sort((a, b) => b - a).find(m => after >= m && (before ?? 0) < m)
73}
74
types/index.d.ts 20 lines
1export type Category = {
2  name: string
3  tokens: number
4  color: string
5  kind: 'used' | 'free' | 'buffer' | 'deferred'
6}
7
8export type Snapshot = {
9  tokens?: number
10  window: number
11  percent?: number
12  categories: Category[]
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    'context-window': { snapshot: Snapshot | null; paneOpen: boolean }
18  }
19}
20