@prometheus-ai/tui 0.5.4 → 0.5.8

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 (55) hide show
  1. package/dist/types/autocomplete.d.ts +3 -1
  2. package/dist/types/components/box.d.ts +1 -1
  3. package/dist/types/components/editor.d.ts +35 -2
  4. package/dist/types/components/image.d.ts +22 -3
  5. package/dist/types/components/input.d.ts +6 -1
  6. package/dist/types/components/loader.d.ts +9 -2
  7. package/dist/types/components/markdown.d.ts +3 -1
  8. package/dist/types/components/scroll-view.d.ts +23 -1
  9. package/dist/types/components/select-list.d.ts +19 -1
  10. package/dist/types/components/settings-list.d.ts +87 -7
  11. package/dist/types/components/spacer.d.ts +1 -1
  12. package/dist/types/components/tab-bar.d.ts +37 -4
  13. package/dist/types/components/text.d.ts +2 -2
  14. package/dist/types/components/truncated-text.d.ts +1 -1
  15. package/dist/types/fuzzy.d.ts +10 -1
  16. package/dist/types/index.d.ts +1 -0
  17. package/dist/types/keybindings.d.ts +5 -3
  18. package/dist/types/keys.d.ts +1 -1
  19. package/dist/types/kill-ring.d.ts +0 -7
  20. package/dist/types/kitty-graphics.d.ts +16 -31
  21. package/dist/types/loop-watchdog.d.ts +39 -0
  22. package/dist/types/mouse.d.ts +41 -0
  23. package/dist/types/stdin-buffer.d.ts +17 -0
  24. package/dist/types/terminal-capabilities.d.ts +74 -18
  25. package/dist/types/terminal.d.ts +34 -36
  26. package/dist/types/tui.d.ts +191 -79
  27. package/dist/types/utils.d.ts +5 -2
  28. package/package.json +4 -4
  29. package/src/autocomplete.ts +79 -65
  30. package/src/components/box.ts +43 -63
  31. package/src/components/editor.ts +471 -136
  32. package/src/components/image.ts +85 -9
  33. package/src/components/input.ts +12 -3
  34. package/src/components/loader.ts +35 -21
  35. package/src/components/markdown.ts +174 -53
  36. package/src/components/scroll-view.ts +63 -2
  37. package/src/components/select-list.ts +233 -38
  38. package/src/components/settings-list.ts +626 -64
  39. package/src/components/spacer.ts +9 -5
  40. package/src/components/tab-bar.ts +153 -28
  41. package/src/components/text.ts +6 -2
  42. package/src/components/truncated-text.ts +10 -2
  43. package/src/fuzzy.ts +214 -59
  44. package/src/index.ts +3 -1
  45. package/src/keybindings.ts +72 -14
  46. package/src/keys.ts +1 -1
  47. package/src/kill-ring.ts +5 -0
  48. package/src/kitty-graphics.ts +2 -101
  49. package/src/loop-watchdog.ts +106 -0
  50. package/src/mouse.ts +55 -0
  51. package/src/stdin-buffer.ts +291 -81
  52. package/src/terminal-capabilities.ts +206 -168
  53. package/src/terminal.ts +367 -110
  54. package/src/tui.ts +2102 -1729
  55. package/src/utils.ts +92 -60
@@ -1,3 +1,4 @@
1
+ import { matchesKey } from "../keys";
1
2
  import type { Component } from "../tui";
2
3
  import { Ellipsis, replaceTabs, truncateToWidth, visibleWidth } from "../utils";
3
4
 
@@ -20,6 +21,18 @@ export interface ScrollViewOptions {
20
21
  theme?: ScrollViewTheme;
21
22
  trackChar?: string;
22
23
  thumbChar?: string;
24
+ /**
25
+ * Indicator appended when a row overflows `contentWidth`. Defaults to
26
+ * {@link Ellipsis.Unicode}. Pass {@link Ellipsis.Omit} when callers wrap
27
+ * lines to width themselves and only trailing padding can overflow (e.g.
28
+ * the plan-review overlay), so no stray `…` lands on every padded row.
29
+ */
30
+ ellipsis?: Ellipsis;
31
+ /**
32
+ * Rows moved per keystroke when {@link ScrollView.handleScrollKey} sees a
33
+ * Shift+Arrow (the "scroll faster" affordance). Defaults to 5.
34
+ */
35
+ fastScrollLines?: number;
23
36
  }
