SLOPSHOPPER

Context View

The context window as one row above the prompt, drawn the way Claude Code draws its own meters: the context's cells, the percentage used, the tokens over the…

newbandcommandtimer
★ 1v0.3.0MITupdated 2026-10-07kongyo2/context-view
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-view
› 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-view ⎿ context-view: Context view hidden ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

context-view

The context window as a plugin: one row in the band above the prompt, drawn the way Claude Code draws its own meters. The cells the context fills come first in ▰, the window left in dim ▱, and the reserve auto-compact keeps at the window's end in dim ▰; then the percentage used, the tokens over the window, and the tokens left before auto-compact, joined by Claude Code's byline separator.

▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▰▰▰ 42% · 84.1k/200k tokens · 82.9k until auto-compact

The row follows the session after every main-thread turn and after every request of a running turn, so it moves while a long turn works; a subagent's requests are left out. It reads the window again whenever the window itself moves: after /clear, a resume or a branch, a compaction (/compact, auto-compact, or a plugin's), a model switch (/model, the picker, a fallback), and a change to auto-compact (/autocompact, or the /config toggle). The engine can land those after the hook that sees them has returned, so the row reads them a tenth of a second later, and twice more after that for a busy machine. Every two seconds it also compares the engine's live figures with its reading, without asking for a breakdown, so a change no event announces, such as a rewound conversation, shows within two seconds.

A request in which Claude consulted the advisor is read differently. The API runs the advisor inside that one request, the main model reading the conversation once before the advice and again after it, and reports the request's usage summed over those passes, so its count is about twice the window's. For such a request the row takes the engine's own figure, the last pass, as the status line and /context count it, and reads it again a tenth of a second later and twice more, in case the engine lands it late; it never shows the summed count, not even for a moment.

Until a response of the window has landed (a new session, and the window after /clear or a compaction), the row shows the engine's local estimate, the total /context shows, each figure marked with ~ as Claude Code marks an approximation:

▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▰ ~3% · ~33.4k/1m tokens · ~933.6k until auto-compact

/context-view hides the row and shows it again, at once even while a turn runs, leaving Context view hidden or Context view shown in the transcript. /context-view hide and /context-view show (or off and on) set it whatever it was, and any other word is answered with Usage: /context-view [show|hide]. The choice holds in later sessions.

