@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
@@ -5,24 +5,28 @@ import type { Component } from "../tui";
5
5
  */
6
6
  export class Spacer implements Component {
7
7
  #lines: number;
8
+ #cached: string[] | undefined;
8
9
 
9
10
  constructor(lines: number = 1) {
10
11
  this.#lines = lines;
11
12
  }
12
13
 
13
14
  setLines(lines: number): void {
15
+ if (lines === this.#lines) return;
14
16
  this.#lines = lines;
17
+ this.#cached = undefined;
15
18
  }
16
19
 
17
20
  invalidate(): void {
18
21
  // No cached state to invalidate currently
19
22
  }
20
23
 
21
- render(_width: number): string[] {
22
- const result: string[] = [];
23
- for (let i = 0; i < this.#lines; i++) {
24
- result.push("");
24
+ render(_width: number): readonly string[] {
25
+ let cached = this.#cached;
26
+ if (cached === undefined) {
27
+ cached = new Array(this.#lines).fill("");
28
+ this.#cached = cached;
25
29
  }
26
- return result;
30
+ return cached;
27
31
  }
28
32
  }
@@ -18,6 +18,10 @@ export interface Tab {
18
18
  id: string;
19
19
  /** Display label shown in the tab bar */
20
20
  label: string;
21
+ /** Compact form (e.g. just the icon) used when the bar must shrink to fit one line. */
22
+ short?: string;
23
+ /** Render with the muted style and skip during keyboard navigation. */
24
+ muted?: boolean;
21
25
  }
22
26
 
23
27
  /** Theme for styling the tab bar */
@@ -30,6 +34,10 @@ export interface TabBarTheme {
30
34
  inactiveTab: (text: string) => string;
31
35
  /** Style for the hint text (e.g., "(tab to cycle)") */
32
36
  hint: (text: string) => string;
37
+ /** Style for muted tabs. Falls back to `inactiveTab` when omitted. */
38
+ mutedTab?: (text: string) => string;
39
+ /** Style for the tab under the mouse pointer. Falls back to `inactiveTab` when omitted. */
40
+ hoverTab?: (text: string) => string;
33
41
  }
34
42
 
35
43
  /**
@@ -50,10 +58,16 @@ export class TabBar implements Component {
50
58
  #activeIndex: number = 0;
51
59
  #theme: TabBarTheme;
52
60
  #label: string;
61
+ #hoverTabId: string | null = null;
62
+ /** Per-render tab hit zones: 0-based line + [start, end) columns. */
63
+ #hitZones: { line: number; start: number; end: number; index: number }[] = [];
53
64
 
54
65
  /** Callback fired when the active tab changes */
55
66
  onTabChange?: (tab: Tab, index: number) => void;
56
67
 
68
+ /** Render the trailing "(tab to cycle)" hint. Disable when the host folds the hint into its own footer. */
69
+ showHint = true;
70
+
57
71
  constructor(label: string, tabs: Tab[], theme: TabBarTheme, initialIndex: number = 0) {
58
72
  this.#label = label;
59
73
  this.#tabs = tabs;
@@ -80,14 +94,55 @@ export class TabBar implements Component {
80
94
  }
81
95
  }
82
96
 
83
- /** Move to the next tab (wraps to first tab after last) */
97
+ /**
98
+ * Replace the tab set without firing onTabChange. The active tab is
99
+ * preserved by id when it survives the swap (or forced via `activeId`);
100
+ * otherwise the index is clamped.
101
+ */
102
+ setTabs(tabs: Tab[], activeId?: string): void {
103
+ const targetId = activeId ?? this.#tabs[this.#activeIndex]?.id;
104
+ this.#tabs = tabs;
105
+ const index = tabs.findIndex(tab => tab.id === targetId);
106
+ this.#activeIndex = index >= 0 ? index : Math.max(0, Math.min(this.#activeIndex, tabs.length - 1));
107
+ }
108
+
109
+ /** Set the active tab by id without firing onTabChange. Returns false when the id is unknown. */
110
+ setActiveById(id: string): boolean {
111
+ const index = this.#tabs.findIndex(tab => tab.id === id);
112
+ if (index === -1) return false;
113
+ this.#activeIndex = index;
114
+ return true;
115
+ }
116
+
117
+ /** Activate the tab with `id`, firing onTabChange when it changes. Muted tabs are ignored. */
118
+ selectTab(id: string): boolean {
119
+ const index = this.#tabs.findIndex(tab => tab.id === id);
120
+ if (index === -1 || this.#tabs[index]?.muted) return false;
121
+ this.setActiveIndex(index);
122
+ return true;
123
+ }
124
+
125
+ /** Move to the next non-muted tab (wraps to first tab after last) */
84
126
  nextTab(): void {
85
- this.setActiveIndex((this.#activeIndex + 1) % this.#tabs.length);
127
+ this.#stepTab(1);
86
128
  }
87
129
 
88
- /** Move to the previous tab (wraps to last tab before first) */
130
+ /** Move to the previous non-muted tab (wraps to last tab before first) */
89
131
  prevTab(): void {
90
- this.setActiveIndex((this.#activeIndex - 1 + this.#tabs.length) % this.#tabs.length);
132
+ this.#stepTab(-1);
133
+ }
134
+
135
+ /** Step to the nearest non-muted tab in `delta` direction; no-op when none exists. */
136
+ #stepTab(delta: -1 | 1): void {
137
+ const len = this.#tabs.length;
138
+ if (len === 0) return;
139
+ for (let step = 1; step <= len; step++) {
140
+ const index = (((this.#activeIndex + delta * step) % len) + len) % len;
141
+ if (!this.#tabs[index]?.muted) {
142
+ this.setActiveIndex(index);
143
+ return;
144
+ }
145
+ }
91
146
  }
92
147
 
93
148
  invalidate(): void {
@@ -110,38 +165,78 @@ export class TabBar implements Component {
110
165
  return false;
111
166
  }
112
167
 
113
- /** Render the tab bar, wrapping to multiple lines if needed */
114
- render(width: number): string[] {
168
+ /**
169
+ * Render the tab bar. When the full labels overflow the width, tabs are
170
+ * collapsed to their `short` form one by one — starting with the tabs
171
+ * farthest from the active one — until the bar fits on a single line.
172
+ * Wrapping to multiple lines is the last resort.
173
+ */
174
+ render(width: number): readonly string[] {
115
175
  const maxWidth = Math.max(1, width);
116
- const chunks: string[] = [];
117
-
118
- // Label prefix
119
- chunks.push(this.#theme.label(`${this.#label}:`));
120
- chunks.push(" ");
121
-
122
- // Tab buttons
123
- for (let i = 0; i < this.#tabs.length; i++) {
124
- const tab = this.#tabs[i];
125
- if (i === this.#activeIndex) {
126
- chunks.push(this.#theme.activeTab(` ${tab.label} `));
127
- } else {
128
- chunks.push(this.#theme.inactiveTab(` ${tab.label} `));
176
+
177
+ interface TabChunk {
178
+ text: string;
179
+ /** Index into #tabs when this chunk is a clickable tab button. */
180
+ tabIndex?: number;
181
+ }
182
+
183
+ const buildChunks = (labels: readonly string[]): TabChunk[] => {
184
+ const chunks: TabChunk[] = [];
185
+ // Label prefix (omitted when the label is empty)
186
+ if (this.#label) {
187
+ chunks.push({ text: this.#theme.label(`${this.#label}:`) });
188
+ chunks.push({ text: " " });
129
189
  }
130
- if (i < this.#tabs.length - 1) {
131
- chunks.push(" ");
190
+ for (let i = 0; i < this.#tabs.length; i++) {
191
+ const tab = this.#tabs[i];
192
+ // Muted tabs never take the active highlight: they are skipped by
193
+ // navigation and only become "active" transiently via setTabs swaps.
194
+ // A hovered (non-active) tab lights up so mouse users see the target.
195
+ const hovered = tab.id === this.#hoverTabId && !tab.muted && i !== this.#activeIndex;
196
+ const style = tab.muted
197
+ ? (this.#theme.mutedTab ?? this.#theme.inactiveTab)
198
+ : i === this.#activeIndex
199
+ ? this.#theme.activeTab
200
+ : hovered
201
+ ? (this.#theme.hoverTab ?? this.#theme.inactiveTab)
202
+ : this.#theme.inactiveTab;
203
+ chunks.push({ text: style(` ${labels[i]} `), tabIndex: i });
204
+ if (i < this.#tabs.length - 1) {
205
+ chunks.push({ text: " " });
206
+ }
132
207
  }
133
- }
208
+ // Navigation hint
209
+ if (this.showHint) {
210
+ chunks.push({ text: " " });
211
+ chunks.push({ text: this.#theme.hint("(tab to cycle)") });
212
+ }
213
+ return chunks;
214
+ };
215
+ const totalWidth = (chunks: TabChunk[]): number =>
216
+ chunks.reduce((sum, chunk) => sum + visibleWidth(chunk.text), 0);
134
217
 
135
- // Navigation hint
136
- chunks.push(" ");
137
- chunks.push(this.#theme.hint("(tab to cycle)"));
218
+ const labels = this.#tabs.map(tab => tab.label);
219
+ let chunks = buildChunks(labels);
138
220
 
221
+ if (totalWidth(chunks) > maxWidth) {
222
+ const collapseOrder = this.#tabs
223
+ .map((_, index) => index)
224
+ .filter(index => index !== this.#activeIndex && this.#tabs[index].short !== undefined)
225
+ .sort((a, b) => Math.abs(b - this.#activeIndex) - Math.abs(a - this.#activeIndex));
226
+ for (const index of collapseOrder) {
227
+ labels[index] = this.#tabs[index].short ?? this.#tabs[index].label;
228
+ chunks = buildChunks(labels);
229
+ if (totalWidth(chunks) <= maxWidth) break;
230
+ }
231
+ }
232
+
233
+ this.#hitZones = [];
139
234
  const lines: string[] = [];
140
235
  let currentLine = "";
141
236
  let currentWidth = 0;
142
237
 
143
238
  for (const chunk of chunks) {
144
- const chunkWidth = visibleWidth(chunk);
239
+ const chunkWidth = visibleWidth(chunk.text);
145
240
  if (chunkWidth <= 0) {
146
241
  continue;
147
242
  }
@@ -152,7 +247,10 @@ export class TabBar implements Component {
152
247
  currentLine = "";
153
248
  currentWidth = 0;
154
249
  }
155
- lines.push(truncateToWidth(chunk, maxWidth));
250
+ if (chunk.tabIndex !== undefined) {
251
+ this.#hitZones.push({ line: lines.length, start: 0, end: maxWidth, index: chunk.tabIndex });
252
+ }
253
+ lines.push(truncateToWidth(chunk.text, maxWidth));
156
254
  continue;
157
255
  }
158
256
 
@@ -162,7 +260,15 @@ export class TabBar implements Component {
162
260
  currentWidth = 0;
163
261
  }
164
262
 
165
- currentLine += chunk;
263
+ if (chunk.tabIndex !== undefined) {
264
+ this.#hitZones.push({
265
+ line: lines.length,
266
+ start: currentWidth,
267
+ end: currentWidth + chunkWidth,
268
+ index: chunk.tabIndex,
269
+ });
270
+ }
271
+ currentLine += chunk.text;
166
272
  currentWidth += chunkWidth;
167
273
  }
168
274
 
@@ -172,4 +278,23 @@ export class TabBar implements Component {
172
278
 
173
279
  return lines.length > 0 ? lines : [""];
174
280
  }
281
+
282
+ /**
283
+ * Resolve a pointer position against the last rendered frame. `line` is the
284
+ * 0-based line index within this component's render output, `col` the
285
+ * 0-based column.
286
+ */
287
+ tabAt(line: number, col: number): Tab | undefined {
288
+ for (const zone of this.#hitZones) {
289
+ if (zone.line === line && col >= zone.start && col < zone.end) {
290
+ return this.#tabs[zone.index];
291
+ }
292
+ }
293
+ return undefined;
294
+ }
295
+
296
+ /** Highlight the tab under the pointer (null clears). */
297
+ setHoverTab(id: string | null): void {
298
+ this.#hoverTabId = id;
299
+ }
175
300
  }
@@ -26,11 +26,15 @@ export class Text implements Component {
26
26
  return this.#text;
27
27
  }
28
28
 
29
- setText(text: string): void {
29
+ setText(text: string): boolean {
30
+ if (text === this.#text) {
31
+ return false;
32
+ }
30
33
  this.#text = text;
31
34
  this.#cachedText = undefined;
32
35
  this.#cachedWidth = undefined;
33
36
  this.#cachedLines = undefined;
37
+ return true;
34
38
  }
35
39
 
36
40
  setCustomBgFn(customBgFn?: (text: string) => string): void {
@@ -46,7 +50,7 @@ export class Text implements Component {
46
50
  this.#cachedLines = undefined;
47
51
  }
48
52
 
49
- render(width: number): string[] {
53
+ render(width: number): readonly string[] {
50
54
  // Check cache
51
55
  if (this.#cachedLines && this.#cachedText === this.#text && this.#cachedWidth === width) {
52
56
  return this.#cachedLines;
@@ -8,6 +8,8 @@ export class TruncatedText implements Component {
8
8
  #text: string;
9
9
  #paddingX: number;
10
10
  #paddingY: number;
11
+ #cachedWidth = -1;
12
+ #cachedLines: string[] | undefined;
11
13
 
12
14
  constructor(text: string, paddingX: number = 0, paddingY: number = 0) {
13
15
  this.#text = text;
@@ -16,10 +18,14 @@ export class TruncatedText implements Component {
16
18
  }
17
19
 
18
20
  invalidate(): void {
19
- // No cached state to invalidate currently
21
+ this.#cachedWidth = -1;
22
+ this.#cachedLines = undefined;
20
23
  }
21
24
 
22
- render(width: number): string[] {
25
+ render(width: number): readonly string[] {
26
+ if (this.#cachedLines && this.#cachedWidth === width) {
27
+ return this.#cachedLines;
28
+ }
23
29
  const result: string[] = [];
24
30
 
25
31
  // Empty line padded to width
@@ -56,6 +62,8 @@ export class TruncatedText implements Component {
56
62
  result.push(emptyLine);
57
63
  }
58
64
 
65
+ this.#cachedWidth = width;
66
+ this.#cachedLines = result;
59
67
  return result;
60
68
  }
61
69
  }
package/src/fuzzy.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  /**
2
2
  * Fuzzy matching utilities.
3
- * Matches if all query characters appear in order (not necessarily consecutive).
3
+ *
4
+ * Matching is deliberately word-local for normal words. This keeps a query like
5
+ * "image provider" from matching a long setting description only because the
6
+ * letters i-m-a-g-e appear somewhere in order across unrelated words.
7
+ *
4
8
  * Lower score = better match.
5
9
  */
6
10
 
@@ -9,56 +13,107 @@ export interface FuzzyMatch {
9
13
  score: number;
10
14
  }
11
15
 
16
+ export interface FuzzyFilterResult<T> {
17
+ item: T;
18
+ score: number;
19
+ }
20
+
21
+ interface CharacterMatch {
22
+ matches: boolean;
23
+ score: number;
24
+ span: number;
25
+ }
26
+
27
+ interface SearchWord {
28
+ text: string;
29
+ index: number;
30
+ ordinal: number;
31
+ }
32
+
33
+ interface SearchIndex {
34
+ normalized: string;
35
+ compact: string;
36
+ /** Start offsets of each word within `compact` (cumulative word lengths). */
37
+ compactWordStarts: Set<number>;
38
+ words: SearchWord[];
39
+ }
40
+
12
41
  const ALPHANUMERIC_SWAP_PENALTY = 5;
42
+ const COMPACT_PHRASE_BONUS = 1200;
43
+ const PHRASE_BONUS = 1000;
13
44
 
14
- function scoreMatch(queryLower: string, textLower: string): FuzzyMatch {
45
+ function normalizeForSearch(value: string): string {
46
+ return value
47
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
48
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
49
+ .toLowerCase()
50
+ .replace(/[^a-z0-9]+/g, " ")
51
+ .trim()
52
+ .replace(/\s+/g, " ");
53
+ }
54
+
55
+ function buildSearchIndex(text: string): SearchIndex {
56
+ const normalized = normalizeForSearch(text);
57
+ if (normalized.length === 0) {
58
+ return { normalized, compact: "", compactWordStarts: new Set(), words: [] };
59
+ }
60
+
61
+ const words: SearchWord[] = [];
62
+ const compactWordStarts = new Set<number>();
63
+ let index = 0;
64
+ let compactIndex = 0;
65
+ let ordinal = 0;
66
+ for (const word of normalized.split(" ")) {
67
+ words.push({ text: word, index, ordinal });
68
+ compactWordStarts.add(compactIndex);
69
+ index += word.length + 1;
70
+ compactIndex += word.length;
71
+ ordinal++;
72
+ }
73
+
74
+ return { normalized, compact: normalized.replaceAll(" ", ""), compactWordStarts, words };
75
+ }
76
+
77
+ function scoreCharacters(queryLower: string, textLower: string): CharacterMatch {
15
78
  if (queryLower.length === 0) {
16
- return { matches: true, score: 0 };
79
+ return { matches: true, score: 0, span: 0 };
17
80
  }
18
81
 
19
82
  if (queryLower.length > textLower.length) {
20
- return { matches: false, score: 0 };
83
+ return { matches: false, score: 0, span: 0 };
21
84
  }
22
85
 
23
86
  let queryIndex = 0;
24
87
  let score = 0;
88
+ let firstMatchIndex = -1;
25
89
  let lastMatchIndex = -1;
26
90
  let consecutiveMatches = 0;
27
91
 
28
92
  for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
29
93
  if (textLower[i] === queryLower[queryIndex]) {
30
- const isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]!);
94
+ if (firstMatchIndex < 0) firstMatchIndex = i;
31
95
 
32
- // Reward consecutive matches
33
96
  if (lastMatchIndex === i - 1) {
34
97
  consecutiveMatches++;
35
98
  score -= consecutiveMatches * 5;
36
99
  } else {
37
100
  consecutiveMatches = 0;
38
- // Penalize gaps
39
101
  if (lastMatchIndex >= 0) {
40
102
  score += (i - lastMatchIndex - 1) * 2;
41
103
  }
42
104
  }
43
105
 
44
- // Reward word boundary matches
45
- if (isWordBoundary) {
46
- score -= 10;
47
- }
48
-
49
- // Slight penalty for later matches
50
106
  score += i * 0.1;
51
-
52
107
  lastMatchIndex = i;
53
108
  queryIndex++;
54
109
  }
55
110
  }
56
111
 
57
112
  if (queryIndex < queryLower.length) {
58
- return { matches: false, score: 0 };
113
+ return { matches: false, score: 0, span: 0 };
59
114
  }
60
115
 
61
- return { matches: true, score };
116
+ return { matches: true, score, span: lastMatchIndex - firstMatchIndex + 1 };
62
117
  }
63
118
 
64
119
  function buildAlphanumericSwapQueries(queryLower: string): string[] {
@@ -76,68 +131,168 @@ function buildAlphanumericSwapQueries(queryLower: string): string[] {
76
131
  return [...variants];
77
132
  }
78
133
 
79
- export function fuzzyMatch(query: string, text: string): FuzzyMatch {
80
- const queryLower = query.toLowerCase();
81
- const textLower = text.toLowerCase();
134
+ function withPosition(score: number, index: number): number {
135
+ return score + index * 0.01;
136
+ }
137
+
138
+ function isWordBoundaryPhrase(normalized: string, index: number, length: number): boolean {
139
+ const before = index === 0 || normalized[index - 1] === " ";
140
+ const afterIndex = index + length;
141
+ const after = afterIndex === normalized.length || normalized[afterIndex] === " ";
142
+ return before && after;
143
+ }
144
+
145
+ function scoreTokenAgainstWord(token: string, word: SearchWord): FuzzyMatch | null {
146
+ if (word.text === token) {
147
+ return { matches: true, score: withPosition(-200, word.index) };
148
+ }
149
+
150
+ if (word.text.startsWith(token)) {
151
+ return { matches: true, score: withPosition(-170 + (word.text.length - token.length) * 0.5, word.index) };
152
+ }
153
+
154
+ if (token.startsWith(word.text) && token.length - word.text.length <= 2) {
155
+ return { matches: true, score: withPosition(-150 + token.length - word.text.length, word.index) };
156
+ }
157
+
158
+ const substringIndex = word.text.indexOf(token);
159
+ if (substringIndex >= 0) {
160
+ return { matches: true, score: withPosition(-20 + substringIndex, word.index) };
161
+ }
162
+
163
+ const characterMatch = scoreCharacters(token, word.text);
164
+ if (!characterMatch.matches) return null;
165
+
166
+ const maxSpan = Math.max(token.length + 2, Math.ceil(token.length * 1.8));
167
+ if (characterMatch.span > maxSpan) return null;
168
+
169
+ return { matches: true, score: withPosition(-40 + characterMatch.score, word.index) };
170
+ }
171
+
172
+ function scoreAcronym(token: string, index: SearchIndex): FuzzyMatch | null {
173
+ if (token.length < 2 || token.length > 4 || index.words.length === 0) return null;
174
+
175
+ let queryIndex = 0;
176
+ let firstOrdinal = -1;
177
+ let lastOrdinal = -1;
178
+ let firstTextIndex = 0;
82
179
 
83
- const direct = scoreMatch(queryLower, textLower);
84
- if (direct.matches) {
85
- return direct;
180
+ for (const word of index.words) {
181
+ if (word.text[0] !== token[queryIndex]) continue;
182
+ if (firstOrdinal < 0) {
183
+ firstOrdinal = word.ordinal;
184
+ firstTextIndex = word.index;
185
+ }
186
+ lastOrdinal = word.ordinal;
187
+ queryIndex++;
188
+ if (queryIndex === token.length) break;
189
+ }
190
+
191
+ if (queryIndex < token.length || firstOrdinal < 0 || lastOrdinal < 0) return null;
192
+
193
+ const wordSpan = lastOrdinal - firstOrdinal + 1;
194
+ if (wordSpan > token.length + 2) return null;
195
+
196
+ return { matches: true, score: withPosition(-30 + wordSpan * 4 - token.length * 2, firstTextIndex) };
197
+ }
198
+
199
+ function scoreTokenDirect(token: string, index: SearchIndex): FuzzyMatch {
200
+ if (token.length === 0) return { matches: true, score: 0 };
201
+
202
+ let best: FuzzyMatch | null = null;
203
+ const compactIndex = index.compact.indexOf(token);
204
+ if (compactIndex >= 0 && index.compactWordStarts.has(compactIndex)) {
205
+ best = { matches: true, score: withPosition(-140, compactIndex) };
206
+ }
207
+
208
+ for (const word of index.words) {
209
+ const match = scoreTokenAgainstWord(token, word);
210
+ if (match && (!best || match.score < best.score)) {
211
+ best = match;
212
+ }
86
213
  }
87
214
 
88
- let bestSwap: FuzzyMatch | null = null;
89
- for (const variant of buildAlphanumericSwapQueries(queryLower)) {
90
- const match = scoreMatch(variant, textLower);
215
+ const acronym = scoreAcronym(token, index);
216
+ if (acronym && (!best || acronym.score < best.score)) {
217
+ best = acronym;
218
+ }
219
+
220
+ return best ?? { matches: false, score: 0 };
221
+ }
222
+
223
+ function scoreToken(token: string, index: SearchIndex): FuzzyMatch {
224
+ let best = scoreTokenDirect(token, index);
225
+ if (best.matches) return best;
226
+
227
+ for (const variant of buildAlphanumericSwapQueries(token)) {
228
+ const match = scoreTokenDirect(variant, index);
91
229
  if (!match.matches) continue;
92
230
  const score = match.score + ALPHANUMERIC_SWAP_PENALTY;
93
- if (!bestSwap || score < bestSwap.score) {
94
- bestSwap = { matches: true, score };
231
+ if (!best.matches || score < best.score) {
232
+ best = { matches: true, score };
95
233
  }
96
234
  }
97
235
 
98
- return bestSwap ?? direct;
236
+ return best;
99
237
  }
100
238
 
101
- /**
102
- * Filter and sort items by fuzzy match quality (best matches first).
103
- * Supports space-separated tokens: all tokens must match.
104
- */
105
- export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[] {
106
- if (!query.trim()) {
107
- return items;
239
+ export function fuzzyMatch(query: string, text: string): FuzzyMatch {
240
+ const normalizedQuery = normalizeForSearch(query);
241
+ if (normalizedQuery.length === 0) {
242
+ return { matches: true, score: 0 };
108
243
  }
109
244
 
110
- const tokens = query
111
- .trim()
112
- .split(/\s+/)
113
- .filter(t => t.length > 0);
245
+ const index = buildSearchIndex(text);
246
+ if (index.words.length === 0) {
247
+ return { matches: false, score: 0 };
248
+ }
114
249
 
115
- if (tokens.length === 0) {
116
- return items;
250
+ let totalScore = 0;
251
+ const phraseIndex = index.normalized.indexOf(normalizedQuery);
252
+ if (phraseIndex >= 0 && isWordBoundaryPhrase(index.normalized, phraseIndex, normalizedQuery.length)) {
253
+ totalScore -= PHRASE_BONUS;
254
+ totalScore += phraseIndex * 0.01;
117
255
  }
118
256
 
119
- const results: { item: T; totalScore: number }[] = [];
257
+ const compactQuery = normalizedQuery.replaceAll(" ", "");
258
+ const compactPhraseIndex = index.compact.indexOf(compactQuery);
259
+ if (compactPhraseIndex >= 0 && index.compactWordStarts.has(compactPhraseIndex)) {
260
+ totalScore -= COMPACT_PHRASE_BONUS;
261
+ totalScore += compactPhraseIndex * 0.01;
262
+ }
120
263
 
121
- for (const item of items) {
122
- const text = getText(item);
123
- let totalScore = 0;
124
- let allMatch = true;
125
-
126
- for (const token of tokens) {
127
- const match = fuzzyMatch(token, text);
128
- if (match.matches) {
129
- totalScore += match.score;
130
- } else {
131
- allMatch = false;
132
- break;
133
- }
264
+ for (const token of normalizedQuery.split(" ")) {
265
+ const match = scoreToken(token, index);
266
+ if (!match.matches) {
267
+ return { matches: false, score: 0 };
134
268
  }
269
+ totalScore += match.score;
270
+ }
271
+
272
+ return { matches: true, score: totalScore };
273
+ }
135
274
 
136
- if (allMatch) {
137
- results.push({ item, totalScore });
275
+ /**
276
+ * Filter and sort items by fuzzy match quality (best matches first).
277
+ * Supports space-separated tokens: all tokens must match.
278
+ */
279
+ export function fuzzyRank<T>(items: T[], query: string, getText: (item: T) => string): FuzzyFilterResult<T>[] {
280
+ if (!query.trim()) {
281
+ return items.map(item => ({ item, score: 0 }));
282
+ }
283
+
284
+ const results: FuzzyFilterResult<T>[] = [];
285
+ for (const item of items) {
286
+ const match = fuzzyMatch(query, getText(item));
287
+ if (match.matches) {
288
+ results.push({ item, score: match.score });
138
289
  }
139
290
  }
140
291
 
141
- results.sort((a, b) => a.totalScore - b.totalScore);
142
- return results.map(r => r.item);
292
+ results.sort((a, b) => a.score - b.score);
293
+ return results;
294
+ }
295
+
296
+ export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[] {
297
+ return fuzzyRank(items, query, getText).map(result => result.item);
143
298
  }