@pi9/context 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @pi9/context
2
2
 
3
- Pi extension that registers `/context` — a scrollable breakdown of how your current context window is being used (model, fixed-scale usage graph, compaction reserve, conversation stats, memory files, tools, and skills).
3
+ Pi extension that registers `/context` — a scrollable breakdown of how your current context window is being used (model, responsive usage graph, compaction reserve, conversation stats, memory files, tools, and skills).
4
4
 
5
5
  ![The context report showing a token-usage graph and detailed breakdown](media/context-report.png)
6
6
 
@@ -37,4 +37,4 @@ To verify the TUI end-to-end:
37
37
 
38
38
  1. Start pi with this extension in a terminal at least 80 columns wide.
39
39
  2. Run `/context` on a session with several messages so the report exceeds the viewport.
40
- 3. Confirm keyboard scrolling, help text, the 1K-token-per-character graph, section token totals, and **Esc** closes the inline view without injecting report text into the chat.
40
+ 3. Confirm keyboard scrolling, help text, the responsive graph and displayed per-block token scale, section token totals, and **Esc** closes the inline view without injecting report text into the chat.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi9/context",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Pi extension to show a breakdown of your current context usage.",
5
5
  "license": "MIT",
6
6
  "author": "Chase Cummings <chaseecummings@gmail.com>",
@@ -9,13 +9,13 @@
9
9
  "types": "src/index.ts",
10
10
  "repository": {
11
11
  "type": "git",
12
- "url": "git+https://github.com/Chase-C/pi9_subagent.git",
12
+ "url": "git+https://github.com/Chase-C/pi9.git",
13
13
  "directory": "packages/context"
14
14
  },
15
15
  "bugs": {
16
- "url": "https://github.com/Chase-C/pi9_subagent/issues"
16
+ "url": "https://github.com/Chase-C/pi9/issues"
17
17
  },
18
- "homepage": "https://github.com/Chase-C/pi9_subagent/tree/main/packages/context#readme",
18
+ "homepage": "https://github.com/Chase-C/pi9/tree/main/packages/context#readme",
19
19
  "files": [
20
20
  "src",
21
21
  "media",
@@ -58,9 +58,9 @@
58
58
  "@earendil-works/pi-tui": "*"
59
59
  },
60
60
  "devDependencies": {
61
- "@earendil-works/pi-ai": "^0.80.6",
62
- "@earendil-works/pi-coding-agent": "^0.80.6",
63
- "@earendil-works/pi-tui": "^0.80.6",
61
+ "@earendil-works/pi-ai": "^0.82.1",
62
+ "@earendil-works/pi-coding-agent": "^0.82.1",
63
+ "@earendil-works/pi-tui": "^0.82.1",
64
64
  "typescript": "~6.0.3",
65
65
  "vitest": "^4.1.6"
66
66
  }
package/src/builders.ts CHANGED
@@ -13,7 +13,6 @@ import type {
13
13
  ContextReport,
14
14
  ConversationDetails,
15
15
  ConversationStats,
16
- ConversationTurn,
17
16
  MemoryDetails,
18
17
  ModelDetails,
19
18
  SkillDetails,
@@ -248,91 +247,52 @@ export function collectConversationDetails(ctx: ExtensionCommandContext): Conver
248
247
  userMessages: 0,
249
248
  assistantMessages: 0,
250
249
  toolResults: 0,
251
- toolCalls: 0,
252
250
  thinkingBlocks: 0,
253
251
  imageBlocks: 0,
254
252
  compactions: branch.filter((entry) => entry.type === "compaction").length,
255
253
  };
256
- const history: ConversationTurn[] = [];
254
+ const toolCallCounts = new Map<string, number>();
257
255
  let tokens = 0;
258
256
 
