SLOPSHOPPER

context-meter

How full the context window is, as a colored bar above the prompt.

newbandcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-meter
› 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-meter ⎿ context-meter: GREEN Context: 49% · 97k of 200k tokens Ctx █████████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 49% · 97k of 200k tokens ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Ctx █████████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 49% · 97k of 200k tokens ⟨Claude Code's own drawing⟩
README

agentic-extensions

  • claude-mods — Claude Code mods: context-meter and usage-meter status lines. This repo is a Claude Code plugin marketplace.
Source 3 files
hooks/register.tsx 130 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ContextReading, InputSplit } from '../types'
5
6import { COLORS, detail, levelOf, ORANGE_FROM, percentOf, segments, splitDetail } from './fill'
7
8const live = atom({ plugin: 'context-meter', key: 'context' } as const, null)
9const inputSplit = atom({ plugin: 'context-meter', key: 'split' } as const, null)
10
11const LABEL = 5
12/** Columns left free at the row's end, so a surface that measures a little narrower still fits it. */
13const RESERVE = 2
14/** The bar never shrinks below this; on a narrow band the text wraps instead. */
15const MIN_BAR = 10
16
17let hasWarned = false
18
19async function record($: EngineInterface, context: ContextReading) {
20  await update($, live, () => context)
21
22  // The band above the prompt is the one display; clear any line an older version pinned.
23  $.ui.status(undefined)
24
25  const percent = percentOf(context)
26  if (percent === undefined) {
27    return
28  }
29
30  if (percent >= ORANGE_FROM && !hasWarned) {
31    hasWarned = true
32    $.ui.toast(`Context ${percent}% full — consider /compact`)
33  }
34  if (percent < ORANGE_FROM) {
35    hasWarned = false
36  }
37}
38
39export const register: Register = on => {
40  on('session.start', async ($, e, next) => {
41    const result = await next(e)
42    await $.command.register({ name: 'context-meter', description: 'Show how full the context window is' })
43    await record($, (await $.session.usage()).context)
44
45    return result
46  })
47
48  on('session.measure', async ($, e, next) => {
49    if (e.changed.includes('context')) {
50      await record($, e.context)
51    }
52
53    return next(e)
54  })
55
56  // Each main-loop response reports its input by source; the last one is the context's make-up.
57  on('turn.step', async function* ($, e, next) {
58    const response = yield* next(e)
59    if (e.agentId === undefined && response.usage !== null) {
60      const split: InputSplit = {
61        cacheRead: response.usage.cache_read_input_tokens,
62        cacheWrite: response.usage.cache_creation_input_tokens,
63        uncached: response.usage.input_tokens,
64      }
65      await update($, inputSplit, () => split)
66    }
67
68    return response
69  })
70
71  on('command.run', { command: 'context-meter' }, async $ => {
72    const context = await read($, live)
73    const split = await read($, inputSplit)
74    const percent = context === null ? undefined : percentOf(context)
75
76    return {
77      text:
78        context === null || percent === undefined
79          ? 'No context reading yet: it arrives with the first reply in a session.'
80          : `${levelOf(percent).toUpperCase()} Context: ${detail(context, percent)}${split === null ? '' : `\n${splitDetail(split)}`}`,
81    }
82  })
83
84  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
85    const below = await next(e)
86    if (e.props.hasSurvey) return below
87
88    const context = await read($, live)
89    const percent = context === null ? undefined : percentOf(context)
90    if (context === null || percent === undefined) return below
91
92    const { Box, Text } = $.ui.resolve(e)
93    const split = await read($, inputSplit)
94    const color = COLORS[levelOf(percent)]
95    // The bar takes every column the label and the text leave, so the row spans the band.
96    const text = ` ${detail(context, percent)}${split === null ? '' : `  ${splitDetail(split)}`}`
97    const width = Math.max(MIN_BAR, e.props.bodyColumns - LABEL - text.length - RESERVE)
98    // One line, never wrapped: label and figures keep their width, and the bar,
99    // in a box clipped to one row, gives up columns first where the band is narrower.
100    const mine = (
101      <Box key="context-meter" flexDirection="row" flexWrap="nowrap">
102        <Box flexShrink={0}>
103          <Text color={color} bold>
104            {'Ctx'.padEnd(LABEL)}
105          </Text>
106        </Box>
107        <Box flexGrow={1} flexShrink={1} minWidth={0} height={1} overflow="hidden">
108          <Text>
109            {segments(percent, split, width).map(cell =>
110              cell.tone === 'empty' ? <Text dimColor>{cell.text}</Text> : <Text color={color}>{cell.text}</Text>,
111            )}
112          </Text>
113        </Box>
114        <Box flexShrink={0}>
115          <Text dimColor>{text}</Text>
116        </Box>
117      </Box>
118    )
119
120    return below ? (
121      <Box flexDirection="column">
122        {mine}
123        {below}
124      </Box>
125    ) : (
126      mine
127    )
128  })
129}
130
hooks/fill.ts 66 lines
1import type { ContextReading as SessionContextUsage, InputSplit } from '../types'
2
3export type Level = 'green' | 'yellow' | 'orange' | 'red'
4
5export const COLORS: Record<Level, string> = {
6  green: '#3fb950',
7  yellow: '#d29922',
8  orange: '#f0883e',
9  red: '#f85149',
10}
11
12
13/** Percent full from which each level starts. */
14export const YELLOW_FROM = 60
15export const ORANGE_FROM = 80
16export const RED_FROM = 95
17
18export const compact = (n: number) =>
19  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
20
21export const percentOf = (context: SessionContextUsage) =>
22  context.tokens === undefined
23    ? undefined
24    : context.percent ?? Math.round((context.tokens / context.window) * 100)
25
26export const levelOf = (percent: number): Level =>
27  percent >= RED_FROM ? 'red' : percent >= ORANGE_FROM ? 'orange' : percent >= YELLOW_FROM ? 'yellow' : 'green'
28
29export const detail = (context: SessionContextUsage, percent: number) =>
30  `${percent}% · ${compact(context.tokens ?? 0)} of ${compact(context.window)} tokens`
31
32export const filledOf = (percent: number, width: number) => Math.round((Math.min(percent, 100) / 100) * width)
33
34export type Cell = { text: string; tone: 'read' | 'write' | 'new' | 'empty' }
35
36export const GLYPHS = { read: '█', write: '▓', new: '▒', empty: '░' } as const
37
38/**
39 * The fill split by where the input came from: cache read, cache written,
40 * uncached. Each part's cells are its share of the fill, rounded.
41 */
42export const segments = (percent: number, split: InputSplit | null, width: number): Cell[] => {
43  const filled = filledOf(percent, width)
44  const total = split === null ? 0 : split.cacheRead + split.cacheWrite + split.uncached
45  const parts: Cell[] = []
46
47  if (total === 0 || split === null) {
48    parts.push({ text: GLYPHS.read.repeat(filled), tone: 'read' })
49  } else {
50    const read = Math.round((split.cacheRead / total) * filled)
51    const write = Math.min(filled - read, Math.round((split.cacheWrite / total) * filled))
52    parts.push(
53      { text: GLYPHS.read.repeat(read), tone: 'read' },
54      { text: GLYPHS.write.repeat(write), tone: 'write' },
55      { text: GLYPHS.new.repeat(filled - read - write), tone: 'new' },
56    )
57  }
58  parts.push({ text: GLYPHS.empty.repeat(width - filled), tone: 'empty' })
59
60  return parts.filter(part => part.text.length > 0)
61}
62
63/** `cached 171k · written 5k · new 2k`, each with the glyph it fills the bar with. */
64export const splitDetail = (split: InputSplit) =>
65  `${GLYPHS.read} cached ${compact(split.cacheRead)}  ${GLYPHS.write} written ${compact(split.cacheWrite)}  ${GLYPHS.new} new ${compact(split.uncached)}`
66
types/index.d.ts 11 lines
1export type ContextReading = { tokens?: number; window: number; percent?: number }
2
3/** The last main-loop response's input, by where it came from. */
4export type InputSplit = { cacheRead: number; cacheWrite: number; uncached: number }
5
6declare module 'claude-code' {
7  interface PluginState {
8    'context-meter': { context: ContextReading | null; split: InputSplit | null }
9  }
10}
11