@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.
@@ -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
- hintRow,
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
- private readonly rows: InjectionRow[];
127
- private readonly navigator: ListNavigator;
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.rows = buildInjectionRows(input.snapshot);
150
- // The trailing separator and total belong to the fixed footer, not the viewport
151
- this.navigator = new ListNavigator(this.rows.length - 2, 1);
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.done(undefined);
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 availableRows = Math.max(
213
- 1,
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 = headerLines.length - 1 + warningRows + statusRows
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[] = [border, "", ...headerLines, "", ...warningLines, ...(warningRows > 0 ? [""] : [])];
224
- const listLines = this.listLines(width);
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.fit(hintRow(theme, [
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, terminalRows, PREVIEW_FIXED_LINE_COUNT, descriptionBlockRows(descriptionLines),
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.rows.filter((row) => row.kind !== "separator");
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
- const row = this.rows[this.navigator.selected];
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
- const rowCount = this.navigator.selectableCount;
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, the total, and the
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 = [border, ...header, ...warnings, ...(selected === undefined ? [] : [selected]), total, ...status];
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: ReadonlyArray<readonly [string, string]>): string {
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}`);