259
257
  for (const message of messages) {
260
- const messageTokens = estimateMessageTokens(message);
261
- tokens += messageTokens;
258
+ tokens += estimateMessageTokens(message);
262
259
 
263
260
  switch (message.role) {
264
261
  case "user":
265
262
  stats.userMessages += 1;
266
263
  stats.imageBlocks += countImageBlocks(message.content);
267
- history.push({ kind: "user", tokens: messageTokens });
268
264
  break;
269
265
  case "assistant":
270
266
  stats.assistantMessages += 1;
271
- addAssistantTurns(message, history, stats, messageTokens);
267
+ collectAssistantStats(message, stats, toolCallCounts);
272
268
  break;
273
269
  case "toolResult":
274
270
  stats.toolResults += 1;
275
271
  stats.imageBlocks += countImageBlocks(message.content);
276
- history.push({
277
- kind: "tool-result",
278
- tool: message.toolName ?? "unknown",
279
- tokens: messageTokens,
280
- callId: message.toolCallId,
281
- isError: message.isError,
282
- });
283
272
  break;
284
273
  case "custom":
285
274
  stats.imageBlocks += countImageBlocks(message.content);
286
- history.push({ kind: "custom", tokens: messageTokens });
287
- break;
288
- default:
289
- history.push({ kind: "custom", tokens: messageTokens });
290
275
  break;
291
276
  }
292
277
  }
293
278
 
294
- return { stats, history, tokens };
279
+ return { stats, toolCallCounts, tokens };
295
280
  }
296
281
 
