@oh-my-pi/pi-tui 16.2.12 → 16.3.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.
- package/CHANGELOG.md +14 -1
- package/dist/types/bracketed-paste.d.ts +19 -1
- package/dist/types/components/editor.d.ts +16 -0
- package/dist/types/fuzzy.d.ts +8 -0
- package/dist/types/terminal-capabilities.d.ts +2 -0
- package/package.json +3 -3
- package/src/bracketed-paste.ts +46 -7
- package/src/components/editor.ts +30 -3
- package/src/fuzzy.ts +68 -10
- package/src/stdin-buffer.ts +235 -217
- package/src/terminal-capabilities.ts +14 -4
- package/src/terminal.ts +21 -1
- package/src/tui.ts +230 -54
- package/src/utils.ts +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.3.0] - 2026-07-02
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed a potential event loop hang when processing oversized, unterminated terminal escape sequences (OSC/DCS/APC).
|
|
10
|
+
- Fixed an issue where large Windows terminal session restores could get truncated mid-frame during ConPTY full-paint resume.
|
|
11
|
+
|
|
12
|
+
## [16.2.13] - 2026-07-01
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Fixed fuzzy-search filtering for CJK and other non-ASCII queries by preserving Unicode letters and numbers during query normalization ([#4114](https://github.com/can1357/oh-my-pi/issues/4114)).
|
|
17
|
+
|
|
5
18
|
## [16.2.12] - 2026-07-01
|
|
6
19
|
|
|
7
20
|
### Fixed
|
|
@@ -585,7 +598,7 @@
|
|
|
585
598
|
|
|
586
599
|
### Changed
|
|
587
600
|
|
|
588
|
-
- Changed native-scrollback safety defaults to treat unknown POSIX, SSH, and multiplexer-shaped terminals as ED3-risk for passive rendering; checkpoint replay now requires a positive at-tail viewport proof instead of assuming prompt submit makes host scrollback safe
|
|
601
|
+
- Changed native-scrollback safety defaults to treat unknown POSIX, SSH, and multiplexer-shaped terminals as ED3-risk for passive rendering; checkpoint replay now requires a positive at-tail viewport proof instead of assuming prompt submit makes host scrollback safe.
|
|
589
602
|
- Changed synchronized-output defaults to a conservative opt-in profile: DEC 2026 paint wrappers stay disabled for remote/multiplexer/VTE/unknown terminals unless explicitly forced, while the autowrap guards remain active.
|
|
590
603
|
|
|
591
604
|
### Fixed
|
|
@@ -12,6 +12,22 @@ export type PasteResult = {
|
|
|
12
12
|
* instead of leaking the printable escape tail into the buffer.
|
|
13
13
|
*/
|
|
14
14
|
export declare function decodeReencodedPasteControls(text: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* Options for {@link BracketedPasteHandler}.
|
|
17
|
+
*/
|
|
18
|
+
export type BracketedPasteHandlerOptions = {
|
|
19
|
+
/**
|
|
20
|
+
* Byte cap for buffered paste content (default: 64 MiB). When exceeded,
|
|
21
|
+
* paste mode is aborted and the accumulated content is delivered as
|
|
22
|
+
* `pasteContent` on the same `process()` call so a lost/corrupted end
|
|
23
|
+
* marker cannot consume unbounded memory. Mirrors `StdinBuffer#abortPaste`
|
|
24
|
+
* — defense in depth for callers that bypass `StdinBuffer` (issue #4073
|
|
25
|
+
* case B). The normal `ProcessTerminal` path re-wraps `StdinBuffer`'s
|
|
26
|
+
* bounded paste with both markers, so this cap only fires on alternate
|
|
27
|
+
* callers.
|
|
28
|
+
*/
|
|
29
|
+
byteLimit?: number;
|
|
30
|
+
};
|
|
15
31
|
/**
|
|
16
32
|
* Handles bracketed paste mode buffering for terminal input components.
|
|
17
33
|
*
|
|
@@ -21,13 +37,15 @@ export declare function decodeReencodedPasteControls(text: string): string;
|
|
|
21
37
|
*/
|
|
22
38
|
export declare class BracketedPasteHandler {
|
|
23
39
|
#private;
|
|
40
|
+
constructor(options?: BracketedPasteHandlerOptions);
|
|
24
41
|
/**
|
|
25
42
|
* Process incoming terminal data for bracketed paste sequences.
|
|
26
43
|
*
|
|
27
44
|
* @returns `{ handled: false }` if the data contains no paste sequence and
|
|
28
45
|
* should be processed normally. `{ handled: true }` if the data was
|
|
29
46
|
* consumed by paste buffering — `pasteContent` is set when a complete
|
|
30
|
-
* paste has been assembled
|
|
47
|
+
* paste has been assembled (or the byte cap has aborted a runaway
|
|
48
|
+
* buffer); omitted when still buffering.
|
|
31
49
|
*/
|
|
32
50
|
process(data: string): PasteResult;
|
|
33
51
|
}
|
|
@@ -60,8 +60,24 @@ export declare class Editor implements Component, Focusable {
|
|
|
60
60
|
/**
|
|
61
61
|
* Set custom content for the top border (e.g., status line).
|
|
62
62
|
* Pass undefined to use the default plain border.
|
|
63
|
+
*
|
|
64
|
+
* Eager: the passed value is cached and reused every frame. Callers that
|
|
65
|
+
* mutate status upstream must recompute and call this again. Prefer
|
|
66
|
+
* {@link setTopBorderProvider} for high-frequency updates — it collapses
|
|
67
|
+
* per-event rebuilds to one per painted frame.
|
|
63
68
|
*/
|
|
64
69
|
setTopBorder(content: EditorTopBorder | undefined): void;
|
|
70
|
+
/**
|
|
71
|
+
* Install a lazy provider invoked once per editor render with the current
|
|
72
|
+
* `availableWidth`. Overrides any eager content set via {@link setTopBorder}
|
|
73
|
+
* — pass `undefined` to detach and fall back to the eager slot.
|
|
74
|
+
*
|
|
75
|
+
* Use this when the top border derives from state that mutates far faster
|
|
76
|
+
* than the render cadence (session events, streaming, subagent updates).
|
|
77
|
+
* The TUI already throttles renders, so a provider is invoked at most once
|
|
78
|
+
* per frame and never does wasted work between paints.
|
|
79
|
+
*/
|
|
80
|
+
setTopBorderProvider(provider: ((availableWidth: number) => EditorTopBorder | undefined) | undefined): void;
|
|
65
81
|
/**
|
|
66
82
|
* Show or hide the editor border chrome.
|
|
67
83
|
*/
|
package/dist/types/fuzzy.d.ts
CHANGED
|
@@ -22,3 +22,11 @@ export declare function fuzzyMatch(query: string, text: string): FuzzyMatch;
|
|
|
22
22
|
*/
|
|
23
23
|
export declare function fuzzyRank<T>(items: T[], query: string, getText: (item: T) => string): FuzzyFilterResult<T>[];
|
|
24
24
|
export declare function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[];
|
|
25
|
+
/**
|
|
26
|
+
* Clear the fuzzy search-index cache. Intended for tests/benchmarks so a fresh
|
|
27
|
+
* cold-start typing session can be measured on demand; not part of the supported
|
|
28
|
+
* TUI API.
|
|
29
|
+
*
|
|
30
|
+
* @internal
|
|
31
|
+
*/
|
|
32
|
+
export declare function resetFuzzyIndexCache(): void;
|
|
@@ -40,6 +40,8 @@ export declare class TerminalInfo {
|
|
|
40
40
|
* the module and so a tmux session attached/detached mid-run is observed.
|
|
41
41
|
*/
|
|
42
42
|
export declare function isInsideTmux(env?: NodeJS.ProcessEnv): boolean;
|
|
43
|
+
/** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
|
|
44
|
+
export declare function isInsideTerminalMultiplexer(env?: NodeJS.ProcessEnv): boolean;
|
|
43
45
|
/**
|
|
44
46
|
* Wrap a control-sequence payload in tmux's DCS passthrough envelope. Each
|
|
45
47
|
* ESC byte inside `payload` is doubled per tmux's escape rules. tmux strips
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "16.
|
|
4
|
+
"version": "16.3.0",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-natives": "16.
|
|
41
|
-
"@oh-my-pi/pi-utils": "16.
|
|
40
|
+
"@oh-my-pi/pi-natives": "16.3.0",
|
|
41
|
+
"@oh-my-pi/pi-utils": "16.3.0",
|
|
42
42
|
"lru-cache": "11.5.1",
|
|
43
43
|
"marked": "^18.0.5"
|
|
44
44
|
},
|
package/src/bracketed-paste.ts
CHANGED
|
@@ -40,6 +40,25 @@ export function decodeReencodedPasteControls(text: string): string {
|
|
|
40
40
|
.replace(REENCODED_CTRL_XTERM, decodeReencodedCtrlByte);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Options for {@link BracketedPasteHandler}.
|
|
45
|
+
*/
|
|
46
|
+
export type BracketedPasteHandlerOptions = {
|
|
47
|
+
/**
|
|
48
|
+
* Byte cap for buffered paste content (default: 64 MiB). When exceeded,
|
|
49
|
+
* paste mode is aborted and the accumulated content is delivered as
|
|
50
|
+
* `pasteContent` on the same `process()` call so a lost/corrupted end
|
|
51
|
+
* marker cannot consume unbounded memory. Mirrors `StdinBuffer#abortPaste`
|
|
52
|
+
* — defense in depth for callers that bypass `StdinBuffer` (issue #4073
|
|
53
|
+
* case B). The normal `ProcessTerminal` path re-wraps `StdinBuffer`'s
|
|
54
|
+
* bounded paste with both markers, so this cap only fires on alternate
|
|
55
|
+
* callers.
|
|
56
|
+
*/
|
|
57
|
+
byteLimit?: number;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const DEFAULT_BYTE_LIMIT = 64 * 1024 * 1024;
|
|
61
|
+
|
|
43
62
|
/**
|
|
44
63
|
* Handles bracketed paste mode buffering for terminal input components.
|
|
45
64
|
*
|
|
@@ -50,6 +69,11 @@ export function decodeReencodedPasteControls(text: string): string {
|
|
|
50
69
|
export class BracketedPasteHandler {
|
|
51
70
|
#buffer = "";
|
|
52
71
|
#active = false;
|
|
72
|
+
readonly #byteLimit: number;
|
|
73
|
+
|
|
74
|
+
constructor(options: BracketedPasteHandlerOptions = {}) {
|
|
75
|
+
this.#byteLimit = options.byteLimit ?? DEFAULT_BYTE_LIMIT;
|
|
76
|
+
}
|
|
53
77
|
|
|
54
78
|
/**
|
|
55
79
|
* Process incoming terminal data for bracketed paste sequences.
|
|
@@ -57,7 +81,8 @@ export class BracketedPasteHandler {
|
|
|
57
81
|
* @returns `{ handled: false }` if the data contains no paste sequence and
|
|
58
82
|
* should be processed normally. `{ handled: true }` if the data was
|
|
59
83
|
* consumed by paste buffering — `pasteContent` is set when a complete
|
|
60
|
-
* paste has been assembled
|
|
84
|
+
* paste has been assembled (or the byte cap has aborted a runaway
|
|
85
|
+
* buffer); omitted when still buffering.
|
|
61
86
|
*/
|
|
62
87
|
process(data: string): PasteResult {
|
|
63
88
|
if (data.includes(PASTE_START)) {
|
|
@@ -71,14 +96,28 @@ export class BracketedPasteHandler {
|
|
|
71
96
|
this.#buffer += data;
|
|
72
97
|
|
|
73
98
|
const endIndex = this.#buffer.indexOf(PASTE_END);
|
|
74
|
-
if (endIndex
|
|
99
|
+
if (endIndex !== -1) {
|
|
100
|
+
const pasteContent = this.#buffer.substring(0, endIndex);
|
|
101
|
+
const remaining = this.#buffer.substring(endIndex + PASTE_END.length);
|
|
102
|
+
|
|
103
|
+
this.#buffer = "";
|
|
104
|
+
this.#active = false;
|
|
75
105
|
|
|
76
|
-
|
|
77
|
-
|
|
106
|
+
return { handled: true, pasteContent, remaining };
|
|
107
|
+
}
|
|
78
108
|
|
|
79
|
-
|
|
80
|
-
|
|
109
|
+
// Byte cap: a lost/corrupted end marker (ssh/tmux truncation) must not
|
|
110
|
+
// consume unbounded memory. Deliver the accumulated bytes so they are
|
|
111
|
+
// neither lost nor held forever, and reset paste mode so subsequent
|
|
112
|
+
// input recovers. See `StdinBuffer#abortPaste` for the sibling recovery
|
|
113
|
+
// semantics inside `StdinBuffer`.
|
|
114
|
+
if (this.#buffer.length > this.#byteLimit) {
|
|
115
|
+
const pasteContent = this.#buffer;
|
|
116
|
+
this.#buffer = "";
|
|
117
|
+
this.#active = false;
|
|
118
|
+
return { handled: true, pasteContent, remaining: "" };
|
|
119
|
+
}
|
|
81
120
|
|
|
82
|
-
return { handled: true,
|
|
121
|
+
return { handled: true, remaining: "" };
|
|
83
122
|
}
|
|
84
123
|
}
|
package/src/components/editor.ts
CHANGED
|
@@ -467,8 +467,12 @@ export class Editor implements Component, Focusable {
|
|
|
467
467
|
onAutocompleteCancel?: () => void;
|
|
468
468
|
disableSubmit: boolean = false;
|
|
469
469
|
|
|
470
|
-
// Custom top border (for status line integration)
|
|
470
|
+
// Custom top border (for status line integration). Either an eager `content`
|
|
471
|
+
// (set once, reused every frame) or a `provider` that recomputes lazily just
|
|
472
|
+
// before the editor paints — the second form lets the host coalesce
|
|
473
|
+
// per-event rebuilds down to one per rendered frame (see #4145).
|
|
471
474
|
#topBorderContent?: EditorTopBorder;
|
|
475
|
+
#topBorderProvider?: (availableWidth: number) => EditorTopBorder | undefined;
|
|
472
476
|
#borderVisible = true;
|
|
473
477
|
|
|
474
478
|
constructor(theme: EditorTheme) {
|
|
@@ -483,11 +487,30 @@ export class Editor implements Component, Focusable {
|
|
|
483
487
|
/**
|
|
484
488
|
* Set custom content for the top border (e.g., status line).
|
|
485
489
|
* Pass undefined to use the default plain border.
|
|
490
|
+
*
|
|
491
|
+
* Eager: the passed value is cached and reused every frame. Callers that
|
|
492
|
+
* mutate status upstream must recompute and call this again. Prefer
|
|
493
|
+
* {@link setTopBorderProvider} for high-frequency updates — it collapses
|
|
494
|
+
* per-event rebuilds to one per painted frame.
|
|
486
495
|
*/
|
|
487
496
|
setTopBorder(content: EditorTopBorder | undefined): void {
|
|
488
497
|
this.#topBorderContent = content;
|
|
489
498
|
}
|
|
490
499
|
|
|
500
|
+
/**
|
|
501
|
+
* Install a lazy provider invoked once per editor render with the current
|
|
502
|
+
* `availableWidth`. Overrides any eager content set via {@link setTopBorder}
|
|
503
|
+
* — pass `undefined` to detach and fall back to the eager slot.
|
|
504
|
+
*
|
|
505
|
+
* Use this when the top border derives from state that mutates far faster
|
|
506
|
+
* than the render cadence (session events, streaming, subagent updates).
|
|
507
|
+
* The TUI already throttles renders, so a provider is invoked at most once
|
|
508
|
+
* per frame and never does wasted work between paints.
|
|
509
|
+
*/
|
|
510
|
+
setTopBorderProvider(provider: ((availableWidth: number) => EditorTopBorder | undefined) | undefined): void {
|
|
511
|
+
this.#topBorderProvider = provider;
|
|
512
|
+
}
|
|
513
|
+
|
|
491
514
|
/**
|
|
492
515
|
* Show or hide the editor border chrome.
|
|
493
516
|
*/
|
|
@@ -806,8 +829,12 @@ export class Editor implements Component, Focusable {
|
|
|
806
829
|
if (borderVisible) {
|
|
807
830
|
// Render top border: ╭─ [status content] ────────────────╮
|
|
808
831
|
const topFillWidth = Math.max(0, width - borderWidth * 2);
|
|
809
|
-
|
|
810
|
-
|
|
832
|
+
// Provider (lazy) wins over eager content — a host that installs both
|
|
833
|
+
// wants the coalesced path; falling back to eager keeps existing
|
|
834
|
+
// setTopBorder callers working unchanged.
|
|
835
|
+
const topBorder = this.#topBorderProvider ? this.#topBorderProvider(topFillWidth) : this.#topBorderContent;
|
|
836
|
+
if (topBorder) {
|
|
837
|
+
const { content, width: statusWidth } = topBorder;
|
|
811
838
|
if (statusWidth <= topFillWidth) {
|
|
812
839
|
// Status fits - add fill after it
|
|
813
840
|
const fillWidth = topFillWidth - statusWidth;
|
package/src/fuzzy.ts
CHANGED
|
@@ -47,12 +47,42 @@ function normalizeForSearch(value: string): string {
|
|
|
47
47
|
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
48
48
|
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
49
49
|
.toLowerCase()
|
|
50
|
-
.replace(/[
|
|
50
|
+
.replace(/[^\p{Letter}\p{Mark}\p{Number}]+/gu, " ")
|
|
51
51
|
.trim()
|
|
52
52
|
.replace(/\s+/g, " ");
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// Module-level memo of the per-text search index. `buildSearchIndex` is a pure
|
|
56
|
+
// function of `text`, but selectors call it once per candidate per keystroke —
|
|
57
|
+
// the same stable candidate list is re-filtered as the user types. Caching the
|
|
58
|
+
// index across keystrokes eliminates the redundant normalize + word-split + Set
|
|
59
|
+
// build on every character. Consumers only read the result, so sharing is safe.
|
|
60
|
+
//
|
|
61
|
+
// Admission is conservative so the cache helps the repeated-filter hot path
|
|
62
|
+
// without paying for one-off text: only short texts are cached (long inputs —
|
|
63
|
+
// pasted prompts, transcripts searched via the message selector — would bloat
|
|
64
|
+
// memory), and admission stops at the cap instead of evicting, so a stream of
|
|
65
|
+
// unique texts (message/session search) can't churn the map.
|
|
66
|
+
const INDEX_CACHE_MAX = 4096;
|
|
67
|
+
const MAX_CACHED_TEXT_LEN = 4096;
|
|
68
|
+
const indexCache = new Map<string, SearchIndex>();
|
|
69
|
+
|
|
55
70
|
function buildSearchIndex(text: string): SearchIndex {
|
|
71
|
+
// Long inputs (pasted prompts, transcripts) are never cached; bypass the Map
|
|
72
|
+
// entirely so they don't pay a hash lookup on every search.
|
|
73
|
+
if (text.length > MAX_CACHED_TEXT_LEN) return buildUncachedSearchIndex(text);
|
|
74
|
+
|
|
75
|
+
const cached = indexCache.get(text);
|
|
76
|
+
if (cached !== undefined) return cached;
|
|
77
|
+
|
|
78
|
+
const result = buildUncachedSearchIndex(text);
|
|
79
|
+
if (indexCache.size < INDEX_CACHE_MAX) {
|
|
80
|
+
indexCache.set(text, result);
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function buildUncachedSearchIndex(text: string): SearchIndex {
|
|
56
86
|
const normalized = normalizeForSearch(text);
|
|
57
87
|
if (normalized.length === 0) {
|
|
58
88
|
return { normalized, compact: "", compactWordStarts: new Set(), words: [] };
|
|
@@ -236,9 +266,22 @@ function scoreToken(token: string, index: SearchIndex): FuzzyMatch {
|
|
|
236
266
|
return best;
|
|
237
267
|
}
|
|
238
268
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
269
|
+
/** A query normalized and split once, so `fuzzyRank` doesn't re-normalize the
|
|
270
|
+
* same query for every candidate in the list. */
|
|
271
|
+
interface PreparedQuery {
|
|
272
|
+
normalized: string;
|
|
273
|
+
tokens: string[];
|
|
274
|
+
compact: string;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function prepareQuery(query: string): PreparedQuery | null {
|
|
278
|
+
const normalized = normalizeForSearch(query);
|
|
279
|
+
if (normalized.length === 0) return null;
|
|
280
|
+
return { normalized, tokens: normalized.split(" "), compact: normalized.replaceAll(" ", "") };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function fuzzyMatchCore(pq: PreparedQuery | null, text: string): FuzzyMatch {
|
|
284
|
+
if (pq === null) {
|
|
242
285
|
return { matches: true, score: 0 };
|
|
243
286
|
}
|
|
244
287
|
|
|
@@ -248,20 +291,19 @@ export function fuzzyMatch(query: string, text: string): FuzzyMatch {
|
|
|
248
291
|
}
|
|
249
292
|
|
|
250
293
|
let totalScore = 0;
|
|
251
|
-
const phraseIndex = index.normalized.indexOf(
|
|
252
|
-
if (phraseIndex >= 0 && isWordBoundaryPhrase(index.normalized, phraseIndex,
|
|
294
|
+
const phraseIndex = index.normalized.indexOf(pq.normalized);
|
|
295
|
+
if (phraseIndex >= 0 && isWordBoundaryPhrase(index.normalized, phraseIndex, pq.normalized.length)) {
|
|
253
296
|
totalScore -= PHRASE_BONUS;
|
|
254
297
|
totalScore += phraseIndex * 0.01;
|
|
255
298
|
}
|
|
256
299
|
|
|
257
|
-
const
|
|
258
|
-
const compactPhraseIndex = index.compact.indexOf(compactQuery);
|
|
300
|
+
const compactPhraseIndex = index.compact.indexOf(pq.compact);
|
|
259
301
|
if (compactPhraseIndex >= 0 && index.compactWordStarts.has(compactPhraseIndex)) {
|
|
260
302
|
totalScore -= COMPACT_PHRASE_BONUS;
|
|
261
303
|
totalScore += compactPhraseIndex * 0.01;
|
|
262
304
|
}
|
|
263
305
|
|
|
264
|
-
for (const token of
|
|
306
|
+
for (const token of pq.tokens) {
|
|
265
307
|
const match = scoreToken(token, index);
|
|
266
308
|
if (!match.matches) {
|
|
267
309
|
return { matches: false, score: 0 };
|
|
@@ -272,6 +314,10 @@ export function fuzzyMatch(query: string, text: string): FuzzyMatch {
|
|
|
272
314
|
return { matches: true, score: totalScore };
|
|
273
315
|
}
|
|
274
316
|
|
|
317
|
+
export function fuzzyMatch(query: string, text: string): FuzzyMatch {
|
|
318
|
+
return fuzzyMatchCore(prepareQuery(query), text);
|
|
319
|
+
}
|
|
320
|
+
|
|
275
321
|
/**
|
|
276
322
|
* Filter and sort items by fuzzy match quality (best matches first).
|
|
277
323
|
* Supports space-separated tokens: all tokens must match.
|
|
@@ -281,9 +327,10 @@ export function fuzzyRank<T>(items: T[], query: string, getText: (item: T) => st
|
|
|
281
327
|
return items.map(item => ({ item, score: 0 }));
|
|
282
328
|
}
|
|
283
329
|
|
|
330
|
+
const pq = prepareQuery(query);
|
|
284
331
|
const results: FuzzyFilterResult<T>[] = [];
|
|
285
332
|
for (const item of items) {
|
|
286
|
-
const match =
|
|
333
|
+
const match = fuzzyMatchCore(pq, getText(item));
|
|
287
334
|
if (match.matches) {
|
|
288
335
|
results.push({ item, score: match.score });
|
|
289
336
|
}
|
|
@@ -296,3 +343,14 @@ export function fuzzyRank<T>(items: T[], query: string, getText: (item: T) => st
|
|
|
296
343
|
export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[] {
|
|
297
344
|
return fuzzyRank(items, query, getText).map(result => result.item);
|
|
298
345
|
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Clear the fuzzy search-index cache. Intended for tests/benchmarks so a fresh
|
|
349
|
+
* cold-start typing session can be measured on demand; not part of the supported
|
|
350
|
+
* TUI API.
|
|
351
|
+
*
|
|
352
|
+
* @internal
|
|
353
|
+
*/
|
|
354
|
+
export function resetFuzzyIndexCache(): void {
|
|
355
|
+
indexCache.clear();
|
|
356
|
+
}
|