@oh-my-pi/pi-tui 18.7.0 → 18.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 (79) hide show
  1. package/CHANGELOG.md +21 -52
  2. package/dist/types/apps/debug/log-viewer.d.ts +4 -0
  3. package/dist/types/apps/debug/raw-sse-buffer.d.ts +2 -0
  4. package/dist/types/chat/fence-figure.d.ts +42 -0
  5. package/dist/types/chat/svg-figure.d.ts +1 -1
  6. package/dist/types/chat/tool-execution.d.ts +2 -0
  7. package/dist/types/components/editor.d.ts +8 -3
  8. package/dist/types/components/markdown.d.ts +11 -6
  9. package/dist/types/fuzzy.d.ts +17 -3
  10. package/dist/types/native/backend.d.ts +2 -1
  11. package/dist/types/native/blobs.d.ts +4 -2
  12. package/dist/types/native/encode.d.ts +3 -4
  13. package/dist/types/overlays/agent-transcript-viewer.d.ts +8 -1
  14. package/dist/types/overlays/history-search.d.ts +10 -0
  15. package/dist/types/overlays/model-browser.d.ts +6 -0
  16. package/dist/types/prompt/custom-editor.d.ts +7 -4
  17. package/dist/types/prompt/interactive-context-helpers.d.ts +2 -1
  18. package/dist/types/tools/renderer.d.ts +22 -0
  19. package/dist/types/tools/streaming-output.d.ts +11 -1
  20. package/dist/types/tools/write.d.ts +8 -1
  21. package/package.json +9 -9
  22. package/src/apps/debug/log-viewer.ts +188 -72
  23. package/src/apps/debug/raw-sse-buffer.ts +8 -0
  24. package/src/apps/debug/raw-sse.ts +39 -5
  25. package/src/apps/git/diff-pane.ts +26 -5
  26. package/src/autocomplete.ts +59 -33
  27. package/src/chat/assistant-message.ts +30 -18
  28. package/src/chat/fence-figure.ts +80 -0
  29. package/src/chat/svg-figure.ts +2 -4
  30. package/src/chat/tool-execution.ts +53 -4
  31. package/src/components/editor.ts +16 -3
  32. package/src/components/markdown.ts +80 -307
  33. package/src/fuzzy.ts +59 -14
  34. package/src/native/backend.ts +170 -15
  35. package/src/native/blobs.ts +4 -2
  36. package/src/native/encode.ts +58 -31
  37. package/src/overlays/agent-hub-projection.ts +70 -2
  38. package/src/overlays/agent-hub.ts +3 -2
  39. package/src/overlays/agent-transcript-viewer.ts +64 -13
  40. package/src/overlays/agents-hub.ts +14 -7
  41. package/src/overlays/copy-selector.ts +30 -8
  42. package/src/overlays/extensions/state-manager.ts +52 -12
  43. package/src/overlays/history-search.ts +100 -7
  44. package/src/overlays/jobs-panel.ts +30 -2
  45. package/src/overlays/model-browser.ts +107 -21
  46. package/src/overlays/model-hub.ts +34 -7
  47. package/src/overlays/oauth-selector.ts +41 -26
  48. package/src/overlays/plan-review-overlay.ts +142 -64
  49. package/src/overlays/rewind-selector.ts +86 -17
  50. package/src/overlays/session-selector.ts +24 -4
  51. package/src/overlays/settings-selector.ts +21 -6
  52. package/src/overlays/tree-selector.ts +20 -4
  53. package/src/overlays/usage-dashboard.ts +12 -15
  54. package/src/prompt/attachment-chips.ts +2 -1
  55. package/src/prompt/composer-attachments.ts +6 -4
  56. package/src/prompt/custom-editor.ts +48 -19
  57. package/src/prompt/interactive-context-helpers.ts +27 -18
  58. package/src/prompt/macos-spelling.ts +7 -0
  59. package/src/prompt/magic-keywords.ts +11 -2
  60. package/src/prompt/word-completion.ts +38 -5
  61. package/src/setup/scenes/splash.ts +109 -45
  62. package/src/status-line/component.ts +220 -64
  63. package/src/status-line/segments.ts +5 -2
  64. package/src/terminal.ts +3 -2
  65. package/src/tools/ast-edit.ts +59 -35
  66. package/src/tools/ast-grep.ts +35 -16
  67. package/src/tools/default-renderer.ts +35 -19
  68. package/src/tools/eval-format/index.ts +28 -2
  69. package/src/tools/eval-format/javascript.ts +7 -5
  70. package/src/tools/eval.ts +164 -78
  71. package/src/tools/grep.ts +81 -48
  72. package/src/tools/lsp.ts +81 -59
  73. package/src/tools/mcp.ts +98 -68
  74. package/src/tools/renderer.ts +27 -0
  75. package/src/tools/streaming-output.ts +120 -48
  76. package/src/tools/task.ts +111 -54
  77. package/src/tools/todo.ts +115 -29
  78. package/src/tools/wait.ts +34 -24
  79. package/src/tools/write.ts +77 -39
