@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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -2
  3. package/package.json +55 -5
  4. package/src/capture/adjustments.ts +649 -0
  5. package/src/capture/attribution.ts +25 -0
  6. package/src/capture/builder.ts +128 -0
  7. package/src/capture/diff.ts +514 -0
  8. package/src/capture/dispatch.ts +36 -0
  9. package/src/capture/guard.ts +287 -0
  10. package/src/capture/messages.ts +246 -0
  11. package/src/capture/payload.ts +584 -0
  12. package/src/capture/redact.ts +58 -0
  13. package/src/capture/register.ts +129 -0
  14. package/src/capture/request.ts +251 -0
  15. package/src/capture/tools.ts +69 -0
  16. package/src/capture/tracker.ts +71 -0
  17. package/src/command.ts +164 -0
  18. package/src/compaction.ts +57 -0
  19. package/src/config.ts +408 -0
  20. package/src/index.ts +191 -0
  21. package/src/injections.ts +670 -0
  22. package/src/measure.ts +887 -0
  23. package/src/message-preview.ts +72 -0
  24. package/src/model-identity.ts +48 -0
  25. package/src/model.ts +548 -0
  26. package/src/pi-version.ts +29 -0
  27. package/src/probe/failure.ts +34 -0
  28. package/src/probe/filter.ts +126 -0
  29. package/src/probe/silent-probe.ts +349 -0
  30. package/src/probe/token.ts +28 -0
  31. package/src/probe/trigger.ts +178 -0
  32. package/src/probe/view.ts +26 -0
  33. package/src/projection.ts +122 -0
  34. package/src/prompt-additions.ts +226 -0
  35. package/src/prompt-blocks.ts +80 -0
  36. package/src/replay.ts +234 -0
  37. package/src/settings.ts +62 -0
  38. package/src/snapshot.ts +188 -0
  39. package/src/system-prompt-rows.ts +311 -0
  40. package/src/text.ts +46 -0
  41. package/src/token-diff.ts +211 -0
  42. package/src/transcript.ts +19 -0
  43. package/src/ui/color.ts +52 -0
  44. package/src/ui/diff-preview.ts +181 -0
  45. package/src/ui/injections-model.ts +487 -0
  46. package/src/ui/injections-view.ts +1023 -0
  47. package/src/ui/json-preview.ts +41 -0
  48. package/src/ui/layout.ts +161 -0
  49. package/src/ui/markers.ts +253 -0
  50. package/src/ui/notes.ts +117 -0
  51. package/src/ui/search.ts +427 -0
  52. package/src/ui/section-preview.ts +267 -0
  53. package/src/ui/skill-preview.ts +83 -0
  54. package/src/ui/status-bar.ts +94 -0
  55. package/src/ui/usage-map.ts +157 -0
  56. package/src/ui/usage-preview.ts +202 -0
  57. package/src/ui/usage-view.ts +1428 -0
  58. package/src/ui/wheel.ts +65 -0
  59. package/src/usage.ts +461 -0
