Mod: context bar above the prompt with per-category legend, ctx % in the status line, toasts as it fills, a Compact chip at the urgent level, and a detail pane…

Mod. Everything about context use, in one place.
■ ctx 63%.■ context 90% [Compact], on the shared chip row above the prompt. It runs /compact with the same focus text /squish prints, through $.command.run, which I have not seen run a built-in command in a live session./whats-in-the-box opens a pane: a header colored by how full you are, one row per category with its own bar and share, and the free space.Options: band (true), minPercent (0), notice (50), warn (70), urgent (85), compactButton (true).
hooks/register.tsx 222 lines1import { atom, read, update } from 'claude-code'
2import type { Hook, Register } from 'claude-code'
3
4import type { Snapshot } from '../types'
5
6import { RANK, chipKey, injectChips, isEmpty } from './chips'
7
8const PANE = 'whats-in-the-box'
9const snapshot = atom({ plugin: 'whats-in-the-box', key: 'snapshot' } as const, null)
10const pctAtom = atom({ plugin: 'whats-in-the-box', key: 'pct' } as const, 0)
11
12const FOCUS =
13 'keep the goal, decisions and why, exact error messages, file paths and line ranges touched, open TODOs, current branch and state; drop exploration, file dumps, command output already acted on, abandoned approaches'
14
15export const showCompact = (percent: number, urgent: number): boolean => percent >= urgent
16
17export type Step = [number, string]
18
19// Toasts show three lines of 40 columns, so these stay short.
20export const stepsFor = (notice: number, warn: number, urgent: number): Step[] =>
21 [
22 [urgent, `Context ${urgent}%: /compact or /clear now`],
23 [warn, `Context ${warn}%: wrap up, consider /compact`],
24 [notice, `Context ${notice}%: keep reads tight`],
25 ].sort((a, b) => (b[0] as number) - (a[0] as number)) as Step[]
26
27const STEPS = stepsFor(50, 70, 85)
28
29/** Which warning (if any) fires at `pct`, given the highest already shown. */
30export const nextWarning = (pct: number, warned: number, steps: Step[] = STEPS): { warned: number; text?: string } => {
31 const lowest = steps.at(-1)?.[0] ?? 0
32 const base = pct < lowest - 10 ? 0 : warned
33 const hit = steps.find(([t]) => pct >= t)
34 return hit && hit[0] > base ? { warned: hit[0], text: hit[1] } : { warned: base }
35}
36
37type Engine = Parameters<Hook<'session.start'>>[0]
38
39type Breakdown = { categories: { name: string; tokens: number; kind?: string }[]; totalTokens: number; maxTokens: number; percentage: number }
40
41export const toSnapshot = (b: Breakdown): Snapshot => ({
42 rows: b.categories
43 .filter(c => c.kind !== 'free')
44 .map(c => ({ name: c.name, tokens: c.tokens }))
45 .sort((a, z) => z.tokens - a.tokens),
46 total: b.totalTokens,
47 max: b.maxTokens,
48 percent: b.percentage,
49})
50
51const PALETTE = ['cyan', 'magenta', 'yellow', 'green', 'blue', 'red', 'white']
52const BAR_WIDTH = 36
53const LEGEND_MAX = 4
54
55export const fmtTokens = (n: number): string => {
56 if (n < 1000) return String(n)
57 return `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k`
58}
59
60const levelColor = (percent: number): string => {
61 if (percent >= 85) return 'red'
62 return percent >= 70 ? 'yellow' : 'cyan'
63}
64
65/** Cells per category for a bar `width` wide over `max` tokens; never more than `width` in total. */
66export const barCells = (rows: { name: string; tokens: number }[], max: number, width: number): { name: string; cells: number }[] => {
67 let left = width
68 return rows
69 .filter(r => r.tokens > 0 && max > 0)
70 .map(r => {
71 const cells = Math.min(left, Math.max(1, Math.round((r.tokens / max) * width)))
72 left -= cells
73 return { name: r.name, cells }
74 })
75 .filter(c => c.cells > 0)
76}
77
78const refresh = async ($: Engine) => {
79 const { context } = await $.session.usage({ breakdown: 'summary' })
80 const b = context.breakdown
81 if (!b) return
82 const snap = toSnapshot(b)
83 await update($, snapshot, () => snap)
84}
85
86export const register: Register = (on, options) => {
87 const num = (v: unknown, d: number) => (typeof v === 'number' && v > 0 && v <= 100 ? v : d)
88 const steps = stepsFor(num(options.notice, 50), num(options.warn, 70), num(options.urgent, 85))
89 let warned = 0
90
91 on('session.start', async ($, e, next) => {
92 await $.command.register({ name: 'whats-in-the-box', description: 'Show context breakdown in a pane' })
93 return next(e)
94 })
95
96 on('command.run', { command: 'whats-in-the-box' }, async $ => {
97 await refresh($)
98 await $.ui.open({ id: PANE, title: 'Context' })
99 return { text: 'Context pane opened.' }
100 })
101
102 on('session.measure', async ($, e, next) => {
103 const pct = e.context.percent
104
105 if (pct !== undefined) {
106 $.ui.status(`■ ctx ${Math.round(pct)}%`)
107 await update($, pctAtom, () => pct)
108 const w = nextWarning(pct, warned, steps)
109 warned = w.warned
110 if (w.text) $.ui.toast(w.text)
111 }
112
113 if (e.changed.includes('context')) await refresh($)
114 return next(e)
115 })
116
117 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
118 const s = await read($, snapshot)
119 const p = await read($, pctAtom)
120 const min = typeof options.minPercent === 'number' ? options.minPercent : 0
121 const showBar = options.band !== false && !!s && s.percent >= min
122 const compact = options.compactButton !== false && showCompact(p, steps[0][0])
123
124 if (e.props.hasSurvey || (!showBar && !compact)) return next(e)
125
126 const { Box, Text, Button } = $.ui.resolve(e)
127 const rest = await next(e)
128
129 // The Compact chip joins the shared chip row (route, turn result) under the bar.
130 const chip = (
131 <Box key={chipKey(RANK.context, 'compact')}>
132 <Text key="ctx-urgent" color="red" bold>■ context {Math.round(p)}% </Text>
133 <Button key="compact" label="Compact" onPress={() => $.command.run({ command: 'compact', args: FOCUS })} />
134 </Box>
135 )
136 let below: unknown = rest
137 if (compact) {
138 below = injectChips(rest, chip)
139 if (!below && isEmpty(rest)) below = <Box key="chips" flexWrap="wrap">{chip}</Box>
140 else if (!below)
141 below = (
142 <Box flexDirection="column">
143 <Box key="chips" flexWrap="wrap">{chip}</Box>
144 {rest}
145 </Box>
146 )
147 }
148
149 if (!showBar || !s) return below as never
150
151 const cells = barCells(s.rows, s.max, BAR_WIDTH)
152 const used = cells.reduce((n, c) => n + c.cells, 0)
153 const color = (name: string) => PALETTE[Math.max(0, s.rows.findIndex(r => r.name === name)) % PALETTE.length]
154 const shown = s.rows.slice(0, LEGEND_MAX)
155 const hidden = s.rows.length - shown.length
156
157 return (
158 <Box flexDirection="column">
159 <Box>
160 <Text bold>■ ctx </Text>
161 {cells.map(c => (
162 <Text key={`b-${c.name}`} color={color(c.name)}>{'█'.repeat(c.cells)}</Text>
163 ))}
164 <Text dimColor>{'░'.repeat(Math.max(0, BAR_WIDTH - used))}</Text>
165 <Text> {Math.round(s.percent)}% </Text>
166 <Text dimColor>
167 {fmtTokens(s.total)}/{fmtTokens(s.max)}{' '}
168 </Text>
169 <Button key="details" label="Details" onPress={() => $.ui.open({ id: PANE, title: 'Context' })} />
170 </Box>
171 <Box>
172 {shown.map(r => (
173 <Text key={`l-${r.name}`} color={color(r.name)}>
174 ■ {r.name} {fmtTokens(r.tokens)}{' '}
175 </Text>
176 ))}
177 {hidden > 0 && <Text dimColor>+{hidden} more</Text>}
178 </Box>
179 {below}
180 </Box>
181 )
182 })
183
184 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
185 const { Box, Text } = $.ui.resolve(e)
186 const s = await read($, snapshot)
187
188 if (!s) return <Text dimColor>No data yet. It fills in after the first reply.</Text>
189
190 const nameW = Math.max(...s.rows.map(r => r.name.length), 4)
191 const color = (name: string) => PALETTE[Math.max(0, s.rows.findIndex(r => r.name === name)) % PALETTE.length]
192 const free = Math.max(0, s.max - s.total)
193
194 return (
195 <Box flexDirection="column">
196 <Text bold color={levelColor(s.percent)}>
197 ■ Context {Math.round(s.percent)}% {fmtTokens(s.total)}/{fmtTokens(s.max)}
198 </Text>
199 <Text dimColor>{'─'.repeat(40)}</Text>
200 {s.rows.map(r => {
201 const n = Math.max(r.tokens > 0 ? 1 : 0, Math.round((r.tokens / Math.max(1, s.max)) * 20))
202 return (
203 <Box key={`r-${r.name}`}>
204 <Text color={color(r.name)}>■ </Text>
205 <Text>{r.name.padEnd(nameW)} </Text>
206 <Text color={color(r.name)}>{'█'.repeat(n)}</Text>
207 <Text dimColor>{'░'.repeat(Math.max(0, 20 - n))}</Text>
208 <Text>
209 {' '}
210 {fmtTokens(r.tokens).padStart(6)} {String(Math.round((r.tokens / Math.max(1, s.max)) * 100)).padStart(3)}%
211 </Text>
212 </Box>
213 )
214 })}
215 <Text dimColor>
216 □ {'free'.padEnd(nameW)} {'░'.repeat(20)} {fmtTokens(free).padStart(6)}
217 </Text>
218 </Box>
219 )
220 })
221}
222hooks/chips.ts 42 lines1// Chip row: small status chips from different plugins share one row above the prompt.
2// - The row is a Box with props.key === 'chips' (wrapping, so a narrow terminal gets a second line
3// instead of cut-off buttons).
4// - Each chip is a Box keyed `chip:<rank>:<name>`; chips sort by rank so the order is the same
5// whichever plugin happens to draw first: gate 5, context 10, route 20, result 30.
6// Plain data in, plain data out, so it works on whatever `next(e)` returns.
7export type Node = { type?: string; props?: Record<string, unknown>; children?: unknown[] }
8
9export const CHIPS_KEY = 'chips'
10export const GAP = ' '
11export const RANK = { gate: 5, context: 10, route: 20, result: 30 } as const
12
13export const chipKey = (rank: number, name: string): string => `chip:${String(rank).padStart(3, '0')}:${name}`
14
15const isNode = (n: unknown): n is Node => typeof n === 'object' && n !== null
16
17const keyOf = (n: unknown): string => (isNode(n) && typeof n.props?.key === 'string' ? n.props.key : '')
18
19/** Chips in rank order with a gap between each. */
20export const arrange = (chips: unknown[]): unknown[] =>
21 [...chips]
22 .sort((a, b) => keyOf(a).localeCompare(keyOf(b)))
23 .flatMap((c, i) => (i === 0 ? [c] : [GAP, c]))
24
25/** Add `chip` to the chips row inside `node`; undefined when there is none. */
26export const injectChips = (node: unknown, chip: unknown): Node | undefined => {
27 if (!isNode(node)) return undefined
28 if (node.props?.key === CHIPS_KEY) {
29 const existing = (node.children ?? []).filter(c => c !== GAP)
30 return { ...node, children: arrange([...existing, chip]) }
31 }
32 const kids = node.children ?? []
33 for (let i = kids.length - 1; i >= 0; i--) {
34 const hit = injectChips(kids[i], chip)
35 if (hit) return { ...node, children: kids.map((c, j) => (j === i ? hit : c)) }
36 }
37 return undefined
38}
39
40/** True when the band beneath drew nothing. */
41export const isEmpty = (node: unknown): boolean => node == null || (isNode(node) && (node.children ?? []).length === 0)
42types/index.d.ts 9 lines1export type Row = { name: string; tokens: number }
2export type Snapshot = { rows: Row[]; total: number; max: number; percent: number }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'whats-in-the-box': { snapshot: Snapshot | null; pct: number }
7 }
8}
9