@oh-my-pi/pi-tui 15.11.4 → 15.11.7

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 CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.11.5] - 2026-06-12
6
+ ### Added
7
+
8
+ - Added `fuzzyRank` to return sorted matches together with a fuzzy score
9
+ - Added a configurable `Input.prompt` field (defaults to `"> "`; set to `""` for chrome-less embedding inside custom banners)
10
+
11
+ ### Changed
12
+
13
+ - Changed fuzzy matching to normalize queries and text into words, including camelCase and punctuation separators, before scoring
14
+ - Changed `Input.setValue` to place the cursor at the end of the new value instead of clamping it to its previous position, so typing after seeding a prefilled value appends rather than prepends
15
+
16
+ ### Fixed
17
+
18
+ - Fixed multi-word searches so `fuzzyMatch` no longer matches when query letters are only scattered across unrelated words
19
+
5
20
  ## [15.11.4] - 2026-06-12
6
21
  ### Added
7
22
 
@@ -4,6 +4,8 @@ import { type Component, type Focusable } from "../tui";
4
4
  */
5
5
  export declare class Input implements Component, Focusable {
6
6
  #private;
7
+ /** Rendered before the editable area; set to "" for chrome-less embedding. */
8
+ prompt: string;
7
9
  onSubmit?: (value: string) => void;
8
10
  onEscape?: () => void;
9
11
  /** Focusable interface - set by TUI when focus changes */
@@ -1,15 +1,24 @@
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
  export interface FuzzyMatch {
7
11
  matches: boolean;
8
12
  score: number;
9
13
  }
14
+ export interface FuzzyFilterResult<T> {
15
+ item: T;
16
+ score: number;
17
+ }
10
18
  export declare function fuzzyMatch(query: string, text: string): FuzzyMatch;
11
19
  /**
12
20
  * Filter and sort items by fuzzy match quality (best matches first).
13
21
  * Supports space-separated tokens: all tokens must match.
14
22
  */
23
+ export declare function fuzzyRank<T>(items: T[], query: string, getText: (item: T) => string): FuzzyFilterResult<T>[];
15
24
  export declare function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "15.11.4",
4
+ "version": "15.11.7",
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": "15.11.4",
41
- "@oh-my-pi/pi-utils": "15.11.4",
40
+ "@oh-my-pi/pi-natives": "15.11.7",
41
+ "@oh-my-pi/pi-utils": "15.11.7",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.4"
44
44
  },
@@ -28,6 +28,8 @@ export class Input implements Component, Focusable {
28
28
  #value: string = "";
29
29
  #cursor: number = 0; // Cursor position in the value
30
30
  #useTerminalCursor = false;
31
+ /** Rendered before the editable area; set to "" for chrome-less embedding. */
32
+ prompt = "> ";
31
33
  onSubmit?: (value: string) => void;
32
34
  onEscape?: () => void;
33
35
 
@@ -50,7 +52,8 @@ export class Input implements Component, Focusable {
50
52
 
51
53
  setValue(value: string): void {
52
54
  this.#value = value;
53
- this.#cursor = Math.min(this.#cursor, value.length);
55
+ // Callers seed or replace the value wholesale; typing continues at the end.
56
+ this.#cursor = value.length;
54
57
  }
55
58
 
56
59
  setUseTerminalCursor(useTerminalCursor: boolean): void {
@@ -399,8 +402,8 @@ export class Input implements Component, Focusable {
399
402
 
400
403
  render(width: number): readonly string[] {
401
404
  // Calculate visible window
402
- const prompt = "> ";
403
- const availableWidth = width - prompt.length;
405
+ const prompt = this.prompt;
406
+ const availableWidth = width - visibleWidth(prompt);
404
407
 
405
408
  if (availableWidth <= 0) {
406
409
  return [prompt];
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
  }
package/src/tui.ts CHANGED
@@ -2712,6 +2712,18 @@ export class TUI extends Container {
2712
2712
  #emitAltFrame(lines: string[], width: number, height: number): void {
2713
2713
  const fitted: string[] = new Array(height);
2714
2714
  for (let r = 0; r < height; r++) fitted[r] = lines[r] ?? "";
2715
+ // Flush queued image-data transmits (`a=t`, no visible output) before the
2716
+ // paint so id-keyed placements and placeholder cells composed into this
2717
+ // frame resolve against loaded data. The normal-screen path flushes these
2718
+ // ahead of its paint; without this, an image first shown inside a
2719
+ // fullscreen overlay (e.g. the settings shape preview) would render as
2720
+ // blank placeholder cells until the overlay closed.
2721
+ const imageTransmits = this.#imageBudget.takeTransmits();
2722
+ if (imageTransmits.length > 0) {
2723
+ let transmitBuffer = "";
2724
+ for (const seq of imageTransmits) transmitBuffer += seq;
2725
+ this.terminal.write(transmitBuffer);
2726
+ }
2715
2727
  // Skip an identical repaint (the modal is mostly static between
2716
2728
  // keystrokes) — unless a forced repaint (resetDisplay,
2717
2729
  // requestRender(true)) is pending: the redraw gesture must repair a