The meter and the percentage are drawn in the blue Claude Code fills its usage meters with, the tokens and the headroom dim. From six tenths of the way to the auto-compact threshold they turn to Claude Code's warning colour, the headroom with them, and within the last 20k tokens before it, where Claude Code's own line under the prompt appears, to its error colour; past the threshold the headroom reads auto-compact next. The headroom counts the last reply too, which the next request carries, as Claude Code counts it toward its threshold. Every colour is a theme key (permission, warning, error, and the dim text's), so the row follows the dark, light, daltonized and ANSI themes; nothing is bold.

The reserve is the one /context draws. Where Claude Code compacts only once the API refuses a full window, as it runs a 200k model with no compaction window set, /context keeps none and neither does the row: the headroom counts down to the window's end.

▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱ 80% · 160k/200k tokens · 40k until auto-compact

With auto-compact off, the reserve is the room Claude Code keeps for a reply and for a manual /compact, and the headroom counts down to where it stops sending requests (92.9k before the limit), then asks for run /compact to continue as Claude Code's own line does.

The meter spans the model's whole window, as the percentage and the tokens do. Where a setting caps the window compaction measures against below the model's (/autocompact 150000, or autoCompactWindow), everything past the threshold is reserve, so the meter shows how little of a large window is left before auto-compact runs:

▱▱▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰ ~3% · ~33.9k/1m tokens · ~83.1k until auto-compact

The row starts at the prompt's text column, where the hints under the prompt start, and runs to the end of the columns Claude Code gives the band, which leave out the [-] it draws at the band's right end. As the band narrows, the meter steps down from 20 cells to 12 and 8, as Claude Code's progress rows narrow their bars, and every word stays; once even that does not fit, the headroom goes, then the tokens, the meter widening again each time, and the percentage stays. In Ghostty the meter is drawn in █ and ░, as Claude Code draws its own there. Nothing is drawn while a survey holds the band, while the row is hidden, or while the engine has neither a figure nor an estimate. Every mod shares the band, so what the mods after this one draw there, Claude Code's own notes among them, stays below the row.

Counts print as Claude Code prints them (84.1k, 200k, 1m). The percentage is the status line's: the last response's input tokens over the model's window. Claude Code's own context used line, which it shows instead of a countdown where compaction waits for the API, counts against the window less the room it keeps for a reply, so it reads higher (89% context used where the row reads 80%). The threshold and the reserve come from the breakdown /context draws, estimated locally with no request sent; where no breakdown answers, the 33k Claude Code usually keeps stands in. A reading the engine cannot give, a command it will not register, and a choice it cannot save go to the debug log (claude --debug) under the plugin's name, and the row keeps its last reading. A choice the store cannot read back leaves the row as it was. A change to autoCompactEnabled made by editing a settings file, rather than in /config, shows after the next turn.

Where Claude Code's built-in guard runs, with managed settings or a Team or Enterprise plan, the settings-hook events (classic.*) don't reach a mod a person installs. The row also follows /clear, /resume, /branch and /model through the commands themselves, and the check every two seconds covers the rest.

hooks/register.ts is the module; everything under hooks/ is its parts, importing claude-code and one another alone. types/index.d.ts declares the values the module keeps in $.state, so they survive a hot reload and each write draws the row again.

What it changes

Nothing but the band. Apart from drawing the row and answering its own /context-view, every hook passes its event on and hands back what Claude Code, and any settings hook or mod after it, answered, unchanged: no prompt, tool call, permission, setting, command, compaction or first message of a session is rewritten, answered, refused or added to. The hooks listed below that sit on a settings-hook event, or on an event that is also the name of a call on $, say so, and what each does there.

What it hooks

eventwhat the hook does
session.startRegisters /context-view; once the session is up, reads which marks the terminal draws the meter with, copies the person's choice from the store, takes the first reading, and starts the check of the engine's live figures every two seconds. Hands back the start's own answer.
classic.SessionStart of clear, resume, forkCopies the choice again and takes a reading, as those reset $.state, and reads again once a resumed session has been swapped in. A settings-hook event, and the hook changes nothing in it: it passes the event on with return next(e), so the SessionStart hooks in your settings run as they would without the mod, and what they answer (context for Claude, a session title, the session's first message, paths to watch) reaches the session as they wrote it.
classic.PostModelSwitchReads the new model's window once the engine has moved to it. A settings-hook event, and the hook changes nothing in it: it passes the event on with return next(e), so the PostModelSwitch hooks in your settings run, and the context they add reaches Claude, as without the mod.
session.measureAfter a main-thread turn whose context moved: takes a reading, the fill from the figures the engine pushed.
turn.stepAfter each request of the main loop: the input tokens it was answered over are the fill at that moment; for a request in which the API ran a tool of its own (the advisor), whose usage is the loop's sum, the engine's own figure instead. The response streams through as it came and is handed back unchanged.
session.compact of manual, auto, pluginAfter a compaction of the main window that stands (not one computed ahead of time, a subagent's, or one vetoed): reads the window once the engine has landed it. The event is also the call $.session.compact, so the hook sees a compaction whoever asks for it, /compact, auto-compact or another plugin; it never skips, rewrites or answers one, and hands back what Claude Code answered.
config.set of autoCompactReads the window once /config has turned auto-compact on or off. The event is also the call $.config.set, so the hook sees that one row change whoever makes it; it passes the change on with return next(e) and hands back what Claude Code answered, a refusal included.
command.run of clear, resume, branchPasses the command on, then copies the choice again and reads the window, as the classic.SessionStart hook does where the guard holds that event back. The event is also the call $.command.run, so the hook sees these three commands whoever runs them, you or another plugin; it never answers or rewrites one, and hands back Claude Code's own result.
command.run of autocompact, modelPasses the command on, then reads the window it set. As above, it sees these commands whoever runs them, never answers or rewrites one, and hands back Claude Code's own result.
command.run of context-viewAnswers the plugin's own command: hides or shows the row as asked, keeps the choice in the store, and says which.
ui.render of AbovePromptDraws the row above what the mods after it draw; passes while a survey holds the band, while the row is hidden, and while there is nothing to draw.

What it calls on $

clock.after (the readings after a change the engine lands late), clock.every (the check of the live figures), command.register, env.get (TERM and TERM_PROGRAM, once a load, to tell Ghostty), session.usage (with breakdown: 'summary', estimated locally; the plain figures where that fails), state.get, state.set, store.get, store.set, ui.log (to the debug log alone) and ui.resolve.

What it reads and sends

It reads the session's own context figures through $.session.usage, the TERM and TERM_PROGRAM environment variables, and the one choice it keeps in its store; it writes that choice to its store and its failures to the debug log. It reads no file, starts no process, calls no model and makes no network request, so nothing it reads leaves the machine.

Try it

claude --plugin-dir /path/to/context-view

The row appears above the prompt as soon as the session is up: the estimate in a new session, the restored figure in a resumed one. Claude Code 2.1.290 or later; tested on 2.1.292.

Install

This repository is its own marketplace. In a Claude Code session:

/plugin install context-view --marketplace kongyo2/context-view

Answer y to add the marketplace, then pick a scope. Or in three steps:

/plugin marketplace add kongyo2/context-view
/plugin install context-view@context-view
/reload-plugins

Testing

claude plugin test .

Each file under tests/ covers the file of its name under hooks/. tests/register.test.ts drives the module through a session's events (the start, the estimate before the first response, measurements, a running turn's requests and their replies, a request that consulted the advisor and one whose figure lands late, /clear, a resume, a branch, compactions of each kind, a model switch, /config and /autocompact, a rewind, a reading that resolves late, /context-view and its words, a survey, another mod's note, the built-in guard, what each hook hands back, failed readings, refused calls, Ghostty) on the terminal and the desktop, and tests/views/band.test.ts reads the drawn row at each width and level, as an estimate, and over a capped window.

claude plugin validate --strict .claude-plugin/plugin.json

reports what the module hooks and calls; claude plugin validate . reads this folder as the marketplace it also is.

Source 69 files
hooks/register.ts 320 lines
1import { atom, read, update } from 'claude-code'
2import type {
3  EngineInterface,
4  On,
5  SessionContextUsage,
6  SessionUsage,
7  Timer,
8  TurnUsage,
9} from 'claude-code'
10
11import Command from './command'
12import Glyphs from './glyphs'
13import Limits from './limits'
14import { messageOf } from './message-of'
15import Names from './names'
16import Readings from './readings'
17import Views from './views'
18
19const contextWindow = atom(
20  { plugin: 'context-view', key: 'window' } as const,
21  null,
22)
23
24const contextFill = atom({ plugin: 'context-view', key: 'fill' } as const, null)
25
26const isHidden = atom(
27  { plugin: 'context-view', key: 'isHidden' } as const,
28  false,
29)
30
31let latest = 0
32
33let knownHidden: boolean | undefined
34
35export function register(on: On): void {
36  let glyphs: Glyphs.GlyphSet = Glyphs.PILL_GLYPHS
37  let rechecking: Timer | undefined
38
39  on('session.start', async ($, e, next) => {
40    try {
41      await $.command.register({
42        name: 'context-view',
43        description: Names.COMMAND_DESCRIPTION,
44        argumentHint: Names.COMMAND_ARGUMENT_HINT,
45        immediate: true,
46      })
47    } catch (error) {
48      $.ui.log(Names.registerFailedTextOf(messageOf(error)), { to: 'debug' })
49    }
50
51    const result = await next(e)
52
53    glyphs = await glyphsFor($)
54    await loadHidden($)
55    await measure($)
56
57    rechecking?.cancel()
58    rechecking = recheckEvery($)
59
60    return result
61  })
62
63  on(
64    'classic.SessionStart',
65    { source: ['clear', 'resume', 'fork'] },
66    async ($, e, next) => {
67      await loadHidden($)
68      await measure($)
69      settle($)
70
71      return next(e)
72    },
73  )
74
75  on(
76    'command.run',
77    { command: ['clear', 'resume', 'branch'] },
78    async ($, e, next) => {
79      const result = await next(e)
80
81      await loadHidden($)
82      await measure($)
83      settle($)
84
85      return result
86    },
87  )
88
89  on('session.measure', async ($, e, next) => {
90    const result = await next(e)
91
92    if (e.changed.includes('context')) {
93      await measure($, e.context)
94    }
95
96    return result
97  })
98
99  on('turn.step', async function* ($, e, next) {
100    const result = yield* next(e)
101
102    if (e.agentId !== undefined || !result.usage) {
103      return result
104    }
105
106    if (Readings.isLoopTotal(result)) {
107      await measure($)
108      settle($)
109    } else {
110      await follow($, result.usage)
111    }
112
113    return result
114  })
115
116  on(
117    'session.compact',
118    { trigger: ['manual', 'auto', 'plugin'] },
119    async ($, e, next) => {
120      const result = await next(e)
121
122      if (e.agentId === undefined && result.skip === undefined) {
123        settle($)
124      }
125
126      return result
127    },
128  )
129
130  on('classic.PostModelSwitch', ($, e, next) => {
131    settle($)
132
133    return next(e)
134  })
135
136  on('config.set', { key: 'autoCompact' }, ($, e, next) => {
137    settle($)
138
139    return next(e)
140  })
141
142  on(
143    'command.run',
144    { command: ['autocompact', 'model'] },
145    async ($, e, next) => {
146      const result = await next(e)
147
148      settle($)
149
150      return result
151    },
152  )
153
154  on('command.run', { command: 'context-view' }, async ($, e) => {
155    const hidden = Command.hiddenOf(e.args, await read($, isHidden))
156
157    if (hidden === null) {
158      return { text: Names.USAGE_TEXT }
159    }
160
161    knownHidden = hidden
162    await update($, isHidden, () => hidden)
163
164    await $.store
165      .set(Names.STORE_HIDDEN_KEY, hidden)
166      .catch(error =>
167        $.ui.log(Names.saveFailedTextOf(messageOf(error)), { to: 'debug' }),
168      )
169
170    return { text: hidden ? Names.HIDDEN_TEXT : Names.SHOWN_TEXT }
171  })
172
173  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
174    const measured = await read($, contextWindow)
175    const filled = await read($, contextFill)
176    const hidden = await read($, isHidden)
177
178    if (e.props.hasSurvey || hidden || !measured || !filled) {
179      return next(e)
180    }
181
182    const { Box, Text } = $.ui.resolve(e)
183
184    return Views.band(
185      {
186        ui: { Box, Text },
187        columns: e.props.bodyColumns,
188        glyphs: e.surface === 'terminal' ? glyphs : Glyphs.PILL_GLYPHS,
189      },
190      measured,
191      filled,
192      await next(e),
193    )
194  })
195}
196
197async function glyphsFor($: EngineInterface): Promise<Glyphs.GlyphSet> {
198  const term = await $.env.get('TERM').catch(() => undefined)
199  const program = await $.env.get('TERM_PROGRAM').catch(() => undefined)
200
201  return Glyphs.glyphsOf(term, program)
202}
203
204async function loadHidden($: EngineInterface): Promise<void> {
205  const hidden = await $.store.get(Names.STORE_HIDDEN_KEY).then(
206    stored => stored === true,
207    () => knownHidden,
208  )
209
210  if (hidden === undefined) {
211    return
212  }
213
214  knownHidden = hidden
215  await update($, isHidden, () => hidden).catch(() => undefined)
216}
217
218async function measure(
219  $: EngineInterface,
220  context?: SessionContextUsage,
221): Promise<void> {
222  latest += 1
223
224  const ticket = latest
225
226  try {
227    const usage = await usageOf($)
228    const measured = Readings.windowOf(usage.context)
229    const filled = Readings.fillOf(
230      context?.tokens === undefined
231        ? usage.context
232        : { ...context, breakdown: usage.context.breakdown },
233    )
234
235    if (ticket === latest) {
236      await update($, contextWindow, () => measured)
237    }
238
239    if (ticket === latest) {
240      await update($, contextFill, () => filled)
241    }
242  } catch (error) {
243    $.ui.log(Names.readingFailedTextOf(messageOf(error)), { to: 'debug' })
244  }
245}
246
247async function usageOf($: EngineInterface): Promise<SessionUsage> {
248  try {
249    return await $.session.usage({ breakdown: 'summary' })
250  } catch {
251    return $.session.usage()
252  }
253}
254
255async function follow($: EngineInterface, usage: TurnUsage): Promise<void> {
256  try {
257    if (!(await read($, contextWindow))) {
258      await measure($)
259    }
260
261    const measured = await read($, contextWindow)
262
263    if (!measured) {
264      return
265    }
266
267    latest += 1
268
269    const tokens = Readings.tokensOf(usage)
270    const percent = Readings.percentOf(tokens, measured.window)
271
272    await update($, contextFill, () => ({
273      tokens,
274      percent,
275      isEstimate: false,
276      output: usage.output_tokens,
277    }))
278  } catch (error) {
279    $.ui.log(Names.readingFailedTextOf(messageOf(error)), { to: 'debug' })
280  }
281}
282
283function settle($: EngineInterface): void {
284  try {
285    for (const ms of Limits.SETTLE_DELAYS_MS) {
286      $.clock.after(ms, () => void measure($))
287    }
288  } catch (error) {
289    $.ui.log(Names.readingFailedTextOf(messageOf(error)), { to: 'debug' })
290  }
291}
292
293function recheckEvery($: EngineInterface): Timer | undefined {
294  try {
295    return $.clock.every(Limits.RECHECK_MS, () => void recheck($))
296  } catch (error) {
297    $.ui.log(Names.readingFailedTextOf(messageOf(error)), { to: 'debug' })
298
299    return undefined
300  }
301}
302
303async function recheck($: EngineInterface): Promise<void> {
304  try {
305    const { context } = await $.session.usage()
306    const measured = await read($, contextWindow)
307    const filled = await read($, contextFill)
308
309    if (!measured) {
310      await loadHidden($)
311    }
312
313    if (Readings.isOutdated(context, measured, filled)) {
314      await measure($)
315    }
316  } catch {
317    return
318  }
319}
320
hooks/command/index.ts 4 lines
1export * from './hidden-of.js'
2
3export * as default from '.'
4
hooks/glyphs/index.ts 10 lines
1export * from './block-glyphs.js'
2export * from './estimate-mark.js'
3export * from './gap.js'
4export * from './glyph-set.js'
5export * from './glyphs-of.js'
6export * from './pill-glyphs.js'
7export * from './separator.js'
8
9export * as default from '.'
10
hooks/limits/index.ts 13 lines
1export * from './band-inset.js'
2export * from './bar-cells.js'
3export * from './default-reserve-tokens.js'
4export * from './empty-cell-share.js'
5export * from './full-cell-share.js'
6export * from './low-headroom-tokens.js'
7export * from './recheck-ms.js'
8export * from './reply-reserve-tokens.js'
9export * from './settle-delays-ms.js'
10export * from './warning-share.js'
11
12export * as default from '.'
13
hooks/message-of/index.ts 4 lines
1export * from './message-of.js'
2
3export * as default from '.'
4
hooks/names/index.ts 19 lines
1export * from './before-limit-text.js'
2export * from './command-argument-hint.js'
3export * from './command-description.js'
4export * from './compact-next-text.js'
5export * from './hidden-text.js'
6export * from './hide-words.js'
7export * from './reading-failed-text-of.js'
8export * from './register-failed-text-of.js'
9export * from './run-compact-text.js'
10export * from './save-failed-text-of.js'
11export * from './show-words.js'
12export * from './shown-text.js'
13export * from './store-hidden-key.js'
14export * from './tokens-text.js'
15export * from './until-compact-text.js'
16export * from './usage-text.js'
17
18export * as default from '.'
19
hooks/readings/index.ts 9 lines
1export * from './fill-of.js'
2export * from './is-loop-total.js'
3export * from './is-outdated.js'
4export * from './percent-of.js'
5export * from './tokens-of.js'
6export * from './window-of.js'
7
8export * as default from '.'
9
hooks/views/index.ts 12 lines
1export * from './band.jsx'
2export * from './bar.jsx'
3export * from './fit.js'
4export * from './fit-of.js'
5export * from './format-count.js'
6export * from './headroom-text-of.js'
7export * from './kit.js'
8export * from './segment.js'
9export * from './segments-of.js'
10
11export * as default from '.'
12
hooks/command/hidden-of.ts 20 lines
1import Names from '../names'
2
3export function hiddenOf(args: string, isHidden: boolean): boolean | null {
4  const word = args.trim().toLowerCase()
5
6  if (word === '') {
7    return !isHidden
8  }
9
10  if (Names.SHOW_WORDS.includes(word)) {
11    return false
12  }
13
14  if (Names.HIDE_WORDS.includes(word)) {
15    return true
16  }
17
18  return null
19}
20
hooks/glyphs/block-glyphs.ts 4 lines
1import type { GlyphSet } from './glyph-set.js'
2
3export const BLOCK_GLYPHS: GlyphSet = { fill: '█', empty: '░' }
4
hooks/glyphs/estimate-mark.ts 2 lines
1export const ESTIMATE_MARK = '~'
2
hooks/glyphs/gap.ts 2 lines
1export const GAP = '  '
2