SLOPSHOPPER

context-lens

A live /context: a pinned line under the prompt with the window fill and the growth per turn, and /context-lens for a pane with the per-category bars and how…

newpanecommandstatus
★ 4v0.1.0MITupdated 2026-09-15Arunjay4213/claude-mods/plugins/context-lens
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-lens
│ ┃ Context lens ✕ › fix the failing auth test and add an audit log call │ ┃ 97.4k of 200k used · 49% │ ┃ window ███████████░░░░░░░░░░░░ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ This build of Claude Code answers $.session… ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ growth nothing measured yet - it ⏺ Bash(bun test) │ ┃ headroom 103k left, no growth rate ⎿ 3 pass, 1 fail │ ┃ limit 200k, the window itself - │ ┃ counted locally, as an estimate - ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /context-lens │ ⎿ context-lens: pane open - /context-lens closes it, /context-lens │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ context-lens: ctx 49% · 97.4k/200k

Draws

Pane · Context lens
97.4k of 200k used · 49% window ███████████░░░░░░░░░░░░ 97.4k 49% This build of Claude Code answers $.session.usage with no c… growth nothing measured yet - it arrives with th headroom 103k left, no growth rate yet limit 200k, the window itself - this build repo counted locally, as an estimate - /context-lens r
README

context-lens

A live /context.

Claude Code's /context is a snapshot you have to ask for. context-lens keeps the same figures on screen: a pinned line under the prompt that updates after every turn, and a pane with the window broken down by category.

What it shows

A pinned status line under the prompt, from the moment the session starts:

ctx 5% · 45.9k/1.0M · +44 last turn · 99+ turns to full

Reading it left to right: how full the context window is, the tokens used against the window size, what the last turn added, and how many more turns fit at the recent average growth. Any figure the engine has not reported yet is left out rather than guessed, so early in a session the line is shorter. Before the first answer comes back it reads ctx waiting for the first answer.

/context-lens opens a pane with the same reading in full:

45.9k of 1.0M used · 5% · claude-opus-5
window          ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  45.9k   5%

messages        █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  22.4k   2%
system tools    █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  14.2k   1%
memory files    █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░   4.9k   0%
system prompt   █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░   3.1k   0%
autocompact bu… ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  45.0k   5%
free space      ████████████████████████████████████████████░░░░   910k  91%

growth          +4.2k last turn, +3.8k average over 5 turns
headroom        12 turns at that rate, 754k left
limit           800k, where auto-compaction runs
counted         locally, as an estimate - /context-lens refresh counts exactly

Each bar is sized to the pane's own width, so it fits the dock and the inline placement alike.

Running /context-lens again closes the pane. /context-lens refresh counts every category with the token-count API, the way /context does, and says so in the counted row.

The /context-lens pane and the pinned ctx line under the prompt

How it reads the window

Everything comes from one call, $.session.usage.

After each finished turn the mod asks for breakdown: "summary", which estimates locally and sends no requests, so the line costs nothing. /context-lens refresh asks for breakdown: "full", which sends one token-count request per tool and memory file. That is the only thing in this mod that spends anything, and it only runs when you ask for it.

The growth figures come from a small in-memory list of the token count after each turn. A compaction clears it, since the window it measured no longer exists.

The compaction line prefers the engine's own autoCompactThreshold from the breakdown. When auto-compaction is off it falls back to the compaction window (rawMaxTokens), and when there is no breakdown at all it falls back to the context window size and says which it used, so the number is never presented as more than it is.

Install

claude plugin marketplace add Arunjay4213/claude-mods
claude plugin install context-lens@claude-mods

Mods are early access, so the module only loads when the function-hooks flag is set. Add it to ~/.claude/settings.json:

{
  "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" }
}

or set it on the command line for one run:

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude

To run it from a checkout instead:

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir plugins/context-lens

Limitations

  • The per-category bars need $.session.usage({ breakdown }).

Claude Code 2.1.272 and later answer it with the categories and the auto-compaction threshold, and the pane draws them. Claude Code 2.1.270 and earlier answer with only tokens, window and percent, so on those builds the pane draws the window bar alone, says so in place of the category rows, and measures headroom against the whole window.

  • tokens and percent come from the last API response, so a fresh or just-compacted session has no reading until its next answer.
  • The turns estimate is a straight line through the recent turns that grew. One large file read moves it a long way; it is a rough guide, not a forecast.
  • The pane is placed by the engine: docked beside the transcript in the fullscreen layout from 110 columns, inline above the prompt otherwise. Inline it gets about a third of the screen, so a long category list scrolls.
  • One pinned status line per plugin, and the engine draws it with its own notice mark.
