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