@xprnvd/pi-context-view 0.0.0-stage → 0.7.2

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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -2
  3. package/package.json +55 -5
  4. package/src/capture/adjustments.ts +649 -0
  5. package/src/capture/attribution.ts +25 -0
  6. package/src/capture/builder.ts +128 -0
  7. package/src/capture/diff.ts +455 -0
  8. package/src/capture/dispatch.ts +36 -0
  9. package/src/capture/guard.ts +287 -0
  10. package/src/capture/messages.ts +246 -0
  11. package/src/capture/payload.ts +584 -0
  12. package/src/capture/redact.ts +58 -0
  13. package/src/capture/register.ts +129 -0
  14. package/src/capture/request.ts +218 -0
  15. package/src/capture/tools.ts +69 -0
  16. package/src/capture/tracker.ts +71 -0
  17. package/src/command.ts +164 -0
  18. package/src/compaction.ts +57 -0
  19. package/src/config.ts +408 -0
  20. package/src/index.ts +191 -0
  21. package/src/injections.ts +670 -0
  22. package/src/measure.ts +887 -0
  23. package/src/message-preview.ts +72 -0
  24. package/src/model-identity.ts +48 -0
  25. package/src/model.ts +548 -0
  26. package/src/pi-version.ts +29 -0
  27. package/src/probe/failure.ts +34 -0
  28. package/src/probe/filter.ts +126 -0
  29. package/src/probe/silent-probe.ts +349 -0
  30. package/src/probe/token.ts +28 -0
  31. package/src/probe/trigger.ts +178 -0
  32. package/src/probe/view.ts +26 -0
  33. package/src/projection.ts +122 -0
  34. package/src/prompt-additions.ts +226 -0
  35. package/src/prompt-blocks.ts +80 -0
  36. package/src/replay.ts +234 -0
  37. package/src/settings.ts +62 -0
  38. package/src/snapshot.ts +188 -0
  39. package/src/system-prompt-rows.ts +311 -0
  40. package/src/text.ts +46 -0
  41. package/src/token-diff.ts +211 -0
  42. package/src/transcript.ts +19 -0
  43. package/src/ui/color.ts +52 -0
  44. package/src/ui/diff-preview.ts +181 -0
  45. package/src/ui/injections-model.ts +378 -0
  46. package/src/ui/injections-view.ts +729 -0
  47. package/src/ui/json-preview.ts +41 -0
  48. package/src/ui/layout.ts +148 -0
  49. package/src/ui/markers.ts +253 -0
  50. package/src/ui/notes.ts +117 -0
  51. package/src/ui/section-preview.ts +267 -0
  52. package/src/ui/skill-preview.ts +83 -0
  53. package/src/ui/status-bar.ts +94 -0
  54. package/src/ui/usage-map.ts +157 -0
  55. package/src/ui/usage-preview.ts +194 -0
  56. package/src/ui/usage-view.ts +1255 -0
  57. package/src/ui/wheel.ts +65 -0
  58. package/src/usage.ts +461 -0
