Side-pane widget showing Claude Code context size, a fill bar, status and session cost, with a nudge to /compact or /clear

This mod now lives in kk5190/claude-code-mods, alongside the other mods.
Install it from there:
/plugin install context-meter --marketplace kk5190/claude-code-mods
This repository is archived and no longer updated.
hooks/register.tsx 99 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Reading } from '../types'
5
6// Every turn re-sends the whole context, so absolute size drives cost more than percent of the window.
7const WARN_TOKENS = 100_000
8const ALERT_TOKENS = 200_000
9
10const PANE = 'context-meter'
11const TITLE = 'Context'
12
13const reading = atom({ plugin: 'context-meter', key: 'reading' } as const, null)
14const hasWarned = atom({ plugin: 'context-meter', key: 'hasWarned' } as const, false)
15
16const k = (n: number) => (n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1000)}k`)
17
18const level = (tokens: number) =>
19 tokens >= ALERT_TOKENS
20 ? ({ color: 'error', status: 'Compact now' } as const)
21 : tokens >= WARN_TOKENS
22 ? ({ color: 'warning', status: 'Growing' } as const)
23 : ({ color: 'success', status: 'Healthy' } as const)
24
25export const register: Register = on => {
26 on('session.start', async ($, e, next) => {
27 await $.command.register({ name: 'context-meter', description: 'Show the context meter pane' })
28 void $.ui.open({ id: PANE, title: TITLE })
29
30 return next(e)
31 })
32
33 on('command.run', { command: 'context-meter' }, async $ => {
34 await $.ui.open({ id: PANE, title: TITLE })
35
36 return { text: 'Context meter opened.' }
37 })
38
39 on('session.measure', async ($, e, next) => {
40 const { tokens, window, percent } = e.context
41 const latest: Reading | null =
42 tokens === undefined ? null : { tokens, window, percent: percent ?? 0, usd: e.cost?.usd }
43 await update($, reading, () => latest)
44
45 // Toast once per crossing; compacting or clearing below the line re-arms it.
46 const isOver = latest !== null && latest.tokens >= ALERT_TOKENS
47 if (isOver && !(await read($, hasWarned))) {
48 $.ui.toast(`Context is ${k(latest.tokens)} and re-sent every turn: consider /compact or /clear`)
49 }
50 await update($, hasWarned, () => isOver)
51
52 return next(e)
53 })
54
55 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
56 const { Box, Text } = $.ui.resolve(e)
57 const r = await read($, reading)
58
59 if (r === null) {
60 return (
61 <Box paddingX={1}>
62 <Text dimColor>Waiting for the first response…</Text>
63 </Box>
64 )
65 }
66
67 const { color, status } = level(r.tokens)
68 const cells = Math.max(10, e.props.bodyColumns - 2)
69 const filled = Math.min(cells, Math.ceil((r.tokens / r.window) * cells))
70 const row = (label: string, value: string, valueColor?: typeof color) => (
71 <Box flexDirection="row" justifyContent="space-between">
72 <Text dimColor>{label}</Text>
73 <Text color={valueColor} bold={valueColor !== undefined}>{value}</Text>
74 </Box>
75 )
76
77 return (
78 <Box flexDirection="column" paddingX={1}>
79 <Text bold color={color}>{`${r.percent}%`}</Text>
80 <Box flexDirection="row">
81 <Text color={color}>{'█'.repeat(filled)}</Text>
82 <Text dimColor>{'░'.repeat(cells - filled)}</Text>
83 </Box>
84 <Text dimColor>{`${k(r.tokens)} of ${k(r.window)} tokens`}</Text>
85 <Box marginTop={1} flexDirection="column">
86 {row('Status', status, color)}
87 {row('Re-sent each turn', k(r.tokens))}
88 {r.usd !== undefined && row('Session cost', `$${r.usd.toFixed(2)}`)}
89 </Box>
90 {r.tokens >= ALERT_TOKENS && (
91 <Box marginTop={1}>
92 <Text color="error">Run /compact or /clear</Text>
93 </Box>
94 )}
95 </Box>
96 )
97 })
98}
99types/index.d.ts 8 lines1export type Reading = { tokens: number; window: number; percent: number; usd?: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'context-meter': { reading: Reading | null; hasWarned: boolean }
6 }
7}
8