pi-context-view 0.2.0

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.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Pure proportional-cell model for the Usage view's 14×14 context map. The
3
+ * map uses estimated category totals against pi's context-window size. Pi's
4
+ * separately reported occupied tokens may differ because of tokenizer,
5
+ * serialization, caching, and last-response timing.
6
+ */
7
+ import type { ContextUsageSnapshot } from "../model.ts";
8
+
9
+ export const DEFAULT_MAP_COLUMNS = 14;
10
+ export const DEFAULT_MAP_ROWS = 14;
11
+
12
+ /** One visual map cell assigned to a category or remaining free space. */
13
+ export interface UsageMapCell {
14
+ readonly categoryId?: string;
15
+ readonly fill: "full" | "partial" | "free";
16
+ }
17
+
18
+ /** Rectangular context-usage map in row-major order. */
19
+ export interface UsageMap {
20
+ readonly columns: number;
21
+ readonly rows: number;
22
+ readonly cells: readonly UsageMapCell[];
23
+ }
24
+
25
+ interface MapSegment {
26
+ readonly categoryId: string;
27
+ readonly start: number;
28
+ readonly end: number;
29
+ }
30
+
31
+ /**
32
+ * Build a proportional map from estimated categories. Returns undefined
33
+ * without a usable context-window denominator.
34
+ */
35
+ export function buildUsageMap(
36
+ usage: ContextUsageSnapshot,
37
+ columns = DEFAULT_MAP_COLUMNS,
38
+ rows = DEFAULT_MAP_ROWS,
39
+ ): UsageMap | undefined {
40
+ const contextWindow = usage.reported?.contextWindow;
41
+ if (contextWindow === undefined || contextWindow <= 0 || columns <= 0 || rows <= 0) return undefined;
42
+
43
+ const cellCount = Math.floor(columns) * Math.floor(rows);
44
+ const estimatedTotal = usage.categories.reduce((sum, category) => sum + category.tokens, 0);
45
+ const occupiedCells = clamp(estimatedTotal, 0, contextWindow) / contextWindow * cellCount;
46
+ const segments = createSegments(usage, estimatedTotal, occupiedCells);
47
+ const cells = Array.from({ length: cellCount }, (_, index) => createCell(index, occupiedCells, segments));
48
+ return { columns: Math.floor(columns), rows: Math.floor(rows), cells };
49
+ }
50
+
51
+ /** Scale estimated category shares into the occupied map range. */
52
+ function createSegments(
53
+ usage: ContextUsageSnapshot,
54
+ estimatedTotal: number,
55
+ occupiedCells: number,
56
+ ): MapSegment[] {
57
+ if (estimatedTotal <= 0 || occupiedCells <= 0) return [];
58
+ const segments: MapSegment[] = [];
59
+ let cursor = 0;
60
+ for (const category of usage.categories) {
61
+ const size = category.tokens / estimatedTotal * occupiedCells;
62
+ segments.push({ categoryId: category.id, start: cursor, end: cursor + size });
63
+ cursor += size;
64
+ }
65
+ return segments;
66
+ }
67
+
68
+ /** Assign one map cell to its largest category overlap and classify its fill. */
69
+ function createCell(index: number, occupiedCells: number, segments: readonly MapSegment[]): UsageMapCell {
70
+ const occupiedOverlap = overlap(index, index + 1, 0, occupiedCells);
71
+ if (occupiedOverlap <= 0) return { fill: "free" };
72
+
73
+ let categoryId: string | undefined;
74
+ let categoryOverlap = 0;
75
+ for (const segment of segments) {
76
+ const currentOverlap = overlap(index, index + 1, segment.start, segment.end);
77
+ if (currentOverlap > categoryOverlap) {
78
+ categoryId = segment.categoryId;
79
+ categoryOverlap = currentOverlap;
80
+ }
81
+ }
82
+ return {
83
+ categoryId,
84
+ fill: categoryOverlap >= 0.7 ? "full" : "partial",
85
+ };
86
+ }
87
+
88
+ /** Length shared by two half-open numeric ranges. */
89
+ function overlap(aStart: number, aEnd: number, bStart: number, bEnd: number): number {
90
+ return Math.max(0, Math.min(aEnd, bEnd) - Math.max(aStart, bStart));
91
+ }
92
+
93
+ /** Restrict a finite value to an inclusive range. */
94
+ function clamp(value: number, minimum: number, maximum: number): number {
95
+ if (!Number.isFinite(value)) return minimum;
96
+ return Math.min(maximum, Math.max(minimum, value));
97
+ }
@@ -0,0 +1,603 @@
1
+ /**
2
+ * Focused `/context usage` view: estimated context composition with a
3
+ * proportional context-window map, pi-reported metadata, selectable category
4
+ * rows, and an Enter-opened chronological content preview.
5
+ */
6
+ import type { ExtensionCommandContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
7
+ import { Key, matchesKey, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
8
+
9
+ import type { ContextUsageSnapshot, UsageCategory, UsagePreviewEntry } from "../model.ts";
10
+ import { collectPreviewEntries } from "../usage.ts";
11
+ import {
12
+ ListNavigator,
13
+ normalizeInlineText,
14
+ normalizePreviewText,
15
+ PreviewScroller,
16
+ } from "./injections-model.ts";
17
+ import {
18
+ BODY_INDENT,
19
+ calculateViewport,
20
+ DEFAULT_TERMINAL_ROWS,
21
+ fitLine,
22
+ fitToTerminalHeight,
23
+ hintRow,
24
+ normalizeTerminalRows,
25
+ spreadLine,
26
+ } from "./layout.ts";
27
+ import { buildUsageMap, type UsageMapCell } from "./usage-map.ts";
28
+
29
+ const USAGE_DESCRIPTION = "Estimated context for the next model request; actual token counts may differ.";
30
+ const USAGE_TAIL_LINE_COUNT = 6;
31
+ const DETAIL_HEADER_LINE_COUNT = 4;
32
+ const PREVIEW_FIXED_LINE_COUNT = 8;
33
+ const PREVIEW_ENTRY_MAX_LINES = 20;
34
+ const CURSOR_COLUMN_WIDTH = 2;
35
+ const MAX_LEGEND_VALUE_COLUMN = 28;
36
+ const LEGEND_VALUE_GAP = 2;
37
+ const MAP_SIDE_BY_SIDE_MIN_WIDTH = 52;
38
+ const SPACED_MAP_MIN_WIDTH = 72;
39
+ const MAP_COLUMN_GAP = 2;
40
+ const SPACED_MAP_COLUMN_GAP = 3;
41
+ const FULL_CELL = "■";
42
+ const PARTIAL_CELL = "◧";
43
+ const COMPACTED_CELL = "▦";
44
+ const FREE_CELL = "⛶";
45
+
46
+ /** Everything the Usage view renders, classified once when the view opens. */
47
+ export interface UsageViewInput {
48
+ readonly usage: ContextUsageSnapshot;
49
+ readonly degradedReason?: string;
50
+ }
51
+
52
+ interface CategoryLegendRow {
53
+ readonly type: "category";
54
+ readonly category: UsageCategory;
55
+ readonly depth: number;
56
+ readonly rootId: string;
57
+ }
58
+
59
+ interface FreeLegendRow {
60
+ readonly type: "free";
61
+ readonly tokens: number;
62
+ }
63
+
64
+ type LegendRow = CategoryLegendRow | FreeLegendRow;
65
+
66
+ interface LegendColumns {
67
+ readonly value: number;
68
+ readonly tokenWidth: number;
69
+ }
70
+
71
+ /** Open the Usage view as a fullscreen overlay. */
72
+ export async function showUsageView(context: ExtensionCommandContext, input: UsageViewInput): Promise<void> {
73
+ await context.ui.custom<void>(
74
+ (tui, theme, _keybindings, done) => {
75
+ const view = new UsageView(theme, input, done, () => tui.terminal.rows);
76
+ return {
77
+ render: (width: number) => view.render(width),
78
+ invalidate: () => view.invalidate(),
79
+ handleInput: (data: string) => {
80
+ view.handleInput(data);
81
+ tui.requestRender();
82
+ },
83
+ };
84
+ },
85
+ {
86
+ overlay: true,
87
+ overlayOptions: { width: "100%", maxHeight: "100%", margin: 0 },
88
+ },
89
+ );
90
+ }
91
+
92
+ /** Exported for direct render/input tests; use showUsageView from pi code. */
93
+ export class UsageView {
94
+ private readonly theme: Theme;
95
+ private readonly input: UsageViewInput;
96
+ private readonly done: (result: undefined) => void;
97
+ private readonly getTerminalRows: () => number;
98
+ private readonly usage: ContextUsageSnapshot;
99
+ private readonly legendRows: readonly LegendRow[];
100
+ private readonly navigator: ListNavigator;
101
+ private readonly previewScroller = new PreviewScroller();
102
+ private previewRow: CategoryLegendRow | undefined;
103
+ private previewLines: string[] | undefined;
104
+ private previewWrapWidth: number | undefined;
105
+ private cachedWidth: number | undefined;
106
+ private cachedTerminalRows: number | undefined;
107
+ private cachedLines: string[] | undefined;
108
+
109
+ /** Create a view over one precomputed usage snapshot. */
110
+ public constructor(
111
+ theme: Theme,
112
+ input: UsageViewInput,
113
+ done: (result: undefined) => void,
114
+ getTerminalRows: () => number = () => process.stdout.rows ?? DEFAULT_TERMINAL_ROWS,
115
+ ) {
116
+ this.theme = theme;
117
+ this.input = input;
118
+ this.done = done;
119
+ this.getTerminalRows = getTerminalRows;
120
+ this.usage = input.usage;
121
+ this.legendRows = this.buildLegendRows();
122
+ this.navigator = new ListNavigator(this.legendRows.length, 1);
123
+ }
124
+
125
+ /** Handle category navigation, preview opening, and close keys. */
126
+ public handleInput(data: string): void {
127
+ if (this.previewRow !== undefined) {
128
+ this.handlePreviewInput(data);
129
+ return;
130
+ }
131
+ if (matchesKey(data, Key.escape) || data === "q") {
132
+ this.done(undefined);
133
+ return;
134
+ }
135
+ if (matchesKey(data, Key.enter)) {
136
+ this.openPreview();
137
+ } else if (matchesKey(data, Key.up)) {
138
+ if (this.navigator.moveBy(-1)) this.clearCache();
139
+ } else if (matchesKey(data, Key.down)) {
140
+ if (this.navigator.moveBy(1)) this.clearCache();
141
+ } else if (matchesKey(data, Key.pageUp)) {
142
+ if (this.navigator.page(-1)) this.clearCache();
143
+ } else if (matchesKey(data, Key.pageDown)) {
144
+ if (this.navigator.page(1)) this.clearCache();
145
+ } else if (matchesKey(data, Key.home)) {
146
+ if (this.navigator.moveTo(0)) this.clearCache();
147
+ } else if (matchesKey(data, Key.end)) {
148
+ if (this.navigator.moveTo(this.legendRows.length - 1)) this.clearCache();
149
+ }
150
+ }
151
+
152
+ /** Render a cached fullscreen frame for the current width and terminal height. */
153
+ public render(width: number): string[] {
154
+ const terminalRows = normalizeTerminalRows(this.getTerminalRows());
155
+ if (
156
+ this.cachedLines !== undefined &&
157
+ this.cachedWidth === width &&
158
+ this.cachedTerminalRows === terminalRows
159
+ ) {
160
+ return this.cachedLines;
161
+ }
162
+
163
+ const lines = this.previewRow === undefined
164
+ ? this.renderDashboard(width, terminalRows)
165
+ : this.renderPreview(width, terminalRows, this.previewRow);
166
+ this.cachedWidth = width;
167
+ this.cachedTerminalRows = terminalRows;
168
+ this.cachedLines = lines;
169
+ return lines;
170
+ }
171
+
172
+ /** Invalidate theme-dependent rendered output. */
173
+ public invalidate(): void {
174
+ this.previewLines = undefined;
175
+ this.previewWrapWidth = undefined;
176
+ this.clearCache();
177
+ }
178
+
179
+ // === Dashboard mode ===
180
+
181
+ /** Full map/legend frame with navigation hints. */
182
+ private renderDashboard(width: number, terminalRows: number): string[] {
183
+ const theme = this.theme;
184
+ const border = theme.fg("border", "─".repeat(Math.max(1, width)));
185
+ const prefix = [border, "", this.headerLine(width), "", ...this.degradedWarningLines(width)];
186
+ const availableDashboardRows = Math.max(1, terminalRows - prefix.length - USAGE_TAIL_LINE_COUNT);
187
+ const dashboard = this.dashboardLines(width, availableDashboardRows).slice(0, availableDashboardRows);
188
+ while (dashboard.length < availableDashboardRows) dashboard.push("");
189
+ const tail = [
190
+ "",
191
+ this.fit(theme.fg("muted", `${BODY_INDENT}${USAGE_DESCRIPTION}`), width),
192
+ "",
193
+ this.fit(
194
+ hintRow(theme, [
195
+ ["↑↓", "Navigate"],
196
+ ["Enter", "Preview"],
197
+ ["Esc", "Close"],
198
+ ]),
199
+ width,
200
+ ),
201
+ "",
202
+ border,
203
+ ];
204
+ return fitToTerminalHeight([...prefix, ...dashboard, ...tail], terminalRows, border);
205
+ }
206
+
207
+ /** Accent title at the top of the fullscreen view. */
208
+ private headerLine(width: number): string {
209
+ return this.fit(this.theme.fg("accent", this.theme.bold("Context Usage")), width);
210
+ }
211
+
212
+ /** Render the map and legend side by side, or only details when width/window data is insufficient. */
213
+ private dashboardLines(width: number, rows: number): string[] {
214
+ const map = buildUsageMap(this.usage);
215
+ if (map === undefined || width < MAP_SIDE_BY_SIDE_MIN_WIDTH) {
216
+ const detailWidth = Math.max(1, width - BODY_INDENT.length);
217
+ return this.detailLines(detailWidth, rows).map((line) => this.fit(`${BODY_INDENT}${line}`, width));
218
+ }
219
+
220
+ const spaced = width >= SPACED_MAP_MIN_WIDTH;
221
+ const separator = spaced ? " " : "";
222
+ const mapLines = Array.from({ length: map.rows }, (_, row) => {
223
+ const start = row * map.columns;
224
+ const cells = map.cells.slice(start, start + map.columns);
225
+ return `${BODY_INDENT}${cells.map((cell) => this.mapCell(cell)).join(separator)}`;
226
+ });
227
+ const mapWidth = BODY_INDENT.length + map.columns + (spaced ? map.columns - 1 : 0);
228
+ const gap = spaced ? SPACED_MAP_COLUMN_GAP : MAP_COLUMN_GAP;
229
+ const detailWidth = Math.max(1, width - mapWidth - gap);
230
+ const details = this.detailLines(detailWidth, rows);
231
+ const lineCount = Math.max(mapLines.length, details.length);
232
+ return Array.from({ length: lineCount }, (_, index) => {
233
+ const mapLine = mapLines[index] ?? " ".repeat(mapWidth);
234
+ const detail = this.fit(details[index] ?? "", detailWidth);
235
+ return this.fit(`${mapLine}${" ".repeat(gap)}${detail}`, width);
236
+ });
237
+ }
238
+
239
+ /** Model/usage metadata plus the selectable category legend viewport. */
240
+ private detailLines(width: number, rows: number): string[] {
241
+ const theme = this.theme;
242
+ const viewportRows = Math.max(1, rows - DETAIL_HEADER_LINE_COUNT);
243
+ this.navigator.setVisibleCount(viewportRows);
244
+
245
+ const heading = theme.fg("mdHeading", theme.bold("Category:"));
246
+ const counter = this.navigator.hasOverflow
247
+ ? theme.fg("dim", `(${this.navigator.selected + 1}/${this.legendRows.length})`)
248
+ : "";
249
+ const rowWidth = Math.max(1, width - CURSOR_COLUMN_WIDTH);
250
+ const columns = this.legendColumns(this.legendRows, rowWidth);
251
+ const visibleRows: string[] = [];
252
+ const start = this.navigator.offset;
253
+ for (let index = start; index < start + this.navigator.windowSize; index++) {
254
+ const row = this.legendRows[index];
255
+ if (row === undefined) break;
256
+ const selected = index === this.navigator.selected;
257
+ // The cursor stays in one fixed column at the start of the legend.
258
+ const cursor = selected ? theme.fg("accent", "→ ") : " ";
259
+ visibleRows.push(this.fit(`${cursor}${this.legendLine(row, columns, rowWidth, selected)}`, width));
260
+ }
261
+ return [
262
+ `${theme.fg("dim", "Model:")} ${theme.fg("muted", normalizeInlineText(this.usage.modelLabel ?? "Unavailable"))}`,
263
+ this.reportedSummary(width),
264
+ "",
265
+ counter === "" ? heading : spreadLine(heading, counter, width),
266
+ ...visibleRows,
267
+ ].slice(0, rows);
268
+ }
269
+
270
+ /** Pi-reported usage/window metadata, including the unknown-after-compaction state. */
271
+ private reportedSummary(width: number): string {
272
+ const reported = this.usage.reported;
273
+ if (reported === undefined) return this.fit(this.theme.fg("muted", "Context usage unavailable."), width);
274
+ const contextWindow = formatTokens(reported.contextWindow);
275
+ if (reported.tokens === undefined) {
276
+ return this.fit(this.theme.fg("muted", `Usage unknown · ${contextWindow} token window`), width);
277
+ }
278
+ const percent = reported.percent === undefined ? "" : ` (${formatPercent(reported.percent / 100)})`;
279
+ return this.fit(
280
+ this.theme.fg("text", `${formatTokens(reported.tokens)}/${contextWindow} tokens${percent}`),
281
+ width,
282
+ );
283
+ }
284
+
285
+ /** All navigable legend rows: top-level categories, Tool Output children, free space. */
286
+ private buildLegendRows(): LegendRow[] {
287
+ const rows: LegendRow[] = buildCategoryLegendRows(this.usage.categories);
288
+ const freeTokens = this.freeSpaceTokens();
289
+ if (freeTokens !== undefined) rows.push({ type: "free", tokens: freeTokens });
290
+ return rows;
291
+ }
292
+
293
+ /** Estimated remaining space, or undefined without a usable context window. */
294
+ private freeSpaceTokens(): number | undefined {
295
+ const contextWindow = this.usage.reported?.contextWindow;
296
+ if (contextWindow === undefined || contextWindow <= 0) return undefined;
297
+ return Math.max(0, contextWindow - this.usage.estimatedTokens);
298
+ }
299
+
300
+ /** Earliest shared token column plus the width needed to align percentages. */
301
+ private legendColumns(rows: readonly LegendRow[], width: number): LegendColumns {
302
+ const labelWidth = Math.max(1, ...rows.map((row) => this.plainLegendLabel(row).length));
303
+ const tokenWidth = Math.max(1, ...rows.map((row) => formatTokens(legendTokens(row)).length));
304
+ const percentWidth = Math.max(0, ...rows.map((row) => this.plainLegendPercent(legendTokens(row)).length));
305
+ const rightWidth = tokenWidth + (percentWidth > 0 ? LEGEND_VALUE_GAP + percentWidth : 0);
306
+ const idealValue = Math.min(MAX_LEGEND_VALUE_COLUMN, labelWidth + LEGEND_VALUE_GAP);
307
+ return {
308
+ value: Math.max(1, Math.min(idealValue, width - rightWidth)),
309
+ tokenWidth,
310
+ };
311
+ }
312
+
313
+ /** One aligned hierarchy row with independent token and percentage columns. */
314
+ private legendLine(row: LegendRow, columns: LegendColumns, width: number, selected: boolean): string {
315
+ const left = fitLine(this.styledLegendLabel(row, selected), columns.value);
316
+ const leftPadding = " ".repeat(Math.max(0, columns.value - visibleWidth(left)));
317
+ const tokens = formatTokens(legendTokens(row));
318
+ const valueColor = selected ? "accent" : row.type === "category" && row.depth > 1 ? "dim" : "muted";
319
+ const tokenPadding = " ".repeat(Math.max(0, columns.tokenWidth - tokens.length));
320
+ const percent = this.plainLegendPercent(legendTokens(row));
321
+ const percentPart = percent === ""
322
+ ? ""
323
+ : `${" ".repeat(LEGEND_VALUE_GAP)}${this.theme.fg(selected ? "accent" : "dim", percent)}`;
324
+ return fitLine(
325
+ `${left}${leftPadding}${this.theme.fg(valueColor, tokens)}${tokenPadding}${percentPart}`,
326
+ width,
327
+ );
328
+ }
329
+
330
+ /** Unstyled hierarchy label used to choose the shared value column. */
331
+ private plainLegendLabel(row: LegendRow): string {
332
+ if (row.type === "free") return `${FREE_CELL} Free Space:`;
333
+ const indent = " ".repeat(row.depth);
334
+ return `${indent}${categoryMarker(row.category.id, row.depth)} ${normalizeInlineText(row.category.label)}:`;
335
+ }
336
+
337
+ /** Themed hierarchy label; the marker keeps its map color even when selected. */
338
+ private styledLegendLabel(row: LegendRow, selected: boolean): string {
339
+ if (row.type === "free") {
340
+ return `${this.theme.fg("dim", FREE_CELL)} ${this.theme.fg(selected ? "accent" : "text", "Free Space:")}`;
341
+ }
342
+ const indent = " ".repeat(row.depth);
343
+ const color = categoryColor(row.rootId);
344
+ const marker = this.theme.fg(color, categoryMarker(row.category.id, row.depth));
345
+ const labelColor = selected ? "accent" : row.depth === 0 ? "text" : row.depth === 1 ? "muted" : "dim";
346
+ return `${indent}${marker} ${this.theme.fg(labelColor, `${normalizeInlineText(row.category.label)}:`)}`;
347
+ }
348
+
349
+ /** Percentage text used by the independently aligned rightmost column. */
350
+ private plainLegendPercent(tokens: number): string {
351
+ const contextWindow = this.usage.reported?.contextWindow;
352
+ if (contextWindow === undefined || contextWindow <= 0) return "";
353
+ return formatPercent(tokens / contextWindow);
354
+ }
355
+
356
+ /** Colored occupied/partial/free glyph for one map cell. */
357
+ private mapCell(cell: UsageMapCell): string {
358
+ if (cell.fill === "free") return this.theme.fg("dim", FREE_CELL);
359
+ const glyph = cell.categoryId === "compacted-data"
360
+ ? COMPACTED_CELL
361
+ : cell.fill === "full" ? FULL_CELL : PARTIAL_CELL;
362
+ return this.theme.fg(categoryColor(cell.categoryId), glyph);
363
+ }
364
+
365
+ /** Wrapped degraded-capture warning placed above the dashboard. */
366
+ private degradedWarningLines(width: number): string[] {
367
+ if (this.input.degradedReason === undefined) return [];
368
+ const reason = normalizeInlineText(this.input.degradedReason);
369
+ return wrapTextWithAnsi(this.theme.fg("warning", `${BODY_INDENT}${reason}`), width);
370
+ }
371
+
372
+ // === Preview mode ===
373
+
374
+ /** Preview scrolling and return-to-list keys. */
375
+ private handlePreviewInput(data: string): void {
376
+ if (matchesKey(data, Key.escape) || data === "q") {
377
+ this.closePreview();
378
+ return;
379
+ }
380
+ if (matchesKey(data, Key.up)) {
381
+ if (this.previewScroller.scrollBy(-1)) this.clearCache();
382
+ } else if (matchesKey(data, Key.down)) {
383
+ if (this.previewScroller.scrollBy(1)) this.clearCache();
384
+ } else if (matchesKey(data, Key.pageUp)) {
385
+ if (this.previewScroller.page(-1)) this.clearCache();
386
+ } else if (matchesKey(data, Key.pageDown)) {
387
+ if (this.previewScroller.page(1)) this.clearCache();
388
+ } else if (matchesKey(data, Key.home)) {
389
+ if (this.previewScroller.scrollTo(0)) this.clearCache();
390
+ } else if (matchesKey(data, Key.end)) {
391
+ if (this.previewScroller.scrollTo(this.previewScroller.maxOffset)) this.clearCache();
392
+ }
393
+ }
394
+
395
+ /** Open the selected category's content preview; free space has no preview. */
396
+ private openPreview(): void {
397
+ const row = this.legendRows[this.navigator.selected];
398
+ if (row === undefined || row.type !== "category") return;
399
+ this.previewRow = row;
400
+ this.previewLines = undefined;
401
+ this.previewWrapWidth = undefined;
402
+ this.previewScroller.reset();
403
+ this.clearCache();
404
+ }
405
+
406
+ /** Return to the list with the same selected row. */
407
+ private closePreview(): void {
408
+ this.previewRow = undefined;
409
+ this.previewLines = undefined;
410
+ this.previewWrapWidth = undefined;
411
+ this.clearCache();
412
+ }
413
+
414
+ /** Scrollable chronological content stream for one category. */
415
+ private renderPreview(width: number, terminalRows: number, row: CategoryLegendRow): string[] {
416
+ const theme = this.theme;
417
+ const border = theme.fg("border", "─".repeat(Math.max(1, width)));
418
+ const body = this.previewBodyLines(width, row);
419
+ const viewport = calculateViewport(body.length, terminalRows, PREVIEW_FIXED_LINE_COUNT);
420
+ this.previewScroller.setExtent(body.length, viewport.visibleCount);
421
+
422
+ const lines: string[] = [border, ""];
423
+ const title = theme.fg("accent", theme.bold(normalizeInlineText(row.category.label)));
424
+ const percent = this.plainLegendPercent(row.category.tokens);
425
+ const meta = theme.fg(
426
+ "muted",
427
+ `${formatTokens(row.category.tokens)} tokens${percent === "" ? "" : ` · ${percent}`} `,
428
+ );
429
+ lines.push(spreadLine(title, meta, width));
430
+ lines.push("");
431
+
432
+ const start = this.previewScroller.offset;
433
+ for (let index = start; index < start + viewport.visibleCount; index++) {
434
+ lines.push(body[index] ?? "");
435
+ }
436
+
437
+ if (viewport.showScroll) {
438
+ lines.push(
439
+ this.fit(theme.fg("dim", `${BODY_INDENT}(${this.previewScroller.visibleEnd}/${body.length})`), width),
440
+ );
441
+ }
442
+ lines.push("");
443
+ lines.push(
444
+ this.fit(
445
+ hintRow(theme, [
446
+ ["↑↓", "Scroll"],
447
+ ["PgUp/PgDn", "Page"],
448
+ ["Esc", "Back"],
449
+ ]),
450
+ width,
451
+ ),
452
+ );
453
+ lines.push("", border);
454
+ return fitToTerminalHeight(lines, terminalRows, border);
455
+ }
456
+
457
+ /** Cached wrapped entry stream: bracket headers plus capped sanitized content. */
458
+ private previewBodyLines(width: number, row: CategoryLegendRow): string[] {
459
+ const wrapWidth = Math.max(10, width - BODY_INDENT.length * 2 - 1);
460
+ if (this.previewLines !== undefined && this.previewWrapWidth === wrapWidth) return this.previewLines;
461
+ const entries = collectPreviewEntries(row.category);
462
+ const lines = entries.length === 0
463
+ ? [this.fit(this.theme.fg("muted", `${BODY_INDENT}No content captured for this category.`), width)]
464
+ : entries.flatMap((entry, index) => [
465
+ ...(index === 0 ? [] : [""]),
466
+ this.fit(`${BODY_INDENT}${this.entryHeader(entry)}`, width),
467
+ ...this.entryContentLines(entry, wrapWidth),
468
+ ]);
469
+ this.previewLines = lines;
470
+ this.previewWrapWidth = wrapWidth;
471
+ return lines;
472
+ }
473
+
474
+ /** Bracketed entry header: dim datetime, mdHeading lead breadcrumb cell, muted rest, dim tokens. */
475
+ private entryHeader(entry: UsagePreviewEntry): string {
476
+ const theme = this.theme;
477
+ const cells: string[] = [];
478
+ if (entry.timestamp !== undefined) {
479
+ cells.push(theme.fg("dim", `[${formatEntryTimestamp(entry.timestamp)}]`));
480
+ }
481
+ entry.breadcrumb.forEach((cell, index) => {
482
+ const color: ThemeColor = index === 0 ? "mdHeading" : "muted";
483
+ cells.push(
484
+ `${theme.fg("dim", "[")}${theme.fg(color, normalizeInlineText(cell))}${theme.fg("dim", "]")}`,
485
+ );
486
+ });
487
+ cells.push(theme.fg("dim", formatTokens(entry.tokens)));
488
+ return cells.join(" ");
489
+ }
490
+
491
+ /** Sanitized, wrapped, per-entry-capped content lines indented under the header. */
492
+ private entryContentLines(entry: UsagePreviewEntry, wrapWidth: number): string[] {
493
+ const indent = BODY_INDENT.repeat(2);
494
+ const lines: string[] = [];
495
+ let hidden = 0;
496
+ for (const paragraph of normalizePreviewText(entry.text).split("\n")) {
497
+ const wrapped = wrapTextWithAnsi(paragraph, wrapWidth);
498
+ const paragraphLines = wrapped.length === 0 ? [""] : wrapped;
499
+ for (const line of paragraphLines) {
500
+ if (lines.length < PREVIEW_ENTRY_MAX_LINES) lines.push(line === "" ? "" : `${indent}${line}`);
501
+ else hidden++;
502
+ }
503
+ }
504
+ if (hidden === 0) return lines;
505
+ return [...lines, `${indent}${this.theme.fg("dim", `… +${hidden} lines`)}`];
506
+ }
507
+
508
+ /** Truncate one rendered line to the supplied width. */
509
+ private fit(line: string, width: number): string {
510
+ return fitLine(line, width);
511
+ }
512
+
513
+ /** Clear render-cache keys after data, theme, or input changes. */
514
+ private clearCache(): void {
515
+ this.cachedWidth = undefined;
516
+ this.cachedTerminalRows = undefined;
517
+ this.cachedLines = undefined;
518
+ }
519
+ }
520
+
521
+ /** Show top-level categories plus Tool Output's direct per-tool breakdown. */
522
+ function buildCategoryLegendRows(categories: readonly UsageCategory[]): CategoryLegendRow[] {
523
+ const rows: CategoryLegendRow[] = [];
524
+ for (const category of categories) {
525
+ rows.push({ type: "category", category, depth: 0, rootId: category.id });
526
+ if (category.id !== "tool-output") continue;
527
+ for (const child of category.children ?? []) {
528
+ rows.push({ type: "category", category: child, depth: 1, rootId: category.id });
529
+ }
530
+ }
531
+ return rows;
532
+ }
533
+
534
+ /** Entry-header datetime: DD-MM-YYYY HH:MM:SS in local time. */
535
+ function formatEntryTimestamp(timestamp: number): string {
536
+ const date = new Date(timestamp);
537
+ const pad = (value: number) => `${value}`.padStart(2, "0");
538
+ return `${pad(date.getDate())}-${pad(date.getMonth() + 1)}-${date.getFullYear()}` +
539
+ ` ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
540
+ }
541
+
542
+ /** Marker distinguishing top-level occupancy, compacted data, and nested breakdowns. */
543
+ function categoryMarker(categoryId: string, depth: number): string {
544
+ if (depth > 0) return "·";
545
+ return categoryId === "compacted-data" ? COMPACTED_CELL : FULL_CELL;
546
+ }
547
+
548
+ /** Token estimate carried by either category or free-space legend rows. */
549
+ function legendTokens(row: LegendRow): number {
550
+ return row.type === "category" ? row.category.tokens : row.tokens;
551
+ }
552
+
553
+ /** Stable semantic theme color for one category across map cells and legend markers. */
554
+ function categoryColor(categoryId: string | undefined): ThemeColor {
555
+ switch (categoryId) {
556
+ case "system-prompt":
557
+ case "system-tools":
558
+ return "mdHeading";
559
+ case "custom-tools":
560
+ return "accent";
561
+ case "mcp-tools":
562
+ return "mdLink";
563
+ case "context-files":
564
+ return "mdCodeBlock";
565
+ case "skills":
566
+ return "customMessageLabel";
567
+ case "user-messages":
568
+ return "syntaxString";
569
+ case "agent-text-messages":
570
+ return "syntaxFunction";
571
+ case "agent-thinking-messages":
572
+ return "thinkingXhigh";
573
+ case "agent-tool-call-messages":
574
+ return "syntaxKeyword";
575
+ case "tool-output":
576
+ return "toolOutput";
577
+ case "extension-messages":
578
+ return "syntaxType";
579
+ case "compacted-data":
580
+ return "thinkingHigh";
581
+ default:
582
+ return "muted";
583
+ }
584
+ }
585
+
586
+ /** Compact token count: 951, 3.7k, 43.8k, 1m. */
587
+ export function formatTokens(tokens: number): string {
588
+ if (tokens < 1_000) return `${tokens}`;
589
+ if (tokens < 1_000_000) return `${trimTrailingZero((tokens / 1_000).toFixed(1))}k`;
590
+ return `${trimTrailingZero((tokens / 1_000_000).toFixed(1))}m`;
591
+ }
592
+
593
+ /** Percentage with one decimal below 10%: 0.4%, 4.2%, 96%. */
594
+ export function formatPercent(ratio: number): string {
595
+ const percent = ratio * 100;
596
+ if (percent >= 10) return `${Math.round(percent)}%`;
597
+ return `${trimTrailingZero(percent.toFixed(1))}%`;
598
+ }
599
+
600
+ /** Drop a redundant ".0" fraction from a fixed-point rendering. */
601
+ function trimTrailingZero(value: string): string {
602
+ return value.endsWith(".0") ? value.slice(0, -2) : value;
603
+ }