@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.
@@ -27,8 +27,10 @@ import {
27
27
  capNoticeLines,
28
28
  DEFAULT_TERMINAL_ROWS,
29
29
  descriptionBlockRows,
30
+ fitHintRow,
30
31
  fitLine,
31
32
  fitToTerminalHeight,
33
+ type Hint,
32
34
  hintRow,
33
35
  isPageBackKey,
34
36
  isPageForwardKey,
@@ -41,6 +43,7 @@ import {
41
43
  } from "./layout.ts";
42
44
  import { forcedMarker } from "./markers.ts";
43
45
  import { descriptionNoteLines, emptyPreviewNote, probingDisabledNote, thinkingNote, usageNote } from "./notes.ts";
46
+ import { MATCH_STEP_KEY_HINT, PreviewSearch, SEARCH_KEY } from "./search.ts";
44
47
  import { previewBodyLines, previewLegendLines } from "./section-preview.ts";
45
48
  import { splitSkillPreview } from "./skill-preview.ts";
46
49
  import { buildUsageMap, calculateFitMapScale, type UsageMap, type UsageMapCell } from "./usage-map.ts";
@@ -87,6 +90,8 @@ const MAP_KEY_SIZE_LABEL = "Block Size";
87
90
  const MAP_KEY_DETAILED_SPARE_ROWS = 5;
88
91
  /** Rows the single-line key costs beside the complete legend: one separator plus one key row. */
89
92
  const MAP_KEY_COMPACT_SPARE_ROWS = 2;
93
+ /** Lines kept above a match scrolled into view, so it is read in context. */
94
+ const SEARCH_CONTEXT_LINES = 2;
90
95
 
91
96
  /** Everything the Usage view renders, classified once when the view opens. */
92
97
  export interface UsageViewInput {
@@ -161,6 +166,29 @@ interface BlockBody {
161
166
  readonly lines: readonly string[];
162
167
  }
163
168
 
169
+ /**
170
+ * Every block of a stream flattened for search: each entry header and its
171
+ * uncapped content, blocks separated by a blank line. Capped stream lines are
172
+ * a prefix of each block here, so a stream line maps to its search row.
173
+ */
174
+ interface StreamSearchLines {
175
+ /** The wrapped entries these lines flatten; a rewrap invalidates them. */
176
+ readonly entries: readonly EntryLines[];
177
+ readonly lines: readonly string[];
178
+ /** First search row of each block. */
179
+ readonly starts: readonly number[];
180
+ }
181
+
182
+ /**
183
+ * Full-content search lines: the entry header when it renders, then the body.
184
+ * Including the header keeps a stream match on it alive once its block opens.
185
+ */
186
+ interface ContentSearchLines {
187
+ readonly body: readonly string[];
188
+ readonly header: string | undefined;
189
+ readonly lines: readonly string[];
190
+ }
191
+
164
192
  /** Open the Usage view as a fullscreen overlay. */
165
193
  export async function showUsageView(context: ExtensionCommandContext, input: UsageViewInput): Promise<void> {
166
194
  await context.ui.custom<void>(
@@ -193,6 +221,10 @@ export class UsageView {
193
221
  private readonly categoryColors: CategoryColors;
194
222
  private readonly legendRows: readonly LegendRow[];
195
223
  private readonly navigator: ListNavigator;
224
+ /** Search of the open category, shared by its block stream and the block it opens. */
225
+ private readonly search = new PreviewSearch();
226
+ private cachedStreamSearch: StreamSearchLines | undefined;
227
+ private cachedContentSearch: ContentSearchLines | undefined;
196
228
  private readonly previewScroller = new PreviewScroller();
197
229
  private readonly blockNavigator = new BlockNavigator();
198
230
  private readonly fitMapScale: number | undefined;
@@ -234,7 +266,9 @@ export class UsageView {
234
266
  public handleInput(data: string): void {
235
267
  if (this.previewRow !== undefined) {
236
268
  // Route on the same predicate the renderer uses, so keys always target the visible level.
237
- if (this.getFullContentEntry(this.previewRow) === undefined) this.handlePreviewInput(data);
269
+ const blockStream = this.getFullContentEntry(this.previewRow) === undefined;
270
+ if (this.search.prompt.editing) this.handleSearchInput(data, blockStream);
271
+ else if (blockStream) this.handlePreviewInput(data);
238
272
  else this.handleContentInput(data);
239
273
  return;
240
274
  }
@@ -735,12 +769,59 @@ export class UsageView {
735
769
 
736
770
  // === Preview mode ===
737
771
 
772
+ /**
773
+ * Keys while the search prompt is open at either preview level: printable
774
+ * keys edit the query, the arrow and page keys still navigate, Enter keeps
775
+ * the query, and Escape drops it.
776
+ */
777
+ private handleSearchInput(data: string, blockStream: boolean): void {
778
+ const search = this.search;
779
+ if (matchesKey(data, Key.escape)) {
780
+ search.clear();
781
+ } else if (matchesKey(data, Key.enter)) {
782
+ if (search.prompt.active) search.prompt.submit();
783
+ else search.clear();
784
+ } else if (
785
+ parseWheelDirection(data) !== undefined ||
786
+ matchesKey(data, Key.up) ||
787
+ matchesKey(data, Key.down) ||
788
+ matchesKey(data, Key.pageUp) ||
789
+ matchesKey(data, Key.pageDown)
790
+ ) {
791
+ // Plain navigation keys never type into the prompt, so the level handles them as usual.
792
+ if (blockStream) this.handlePreviewInput(data);
793
+ else this.handleContentInput(data);
794
+ } else {
795
+ search.type(data);
796
+ }
797
+ this.clearCache();
798
+ }
799
+
800
+ /** `/` opens the search prompt and `n`/`N` step through matches; false for any other key. */
801
+ private handleSearchKey(data: string, anchorRow: number): boolean {
802
+ if (data === SEARCH_KEY) {
803
+ this.search.edit(anchorRow);
804
+ } else if (data === "n" || data === "N") {
805
+ this.search.step(data === "n" ? 1 : -1);
806
+ } else {
807
+ return false;
808
+ }
809
+ this.clearCache();
810
+ return true;
811
+ }
812
+
738
813
  /** Block navigation, block opening, and return-to-list keys. */
739
814
  private handlePreviewInput(data: string): void {
740
815
  if (matchesKey(data, Key.escape) || data === "q") {
741
816
  this.closePreview();
742
817
  return;
743
818
  }
819
+ // Decide from the entries, not render caches: keys can arrive before the stream first renders.
820
+ const row = this.previewRow;
821
+ if (row !== undefined && this.previewEntries(row).length > 0) {
822
+ const anchor = this.cachedStreamSearch?.starts[this.blockNavigator.selected] ?? 0;
823
+ if (this.handleSearchKey(data, anchor)) return;
824
+ }
744
825
  // Blocks are selected rather than scrolled, so one notch steps one block.
745
826
  const wheel = parseWheelDirection(data);
746
827
  if (wheel !== undefined) {
@@ -772,6 +853,7 @@ export class UsageView {
772
853
  else this.closeBlock();
773
854
  return;
774
855
  }
856
+ if (this.handleSearchKey(data, this.previewScroller.offset)) return;
775
857
  const wheel = parseWheelDirection(data);
776
858
  if (wheel !== undefined) {
777
859
  if (this.previewScroller.scrollBy(wheel * this.wheelScrollLines)) this.clearCache();
@@ -801,6 +883,7 @@ export class UsageView {
801
883
  this.clearPreviewContent();
802
884
  this.blockNavigator.reset();
803
885
  this.previewScroller.reset();
886
+ this.search.clear();
804
887
  this.clearCache();
805
888
  }
806
889
 
@@ -809,52 +892,77 @@ export class UsageView {
809
892
  this.previewRow = undefined;
810
893
  this.openBlockIndex = undefined;
811
894
  this.cachedPreviewEntries = undefined;
895
+ this.search.clear();
812
896
  this.clearPreviewContent();
813
897
  this.clearCache();
814
898
  }
815
899
 
816
- /** Open hidden content from the selected capped block; complete and empty blocks have nothing to open. */
900
+ /**
901
+ * Open hidden content from the selected capped block; complete and empty
902
+ * blocks have nothing to open. A search continues at the current match when
903
+ * it lies in this block, so a match hidden by the cap is one Enter away.
904
+ */
817
905
  private openBlock(): void {
818
906
  // The rendered stream carries the height-dependent cap the user sees, so it decides what Enter opens.
819
907
  const index = this.blockNavigator.selected;
820
908
  const block = this.cachedStream?.blocks[index];
821
909
  if (block === undefined || block.hiddenLineCount === 0) return;
910
+ // The opened level searches the same header and content rows, so offsets carry over unchanged.
911
+ const start = this.cachedStreamSearch?.starts[index] ?? 0;
912
+ const blockLineCount = block.lines.length + block.hiddenLineCount;
913
+ const currentRow = this.currentMatchRow();
914
+ const blockRow = currentRow === undefined ? -1 : currentRow - start;
822
915
  this.openBlockIndex = index;
823
916
  this.cachedBlockBody = undefined;
824
917
  this.previewScroller.reset();
918
+ this.search.restart(blockRow >= 0 && blockRow < blockLineCount ? blockRow : 0);
825
919
  this.clearCache();
826
920
  }
827
921
 
828
- /** Return to the block stream with the same selected block. */
922
+ /** Return to the block stream with the same selected block, keeping the search on it. */
829
923
  private closeBlock(): void {
924
+ const index = this.openBlockIndex ?? 0;
830
925
  this.openBlockIndex = undefined;
831
926
  this.cachedBlockBody = undefined;
927
+ this.search.restart(this.cachedStreamSearch?.starts[index] ?? 0);
832
928
  this.clearCache();
833
929
  }
834
930
 
931
+ /** First row of the current match in the lines the search last saw. */
932
+ private currentMatchRow(): number | undefined {
933
+ const current = this.search.current;
934
+ return current === undefined ? undefined : this.search.matches[current]?.segments[0]?.row;
935
+ }
936
+
835
937
  /** Drop width- and theme-dependent preview rendering. */
836
938
  private clearPreviewContent(): void {
837
939
  this.cachedContent = undefined;
838
940
  this.cachedStream = undefined;
839
941
  this.cachedBlockBody = undefined;
942
+ this.cachedStreamSearch = undefined;
943
+ this.cachedContentSearch = undefined;
840
944
  }
841
945
 
842
946
  /** Scrollable chronological block stream for one category. */
843
947
  private renderPreview(width: number, terminalRows: number, row: CategoryLegendRow): string[] {
844
948
  const theme = this.theme;
845
949
  const border = theme.fg("border", "─".repeat(Math.max(1, width)));
846
- const descriptionLines = this.previewDescriptionLines(width, terminalRows, row);
847
- const descriptionLineCount = descriptionBlockRows(descriptionLines);
848
- const stream = this.previewStream(width, previewBlockMaxLines(terminalRows, descriptionLineCount), row);
950
+ const searchRowCount = this.searchRowCount();
951
+ const descriptionLines = this.previewDescriptionLines(width, terminalRows - searchRowCount, row);
952
+ const reservedLineCount = descriptionBlockRows(descriptionLines) + searchRowCount;
953
+ const stream = this.previewStream(width, previewBlockMaxLines(terminalRows, reservedLineCount), row);
849
954
  const viewport = calculateViewport(
850
955
  Math.max(1, stream.layout.lines.length),
851
956
  terminalRows,
852
957
  PREVIEW_FIXED_LINE_COUNT,
853
- descriptionLineCount,
958
+ reservedLineCount,
854
959
  );
855
960
  this.blockNavigator.setExtent(stream.layout, viewport.visibleCount);
961
+ const searchLines = this.streamSearch(width, row);
962
+ const revealRow = this.search.sync(searchLines.lines);
963
+ if (revealRow !== undefined) this.blockNavigator.select(blockAtRow(searchLines.starts, revealRow));
856
964
 
857
- const lines: string[] = [border, "", this.categoryHeaderLine(row, width), ""];
965
+ const lines: string[] = [border, "", this.categoryHeaderLine(row, width), "", ...this.searchPromptLines(width)];
858
966
  lines.push(...this.previewStreamLines(stream, viewport.visibleCount, width));
859
967
  if (viewport.showScroll) {
860
968
  lines.push(
@@ -866,11 +974,67 @@ export class UsageView {
866
974
  }
867
975
  if (descriptionLines.length > 0) lines.push("", ...descriptionLines);
868
976
  lines.push("");
869
- lines.push(this.fit(hintRow(theme, previewHints(stream.blocks.length)), width));
977
+ lines.push(fitHintRow(theme, this.streamHints(stream.blocks.length), width));
870
978
  lines.push("", border);
871
979
  return fitToTerminalHeight(lines, terminalRows, border);
872
980
  }
873
981
 
982
+ /** Rows the search prompt and its trailing blank row take while a query is typed or kept. */
983
+ private searchRowCount(): number {
984
+ return this.search.prompt.editing || this.search.prompt.active ? 2 : 0;
985
+ }
986
+
987
+ /** Search prompt row and its trailing blank row, rendered after the frame's matches are known. */
988
+ private searchPromptLines(width: number): string[] {
989
+ if (this.searchRowCount() === 0) return [];
990
+ return [this.search.prompt.render(this.theme, width, "Search", this.search.status), ""];
991
+ }
992
+
993
+ /** Cached search lines of the open stream; rebuilt with the content they flatten. */
994
+ private streamSearch(width: number, row: CategoryLegendRow): StreamSearchLines {
995
+ const entries = this.previewContent(width, row);
996
+ if (this.cachedStreamSearch?.entries === entries) return this.cachedStreamSearch;
997
+ const lines: string[] = [];
998
+ const starts: number[] = [];
999
+ this.previewEntries(row).forEach((entry, index) => {
1000
+ if (index > 0) lines.push("");
1001
+ starts.push(lines.length);
1002
+ lines.push(this.entryHeader(entry), ...entries[index] ?? []);
1003
+ });
1004
+ this.cachedStreamSearch = { entries, lines, starts };
1005
+ return this.cachedStreamSearch;
1006
+ }
1007
+
1008
+ /** Cached full-content search lines, rebuilt with the body they cover. */
1009
+ private contentSearch(body: readonly string[], header: string | undefined): ContentSearchLines {
1010
+ const cached = this.cachedContentSearch;
1011
+ if (cached?.body === body && cached.header === header) return cached;
1012
+ const lines = header === undefined ? body : [header, ...body];
1013
+ this.cachedContentSearch = { body, header, lines };
1014
+ return this.cachedContentSearch;
1015
+ }
1016
+
1017
+ /** Block stream hints; an empty stream moves nowhere and has nothing to search. */
1018
+ private streamHints(blockCount: number): Hint[] {
1019
+ if (blockCount === 0) return [["Esc", "Back"]];
1020
+ return this.searchHints("Navigate");
1021
+ }
1022
+
1023
+ /** Hints for plain navigation, an open search prompt, or a kept search. */
1024
+ private searchHints(stepLabel: string): Hint[] {
1025
+ const prompt = this.search.prompt;
1026
+ if (prompt.editing) return [["↑↓", stepLabel], ["Enter", "Done"], ["Esc", "Clear"]];
1027
+ if (prompt.active) {
1028
+ return [
1029
+ [STEP_KEY_HINT, stepLabel],
1030
+ [MATCH_STEP_KEY_HINT, "Next/Prev"],
1031
+ [SEARCH_KEY, "Search", true],
1032
+ ["Esc", "Back"],
1033
+ ];
1034
+ }
1035
+ return [[STEP_KEY_HINT, stepLabel], ["PgUp/PgDn", "Page"], [SEARCH_KEY, "Search", true], ["Esc", "Back"]];
1036
+ }
1037
+
874
1038
  /** Full content with an identity header unless the System Prompt category already identifies it. */
875
1039
  private renderContentView(
876
1040
  width: number,
@@ -882,7 +1046,8 @@ export class UsageView {
882
1046
  const border = theme.fg("border", "─".repeat(Math.max(1, width)));
883
1047
  const body = this.blockBodyLines(width, row, entry);
884
1048
  const showEntryHeader = row.rootId !== "system-prompt" || this.openBlockIndex !== undefined;
885
- const fixedLineCount = showEntryHeader ? BLOCK_FIXED_LINE_COUNT : PREVIEW_FIXED_LINE_COUNT;
1049
+ const fixedLineCount = (showEntryHeader ? BLOCK_FIXED_LINE_COUNT : PREVIEW_FIXED_LINE_COUNT) +
1050
+ this.searchRowCount();
886
1051
  const descriptionLines = row.rootId === "assistant-thinking" && this.openBlockIndex === undefined
887
1052
  ? this.thinkingDescriptionLines(width, row)
888
1053
  : previewLegendLines(theme, [entry], {
@@ -894,17 +1059,27 @@ export class UsageView {
894
1059
  body.length, terminalRows, fixedLineCount, descriptionBlockRows(descriptionLines),
895
1060
  );
896
1061
  this.previewScroller.setExtent(body.length, viewport.visibleCount);
1062
+ const searchLines = this.contentSearch(body, showEntryHeader ? this.entryHeader(entry) : undefined);
1063
+ // Body rows follow the header row when it is searched.
1064
+ const bodyRow = searchLines.header === undefined ? 0 : 1;
1065
+ const revealLine = this.search.sync(searchLines.lines);
1066
+ if (revealLine !== undefined) {
1067
+ this.previewScroller.reveal(Math.max(0, revealLine - bodyRow), SEARCH_CONTEXT_LINES);
1068
+ }
897
1069
 
898
1070
  const lines: string[] = [
899
1071
  border,
900
1072
  "",
901
1073
  this.categoryHeaderLine(row, width),
902
1074
  "",
903
- ...(showEntryHeader ? [this.fit(`${BODY_INDENT}${this.entryHeader(entry)}`, width), ""] : []),
1075
+ ...this.searchPromptLines(width),
1076
+ ...(searchLines.header === undefined
1077
+ ? []
1078
+ : [this.fit(`${BODY_INDENT}${this.search.highlight(theme, searchLines.header, 0)}`, width), ""]),
904
1079
  ];
905
1080
  const start = this.previewScroller.offset;
906
1081
  for (let index = start; index < start + viewport.visibleCount; index++) {
907
- lines.push(body[index] ?? "");
1082
+ lines.push(this.search.highlight(theme, body[index] ?? "", index + bodyRow));
908
1083
  }
909
1084
  if (viewport.showScroll) {
910
1085
  lines.push(
@@ -913,16 +1088,7 @@ export class UsageView {
913
1088
  }
914
1089
  if (descriptionLines.length > 0) lines.push("", ...descriptionLines);
915
1090
  lines.push("");
916
- lines.push(
917
- this.fit(
918
- hintRow(theme, [
919
- [STEP_KEY_HINT, "Scroll"],
920
- ["PgUp/PgDn", "Page"],
921
- ["Esc", "Back"],
922
- ]),
923
- width,
924
- ),
925
- );
1091
+ lines.push(fitHintRow(theme, this.searchHints("Scroll"), width));
926
1092
  lines.push("", border);
927
1093
  return fitToTerminalHeight(lines, terminalRows, border);
928
1094
  }
@@ -963,13 +1129,19 @@ export class UsageView {
963
1129
  const block = stream.blocks[ref.blockIndex];
964
1130
  if (block === undefined) return "";
965
1131
  const selected = ref.blockIndex === this.blockNavigator.selected;
1132
+ const start = this.cachedStreamSearch?.starts[ref.blockIndex];
966
1133
  const line = ref.lineIndex < block.lines.length
967
- ? block.lines[ref.lineIndex] ?? ""
1134
+ ? this.highlightStreamLine(block.lines[ref.lineIndex] ?? "", start, ref.lineIndex)
968
1135
  : this.truncationMarker(block.hiddenLineCount, selected);
969
1136
  const gutter = this.blockGutter(selected, line === "");
970
1137
  return this.fit(`${gutter}${line}`, width);
971
1138
  }
972
1139
 
1140
+ /** Highlight one visible stream line through its row in the flattened search lines. */
1141
+ private highlightStreamLine(line: string, blockStart: number | undefined, lineIndex: number): string {
1142
+ return blockStart === undefined ? line : this.search.highlight(this.theme, line, blockStart + lineIndex);
1143
+ }
1144
+
973
1145
  /** Accent bar marking the selected block, or the plain two-column indent. */
974
1146
  private blockGutter(selected: boolean, blankLine: boolean): string {
975
1147
  if (!selected) return blankLine ? "" : BODY_INDENT;
@@ -1205,10 +1377,11 @@ function previewBlockMaxLines(terminalRows: number, descriptionLineCount: number
1205
1377
  return Math.max(PREVIEW_BLOCK_MIN_LINES, Math.min(PREVIEW_BLOCK_MAX_LINES, fitted));
1206
1378
  }
1207
1379
 
1208
- /** Block stream hints; the selected block carries the open affordance, and an empty stream moves nowhere. */
1209
- function previewHints(blockCount: number): Array<readonly [string, string]> {
1210
- if (blockCount === 0) return [["Esc", "Back"]];
1211
- return [[STEP_KEY_HINT, "Navigate"], ["PgUp/PgDn", "Page"], ["Esc", "Back"]];
1380
+ /** Block holding one row of the flattened stream search lines. */
1381
+ function blockAtRow(starts: readonly number[], row: number): number {
1382
+ let index = 0;
1383
+ while (index + 1 < starts.length && (starts[index + 1] ?? Infinity) <= row) index++;
1384
+ return index;
1212
1385
  }
1213
1386
 
1214
1387
  /** Stream lines one block occupies, including its truncation marker row. */