@xprnvd/pi-context-view 0.7.2 → 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/README.md +14 -12
- package/package.json +3 -3
- package/src/capture/diff.ts +72 -13
- package/src/capture/request.ts +47 -14
- package/src/ui/injections-model.ts +114 -5
- package/src/ui/injections-view.ts +336 -42
- package/src/ui/layout.ts +14 -1
- package/src/ui/search.ts +427 -0
- package/src/ui/usage-preview.ts +8 -0
- package/src/ui/usage-view.ts +200 -27
|
@@ -11,6 +11,8 @@ import { normalizeInlineText, normalizePreviewText } from "../text.ts";
|
|
|
11
11
|
import {
|
|
12
12
|
buildInjectionRows,
|
|
13
13
|
collectItemsById,
|
|
14
|
+
filterInjections,
|
|
15
|
+
type InjectionFilterMode,
|
|
14
16
|
type InjectionRow,
|
|
15
17
|
ListNavigator,
|
|
16
18
|
PreviewScroller,
|
|
@@ -22,9 +24,10 @@ import {
|
|
|
22
24
|
capNoticeLines,
|
|
23
25
|
DEFAULT_TERMINAL_ROWS,
|
|
24
26
|
descriptionBlockRows,
|
|
27
|
+
fitHintRow,
|
|
25
28
|
fitLine,
|
|
26
29
|
fitToTerminalHeight,
|
|
27
|
-
|
|
30
|
+
type Hint,
|
|
28
31
|
isPageBackKey,
|
|
29
32
|
isPageForwardKey,
|
|
30
33
|
isStepBackKey,
|
|
@@ -44,6 +47,17 @@ import {
|
|
|
44
47
|
import {
|
|
45
48
|
descriptionNoteLines, findingNotes, injectionsNotes, payloadCheck, payloadCheckLabel, requestOriginLabel,
|
|
46
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";
|
|
47
61
|
import { previewBodyLines, previewLegendLines } from "./section-preview.ts";
|
|
48
62
|
import { statusBarLines } from "./status-bar.ts";
|
|
49
63
|
import { DEFAULT_WHEEL_SCROLL_LINES, parseWheelDirection, readWheelScrollLines } from "./wheel.ts";
|
|
@@ -66,6 +80,9 @@ const MAX_TOKEN_VALUE_COLUMN = 54;
|
|
|
66
80
|
const TOKEN_LEADER_GAP = 4;
|
|
67
81
|
/** Spaces before each diff column, after the value column. */
|
|
68
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.";
|
|
69
86
|
|
|
70
87
|
/** Everything the Injections view renders. */
|
|
71
88
|
export interface InjectionsViewInput {
|
|
@@ -123,9 +140,18 @@ export class InjectionsView {
|
|
|
123
140
|
private readonly done: (result: undefined) => void;
|
|
124
141
|
private readonly getTerminalRows: () => number;
|
|
125
142
|
private readonly wheelScrollLines: number;
|
|
126
|
-
|
|
127
|
-
private readonly
|
|
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;
|
|
128
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();
|
|
129
155
|
private readonly previewScroller = new PreviewScroller();
|
|
130
156
|
private previewItem: InjectionItem | undefined;
|
|
131
157
|
private previewLines: string[] | undefined;
|
|
@@ -146,9 +172,9 @@ export class InjectionsView {
|
|
|
146
172
|
this.done = done;
|
|
147
173
|
this.getTerminalRows = getTerminalRows;
|
|
148
174
|
this.wheelScrollLines = wheelScrollLines;
|
|
149
|
-
this.
|
|
150
|
-
|
|
151
|
-
this.navigator =
|
|
175
|
+
this.allRows = buildInjectionRows(input.snapshot);
|
|
176
|
+
this.rows = this.allRows;
|
|
177
|
+
this.navigator = listNavigator(this.rows);
|
|
152
178
|
this.itemsById = collectItemsById(input.snapshot);
|
|
153
179
|
}
|
|
154
180
|
|
|
@@ -157,8 +183,19 @@ export class InjectionsView {
|
|
|
157
183
|
this.handlePreviewInput(data);
|
|
158
184
|
return;
|
|
159
185
|
}
|
|
186
|
+
if (this.filter.editing) {
|
|
187
|
+
this.handleFilterInput(data);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
160
190
|
if (matchesKey(data, Key.escape) || data === "q") {
|
|
161
|
-
this.
|
|
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();
|
|
162
199
|
return;
|
|
163
200
|
}
|
|
164
201
|
// One notch moves the selection one row, like a single step key.
|
|
@@ -203,40 +240,40 @@ export class InjectionsView {
|
|
|
203
240
|
const theme = this.theme;
|
|
204
241
|
const border = theme.fg("border", "─".repeat(Math.max(1, width)));
|
|
205
242
|
const headerLines = this.headerLines(width);
|
|
243
|
+
const filterLines = this.filterLines(width);
|
|
206
244
|
const warningLines = this.degradedWarningLines(width);
|
|
207
245
|
// A blank row separates warnings from the list; the emergency frame drops it with the other spacing
|
|
208
246
|
const warningRows = warningLines.length === 0 ? 0 : warningLines.length + 1;
|
|
209
247
|
// The status bar never collapses: the list and the description give way to it
|
|
210
248
|
const statusLines = this.statusLines(width);
|
|
211
249
|
const statusRows = descriptionBlockRows(statusLines);
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
terminalRows - LIST_FIXED_LINE_COUNT - (headerLines.length - 1) - warningRows - statusRows,
|
|
215
|
-
);
|
|
250
|
+
const prefixRows = headerLines.length - 1 + filterLines.length + warningRows;
|
|
251
|
+
const availableRows = Math.max(1, terminalRows - LIST_FIXED_LINE_COUNT - prefixRows - statusRows);
|
|
216
252
|
const descriptionLines = this.fittedDescriptionLines(width, availableRows);
|
|
217
|
-
const extraLineCount =
|
|
218
|
-
+ descriptionBlockRows(descriptionLines);
|
|
253
|
+
const extraLineCount = prefixRows + statusRows + descriptionBlockRows(descriptionLines);
|
|
219
254
|
const viewport = calculateViewport(
|
|
220
255
|
this.navigator.selectableCount, terminalRows, LIST_FIXED_LINE_COUNT, extraLineCount,
|
|
221
256
|
);
|
|
222
257
|
this.navigator.setVisibleCount(viewport.visibleCount);
|
|
223
|
-
const lines: string[] = [
|
|
224
|
-
|
|
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);
|
|
225
264
|
lines.push(...listLines);
|
|
226
265
|
if (viewport.showScroll) lines.push(this.scrollLine(width));
|
|
227
266
|
const total = this.totalLine(width);
|
|
228
267
|
lines.push("", total);
|
|
229
268
|
if (statusLines.length > 0) lines.push("", ...statusLines);
|
|
230
|
-
const hints = this.
|
|
231
|
-
[STEP_KEY_HINT, "Navigate"], ["Enter", "Preview"], ["Esc", "Close"],
|
|
232
|
-
]), width);
|
|
269
|
+
const hints = fitHintRow(theme, this.listHints(), width);
|
|
233
270
|
const tail = [...(descriptionLines.length > 0 ? ["", ...descriptionLines] : []), "", hints, "", border];
|
|
234
271
|
// Fill the terminal so the overlay hides the chat, keeping the list, total, and bar together
|
|
235
272
|
while (lines.length + tail.length < terminalRows) lines.push("");
|
|
236
273
|
lines.push(...tail);
|
|
237
274
|
|
|
238
275
|
const fittedLines = lines.length <= terminalRows ? lines : compactListLines({
|
|
239
|
-
border, header: this.headerLines(width, true), warnings: warningLines,
|
|
276
|
+
border, header: this.headerLines(width, true), filter: filterLines.slice(0, 1), warnings: warningLines,
|
|
240
277
|
selected: listLines[this.navigator.selected - this.navigator.offset],
|
|
241
278
|
total, status: statusLines, hints, terminalRows,
|
|
242
279
|
});
|
|
@@ -251,13 +288,185 @@ export class InjectionsView {
|
|
|
251
288
|
this.clearCache();
|
|
252
289
|
}
|
|
253
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
|
+
|
|
254
450
|
// === Preview mode ===
|
|
255
451
|
|
|
256
452
|
private handlePreviewInput(data: string): void {
|
|
453
|
+
if (this.previewSearch.prompt.editing) {
|
|
454
|
+
this.handlePreviewSearchInput(data);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
257
457
|
if (matchesKey(data, Key.escape) || data === "q") {
|
|
258
458
|
this.closePreview();
|
|
259
459
|
return;
|
|
260
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
|
+
}
|
|
261
470
|
const wheel = parseWheelDirection(data);
|
|
262
471
|
if (wheel !== undefined) {
|
|
263
472
|
if (this.previewScroller.scrollBy(wheel * this.wheelScrollLines)) this.clearCache();
|
|
@@ -278,6 +487,36 @@ export class InjectionsView {
|
|
|
278
487
|
}
|
|
279
488
|
}
|
|
280
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. */
|
|
281
520
|
private openPreview(): void {
|
|
282
521
|
const row = this.rows[this.navigator.selected];
|
|
283
522
|
if (row?.kind !== "item") return;
|
|
@@ -286,11 +525,17 @@ export class InjectionsView {
|
|
|
286
525
|
this.previewItem = item;
|
|
287
526
|
this.clearPreviewContent();
|
|
288
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
|
+
}
|
|
289
533
|
this.clearCache();
|
|
290
534
|
}
|
|
291
535
|
|
|
292
536
|
private closePreview(): void {
|
|
293
537
|
this.previewItem = undefined;
|
|
538
|
+
this.previewSearch.clear();
|
|
294
539
|
this.clearPreviewContent();
|
|
295
540
|
this.clearCache();
|
|
296
541
|
}
|
|
@@ -305,15 +550,25 @@ export class InjectionsView {
|
|
|
305
550
|
const theme = this.theme;
|
|
306
551
|
const border = theme.fg("border", "─".repeat(Math.max(1, width)));
|
|
307
552
|
const wrapped = this.getPreviewLines(width, item);
|
|
553
|
+
const search = this.previewSearch;
|
|
554
|
+
const searchRowCount = search.prompt.editing || search.prompt.active ? 2 : 0;
|
|
308
555
|
const descriptionLines = previewLegendLines(theme, [item], {
|
|
309
556
|
width,
|
|
310
|
-
availableRows: terminalRows - PREVIEW_FIXED_LINE_COUNT,
|
|
557
|
+
availableRows: terminalRows - PREVIEW_FIXED_LINE_COUNT - searchRowCount,
|
|
311
558
|
contentLineCount: wrapped.length,
|
|
312
559
|
}, findingNotes([item, ...(item.children ?? [])]).map((note) => note.text), previewDiffBase(item));
|
|
313
560
|
const viewport = calculateViewport(
|
|
314
|
-
wrapped.length,
|
|
561
|
+
wrapped.length,
|
|
562
|
+
terminalRows,
|
|
563
|
+
PREVIEW_FIXED_LINE_COUNT,
|
|
564
|
+
searchRowCount + descriptionBlockRows(descriptionLines),
|
|
315
565
|
);
|
|
316
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), ""];
|
|
317
572
|
|
|
318
573
|
const lines: string[] = [border, ""];
|
|
319
574
|
const title = theme.fg("accent", theme.bold(normalizeInlineText(item.label)));
|
|
@@ -335,30 +590,36 @@ export class InjectionsView {
|
|
|
335
590
|
const fitsMarker = fitsNote && headerWidth + visibleWidth(diff) + visibleWidth(note) + visibleWidth(marker) <= width;
|
|
336
591
|
lines.push(this.spread(title,
|
|
337
592
|
`${meta}${fitsDiff ? diff : ""}${fitsNote ? note : ""}${fitsMarker ? marker : ""} `, width));
|
|
338
|
-
lines.push("");
|
|
593
|
+
lines.push("", ...searchLines);
|
|
339
594
|
|
|
340
595
|
const start = this.previewScroller.offset;
|
|
341
596
|
for (let index = start; index < start + viewport.visibleCount; index++) {
|
|
342
|
-
lines.push(wrapped[index] ?? "");
|
|
597
|
+
lines.push(search.highlight(theme, wrapped[index] ?? "", index));
|
|
343
598
|
}
|
|
344
599
|
|
|
345
600
|
if (viewport.showScroll) lines.push(this.previewScrollLine(width, wrapped.length));
|
|
346
601
|
if (descriptionLines.length > 0) lines.push("", ...descriptionLines);
|
|
347
602
|
lines.push("");
|
|
348
|
-
lines.push(
|
|
349
|
-
this.fit(
|
|
350
|
-
hintRow(this.theme, [
|
|
351
|
-
[STEP_KEY_HINT, "Scroll"],
|
|
352
|
-
["PgUp/PgDn", "Page"],
|
|
353
|
-
["Esc", "Back"],
|
|
354
|
-
]),
|
|
355
|
-
width,
|
|
356
|
-
),
|
|
357
|
-
);
|
|
603
|
+
lines.push(fitHintRow(this.theme, this.previewHints(), width));
|
|
358
604
|
lines.push("", border);
|
|
359
605
|
return fitToTerminalHeight(lines, terminalRows, border);
|
|
360
606
|
}
|
|
361
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
|
+
|
|
362
623
|
private getPreviewLines(width: number, item: InjectionItem): string[] {
|
|
363
624
|
const wrapWidth = Math.max(10, width - BODY_INDENT.length - 1);
|
|
364
625
|
if (this.previewLines !== undefined && this.previewWrapWidth === wrapWidth) return this.previewLines;
|
|
@@ -452,12 +713,23 @@ export class InjectionsView {
|
|
|
452
713
|
return this.fit(`${BODY_INDENT}${content}`, width);
|
|
453
714
|
}
|
|
454
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
|
+
|
|
455
726
|
/**
|
|
456
727
|
* Choose the earliest useful shared value column, capped on wide terminals,
|
|
457
|
-
* and size the diff columns from the widest `+N` and `-N` in the whole tree
|
|
728
|
+
* and size the diff columns from the widest `+N` and `-N` in the whole tree,
|
|
729
|
+
* unfiltered so a filter never shifts them.
|
|
458
730
|
*/
|
|
459
731
|
private injectionColumns(width: number): InjectionColumns {
|
|
460
|
-
const contentRows = this.
|
|
732
|
+
const contentRows = this.allRows.filter((row) => row.kind !== "separator");
|
|
461
733
|
const labelWidth = Math.max(1, ...contentRows.map((row) => visibleWidth(this.plainRowLabel(row))));
|
|
462
734
|
const tokenWidth = Math.max(
|
|
463
735
|
1,
|
|
@@ -488,7 +760,7 @@ export class InjectionsView {
|
|
|
488
760
|
selected: boolean,
|
|
489
761
|
): string {
|
|
490
762
|
const labelWidth = Math.max(1, columns.value - 1);
|
|
491
|
-
const left = fitLine(this.styledRowLabel(row, selected, labelWidth), labelWidth);
|
|
763
|
+
const left = this.highlightLabel(row, fitLine(this.styledRowLabel(row, selected, labelWidth), labelWidth));
|
|
492
764
|
const diffOnly = isDiffOnly(row);
|
|
493
765
|
const value = diffOnly ? "" : row.tokens.toLocaleString("en-US").padStart(columns.valueWidth);
|
|
494
766
|
const leader = this.tokenLeader(
|
|
@@ -593,7 +865,8 @@ export class InjectionsView {
|
|
|
593
865
|
|
|
594
866
|
/** The selected row's wrapped fields, or one empty reserved line without a change or marker. */
|
|
595
867
|
private statusLines(width: number): string[] {
|
|
596
|
-
|
|
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];
|
|
597
870
|
const lines = row === undefined ? [] : statusBarLines(this.theme, row, width);
|
|
598
871
|
return lines.length > 0 ? lines : [""];
|
|
599
872
|
}
|
|
@@ -622,7 +895,8 @@ export class InjectionsView {
|
|
|
622
895
|
*/
|
|
623
896
|
private fittedDescriptionLines(width: number, availableRows: number): string[] {
|
|
624
897
|
const lines = this.descriptionLines(width);
|
|
625
|
-
|
|
898
|
+
// An empty filter result still shows its one-row message
|
|
899
|
+
const rowCount = Math.max(1, this.navigator.selectableCount);
|
|
626
900
|
const floor = Math.min(LIST_DESCRIPTION_MIN_ROWS, rowCount);
|
|
627
901
|
const viewport = calculateViewport(rowCount, availableRows - descriptionBlockRows(lines), 0);
|
|
628
902
|
return viewport.visibleCount >= floor ? lines : [];
|
|
@@ -654,10 +928,28 @@ export class InjectionsView {
|
|
|
654
928
|
}
|
|
655
929
|
}
|
|
656
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
|
+
|
|
657
947
|
/** Parts of an emergency list frame, in which the bar has priority over frame spacing. */
|
|
658
948
|
interface CompactListInput {
|
|
659
949
|
readonly border: string;
|
|
660
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[];
|
|
661
953
|
readonly warnings: readonly string[];
|
|
662
954
|
readonly selected?: string;
|
|
663
955
|
readonly total: string;
|
|
@@ -668,13 +960,15 @@ interface CompactListInput {
|
|
|
668
960
|
|
|
669
961
|
/**
|
|
670
962
|
* When even a one-row list cannot fit, remove spare spacing and the title.
|
|
671
|
-
* Keep capture status, degraded reasons, the selected row,
|
|
672
|
-
* bar between borders; add hints only if they fit, after empty rows that fill
|
|
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
|
|
673
965
|
* the remaining height. Below that physical minimum, clip content.
|
|
674
966
|
*/
|
|
675
967
|
function compactListLines(input: CompactListInput): string[] {
|
|
676
|
-
const { border, header, warnings, selected, total, status, hints, terminalRows } = input;
|
|
677
|
-
const lines = [
|
|
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
|
+
];
|
|
678
972
|
const tail = lines.length + 2 <= terminalRows ? [hints, border] : [border];
|
|
679
973
|
while (lines.length + tail.length < terminalRows) lines.push("");
|
|
680
974
|
return fitToTerminalHeight([...lines, ...tail], terminalRows, border);
|
package/src/ui/layout.ts
CHANGED
|
@@ -137,11 +137,24 @@ export function spreadLine(left: string, right: string, width: number): string {
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
/** Pi-style hint row: two-space indent, `key description` pairs joined by ` · `. */
|
|
140
|
-
export function hintRow(theme: Theme, hints:
|
|
140
|
+
export function hintRow(theme: Theme, hints: readonly Hint[]): string {
|
|
141
141
|
const separator = theme.fg("dim", " · ");
|
|
142
142
|
return `${BODY_INDENT}${hints.map(([key, description]) => hint(theme, key, description)).join(separator)}`;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
/** One hint pair; an optional hint is the first to go when the row is too narrow. */
|
|
146
|
+
export type Hint = readonly [key: string, description: string, optional?: boolean];
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Hint row fitted to the width: optional hints drop out whole before any
|
|
150
|
+
* required hint is truncated, so a narrow frame keeps its essential keys.
|
|
151
|
+
*/
|
|
152
|
+
export function fitHintRow(theme: Theme, hints: readonly Hint[], width: number): string {
|
|
153
|
+
const full = hintRow(theme, hints);
|
|
154
|
+
if (visibleWidth(full) <= width) return full;
|
|
155
|
+
return fitLine(hintRow(theme, hints.filter(([, , optional]) => optional !== true)), width);
|
|
156
|
+
}
|
|
157
|
+
|
|
145
158
|
/** Pi-style hint: dim key, slightly brighter (muted) description. */
|
|
146
159
|
function hint(theme: Theme, key: string, description: string): string {
|
|
147
160
|
return theme.fg("dim", key) + theme.fg("muted", ` ${description}`);
|