A coloured bar above the prompt showing what fills the context window, by category, and how much the last turn and its steps added.

A coloured bar in the band above the prompt showing what fills the context window, by category, and how much the last turn added.
████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒ 60k/200k (30%) · $1.30
System 20k MCP 4.0k Skills 2.0k Messages 22k Turn 13k Free 107k Buffer 33k
Last turn: 3 steps · context 40k → 52k (+13k) · 1.5k out · $0.30 · steps +1.0k +6.0k +5.0k
/context uses: system prompt and tools, MCP tools, custom agents, skills, memory files, then messages. After the content come the free space (shaded) and the autocompact buffer. Every category in use gets at least one cell, however small, where the bar has room.This turn) and the previous one after it ends (Last turn): how many model requests it made, the context it started from and reached, the tokens it generated, what it cost, and what each of its last eight steps added. When a compaction runs partway through a turn, the line shows where it left the context (context 166k → compacted 34k → 37k) and counts the turn's growth from there.The band yields to a survey, and can be collapsed and restored like any band above the prompt (ctrl+x ctrl+a, or the [-]/[+] at its right edge).
/context-toolsOpens a pane, as wide as the terminal allows, with a table of the twenty largest tool results in the context, each by what its call set out to do:
Largest tool results in the context 20 of 54 results · ~31k of 101k in messages · ~9.2k in reminders · 4 characters a token
# Size Share Tool Call Detail
──────────────────────────────────────────────────────────────────────────────────────────────────────────
1 ~12k ██████████ Bash ▸ Run plugin tests and type-check claude plugin test plugins/context-bar
2 ~5.0k ████▏ Read ▾ register.tsx plugins/context-bar/hooks
file_path S:\repo\plugins\context-bar\hooks\register.tsx
Result
│ import { atom, read, update } from 'claude-code';
… 412 more lines
3 ~725 ▋ Grep ▸ "session.compact" in types *.ts
[ Close ]
Bash, Agent) goes by its description, its command in the detail. A file goes by its name, its folder relative to the project in the detail. A search goes by its pattern and where it looked, and a fetch by its address. The detail is dropped when the pane is too narrow for it, and a failed call's tool is drawn in the error colour.<system-reminder> blocks Claude Code attaches to whichever result comes next are left out of it and counted together in the header.q or Close dismisses it.The list is read from the context as it stands when drawn, so it holds what a compaction kept and nothing it dropped, and is drawn again after every response. Sizes are estimated from each result's text at about four characters a token, so images are not counted.
/context's summary breakdown), refreshed after every response. It sends no requests, so it costs nothing. The estimate is measured against the compaction window and need not match the status line's figure exactly./cost and the status line total it; a turn's cost is how much that total grew during it, so it includes its subagents' requests./clear resets everything.hooks/register.tsx 342 lines1import { atom, read, update } from 'claude-code';
2import type { EngineInterface, ModelUsage, Register } from 'claude-code';
3
4import type { ContextBarBreakdown, ContextBarSlice, ContextBarStep, ContextBarTurn } from '../types';
5import { formatTokens, registerTools, TOOLS_PANE } from './tools';
6
7// Kept in the session's state, so a reload of the module draws at once rather
8// than waiting for the next response.
9const breakdown = atom({ plugin: 'context-bar', key: 'breakdown' } as const, null);
10const measured = atom({ plugin: 'context-bar', key: 'measured' } as const, null);
11const cost = atom({ plugin: 'context-bar', key: 'cost' } as const, null);
12const turn = atom({ plugin: 'context-bar', key: 'turn' } as const, null);
13
14// The steps the turn line lists, newest last.
15const MAX_STEPS_SHOWN = 8;
16// The bar's narrowest, in cells; otherwise it fills the band beside its summary.
17const MIN_BAR_CELLS = 10;
18// What the last turn is drawn in: the first of these no other segment uses.
19const LAST_TURN_COLORS = ['warning', 'suggestion', 'success', 'permission'];
20
21// The short names for /context's rows, by the start of the row's name, in the
22// order the bar draws them. A row named otherwise goes by its first word, just
23// before the messages, which stay last so the last turn can be carved off them.
24const SHORT_NAMES: [prefix: string, name: string][] = [
25 ['System', 'System'],
26 ['MCP', 'MCP'],
27 ['Custom agents', 'Agents'],
28 ['Skills', 'Skills'],
29 ['Memory', 'Memory'],
30 ['Messages', 'Messages'],
31];
32
33// Re-estimates the window by category. `summary` counts locally and sends no
34// request, so it is cheap enough to run after every response.
35async function refresh($: EngineInterface) {
36 try {
37 const usage = await $.session.usage({ breakdown: 'summary' });
38 await update($, cost, () => usage.cost?.usd ?? null);
39 const rows = usage.context.breakdown;
40 if (rows === undefined) return;
41 const slices: ContextBarSlice[] = [];
42 for (const { name, tokens, color, kind } of rows.categories) {
43 if (kind !== 'deferred' && tokens > 0) slices.push({ name, tokens, color, kind });
44 }
45 await update($, breakdown, () => ({ slices, window: rows.rawMaxTokens }));
46 } catch (error) {
47 $.ui.log(`context-bar: could not break the context down: ${error instanceof Error ? error.message : String(error)}`);
48 }
49}
50
51function inputSide(usage: ModelUsage) {
52 return usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens;
53}
54
55// What the turn has added to the context so far, from where it started (or
56// where a compaction within it left the context) to its latest request, that
57// request's own output included, since the next one carries it.
58function turnGrowth(current: ContextBarTurn) {
59 const last = current.steps.at(-1);
60 const start = current.compactedAt === null ? current.contextBefore : current.steps[current.compactedAt]!.context;
61 if (last === undefined || start === null) return null;
62 return last.context + last.output - start;
63}
64
65// What each step added over the one before it (the first over the turn's
66// start): 'compacted' for the step a compaction shrank, null where there is
67// nothing to compare against.
68function stepGrowth(current: ContextBarTurn) {
69 return current.steps.map((step, index) => {
70 if (index === current.compactedAt) return 'compacted';
71 const before = index === 0 ? current.contextBefore : current.steps[index - 1]!.context;
72 return before === null ? null : step.context - before;
73 });
74}
75
76function signed(tokens: number) {
77 return `${tokens < 0 ? '-' : '+'}${formatTokens(Math.abs(tokens))}`;
78}
79
80function formatUsd(usd: number) {
81 return `$${usd.toFixed(2)}`;
82}
83
84// The line under the legend: the turn's requests, the context it went from
85// and to, what it generated and cost (from the session's cost `costNow`), and
86// what each of its last steps added.
87export function turnLine(current: ContextBarTurn, costNow: number | null) {
88 const last = current.steps.at(-1);
89 if (last === undefined) return null;
90 const growth = turnGrowth(current);
91 const context = [
92 current.contextBefore === null ? null : formatTokens(current.contextBefore),
93 current.compactedAt === null ? null : `compacted ${formatTokens(current.steps[current.compactedAt]!.context)}`,
94 formatTokens(last.context),
95 ].filter(point => point !== null);
96 const steps = stepGrowth(current);
97 const shown = steps
98 .slice(-MAX_STEPS_SHOWN)
99 .map(delta => (delta === null ? '?' : delta === 'compacted' ? delta : signed(delta)));
100 const count = current.steps.length;
101 return [
102 `${current.isRunning ? 'This turn' : 'Last turn'}: ${count} step${count === 1 ? '' : 's'}`,
103 `context ${context.join(' → ')}${growth === null ? '' : ` (${signed(growth)})`}`,
104 `${formatTokens(current.steps.reduce((sum, step) => sum + step.output, 0))} out`,
105 current.costBefore === null || costNow === null ? null : formatUsd(costNow - current.costBefore),
106 shown.length < 2 ? null : `steps ${steps.length > shown.length ? '… ' : ''}${shown.join(' ')}`,
107 ]
108 .filter(part => part !== null)
109 .join(' · ');
110}
111
112type Segment = { name: string; tokens: number; color: string; kind: ContextBarSlice['kind'] | 'turn' };
113
114function isContent(segment: Segment) {
115 return segment.kind === 'used' || segment.kind === 'turn';
116}
117
118// How a segment's cells and its legend label are drawn: the free space dimmed.
119function style(segment: Segment) {
120 return segment.kind === 'free' ? { color: undefined, isDim: true } : { color: segment.color, isDim: false };
121}
122
123function knownIndex(slice: ContextBarSlice) {
124 return SHORT_NAMES.findIndex(([prefix]) => slice.name.startsWith(prefix));
125}
126
127function shortName(slice: ContextBarSlice) {
128 if (slice.kind === 'free') return 'Free';
129 if (slice.kind === 'buffer') return 'Buffer';
130 return SHORT_NAMES[knownIndex(slice)]?.[1] ?? slice.name.split(' ')[0] ?? slice.name;
131}
132
133// The bar's segments in order, each by its short name, neighbours of one name
134// (the system prompt and the system tools) drawn as one in the first one's
135// colour: the content in SHORT_NAMES' order with the last turn's growth carved
136// off its end, then the free space and the buffer.
137export function segments(rows: ContextBarBreakdown, growth: number | null): Segment[] {
138 const result: Segment[] = [];
139 const add = (slice: ContextBarSlice) => {
140 const name = shortName(slice);
141 const previous = result.at(-1);
142 if (previous?.name === name) previous.tokens += slice.tokens;
143 else result.push({ ...slice, name });
144 };
145 const rank = (slice: ContextBarSlice) => {
146 const index = knownIndex(slice);
147 return index < 0 ? SHORT_NAMES.length - 1.5 : index;
148 };
149 rows.slices
150 .filter(slice => slice.kind === 'used')
151 .sort((a, b) => rank(a) - rank(b))
152 .forEach(add);
153 const tail = result.at(-1);
154 if (tail !== undefined && growth !== null && growth > 0) {
155 const carved = Math.min(growth, tail.tokens);
156 tail.tokens -= carved;
157 result.push({
158 name: 'Turn',
159 tokens: carved,
160 color: LAST_TURN_COLORS.find(color => result.every(each => each.color !== color)) ?? LAST_TURN_COLORS[0]!,
161 kind: 'turn',
162 });
163 }
164 rows.slices.filter(slice => slice.kind === 'free').forEach(add);
165 rows.slices.filter(slice => slice.kind === 'buffer').forEach(add);
166 return result.filter(segment => segment.tokens > 0);
167}
168
169// Splits `cells` among the weights in proportion, by largest remainder, then
170// gives a cell to each required weight that rounded to none, taken from the
171// largest, so every kind of content in the window shows.
172export function allocate(weights: number[], cells: number, isRequired: boolean[]) {
173 const total = weights.reduce((sum, weight) => sum + weight, 0);
174 if (total <= 0 || cells <= 0) return weights.map(() => 0);
175 const exact = weights.map(weight => (weight / total) * cells);
176 const counts = exact.map(Math.floor);
177 let left = cells - counts.reduce((sum, count) => sum + count, 0);
178 const byRemainder = exact.map((value, index) => ({ index, remainder: value - Math.floor(value) }));
179 byRemainder.sort((a, b) => b.remainder - a.remainder);
180 for (const { index } of byRemainder) {
181 if (left <= 0) break;
182 counts[index]!++;
183 left--;
184 }
185 for (const [index, required] of isRequired.entries()) {
186 if (!required || counts[index]! > 0) continue;
187 const largest = counts.indexOf(Math.max(...counts));
188 if (counts[largest]! <= 1) break;
189 counts[largest]!--;
190 counts[index] = 1;
191 }
192 return counts;
193}
194
195const GLYPH: Record<Segment['kind'], string> = { used: '█', turn: '█', free: '░', buffer: '▒' };
196
197type LegendItem = { segment: Segment; label: string };
198
199// The legend that fits on one line of `columns`, labels two spaces apart: every
200// segment with its tokens; then by name alone; then as many names as fit.
201export function legend(parts: Segment[], columns: number): LegendItem[] {
202 const width = (items: LegendItem[]) => items.reduce((sum, item) => sum + item.label.length + 2, -2);
203 const full = parts.map(segment => ({ segment, label: `${segment.name} ${formatTokens(segment.tokens)}` }));
204 if (width(full) <= columns) return full;
205 const names = parts.map(segment => ({ segment, label: segment.name }));
206 let used = -2;
207 const fitting: LegendItem[] = [];
208 for (const item of names) {
209 used += item.label.length + 2;
210 if (used > columns) break;
211 fitting.push(item);
212 }
213 return fitting;
214}
215
216export const register: Register = on => {
217 on('session.start', async ($, e, next) => {
218 await $.command.register({
219 name: TOOLS_PANE,
220 description: 'Show the largest tool results in the context',
221 immediate: true,
222 });
223 const result = await next(e);
224 await refresh($);
225 return result;
226 });
227
228 // A /clear starts the conversation over.
229 on('session.end', async ($, e, next) => {
230 if (e.reason === 'clear') {
231 await update($, breakdown, () => null);
232 await update($, measured, () => null);
233 await update($, cost, () => null);
234 await update($, turn, () => null);
235 }
236 return next(e);
237 });
238
239 registerTools(on);
240
241 on('session.measure', async ($, e, next) => {
242 if (e.changed.includes('cost') && e.cost !== undefined) {
243 const usd = e.cost.usd;
244 await update($, cost, () => usd);
245 }
246 if (e.changed.includes('context')) {
247 await update($, measured, () => e.context.tokens ?? null);
248 await refresh($);
249 }
250 return next(e);
251 });
252
253 on('turn.start', async ($, e, next) => {
254 const [contextBefore, costBefore] = await Promise.all([read($, measured), read($, cost)]);
255 await update($, turn, () => ({ contextBefore, steps: [], compactedAt: null, costBefore, isRunning: true }));
256 return next(e);
257 });
258
259 on('turn.step', async function* ($, e, next) {
260 const result = yield* next(e);
261 if (e.agentId === undefined && result.usage !== null) {
262 const step: ContextBarStep = { context: inputSide(result.usage), output: result.usage.output_tokens };
263 await update($, turn, current => {
264 if (current === null) return current;
265 // The context only shrinks when a compaction runs mid-turn.
266 const previous = current.steps.at(-1)?.context ?? current.contextBefore;
267 const compactedAt = previous !== null && step.context < previous ? current.steps.length : current.compactedAt;
268 return { ...current, steps: [...current.steps, step], compactedAt };
269 });
270 }
271 return result;
272 });
273
274 on('turn.complete', async ($, e, next) => {
275 if (e.agentId === undefined) {
276 await update($, turn, current => (current === null ? current : { ...current, isRunning: false }));
277 }
278 return next(e);
279 });
280
281 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
282 const rows = await read($, breakdown);
283 if (e.props.hasSurvey || rows === null || rows.slices.length < 1) return next(e);
284 const [current, costNow] = await Promise.all([read($, turn), read($, cost)]);
285 const { Box, Text } = $.ui.resolve(e);
286
287 const used = rows.slices.filter(slice => slice.kind === 'used').reduce((sum, slice) => sum + slice.tokens, 0);
288 const percent = Math.round((used / Math.max(rows.window, 1)) * 100);
289 const summary = [
290 `${formatTokens(used)}/${formatTokens(rows.window)} (${percent}%)`,
291 costNow === null ? null : formatUsd(costNow),
292 ]
293 .filter(part => part !== null)
294 .join(' · ');
295
296 const parts = segments(rows, current === null ? null : turnGrowth(current));
297 // The bar fills the band beside its summary, down to a floor.
298 const cells = Math.max(e.props.bodyColumns - summary.length - 1, MIN_BAR_CELLS);
299 const counts = allocate(
300 parts.map(part => part.tokens),
301 cells,
302 parts.map(isContent),
303 );
304 const line = current === null ? null : turnLine(current, costNow);
305
306 return (
307 <Box flexDirection="column">
308 <Box flexDirection="row">
309 <Box key="bar" flexDirection="row">
310 {parts.map((part, index) => {
311 if (counts[index]! < 1) return null;
312 const { color, isDim } = style(part);
313 return (
314 <Text color={color} dimColor={isDim}>
315 {GLYPH[part.kind].repeat(counts[index]!)}
316 </Text>
317 );
318 })}
319 </Box>
320 <Text bold> {summary}</Text>
321 </Box>
322 <Box key="legend" flexDirection="row">
323 {legend(parts, e.props.bodyColumns).map(({ segment, label }, index) => {
324 const { color, isDim } = style(segment);
325 return (
326 <Text color={color} dimColor={isDim}>
327 {index === 0 ? '' : ' '}
328 {label}
329 </Text>
330 );
331 })}
332 </Box>
333 {line === null ? null : (
334 <Text dimColor wrap="truncate-end">
335 {line}
336 </Text>
337 )}
338 </Box>
339 );
340 });
341};
342hooks/tools.tsx 379 lines1import { atom, read, update } from 'claude-code';
2import type { EngineInterface, On } from 'claude-code';
3
4// The row expanded, by its call's id; the pane shows one at a time.
5const expanded = atom({ plugin: 'context-bar', key: 'expandedTool' } as const, null);
6// The window by category, which register.tsx keeps.
7const breakdown = atom({ plugin: 'context-bar', key: 'breakdown' } as const, null);
8
9// The command, and the pane it opens, listing the largest tool results.
10export const TOOLS_PANE = 'context-tools';
11const MAX_LISTED = 20;
12// A tool result's tokens, estimated from its text's length.
13const CHARS_PER_TOKEN = 4;
14// The width of a row's size bar, in cells, drawn in eighths.
15const BAR_CELLS = 10;
16const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉'];
17// What an expanded row shows of its result.
18const PREVIEW_LINES = 8;
19// The arguments that say what an unknown tool was called on, the first present.
20const TARGET_KEYS = ['path', 'command', 'pattern', 'url', 'query', 'skill', 'description', 'prompt'];
21
22// One tool result in the context: the call it answers and its estimated size.
23type ToolResult = {
24 id: string;
25 tool: string;
26 input: Record<string, unknown>;
27 text: string;
28 isError: boolean;
29 tokens: number;
30};
31
32type Block = { type: string; [field: string]: unknown };
33type Message = { role: string; content: Block[] };
34
35// What Claude Code attaches to a message, whichever tool's result it lands in.
36const REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
37
38function blockText(content: unknown) {
39 if (typeof content === 'string') return content;
40 if (!Array.isArray(content)) return '';
41 return (content as Block[])
42 .filter(block => block.type === 'text' && typeof block.text === 'string')
43 .map(block => block.text as string)
44 .join('\n');
45}
46
47// A result's own text: its reminders cut out, and the blank lines they left.
48function resultText(content: unknown) {
49 return blockText(content).replace(REMINDER, '').replace(/\n{3,}/g, '\n\n').trim();
50}
51
52// The reminders in `messages`, in tokens: in tool results or beside them.
53export function reminderTokens(messages: readonly Message[]) {
54 let chars = 0;
55 for (const message of messages) {
56 for (const block of message.content) {
57 const text = block.type === 'tool_result' ? blockText(block.content) : block.type === 'text' ? blockText([block]) : '';
58 for (const reminder of text.match(REMINDER) ?? []) chars += reminder.length;
59 }
60 }
61 return Math.ceil(chars / CHARS_PER_TOKEN);
62}
63
64// The tool results of `messages` (the context in API form), largest first,
65// each paired with the call it answers and sized by its own text.
66export function toolResults(messages: readonly Message[]): ToolResult[] {
67 const calls = new Map<string, Block>();
68 const results: ToolResult[] = [];
69 for (const message of messages) {
70 for (const block of message.content) {
71 if (block.type === 'tool_use' && typeof block.id === 'string') calls.set(block.id, block);
72 if (block.type !== 'tool_result' || typeof block.tool_use_id !== 'string') continue;
73 const call = calls.get(block.tool_use_id);
74 const text = resultText(block.content);
75 results.push({
76 id: block.tool_use_id,
77 tool: typeof call?.name === 'string' ? call.name : 'Tool',
78 input: (call?.input ?? {}) as Record<string, unknown>,
79 text,
80 isError: block.is_error === true,
81 tokens: Math.ceil(text.length / CHARS_PER_TOKEN),
82 });
83 }
84 }
85 return results.filter(result => result.tokens > 0).sort((a, b) => b.tokens - a.tokens);
86}
87
88function oneLine(value: unknown) {
89 return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
90}
91
92// `path` relative to `cwd` when it lies inside it.
93function relative(path: string, cwd: string) {
94 const norm = (each: string) => each.replace(/\\/g, '/').toLowerCase();
95 const root = norm(cwd).replace(/\/$/, '');
96 return norm(path).startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
97}
98
99function splitPath(path: string) {
100 const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
101 return cut < 0 ? { name: path, folder: '' } : { name: path.slice(cut + 1), folder: path.slice(0, cut) };
102}
103
104// The tool's name for a row: an MCP tool by its server and tool.
105function toolName(tool: string) {
106 const mcp = /^mcp__(.+?)__(.+)$/.exec(tool);
107 return mcp === null ? tool : `${mcp[1]} ${mcp[2]}`;
108}
109
110// What a call set out to do, in a row's words: `title` first, `detail` dim
111// after it. A description the model wrote leads; else the file, search or
112// address the call was on.
113export function describe(tool: string, input: Record<string, unknown>, cwd: string) {
114 const path = oneLine(input.file_path) || oneLine(input.notebook_path);
115 if (path) {
116 const { name, folder } = splitPath(relative(path, cwd));
117 return { title: name, detail: folder };
118 }
119 const where = oneLine(input.path) ? `in ${relative(oneLine(input.path), cwd)}` : '';
120 switch (tool) {
121 case 'Bash':
122 return oneLine(input.description)
123 ? { title: oneLine(input.description), detail: oneLine(input.command) }
124 : { title: oneLine(input.command), detail: '' };
125 case 'Agent':
126 case 'Task':
127 return { title: oneLine(input.description) || oneLine(input.prompt), detail: oneLine(input.subagent_type) };
128 case 'Grep':
129 return { title: `"${oneLine(input.pattern)}"`, detail: [where, oneLine(input.glob)].filter(Boolean).join(' ') };
130 case 'Glob':
131 return { title: oneLine(input.pattern), detail: where };
132 case 'WebFetch':
133 return { title: oneLine(input.url).replace(/^https?:\/\//, ''), detail: oneLine(input.prompt) };
134 }
135 const target = TARGET_KEYS.map(key => oneLine(input[key])).find(Boolean) ?? '';
136 return { title: target || toolName(tool), detail: '' };
137}
138
139// `text` in `width` cells, its middle cut where it is wider, so a path keeps
140// both its root and its file name.
141export function fitMiddle(text: string, width: number) {
142 if (text.length <= width) return text;
143 if (width < 2) return '…'.slice(0, Math.max(width, 0));
144 const head = Math.ceil((width - 1) / 2);
145 return `${text.slice(0, head)}…${text.slice(text.length - (width - 1 - head))}`;
146}
147
148// A bar of BAR_CELLS for `tokens` against the largest, in eighths of a cell,
149// never less than one eighth.
150export function sizeBar(tokens: number, largest: number) {
151 const eighths = Math.max(Math.round((tokens / Math.max(largest, 1)) * BAR_CELLS * 8), 1);
152 return `${'█'.repeat(Math.floor(eighths / 8))}${EIGHTHS[eighths % 8]}`.padEnd(BAR_CELLS);
153}
154
155export function formatTokens(tokens: number) {
156 const size = Math.abs(tokens);
157 if (size < 1_000) return String(Math.round(tokens));
158 if (size < 10_000) return `${(tokens / 1_000).toFixed(1)}k`;
159 if (size < 1_000_000) return `${Math.round(tokens / 1_000)}k`;
160 return `${(tokens / 1_000_000).toFixed(1)}M`;
161}
162
163// A size estimated from characters.
164function estimate(tokens: number) {
165 return `~${formatTokens(tokens)}`;
166}
167
168// The arguments of a call, a key and its value each, as an expanded row lists them.
169function argumentRows(input: Record<string, unknown>): [key: string, value: string][] {
170 return Object.entries(input)
171 .filter(([, value]) => value !== undefined && value !== '')
172 .map(([key, value]) => [key, typeof value === 'string' ? oneLine(value) : JSON.stringify(value)]);
173}
174
175const GAP = 2;
176const MIN_DETAIL = 12;
177
178// The table's column widths in `columns` cells, a gap after each but the last:
179// the call takes what its titles need up to half the room the fixed columns
180// leave, the detail the rest, and is dropped when too narrow to read.
181export function tableColumns(columns: number, rows: { size: string; title: string; tool: string }[]) {
182 const rank = String(rows.length).length;
183 const size = Math.max(4, ...rows.map(row => row.size.length));
184 const tool = Math.max(4, ...rows.map(row => row.tool.length));
185 // What the call, a gap and the detail share, past the fixed columns and their gaps.
186 const room = columns - rank - size - BAR_CELLS - tool - 4 * GAP;
187 // A title follows its row's marker and a space.
188 const titles = Math.max(4, ...rows.map(row => row.title.length)) + 2;
189 let call = Math.min(titles, Math.max(Math.ceil(room / 2), 20));
190 let detail = room - call - GAP;
191 if (detail < MIN_DETAIL) {
192 call = Math.max(room - GAP, 4);
193 detail = 0;
194 }
195 return { rank, size, bar: BAR_CELLS, call, detail, tool };
196}
197
198// The first lines of a result, and how many more there are.
199function preview(text: string) {
200 const lines = text.replace(/\s+$/, '').split('\n');
201 return { lines: lines.slice(0, PREVIEW_LINES), more: Math.max(lines.length - PREVIEW_LINES, 0) };
202}
203
204// The messages' share of the window, as last estimated; null before one was.
205// Read while drawn, it draws the pane again after every response.
206async function messagesTokens($: EngineInterface) {
207 const rows = await read($, breakdown);
208 return rows?.slices.find(slice => slice.name.startsWith('Messages'))?.tokens ?? null;
209}
210
211export function registerTools(on: On) {
212 on('command.run', { command: TOOLS_PANE }, async ($, e) => {
213 await $.ui.open({
214 id: TOOLS_PANE,
215 title: 'Largest tool results',
216 focus: true,
217 closeOnEscape: true,
218 // The rows, and the title, column header, rule, Close and the space between.
219 rows: MAX_LISTED + 6,
220 // A one-off look, so docked it asks for the whole width.
221 columns: e.presentation.columns,
222 });
223 return {};
224 });
225
226 on('ui.render', { component: 'Pane', requestId: TOOLS_PANE }, async ($, e) => {
227 const { Box, Button, Text } = $.ui.resolve(e);
228 const [messages, cwd, open, inMessages] = await Promise.all([
229 $.session.messages({ as: 'api' }),
230 $.session.cwd(),
231 read($, expanded),
232 messagesTokens($),
233 ]);
234 const all = toolResults(messages as Message[]);
235 const reminders = reminderTokens(messages as Message[]);
236 const listed = all.slice(0, MAX_LISTED);
237 const total = all.reduce((sum, each) => sum + each.tokens, 0);
238 const rows = listed.map(each => ({
239 result: each,
240 size: estimate(each.tokens),
241 tool: toolName(each.tool),
242 ...describe(each.tool, each.input, cwd),
243 }));
244 const widths = tableColumns(e.props.bodyColumns, rows);
245 // Where the call column starts, past its row's marker, as details indent to.
246 const indent = widths.rank + widths.size + widths.bar + widths.tool + 4 * GAP + 2;
247 const close = () => void $.ui.close({ id: TOOLS_PANE });
248 const toggle = async (id: string) => {
249 await update($, expanded, current => (current === id ? null : id));
250 $.ui.invalidate('ui.render');
251 };
252
253 const header = [
254 `${listed.length < all.length ? `${listed.length} of ` : ''}${all.length} result${all.length === 1 ? '' : 's'}`,
255 `${estimate(total)}${inMessages === null ? '' : ` of ${formatTokens(inMessages)} in messages`}`,
256 ...(reminders > 0 ? [`${estimate(reminders)} in reminders`] : []),
257 `${CHARS_PER_TOKEN} characters a token`,
258 ].join(' · ');
259
260 return (
261 <Box flexDirection="column">
262 <Box flexDirection="row" justifyContent="space-between">
263 <Text bold>Largest tool results in the context</Text>
264 <Text dimColor wrap="truncate-end">
265 {header}
266 </Text>
267 </Box>
268 {listed.length < 1 ? (
269 <Text dimColor>No tool results in the context.</Text>
270 ) : (
271 <Box flexDirection="column" marginTop={1}>
272 <Box flexDirection="row">
273 <Box width={widths.rank + GAP}>
274 <Text dimColor bold>
275 {'#'.padStart(widths.rank)}
276 </Text>
277 </Box>
278 <Box width={widths.size + GAP}>
279 <Text dimColor bold>
280 {'Size'.padStart(widths.size)}
281 </Text>
282 </Box>
283 <Box width={widths.bar + GAP}>
284 <Text dimColor bold>
285 Share
286 </Text>
287 </Box>
288 <Box width={widths.tool + GAP}>
289 <Text dimColor bold>
290 Tool
291 </Text>
292 </Box>
293 <Box width={widths.call + GAP}>
294 <Text dimColor bold>
295 {' Call'}
296 </Text>
297 </Box>
298 {widths.detail > 0 ? (
299 <Text dimColor bold>
300 Detail
301 </Text>
302 ) : null}
303 </Box>
304 <Text dimColor>
305 {'─'.repeat(Math.max(e.props.bodyColumns, 0))}
306 </Text>
307 </Box>
308 )}
309 {rows.map((row, index) => {
310 const each = row.result;
311 const isOpen = open === each.id;
312 return (
313 <Box key={`row-${each.id}`} flexDirection="column">
314 <Box flexDirection="row">
315 <Box width={widths.rank + GAP}>
316 <Text dimColor>{String(index + 1).padStart(widths.rank)}</Text>
317 </Box>
318 <Box width={widths.size + GAP}>
319 <Text>{row.size.padStart(widths.size)}</Text>
320 </Box>
321 <Box width={widths.bar + GAP}>
322 <Text color="suggestion">{sizeBar(each.tokens, listed[0]!.tokens)}</Text>
323 </Box>
324 <Box width={widths.tool + GAP}>
325 {each.isError ? <Text color="error">{row.tool}</Text> : <Text dimColor>{row.tool}</Text>}
326 </Box>
327 <Box width={widths.call + GAP}>
328 <Button
329 key={`tool-${each.id}`}
330 plain
331 label={`${isOpen ? '▾' : '▸'} ${fitMiddle(row.title, widths.call - 2)}`}
332 onPress={() => void toggle(each.id)}
333 />
334 </Box>
335 {widths.detail > 0 ? <Text dimColor>{fitMiddle(row.detail, widths.detail)}</Text> : null}
336 </Box>
337 {isOpen ? details(each, Math.max(e.props.bodyColumns - indent, 8)) : null}
338 </Box>
339 );
340 })}
341 <Box flexDirection="row" marginTop={1}>
342 <Button key="close" role="dismiss" hotkey="q" onPress={close}>
343 Close
344 </Button>
345 </Box>
346 </Box>
347 );
348
349 // An expanded row: the call's arguments and the start of its result.
350 function details(result: ToolResult, width: number) {
351 const { lines, more } = preview(result.text);
352 const args = argumentRows(result.input);
353 const keyWidth = Math.max(0, ...args.map(([key]) => key.length)) + GAP;
354 return (
355 <Box flexDirection="column" paddingLeft={indent} marginBottom={1}>
356 {args.map(([key, value]) => (
357 <Box key={`arg-${key}`} flexDirection="row">
358 <Box width={keyWidth}>
359 <Text dimColor>{key}</Text>
360 </Box>
361 <Text wrap="truncate-end">{fitMiddle(value, width - keyWidth)}</Text>
362 </Box>
363 ))}
364 <Text color={result.isError ? 'error' : 'suggestion'} bold>
365 {result.isError ? 'Error result' : 'Result'}
366 </Text>
367 {lines.map(line => (
368 <Text wrap="truncate-end">
369 <Text dimColor>│ </Text>
370 {line.slice(0, width - 2)}
371 </Text>
372 ))}
373 {more > 0 ? <Text dimColor>… {more} more lines</Text> : null}
374 </Box>
375 );
376 }
377 });
378}
379types/index.d.ts 51 lines1// One row of the context window as /context breaks it down: content in use,
2// the free space, or the compaction buffer.
3export type ContextBarSlice = {
4 name: string;
5 tokens: number;
6 // The theme colour /context draws the row in, by its key in the theme.
7 color: string;
8 kind: 'used' | 'free' | 'buffer';
9};
10
11// The window by category, as last estimated; on-demand tool schemas, which
12// sit outside the window, are left out.
13export type ContextBarBreakdown = {
14 slices: ContextBarSlice[];
15 // The window measured against: the model's, or a smaller compaction window.
16 window: number;
17};
18
19// One model request of the main conversation: the input side it was answered
20// over (uncached, cache-read and cache-written together) and what it generated.
21export type ContextBarStep = { context: number; output: number };
22
23// The main conversation's latest turn, kept until the next one starts.
24export type ContextBarTurn = {
25 // The context the turn started from: the last response's input side, or
26 // null when none was measured (a fresh or just-compacted session).
27 contextBefore: number | null;
28 steps: ContextBarStep[];
29 // The first step after a compaction within the turn, if one ran: the
30 // turn's growth is counted from there instead.
31 compactedAt: number | null;
32 // The session's cost when the turn started, in US dollars; null when unknown.
33 costBefore: number | null;
34 isRunning: boolean;
35};
36
37declare module 'claude-code' {
38 interface PluginState {
39 'context-bar': {
40 breakdown: ContextBarBreakdown | null;
41 // The input side of the last response, as the status line reports it.
42 measured: number | null;
43 // What the session has cost so far, in US dollars, as /cost totals it.
44 cost: number | null;
45 turn: ContextBarTurn | null;
46 // The /context-tools row expanded, by its call's id.
47 expandedTool: string | null;
48 };
49 }
50}
51