@@ -0,0 +1,1023 @@
1
+ /**
2
+ * Focused `/context injections` view: hierarchical rows of the latest request
3
+ * snapshot. The Runtime label stays hidden until the runtime-inspection roadmap step.
4
+ */
5
+ import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
6
+ import { Key, matchesKey, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
7
+
8
+ import type { InjectionItem, InjectionSnapshot, TokenDiff } from "../model.ts";
9
+ import type { GuardResult } from "../snapshot.ts";
10
+ import { normalizeInlineText, normalizePreviewText } from "../text.ts";
11
+ import {
12
+ buildInjectionRows,
13
+ collectItemsById,
14
+ filterInjections,
15
+ type InjectionFilterMode,
16
+ type InjectionRow,
17
+ ListNavigator,
18
+ PreviewScroller,
19
+ } from "./injections-model.ts";
20
+ import { expandJsonSpan } from "./json-preview.ts";
21
+ import {
22
+ BODY_INDENT,
23
+ calculateViewport,
24
+ capNoticeLines,
25
+ DEFAULT_TERMINAL_ROWS,
26
+ descriptionBlockRows,
27
+ fitHintRow,
28
+ fitLine,
29
+ fitToTerminalHeight,
30
+ type Hint,
31
+ isPageBackKey,
32
+ isPageForwardKey,
33
+ isStepBackKey,
34
+ isStepForwardKey,
35
+ normalizeTerminalRows,
36
+ spreadLine,
37
+ STEP_KEY_HINT,
38
+ wrapDescriptionLines,
39
+ } from "./layout.ts";
40
+ import {
41
+ type DiffBase,
42
+ diffMarker,
43
+ formatDiffAdded,
44
+ formatDiffRemoved,
45
+ stateMarkers,
46
+ } from "./markers.ts";
47
+ import {
48
+ descriptionNoteLines, findingNotes, injectionsNotes, payloadCheck, payloadCheckLabel, requestOriginLabel,
49
+ } from "./notes.ts";
50
+ import {
51
+ findMatches,
52
+ highlightLine,
53
+ labelMatches,
54
+ MATCH_STEP_KEY_HINT,
55
+ matchStatus,
56
+ PreviewSearch,
57
+ SEARCH_KEY,
58
+ searchableContent,
59
+ SearchPrompt,
60
+ } from "./search.ts";
61
+ import { previewBodyLines, previewLegendLines } from "./section-preview.ts";
62
+ import { statusBarLines } from "./status-bar.ts";
63
+ import { DEFAULT_WHEEL_SCROLL_LINES, parseWheelDirection, readWheelScrollLines } from "./wheel.ts";
64
+
65
+ /**
66
+ * List frame rows excluding the status bar and the collapsible description:
67
+ * both borders and their blank rows, one header row, the fixed total with its
68
+ * preceding blank row, and the hint row above its own blank row.
69
+ */
70
+ const LIST_FIXED_LINE_COUNT = 10;
71
+ /** Rows configuration notices may occupy before they start crowding out the list. */
72
+ const MAX_NOTICE_LINES = 3;
73
+ const PREVIEW_FIXED_LINE_COUNT = 8;
74
+ /** Where a diff-only row's changes count, in place of its preview estimate. */
75
+ const DIFF_ONLY_NOTE = "counted in System Prompt";
76
+ /** List rows that must stay visible for the description to keep its own rows. */
77
+ const LIST_DESCRIPTION_MIN_ROWS = 26;
78
+ const CURSOR_COLUMN_WIDTH = 2;
79
+ const MAX_TOKEN_VALUE_COLUMN = 54;
80
+ const TOKEN_LEADER_GAP = 4;
81
+ /** Spaces before each diff column, after the value column. */
82
+ const DIFF_COLUMN_GAP = 2;
83
+ /** Lines kept above a match scrolled into view, so it is read in context. */
84
+ const SEARCH_CONTEXT_LINES = 2;
85
+ const EMPTY_FILTER_MESSAGE = "No injections match the filter.";
86
+
87
+ /** Everything the Injections view renders. */
88
+ export interface InjectionsViewInput {
89
+ readonly snapshot: InjectionSnapshot;
90
+ readonly degradedReason?: string;
91
+ /** Non-fatal problems shown under the header, such as ignored configuration entries. */
92
+ readonly notices?: readonly string[];
93
+ /** True when no request was captured yet and configuration disables the silent probe. */
94
+ readonly probingDisabled?: boolean;
95
+ /** True when a silent probe captured the request, not a real turn. */
96
+ readonly probe?: boolean;
97
+ /** Payload comparison of the request; absent for the degraded fallback, which has no request. */
98
+ readonly guard?: GuardResult;
99
+ }
100
+
101
+ /** Shared token-value and diff columns measured after the fixed cursor column. */
102
+ interface InjectionColumns {
103
+ readonly value: number;
104
+ /** Width of the largest formatted estimate in the whole tree, including TOTAL. */
105
+ readonly valueWidth: number;
106
+ /** Width of the widest `+N` in the whole tree; 0 when no row adds tokens. */
107
+ readonly addedWidth: number;
108
+ /** Width of the widest `-N` in the whole tree; 0 when no row removes tokens. */
109
+ readonly removedWidth: number;
110
+ }
111
+
112
+ /** Open the Injections view as a fullscreen overlay. */
113
+ export async function showInjectionsView(
114
+ context: ExtensionCommandContext,
115
+ input: InjectionsViewInput,
116
+ ): Promise<void> {
117
+ await context.ui.custom<void>(
118
+ (tui, theme, _keybindings, done) => {
119
+ const view = new InjectionsView(theme, input, done, () => tui.terminal.rows, readWheelScrollLines(tui));
120
+ return {
121
+ render: (width: number) => view.render(width),
122
+ invalidate: () => view.invalidate(),
123
+ handleInput: (data: string) => {
124
+ view.handleInput(data);
125
+ tui.requestRender();
126
+ },
127
+ };
128
+ },
129
+ {
130
+ overlay: true,
131
+ overlayOptions: { width: "100%", maxHeight: "100%", margin: 0 },
132
+ },
133
+ );
134
+ }
135
+
136
+ /** Exported for direct render/input tests; use showInjectionsView from pi code. */
137
+ export class InjectionsView {
138
+ private readonly theme: Theme;
139
+ private readonly input: InjectionsViewInput;
140
+ private readonly done: (result: undefined) => void;
141
+ private readonly getTerminalRows: () => number;
142
+ private readonly wheelScrollLines: number;
143
+ /** Unfiltered rows; they fix the value column so filtering never shifts it. */
144
+ private readonly allRows: InjectionRow[];
145
+ private rows: InjectionRow[];
146
+ private navigator: ListNavigator;
147
+ /** Unfiltered selection and scroll from before the filter, restored when the query is cleared or canceled. */
148
+ private unfilteredNavigator: ListNavigator | undefined;
149
+ private readonly itemsById: Map<string, InjectionItem>;
150
+ private readonly filter = new SearchPrompt();
151
+ private filterMode: InjectionFilterMode = "name";
152
+ /** Searchable content per item id, built on first use by a content filter. */
153
+ private readonly contentById = new Map<string, string>();
154
+ private readonly previewSearch = new PreviewSearch();
155
+ private readonly previewScroller = new PreviewScroller();
156
+ private previewItem: InjectionItem | undefined;
157
+ private previewLines: string[] | undefined;
158
+ private previewWrapWidth: number | undefined;
159
+ private cachedWidth: number | undefined;
160
+ private cachedTerminalRows: number | undefined;
161
+ private cachedLines: string[] | undefined;
162
+
163
+ public constructor(
164
+ theme: Theme,
165
+ input: InjectionsViewInput,
166
+ done: (result: undefined) => void,
167
+ getTerminalRows: () => number = () => process.stdout.rows ?? DEFAULT_TERMINAL_ROWS,
168
+ wheelScrollLines: number = DEFAULT_WHEEL_SCROLL_LINES,
169
+ ) {
170
+ this.theme = theme;
171
+ this.input = input;
172
+ this.done = done;
173
+ this.getTerminalRows = getTerminalRows;
174
+ this.wheelScrollLines = wheelScrollLines;
175
+ this.allRows = buildInjectionRows(input.snapshot);
176
+ this.rows = this.allRows;
177
+ this.navigator = listNavigator(this.rows);
178
+ this.itemsById = collectItemsById(input.snapshot);
179
+ }
180
+
181
+ public handleInput(data: string): void {
182
+ if (this.previewItem !== undefined) {
183
+ this.handlePreviewInput(data);
184
+ return;
185
+ }
186
+ if (this.filter.editing) {
187
+ this.handleFilterInput(data);
188
+ return;
189
+ }
190
+ if (matchesKey(data, Key.escape) || data === "q") {
191
+ if (this.filter.active) this.clearFilter();
192
+ else this.done(undefined);
193
+ return;
194
+ }
195
+ if (data === SEARCH_KEY) {
196
+ if (!this.filter.active) this.unfilteredNavigator = this.navigator;
197
+ this.filter.edit();
198
+ this.clearCache();
199
+ return;
200
+ }
201
+ // One notch moves the selection one row, like a single step key.
202
+ const wheel = parseWheelDirection(data);
203
+ if (wheel !== undefined) {
204
+ if (this.navigator.moveBy(wheel)) this.clearCache();
205
+ return;
206
+ }
207
+ if (matchesKey(data, Key.enter)) {
208
+ this.openPreview();
209
+ } else if (isStepBackKey(data)) {
210
+ if (this.navigator.moveBy(-1)) this.clearCache();
211
+ } else if (isStepForwardKey(data)) {
212
+ if (this.navigator.moveBy(1)) this.clearCache();
213
+ } else if (isPageBackKey(data)) {
214
+ if (this.navigator.page(-1)) this.clearCache();
215
+ } else if (isPageForwardKey(data)) {
216
+ if (this.navigator.page(1)) this.clearCache();
217
+ } else if (matchesKey(data, Key.home)) {
218
+ if (this.navigator.moveTo(0)) this.clearCache();
219
+ } else if (matchesKey(data, Key.end)) {
220
+ if (this.navigator.moveTo(this.navigator.selectableCount - 1)) this.clearCache();
221
+ }
222
+ }
223
+
224
+ public render(width: number): string[] {
225
+ const terminalRows = normalizeTerminalRows(this.getTerminalRows());
226
+ if (
227
+ this.cachedLines !== undefined &&
228
+ this.cachedWidth === width &&
229
+ this.cachedTerminalRows === terminalRows
230
+ ) {
231
+ return this.cachedLines;
232
+ }
233
+ if (this.previewItem !== undefined) {
234
+ const lines = this.renderPreview(width, terminalRows, this.previewItem);
235
+ this.cachedWidth = width;
236
+ this.cachedTerminalRows = terminalRows;
237
+ this.cachedLines = lines;
238
+ return lines;
239
+ }
240
+ const theme = this.theme;
241
+ const border = theme.fg("border", "─".repeat(Math.max(1, width)));
242
+ const headerLines = this.headerLines(width);
243
+ const filterLines = this.filterLines(width);
244
+ const warningLines = this.degradedWarningLines(width);
245
+ // A blank row separates warnings from the list; the emergency frame drops it with the other spacing
246
+ const warningRows = warningLines.length === 0 ? 0 : warningLines.length + 1;
247
+ // The status bar never collapses: the list and the description give way to it
248
+ const statusLines = this.statusLines(width);
249
+ const statusRows = descriptionBlockRows(statusLines);
250
+ const prefixRows = headerLines.length - 1 + filterLines.length + warningRows;
251
+ const availableRows = Math.max(1, terminalRows - LIST_FIXED_LINE_COUNT - prefixRows - statusRows);
252
+ const descriptionLines = this.fittedDescriptionLines(width, availableRows);
253
+ const extraLineCount = prefixRows + statusRows + descriptionBlockRows(descriptionLines);
254
+ const viewport = calculateViewport(
255
+ this.navigator.selectableCount, terminalRows, LIST_FIXED_LINE_COUNT, extraLineCount,
256
+ );
257
+ this.navigator.setVisibleCount(viewport.visibleCount);
258
+ const lines: string[] = [
259
+ border, "", ...headerLines, "", ...filterLines, ...warningLines, ...(warningRows > 0 ? [""] : []),
260
+ ];
261
+ const listLines = this.navigator.selectableCount === 0
262
+ ? [this.fit(theme.fg("muted", `${BODY_INDENT}${EMPTY_FILTER_MESSAGE}`), width)]
263
+ : this.listLines(width);
264
+ lines.push(...listLines);
265
+ if (viewport.showScroll) lines.push(this.scrollLine(width));
266
+ const total = this.totalLine(width);
267
+ lines.push("", total);
268
+ if (statusLines.length > 0) lines.push("", ...statusLines);
269
+ const hints = fitHintRow(theme, this.listHints(), width);
270
+ const tail = [...(descriptionLines.length > 0 ? ["", ...descriptionLines] : []), "", hints, "", border];
271
+ // Fill the terminal so the overlay hides the chat, keeping the list, total, and bar together
272
+ while (lines.length + tail.length < terminalRows) lines.push("");
273
+ lines.push(...tail);
274
+
275
+ const fittedLines = lines.length <= terminalRows ? lines : compactListLines({
276
+ border, header: this.headerLines(width, true), filter: filterLines.slice(0, 1), warnings: warningLines,
277
+ selected: listLines[this.navigator.selected - this.navigator.offset],
278
+ total, status: statusLines, hints, terminalRows,
279
+ });
280
+ this.cachedWidth = width;
281
+ this.cachedTerminalRows = terminalRows;
282
+ this.cachedLines = fittedLines;
283
+ return fittedLines;
284
+ }
285
+
286
+ public invalidate(): void {
287
+ this.clearPreviewContent();
288
+ this.clearCache();
289
+ }
290
+
291
+ // === List filter ===
292
+
293
+ /**
294
+ * Keys while the filter prompt is open: printable keys edit the query,
295
+ * the arrow and page keys still move the selection, Tab switches between
296
+ * names and content, Enter keeps the filter and opens the selected row,
297
+ * and Escape drops the filter.
298
+ */
299
+ private handleFilterInput(data: string): void {
300
+ if (matchesKey(data, Key.escape)) {
301
+ this.cancelFilter();
302
+ return;
303
+ }
304
+ if (matchesKey(data, Key.enter)) {
305
+ if (this.filter.active) {
306
+ this.filter.submit();
307
+ this.openPreview();
308
+ } else {
309
+ this.cancelFilter();
310
+ }
311
+ this.clearCache();
312
+ return;
313
+ }
314
+ if (matchesKey(data, Key.tab)) {
315
+ this.filterMode = this.filterMode === "name" ? "content" : "name";
316
+ this.applyFilter();
317
+ return;
318
+ }
319
+ const wheel = parseWheelDirection(data);
320
+ if (wheel !== undefined) {
321
+ if (this.navigator.moveBy(wheel)) this.clearCache();
322
+ } else if (matchesKey(data, Key.up)) {
323
+ if (this.navigator.moveBy(-1)) this.clearCache();
324
+ } else if (matchesKey(data, Key.down)) {
325
+ if (this.navigator.moveBy(1)) this.clearCache();
326
+ } else if (matchesKey(data, Key.pageUp)) {
327
+ if (this.navigator.page(-1)) this.clearCache();
328
+ } else if (matchesKey(data, Key.pageDown)) {
329
+ if (this.navigator.page(1)) this.clearCache();
330
+ } else if (this.filter.type(data)) {
331
+ this.applyFilter();
332
+ } else {
333
+ // The cursor may have moved without changing the query.
334
+ this.clearCache();
335
+ }
336
+ }
337
+
338
+ /** Rebuild rows for the current query, selecting the first row that matched itself. */
339
+ private applyFilter(): void {
340
+ const pattern = this.filter.pattern;
341
+ if (pattern === undefined) {
342
+ this.restoreUnfilteredRows();
343
+ return;
344
+ }
345
+ const filtered = filterInjections(this.input.snapshot, {
346
+ mode: this.filterMode,
347
+ nameMatches: (label) => labelMatches(label, pattern),
348
+ contentMatches: (item) => pattern.test(this.contentOf(item)),
349
+ });
350
+ const rows = buildInjectionRows(this.input.snapshot, filtered);
351
+ this.rows = rows;
352
+ this.navigator = listNavigator(rows);
353
+ this.navigator.moveTo(Math.max(0, rows.findIndex((row) => isMatchedRow(row))));
354
+ this.clearCache();
355
+ }
356
+
357
+ /** Drop the filter and restore every row, keeping the selected row selected. */
358
+ private clearFilter(): void {
359
+ const selected = this.selectedRowKey();
360
+ this.filter.clear();
361
+ this.unfilteredNavigator = undefined;
362
+ this.showRows(this.allRows, selected);
363
+ }
364
+
365
+ /** Drop the filter being typed and return to the selection and scroll from before it. */
366
+ private cancelFilter(): void {
367
+ this.filter.clear();
368
+ this.restoreUnfilteredRows();
369
+ this.unfilteredNavigator = undefined;
370
+ }
371
+
372
+ /** Show every row with the navigator saved before filtering, or keep the selected row without one. */
373
+ private restoreUnfilteredRows(): void {
374
+ if (this.unfilteredNavigator === undefined) {
375
+ this.showRows(this.allRows, this.selectedRowKey());
376
+ return;
377
+ }
378
+ this.rows = this.allRows;
379
+ this.navigator = this.unfilteredNavigator;
380
+ this.clearCache();
381
+ }
382
+
383
+ /** Replace the visible rows and reselect the row with `key`, or the first row. */
384
+ private showRows(rows: InjectionRow[], key: string | undefined): void {
385
+ this.rows = rows;
386
+ this.navigator = listNavigator(rows);
387
+ this.navigator.moveTo(Math.max(0, rows.findIndex((row) => rowKey(row) === key)));
388
+ this.clearCache();
389
+ }
390
+
391
+ private selectedRowKey(): string | undefined {
392
+ if (this.navigator.selectableCount === 0) return undefined;
393
+ const row = this.rows[this.navigator.selected];
394
+ return row === undefined ? undefined : rowKey(row);
395
+ }
396
+
397
+ /**
398
+ * Searchable content of one item, cached for repeated keystrokes. JSON runs
399
+ * expand as the preview shows them, so a query that keeps a row also finds
400
+ * its match once the preview opens.
401
+ */
402
+ private contentOf(item: InjectionItem): string {
403
+ let content = this.contentById.get(item.id);
404
+ if (content === undefined) {
405
+ const parts = item.sections?.length ? item.sections : [item];
406
+ content = searchableContent(parts.map((part) => expandJsonSpan(part.text, part.jsonSpan)).join(""));
407
+ this.contentById.set(item.id, content);
408
+ }
409
+ return content;
410
+ }
411
+
412
+ /** Prompt row and its trailing blank row, shown while a filter is typed or kept. */
413
+ private filterLines(width: number): string[] {
414
+ if (!this.filter.editing && !this.filter.active) return [];
415
+ const count = this.rows.filter(isMatchedRow).length;
416
+ const status = matchStatus(this.filter.active, count, undefined);
417
+ return [this.filter.renderAfter(this.theme, width, this.filterModeSwitcher(), status), ""];
418
+ }
419
+
420
+ /**
421
+ * `◉ Name | ○ Content >` lead of the filter prompt, marked like pi's own scope toggles:
422
+ * the active mode `◉` in bold accent, the other `○` dim.
423
+ */
424
+ private filterModeSwitcher(): string {
425
+ const theme = this.theme;
426
+ const mode = (value: InjectionFilterMode, label: string): string =>
427
+ value === this.filterMode ? theme.bold(theme.fg("accent", `◉ ${label}`)) : theme.fg("dim", `○ ${label}`);
428
+ return `${mode("name", "Name")}${theme.fg("dim", " | ")}${mode("content", "Content")}${theme.fg("dim", " > ")}`;
429
+ }
430
+
431
+ /** List hints for the plain list, an open filter prompt, or a kept filter. */
432
+ private listHints(): Hint[] {
433
+ if (this.filter.editing) {
434
+ // The prompt shows both modes; the hint only names the key that switches them
435
+ return [
436
+ ["↑↓", "Navigate"],
437
+ ["Tab", "Mode"],
438
+ ["Enter", "Preview"],
439
+ ["Esc", "Clear"],
440
+ ];
441
+ }
442
+ return [
443
+ [STEP_KEY_HINT, "Navigate"],
444
+ ["Enter", "Preview"],
445
+ [SEARCH_KEY, "Filter", true],
446
+ ["Esc", this.filter.active ? "Clear" : "Close"],
447
+ ];
448
+ }
449
+
450
+ // === Preview mode ===
451
+
452
+ private handlePreviewInput(data: string): void {
453
+ if (this.previewSearch.prompt.editing) {
454
+ this.handlePreviewSearchInput(data);
455
+ return;
456
+ }
457
+ if (matchesKey(data, Key.escape) || data === "q") {
458
+ this.closePreview();
459
+ return;
460
+ }
461
+ if (data === SEARCH_KEY) {
462
+ this.previewSearch.edit(this.previewScroller.offset);
463
+ this.clearCache();
464
+ return;
465
+ }
466
+ if (data === "n" || data === "N") {
467
+ if (this.previewSearch.step(data === "n" ? 1 : -1)) this.clearCache();
468
+ return;
469
+ }
470
+ const wheel = parseWheelDirection(data);
471
+ if (wheel !== undefined) {
472
+ if (this.previewScroller.scrollBy(wheel * this.wheelScrollLines)) this.clearCache();
473
+ return;
474
+ }
475
+ if (isStepBackKey(data)) {
476
+ if (this.previewScroller.scrollBy(-1)) this.clearCache();
477
+ } else if (isStepForwardKey(data)) {
478
+ if (this.previewScroller.scrollBy(1)) this.clearCache();
479
+ } else if (isPageBackKey(data)) {
480
+ if (this.previewScroller.page(-1)) this.clearCache();
481
+ } else if (isPageForwardKey(data)) {
482
+ if (this.previewScroller.page(1)) this.clearCache();
483
+ } else if (matchesKey(data, Key.home)) {
484
+ if (this.previewScroller.scrollTo(0)) this.clearCache();
485
+ } else if (matchesKey(data, Key.end)) {
486
+ if (this.previewScroller.scrollTo(this.previewScroller.maxOffset)) this.clearCache();
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Keys while the preview search prompt is open: printable keys edit the
492
+ * query, the arrow and page keys still scroll, Enter keeps the query, and
493
+ * Escape drops it.
494
+ */
495
+ private handlePreviewSearchInput(data: string): void {
496
+ const search = this.previewSearch;
497
+ const wheel = parseWheelDirection(data);
498
+ if (matchesKey(data, Key.escape)) {
499
+ search.clear();
500
+ } else if (matchesKey(data, Key.enter)) {
501
+ if (search.prompt.active) search.prompt.submit();
502
+ else search.clear();
503
+ } else if (wheel !== undefined) {
504
+ this.previewScroller.scrollBy(wheel * this.wheelScrollLines);
505
+ } else if (matchesKey(data, Key.up)) {
506
+ this.previewScroller.scrollBy(-1);
507
+ } else if (matchesKey(data, Key.down)) {
508
+ this.previewScroller.scrollBy(1);
509
+ } else if (matchesKey(data, Key.pageUp)) {
510
+ this.previewScroller.page(-1);
511
+ } else if (matchesKey(data, Key.pageDown)) {
512
+ this.previewScroller.page(1);
513
+ } else {
514
+ search.type(data);
515
+ }
516
+ this.clearCache();
517
+ }
518
+
519
+ /** Open the selected item; a content filter carries its query into the preview search. */
520
+ private openPreview(): void {
521
+ const row = this.rows[this.navigator.selected];
522
+ if (row?.kind !== "item") return;
523
+ const item = this.itemsById.get(row.itemId);
524
+ if (item === undefined) return;
525
+ this.previewItem = item;
526
+ this.clearPreviewContent();
527
+ this.previewScroller.reset();
528
+ this.previewSearch.clear();
529
+ if (this.filter.active && this.filterMode === "content") {
530
+ this.previewSearch.prompt.set(this.filter.query);
531
+ this.previewSearch.restart();
532
+ }
533
+ this.clearCache();
534
+ }
535
+
536
+ private closePreview(): void {
537
+ this.previewItem = undefined;
538
+ this.previewSearch.clear();
539
+ this.clearPreviewContent();
540
+ this.clearCache();
541
+ }
542
+
543
+ /** Drop width- and theme-dependent preview rendering. */
544
+ private clearPreviewContent(): void {
545
+ this.previewLines = undefined;
546
+ this.previewWrapWidth = undefined;
547
+ }
548
+
549
+ private renderPreview(width: number, terminalRows: number, item: InjectionItem): string[] {
550
+ const theme = this.theme;
551
+ const border = theme.fg("border", "─".repeat(Math.max(1, width)));
552
+ const wrapped = this.getPreviewLines(width, item);
553
+ const search = this.previewSearch;
554
+ const searchRowCount = search.prompt.editing || search.prompt.active ? 2 : 0;
555
+ const descriptionLines = previewLegendLines(theme, [item], {
556
+ width,
557
+ availableRows: terminalRows - PREVIEW_FIXED_LINE_COUNT - searchRowCount,
558
+ contentLineCount: wrapped.length,
559
+ }, findingNotes([item, ...(item.children ?? [])]).map((note) => note.text), previewDiffBase(item));
560
+ const viewport = calculateViewport(
561
+ wrapped.length,
562
+ terminalRows,
563
+ PREVIEW_FIXED_LINE_COUNT,
564
+ searchRowCount + descriptionBlockRows(descriptionLines),
565
+ );
566
+ this.previewScroller.setExtent(wrapped.length, viewport.visibleCount);
567
+ const revealLine = search.sync(wrapped);
568
+ if (revealLine !== undefined) this.previewScroller.reveal(revealLine, SEARCH_CONTEXT_LINES);
569
+ const searchLines = searchRowCount === 0
570
+ ? []
571
+ : [search.prompt.render(theme, width, "Search", search.status), ""];
572
+
573
+ const lines: string[] = [border, ""];
574
+ const title = theme.fg("accent", theme.bold(normalizeInlineText(item.label)));
575
+ const source = normalizeInlineText(item.source.label);
576
+ const meta = theme.fg("muted", item.diffOnly === true
577
+ ? source
578
+ : `${source} · ${item.tokens.toLocaleString("en-US")} tokens`);
579
+ const diff = diffMarker(theme, item.diff);
580
+ const note = item.diffOnly === true
581
+ ? theme.fg("dim", " · ") + theme.fg("muted", DIFF_ONLY_NOTE)
582
+ : "";
583
+ const marker = stateMarkers(theme, {
584
+ moved: item.moved, change: item.change, forcedEdit: item.forcedEdit, requestOnly: item.requestOnly,
585
+ });
586
+ // Drop the marker first when space runs out, then the accounting note, then the diff
587
+ const headerWidth = visibleWidth(title) + visibleWidth(meta) + 2;
588
+ const fitsDiff = headerWidth + visibleWidth(diff) <= width;
589
+ const fitsNote = fitsDiff && headerWidth + visibleWidth(diff) + visibleWidth(note) <= width;
590
+ const fitsMarker = fitsNote && headerWidth + visibleWidth(diff) + visibleWidth(note) + visibleWidth(marker) <= width;
591
+ lines.push(this.spread(title,
592
+ `${meta}${fitsDiff ? diff : ""}${fitsNote ? note : ""}${fitsMarker ? marker : ""} `, width));
593
+ lines.push("", ...searchLines);
594
+
595
+ const start = this.previewScroller.offset;
596
+ for (let index = start; index < start + viewport.visibleCount; index++) {
597
+ lines.push(search.highlight(theme, wrapped[index] ?? "", index));
598
+ }
599
+
600
+ if (viewport.showScroll) lines.push(this.previewScrollLine(width, wrapped.length));
601
+ if (descriptionLines.length > 0) lines.push("", ...descriptionLines);
602
+ lines.push("");
603
+ lines.push(fitHintRow(this.theme, this.previewHints(), width));
604
+ lines.push("", border);
605
+ return fitToTerminalHeight(lines, terminalRows, border);
606
+ }
607
+
608
+ /** Preview hints for plain scrolling, an open search prompt, or a kept search. */
609
+ private previewHints(): Hint[] {
610
+ const prompt = this.previewSearch.prompt;
611
+ if (prompt.editing) return [["↑↓", "Scroll"], ["Enter", "Done"], ["Esc", "Clear"]];
612
+ if (prompt.active) {
613
+ return [
614
+ [STEP_KEY_HINT, "Scroll"],
615
+ [MATCH_STEP_KEY_HINT, "Next/Prev"],
616
+ [SEARCH_KEY, "Search", true],
617
+ ["Esc", "Back"],
618
+ ];
619
+ }
620
+ return [[STEP_KEY_HINT, "Scroll"], ["PgUp/PgDn", "Page"], [SEARCH_KEY, "Search", true], ["Esc", "Back"]];
621
+ }
622
+
623
+ private getPreviewLines(width: number, item: InjectionItem): string[] {
624
+ const wrapWidth = Math.max(10, width - BODY_INDENT.length - 1);
625
+ if (this.previewLines !== undefined && this.previewWrapWidth === wrapWidth) return this.previewLines;
626
+ // The item preview is the full-content level, so marked JSON expands here.
627
+ const lines = previewBodyLines(
628
+ this.theme,
629
+ item,
630
+ wrapWidth,
631
+ (text, jsonSpan) => this.wrappedTextLines(expandJsonSpan(text, jsonSpan), wrapWidth),
632
+ item.label,
633
+ );
634
+ this.previewLines = lines;
635
+ this.previewWrapWidth = wrapWidth;
636
+ return lines;
637
+ }
638
+
639
+ /** Wrap sanitized text into indented preview lines, keeping blank lines. */
640
+ private wrappedTextLines(text: string, wrapWidth: number): string[] {
641
+ const lines: string[] = [];
642
+ for (const paragraph of normalizePreviewText(text).split("\n")) {
643
+ const wrapped = wrapTextWithAnsi(paragraph, wrapWidth);
644
+ if (wrapped.length === 0) {
645
+ lines.push("");
646
+ continue;
647
+ }
648
+ for (const line of wrapped) lines.push(`${BODY_INDENT}${line}`);
649
+ }
650
+ return lines;
651
+ }
652
+
653
+ private previewScrollLine(width: number, totalLines: number): string {
654
+ if (!this.previewScroller.hasOverflow) return this.fit("", width);
655
+ return this.fit(
656
+ this.theme.fg("dim", `${BODY_INDENT}(${this.previewScroller.visibleEnd}/${totalLines})`),
657
+ width,
658
+ );
659
+ }
660
+
661
+ /** Keep capture origin and payload attention visible, wrapping below the title rather than omitting status. */
662
+ private headerLines(width: number, compact = false): string[] {
663
+ const theme = this.theme;
664
+ const separator = theme.fg("dim", " · ");
665
+ const probe = this.input.probe === true;
666
+ const label = theme.fg("mdHeading", theme.bold(requestOriginLabel(probe)));
667
+ const payloadState = payloadCheck(this.input.guard);
668
+ const status = payloadState === undefined ? ""
669
+ : theme.fg("muted", "Payload") + theme.fg("dim", `: ${payloadCheckLabel(payloadState)}`);
670
+ // An emergency frame drops the title, never the origin or payload attention
671
+ if (compact) return wrapTextWithAnsi(`${label}${status === "" ? "" : separator + status}`, Math.max(1, width));
672
+ const title = theme.fg("accent", theme.bold("Context Injections"));
673
+ const combined = `${title}${separator}${label}`;
674
+ const lines = visibleWidth(combined) <= width
675
+ ? [combined]
676
+ : [this.fit(title, width), "", ...wrapTextWithAnsi(label, Math.max(1, width))];
677
+ if (status === "") return lines;
678
+ if (lines.length === 1 && visibleWidth(combined) + visibleWidth(status) < width) {
679
+ return [this.spread(combined, status, width)];
680
+ }
681
+ return [...lines, ...wrapDescriptionLines(theme, status, "dim", width)];
682
+ }
683
+
684
+ /** Render the current hierarchy viewport against one stable, nearby value column. */
685
+ private listLines(width: number): string[] {
686
+ const theme = this.theme;
687
+ const lines: string[] = [];
688
+ const contentWidth = Math.max(1, width - CURSOR_COLUMN_WIDTH);
689
+ const columns = this.injectionColumns(contentWidth);
690
+ const start = this.navigator.offset;
691
+ const end = start + this.navigator.windowSize;
692
+ for (let index = start; index < end; index++) {
693
+ const row = this.rows[index];
694
+ if (row === undefined) break;
695
+ if (row.kind === "separator") {
696
+ lines.push("");
697
+ continue;
698
+ }
699
+ const selected = row.kind !== "total" && index === this.navigator.selected;
700
+ const cursor = selected ? theme.fg("accent", "→ ") : BODY_INDENT;
701
+ const content = this.injectionLine(row, columns, contentWidth, selected);
702
+ lines.push(this.fit(`${cursor}${content}`, width));
703
+ }
704
+ return lines;
705
+ }
706
+
707
+ /** Render the fixed snapshot total with the hierarchy's shared value column and no cursor. */
708
+ private totalLine(width: number): string {
709
+ const row = this.rows.at(-1);
710
+ if (row?.kind !== "total") return "";
711
+ const contentWidth = Math.max(1, width - CURSOR_COLUMN_WIDTH);
712
+ const content = this.injectionLine(row, this.injectionColumns(contentWidth), contentWidth, false);
713
+ return this.fit(`${BODY_INDENT}${content}`, width);
714
+ }
715
+
716
+ /** Mark a name filter's query inside the rendered label of a row it matched. */
717
+ private highlightLabel(row: InjectionRow, label: string): string {
718
+ const pattern = this.filter.pattern;
719
+ if (pattern === undefined || this.filterMode !== "name" || !isMatchedRow(row)) return label;
720
+ const ranges = findMatches([label], pattern)
721
+ .flatMap((match) => match.segments)
722
+ .map((segment) => ({ start: segment.start, end: segment.end, current: false }));
723
+ return ranges.length === 0 ? label : highlightLine(this.theme, label, ranges);
724
+ }
725
+
726
+ /**
727
+ * Choose the earliest useful shared value column, capped on wide terminals,
728
+ * and size the diff columns from the widest `+N` and `-N` in the whole tree,
729
+ * unfiltered so a filter never shifts them.
730
+ */
731
+ private injectionColumns(width: number): InjectionColumns {
732
+ const contentRows = this.allRows.filter((row) => row.kind !== "separator");
733
+ const labelWidth = Math.max(1, ...contentRows.map((row) => visibleWidth(this.plainRowLabel(row))));
734
+ const tokenWidth = Math.max(
735
+ 1,
736
+ ...contentRows.filter((row) => !isDiffOnly(row)).map((row) => row.tokens.toLocaleString("en-US").length),
737
+ );
738
+ const diffs = contentRows.map(rowDiff);
739
+ const idealValue = Math.min(MAX_TOKEN_VALUE_COLUMN, labelWidth + TOKEN_LEADER_GAP);
740
+ return {
741
+ value: Math.max(1, Math.min(idealValue, width - tokenWidth)),
742
+ valueWidth: tokenWidth,
743
+ addedWidth: Math.max(0, ...diffs.map((diff) => diff !== undefined && diff.added > 0
744
+ ? formatDiffAdded(diff.added).length : 0)),
745
+ removedWidth: Math.max(0, ...diffs.map((diff) => diff !== undefined && diff.removed > 0
746
+ ? formatDiffRemoved(diff.removed).length : 0)),
747
+ };
748
+ }
749
+
750
+ /**
751
+ * One hierarchy row with dim leaders and a full token estimate when width
752
+ * permits, then its diff columns and state markers. A diff-only row has no
753
+ * estimate: its leaders run through the value column. Narrow widths drop the
754
+ * diff columns whole for every row, and a marker that does not fit.
755
+ */
756
+ private injectionLine(
757
+ row: Exclude<InjectionRow, { readonly kind: "separator" }>,
758
+ columns: InjectionColumns,
759
+ width: number,
760
+ selected: boolean,
761
+ ): string {
762
+ const labelWidth = Math.max(1, columns.value - 1);
763
+ const left = this.highlightLabel(row, fitLine(this.styledRowLabel(row, selected, labelWidth), labelWidth));
764
+ const diffOnly = isDiffOnly(row);
765
+ const value = diffOnly ? "" : row.tokens.toLocaleString("en-US").padStart(columns.valueWidth);
766
+ const leader = this.tokenLeader(
767
+ columns.value - visibleWidth(left) + (diffOnly ? columns.valueWidth : 0),
768
+ !diffOnly,
769
+ );
770
+ const tokens = row.kind === "total"
771
+ ? this.theme.bold(this.theme.fg("text", value))
772
+ : this.theme.fg(selected ? "accent" : "muted", value);
773
+ const marker = this.rowMarker(row);
774
+ const diff = rowDiff(row);
775
+ let lineWidth = columns.value + columns.valueWidth;
776
+ let diffs = "";
777
+ const diffWidth = diffColumnsWidth(columns);
778
+ // Blank diff columns only keep a following marker aligned
779
+ if (diffWidth > 0 && lineWidth + diffWidth <= width && (diff !== undefined || marker.length > 0)) {
780
+ diffs = this.diffColumns(diff, columns, marker.length > 0);
781
+ lineWidth += diffWidth;
782
+ }
783
+ const fittedMarker = lineWidth + visibleWidth(marker) <= width ? marker : "";
784
+ return fitLine(`${left}${leader}${tokens}${diffs}${fittedMarker}`, width);
785
+ }
786
+
787
+ /**
788
+ * `+N` and `-N` right-aligned in their tree-wide columns. A blank column is
789
+ * padded only when `followed` by a marker, so no row ends in spaces.
790
+ */
791
+ private diffColumns(diff: TokenDiff | undefined, columns: InjectionColumns, followed: boolean): string {
792
+ const added = diff !== undefined && diff.added > 0 ? formatDiffAdded(diff.added) : "";
793
+ const removed = diff !== undefined && diff.removed > 0 ? formatDiffRemoved(diff.removed) : "";
794
+ let text = "";
795
+ if (columns.addedWidth > 0 && (added.length > 0 || removed.length > 0 || followed)) {
796
+ text += " ".repeat(DIFF_COLUMN_GAP + columns.addedWidth - added.length);
797
+ if (added.length > 0) text += this.theme.fg("toolDiffAdded", added);
798
+ }
799
+ if (columns.removedWidth > 0 && (removed.length > 0 || followed)) {
800
+ text += " ".repeat(DIFF_COLUMN_GAP + columns.removedWidth - removed.length);
801
+ if (removed.length > 0) text += this.theme.fg("toolDiffRemoved", removed);
802
+ }
803
+ return text;
804
+ }
805
+
806
+ /**
807
+ * State markers after the estimate and diff columns: `Dropped`, `Moved`,
808
+ * `Forced`, and `Request-Only`. The diff columns show what a change added
809
+ * or removed.
810
+ */
811
+ private rowMarker(row: Exclude<InjectionRow, { readonly kind: "separator" }>): string {
812
+ return row.kind === "item" ? stateMarkers(this.theme, row) : "";
813
+ }
814
+
815
+ /** Fill a label/value gap with dim dots, omitting the trailing space for an empty value. */
816
+ private tokenLeader(width: number, trailingSpace = true): string {
817
+ if (width < 3) return " ".repeat(Math.max(0, width));
818
+ return ` ${this.theme.fg("dim", ".".repeat(width - (trailingSpace ? 2 : 1)))}${trailingSpace ? " " : ""}`;
819
+ }
820
+
821
+ /** Unstyled hierarchy label used to keep the value column stable while scrolling. */
822
+ private plainRowLabel(row: Exclude<InjectionRow, { readonly kind: "separator" }>): string {
823
+ const label = normalizeInlineText(row.label);
824
+ return row.kind === "item" ? `${this.treePrefix(row)}${row.labelPrefix ?? ""}${label}` : label;
825
+ }
826
+
827
+ /** Themed hierarchy label with connectors intentionally dim even on selection. */
828
+ private styledRowLabel(
829
+ row: Exclude<InjectionRow, { readonly kind: "separator" }>,
830
+ selected: boolean,
831
+ width: number,
832
+ ): string {
833
+ const theme = this.theme;
834
+ let label = normalizeInlineText(row.label);
835
+ if (row.kind === "group" || row.kind === "total") {
836
+ return theme.bold(theme.fg(selected ? "accent" : "text", label));
837
+ }
838
+ // The tool prefix stays dim on selection, like the connectors
839
+ const prefix = theme.fg("dim", `${this.treePrefix(row)}${row.labelPrefix ?? ""}`);
840
+ const item = this.itemsById.get(row.itemId);
841
+ if (item?.kind === "context-file" && item.children === undefined) {
842
+ label = shortenInstructionPath(label, Math.max(0, width - visibleWidth(prefix)));
843
+ }
844
+ const color = selected ? "accent" : row.depth === 1 ? "muted" : "dim";
845
+ return `${prefix}${theme.fg(color, label)}`;
846
+ }
847
+
848
+ /** Tree branch and ancestor continuation prefix for one item row. */
849
+ private treePrefix(row: Extract<InjectionRow, { readonly kind: "item" }>): string {
850
+ const branch = row.isLast ? "└─ " : "├─ ";
851
+ if (row.depth === 1) return branch;
852
+ return `${row.parentContinues === true ? "│ " : " "}${branch}`;
853
+ }
854
+
855
+ private scrollLine(width: number): string {
856
+ if (!this.navigator.hasOverflow) return this.fit("", width);
857
+ return this.fit(
858
+ this.theme.fg(
859
+ "dim",
860
+ `${BODY_INDENT}(${this.navigator.selectedOrdinal + 1}/${this.navigator.selectableCount})`,
861
+ ),
862
+ width,
863
+ );
864
+ }
865
+
866
+ /** The selected row's wrapped fields, or one empty reserved line without a change or marker. */
867
+ private statusLines(width: number): string[] {
868
+ // An empty filter result selects nothing; its rows are only the footer
869
+ const row = this.navigator.selectableCount === 0 ? undefined : this.rows[this.navigator.selected];
870
+ const lines = row === undefined ? [] : statusBarLines(this.theme, row, width);
871
+ return lines.length > 0 ? lines : [""];
872
+ }
873
+
874
+ /**
875
+ * Wrapped degraded-capture reason, then configuration problems, placed below
876
+ * the dialog header. Only the notices are capped: the precise degraded
877
+ * reason always stays whole.
878
+ */
879
+ private degradedWarningLines(width: number): string[] {
880
+ const degraded = this.input.degradedReason === undefined ? [] : this.wrapWarning(this.input.degradedReason, width);
881
+ const notices = (this.input.notices ?? []).map((notice) => this.wrapWarning(notice, width));
882
+ return [...degraded, ...capNoticeLines(notices, MAX_NOTICE_LINES, (notice) => this.wrapWarning(notice, width))];
883
+ }
884
+
885
+ /** One sanitized warning wrapped to the available width, indented on its first line. */
886
+ private wrapWarning(warning: string, width: number): string[] {
887
+ return wrapTextWithAnsi(this.theme.fg("warning", `${BODY_INDENT}${normalizeInlineText(warning)}`), width);
888
+ }
889
+
890
+ /**
891
+ * Description block, collapsed whole once the list window falls below its
892
+ * readable floor. The injection list is unbounded and scrolls on most
893
+ * terminals, so the description outlives the scroll counter instead of
894
+ * yielding to it; a list shorter than the floor keeps every row instead.
895
+ */
896
+ private fittedDescriptionLines(width: number, availableRows: number): string[] {
897
+ const lines = this.descriptionLines(width);
898
+ // An empty filter result still shows its one-row message
899
+ const rowCount = Math.max(1, this.navigator.selectableCount);
900
+ const floor = Math.min(LIST_DESCRIPTION_MIN_ROWS, rowCount);
901
+ const viewport = calculateViewport(rowCount, availableRows - descriptionBlockRows(lines), 0);
902
+ return viewport.visibleCount >= floor ? lines : [];
903
+ }
904
+
905
+ /**
906
+ * Wrapped dialog description: the list sentence, the degraded-capture
907
+ * indicator, request notes, and the note that explains payload changes
908
+ * further. The status bar, not a legend, explains the selected row's markers.
909
+ */
910
+ private descriptionLines(width: number): string[] {
911
+ return injectionsNotes(this.theme, {
912
+ ...this.input, hiddenTools: this.input.snapshot.hiddenTools, items: [...this.itemsById.values()],
913
+ }).flatMap((note) => descriptionNoteLines(this.theme, note, width));
914
+ }
915
+
916
+ private spread(left: string, right: string, width: number): string {
917
+ return spreadLine(left, right, width);
918
+ }
919
+
920
+ private fit(line: string, width: number): string {
921
+ return fitLine(line, width);
922
+ }
923
+
924
+ private clearCache(): void {
925
+ this.cachedWidth = undefined;
926
+ this.cachedTerminalRows = undefined;
927
+ this.cachedLines = undefined;
928
+ }
929
+ }
930
+
931
+ /** Navigator over rows whose trailing separator and total belong to the fixed footer, not the viewport. */
932
+ function listNavigator(rows: readonly InjectionRow[]): ListNavigator {
933
+ return new ListNavigator(rows.length - 2, 1);
934
+ }
935
+
936
+ /** Whether a filter matched the row itself rather than keeping it as context. */
937
+ function isMatchedRow(row: InjectionRow): boolean {
938
+ return (row.kind === "group" || row.kind === "item") && row.matched === true;
939
+ }
940
+
941
+ /** Identity that survives rebuilding rows for a different filter. */
942
+ function rowKey(row: InjectionRow): string {
943
+ if (row.kind === "item") return `item:${row.itemId}`;
944
+ return `${row.kind}:${row.label}`;
945
+ }
946
+
947
+ /** Parts of an emergency list frame, in which the bar has priority over frame spacing. */
948
+ interface CompactListInput {
949
+ readonly border: string;
950
+ readonly header: readonly string[];
951
+ /** The filter prompt row, without its trailing blank row; empty when no filter is shown. */
952
+ readonly filter: readonly string[];
953
+ readonly warnings: readonly string[];
954
+ readonly selected?: string;
955
+ readonly total: string;
956
+ readonly status: readonly string[];
957
+ readonly hints: string;
958
+ readonly terminalRows: number;
959
+ }
960
+
961
+ /**
962
+ * When even a one-row list cannot fit, remove spare spacing and the title.
963
+ * Keep capture status, the filter prompt, degraded reasons, the selected row,
964
+ * the total, and the bar between borders; add hints only if they fit, after empty rows that fill
965
+ * the remaining height. Below that physical minimum, clip content.
966
+ */
967
+ function compactListLines(input: CompactListInput): string[] {
968
+ const { border, header, filter, warnings, selected, total, status, hints, terminalRows } = input;
969
+ const lines = [
970
+ border, ...header, ...filter, ...warnings, ...(selected === undefined ? [] : [selected]), total, ...status,
971
+ ];
972
+ const tail = lines.length + 2 <= terminalRows ? [hints, border] : [border];
973
+ while (lines.length + tail.length < terminalRows) lines.push("");
974
+ return fitToTerminalHeight([...lines, ...tail], terminalRows, border);
975
+ }
976
+
977
+ /**
978
+ * Fit a sanitized path by replacing middle directories with an ellipsis. Keep
979
+ * the root and longest whole tail that fits; give the filename priority when
980
+ * even the root and ellipsis leave too little room.
981
+ */
982
+ function shortenInstructionPath(path: string, width: number): string {
983
+ if (visibleWidth(path) <= width) return path;
984
+ const separators = [...path.matchAll(/[/\\]/g)];
985
+ const lastSeparator = separators.at(-1);
986
+ if (lastSeparator === undefined) return fitLine(path, width);
987
+ const root = /^(?:~|[A-Za-z]:)?[/\\]/.exec(path)?.[0] ?? "";
988
+ for (const separator of separators) {
989
+ if (separator.index < root.length) continue;
990
+ const shortened = `${root}…${path.slice(separator.index)}`;
991
+ if (visibleWidth(shortened) <= width) return shortened;
992
+ }
993
+ const tail = `…${path.slice(lastSeparator.index)}`;
994
+ return visibleWidth(tail) <= width ? tail : fitLine(path.slice(lastSeparator.index + 1), width);
995
+ }
996
+
997
+ /** Whether a list row shows only a diff, with no estimate in the value column. */
998
+ function isDiffOnly(row: InjectionRow): boolean {
999
+ return row.kind === "item" && row.diffOnly === true;
1000
+ }
1001
+
1002
+ /** Token diff a list row shows; the total and the separator have none. */
1003
+ function rowDiff(row: InjectionRow): TokenDiff | undefined {
1004
+ return row.kind === "group" || row.kind === "item" ? row.diff : undefined;
1005
+ }
1006
+
1007
+ /** Width the diff columns take after the value column, including their gaps. */
1008
+ function diffColumnsWidth(columns: InjectionColumns): number {
1009
+ const added = columns.addedWidth > 0 ? DIFF_COLUMN_GAP + columns.addedWidth : 0;
1010
+ const removed = columns.removedWidth > 0 ? DIFF_COLUMN_GAP + columns.removedWidth : 0;
1011
+ return added + removed;
1012
+ }
1013
+
1014
+ /**
1015
+ * What a preview's token diffs compare with: pi's default prompt for pi's
1016
+ * contributions and `system prompt` rows, the captured request for a payload
1017
+ * change, and the session for everything else.
1018
+ */
1019
+ function previewDiffBase(item: InjectionItem): DiffBase {
1020
+ if (item.kind === "payload-change") return "captured-request";
1021
+ // A `system prompt` row splits pi's prompt changes by source
1022
+ return item.source.native || item.diffOnly === true ? "default-prompt" : "session";
1023
+ }