297
- function addAssistantTurns(
282
+ function collectAssistantStats(
298
283
  message: AssistantMessage,
299
- history: ConversationTurn[],
300
284
  stats: ConversationStats,
301
- fallbackTokens: number,
285
+ toolCallCounts: Map<string, number>,
302
286
  ): void {
303
- if (!Array.isArray(message.content)) {
304
- history.push({ kind: "assistant", tokens: fallbackTokens });
305
- return;
306
- }
287
+ if (!Array.isArray(message.content)) return;
307
288
 
308
- let emitted = false;
309
289
  for (const block of message.content) {
310
- if (!block || typeof block !== "object" || !("type" in block)) {
311
- continue;
312
- }
313
-
314
- if (block.type === "text") {
315
- history.push({ kind: "assistant", tokens: estimateTokens("text" in block ? block.text : "") });
316
- emitted = true;
317
- } else if (block.type === "thinking") {
290
+ if (block.type === "thinking") {
318
291
  stats.thinkingBlocks += 1;
319
- history.push({ kind: "thinking", tokens: estimateTokens("thinking" in block ? block.thinking : "") });
320
- emitted = true;
321
292
  } else if (block.type === "toolCall") {
322
- stats.toolCalls += 1;
323
- history.push({
324
- kind: "tool-call",
325
- tool: "name" in block && typeof block.name === "string" ? block.name : "unknown",
326
- tokens: estimateTokens(block),
327
- callId: "id" in block && typeof block.id === "string" ? block.id : undefined,
328
- });
329
- emitted = true;
293
+ toolCallCounts.set(block.name, (toolCallCounts.get(block.name) ?? 0) + 1);
330
294
  }
331
295
  }
332
-
333
- if (!emitted) {
334
- history.push({ kind: "assistant", tokens: fallbackTokens });
335
- }
336
296
  }
337
297
 
338
298
  function escapeXml(value: string): string {
package/src/component.ts CHANGED
@@ -5,22 +5,21 @@ import type { ContextReport, ToolSource } from "./types.js";
5
5
 
6
6
  export const CONTEXT_REPORT_HELP = "↑↓/jk scroll · PgUp/PgDn or u/d page · Home/End · q/Esc close";
7
7
 
8
- const CHROME_LINES = 4;
8
+ const CHROME_LINES = 3;
9
9
  const VIEW_HEIGHT_FRACTION = 0.9;
10
- const GRAPH_CELL_TOKENS = 1_000;
11
10
  const GRAPH_CELL_GAP = " ";
12
11
  const GRAPH_GAP = 4;
13
- const SUMMARY_MIN_WIDTH = 28;
12
+ const STACKED_GRAPH_ROWS = 7;
14
13
  const GRAPH_STYLE = {
15
- prompt: { glyph: "●", color: "muted" },
16
- tools: { glyph: "", color: "warning" },
17
- memory: { glyph: "", color: "error" },
18
- skills: { glyph: "", color: "success" },
14
+ prompt: { glyph: "●", color: "text" },
15
+ tools: { glyph: "", color: "warning" },
16
+ memory: { glyph: "", color: "error" },
17
+ skills: { glyph: "", color: "success" },
19
18
  conversation: { glyph: "◉", color: "accent" },
20
- other: { glyph: "", color: "dim" },
19
+ other: { glyph: "", color: "muted" },
21
20
  free: { glyph: "○", color: "borderMuted" },
22
- compaction: { glyph: "", color: "warning" },
23
- unknown: { glyph: "?", color: "dim" },
21
+ compaction: { glyph: "", color: "warning" },
22
+ unknown: { glyph: "", color: "dim" },
24
23
  } as const satisfies Record<string, { glyph: string; color: ThemeColor }>;
25
24
 
26
25
  type TreeBranch = "├" | "└";
@@ -119,12 +118,18 @@ export function createContextReportComponent(
119
118
 
120
119
  const innerWidth = width - 2;
121
120
  const border = (text: string) => theme.fg("border", text);
122
- const padLine = (text: string) => truncateToWidth(text, innerWidth, "...", true);
121
+ const padLine = (text: string) => {
122
+ const clipped = truncateToWidth(text, innerWidth, "...", true);
123
+ return `${clipped}${" ".repeat(Math.max(0, innerWidth - visibleWidth(clipped)))}`;
124
+ };
123
125
  const title = truncateToWidth(" Context Report ", innerWidth);
124
- const titlePad = Math.max(0, innerWidth - visibleWidth(title));
126
+ const titleWidth = visibleWidth(title);
127
+ const separator = "─".repeat(Math.min(4, Math.max(0, innerWidth - titleWidth)));
128
+ const helpWidth = Math.max(0, innerWidth - titleWidth - separator.length);
129
+ const help = truncateToWidth(` ${CONTEXT_REPORT_HELP} `, helpWidth, "");
130
+ const titlePad = Math.max(0, helpWidth - visibleWidth(help));
125
131
  const result = [
126
- border("╭") + theme.fg("accent", title) + border(`${"─".repeat(titlePad)}╮`),
127
- border("│") + padLine(` ${theme.fg("dim", CONTEXT_REPORT_HELP)}`) + border("│"),
132
+ border("╭") + theme.fg("accent", theme.bold(title)) + border(separator) + theme.fg("dim", help) + border(`${"─".repeat(titlePad)}╮`),
128
133
  ];
129
134
 
130
135
  const visible = displayLines.slice(scrollOffset, scrollOffset + viewport);
@@ -137,7 +142,7 @@ export function createContextReportComponent(
137
142
 
138
143
  const maxOffset = maxScrollOffset(displayLines.length, viewport);
139
144
  const scrollHint = maxOffset > 0
140
- ? theme.fg("dim", ` ${scrollOffset + 1}-${scrollOffset + visible.length} of ${displayLines.length}`)
145
+ ? theme.fg("muted", ` ${scrollOffset + 1}-${scrollOffset + visible.length} of ${displayLines.length}`)
141
146
  : "";
142
147
  result.push(border("│") + padLine(scrollHint) + border("│"));
143
148
  result.push(border(`╰${"─".repeat(innerWidth)}╯`));
@@ -167,11 +172,7 @@ export function clampScrollOffset(offset: number, lineCount: number, viewportLin
167
172
  }
168
173
 
169
174
  export function formatContextReportLines(report: ContextReport, theme: Theme, width = 80): string[] {
170
- const lines = [
171
- theme.fg("customMessageLabel", theme.bold("Context Usage")),
172
- "",
173
- ...formatUsageOverview(report, theme, width),
174
- ];
175
+ const lines = ["", ...formatUsageOverview(report, theme, width)];
175
176
 
176
177
  if (report.kind === "conversation") {
177
178
  pushSection(lines, formatConversationSection(report, theme));
@@ -221,11 +222,28 @@ function formatUsageOverview(report: ContextReport, theme: Theme, width: number)
221
222
  ];
222
223
  const totalGraphTokens = contextWindow > 0
223
224
  ? contextWindow
224
- : Math.max(GRAPH_CELL_TOKENS, sum(graphCategories.map((category) => category.tokens)));
225
+ : Math.max(1, sum(graphCategories.map((category) => category.tokens)));
225
226
  const visibleGraphCategories = graphCategories.length > 0
226
227
  ? graphCategories
227
228
  : [{ label: "Free space", tokens: totalGraphTokens, ...GRAPH_STYLE.free }];
228
- const graphLines = formatGraphGrid(visibleGraphCategories, totalGraphTokens, graphColumnCount(width), theme);
229
+ const summaryProbe = formatUsageSummary(
230
+ report,
231
+ usedCategories,
232
+ freeTokens,
233
+ unknownTokens,
234
+ configuredReserve,
235
+ compactionTokens,
236
+ totalGraphTokens,
237
+ theme,
238
+ );
239
+ const summaryWidth = Math.max(...summaryProbe.map(visibleWidth));
240
+ const columns = graphColumnCount(width, summaryWidth);
241
+ const sideBySide = shouldRenderSideBySide(width, columns, summaryWidth);
242
+ const targetRows = sideBySide ? summaryProbe.length : STACKED_GRAPH_ROWS;
243
+ const graphRows = Math.max(targetRows, Math.ceil(visibleGraphCategories.length / columns));
244
+ const cellCount = columns * graphRows;
245
+ const tokensPerCell = totalGraphTokens / cellCount;
246
+ const graphLines = formatGraphGrid(visibleGraphCategories, cellCount, columns, theme);
229
247
  const summaryLines = formatUsageSummary(
230
248
  report,
231
249
  usedCategories,
@@ -233,10 +251,11 @@ function formatUsageOverview(report: ContextReport, theme: Theme, width: number)
233
251
  unknownTokens,
234
252
  configuredReserve,
235
253
  compactionTokens,
254
+ tokensPerCell,
236
255
  theme,
237
256
  );
238
257
 
239
- if (!shouldRenderSideBySide(width, graphLines)) {
258
+ if (!sideBySide) {
240
259
  return [
241
260
  ...graphLines,
242
261
  "",
@@ -260,6 +279,7 @@ function formatUsageSummary(
260
279
  unknownTokens: number,
261
280
  configuredReserve: number,
262
281
  compactionTokens: number,
282
+ tokensPerCell: number,
263
283
  theme: Theme,
264
284
  ): string[] {
265
285
  const contextWindow = knownTokenValue(report.usage.contextWindow) ?? 0;
@@ -272,25 +292,25 @@ function formatUsageSummary(
272
292
  `${theme.fg("text", theme.bold(modelLabel))}${contextWindow > 0 ? theme.fg("muted", ` (${formatTokens(contextWindow)} context)`) : ""}`,
273
293
  theme.fg("muted", modelParts.join(" · ")),
274
294
  `${theme.fg("accent", formatTokens(currentTotal))}${contextWindow > 0 ? `/${formatTokens(contextWindow)}` : ""} tokens (${formatPercent(report.usage.percent)})`,
275
- theme.fg("muted", `1 char = ${formatTokens(GRAPH_CELL_TOKENS)} tokens`),
295
+ theme.fg("muted", `1 block ${formatTokens(tokensPerCell)} tokens`),
276
296
  "",
277
297
  heading(theme, "Estimated breakdown", currentTotal),
278
298
  ];
279
299
 
280
300
  for (const category of categories) {
281
- lines.push(`${theme.fg(category.color, category.glyph)} ${category.label}: ${formatTokens(category.tokens)}${formatCategoryPercent(category.tokens, contextWindow)}`);
301
+ lines.push(`${theme.fg(category.color, category.glyph)} ${category.label}: ${formatBreakdownValue(category.tokens, contextWindow, theme)}`);
282
302
  }
283
303
  if (freeTokens > 0) {
284
- lines.push(`${theme.fg(GRAPH_STYLE.free.color, GRAPH_STYLE.free.glyph)} Free space: ${formatTokens(freeTokens)}${formatCategoryPercent(freeTokens, contextWindow)}`);
304
+ lines.push(`${theme.fg(GRAPH_STYLE.free.color, GRAPH_STYLE.free.glyph)} Free space: ${formatBreakdownValue(freeTokens, contextWindow, theme)}`);
285
305
  }
286
306
  if (unknownTokens > 0) {
287
- lines.push(`${theme.fg(GRAPH_STYLE.unknown.color, GRAPH_STYLE.unknown.glyph)} Unknown capacity: ${formatTokens(unknownTokens)}${formatCategoryPercent(unknownTokens, contextWindow)}`);
307
+ lines.push(`${theme.fg(GRAPH_STYLE.unknown.color, GRAPH_STYLE.unknown.glyph)} Unknown capacity: ${formatBreakdownValue(unknownTokens, contextWindow, theme)}`);
288
308
  }
289
309
  if (configuredReserve > 0) {
290
310
  const remaining = compactionTokens < configuredReserve
291
- ? theme.fg("dim", ` · ${formatTokens(compactionTokens)} unoccupied`)
311
+ ? theme.fg("muted", ` · ${formatTokens(compactionTokens)} unoccupied`)
292
312
  : "";
293
- lines.push(`${theme.fg(GRAPH_STYLE.compaction.color, GRAPH_STYLE.compaction.glyph)} Compaction reserve: ${formatTokens(configuredReserve)}${formatCategoryPercent(configuredReserve, contextWindow)}${remaining}`);
313
+ lines.push(`${theme.fg(GRAPH_STYLE.compaction.color, GRAPH_STYLE.compaction.glyph)} Compaction reserve: ${formatBreakdownValue(configuredReserve, contextWindow, theme)}${remaining}`);
294
314
  }
295
315
 
296
316
  return lines;
@@ -352,15 +372,14 @@ function scaleCategories(categories: GraphCategory[], targetTokens: number): Gra
352
372
 
353
373
  function formatGraphGrid(
354
374
  categories: GraphCategory[],
355
- totalTokens: number,
375
+ cellCount: number,
356
376
  columns: number,
357
377
  theme: Theme,
358
378
  ): string[] {
359
- const cellCount = Math.max(1, Math.ceil(totalTokens / GRAPH_CELL_TOKENS));
360
379
  const cells = allocateGraphCells(categories, cellCount);
361
380
  const lines: string[] = [];
362
381
 
363
- for (let start = 0; start < cellCount; start += columns) {
382
+ for (let start = 0; start < cells.length; start += columns) {
364
383
  lines.push(cells
365
384
  .slice(start, start + columns)
366
385
  .map((category) => theme.fg(category.color, category.glyph))
@@ -371,39 +390,41 @@ function formatGraphGrid(
371
390
  }
372
391
 
373
392
  function allocateGraphCells(categories: GraphCategory[], cellCount: number): GraphCategory[] {
374
- const ranges: Array<GraphCategory & { start: number; end: number }> = [];
375
- let cursor = 0;
376
- for (const category of categories) {
377
- const tokens = Math.max(0, category.tokens);
378
- if (tokens <= 0) continue;
379
- ranges.push({ ...category, tokens, start: cursor, end: cursor + tokens });
380
- cursor += tokens;
393
+ const visibleCategories = categories.filter((category) => category.tokens > 0);
394
+ if (visibleCategories.length === 0) return [];
395
+
396
+ const targetCellCount = Math.max(cellCount, visibleCategories.length);
397
+ const totalTokens = sum(visibleCategories.map((category) => category.tokens));
398
+ const allocations = visibleCategories.map((category) => {
399
+ const ideal = (category.tokens / totalTokens) * targetCellCount;
400
+ return { category, ideal, count: Math.max(1, Math.floor(ideal)) };
401
+ });
402
+ let allocated = sum(allocations.map((allocation) => allocation.count));
403
+
404
+ while (allocated > targetCellCount) {
405
+ const donor = allocations
406
+ .filter((allocation) => allocation.count > 1)
407
+ .sort((a, b) => (b.count - b.ideal) - (a.count - a.ideal))[0];
408
+ if (!donor) break;
409
+ donor.count -= 1;
410
+ allocated -= 1;
381
411
  }
382
412
 
383
- const fallback = ranges[ranges.length - 1] ?? { label: "Free space", tokens: 0, start: 0, end: 0, ...GRAPH_STYLE.free };
384
- const cells: GraphCategory[] = [];
385
- for (let index = 0; index < cellCount; index += 1) {
386
- const start = index * GRAPH_CELL_TOKENS;
387
- const end = start + GRAPH_CELL_TOKENS;
388
- let best = fallback;
389
- let bestOverlap = 0;
390
-
391
- for (const range of ranges) {
392
- const overlap = Math.max(0, Math.min(end, range.end) - Math.max(start, range.start));
393
- if (overlap > bestOverlap) {
394
- best = range;
395
- bestOverlap = overlap;
396
- }
397
- }
398
-
399
- cells.push(best);
413
+ while (allocated < targetCellCount) {
414
+ const recipient = [...allocations]
415
+ .sort((a, b) => (b.ideal - b.count) - (a.ideal - a.count))[0]!;
416
+ recipient.count += 1;
417
+ allocated += 1;
400
418
  }
401
- return cells;
419
+
420
+ return allocations.flatMap(({ category, count }) =>
421
+ Array.from({ length: count }, () => category),
422
+ );
402
423
  }
403
424
 
404
- function graphColumnCount(width: number): number {
425
+ function graphColumnCount(width: number, summaryWidth: number): number {
405
426
  const target = width >= 112 ? 36 : width >= 88 ? 32 : width >= 68 ? 24 : 20;
406
- const sideBySideWidth = width - GRAPH_GAP - SUMMARY_MIN_WIDTH;
427
+ const sideBySideWidth = width - GRAPH_GAP - summaryWidth;
407
428
  const maxWidth = sideBySideWidth >= 10 ? sideBySideWidth : width;
408
429
  return Math.max(1, Math.min(target, maxGraphColumnsForWidth(maxWidth)));
409
430
  }
@@ -413,9 +434,9 @@ function maxGraphColumnsForWidth(width: number): number {
413
434
  return Math.max(1, Math.floor((Math.max(1, width) + gapWidth) / (1 + gapWidth)));
414
435
  }
415
436
 
416
- function shouldRenderSideBySide(width: number, graphLines: readonly string[]): boolean {
417
- const graphWidth = visibleWidth(graphLines[0] ?? "");
418
- return width - graphWidth - GRAPH_GAP >= SUMMARY_MIN_WIDTH;
437
+ function shouldRenderSideBySide(width: number, columns: number, summaryWidth: number): boolean {
438
+ const graphWidth = columns + (columns - 1) * visibleWidth(GRAPH_CELL_GAP);
439
+ return width - graphWidth - GRAPH_GAP >= summaryWidth;
419
440
  }
420
441
 
421
442
  function padAnsi(text: string, width: number): string {
@@ -432,7 +453,7 @@ function formatConversationSection(report: Extract<ContextReport, { kind: "conve
432
453
  return [
433
454
  heading(theme, "Conversation (estimated)", report.conversation.tokens),
434
455
  `${branch(theme, "├")}messages: user ${stats.userMessages} · assistant ${stats.assistantMessages} · tool results ${stats.toolResults}`,
435
- `${branch(theme, "├")}blocks: tool calls ${stats.toolCalls} · thinking ${stats.thinkingBlocks} · images ${stats.imageBlocks}`,
456
+ `${branch(theme, "├")}blocks: tool calls ${sum([...report.conversation.toolCallCounts.values()])} · thinking ${stats.thinkingBlocks} · images ${stats.imageBlocks}`,
436
457
  `${branch(theme, "├")}compactions: ${stats.compactions}`,
437
458
  `${branch(theme, "└")}message tokens: ${theme.fg("accent", formatTokens(report.conversation.tokens))}`,
438
459
  ];
@@ -442,7 +463,7 @@ function formatToolsSection(report: ContextReport, theme: Theme): string[] {
442
463
  if (report.tools.length === 0) return [];
443
464
 
444
465
  const callCounts = report.kind === "conversation"
445
- ? collectToolCallCounts(report.conversation.history)
466
+ ? report.conversation.toolCallCounts
446
467
  : new Map<string, number>();
447
468
  const groups: Array<{ title: string; kind: ToolSource["kind"] }> = [
448
469
  { title: "Built-in tools", kind: "builtin" },
@@ -457,11 +478,11 @@ function formatToolsSection(report: ContextReport, theme: Theme): string[] {
457
478
  if (tools.length === 0) continue;
458
479
 
459
480
  const groupTokens = sum(tools.filter((tool) => tool.active).map((tool) => tool.tokens));
460
- lines.push(`${theme.fg("muted", " ")}${theme.fg("text", theme.bold(group.title))}${theme.fg("dim", ` · ${formatTokens(groupTokens)} tokens`)}`);
481
+ lines.push(`${theme.fg("muted", " ")}${theme.fg("text", theme.bold(group.title))}${theme.fg("muted", ` · ${formatTokens(groupTokens)} tokens`)}`);
461
482
  tools.forEach((tool, index) => {
462
483
  const tree = index === tools.length - 1 ? "└" : "├";
463
484
  const meta = `${tool.active ? "active" : "inactive"} · ${formatToolSource(tool.source)} · ${formatCallCount(callCounts.get(tool.name) ?? 0)}`;
464
- lines.push(`${theme.fg("muted", ` ${tree}─ `)}${theme.fg("text", tool.name)}: ${theme.fg("accent", formatTokens(tool.tokens))} tokens${theme.fg("dim", ` · ${meta}`)}`);
485
+ lines.push(`${theme.fg("muted", ` ${tree}─ `)}${theme.fg("text", tool.name)}: ${theme.fg("accent", formatTokens(tool.tokens))} tokens${theme.fg("muted", ` · ${meta}`)}`);
465
486
  });
466
487
  }
467
488
 
@@ -477,7 +498,7 @@ function formatDetailSection(
477
498
 
478
499
  const lines = [heading(theme, title, sum(items.map((item) => item.tokens)))];
479
500
  items.forEach((item, index) => {
480
- const meta = item.meta ? theme.fg("dim", ` · ${item.meta}`) : "";
501
+ const meta = item.meta ? theme.fg("muted", ` · ${item.meta}`) : "";
481
502
  lines.push(`${branch(theme, index === items.length - 1 ? "└" : "├")}${theme.fg("text", item.label)}: ${theme.fg("accent", formatTokens(item.tokens))} tokens${meta}`);
482
503
  });
483
504
 
@@ -493,16 +514,6 @@ function formatCallCount(count: number): string {
493
514
  return count === 1 ? "1 call" : `${count.toLocaleString()} calls`;
494
515
  }
495
516
 
496
- function collectToolCallCounts(history: Extract<ContextReport, { kind: "conversation" }>["conversation"]["history"]): Map<string, number> {
497
- const counts = new Map<string, number>();
498
- for (const turn of history) {
499
- if (turn.kind === "tool-call") {
500
- counts.set(turn.tool, (counts.get(turn.tool) ?? 0) + 1);
501
- }
502
- }
503
- return counts;
504
- }
505
-
506
517
  function pushSection(lines: string[], section: string[]): void {
507
518
  if (section.length === 0) return;
508
519
  if (lines.length > 0) lines.push("");
@@ -510,7 +521,7 @@ function pushSection(lines: string[], section: string[]): void {
510
521
  }
511
522
 
512
523
  function heading(theme: Theme, text: string, tokens?: number | null): string {
513
- const total = tokens === undefined ? "" : theme.fg("dim", ` · ${formatTokens(tokens)} tokens`);
524
+ const total = tokens === undefined ? "" : theme.fg("muted", ` · ${formatTokens(tokens)} tokens`);
514
525
  return theme.fg("customMessageLabel", theme.bold(text)) + total;
515
526
  }
516
527
 
@@ -532,8 +543,12 @@ function formatPercent(value: number | null | undefined): string {
532
543
  return value === null || value === undefined ? "unknown" : `${value.toFixed(1)}%`;
533
544
  }
534
545
 
535
- function formatCategoryPercent(tokens: number | null, contextWindow: number): string {
536
- return tokens === null || contextWindow <= 0 ? "" : ` — ${((tokens / contextWindow) * 100).toFixed(1)}%`;
546
+ function formatBreakdownValue(tokens: number, contextWindow: number, theme: Theme): string {
547
+ const tokenValue = `${theme.fg("accent", formatTokens(tokens))}${theme.fg("text", " tokens")}`;
548
+ const percent = contextWindow > 0
549
+ ? theme.fg("muted", ` · ${((tokens / contextWindow) * 100).toFixed(1)}%`)
550
+ : "";
551
+ return `${tokenValue}${percent}`;
537
552
  }
538
553
 
539
554
  function sum(values: readonly number[]): number {
package/src/types.ts CHANGED
@@ -71,7 +71,7 @@ export interface SnapshotDetails {
71
71
 
72
72
  export interface ConversationDetails {
73
73
  stats: ConversationStats;
74
- history: ConversationTurn[];
74
+ toolCallCounts: Map<string, number>;
75
75
  tokens: number;
76
76
  }
77
77
 
@@ -79,27 +79,7 @@ export interface ConversationStats {
79
79
  userMessages: number;
80
80
  assistantMessages: number;
81
81
  toolResults: number;
82
- toolCalls: number;
83
82
  thinkingBlocks: number;
84
83
  imageBlocks: number;
85
84
  compactions: number;
86
85
  }
87
-
88
- export type ConversationTurn =
89
- | {
90
- kind: "user" | "assistant" | "thinking" | "bash" | "custom" | "compact" | "branch";
91
- tokens: number;
92
- }
93
- | {
94
- kind: "tool-call";
95
- tool: string;
96
- tokens: number;
97
- callId?: string;
98
- }
99
- | {
100
- kind: "tool-result";
101
- tool: string;
102
- tokens: number;
103
- callId?: string;
104
- isError?: boolean;
105
- };