@@ -21,6 +21,7 @@ import {
21
21
  type Component,
22
22
  Ellipsis,
23
23
  Editor,
24
+ getWidthConfigEpoch,
24
25
  Markdown,
25
26
  type MarkdownTheme,
26
27
  matchesKey,
@@ -32,7 +33,7 @@ import {
32
33
  } from "../index";
33
34
  import { sanitizeText } from "@oh-my-pi/pi-utils";
34
35
  import { sanitizeStatusText } from "../chrome/shared";
35
- import { getEditorTheme, getMarkdownTheme, theme } from "../theme/theme";
36
+ import { getEditorTheme, getMarkdownTheme, getThemeEpoch, theme } from "../theme/theme";
36
37
  import {
37
38
  matchesAppExternalEditor,
38
39
  matchesSelectCancel,
@@ -76,6 +77,8 @@ const SIDEBAR_MIN_TOTAL_WIDTH = 64;
76
77
  const SIDEBAR_MIN_BODY_WIDTH = 40;
77
78
  /** Persisted line-context cap; render-time captions clamp again to the viewport. */
78
79
  const MAX_ANNOTATION_CONTEXT_WIDTH = 120;
80
+ /** Oldest delete/annotate undo snapshots are dropped past this depth (each holds the full plan text). */
81
+ const MAX_UNDO_ENTRIES = 100;
79
82
 
80
83
  type Focus = "toc" | "body" | "actions";
81
84
 
@@ -148,6 +151,78 @@ interface UndoEntry {
148
151
  deleted: string[];
149
152
  }
150
153
 
154
+ /** Concatenated plan body rows and their anchors, reused while every render input is unchanged. */
155
+ interface BodyLayout {
156
+ width: number;
157
+ annotationRev: number;
158
+ themeEpoch: number;
159
+ widthEpoch: number;
160
+ /** Each section's `Markdown.render` result the layout was built from (compared by identity). */
161
+ rendered: readonly (readonly string[])[];
162
+ lines: readonly string[];
163
+ anchors: BodyRowAnchor[];
164
+ offsets: number[];
165
+ }
166
+
167
+ /** Line contexts per rendered-lines array; `Markdown.render` returns a stable array per (text, width). */
168
+ const lineContextCache = new WeakMap<readonly string[], readonly LineAnchorContext[]>();
169
+ /** Match keys per context list, so resolving several annotations normalizes each row once. */
170
+ const normalizedContextCache = new WeakMap<readonly LineAnchorContext[], readonly string[]>();
171
+
172
+ function lineContexts(rendered: readonly string[]): readonly LineAnchorContext[] {
173
+ let contexts = lineContextCache.get(rendered);
174
+ if (!contexts) {
175
+ contexts = rendered.map(line => {
176
+ const sanitized = sanitizeStatusText(line);
177
+ const truncated = visibleWidth(sanitized) > MAX_ANNOTATION_CONTEXT_WIDTH;
178
+ const text = truncateToWidth(sanitized, MAX_ANNOTATION_CONTEXT_WIDTH, Ellipsis.Unicode);
179
+ return { text: text || "(blank line)", truncated };
180
+ });
181
+ lineContextCache.set(rendered, contexts);
182
+ }
183
+ return contexts;
184
+ }
185
+
186
+ function normalizeLineContext(context: LineAnchorContext): string {
187
+ const normalized = sanitizeStatusText(context.text).replace(/\s+/g, " ").trim();
188
+ return context.truncated && normalized.endsWith("…") ? normalized.slice(0, -1) : normalized;
189
+ }
190
+
191
+ /** Row in `contexts` nearest `storedRow` whose text matches the stored context, or -1. */
192
+ function resolveLineRow(
193
+ storedRow: number,
194
+ storedContext: LineAnchorContext,
195
+ contexts: readonly LineAnchorContext[],
196
+ ): number {
197
+ if (contexts.length === 0) return -1;
198
+ const targetRow = Math.max(0, Math.floor(storedRow));
199
+ const normalizedStoredContext = normalizeLineContext(storedContext);
200
+ if (!normalizedStoredContext) return -1;
201
+ let normalizedContexts = normalizedContextCache.get(contexts);
202
+ if (!normalizedContexts) {
203
+ normalizedContexts = contexts.map(normalizeLineContext);
204
+ normalizedContextCache.set(contexts, normalizedContexts);
205
+ }
206
+ let best = -1;
207
+ let bestDistance = Number.POSITIVE_INFINITY;
208
+ for (let row = 0; row < normalizedContexts.length; row++) {
209
+ const normalizedContext = normalizedContexts[row]!;
210
+ if (
211
+ normalizedContext !== normalizedStoredContext &&
212
+ !normalizedContext.includes(normalizedStoredContext) &&
213
+ !normalizedStoredContext.includes(normalizedContext)
214
+ ) {
215
+ continue;
216
+ }
217
+ const distance = Math.abs(row - targetRow);
218
+ if (distance < bestDistance) {
219
+ best = row;
220
+ bestDistance = distance;
221
+ }
222
+ }
223
+ return best;
224
+ }
225
+
151
226
  export interface PlanReviewOverlayCallbacks {
152
227
  /** Invoked with the chosen option label (never a disabled one). */
153
228
  onPick: (label: string) => void;
@@ -199,6 +274,10 @@ export class PlanReviewOverlay implements Component {
199
274
  #sectionOffsets: number[] = [];
200
275
  /** Rendered body row to underlying plan row; callouts retain their owner's anchor. */
201
276
  #bodyRowAnchors: BodyRowAnchor[] = [];
277
+ /** Memoized {@link #buildBody} result. */
278
+ #bodyLayout: BodyLayout | undefined;
279
+ /** Lines last handed to the scroll view; unchanged layouts skip the copy and keep its render cache. */
280
+ #scrollLines: readonly string[] | undefined;
202
281
  #undo: UndoEntry[] = [];
203
282
  /** Titles of sections deleted in the overlay, surfaced as Refine feedback. */
204
283
  #deleted: string[] = [];
@@ -444,8 +523,8 @@ export class PlanReviewOverlay implements Component {
444
523
  ) {
445
524
  continue;
446
525
  }
447
- const contexts = section.md.render(MAX_ANNOTATION_CONTEXT_WIDTH).map(line => this.#lineContext(line));
448
- const row = this.#resolveLineRow(
526
+ const contexts = lineContexts(section.md.render(MAX_ANNOTATION_CONTEXT_WIDTH));
527
+ const row = resolveLineRow(
449
528
  entry.target.row,
450
529
  { text: entry.target.context, truncated: entry.target.contextTruncated === true },
451
530
  contexts,
@@ -866,6 +945,7 @@ export class PlanReviewOverlay implements Component {
866
945
  }
867
946
 
868
947
  #pushUndo(): void {
948
+ if (this.#undo.length >= MAX_UNDO_ENTRIES) this.#undo.shift();
869
949
  this.#undo.push({
870
950
  text: joinPlanSections(this.#sections),
871
951
  annotations: this.#sections.map(section =>
@@ -959,7 +1039,7 @@ export class PlanReviewOverlay implements Component {
959
1039
  seenRows.add(candidate.row);
960
1040
  sectionAnchors.push({ text: candidate.context, truncated: candidate.contextTruncated });
961
1041
  }
962
- const resolvedRow = this.#resolveLineRow(
1042
+ const resolvedRow = resolveLineRow(
963
1043
  annotation.target.row,
964
1044
  { text: annotation.target.context, truncated: annotation.target.contextTruncated },
965
1045
  sectionAnchors,
@@ -1187,23 +1267,64 @@ export class PlanReviewOverlay implements Component {
1187
1267
 
1188
1268
  #layoutBody(lines: readonly string[], height: number): void {
1189
1269
  this.#captureScrollProgress();
1190
- this.#scrollView.setLines(lines);
1270
+ if (lines !== this.#scrollLines) {
1271
+ this.#scrollView.setLines(lines);
1272
+ this.#scrollLines = lines;
1273
+ }
1191
1274
  this.#scrollView.setHeight(height);
1192
1275
  const maxOffset = this.#scrollView.getMaxScrollOffset();
1193
1276
  if (maxOffset > 0) this.#scrollView.setScrollOffset(Math.round(this.#scrollProgress * maxOffset));
1194
1277
  }
1195
1278
 
1196
- /** Build the concatenated body lines and map each rendered row back to a plan anchor. */
1197
- #buildBody(bodyContentWidth: number): string[] {
1279
+ /**
1280
+ * Build the concatenated body lines and map each rendered row back to a plan
1281
+ * anchor. Memoized on width, annotation revision, theme/width epochs and each
1282
+ * section's rendered-lines identity, so steady frames and scrolling reuse it.
1283
+ */
1284
+ #buildBody(bodyContentWidth: number): readonly string[] {
1285
+ const themeEpoch = getThemeEpoch();
1286
+ const widthEpoch = getWidthConfigEpoch();
1287
+ const cached = this.#bodyLayout;
1288
+ if (
1289
+ cached?.width === bodyContentWidth &&
1290
+ cached.annotationRev === this.#annotationRev &&
1291
+ cached.themeEpoch === themeEpoch &&
1292
+ cached.widthEpoch === widthEpoch &&
1293
+ cached.rendered.length === this.#sections.length &&
1294
+ this.#sections.every((section, i) => section.md.render(bodyContentWidth) === cached.rendered[i])
1295
+ ) {
1296
+ this.#sectionOffsets = cached.offsets;
1297
+ this.#bodyRowAnchors = cached.anchors;
1298
+ return cached.lines;
1299
+ }
1198
1300
  const lines: string[] = [];
1199
1301
  const anchors: BodyRowAnchor[] = [];
1302
+ const renderedSections: (readonly string[])[] = [];
1200
1303
  // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
1201
1304
  const offsets: number[] = new Array(this.#sections.length);
1202
1305
  for (let sectionIndex = 0; sectionIndex < this.#sections.length; sectionIndex++) {
1203
1306
  const section = this.#sections[sectionIndex]!;
1204
1307
  offsets[sectionIndex] = lines.length;
1205
1308
  const rendered = section.md.render(bodyContentWidth);
1206
- const contexts = rendered.map(line => this.#lineContext(line));
1309
+ renderedSections.push(rendered);
1310
+ const contexts = lineContexts(rendered);
1311
+ // Resolve each note's row once; notes on one row keep their array order.
1312
+ let notesByRow: Map<number, string[]> | undefined;
1313
+ for (const annotation of section.annotations) {
1314
+ const annotationRow =
1315
+ annotation.target.kind === "section"
1316
+ ? 0
1317
+ : resolveLineRow(
1318
+ annotation.target.row,
1319
+ { text: annotation.target.context, truncated: annotation.target.contextTruncated },
1320
+ contexts,
1321
+ );
1322
+ if (annotationRow < 0) continue;
1323
+ notesByRow ??= new Map();
1324
+ const notes = notesByRow.get(annotationRow);
1325
+ if (notes) notes.push(annotation.note);
1326
+ else notesByRow.set(annotationRow, [annotation.note]);
1327
+ }
1207
1328
  for (let row = 0; row < rendered.length; row++) {
1208
1329
  const context = contexts[row]!;
1209
1330
  const anchor = {
@@ -1214,67 +1335,24 @@ export class PlanReviewOverlay implements Component {
1214
1335
  };
1215
1336
  lines.push(rendered[row]!);
1216
1337
  anchors.push(anchor);
1217
- for (const annotation of section.annotations) {
1218
- const annotationRow =
1219
- annotation.target.kind === "section"
1220
- ? 0
1221
- : this.#resolveLineRow(
1222
- annotation.target.row,
1223
- {
1224
- text: annotation.target.context,
1225
- truncated: annotation.target.contextTruncated,
1226
- },
1227
- contexts,
1228
- );
1229
- if (annotationRow === row) {
1230
- this.#appendAnnotationCallout(lines, anchors, annotation.note, anchor, bodyContentWidth);
1231
- }
1232
- }
1338
+ const notes = notesByRow?.get(row);
1339
+ if (!notes) continue;
1340
+ for (const note of notes) this.#appendAnnotationCallout(lines, anchors, note, anchor, bodyContentWidth);
1233
1341
  }
1234
1342
  }
1235
1343
  this.#sectionOffsets = offsets;
1236
1344
  this.#bodyRowAnchors = anchors;
1237
- return lines;
1238
- }
1239
-
1240
- #lineContext(line: string): LineAnchorContext {
1241
- const sanitized = sanitizeStatusText(line);
1242
- const truncated = visibleWidth(sanitized) > MAX_ANNOTATION_CONTEXT_WIDTH;
1243
- const text = truncateToWidth(sanitized, MAX_ANNOTATION_CONTEXT_WIDTH, Ellipsis.Unicode);
1244
- return { text: text || "(blank line)", truncated };
1245
- }
1246
-
1247
- #resolveLineRow(
1248
- storedRow: number,
1249
- storedContext: LineAnchorContext,
1250
- contexts: readonly LineAnchorContext[],
1251
- ): number {
1252
- if (contexts.length === 0) return -1;
1253
- const targetRow = Math.max(0, Math.floor(storedRow));
1254
- const normalize = (context: LineAnchorContext): string => {
1255
- const normalized = sanitizeStatusText(context.text).replace(/\s+/g, " ").trim();
1256
- return context.truncated && normalized.endsWith("…") ? normalized.slice(0, -1) : normalized;
1345
+ this.#bodyLayout = {
1346
+ width: bodyContentWidth,
1347
+ annotationRev: this.#annotationRev,
1348
+ themeEpoch,
1349
+ widthEpoch,
1350
+ rendered: renderedSections,
1351
+ lines,
1352
+ anchors,
1353
+ offsets,
1257
1354
  };
1258
- const normalizedStoredContext = normalize(storedContext);
1259
- if (!normalizedStoredContext) return -1;
1260
- let best = -1;
1261
- let bestDistance = Number.POSITIVE_INFINITY;
1262
- for (let row = 0; row < contexts.length; row++) {
1263
- const normalizedContext = normalize(contexts[row]!);
1264
- if (
1265
- normalizedContext !== normalizedStoredContext &&
1266
- !normalizedContext.includes(normalizedStoredContext) &&
1267
- !normalizedStoredContext.includes(normalizedContext)
1268
- ) {
1269
- continue;
1270
- }
1271
- const distance = Math.abs(row - targetRow);
1272
- if (distance < bestDistance) {
1273
- best = row;
1274
- bestDistance = distance;
1275
- }
1276
- }
1277
- return best;
1355
+ return lines;
1278
1356
  }
1279
1357
 
1280
1358
  #appendAnnotationCallout(
@@ -157,6 +157,14 @@ const STRIP_GAP = 2;
157
157
  /** Duration of the branch-swap camera slide. */
158
158
  const SLIDE_MS = 160;
159
159
 
160
+ /** Same length and identical elements (or both undefined). */
161
+ function sameElements<T>(a: readonly T[] | undefined, b: readonly T[] | undefined): boolean {
162
+ if (a === b) return true;
163
+ if (a === undefined || b === undefined || a.length !== b.length) return false;
164
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
165
+ return true;
166
+ }
167
+
160
168
  export class RewindSelectorComponent implements Component {
161
169
  #builder: ChatTranscriptBuilder;
162
170
  #browser: TranscriptBrowser;
@@ -195,6 +203,24 @@ export class RewindSelectorComponent implements Component {
195
203
  #rowText = new WeakMap<readonly string[], string>();
196
204
  /** Native filter haystack: lowercased turn text per main target (no rendered rows under TSP). */
197
205
  #nativeTexts: { targets: OutlineTarget[]; texts: string[] } | undefined;
206
+ /** Compiled word patterns of the last filter query. */
207
+ #filterPatterns: { query: string; patterns: RegExp[] } | undefined;
208
+ /**
209
+ * Last {@link #filterMatches} result and its inputs. Frames hand back fresh
210
+ * outer `mainRows`/`mainVisible` arrays over cached per-child rows, so those
211
+ * compare element-wise.
212
+ */
213
+ #filterMemo:
214
+ | {
215
+ query: string;
216
+ targets: OutlineTarget[];
217
+ expanded: boolean;
218
+ native: boolean;
219
+ mainRows: readonly (readonly string[])[];
220
+ mainVisible: readonly boolean[] | undefined;
221
+ matches: readonly number[];
222
+ }
223
+ | undefined;
198
224
  /** Last described bar and the state it was built from. */
199
225
  #bar: { memo: string; targets: OutlineTarget[]; node: NativeNode } | undefined;
200
226
 
@@ -528,14 +554,25 @@ export class RewindSelectorComponent implements Component {
528
554
  * Matching the rendered rows keeps results honest: collapsed tool output
529
555
  * only matches once Ctrl+O expands it.
530
556
  */
531
- #filterMatches(): number[] {
532
- const words = (this.#filter ?? "").toLowerCase().split(/\s+/).filter(Boolean);
533
- const patterns = words.map(word =>
534
- /^[\p{Script=Latin}\p{N}_]+$/u.test(word)
535
- ? new RegExp(`(?<![\\p{L}\\p{N}_])${RegExp.escape(word)}(?![\\p{L}\\p{N}_])`, "u")
536
- : new RegExp(RegExp.escape(word), "u"),
537
- );
538
- if (isNativeRendering() && this.#nativeTexts?.targets !== this.#targets) {
557
+ #filterMatches(): readonly number[] {
558
+ const query = this.#filter ?? "";
559
+ const native = isNativeRendering();
560
+ const memo = this.#filterMemo;
561
+ if (
562
+ memo &&
563
+ memo.query === query &&
564
+ memo.targets === this.#targets &&
565
+ memo.expanded === this.#expanded &&
566
+ memo.native === native &&
567
+ sameElements(memo.mainVisible, this.#mainVisible) &&
568
+ (native || sameElements(memo.mainRows, this.#mainRows))
569
+ ) {
570
+ memo.mainRows = this.#mainRows;
571
+ memo.mainVisible = this.#mainVisible;
572
+ return memo.matches;
573
+ }
574
+ const patterns = this.#compileFilter(query);
575
+ if (native && this.#nativeTexts?.targets !== this.#targets) {
539
576
  this.#nativeTexts = {
540
577
  targets: this.#targets,
541
578
  // Turn text plus its commands and tool output, like the expanded rendered rows.
@@ -550,21 +587,53 @@ export class RewindSelectorComponent implements Component {
550
587
  for (let index = 0; index < this.#targets.length; index++) {
551
588
  if (!this.#isMainSelectable(index)) continue;
552
589
  const target = this.#targets[index]!;
553
- const texts = isNativeRendering()
554
- ? [this.#nativeTexts?.texts[index] ?? ""]
555
- : this.#mainRows.slice(target.start, target.end).map(rows => {
556
- let text = this.#rowText.get(rows);
557
- if (text === undefined) {
558
- text = Bun.stripANSI(rows.join("\n")).toLowerCase();
559
- this.#rowText.set(rows, text);
590
+ const matched = native
591
+ ? patterns.every(pattern => pattern.test(this.#nativeTexts?.texts[index] ?? ""))
592
+ : patterns.every(pattern => {
593
+ for (let child = target.start; child < target.end; child++) {
594
+ const rows = this.#mainRows[child];
595
+ if (rows !== undefined && pattern.test(this.#plainRowText(rows))) return true;
560
596
  }
561
- return text;
597
+ return false;
562
598
  });
563
- if (patterns.every(pattern => texts.some(text => pattern.test(text)))) matches.push(index);
599
+ if (matched) matches.push(index);
564
600
  }
601
+ this.#filterMemo = {
602
+ query,
603
+ targets: this.#targets,
604
+ expanded: this.#expanded,
605
+ native,
606
+ mainRows: this.#mainRows,
607
+ mainVisible: this.#mainVisible,
608
+ matches,
609
+ };
565
610
  return matches;
566
611
  }
567
612
 
613
+ /** Word patterns of `query`, compiled once per distinct query. */
614
+ #compileFilter(query: string): RegExp[] {
615
+ const cached = this.#filterPatterns;
616
+ if (cached?.query === query) return cached.patterns;
617
+ const words = query.toLowerCase().split(/\s+/).filter(Boolean);
618
+ const patterns = words.map(word =>
619
+ /^[\p{Script=Latin}\p{N}_]+$/u.test(word)
620
+ ? new RegExp(`(?<![\\p{L}\\p{N}_])${RegExp.escape(word)}(?![\\p{L}\\p{N}_])`, "u")
621
+ : new RegExp(RegExp.escape(word), "u"),
622
+ );
623
+ this.#filterPatterns = { query, patterns };
624
+ return patterns;
625
+ }
626
+
627
+ /** Lowercased plain text of one rendered child row array, cached by array identity. */
628
+ #plainRowText(rows: readonly string[]): string {
629
+ let text = this.#rowText.get(rows);
630
+ if (text === undefined) {
631
+ text = Bun.stripANSI(rows.join("\n")).toLowerCase();
632
+ this.#rowText.set(rows, text);
633
+ }
634
+ return text;
635
+ }
636
+
568
637
  /** Up/Down: step within the active column; leaving a sibling column's top exits the strip. */
569
638
  #moveVertical(delta: -1 | 1): void {
570
639
  if (this.#activeVariant > 0) {
@@ -179,9 +179,13 @@ function sessionPreview(session: SessionSelectorEntry, forkedFrom: string | unde
179
179
  ]);
180
180
  }
181
181
 
182
+ /** Absolute dates of week-old sessions; `toLocaleDateString` goes through Intl on every call. */
183
+ const localeDateCache = new WeakMap<Date, { time: number; text: string }>();
184
+
182
185
  /** Relative age of a session's last modification (`"3 hours ago"`), falling back to the date after a week. */
183
186
  function formatSessionDate(date: Date): string {
184
- const diffMs = Date.now() - date.getTime();
187
+ const time = date.getTime();
188
+ const diffMs = Date.now() - time;
185
189
  const diffMins = Math.floor(diffMs / 60000);
186
190
  const diffHours = Math.floor(diffMs / 3600000);
187
191
  const diffDays = Math.floor(diffMs / 86400000);
@@ -192,7 +196,11 @@ function formatSessionDate(date: Date): string {
192
196
  if (diffDays === 1) return "1 day ago";
193
197
  if (diffDays < 7) return `${diffDays} days ago`;
194
198
 
195
- return date.toLocaleDateString();
199
+ const cached = localeDateCache.get(date);
200
+ if (cached?.time === time) return cached.text;
201
+ const text = date.toLocaleDateString();
202
+ localeDateCache.set(date, { time, text });
203
+ return text;
196
204
  }
197
205
 
198
206
  /** A cached native session item and the inputs it was built from. */
@@ -545,6 +553,8 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
545
553
  #lastFilterQuery = "";
546
554
  /** Bumped whenever the visible session set may have changed (native memo key). */
547
555
  #itemsVersion = 0;
556
+ /** Per-row line heights of the visible list for the ANSI window, reused until the set changes. */
557
+ #rowHeights: { items: readonly T[]; length: number; version: number; heights: readonly number[] } | undefined;
548
558
  #itemsNative:
549
559
  | { version: number; showCwd: boolean; currentPath: string | undefined; items: NativeNode[] }
550
560
  | undefined;
@@ -1130,14 +1140,13 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
1130
1140
  // worst-case count-based window would leave (then padded by
1131
1141
  // fill-height).
1132
1142
  const filtered = this.#menu.visibleItems;
1133
- const itemHeight = (session: T): number => (session.title ? 4 : 3);
1134
1143
  const budget = this.#lineBudget();
1135
1144
  const {
1136
1145
  startIndex,
1137
1146
  endIndex,
1138
1147
  rowOffset: offsetRows,
1139
1148
  totalRows: rawTotalRows,
1140
- } = getMenuWindow(filtered.map(itemHeight), this.#menu.selectedIndex, budget);
1149
+ } = getMenuWindow(this.#visibleRowHeights(filtered), this.#menu.selectedIndex, budget);
1141
1150
 
1142
1151
  // Each session block is built into sessionLines, then wrapped by ScrollView
1143
1152
  // so the right-edge scrollbar is proportional at the physical-line level.
@@ -1232,6 +1241,17 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
1232
1241
  return lines;
1233
1242
  }
1234
1243
 
1244
+ /** Line height per visible session (3, or 4 when a title adds a preview line), memoized per visible set. */
1245
+ #visibleRowHeights(items: readonly T[]): readonly number[] {
1246
+ const cached = this.#rowHeights;
1247
+ if (cached?.items === items && cached.length === items.length && cached.version === this.#itemsVersion) {
1248
+ return cached.heights;
1249
+ }
1250
+ const heights = items.map(session => (session.title ? 4 : 3));
1251
+ this.#rowHeights = { items, length: items.length, version: this.#itemsVersion, heights };
1252
+ return heights;
1253
+ }
1254
+
1235
1255
  handleInput(keyData: string): void {
1236
1256
  // Delete key — or Backspace on an empty search query — request delete
1237
1257
  // confirmation from the parent. macOS laptops have no dedicated Forward
@@ -4,7 +4,7 @@ import {
4
4
  type Component,
5
5
  Container,
6
6
  extractPrintableText,
7
- fuzzyRank,
7
+ FuzzyCorpus,
8
8
  getKeybindings,
9
9
  getSettingItemFilterText,
10
10
  type ImageBudget,
@@ -630,6 +630,12 @@ export class SettingsSelectorComponent implements Component {
630
630
  #searchMatchCount = 0;
631
631
  /** First matching item id per tab id, for Tab-key jumps while searching. */
632
632
  #searchFirstMatch = new Map<string, string>();
633
+ /**
634
+ * Per-tab search candidates prepared once per search session. Dropped on
635
+ * search start/end and whenever a value or condition may have changed
636
+ * (`#refreshItems`, `#onSearchSettingChange`); rebuilt lazily by `#setSearchQuery`.
637
+ */
638
+ #searchCorpora: { tab: SettingTab; corpus: FuzzyCorpus<SettingItem> }[] | null = null;
633
639
  #textInputActive = false;
634
640
  #hasSectionJump = false;
635
641
  // Frame geometry from the last render, for mouse hit-testing (the
@@ -1111,8 +1117,10 @@ export class SettingsSelectorComponent implements Component {
1111
1117
 
1112
1118
  /** Rebuilds the visible items after a change made outside a list's own `onChange` (changed flags, conditions). */
1113
1119
  #refreshItems(): void {
1114
- if (this.#searchList) this.#setSearchQuery(this.#searchQuery);
1115
- else if (this.#currentTabId !== "plugins")
1120
+ if (this.#searchList) {
1121
+ this.#searchCorpora = null;
1122
+ this.#setSearchQuery(this.#searchQuery);
1123
+ } else if (this.#currentTabId !== "plugins")
1116
1124
  this.#refreshCurrentTabItems(getSettingsForTab(this.#context.settings.entries, this.#currentTabId));
1117
1125
  }
1118
1126
 
@@ -1281,6 +1289,7 @@ export class SettingsSelectorComponent implements Component {
1281
1289
  /** Swap the tab content for the global search result list. */
1282
1290
  #startSearch(initialQuery: string): void {
1283
1291
  this.#preSearchTabId = this.#currentTabId;
1292
+ this.#searchCorpora = null;
1284
1293
  this.#searchInput = new Input();
1285
1294
  this.#searchInput.prompt = "";
1286
1295
  this.#searchInput.setValue(initialQuery);
@@ -1324,13 +1333,17 @@ export class SettingsSelectorComponent implements Component {
1324
1333
  const tabResults: { tab: SettingTab; matched: SettingItem[]; bestScore: number; order: number }[] = [];
1325
1334
  this.#searchFirstMatch.clear();
1326
1335
  let total = 0;
1327
- for (const tab of SETTING_TABS) {
1336
+ this.#searchCorpora ??= SETTING_TABS.map(tab => {
1328
1337
  const candidates: SettingItem[] = [];
1329
1338
  for (const def of getSettingsForTab(this.#context.settings.entries, tab)) {
1330
1339
  const item = this.#defToItem(def);
1331
1340
  if (item) candidates.push(item);
1332
1341
  }
1333
- const ranked = fuzzyRank(candidates, query, getSettingItemFilterText);
1342
+ return { tab, corpus: new FuzzyCorpus(candidates, getSettingItemFilterText) };
1343
+ });
1344
+ for (let order = 0; order < this.#searchCorpora.length; order++) {
1345
+ const { tab, corpus } = this.#searchCorpora[order]!;
1346
+ const ranked = corpus.rank(query);
1334
1347
  const matched = ranked.map(result => result.item);
1335
1348
  counts.set(tab, matched.length);
1336
1349
  if (matched.length === 0) continue;
@@ -1339,7 +1352,7 @@ export class SettingsSelectorComponent implements Component {
1339
1352
  tab,
1340
1353
  matched,
1341
1354
  bestScore: ranked[0]?.score ?? 0,
1342
- order: SETTING_TABS.indexOf(tab),
1355
+ order,
1343
1356
  });
1344
1357
  }
1345
1358
 
@@ -1378,6 +1391,7 @@ export class SettingsSelectorComponent implements Component {
1378
1391
  const targetTab: SettingTab | "plugins" = selectedDef?.tab ?? this.#preSearchTabId;
1379
1392
 
1380
1393
  this.#searchQuery = "";
1394
+ this.#searchCorpora = null;
1381
1395
  this.#searchFirstMatch.clear();
1382
1396
  this.#searchMatchCount = 0;
1383
1397
  this.#tabs = getSettingsTabs();
@@ -1441,6 +1455,7 @@ export class SettingsSelectorComponent implements Component {
1441
1455
  }
1442
1456
  // Values feed the searchable text and condition gates may have flipped:
1443
1457
  // recompute results in place (selection is preserved by item id).
1458
+ this.#searchCorpora = null;
1444
1459
  this.#setSearchQuery(this.#searchQuery);
1445
1460
  }
1446
1461
 
@@ -2,7 +2,8 @@ import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
2
  import {
3
3
  type Component,
4
4
  Container,
5
- fuzzyMatch,
5
+ FuzzyQuery,
6
+ FuzzyText,
6
7
  Input,
7
8
  matchesKey,
8
9
  Spacer,
@@ -210,6 +211,8 @@ class TreeList implements Component {
210
211
  #multipleRoots = false;
211
212
  #activePathIds: Set<string> = new Set();
212
213
  #containsActive: Map<TreeSelectorNode, boolean> = new Map();
214
+ /** Prepared search text per node; rebuilt when the node's label changes. */
215
+ #searchTextCache = new WeakMap<TreeSelectorNode, { label: string | undefined; text: FuzzyText }>();
213
216
 
214
217
  onSelect?: (entryId: string, options: { summarize: boolean }) => void;
215
218
  onCancel?: () => void;
@@ -373,7 +376,11 @@ class TreeList implements Component {
373
376
  #buildFilter(): (node: TreeSelectorNode, row: TreeRow<TreeSelectorNode, string>) => boolean {
374
377
  const filterMode = this.#filterMode;
375
378
  const leafId = this.currentLeafId;
376
- const searchTokens = this.getSearchQuery().toLowerCase().split(/\s+/).filter(Boolean);
379
+ const searchTokens = this.getSearchQuery()
380
+ .toLowerCase()
381
+ .split(/\s+/)
382
+ .filter(Boolean)
383
+ .map(token => new FuzzyQuery(token));
377
384
  return node => {
378
385
  const entry = node.entry;
379
386
  const isCurrentLeaf = entry.id === leafId;
@@ -435,14 +442,23 @@ class TreeList implements Component {
435
442
 
436
443
  // Apply fuzzy search filter
437
444
  if (searchTokens.length > 0) {
438
- const nodeText = this.#getSearchableText(node);
439
- return searchTokens.every(token => fuzzyMatch(token, nodeText).matches);
445
+ const nodeText = this.#getFuzzyText(node);
446
+ return searchTokens.every(token => token.match(nodeText).matches);
440
447
  }
441
448
 
442
449
  return true;
443
450
  };
444
451
  }
445
452
 
453
+ /** Prepared fuzzy text for a node's searchable content, built once per label. */
454
+ #getFuzzyText(node: TreeSelectorNode): FuzzyText {
455
+ const cached = this.#searchTextCache.get(node);
456
+ if (cached !== undefined && cached.label === node.label) return cached.text;
457
+ const text = new FuzzyText(this.#getSearchableText(node));
458
+ this.#searchTextCache.set(node, { label: node.label, text });
459
+ return text;
460
+ }
461
+
446
462
  /** Get searchable text content from a node */
447
463
  #getSearchableText(node: TreeSelectorNode): string {
448
464
  const entry = node.entry;