24
37
 
25
38
  function normalizeScrollbarMode(scrollbar: ScrollViewOptions["scrollbar"]): ScrollbarMode {
@@ -48,6 +61,8 @@ export class ScrollView implements Component {
48
61
  #theme: Required<ScrollViewTheme>;
49
62
  #trackChar: string;
50
63
  #thumbChar: string;
64
+ #ellipsis: Ellipsis;
65
+ #fastScrollLines: number;
51
66
 
52
67
  constructor(lines: readonly string[], options: ScrollViewOptions) {
53
68
  this.#lines = [...lines];
@@ -60,6 +75,8 @@ export class ScrollView implements Component {
60
75
  };
61
76
  this.#trackChar = firstCellGlyph(options.trackChar ?? DEFAULT_TRACK, DEFAULT_TRACK);
62
77
  this.#thumbChar = firstCellGlyph(options.thumbChar ?? DEFAULT_THUMB, DEFAULT_THUMB);
78
+ this.#ellipsis = options.ellipsis ?? Ellipsis.Unicode;
79
+ this.#fastScrollLines = Math.max(1, Math.trunc(options.fastScrollLines ?? 5));
63
80
  this.#clampScrollOffset();
64
81
  }
65
82
 
@@ -113,11 +130,55 @@ export class ScrollView implements Component {
113
130
  this.#scrollOffset = this.getMaxScrollOffset();
114
131
  }
115
132
 
133
+ /**
134
+ * Apply a standard navigation key to the viewport. Shift+Arrow scrolls by
135
+ * {@link ScrollViewOptions.fastScrollLines} (the "scroll faster" affordance);
136
+ * plain Arrow by one line; PageUp/PageDown by a page; Home/End to the ends.
137
+ * Returns true when the key was consumed, so callers can fall through to
138
+ * their own (e.g. vim-style) bindings. Generic on purpose: every ScrollView
139
+ * consumer gets the same scroll keys, including Shift-to-go-faster.
140
+ */
141
+ handleScrollKey(data: string): boolean {
142
+ if (matchesKey(data, "shift+up")) {
143
+ this.scroll(-this.#fastScrollLines);
144
+ return true;
145
+ }
146
+ if (matchesKey(data, "shift+down")) {
147
+ this.scroll(this.#fastScrollLines);
148
+ return true;
149
+ }
150
+ if (matchesKey(data, "up")) {
151
+ this.scroll(-1);
152
+ return true;
153
+ }
154
+ if (matchesKey(data, "down")) {
155
+ this.scroll(1);
156
+ return true;
157
+ }
158
+ if (matchesKey(data, "pageUp")) {
159
+ this.page(-1);
160
+ return true;
161
+ }
162
+ if (matchesKey(data, "pageDown")) {
163
+ this.page(1);
164
+ return true;
165
+ }
166
+ if (matchesKey(data, "home")) {
167
+ this.scrollToTop();
168
+ return true;
169
+ }
170
+ if (matchesKey(data, "end")) {
171
+ this.scrollToBottom();
172
+ return true;
173
+ }
174
+ return false;
175
+ }
176
+
116
177
  invalidate(): void {
117
178
  // No cached layout to invalidate.
118
179
  }
119
180
 
120
- render(width: number): string[] {
181
+ render(width: number): readonly string[] {
121
182
  this.#clampScrollOffset();
122
183
  const safeWidth = Number.isFinite(width) ? Math.max(0, Math.trunc(width)) : 0;
123
184
  if (this.#height === 0) return [];
@@ -128,7 +189,7 @@ export class ScrollView implements Component {
128
189
  for (let row = 0; row < this.#height; row++) {
129
190
  const sourceIndex = this.#totalRows === undefined ? this.#scrollOffset + row : row;
130
191
  const source = this.#lines[sourceIndex] ?? "";
131
- const truncated = truncateToWidth(replaceTabs(source), contentWidth, Ellipsis.Unicode);
192
+ const truncated = truncateToWidth(replaceTabs(source), contentWidth, this.#ellipsis);
132
193
  if (!showScrollbar) {
133
194
  lines.push(truncated);
134
195
  continue;
@@ -1,9 +1,10 @@
1
+ import { popLoopPhase, pushLoopPhase } from "@prometheus-ai/utils";
1
2
  import { fuzzyFilter } from "../fuzzy";
2
3
  import { getKeybindings } from "../keybindings";
3
4
  import { extractPrintableText } from "../keys";
4
5
  import type { SymbolTheme } from "../symbols";
5
6
  import type { Component } from "../tui";
6
- import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth } from "../utils";
7
+ import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
7
8
  import { ScrollView } from "./scroll-view";
8
9
 
9
10
  const DEFAULT_PRIMARY_COLUMN_WIDTH = 32;
@@ -34,6 +35,8 @@ export interface SelectListTheme {
34
35
  scrollInfo: (text: string) => string;
35
36
  noMatch: (text: string) => string;
36
37
  symbols: SymbolTheme;
38
+ /** Hover band applied to the full row under the mouse pointer. */
39
+ hovered?: (text: string) => string;
37
40
  }
38
41
 
39
42
  export interface SelectListTruncatePrimaryContext {
@@ -50,12 +53,40 @@ export interface SelectListLayoutOptions {
50
53
  truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string;
51
54
  /** Enable type-to-filter search when the item count exceeds maxVisible. Defaults to true. */
52
55
  overflowSearch?: boolean;
56
+ /**
57
+ * Wrap long descriptions onto continuation rows indented under the
58
+ * description column instead of truncating. Defaults to false so existing
59
+ * single-line consumers are unaffected. Navigation remains item-to-item;
60
+ * the scrollbar tracks visual rows so the thumb stays correct when items
61
+ * wrap unevenly.
62
+ */
63
+ wrapDescription?: boolean;
53
64
  }
54
65
 
66
+ type SelectItemLayout =
67
+ | {
68
+ kind: "description";
69
+ prefix: string;
70
+ truncatedValue: string;
71
+ spacing: string;
72
+ descriptionSingleLine: string;
73
+ descriptionStart: number;
74
+ remainingWidth: number;
75
+ }
76
+ | {
77
+ kind: "primary";
78
+ prefix: string;
79
+ truncatedValue: string;
80
+ spacing: "";
81
+ };
82
+
55
83
  export class SelectList implements Component {
56
84
  #filteredItems: ReadonlyArray<SelectItem>;
57
85
  #filterQuery = "";
58
86
  #selectedIndex: number = 0;
87
+ #hoveredIndex: number | null = null;
88
+ /** Per-render map of 0-based output line → filtered-item index. */
89
+ #hitRows: (number | undefined)[] = [];
59
90
 
60
91
  onSelect?: (item: SelectItem) => void;
61
92
  onCancel?: () => void;
@@ -78,12 +109,43 @@ export class SelectList implements Component {
78
109
  this.#selectedIndex = Math.max(0, Math.min(index, this.#filteredItems.length - 1));
79
110
  }
80
111
 
112
+ /** Resolve a 0-based rendered-line index to a filtered-item index. */
113
+ hitTest(line: number): number | undefined {
114
+ return this.#hitRows[line];
115
+ }
116
+
117
+ /** Highlight the item under the pointer (null clears). */
118
+ setHoverIndex(index: number | null): void {
119
+ this.#hoveredIndex = index;
120
+ }
121
+
122
+ /** Move the selection one step for a wheel notch. */
123
+ handleWheel(delta: -1 | 1): void {
124
+ if (this.#filteredItems.length === 0) return;
125
+ const next = clamp(this.#selectedIndex + delta, 0, this.#filteredItems.length - 1);
126
+ if (next === this.#selectedIndex) return;
127
+ this.#selectedIndex = next;
128
+ this.#notifySelectionChange();
129
+ }
130
+
131
+ /** Mouse click: select the item under the pointer and confirm it. */
132
+ clickItem(index: number): void {
133
+ const item = this.#filteredItems[index];
134
+ if (!item) return;
135
+ if (index !== this.#selectedIndex) {
136
+ this.#selectedIndex = index;
137
+ this.#notifySelectionChange();
138
+ }
139
+ this.onSelect?.(item);
140
+ }
141
+
81
142
  invalidate(): void {
82
143
  // No cached state to invalidate currently
83
144
  }
84
145
 
85
- render(width: number): string[] {
146
+ render(width: number): readonly string[] {
86
147
  const lines: string[] = [];
148
+ this.#hitRows = [];
87
149
  const showSearchStatus = this.#shouldRenderSearchStatus();
88
150
 
89
151
  // If no items match filter, show message
@@ -96,34 +158,60 @@ export class SelectList implements Component {
96
158
  }
97
159
 
98
160
  const primaryColumnWidth = this.#getPrimaryColumnWidth();
161
+ const wrapEnabled = this.layout.wrapDescription === true;
162
+ // `maxVisible` is the picker's visual row budget. For non-wrap layouts
163
+ // every item is one row, so the budget matches the original item count.
164
+ const visualBudget = this.maxVisible;
165
+
166
+ // Compute per-item visual row counts at the conservative width (i.e.
167
+ // assume the scrollbar column might be reserved). For non-wrap layouts
168
+ // every count is 1, so visualTotal == #filteredItems and overflow falls
169
+ // back to the original `N > maxVisible` predicate exactly.
170
+ const conservativeRowWidth = Math.max(0, width - 1);
171
+ const rowCounts = new Array<number>(this.#filteredItems.length);
172
+ let visualTotal = 0;
173
+ for (let i = 0; i < this.#filteredItems.length; i++) {
174
+ const item = this.#filteredItems[i];
175
+ if (!item) {
176
+ rowCounts[i] = 0;
177
+ continue;
178
+ }
179
+ rowCounts[i] = wrapEnabled ? this.#computeItemRowCount(item, conservativeRowWidth, primaryColumnWidth) : 1;
180
+ visualTotal += rowCounts[i];
181
+ }
99
182
 
100
- // Calculate visible range with scrolling
101
- const startIndex = Math.max(
102
- 0,
103
- Math.min(this.#selectedIndex - Math.floor(this.maxVisible / 2), this.#filteredItems.length - this.maxVisible),
104
- );
105
- const endIndex = Math.min(startIndex + this.maxVisible, this.#filteredItems.length);
106
-
107
- // Render visible items
108
- const overflow = this.#filteredItems.length > this.maxVisible;
183
+ const overflow = visualTotal > visualBudget;
109
184
  const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
185
+
186
+ // Pick a window centered on the selected item that fits in visualBudget
187
+ // rows. Falls through to the original item-count window when every row
188
+ // count is 1.
189
+ const { startIndex, endIndex, visualOffset } = this.#pickWindow(rowCounts, visualBudget);
190
+
191
+ // Render visible items. Cap rows at the budget so a single item that
192
+ // wraps to more than `visualBudget` rows (pathological — e.g. a 5-row
193
+ // description with maxVisible=3) still keeps the popup bounded; the
194
+ // scrollbar carries the offscreen rows.
110
195
  const rows: string[] = [];
111
- for (let i = startIndex; i < endIndex; i++) {
196
+ for (let i = startIndex; i < endIndex && rows.length < visualBudget; i++) {
112
197
  const item = this.#filteredItems[i];
113
198
  if (!item) continue;
114
-
115
- const isSelected = i === this.#selectedIndex;
116
- const descriptionText = item.description ? sanitizeSingleLine(item.description) : undefined;
117
- rows.push(this.#renderItem(item, isSelected, rowWidth, descriptionText, primaryColumnWidth));
199
+ const hovered = this.theme.hovered !== undefined && i === this.#hoveredIndex && i !== this.#selectedIndex;
200
+ const itemRows = this.#renderItem(item, i === this.#selectedIndex, rowWidth, primaryColumnWidth);
201
+ for (const row of itemRows) {
202
+ if (rows.length >= visualBudget) break;
203
+ this.#hitRows[rows.length] = i;
204
+ rows.push(hovered && this.theme.hovered ? this.theme.hovered(row) : row);
205
+ }
118
206
  }
119
207
 
120
208
  const sv = new ScrollView(rows, {
121
209
  height: rows.length,
122
210
  scrollbar: "auto",
123
- totalRows: this.#filteredItems.length,
211
+ totalRows: visualTotal,
124
212
  theme: { track: t => this.theme.scrollInfo(t), thumb: t => this.theme.selectedPrefix(t) },
125
213
  });
126
- sv.setScrollOffset(startIndex);
214
+ sv.setScrollOffset(visualOffset);
127
215
  lines.push(...sv.render(width));
128
216
 
129
217
  // Add search status when relevant (scrollbar now indicates overflow)
@@ -178,17 +266,112 @@ export class SelectList implements Component {
178
266
  }
179
267
  }
180
268
 
181
- #renderItem(
269
+ #renderItem(item: SelectItem, isSelected: boolean, width: number, primaryColumnWidth: number): string[] {
270
+ const layout = this.#computeItemLayout(item, isSelected, width, primaryColumnWidth);
271
+ const { prefix, truncatedValue, spacing } = layout;
272
+
273
+ if (layout.kind === "description") {
274
+ const { descriptionSingleLine, descriptionStart, remainingWidth } = layout;
275
+ if (this.layout.wrapDescription) {
276
+ const wrapped = wrapTextWithAnsi(descriptionSingleLine, remainingWidth);
277
+ if (wrapped.length === 0) wrapped.push("");
278
+ const indent = padding(descriptionStart);
279
+ const first = wrapped[0] ?? "";
280
+ if (isSelected) {
281
+ const rows = [this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${first}`)];
282
+ for (let i = 1; i < wrapped.length; i++) {
283
+ rows.push(this.theme.selectedText(`${indent}${wrapped[i]}`));
284
+ }
285
+ return rows;
286
+ }
287
+ const rows = [prefix + truncatedValue + this.theme.description(spacing + first)];
288
+ for (let i = 1; i < wrapped.length; i++) {
289
+ rows.push(this.theme.description(`${indent}${wrapped[i]}`));
290
+ }
291
+ return rows;
292
+ }
293
+
294
+ const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, Ellipsis.Omit);
295
+ if (isSelected) {
296
+ return [this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`)];
297
+ }
298
+ return [prefix + truncatedValue + this.theme.description(spacing + truncatedDesc)];
299
+ }
300
+
301
+ if (isSelected) {
302
+ return [this.theme.selectedText(`${prefix}${truncatedValue}`)];
303
+ }
304
+ return [prefix + truncatedValue];
305
+ }
306
+
307
+ #computeItemRowCount(item: SelectItem, width: number, primaryColumnWidth: number): number {
308
+ // Selection style does not change row count; pass isSelected=false to
309
+ // keep the cheap path uniform for items outside the visible window.
310
+ const layout = this.#computeItemLayout(item, false, width, primaryColumnWidth);
311
+ if (layout.kind !== "description") return 1;
312
+ const wrapped = wrapTextWithAnsi(layout.descriptionSingleLine, layout.remainingWidth);
313
+ return Math.max(1, wrapped.length);
314
+ }
315
+
316
+ /**
317
+ * Pick a contiguous window of items containing `selectedIndex` such that
318
+ * their visual rows fit within `budget`. Centers the selection roughly
319
+ * mid-window: first expands up by ⌊budget/2⌋ rows, then fills downward,
320
+ * then back upward with any remaining budget. For non-wrap layouts (every
321
+ * `rowCounts[i] === 1`) this resolves to the same `[start, start+maxVisible)`
322
+ * window the prior arithmetic produced.
323
+ */
324
+ #pickWindow(
325
+ rowCounts: ReadonlyArray<number>,
326
+ budget: number,
327
+ ): { startIndex: number; endIndex: number; visualOffset: number } {
328
+ const n = rowCounts.length;
329
+ const selected = Math.max(0, Math.min(this.#selectedIndex, n - 1));
330
+ if (n === 0) return { startIndex: 0, endIndex: 0, visualOffset: 0 };
331
+
332
+ const half = Math.floor(budget / 2);
333
+ let lo = selected;
334
+ let rowsAboveSelected = 0;
335
+ // Step 1: expand upward up to `half` rows above the selection so it
336
+ // lands near the visual middle, matching the prior centering.
337
+ while (lo > 0 && rowsAboveSelected + (rowCounts[lo - 1] ?? 0) <= half) {
338
+ lo--;
339
+ rowsAboveSelected += rowCounts[lo] ?? 0;
340
+ }
341
+
342
+ // Step 2: expand downward until the budget is filled. The selected
343
+ // item's own rows are always counted; if it alone exceeds `budget`
344
+ // the surplus is clipped at render time and the scrollbar carries it.
345
+ let hi = selected + 1;
346
+ let used = rowsAboveSelected + (rowCounts[selected] ?? 0);
347
+ while (hi < n && used + (rowCounts[hi] ?? 0) <= budget) {
348
+ used += rowCounts[hi] ?? 0;
349
+ hi++;
350
+ }
351
+
352
+ // Step 3: if room remains (selection sat near the bottom), keep
353
+ // expanding upward.
354
+ while (lo > 0 && used + (rowCounts[lo - 1] ?? 0) <= budget) {
355
+ lo--;
356
+ used += rowCounts[lo] ?? 0;
357
+ }
358
+
359
+ let visualOffset = 0;
360
+ for (let i = 0; i < lo; i++) visualOffset += rowCounts[i] ?? 0;
361
+ return { startIndex: lo, endIndex: hi, visualOffset };
362
+ }
363
+
364
+ #computeItemLayout(
182
365
  item: SelectItem,
183
366
  isSelected: boolean,
184
367
  width: number,
185
- descriptionSingleLine: string | undefined,
186
368
  primaryColumnWidth: number,
187
- ): string {
369
+ ): SelectItemLayout {
188
370
  const prefix = isSelected
189
371
  ? `${this.theme.symbols.cursor} `
190
372
  : padding(visibleWidth(this.theme.symbols.cursor) + 1);
191
373
  const prefixWidth = visibleWidth(prefix);
374
+ const descriptionSingleLine = item.description ? sanitizeSingleLine(item.description) : undefined;
192
375
 
193
376
  if (descriptionSingleLine && width > 40) {
194
377
  const effectivePrimaryColumnWidth = Math.max(1, Math.min(primaryColumnWidth, width - prefixWidth - 4));
@@ -200,23 +383,26 @@ export class SelectList implements Component {
200
383
  const remainingWidth = width - descriptionStart - 2; // -2 for safety
201
384
 
202
385
  if (remainingWidth > MIN_DESCRIPTION_WIDTH) {
203
- const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, Ellipsis.Omit);
204
- if (isSelected) {
205
- return this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
206
- }
207
-
208
- const descText = this.theme.description(spacing + truncatedDesc);
209
- return prefix + truncatedValue + descText;
386
+ return {
387
+ kind: "description",
388
+ prefix,
389
+ truncatedValue,
390
+ spacing,
391
+ descriptionSingleLine,
392
+ descriptionStart,
393
+ remainingWidth,
394
+ };
210
395
  }
211
396
  }
212
397
 
213
- const maxWidth = width - prefixWidth - 2;
214
- const truncatedValue = this.#truncatePrimary(item, isSelected, maxWidth, maxWidth);
215
- if (isSelected) {
216
- return this.theme.selectedText(`${prefix}${truncatedValue}`);
217
- }
218
-
219
- return prefix + truncatedValue;
398
+ const fallbackMax = width - prefixWidth - 2;
399
+ const truncatedValue = this.#truncatePrimary(item, isSelected, fallbackMax, fallbackMax);
400
+ return {
401
+ kind: "primary",
402
+ prefix,
403
+ truncatedValue,
404
+ spacing: "",
405
+ };
220
406
  }
221
407
 
222
408
  #getPrimaryColumnWidth(): number {
@@ -297,9 +483,18 @@ export class SelectList implements Component {
297
483
 
298
484
  #setFilter(filter: string, notify: boolean): void {
299
485
  this.#filterQuery = filter;
300
- this.#filteredItems = filter.trim()
301
- ? fuzzyFilter([...this.items], filter, item => this.#getFilterText(item))
302
- : this.items;
486
+ if (filter.trim()) {
487
+ // Breadcrumb the fuzzy match so the loop watchdog can attribute a
488
+ // large-list filter stall instead of logging it as "unknown".
489
+ pushLoopPhase("ui.select-filter");
490
+ try {
491
+ this.#filteredItems = fuzzyFilter([...this.items], filter, item => this.#getFilterText(item));
492
+ } finally {
493
+ popLoopPhase();
494
+ }
495
+ } else {
496
+ this.#filteredItems = this.items;
497
+ }
303
498
  this.#selectedIndex = 0;
304
499
  if (notify) {
305
500
  this.#notifySelectionChange();