SLOPSHOPPER

context-bar

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

newpanebandcommand
v0.1.0no licenseupdated 2026-10-06ShadowDog007/claude-mods/plugins/context-bar
A shopper browsing a rack in a slop shop
README

context-bar

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
  • The bar fills the band, with the total in use against the window and what the session has cost so far at its end. Content is drawn in the colours /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.
  • The last turn is carved off the end of the messages in a colour no other segment uses, so you can see how much of the window the latest turn took.
  • The legend names each segment, in its colour, with its tokens. When the line is too narrow it drops the token counts, then the trailing names, so it never wraps.
  • The turn line shows the current turn while it runs (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-tools

Opens 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 ]
  • A call the model described (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.
  • Click a call, or Tab to it and press Enter, to expand it: the call's arguments and the first lines of its result. Press it again to fold it.
  • The header counts the results and their total against the messages' share of the window.
  • Each result is sized by the tool's own output. The <system-reminder> blocks Claude Code attaches to whichever result comes next are left out of it and counted together in the header.
  • The pane is for you alone: nothing of it reaches the model. Escape, 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.

Notes

  • The categories are the engine's local estimate (/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.
  • Turn and step figures come from the API's reported usage of each main-conversation request (uncached, cache-read and cache-written input together); subagents' requests are not counted.
  • Costs are the session's running total as /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.
  • Tool schemas loaded on demand sit outside the window and are left out.
  • What it shows is kept in the session's plugin state, so a reload draws at once. /clear resets everything.
Source 3 files
hooks/register.tsx 342 lines
1import { 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};
342
hooks/tools.tsx 379 lines
1import { 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}
379
types/index.d.ts 51 lines
1// 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