@prometheus-ai/tui 0.5.3 → 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.
- package/dist/types/autocomplete.d.ts +3 -1
- package/dist/types/components/box.d.ts +1 -1
- package/dist/types/components/editor.d.ts +35 -2
- package/dist/types/components/image.d.ts +22 -3
- package/dist/types/components/input.d.ts +6 -1
- package/dist/types/components/loader.d.ts +9 -2
- package/dist/types/components/markdown.d.ts +3 -1
- package/dist/types/components/scroll-view.d.ts +23 -1
- package/dist/types/components/select-list.d.ts +19 -1
- package/dist/types/components/settings-list.d.ts +87 -7
- package/dist/types/components/spacer.d.ts +1 -1
- package/dist/types/components/tab-bar.d.ts +37 -4
- package/dist/types/components/text.d.ts +2 -2
- package/dist/types/components/truncated-text.d.ts +1 -1
- package/dist/types/fuzzy.d.ts +10 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/keybindings.d.ts +5 -3
- package/dist/types/keys.d.ts +1 -1
- package/dist/types/kill-ring.d.ts +0 -7
- package/dist/types/kitty-graphics.d.ts +16 -31
- package/dist/types/loop-watchdog.d.ts +39 -0
- package/dist/types/mouse.d.ts +41 -0
- package/dist/types/stdin-buffer.d.ts +17 -0
- package/dist/types/terminal-capabilities.d.ts +74 -18
- package/dist/types/terminal.d.ts +34 -36
- package/dist/types/tui.d.ts +191 -79
- package/dist/types/utils.d.ts +5 -2
- package/package.json +4 -4
- package/src/autocomplete.ts +79 -65
- package/src/components/box.ts +43 -63
- package/src/components/editor.ts +471 -136
- package/src/components/image.ts +85 -9
- package/src/components/input.ts +12 -3
- package/src/components/loader.ts +35 -21
- package/src/components/markdown.ts +174 -53
- package/src/components/scroll-view.ts +63 -2
- package/src/components/select-list.ts +233 -38
- package/src/components/settings-list.ts +626 -64
- package/src/components/spacer.ts +9 -5
- package/src/components/tab-bar.ts +153 -28
- package/src/components/text.ts +6 -2
- package/src/components/truncated-text.ts +10 -2
- package/src/fuzzy.ts +214 -59
- package/src/index.ts +3 -1
- package/src/keybindings.ts +72 -14
- package/src/keys.ts +1 -1
- package/src/kill-ring.ts +5 -0
- package/src/kitty-graphics.ts +2 -101
- package/src/loop-watchdog.ts +106 -0
- package/src/mouse.ts +55 -0
- package/src/stdin-buffer.ts +291 -81
- package/src/terminal-capabilities.ts +206 -168
- package/src/terminal.ts +367 -110
- package/src/tui.ts +2102 -1729
- package/src/utils.ts +92 -60
package/src/components/spacer.ts
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
chunks.push(
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
136
|
-
chunks
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/components/text.ts
CHANGED
|
@@ -26,11 +26,15 @@ export class Text implements Component {
|
|
|
26
26
|
return this.#text;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
setText(text: string):
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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 (!
|
|
94
|
-
|
|
231
|
+
if (!best.matches || score < best.score) {
|
|
232
|
+
best = { matches: true, score };
|
|
95
233
|
}
|
|
96
234
|
}
|
|
97
235
|
|
|
98
|
-
return
|
|
236
|
+
return best;
|
|
99
237
|
}
|
|
100
238
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
245
|
+
const index = buildSearchIndex(text);
|
|
246
|
+
if (index.words.length === 0) {
|
|
247
|
+
return { matches: false, score: 0 };
|
|
248
|
+
}
|
|
114
249
|
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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.
|
|
142
|
-
return results
|
|
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
|
}
|