@@ -0,0 +1,1255 @@
1
+ /**
2
+ * Focused `/context usage` view: estimated context composition with a
3
+ * proportional context-window map, pi-reported metadata, selectable category
4
+ * rows, direct uncapped content for single-entry categories, and chronological
5
+ * block streams with opt-in full content for multi-entry categories.
6
+ */
7
+ import type { ExtensionCommandContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
8
+ import { Key, matchesKey, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
9
+
10
+ import {
11
+ AUTO_COMPACT_BUFFER_CATEGORY_ID,
12
+ type CategoryColor,
13
+ type CategoryColors,
14
+ FREE_SPACE_CATEGORY_ID,
15
+ type MapSize,
16
+ resolveCategoryColor,
17
+ } from "../config.ts";
18
+ import type { ContextUsageSnapshot, UsageCategory, UsagePreviewEntry } from "../model.ts";
19
+ import { normalizeInlineText, normalizePreviewText } from "../text.ts";
20
+ import { collectPreviewEntries } from "../usage.ts";
21
+ import { colorize } from "./color.ts";
22
+ import { ListNavigator, PreviewScroller } from "./injections-model.ts";
23
+ import { expandJsonSpan } from "./json-preview.ts";
24
+ import {
25
+ BODY_INDENT,
26
+ calculateViewport,
27
+ capNoticeLines,
28
+ DEFAULT_TERMINAL_ROWS,
29
+ descriptionBlockRows,
30
+ fitLine,
31
+ fitToTerminalHeight,
32
+ hintRow,
33
+ isPageBackKey,
34
+ isPageForwardKey,
35
+ isStepBackKey,
36
+ isStepForwardKey,
37
+ normalizeTerminalRows,
38
+ spreadLine,
39
+ STEP_KEY_HINT,
40
+ wrapDescriptionLines,
41
+ } from "./layout.ts";
42
+ import { forcedMarker } from "./markers.ts";
43
+ import { descriptionNoteLines, emptyPreviewNote, probingDisabledNote, thinkingNote, usageNote } from "./notes.ts";
44
+ import { previewBodyLines, previewLegendLines } from "./section-preview.ts";
45
+ import { splitSkillPreview } from "./skill-preview.ts";
46
+ import { buildUsageMap, calculateFitMapScale, type UsageMap, type UsageMapCell } from "./usage-map.ts";
47
+ import { BlockNavigator, layoutPreviewBlocks, type PreviewLayout } from "./usage-preview.ts";
48
+ import { DEFAULT_WHEEL_SCROLL_LINES, parseWheelDirection, readWheelScrollLines } from "./wheel.ts";
49
+
50
+ /** Dashboard rows below the content, excluding the collapsible description: blank, hints, blank, border. */
51
+ const USAGE_TAIL_FIXED_LINE_COUNT = 4;
52
+ const DETAIL_CATEGORY_HEADER_LINE_COUNT = 1;
53
+ const PREVIEW_FIXED_LINE_COUNT = 8;
54
+ /** The block view adds the block identity header and its preceding separator to the preview frame. */
55
+ const BLOCK_FIXED_LINE_COUNT = PREVIEW_FIXED_LINE_COUNT + 2;
56
+ const PREVIEW_BLOCK_MAX_LINES = 10;
57
+ const PREVIEW_BLOCK_MIN_LINES = 4;
58
+ /** Stream rows two blocks spend around their content: both entry headers, both markers, one separator. */
59
+ const TWO_BLOCK_FRAME_ROWS = 5;
60
+ const BLOCK_GUTTER = "┃";
61
+ const CURSOR_COLUMN_WIDTH = 2;
62
+ /** Rows the notice block may occupy before it starts crowding out the map. */
63
+ const MAX_NOTICE_LINES = 3;
64
+ const MAX_LEGEND_VALUE_COLUMN = 32;
65
+ const LEGEND_VALUE_GAP = 2;
66
+ const LEGEND_LEADER_GAP = 4;
67
+ const MAP_SIDE_BY_SIDE_MIN_WIDTH = 52;
68
+ const SPACED_MAP_MIN_WIDTH = 72;
69
+ const MAP_COLUMN_GAP = 2;
70
+ const SPACED_MAP_COLUMN_GAP = 3;
71
+ /**
72
+ * Columns the legend keeps beside the map, so a wide configured map never
73
+ * squeezes its labels out. The default geometry fits beside it at every width
74
+ * that renders a map at all.
75
+ */
76
+ const MIN_DETAIL_WIDTH = 32;
77
+ const FULL_CELL = "■";
78
+ const PARTIAL_CELL = "◧";
79
+ const COMPACTED_CELL = "▦";
80
+ const BUFFER_CELL = "⛝";
81
+ const FREE_CELL = "⛶";
82
+ const BREAKDOWN_MARKER = "•";
83
+ const MAP_KEY_FULL_DESCRIPTION = "Single category block";
84
+ const MAP_KEY_PART_DESCRIPTION = "Shared block, largest category shown";
85
+ const MAP_KEY_SIZE_LABEL = "Block Size";
86
+ /** Rows the detailed key costs beside the complete legend: one separator plus four key rows. */
87
+ const MAP_KEY_DETAILED_SPARE_ROWS = 5;
88
+ /** Rows the single-line key costs beside the complete legend: one separator plus one key row. */
89
+ const MAP_KEY_COMPACT_SPARE_ROWS = 2;
90
+
91
+ /** Everything the Usage view renders, classified once when the view opens. */
92
+ export interface UsageViewInput {
93
+ readonly usage: ContextUsageSnapshot;
94
+ readonly degradedReason?: string;
95
+ /** True when no request was captured yet and configuration disables the silent probe. */
96
+ readonly probingDisabled?: boolean;
97
+ /** Non-fatal problems shown under the header, such as ignored configuration entries. */
98
+ readonly notices?: readonly string[];
99
+ /** Category colors resolved from user overrides, or `DEFAULT_CATEGORY_COLORS`. */
100
+ readonly categoryColors: CategoryColors;
101
+ /** Map geometry requested by configuration; the viewport clamps it per frame. */
102
+ readonly mapSize: MapSize;
103
+ }
104
+
105
+ /** View-local denominator selected for the context map. */
106
+ type UsageMapScale = "window" | "fit";
107
+
108
+ interface CategoryLegendRow {
109
+ readonly type: "category";
110
+ readonly category: UsageCategory;
111
+ readonly depth: number;
112
+ readonly rootId: string;
113
+ }
114
+
115
+ interface BufferLegendRow {
116
+ readonly type: "buffer";
117
+ readonly tokens: number;
118
+ }
119
+
120
+ interface FreeLegendRow {
121
+ readonly type: "free";
122
+ readonly tokens: number;
123
+ }
124
+
125
+ type LegendRow = CategoryLegendRow | BufferLegendRow | FreeLegendRow;
126
+
127
+ interface LegendColumns {
128
+ readonly value: number;
129
+ readonly tokenWidth: number;
130
+ }
131
+
132
+ /** One navigable preview block: an entry header plus its capped content lines. */
133
+ interface PreviewBlock {
134
+ /** Rendered after the two-column gutter, with the entry header first. */
135
+ readonly lines: readonly string[];
136
+ /** Content lines the per-block cap removed, shown as a trailing marker row. */
137
+ readonly hiddenLineCount: number;
138
+ }
139
+
140
+ /** Width- and cap-dependent block rendering cached for one category preview. */
141
+ interface PreviewStream {
142
+ readonly wrapWidth: number;
143
+ readonly maxLines: number;
144
+ readonly blocks: readonly PreviewBlock[];
145
+ readonly layout: PreviewLayout;
146
+ }
147
+
148
+ /** Wrapped, indented content lines of one preview entry, before any cap. */
149
+ type EntryLines = readonly string[];
150
+
151
+ /** Uncapped content of every entry in the open category, wrapped for one width. */
152
+ interface PreviewContent {
153
+ readonly wrapWidth: number;
154
+ /** Positionally aligned with the category's preview entries. */
155
+ readonly entries: readonly EntryLines[];
156
+ }
157
+
158
+ /** Uncapped content of the open block, wrapped and fitted for one terminal width. */
159
+ interface BlockBody {
160
+ readonly width: number;
161
+ readonly lines: readonly string[];
162
+ }
163
+
164
+ /** Open the Usage view as a fullscreen overlay. */
165
+ export async function showUsageView(context: ExtensionCommandContext, input: UsageViewInput): Promise<void> {
166
+ await context.ui.custom<void>(
167
+ (tui, theme, _keybindings, done) => {
168
+ const view = new UsageView(theme, input, done, () => tui.terminal.rows, readWheelScrollLines(tui));
169
+ return {
170
+ render: (width: number) => view.render(width),
171
+ invalidate: () => view.invalidate(),
172
+ handleInput: (data: string) => {
173
+ view.handleInput(data);
174
+ tui.requestRender();
175
+ },
176
+ };
177
+ },
178
+ {
179
+ overlay: true,
180
+ overlayOptions: { width: "100%", maxHeight: "100%", margin: 0 },
181
+ },
182
+ );
183
+ }
184
+
185
+ /** Exported for direct render/input tests; use showUsageView from pi code. */
186
+ export class UsageView {
187
+ private readonly theme: Theme;
188
+ private readonly input: UsageViewInput;
189
+ private readonly done: (result: undefined) => void;
190
+ private readonly getTerminalRows: () => number;
191
+ private readonly wheelScrollLines: number;
192
+ private readonly usage: ContextUsageSnapshot;
193
+ private readonly categoryColors: CategoryColors;
194
+ private readonly legendRows: readonly LegendRow[];
195
+ private readonly navigator: ListNavigator;
196
+ private readonly previewScroller = new PreviewScroller();
197
+ private readonly blockNavigator = new BlockNavigator();
198
+ private readonly fitMapScale: number | undefined;
199
+ private mapScale: UsageMapScale = "window";
200
+ private currentWidth: number | undefined;
201
+ private previewRow: CategoryLegendRow | undefined;
202
+ private cachedPreviewEntries: readonly UsagePreviewEntry[] | undefined;
203
+ private cachedContent: PreviewContent | undefined;
204
+ private cachedStream: PreviewStream | undefined;
205
+ private openBlockIndex: number | undefined;
206
+ private cachedBlockBody: BlockBody | undefined;
207
+ private cachedWidth: number | undefined;
208
+ private cachedTerminalRows: number | undefined;
209
+ private cachedLines: string[] | undefined;
210
+
211
+ /** Create a view over one precomputed usage snapshot. */
212
+ public constructor(
213
+ theme: Theme,
214
+ input: UsageViewInput,
215
+ done: (result: undefined) => void,
216
+ getTerminalRows: () => number = () => process.stdout.rows ?? DEFAULT_TERMINAL_ROWS,
217
+ wheelScrollLines: number = DEFAULT_WHEEL_SCROLL_LINES,
218
+ ) {
219
+ this.theme = theme;
220
+ this.input = input;
221
+ this.done = done;
222
+ this.getTerminalRows = getTerminalRows;
223
+ this.wheelScrollLines = wheelScrollLines;
224
+ this.usage = input.usage;
225
+ this.categoryColors = input.categoryColors;
226
+ this.fitMapScale = calculateFitMapScale(this.usage);
227
+ this.legendRows = this.buildLegendRows();
228
+ // The trailing buffer/free block has no preview: it scrolls with the list but is never selectable.
229
+ const selectableCount = this.legendRows.filter((row) => row.type === "category").length;
230
+ this.navigator = new ListNavigator(this.legendRows.length, 1, selectableCount);
231
+ }
232
+
233
+ /** Handle category navigation, preview opening, and close keys. */
234
+ public handleInput(data: string): void {
235
+ if (this.previewRow !== undefined) {
236
+ // Route on the same predicate the renderer uses, so keys always target the visible level.
237
+ if (this.getFullContentEntry(this.previewRow) === undefined) this.handlePreviewInput(data);
238
+ else this.handleContentInput(data);
239
+ return;
240
+ }
241
+ if (matchesKey(data, Key.escape) || data === "q") {
242
+ this.done(undefined);
243
+ return;
244
+ }
245
+ // One notch moves the selection one row, like a single step key.
246
+ const wheel = parseWheelDirection(data);
247
+ if (wheel !== undefined) {
248
+ if (this.navigator.moveBy(wheel)) this.clearCache();
249
+ return;
250
+ }
251
+ if (matchesKey(data, "z")) {
252
+ this.toggleMapScale();
253
+ } else if (matchesKey(data, Key.enter)) {
254
+ this.openPreview();
255
+ } else if (isStepBackKey(data)) {
256
+ if (this.navigator.moveBy(-1)) this.clearCache();
257
+ } else if (isStepForwardKey(data)) {
258
+ if (this.navigator.moveBy(1)) this.clearCache();
259
+ } else if (isPageBackKey(data)) {
260
+ if (this.navigator.page(-1)) this.clearCache();
261
+ } else if (isPageForwardKey(data)) {
262
+ if (this.navigator.page(1)) this.clearCache();
263
+ } else if (matchesKey(data, Key.home)) {
264
+ if (this.navigator.moveTo(0)) this.clearCache();
265
+ } else if (matchesKey(data, Key.end)) {
266
+ if (this.navigator.moveTo(this.legendRows.length - 1)) this.clearCache();
267
+ }
268
+ }
269
+
270
+ /** Render a cached fullscreen frame for the current width and terminal height. */
271
+ public render(width: number): string[] {
272
+ this.currentWidth = width;
273
+ const terminalRows = normalizeTerminalRows(this.getTerminalRows());
274
+ if (
275
+ this.cachedLines !== undefined &&
276
+ this.cachedWidth === width &&
277
+ this.cachedTerminalRows === terminalRows
278
+ ) {
279
+ return this.cachedLines;
280
+ }
281
+
282
+ const lines = this.renderActiveMode(width, terminalRows);
283
+ this.cachedWidth = width;
284
+ this.cachedTerminalRows = terminalRows;
285
+ this.cachedLines = lines;
286
+ return lines;
287
+ }
288
+
289
+ /** Invalidate theme-dependent rendered output. */
290
+ public invalidate(): void {
291
+ this.clearPreviewContent();
292
+ this.clearCache();
293
+ }
294
+
295
+ /** Render the dashboard, a multi-entry block stream, or one entry's full content. */
296
+ private renderActiveMode(width: number, terminalRows: number): string[] {
297
+ const row = this.previewRow;
298
+ if (row === undefined) return this.renderDashboard(width, terminalRows);
299
+ const entry = this.getFullContentEntry(row);
300
+ if (entry === undefined) return this.renderPreview(width, terminalRows, row);
301
+ return this.renderContentView(width, terminalRows, row, entry);
302
+ }
303
+
304
+ // === Dashboard mode ===
305
+
306
+ /** Full map/legend frame with navigation hints. */
307
+ private renderDashboard(width: number, terminalRows: number): string[] {
308
+ const theme = this.theme;
309
+ const border = theme.fg("border", "─".repeat(Math.max(1, width)));
310
+ const notices = this.noticeLines(width);
311
+ const prefix = [border, "", ...this.headerLines(width), "", ...notices, ...(notices.length > 0 ? [""] : [])];
312
+ const availableRows = Math.max(1, terminalRows - prefix.length - USAGE_TAIL_FIXED_LINE_COUNT);
313
+ const map = this.dashboardMap(width, availableRows);
314
+ const descriptionLines = this.dashboardDescriptionLines(width, availableRows, map);
315
+ const dashboardRows = Math.max(1, availableRows - descriptionBlockRows(descriptionLines));
316
+ const dashboard = this.dashboardLines(width, dashboardRows, map).slice(0, dashboardRows);
317
+ while (dashboard.length < dashboardRows) dashboard.push("");
318
+ const tail = [
319
+ ...(descriptionLines.length === 0 ? [] : ["", ...descriptionLines]),
320
+ "",
321
+ this.fit(
322
+ hintRow(theme, this.dashboardHints(width)),
323
+ width,
324
+ ),
325
+ "",
326
+ border,
327
+ ];
328
+ return fitToTerminalHeight([...prefix, ...dashboard, ...tail], terminalRows, border);
329
+ }
330
+
331
+ /** Accent title with responsive model, zoom label, and true-window usage metadata. */
332
+ private headerLines(width: number): string[] {
333
+ const theme = this.theme;
334
+ const title = theme.fg("accent", theme.bold("Context Usage"));
335
+ const summary = this.reportedSummary();
336
+ if (width < MAP_SIDE_BY_SIDE_MIN_WIDTH) {
337
+ return [this.fit(title, width), "", this.fit(summary, width)];
338
+ }
339
+
340
+ const normalizedModel = normalizeInlineText(this.usage.modelLabel ?? "");
341
+ const separator = theme.fg("dim", " · ");
342
+ const fullMetadata = normalizedModel === ""
343
+ ? summary
344
+ : `${theme.fg("muted", normalizedModel)}${separator}${summary}`;
345
+ const zoomLabel = this.zoomLabel(width);
346
+ if (zoomLabel !== undefined) {
347
+ const titleWithZoom = `${title}${separator}${zoomLabel}`;
348
+ if (visibleWidth(titleWithZoom) + 1 + visibleWidth(fullMetadata) <= width) {
349
+ return [spreadLine(titleWithZoom, fullMetadata, width)];
350
+ }
351
+ if (visibleWidth(titleWithZoom) + 1 + visibleWidth(summary) <= width) {
352
+ return [spreadLine(titleWithZoom, summary, width)];
353
+ }
354
+ if (visibleWidth(title) + 1 + visibleWidth(summary) <= width) {
355
+ return [spreadLine(title, summary, width), "", this.fit(zoomLabel, width)];
356
+ }
357
+ return [
358
+ this.fit(title, width),
359
+ "",
360
+ this.fit(zoomLabel, width),
361
+ "",
362
+ this.fit(summary, width),
363
+ ];
364
+ }
365
+
366
+ if (visibleWidth(title) + 1 + visibleWidth(fullMetadata) <= width) {
367
+ return [spreadLine(title, fullMetadata, width)];
368
+ }
369
+ if (visibleWidth(title) + 1 + visibleWidth(summary) <= width) {
370
+ return [spreadLine(title, summary, width)];
371
+ }
372
+ return [this.fit(title, width), "", this.fit(summary, width)];
373
+ }
374
+
375
+ /** Toggle the view-local map denominator when the binding is currently visible. */
376
+ private toggleMapScale(): void {
377
+ if (!this.canToggleMapScale(this.currentWidth)) return;
378
+ this.mapScale = this.mapScale === "window" ? "fit" : "window";
379
+ this.clearCache();
380
+ }
381
+
382
+ /** Whether zoom can help and its binding is visible at this width. */
383
+ private canToggleMapScale(width: number | undefined): boolean {
384
+ const contextWindow = this.usage.reported?.contextWindow;
385
+ return width !== undefined &&
386
+ width >= MAP_SIDE_BY_SIDE_MIN_WIDTH &&
387
+ contextWindow !== undefined &&
388
+ this.fitMapScale !== undefined &&
389
+ this.fitMapScale < contextWindow;
390
+ }
391
+
392
+ /** The active Fit label, omitted together with its map and binding. */
393
+ private zoomLabel(width: number): string | undefined {
394
+ const contextWindow = this.usage.reported?.contextWindow;
395
+ if (
396
+ this.mapScale !== "fit" ||
397
+ !this.canToggleMapScale(width) ||
398
+ contextWindow === undefined ||
399
+ this.fitMapScale === undefined
400
+ ) return undefined;
401
+ return this.theme.fg(
402
+ "mdHeading",
403
+ `Zoom ${formatTokens(contextWindow)} → ${formatTokens(this.fitMapScale)}`,
404
+ );
405
+ }
406
+
407
+ /** Dashboard hints with Zoom immediately before Close when the binding is active. */
408
+ private dashboardHints(width: number): Array<readonly [string, string]> {
409
+ const hints: Array<readonly [string, string]> = [
410
+ [STEP_KEY_HINT, "Navigate"],
411
+ ["Enter", "Preview"],
412
+ ];
413
+ if (this.canToggleMapScale(width)) hints.push(["Z", "Zoom"]);
414
+ hints.push(["Esc", "Close"]);
415
+ return hints;
416
+ }
417
+
418
+ /**
419
+ * Dashboard description, collapsed whole as soon as the map, the complete
420
+ * legend, or the full map key would lose a row to it. It is the least
421
+ * important block on the frame, so it goes before any of them degrades.
422
+ */
423
+ private dashboardDescriptionLines(width: number, availableRows: number, map: UsageMap | undefined): string[] {
424
+ const notes = [usageNote(), ...(this.input.probingDisabled === true ? [probingDisabledNote(this.theme)] : [])];
425
+ const lines = notes.flatMap((note) => descriptionNoteLines(this.theme, note, width));
426
+ const required = this.fullDashboardRows(width, map) + descriptionBlockRows(lines);
427
+ return required <= availableRows ? lines : [];
428
+ }
429
+
430
+ /**
431
+ * Rows the dashboard needs at full detail: the whole map beside the complete
432
+ * legend and its full map key. The description yields to this height, so a
433
+ * shrinking terminal drops it before the key degrades or the legend scrolls.
434
+ */
435
+ private fullDashboardRows(width: number, map: UsageMap | undefined): number {
436
+ const detailRows = DETAIL_CATEGORY_HEADER_LINE_COUNT + this.legendRows.length;
437
+ if (map === undefined || width < MAP_SIDE_BY_SIDE_MIN_WIDTH) return detailRows;
438
+ return Math.max(map.rows, detailRows + MAP_KEY_DETAILED_SPARE_ROWS);
439
+ }
440
+
441
+ /**
442
+ * Map for the active scale at the largest configured geometry this frame can
443
+ * render, or undefined without a usable context window or map width. Clamping
444
+ * rebuilds the proportions instead of cropping cells, so a map too large for
445
+ * the viewport still maps the whole scale.
446
+ */
447
+ private dashboardMap(width: number, availableRows: number): UsageMap | undefined {
448
+ const requested = this.input.mapSize;
449
+ const columns = Math.min(requested.columns, maxMapColumns(width));
450
+ const rows = Math.min(requested.rows, availableRows);
451
+ if (columns < 1 || rows < 1) return undefined;
452
+ const scaleTokens = this.mapScale === "fit" ? this.fitMapScale : undefined;
453
+ return buildUsageMap(this.usage, columns, rows, scaleTokens);
454
+ }
455
+
456
+ /** Render the map and legend side by side, or only details when width/window data is insufficient. */
457
+ private dashboardLines(width: number, rows: number, map: UsageMap | undefined): string[] {
458
+ if (map === undefined || width < MAP_SIDE_BY_SIDE_MIN_WIDTH) {
459
+ return this.detailLines(width, rows, undefined).map((line) => this.fit(line, width));
460
+ }
461
+
462
+ const spaced = width >= SPACED_MAP_MIN_WIDTH;
463
+ const separator = spaced ? " " : "";
464
+ const mapLines = Array.from({ length: map.rows }, (_, row) => {
465
+ const start = row * map.columns;
466
+ const cells = map.cells.slice(start, start + map.columns);
467
+ return `${BODY_INDENT}${cells.map((cell) => this.mapCell(cell)).join(separator)}`;
468
+ });
469
+ const mapWidth = BODY_INDENT.length + map.columns + (spaced ? map.columns - 1 : 0);
470
+ const gap = spaced ? SPACED_MAP_COLUMN_GAP : MAP_COLUMN_GAP;
471
+ const detailWidth = Math.max(1, width - mapWidth - gap);
472
+ const details = this.detailLines(detailWidth, rows, map);
473
+ const lineCount = Math.max(mapLines.length, details.length);
474
+ return Array.from({ length: lineCount }, (_, index) => {
475
+ const mapLine = mapLines[index] ?? " ".repeat(mapWidth);
476
+ const detail = this.fit(details[index] ?? "", detailWidth);
477
+ return this.fit(`${mapLine}${" ".repeat(gap)}${detail}`, width);
478
+ });
479
+ }
480
+
481
+ /** Category heading, selectable category legend viewport, scroll counter, and map-fill key. */
482
+ private detailLines(width: number, rows: number, map: UsageMap | undefined): string[] {
483
+ const theme = this.theme;
484
+ const keyLines = map === undefined ? [] : this.mapKeyLines(map, width, rows);
485
+ const reservedLineCount = DETAIL_CATEGORY_HEADER_LINE_COUNT +
486
+ (keyLines.length === 0 ? 0 : keyLines.length + 1);
487
+ // The counter sits below the last legend row, so it consumes one of the available rows.
488
+ const viewport = calculateViewport(this.legendRows.length, rows, reservedLineCount);
489
+ this.navigator.setVisibleCount(viewport.visibleCount);
490
+
491
+ const heading = theme.fg("mdHeading", theme.bold("Category:"));
492
+ const rowWidth = Math.max(1, width - CURSOR_COLUMN_WIDTH);
493
+ const columns = this.legendColumns(rowWidth);
494
+ const visibleRows: string[] = [];
495
+ const start = this.navigator.offset;
496
+ for (let index = start; index < start + this.navigator.windowSize; index++) {
497
+ const row = this.legendRows[index];
498
+ if (row === undefined) break;
499
+ const selected = index === this.navigator.selected;
500
+ // The cursor stays in one fixed column at the start of the legend.
501
+ const cursor = selected ? theme.fg("accent", "→ ") : " ";
502
+ visibleRows.push(this.fit(`${cursor}${this.legendLine(row, columns, rowWidth, selected)}`, width));
503
+ }
504
+ const counterLines = viewport.showScroll
505
+ ? [this.fit(
506
+ theme.fg("dim", `${BODY_INDENT}(${this.navigator.visibleEnd}/${this.legendRows.length})`),
507
+ width,
508
+ )]
509
+ : [];
510
+ return [
511
+ heading,
512
+ ...visibleRows,
513
+ ...counterLines,
514
+ ...(keyLines.length === 0 ? [] : ["", ...keyLines]),
515
+ ].slice(0, rows);
516
+ }
517
+
518
+ /**
519
+ * Map key: one heading plus a row per occupancy glyph and the scale-dependent
520
+ * block size, rendered below the more important category legend. It claims
521
+ * only rows the complete legend leaves over, so a shrinking terminal degrades
522
+ * it to the single-line key and then drops it before any category row goes.
523
+ */
524
+ private mapKeyLines(map: UsageMap, width: number, rows: number): string[] {
525
+ const spare = rows - DETAIL_CATEGORY_HEADER_LINE_COUNT - this.legendRows.length;
526
+ if (spare < MAP_KEY_COMPACT_SPARE_ROWS) return [];
527
+ if (spare < MAP_KEY_DETAILED_SPARE_ROWS) return [this.compactMapKeyLine(map, width)];
528
+ const theme = this.theme;
529
+ const sizeLabel = theme.fg("muted", `${MAP_KEY_SIZE_LABEL}: `);
530
+ return [
531
+ this.fit(theme.fg("mdHeading", theme.bold("Map:")), width),
532
+ this.fit(this.mapKeyEntry("text", FULL_CELL, theme.fg("muted", MAP_KEY_FULL_DESCRIPTION)), width),
533
+ this.fit(this.mapKeyEntry("text", PARTIAL_CELL, theme.fg("muted", MAP_KEY_PART_DESCRIPTION)), width),
534
+ this.fit(
535
+ this.mapKeyEntry(
536
+ this.categoryColor(FREE_SPACE_CATEGORY_ID),
537
+ FREE_CELL,
538
+ `${sizeLabel}${this.blockSizeText(map, true)}`,
539
+ ),
540
+ width,
541
+ ),
542
+ ];
543
+ }
544
+
545
+ /** One indented `glyph - text` key row. */
546
+ private mapKeyEntry(glyphColor: CategoryColor, glyph: string, text: string): string {
547
+ return `${BODY_INDENT}${this.paint(glyphColor, glyph)}${this.theme.fg("dim", " - ")}${text}`;
548
+ }
549
+
550
+ /** Single-line key that drops detail in stages before it would truncate. */
551
+ private compactMapKeyLine(map: UsageMap, width: number): string {
552
+ const theme = this.theme;
553
+ const heading = theme.fg("mdHeading", theme.bold("Map:"));
554
+ const separator = theme.fg("dim", " · ");
555
+ const full = `${theme.fg("text", FULL_CELL)}${theme.fg("muted", " One category")}`;
556
+ const partial = `${theme.fg("text", PARTIAL_CELL)}${theme.fg("muted", " Mixed")}`;
557
+ const size = (withPercent: boolean) =>
558
+ `${this.paint(this.categoryColor(FREE_SPACE_CATEGORY_ID), FREE_CELL)} ${this.blockSizeText(map, withPercent)}`;
559
+ const prefix = `${heading} ${full}${separator}${partial}${separator}`;
560
+ const detailed = `${prefix}${size(true)}`;
561
+ if (visibleWidth(detailed) <= width) return detailed;
562
+ const withoutPercent = `${prefix}${size(false)}`;
563
+ if (visibleWidth(withoutPercent) <= width) return withoutPercent;
564
+ const shortenedFull = `${theme.fg("text", FULL_CELL)}${theme.fg("muted", " One")}`;
565
+ return this.fit(`${heading} ${shortenedFull}${separator}${partial}${separator}${size(false)}`, width);
566
+ }
567
+
568
+ /**
569
+ * Tokens one map cell covers, with its share of the mapped range. While Fit
570
+ * zoom shrinks the value, it shares the header zoom label's color. The share
571
+ * is derived from the current map geometry rather than assumed, so it follows
572
+ * any future cell count.
573
+ */
574
+ private blockSizeText(map: UsageMap, withPercent: boolean): string {
575
+ const percent = withPercent ? formatPercent(1 / (map.columns * map.rows)) : "";
576
+ const tokens = formatTokens(Math.round(map.blockTokens));
577
+ return this.theme.fg(
578
+ this.mapScale === "fit" ? "mdHeading" : "muted",
579
+ percent === "" ? tokens : `${tokens} (${percent})`,
580
+ );
581
+ }
582
+
583
+ /** Pi-reported usage/window metadata, with a marked estimate when current usage is unknown. */
584
+ private reportedSummary(): string {
585
+ const reported = this.usage.reported;
586
+ if (reported === undefined) return this.theme.fg("muted", "Context usage unavailable.");
587
+ const contextWindow = formatTokens(reported.contextWindow);
588
+ if (reported.tokens === undefined) {
589
+ const percent = formatPercent(this.usage.estimatedTokens / reported.contextWindow);
590
+ return this.theme.fg(
591
+ "text",
592
+ `≈${formatTokens(this.usage.estimatedTokens)}/${contextWindow} (${percent})`,
593
+ );
594
+ }
595
+ const percent = reported.percent === undefined ? "" : ` (${formatPercent(reported.percent / 100)})`;
596
+ return this.theme.fg("text", `${formatTokens(reported.tokens)}/${contextWindow}${percent}`);
597
+ }
598
+
599
+ /**
600
+ * All legend rows: top-level categories, Tool Output children, then the
601
+ * non-selectable auto-compact buffer and free space.
602
+ */
603
+ private buildLegendRows(): LegendRow[] {
604
+ const rows: LegendRow[] = buildCategoryLegendRows(this.usage.categories);
605
+ const bufferTokens = this.bufferTokens();
606
+ if (bufferTokens > 0) rows.push({ type: "buffer", tokens: bufferTokens });
607
+ const freeTokens = this.freeSpaceTokens();
608
+ if (freeTokens !== undefined) rows.push({ type: "free", tokens: freeTokens });
609
+ return rows;
610
+ }
611
+
612
+ /** Tokens auto-compaction keeps unoccupied; zero when disabled or without a context window. */
613
+ private bufferTokens(): number {
614
+ const contextWindow = this.usage.reported?.contextWindow;
615
+ const reserve = this.usage.autoCompactReserveTokens;
616
+ if (contextWindow === undefined || contextWindow <= 0 || reserve === undefined) return 0;
617
+ return Math.min(reserve, Math.max(0, contextWindow - this.usage.estimatedTokens));
618
+ }
619
+
620
+ /** Estimated remaining space before the buffer, or undefined without a usable context window. */
621
+ private freeSpaceTokens(): number | undefined {
622
+ const contextWindow = this.usage.reported?.contextWindow;
623
+ if (contextWindow === undefined || contextWindow <= 0) return undefined;
624
+ return Math.max(0, contextWindow - this.usage.estimatedTokens - this.bufferTokens());
625
+ }
626
+
627
+ /** Earliest shared token column plus the width needed to align percentages. */
628
+ private legendColumns(width: number): LegendColumns {
629
+ const rows = this.legendRows;
630
+ const labelWidth = Math.max(1, ...rows.map((row) => this.plainLegendLabel(row).length));
631
+ const tokenWidth = Math.max(1, ...rows.map((row) => formatTokens(legendTokens(row)).length));
632
+ const percentWidth = Math.max(0, ...rows.map((row) => this.plainLegendPercent(legendTokens(row)).length));
633
+ const rightWidth = tokenWidth + (percentWidth > 0 ? LEGEND_VALUE_GAP + percentWidth : 0);
634
+ const idealValue = Math.min(MAX_LEGEND_VALUE_COLUMN, labelWidth + LEGEND_LEADER_GAP);
635
+ return {
636
+ value: Math.max(1, Math.min(idealValue, width - rightWidth)),
637
+ tokenWidth,
638
+ };
639
+ }
640
+
641
+ /** One aligned hierarchy row with dim leaders and independent token/percentage columns. */
642
+ private legendLine(row: LegendRow, columns: LegendColumns, width: number, selected: boolean): string {
643
+ const labelWidth = Math.max(1, columns.value - 1);
644
+ const left = fitLine(this.styledLegendLabel(row, selected), labelWidth);
645
+ const leader = this.legendLeader(columns.value - visibleWidth(left));
646
+ const tokens = formatTokens(legendTokens(row));
647
+ const valueColor = selected ? "accent" : row.type === "category" && row.depth > 1 ? "dim" : "muted";
648
+ const tokenPadding = " ".repeat(Math.max(0, columns.tokenWidth - tokens.length));
649
+ const percent = this.plainLegendPercent(legendTokens(row));
650
+ const percentPart = percent === ""
651
+ ? ""
652
+ : `${" ".repeat(LEGEND_VALUE_GAP)}${this.theme.fg(selected ? "accent" : "dim", percent)}`;
653
+ return fitLine(
654
+ `${left}${leader}${this.theme.fg(valueColor, tokens)}${tokenPadding}${percentPart}`,
655
+ width,
656
+ );
657
+ }
658
+
659
+ /** Fill a label/value gap with dim dots, retaining spaces at both ends. */
660
+ private legendLeader(width: number): string {
661
+ if (width < 3) return " ".repeat(Math.max(0, width));
662
+ return ` ${this.theme.fg("dim", ".".repeat(width - 2))} `;
663
+ }
664
+
665
+ /** Unstyled hierarchy label used to choose the shared value column. */
666
+ private plainLegendLabel(row: LegendRow): string {
667
+ if (row.type === "buffer") return `${BUFFER_CELL} Auto-Compact Buffer`;
668
+ if (row.type === "free") return `${FREE_CELL} Free Space`;
669
+ const indent = " ".repeat(row.depth);
670
+ return `${indent}${categoryMarker(row.category.id, row.depth)} ${normalizeInlineText(row.category.label)}`;
671
+ }
672
+
673
+ /** Themed hierarchy label; the marker keeps its map color even when selected. */
674
+ private styledLegendLabel(row: LegendRow, selected: boolean): string {
675
+ if (row.type === "buffer") {
676
+ const color = this.categoryColor(AUTO_COMPACT_BUFFER_CATEGORY_ID);
677
+ return `${this.paint(color, BUFFER_CELL)} ${this.theme.fg("text", "Auto-Compact Buffer")}`;
678
+ }
679
+ if (row.type === "free") {
680
+ const color = this.categoryColor(FREE_SPACE_CATEGORY_ID);
681
+ return `${this.paint(color, FREE_CELL)} ${this.theme.fg(selected ? "accent" : "text", "Free Space")}`;
682
+ }
683
+ const indent = " ".repeat(row.depth);
684
+ const color = this.categoryColor(row.rootId);
685
+ const marker = this.paint(color, categoryMarker(row.category.id, row.depth));
686
+ const labelColor = selected ? "accent" : row.depth === 0 ? "text" : row.depth === 1 ? "muted" : "dim";
687
+ return `${indent}${marker} ${this.theme.fg(labelColor, normalizeInlineText(row.category.label))}`;
688
+ }
689
+
690
+ /** Percentage text used by the independently aligned rightmost column. */
691
+ private plainLegendPercent(tokens: number): string {
692
+ const contextWindow = this.usage.reported?.contextWindow;
693
+ if (contextWindow === undefined || contextWindow <= 0) return "";
694
+ return formatPercent(tokens / contextWindow);
695
+ }
696
+
697
+ /** Colored occupied/partial/buffer/free glyph for one map cell. */
698
+ private mapCell(cell: UsageMapCell): string {
699
+ if (cell.fill === "buffer") {
700
+ return this.paint(this.categoryColor(AUTO_COMPACT_BUFFER_CATEGORY_ID), BUFFER_CELL);
701
+ }
702
+ if (cell.fill === "free") return this.paint(this.categoryColor(FREE_SPACE_CATEGORY_ID), FREE_CELL);
703
+ const glyph = cell.categoryId === "compacted-data"
704
+ ? COMPACTED_CELL
705
+ : cell.fill === "full" ? FULL_CELL : PARTIAL_CELL;
706
+ return this.paint(this.categoryColor(cell.categoryId), glyph);
707
+ }
708
+
709
+ /** Resolve one category through user overrides, with a safe fallback for unknown ids. */
710
+ private categoryColor(categoryId: string | undefined): CategoryColor {
711
+ return resolveCategoryColor(this.categoryColors, categoryId);
712
+ }
713
+
714
+ /** Paint text with a configured color, which may name a theme color or a literal value. */
715
+ private paint(color: CategoryColor, text: string): string {
716
+ return colorize(this.theme, color, text);
717
+ }
718
+
719
+ /**
720
+ * Wrapped notices placed above the dashboard: the degraded-capture reason
721
+ * first, then configuration problems. Capped so a broken configuration file
722
+ * cannot push the map and legend off the frame.
723
+ */
724
+ private noticeLines(width: number): string[] {
725
+ const degraded = this.input.degradedReason === undefined ? [] : [this.input.degradedReason];
726
+ const blocks = [...degraded, ...this.input.notices ?? []]
727
+ .map((notice) => this.wrapNotice(notice, width));
728
+ return capNoticeLines(blocks, MAX_NOTICE_LINES, (notice) => this.wrapNotice(notice, width));
729
+ }
730
+
731
+ /** One sanitized notice wrapped to the available width, indented on every line. */
732
+ private wrapNotice(notice: string, width: number): string[] {
733
+ return wrapDescriptionLines(this.theme, normalizeInlineText(notice), "warning", width);
734
+ }
735
+
736
+ // === Preview mode ===
737
+
738
+ /** Block navigation, block opening, and return-to-list keys. */
739
+ private handlePreviewInput(data: string): void {
740
+ if (matchesKey(data, Key.escape) || data === "q") {
741
+ this.closePreview();
742
+ return;
743
+ }
744
+ // Blocks are selected rather than scrolled, so one notch steps one block.
745
+ const wheel = parseWheelDirection(data);
746
+ if (wheel !== undefined) {
747
+ const moved = wheel < 0 ? this.blockNavigator.stepBack() : this.blockNavigator.stepForward();
748
+ if (moved) this.clearCache();
749
+ return;
750
+ }
751
+ if (matchesKey(data, Key.enter)) {
752
+ this.openBlock();
753
+ } else if (isStepBackKey(data)) {
754
+ if (this.blockNavigator.stepBack()) this.clearCache();
755
+ } else if (isStepForwardKey(data)) {
756
+ if (this.blockNavigator.stepForward()) this.clearCache();
757
+ } else if (isPageBackKey(data)) {
758
+ if (this.blockNavigator.page(-1)) this.clearCache();
759
+ } else if (isPageForwardKey(data)) {
760
+ if (this.blockNavigator.page(1)) this.clearCache();
761
+ } else if (matchesKey(data, Key.home)) {
762
+ if (this.blockNavigator.moveToFirst()) this.clearCache();
763
+ } else if (matchesKey(data, Key.end)) {
764
+ if (this.blockNavigator.moveToLast()) this.clearCache();
765
+ }
766
+ }
767
+
768
+ /** Scroll full content and return to its category or originating block stream. */
769
+ private handleContentInput(data: string): void {
770
+ if (matchesKey(data, Key.escape) || data === "q") {
771
+ if (this.openBlockIndex === undefined) this.closePreview();
772
+ else this.closeBlock();
773
+ return;
774
+ }
775
+ const wheel = parseWheelDirection(data);
776
+ if (wheel !== undefined) {
777
+ if (this.previewScroller.scrollBy(wheel * this.wheelScrollLines)) this.clearCache();
778
+ return;
779
+ }
780
+ if (isStepBackKey(data)) {
781
+ if (this.previewScroller.scrollBy(-1)) this.clearCache();
782
+ } else if (isStepForwardKey(data)) {
783
+ if (this.previewScroller.scrollBy(1)) this.clearCache();
784
+ } else if (isPageBackKey(data)) {
785
+ if (this.previewScroller.page(-1)) this.clearCache();
786
+ } else if (isPageForwardKey(data)) {
787
+ if (this.previewScroller.page(1)) this.clearCache();
788
+ } else if (matchesKey(data, Key.home)) {
789
+ if (this.previewScroller.scrollTo(0)) this.clearCache();
790
+ } else if (matchesKey(data, Key.end)) {
791
+ if (this.previewScroller.scrollTo(this.previewScroller.maxOffset)) this.clearCache();
792
+ }
793
+ }
794
+
795
+ /** Open the selected category, bypassing blocks when it has exactly one entry. */
796
+ private openPreview(): void {
797
+ const row = this.legendRows[this.navigator.selected];
798
+ if (row === undefined || row.type !== "category") return;
799
+ this.previewRow = row;
800
+ this.cachedPreviewEntries = undefined;
801
+ this.clearPreviewContent();
802
+ this.blockNavigator.reset();
803
+ this.previewScroller.reset();
804
+ this.clearCache();
805
+ }
806
+
807
+ /** Return to the list with the same selected row. */
808
+ private closePreview(): void {
809
+ this.previewRow = undefined;
810
+ this.openBlockIndex = undefined;
811
+ this.cachedPreviewEntries = undefined;
812
+ this.clearPreviewContent();
813
+ this.clearCache();
814
+ }
815
+
816
+ /** Open hidden content from the selected capped block; complete and empty blocks have nothing to open. */
817
+ private openBlock(): void {
818
+ // The rendered stream carries the height-dependent cap the user sees, so it decides what Enter opens.
819
+ const index = this.blockNavigator.selected;
820
+ const block = this.cachedStream?.blocks[index];
821
+ if (block === undefined || block.hiddenLineCount === 0) return;
822
+ this.openBlockIndex = index;
823
+ this.cachedBlockBody = undefined;
824
+ this.previewScroller.reset();
825
+ this.clearCache();
826
+ }
827
+
828
+ /** Return to the block stream with the same selected block. */
829
+ private closeBlock(): void {
830
+ this.openBlockIndex = undefined;
831
+ this.cachedBlockBody = undefined;
832
+ this.clearCache();
833
+ }
834
+
835
+ /** Drop width- and theme-dependent preview rendering. */
836
+ private clearPreviewContent(): void {
837
+ this.cachedContent = undefined;
838
+ this.cachedStream = undefined;
839
+ this.cachedBlockBody = undefined;
840
+ }
841
+
842
+ /** Scrollable chronological block stream for one category. */
843
+ private renderPreview(width: number, terminalRows: number, row: CategoryLegendRow): string[] {
844
+ const theme = this.theme;
845
+ const border = theme.fg("border", "─".repeat(Math.max(1, width)));
846
+ const descriptionLines = this.previewDescriptionLines(width, terminalRows, row);
847
+ const descriptionLineCount = descriptionBlockRows(descriptionLines);
848
+ const stream = this.previewStream(width, previewBlockMaxLines(terminalRows, descriptionLineCount), row);
849
+ const viewport = calculateViewport(
850
+ Math.max(1, stream.layout.lines.length),
851
+ terminalRows,
852
+ PREVIEW_FIXED_LINE_COUNT,
853
+ descriptionLineCount,
854
+ );
855
+ this.blockNavigator.setExtent(stream.layout, viewport.visibleCount);
856
+
857
+ const lines: string[] = [border, "", this.categoryHeaderLine(row, width), ""];
858
+ lines.push(...this.previewStreamLines(stream, viewport.visibleCount, width));
859
+ if (viewport.showScroll) {
860
+ lines.push(
861
+ this.fit(
862
+ theme.fg("dim", `${BODY_INDENT}(${this.blockNavigator.selected + 1}/${stream.blocks.length})`),
863
+ width,
864
+ ),
865
+ );
866
+ }
867
+ if (descriptionLines.length > 0) lines.push("", ...descriptionLines);
868
+ lines.push("");
869
+ lines.push(this.fit(hintRow(theme, previewHints(stream.blocks.length)), width));
870
+ lines.push("", border);
871
+ return fitToTerminalHeight(lines, terminalRows, border);
872
+ }
873
+
874
+ /** Full content with an identity header unless the System Prompt category already identifies it. */
875
+ private renderContentView(
876
+ width: number,
877
+ terminalRows: number,
878
+ row: CategoryLegendRow,
879
+ entry: UsagePreviewEntry,
880
+ ): string[] {
881
+ const theme = this.theme;
882
+ const border = theme.fg("border", "─".repeat(Math.max(1, width)));
883
+ const body = this.blockBodyLines(width, row, entry);
884
+ const showEntryHeader = row.rootId !== "system-prompt" || this.openBlockIndex !== undefined;
885
+ const fixedLineCount = showEntryHeader ? BLOCK_FIXED_LINE_COUNT : PREVIEW_FIXED_LINE_COUNT;
886
+ const descriptionLines = row.rootId === "assistant-thinking" && this.openBlockIndex === undefined
887
+ ? this.thinkingDescriptionLines(width, row)
888
+ : previewLegendLines(theme, [entry], {
889
+ width,
890
+ availableRows: terminalRows - fixedLineCount,
891
+ contentLineCount: body.length,
892
+ });
893
+ const viewport = calculateViewport(
894
+ body.length, terminalRows, fixedLineCount, descriptionBlockRows(descriptionLines),
895
+ );
896
+ this.previewScroller.setExtent(body.length, viewport.visibleCount);
897
+
898
+ const lines: string[] = [
899
+ border,
900
+ "",
901
+ this.categoryHeaderLine(row, width),
902
+ "",
903
+ ...(showEntryHeader ? [this.fit(`${BODY_INDENT}${this.entryHeader(entry)}`, width), ""] : []),
904
+ ];
905
+ const start = this.previewScroller.offset;
906
+ for (let index = start; index < start + viewport.visibleCount; index++) {
907
+ lines.push(body[index] ?? "");
908
+ }
909
+ if (viewport.showScroll) {
910
+ lines.push(
911
+ this.fit(theme.fg("dim", `${BODY_INDENT}(${this.previewScroller.visibleEnd}/${body.length})`), width),
912
+ );
913
+ }
914
+ if (descriptionLines.length > 0) lines.push("", ...descriptionLines);
915
+ lines.push("");
916
+ lines.push(
917
+ this.fit(
918
+ hintRow(theme, [
919
+ [STEP_KEY_HINT, "Scroll"],
920
+ ["PgUp/PgDn", "Page"],
921
+ ["Esc", "Back"],
922
+ ]),
923
+ width,
924
+ ),
925
+ );
926
+ lines.push("", border);
927
+ return fitToTerminalHeight(lines, terminalRows, border);
928
+ }
929
+
930
+ /**
931
+ * Accent category title with its token and percentage metadata, shared by
932
+ * both preview levels. A forced prompt adds its `Forced` marker, dropped whole
933
+ * when it does not fit, as in the Injections preview header.
934
+ */
935
+ private categoryHeaderLine(row: CategoryLegendRow, width: number): string {
936
+ const theme = this.theme;
937
+ const title = theme.fg("accent", theme.bold(normalizeInlineText(row.category.label)));
938
+ const percent = this.plainLegendPercent(row.category.tokens);
939
+ const meta = theme.fg("muted", `${formatTokens(row.category.tokens)}${percent === "" ? "" : ` · ${percent}`}`);
940
+ const forced = this.previewEntries(row).some((entry) => entry.change === "forced");
941
+ const marker = forced ? forcedMarker(theme) : "";
942
+ const fitsMarker = visibleWidth(title) + visibleWidth(meta) + visibleWidth(marker) + 2 <= width;
943
+ return spreadLine(title, `${meta}${fitsMarker ? marker : ""} `, width);
944
+ }
945
+
946
+ /** Visible window of the block stream, or the message an empty category shows instead. */
947
+ private previewStreamLines(stream: PreviewStream, visibleCount: number, width: number): string[] {
948
+ if (stream.blocks.length === 0) {
949
+ const message = this.theme.fg("muted", `${BODY_INDENT}${emptyPreviewNote().text}`);
950
+ return Array.from({ length: visibleCount }, (_, index) => index === 0 ? this.fit(message, width) : "");
951
+ }
952
+ const start = this.blockNavigator.offset;
953
+ return Array.from(
954
+ { length: visibleCount },
955
+ (_, index) => this.previewStreamLine(stream, start + index, width),
956
+ );
957
+ }
958
+
959
+ /** One stream line: blank separator, block content, or the block's truncation marker. */
960
+ private previewStreamLine(stream: PreviewStream, index: number, width: number): string {
961
+ const ref = stream.layout.lines[index];
962
+ if (ref === undefined) return "";
963
+ const block = stream.blocks[ref.blockIndex];
964
+ if (block === undefined) return "";
965
+ const selected = ref.blockIndex === this.blockNavigator.selected;
966
+ const line = ref.lineIndex < block.lines.length
967
+ ? block.lines[ref.lineIndex] ?? ""
968
+ : this.truncationMarker(block.hiddenLineCount, selected);
969
+ const gutter = this.blockGutter(selected, line === "");
970
+ return this.fit(`${gutter}${line}`, width);
971
+ }
972
+
973
+ /** Accent bar marking the selected block, or the plain two-column indent. */
974
+ private blockGutter(selected: boolean, blankLine: boolean): string {
975
+ if (!selected) return blankLine ? "" : BODY_INDENT;
976
+ return this.theme.fg("accent", blankLine ? BLOCK_GUTTER : `${BLOCK_GUTTER} `);
977
+ }
978
+
979
+ /** Left-aligned truncation marker with a brighter action label on the selected block only. */
980
+ private truncationMarker(hiddenLineCount: number, selected: boolean): string {
981
+ const marker = `${BODY_INDENT}${this.theme.fg("dim", `… +${hiddenLineCount} lines`)}`;
982
+ if (!selected) return marker;
983
+ const separator = this.theme.fg("dim", " · ");
984
+ const action = this.theme.fg("accent", "Enter - View Content");
985
+ return `${marker}${separator}${action}`;
986
+ }
987
+
988
+ /** Cached blocks and their flattened line layout: bracket headers plus capped sanitized content. */
989
+ private previewStream(width: number, maxLines: number, row: CategoryLegendRow): PreviewStream {
990
+ const wrapWidth = previewWrapWidth(width);
991
+ const cached = this.cachedStream;
992
+ if (cached !== undefined && cached.wrapWidth === wrapWidth && cached.maxLines === maxLines) return cached;
993
+ const content = this.previewContent(width, row);
994
+ const blocks = this.previewEntries(row).map((entry, index) => {
995
+ const lines = content[index] ?? [];
996
+ return {
997
+ lines: [this.entryHeader(entry), ...lines.slice(0, maxLines)],
998
+ hiddenLineCount: Math.max(0, lines.length - maxLines),
999
+ };
1000
+ });
1001
+ this.cachedStream = {
1002
+ wrapWidth,
1003
+ maxLines,
1004
+ blocks,
1005
+ layout: layoutPreviewBlocks(blocks.map(blockHeight)),
1006
+ };
1007
+ return this.cachedStream;
1008
+ }
1009
+
1010
+ /**
1011
+ * Cached uncapped content of the open category, one wrapped array per entry.
1012
+ * Wrapping is the expensive step and depends on width alone, so a cap that
1013
+ * shrinks with terminal height re-slices these lines instead of redoing it.
1014
+ */
1015
+ private previewContent(width: number, row: CategoryLegendRow): readonly EntryLines[] {
1016
+ const wrapWidth = previewWrapWidth(width);
1017
+ if (this.cachedContent !== undefined && this.cachedContent.wrapWidth === wrapWidth) {
1018
+ return this.cachedContent.entries;
1019
+ }
1020
+ const compactSkills = row.rootId === "user-messages";
1021
+ const entries = this.previewEntries(row)
1022
+ .map((entry) => this.entryContentLines(entry, wrapWidth, compactSkills));
1023
+ this.cachedContent = { wrapWidth, entries };
1024
+ return entries;
1025
+ }
1026
+
1027
+ /**
1028
+ * Cached uncapped content of the open block, indented in place of the stream gutter. These lines
1029
+ * are already fitted, so the cache key is the terminal width: narrow widths share a clamped wrap
1030
+ * width while still needing their own truncation.
1031
+ */
1032
+ private blockBodyLines(width: number, row: CategoryLegendRow, entry: UsagePreviewEntry): readonly string[] {
1033
+ if (this.cachedBlockBody !== undefined && this.cachedBlockBody.width === width) {
1034
+ return this.cachedBlockBody.lines;
1035
+ }
1036
+ const wrapWidth = previewWrapWidth(width);
1037
+ const content = this.entryContentLines(entry, wrapWidth, row.rootId === "user-messages");
1038
+ const lines = content.map((line) => line === "" ? "" : this.fit(`${BODY_INDENT}${line}`, width));
1039
+ this.cachedBlockBody = { width, lines };
1040
+ return lines;
1041
+ }
1042
+
1043
+ /** The sole category entry or an explicitly opened block; undefined for a block stream. */
1044
+ private getFullContentEntry(row: CategoryLegendRow): UsagePreviewEntry | undefined {
1045
+ const entries = this.previewEntries(row);
1046
+ if (entries.length === 1) return entries[0];
1047
+ return this.openBlockIndex === undefined ? undefined : entries[this.openBlockIndex];
1048
+ }
1049
+
1050
+ /** Collect and cache the immutable entries shared by preview body and description rendering. */
1051
+ private previewEntries(row: CategoryLegendRow): readonly UsagePreviewEntry[] {
1052
+ this.cachedPreviewEntries ??= collectPreviewEntries(row.category);
1053
+ return this.cachedPreviewEntries;
1054
+ }
1055
+
1056
+ /** Bracketed entry header: dim datetime, breadcrumbs, visible tokens, and optional invisible reasoning. */
1057
+ private entryHeader(entry: UsagePreviewEntry): string {
1058
+ const theme = this.theme;
1059
+ const cells: string[] = [];
1060
+ if (entry.timestamp !== undefined) {
1061
+ cells.push(theme.fg("dim", `[${formatEntryTimestamp(entry.timestamp)}]`));
1062
+ }
1063
+ entry.breadcrumb.forEach((cell, index) => {
1064
+ // The leading cell names the producer and heads the whole block, so it is bold.
1065
+ const lead = index === 0;
1066
+ const color: ThemeColor = lead ? "mdHeading" : "muted";
1067
+ const name = normalizeInlineText(cell);
1068
+ cells.push(
1069
+ `${theme.fg("dim", "[")}${theme.fg(color, lead ? theme.bold(name) : name)}${theme.fg("dim", "]")}`,
1070
+ );
1071
+ });
1072
+ cells.push(theme.fg("dim", formatTokens(entry.visibleTokens ?? entry.tokens)));
1073
+ if (entry.invisibleReasoning !== undefined) {
1074
+ const { tokens, basis, encoded } = entry.invisibleReasoning;
1075
+ const marker = basis === "provider-reported" ? "≈" : "~";
1076
+ const label = encoded ? "Encoded" : "Reasoning";
1077
+ const total = (entry.visibleTokens ?? entry.tokens) + tokens;
1078
+ cells.push(
1079
+ theme.fg("dim", `+ ${label} ${marker}${formatTokens(tokens)} (${marker}${formatTokens(total)})`),
1080
+ );
1081
+ }
1082
+ return cells.join(" ");
1083
+ }
1084
+
1085
+ /** The legend collapses around uncapped content geometry; reasoning notation keeps its existing fixed footer. */
1086
+ private previewDescriptionLines(width: number, terminalRows: number, row: CategoryLegendRow): string[] {
1087
+ if (row.rootId === "assistant-thinking") return this.thinkingDescriptionLines(width, row);
1088
+ // Count entry headers and separator rows before applying the legend-dependent cap
1089
+ const contentLineCount = this.previewContent(width, row)
1090
+ .reduce((total, lines) => total + lines.length + 2, -1);
1091
+ return previewLegendLines(this.theme, this.previewEntries(row), {
1092
+ width,
1093
+ availableRows: terminalRows - PREVIEW_FIXED_LINE_COUNT,
1094
+ contentLineCount: Math.max(1, contentLineCount),
1095
+ });
1096
+ }
1097
+
1098
+ /** Keep reasoning notation visible when the category opens as blocks or direct full content. */
1099
+ private thinkingDescriptionLines(width: number, row: CategoryLegendRow): string[] {
1100
+ if (row.rootId !== "assistant-thinking") return [];
1101
+ const hasInvisibleReasoning = this.previewEntries(row)
1102
+ .some((entry) => entry.invisibleReasoning !== undefined);
1103
+ return hasInvisibleReasoning
1104
+ ? descriptionNoteLines(this.theme, thinkingNote(), width)
1105
+ : [];
1106
+ }
1107
+
1108
+ /** Complete content lines under the entry header: labeled tool parts, or the whole raw entry. */
1109
+ private entryContentLines(entry: UsagePreviewEntry, wrapWidth: number, compactSkills: boolean): string[] {
1110
+ return previewBodyLines(
1111
+ this.theme,
1112
+ entry,
1113
+ wrapWidth,
1114
+ (text, jsonSpan) => this.wrappedEntryLines(expandJsonSpan(text, jsonSpan), wrapWidth, compactSkills),
1115
+ entry.breadcrumb.at(-1),
1116
+ );
1117
+ }
1118
+
1119
+ /** Sanitized, wrapped lines of one text run, indented under the entry header. */
1120
+ private wrappedEntryLines(text: string, wrapWidth: number, compactSkills: boolean): string[] {
1121
+ const lines: string[] = [];
1122
+ for (const paragraph of this.entryPreviewText(text, compactSkills).split("\n")) {
1123
+ const wrapped = wrapTextWithAnsi(paragraph, wrapWidth);
1124
+ const paragraphLines = wrapped.length === 0 ? [""] : wrapped;
1125
+ for (const line of paragraphLines) lines.push(line === "" ? "" : `${BODY_INDENT}${line}`);
1126
+ }
1127
+ return lines;
1128
+ }
1129
+
1130
+ /** Sanitize raw entry text and replace complete attached skills with pi-colored badges. */
1131
+ private entryPreviewText(text: string, compactSkills: boolean): string {
1132
+ const sanitized = normalizePreviewText(text);
1133
+ if (!compactSkills) return sanitized;
1134
+ return splitSkillPreview(sanitized)
1135
+ .map((segment) => segment.type === "text" ? segment.text : this.skillBadge(segment.name))
1136
+ .join("");
1137
+ }
1138
+
1139
+ /** Render the same collapsed skill label/name colors used by pi's transcript component. */
1140
+ private skillBadge(name: string): string {
1141
+ const label = this.theme.fg("customMessageLabel", this.theme.bold("[skill]"));
1142
+ const safeName = normalizeInlineText(name);
1143
+ if (safeName === "") return label;
1144
+ return `${label} ${this.theme.fg("customMessageText", safeName)}`;
1145
+ }
1146
+
1147
+ /** Truncate one rendered line to the supplied width. */
1148
+ private fit(line: string, width: number): string {
1149
+ return fitLine(line, width);
1150
+ }
1151
+
1152
+ /** Clear render-cache keys after data, theme, or input changes. */
1153
+ private clearCache(): void {
1154
+ this.cachedWidth = undefined;
1155
+ this.cachedTerminalRows = undefined;
1156
+ this.cachedLines = undefined;
1157
+ }
1158
+ }
1159
+
1160
+ /** Show top-level categories plus Tool Output's direct per-tool breakdown. */
1161
+ function buildCategoryLegendRows(categories: readonly UsageCategory[]): CategoryLegendRow[] {
1162
+ const rows: CategoryLegendRow[] = [];
1163
+ for (const category of categories) {
1164
+ rows.push({ type: "category", category, depth: 0, rootId: category.id });
1165
+ if (category.id !== "tool-output") continue;
1166
+ for (const child of category.children ?? []) {
1167
+ rows.push({ type: "category", category: child, depth: 1, rootId: category.id });
1168
+ }
1169
+ }
1170
+ return rows;
1171
+ }
1172
+
1173
+ /**
1174
+ * Map cells one frame can place beside the legend, using the same spacing tier
1175
+ * the map renders with and reserving the legend's minimum width. Zero below the
1176
+ * side-by-side width, where no map renders at all.
1177
+ */
1178
+ function maxMapColumns(width: number): number {
1179
+ if (width < MAP_SIDE_BY_SIDE_MIN_WIDTH) return 0;
1180
+ const spaced = width >= SPACED_MAP_MIN_WIDTH;
1181
+ const gap = spaced ? SPACED_MAP_COLUMN_GAP : MAP_COLUMN_GAP;
1182
+ // Spaced cells cost two columns each, except the last one, which has no trailing space.
1183
+ const available = width - BODY_INDENT.length - gap - MIN_DETAIL_WIDTH + (spaced ? 1 : 0);
1184
+ return Math.max(0, Math.floor(available / (spaced ? 2 : 1)));
1185
+ }
1186
+
1187
+ /**
1188
+ * Content wrap width shared by both preview levels: gutter, inner indent, and one spare column.
1189
+ * The block view keeps the stream's narrower width, so the `… +N lines` count it opens stays exact.
1190
+ */
1191
+ function previewWrapWidth(width: number): number {
1192
+ return Math.max(10, width - BODY_INDENT.length * 2 - 1);
1193
+ }
1194
+
1195
+ /**
1196
+ * Content lines one block may show at this terminal height: enough for two whole
1197
+ * blocks, clamped between the readable floor and the standard cap. Derived from
1198
+ * the height alone, never from the viewport, whose scroll counter depends on the
1199
+ * stream this cap produces.
1200
+ */
1201
+ function previewBlockMaxLines(terminalRows: number, descriptionLineCount: number): number {
1202
+ // Assume the counter row; a stream short enough to drop it loses at most one line per block.
1203
+ const visibleCount = terminalRows - PREVIEW_FIXED_LINE_COUNT - descriptionLineCount - 1;
1204
+ const fitted = Math.floor((visibleCount - TWO_BLOCK_FRAME_ROWS) / 2);
1205
+ return Math.max(PREVIEW_BLOCK_MIN_LINES, Math.min(PREVIEW_BLOCK_MAX_LINES, fitted));
1206
+ }
1207
+
1208
+ /** Block stream hints; the selected block carries the open affordance, and an empty stream moves nowhere. */
1209
+ function previewHints(blockCount: number): Array<readonly [string, string]> {
1210
+ if (blockCount === 0) return [["Esc", "Back"]];
1211
+ return [[STEP_KEY_HINT, "Navigate"], ["PgUp/PgDn", "Page"], ["Esc", "Back"]];
1212
+ }
1213
+
1214
+ /** Stream lines one block occupies, including its truncation marker row. */
1215
+ function blockHeight(block: PreviewBlock): number {
1216
+ return block.lines.length + (block.hiddenLineCount > 0 ? 1 : 0);
1217
+ }
1218
+
1219
+ /** Entry-header datetime: DD-MM-YYYY HH:MM:SS in local time. */
1220
+ function formatEntryTimestamp(timestamp: number): string {
1221
+ const date = new Date(timestamp);
1222
+ const pad = (value: number) => `${value}`.padStart(2, "0");
1223
+ return `${pad(date.getDate())}-${pad(date.getMonth() + 1)}-${date.getFullYear()}` +
1224
+ ` ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
1225
+ }
1226
+
1227
+ /** Marker distinguishing top-level occupancy, compacted data, and nested breakdowns. */
1228
+ function categoryMarker(categoryId: string, depth: number): string {
1229
+ if (depth > 0) return BREAKDOWN_MARKER;
1230
+ return categoryId === "compacted-data" ? COMPACTED_CELL : FULL_CELL;
1231
+ }
1232
+
1233
+ /** Token estimate carried by category, buffer, or free-space legend rows. */
1234
+ function legendTokens(row: LegendRow): number {
1235
+ return row.type === "category" ? row.category.tokens : row.tokens;
1236
+ }
1237
+
1238
+ /** Compact token count: 951, 3.7k, 43.8k, 1M. */
1239
+ export function formatTokens(tokens: number): string {
1240
+ if (tokens < 1_000) return `${tokens}`;
1241
+ if (tokens < 1_000_000) return `${trimTrailingZero((tokens / 1_000).toFixed(1))}k`;
1242
+ return `${trimTrailingZero((tokens / 1_000_000).toFixed(1))}M`;
1243
+ }
1244
+
1245
+ /** Percentage with one decimal below 10%: 0.4%, 4.2%, 96%. */
1246
+ export function formatPercent(ratio: number): string {
1247
+ const percent = ratio * 100;
1248
+ if (percent >= 10) return `${Math.round(percent)}%`;
1249
+ return `${trimTrailingZero(percent.toFixed(1))}%`;
1250
+ }
1251
+
1252
+ /** Drop a redundant ".0" fraction from a fixed-point rendering. */
1253
+ function trimTrailingZero(value: string): string {
1254
+ return value.endsWith(".0") ? value.slice(0, -2) : value;
1255
+ }