Source 2 files
hooks/register.tsx 328 lines
1/* @jsx h */
2import type {
3  EngineInterface,
4  Register,
5  SessionContextBreakdown,
6  ContextCategory,
7} from 'claude-code'
8
9import { fit, filledCells, right, signed, tokens } from './format'
10
11// context-lens: a live /context.
12//
13// A pinned line under the prompt carries the window fill and what the last turn added;
14// `/context-lens` opens a pane with the same figures broken down by category. Every
15// reading comes from `$.session.usage`, asked with `breakdown: "summary"` (free, local)
16// after each turn, and with `"full"` only when the user runs `/context-lens refresh`.
17
18const PANE_ID = 'context-lens'
19const COMMAND = 'context-lens'
20const HISTORY_MAX = 24
21const AVERAGE_OVER = 5
22const STATUS_MAX = 70
23const BAR_MAX = 48
24
25type Snapshot = {
26  used: number
27  window: number
28  percent: number
29  /** The token count the headroom is measured against. */
30  threshold: number
31  /** Where that figure came from: the engine's own threshold, the compaction window, or the plain window. */
32  limit: 'auto-compact' | 'compaction-window' | 'window'
33  breakdown: SessionContextBreakdown | null
34  detail: 'summary' | 'full'
35}
36
37let snap: Snapshot | null = null
38/** Tokens in the window after each finished turn, since the last compaction. */
39let history: number[] = []
40let paneOpen = false
41/** True from a compaction or /clear until the next answer gives a fresh reading. */
42let awaitingReading = false
43
44/** Reads the window. Keeps the last good snapshot when the call fails. */
45async function read($: EngineInterface, detail: 'summary' | 'full'): Promise<void> {
46  let context
47  try {
48    context = (await $.session.usage({ breakdown: detail })).context
49  } catch {
50    return
51  }
52  const breakdown = context.breakdown ?? snap?.breakdown ?? null
53  const used = context.tokens ?? breakdown?.totalTokens
54  const window = context.window || breakdown?.rawMaxTokens || 0
55  if (used === undefined || window <= 0) return
56  const auto =
57    breakdown && breakdown.isAutoCompactEnabled ? breakdown.autoCompactThreshold : undefined
58  snap = {
59    used,
60    window,
61    percent: context.percent ?? Math.round((used / window) * 100),
62    threshold: auto ?? breakdown?.rawMaxTokens ?? window,
63    limit: auto !== undefined ? 'auto-compact' : breakdown ? 'compaction-window' : 'window',
64    breakdown,
65    detail: context.breakdown ? detail : (snap?.detail ?? detail),
66  }
67}
68
69/** What the last turn added, and the average of the recent turns that grew. */
70function growth(): { last?: number; average?: number; over: number } {
71  const deltas: number[] = []
72  for (let i = 1; i < history.length; i++) deltas.push((history[i] ?? 0) - (history[i - 1] ?? 0))
73  const rising = deltas.filter(d => d > 0).slice(-AVERAGE_OVER)
74  const average = rising.length
75    ? rising.reduce((a, b) => a + b, 0) / rising.length
76    : undefined
77  return { last: deltas.at(-1), average, over: rising.length }
78}
79
80/** Turns of the recent average that still fit before the threshold. */
81function turnsLeft(average: number | undefined): number | undefined {
82  if (!snap || average === undefined || average <= 0) return undefined
83  return Math.max(0, Math.floor((snap.threshold - snap.used) / average))
84}
85
86/** `3 turns`, and `99+ turns` past a hundred: a five-digit count says nothing useful. */
87function turnCount(n: number): string {
88  return n > 99 ? '99+ turns' : `${n} turn${n === 1 ? '' : 's'}`
89}
90
91function statusText(): string {
92  // The line is pinned from the session's first moment, even before a reading exists.
93  if (!snap) return awaitingReading ? 'ctx waiting for the next answer' : 'ctx waiting for the first answer'
94  const parts = [`ctx ${snap.percent}%`, `${tokens(snap.used)}/${tokens(snap.window)}`]
95  const { last, average } = growth()
96  if (last !== undefined) parts.push(`${signed(last)} last turn`)
97  const left = turnsLeft(average)
98  const edge = snap.limit === 'auto-compact' ? 'compact' : 'full'
99  if (left !== undefined) {
100    parts.push(left === 0 ? `${edge} next turn` : `${turnCount(left)} to ${edge}`)
101  }
102  const line = parts.join(' · ')
103  return line.length > STATUS_MAX ? `${line.slice(0, STATUS_MAX - 1)}…` : line
104}
105
106/** Pins the line and redraws the pane, if it is open. Never throws. */
107function show($: EngineInterface): void {
108  try {
109    $.ui.status(statusText())
110  } catch {
111    /* a status line that will not pin costs nothing else */
112  }
113  if (!paneOpen) return
114  try {
115    $.ui.invalidate('ui.render')
116  } catch {
117    /* the next turn redraws it */
118  }
119}
120
121/** Used rows biggest first, then the compaction buffer, then the free space. */
122function rowsOf(breakdown: SessionContextBreakdown): ContextCategory[] {
123  const rank = (c: ContextCategory) => (c.kind === 'free' ? 2 : c.kind === 'buffer' ? 1 : 0)
124  return breakdown.categories
125    .filter(c => !c.isDeferred && c.tokens > 0)
126    .sort((a, b) => rank(a) - rank(b) || b.tokens - a.tokens)
127}
128
129/**
130 * After a compaction or /clear the window was rewritten: the old reading and the
131 * old per-turn growth say nothing about the new one. The engine reports no token
132 * count until the next answer, so the line says it is waiting rather than showing
133 * the stale figures as current.
134 */
135async function forget($: EngineInterface): Promise<void> {
136  snap = null
137  history = []
138  awaitingReading = true
139  await read($, 'summary')
140  // read() may have set snap again; the cast tells the type checker so. The
141  // growth history restarts from the next finished turn, as at session start.
142  if ((snap as Snapshot | null) !== null) awaitingReading = false
143  show($)
144}
145
146export const register: Register = on => {
147  on('session.start', async ($, e, next) => {
148    const result = await next(e)
149    try {
150      await $.command.register({
151        name: COMMAND,
152        description: 'Live /context: the window by category, growth per turn, turns to compaction',
153        argumentHint: '[refresh]',
154      })
155    } catch (error) {
156      $.ui.log(`context-lens: /${COMMAND} not registered: ${String(error)}`)
157    }
158    // The reading at start is the engine's estimate of the window; the growth
159    // history starts from the first finished turn, so a real count is never
160    // compared against an estimate.
161    await read($, 'summary')
162    show($)
163    return result
164  })
165
166  on('turn.complete', async ($, e, next) => {
167    const result = await next(e)
168    // A subagent's turn is answered over its own window, not this one.
169    if (e.agentId !== undefined) return result
170    await read($, 'summary')
171    if (snap) {
172      awaitingReading = false
173      history.push(snap.used)
174      if (history.length > HISTORY_MAX) history = history.slice(-HISTORY_MAX)
175    }
176    show($)
177    return result
178  })
179
180  on('session.compact', async ($, e, next) => {
181    const result = await next(e)
182    await forget($)
183    return result
184  })
185
186  on('command.run', { command: 'clear' }, async ($, e, next) => {
187    const result = await next(e)
188    await forget($)
189    return result
190  })
191
192  on('command.run', { command: COMMAND }, async ($, e, next) => {
193    if (e.args.trim().toLowerCase() === 'refresh') {
194      await read($, 'full')
195      if (!paneOpen) {
196        try {
197          await $.ui.open({ id: PANE_ID, title: 'Context lens', rows: 18 })
198          paneOpen = true
199        } catch {
200          /* fall through: the counted figures still land on the status line */
201        }
202      }
203      show($)
204      return { text: 'counted with the token-count API' }
205    }
206    if (paneOpen) {
207      try {
208        await $.ui.close({ id: PANE_ID })
209      } catch {
210        return { text: 'the pane would not close' }
211      }
212      paneOpen = false
213      return { text: 'pane closed' }
214    }
215    if (!snap) await read($, 'summary')
216    try {
217      await $.ui.open({ id: PANE_ID, title: 'Context lens', rows: 18 })
218    } catch (error) {
219      return { text: `the pane would not open (${String(error)})` }
220    }
221    paneOpen = true
222    show($)
223    return { text: 'pane open - /context-lens closes it, /context-lens refresh counts exactly' }
224  })
225
226  on('ui.close', { id: PANE_ID }, async ($, e, next) => {
227    const result = await next(e)
228    paneOpen = false
229    return result
230  })
231
232  on('ui.render', { component: 'Pane' }, ($, e, next) => {
233    if (e.requestId !== PANE_ID) return next(e)
234    // Being asked to draw is proof the pane is open: after a hot reload this is how the
235    // module learns it again, so the toggle and the per-turn redraw stay in step.
236    paneOpen = true
237    const { Box, Text } = $.ui.resolve(e)
238    const columns = Math.max(24, e.props.bodyColumns)
239
240    if (!snap) {
241      return (
242        <Box flexDirection="column">
243          <Text dimColor>No reading of the context window yet. It arrives with the first answer.</Text>
244        </Box>
245      )
246    }
247
248    // label, bar, tokens, percent. The bar takes what is left, and stops growing past
249    // BAR_MAX so a docked pane on a wide screen does not draw a bar the width of the screen.
250    const labelW = Math.min(19, Math.max(7, columns - 26))
251    const barW = Math.min(BAR_MAX, Math.max(4, columns - labelW - 14))
252    const { last, average, over } = growth()
253    const left = turnsLeft(average)
254    const breakdown = snap.breakdown
255    const base = breakdown?.rawMaxTokens || snap.window
256    const spare = Math.max(0, snap.threshold - snap.used)
257
258    const row = (key: string, label: string, share: number, count: number, color: string) => {
259      const lit = filledCells(share, barW)
260      return (
261        <Box key={key} flexDirection="row">
262          <Text wrap="truncate-end">{fit(label, labelW)}</Text>
263          <Text color={color}>{'█'.repeat(lit)}</Text>
264          <Text color="inactive">{'░'.repeat(barW - lit)}</Text>
265          <Text>{right(tokens(count), 7)}</Text>
266          <Text dimColor>{right(`${Math.round(share * 100)}%`, 5)}</Text>
267        </Box>
268      )
269    }
270
271    const note = (label: string, text: string, dim?: true) => (
272      <Box flexDirection="row">
273        <Text dimColor>{fit(label, labelW)}</Text>
274        <Text dimColor={dim} wrap="truncate-end">{text}</Text>
275      </Box>
276    )
277
278    const limitText =
279      snap.limit === 'auto-compact'
280        ? `${tokens(snap.threshold)}, where auto-compaction runs`
281        : snap.limit === 'compaction-window'
282          ? `${tokens(snap.threshold)}, the compaction window (auto-compaction is off)`
283          : `${tokens(snap.threshold)}, the window itself - this build reports no compaction threshold`
284
285    return (
286      <Box flexDirection="column">
287        <Text bold wrap="truncate-end">
288          {`${tokens(snap.used)} of ${tokens(snap.window)} used · ${snap.percent}%${breakdown ? ` · ${breakdown.model}` : ''}`}
289        </Text>
290        {row('total', 'window', snap.used / snap.window, snap.used, 'success')}
291        <Text> </Text>
292        {breakdown ? (
293          <Box flexDirection="column">
294            {rowsOf(breakdown).map(c =>
295              row(`cat:${c.name}`, c.name.toLowerCase(), c.tokens / base, c.tokens, c.color),
296            )}
297          </Box>
298        ) : (
299          <Text dimColor wrap="truncate-end">
300            This build of Claude Code answers $.session.usage with no category breakdown, so only the window bar is drawn.
301          </Text>
302        )}
303        <Text> </Text>
304        {note(
305          'growth',
306          last === undefined
307            ? 'nothing measured yet - it arrives with the second answer'
308            : `${signed(last)} last turn${average === undefined ? '' : `, ${signed(average)} average over ${over} turn${over === 1 ? '' : 's'}`}`,
309        )}
310        {note(
311          'headroom',
312          left === undefined
313            ? `${tokens(spare)} left, no growth rate yet`
314            : `${turnCount(left)} at that rate, ${tokens(spare)} left`,
315        )}
316        {note('limit', limitText)}
317        {note(
318          'counted',
319          snap.detail === 'full'
320            ? 'exactly, with the token-count API'
321            : 'locally, as an estimate - /context-lens refresh counts exactly',
322          true,
323        )}
324      </Box>
325    )
326  })
327}
328
hooks/format.ts 37 lines
1// Plain formatting helpers for the status line and the pane. No engine calls here,
2// so nothing in this file can fail a session.
3
4/** `68412` as `68.4k`, `1240000` as `1.2M`; small numbers as they are. */
5export function tokens(n: number): string {
6  const v = Math.round(n)
7  if (Math.abs(v) >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`
8  if (Math.abs(v) >= 100_000) return `${Math.round(v / 1000)}k`
9  if (Math.abs(v) >= 1000) return `${(v / 1000).toFixed(1)}k`
10  return String(v)
11}
12
13/** The same, with a sign kept: `+4.2k`, `-1.1k`, `0`. */
14export function signed(n: number): string {
15  if (Math.round(n) === 0) return '0'
16  return n > 0 ? `+${tokens(n)}` : `-${tokens(-n)}`
17}
18
19/** Pads or clips `text` to exactly `width` cells. */
20export function fit(text: string, width: number): string {
21  if (width <= 0) return ''
22  if (text.length === width) return text
23  if (text.length < width) return text + ' '.repeat(width - text.length)
24  return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`
25}
26
27/** Right-aligns `text` in `width` cells. */
28export function right(text: string, width: number): string {
29  return text.length >= width ? text : ' '.repeat(width - text.length) + text
30}
31
32/** How many of `width` cells a share of `0..1` fills; a non-zero share always shows one. */
33export function filledCells(share: number, width: number): number {
34  if (!(share > 0) || width <= 0) return 0
35  return Math.min(width, Math.max(1, Math.round(share * width)))
36}
37