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

context-meter and usage-meter status lines. This repo is a Claude Code plugin marketplace.hooks/register.tsx 130 lines1import { 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}
130hooks/fill.ts 66 lines1import 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)}`
66types/index.d.ts 11 lines1export 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