@linxiraos/pi-tui 1.0.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +2219 -0
  2. package/README.md +705 -0
  3. package/dist/types/autocomplete.d.ts +116 -0
  4. package/dist/types/bracketed-paste.d.ts +51 -0
  5. package/dist/types/components/box.d.ts +31 -0
  6. package/dist/types/components/cancellable-loader.d.ts +21 -0
  7. package/dist/types/components/editor.d.ts +162 -0
  8. package/dist/types/components/image.d.ts +112 -0
  9. package/dist/types/components/input.d.ts +25 -0
  10. package/dist/types/components/loader.d.ts +25 -0
  11. package/dist/types/components/markdown.d.ts +88 -0
  12. package/dist/types/components/scroll-view.d.ts +62 -0
  13. package/dist/types/components/select-list.d.ts +69 -0
  14. package/dist/types/components/settings-list.d.ts +123 -0
  15. package/dist/types/components/spacer.d.ts +11 -0
  16. package/dist/types/components/tab-bar.d.ts +89 -0
  17. package/dist/types/components/text.d.ts +27 -0
  18. package/dist/types/components/truncated-text.d.ts +10 -0
  19. package/dist/types/deccara.d.ts +49 -0
  20. package/dist/types/desktop-notify.d.ts +52 -0
  21. package/dist/types/editor-component.d.ts +38 -0
  22. package/dist/types/fuzzy.d.ts +48 -0
  23. package/dist/types/index.d.ts +32 -0
  24. package/dist/types/keybindings.d.ts +197 -0
  25. package/dist/types/keys.d.ts +210 -0
  26. package/dist/types/kill-ring.d.ts +20 -0
  27. package/dist/types/kitty-graphics.d.ts +76 -0
  28. package/dist/types/latex-block.d.ts +8 -0
  29. package/dist/types/latex-to-unicode.d.ts +50 -0
  30. package/dist/types/loop-watchdog.d.ts +44 -0
  31. package/dist/types/mouse.d.ts +67 -0
  32. package/dist/types/stdin-buffer.d.ts +60 -0
  33. package/dist/types/symbols.d.ts +25 -0
  34. package/dist/types/terminal-capabilities.d.ts +285 -0
  35. package/dist/types/terminal.d.ts +175 -0
  36. package/dist/types/tmux.d.ts +6 -0
  37. package/dist/types/ttyid.d.ts +9 -0
  38. package/dist/types/tui.d.ts +457 -0
  39. package/dist/types/utils.d.ts +100 -0
  40. package/package.json +70 -0
  41. package/src/autocomplete.ts +1079 -0
  42. package/src/bracketed-paste.ts +123 -0
  43. package/src/components/box.ts +236 -0
  44. package/src/components/cancellable-loader.ts +40 -0
  45. package/src/components/editor.ts +3301 -0
  46. package/src/components/image.ts +460 -0
  47. package/src/components/input.ts +482 -0
  48. package/src/components/loader.ts +174 -0
  49. package/src/components/markdown.ts +3119 -0
  50. package/src/components/scroll-view.ts +227 -0
  51. package/src/components/select-list.ts +539 -0
  52. package/src/components/settings-list.ts +793 -0
  53. package/src/components/spacer.ts +32 -0
  54. package/src/components/tab-bar.ts +300 -0
  55. package/src/components/text.ts +173 -0
  56. package/src/components/truncated-text.ts +69 -0
  57. package/src/deccara.ts +314 -0
  58. package/src/desktop-notify.ts +192 -0
  59. package/src/editor-component.ts +74 -0
  60. package/src/fuzzy.ts +384 -0
  61. package/src/index.ts +51 -0
  62. package/src/keybindings.ts +346 -0
  63. package/src/keys.ts +566 -0
  64. package/src/kill-ring.ts +51 -0
  65. package/src/kitty-graphics.ts +171 -0
  66. package/src/latex-block.ts +1338 -0
  67. package/src/latex-to-unicode.ts +2017 -0
  68. package/src/loop-watchdog.ts +115 -0
  69. package/src/mouse.ts +105 -0
  70. package/src/stdin-buffer.ts +781 -0
  71. package/src/symbols.ts +26 -0
  72. package/src/terminal-capabilities.ts +1211 -0
  73. package/src/terminal.ts +1854 -0
  74. package/src/tmux.ts +14 -0
  75. package/src/ttyid.ts +84 -0
  76. package/src/tui.ts +4275 -0
  77. package/src/utils.ts +619 -0
package/src/fuzzy.ts ADDED
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Fuzzy matching utilities.
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
+ *
8
+ * Lower score = better match.
9
+ */
10
+
11
+ export interface FuzzyMatch {
12
+ matches: boolean;
13
+ score: number;
14
+ }
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
+
41
+ const ALPHANUMERIC_SWAP_PENALTY = 5;
42
+ const COMPACT_PHRASE_BONUS = 1200;
43
+ const PHRASE_BONUS = 1000;
44
+
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(/[^\p{Letter}\p{Mark}\p{Number}]+/gu, " ")
51
+ .trim()
52
+ .replace(/\s+/g, " ");
53
+ }
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
+
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 {
86
+ const normalized = normalizeForSearch(text);
87
+ if (normalized.length === 0) {
88
+ return { normalized, compact: "", compactWordStarts: new Set(), words: [] };
89
+ }
90
+
91
+ const words: SearchWord[] = [];
92
+ const compactWordStarts = new Set<number>();
93
+ let index = 0;
94
+ let compactIndex = 0;
95
+ let ordinal = 0;
96
+ for (const word of normalized.split(" ")) {
97
+ words.push({ text: word, index, ordinal });
98
+ compactWordStarts.add(compactIndex);
99
+ index += word.length + 1;
100
+ compactIndex += word.length;
101
+ ordinal++;
102
+ }
103
+
104
+ return { normalized, compact: normalized.replaceAll(" ", ""), compactWordStarts, words };
105
+ }
106
+
107
+ function scoreCharacters(queryLower: string, textLower: string): CharacterMatch {
108
+ if (queryLower.length === 0) {
109
+ return { matches: true, score: 0, span: 0 };
110
+ }
111
+
112
+ if (queryLower.length > textLower.length) {
113
+ return { matches: false, score: 0, span: 0 };
114
+ }
115
+
116
+ let queryIndex = 0;
117
+ let score = 0;
118
+ let firstMatchIndex = -1;
119
+ let lastMatchIndex = -1;
120
+ let consecutiveMatches = 0;
121
+
122
+ for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
123
+ if (textLower[i] === queryLower[queryIndex]) {
124
+ if (firstMatchIndex < 0) firstMatchIndex = i;
125
+
126
+ if (lastMatchIndex === i - 1) {
127
+ consecutiveMatches++;
128
+ score -= consecutiveMatches * 5;
129
+ } else {
130
+ consecutiveMatches = 0;
131
+ if (lastMatchIndex >= 0) {
132
+ score += (i - lastMatchIndex - 1) * 2;
133
+ }
134
+ }
135
+
136
+ score += i * 0.1;
137
+ lastMatchIndex = i;
138
+ queryIndex++;
139
+ }
140
+ }
141
+
142
+ if (queryIndex < queryLower.length) {
143
+ return { matches: false, score: 0, span: 0 };
144
+ }
145
+
146
+ return { matches: true, score, span: lastMatchIndex - firstMatchIndex + 1 };
147
+ }
148
+
149
+ function buildAlphanumericSwapQueries(queryLower: string): string[] {
150
+ const variants = new Set<string>();
151
+ for (let i = 0; i < queryLower.length - 1; i++) {
152
+ const current = queryLower[i];
153
+ const next = queryLower[i + 1];
154
+ const isAlphaNumSwap =
155
+ (current && /[a-z]/.test(current) && next && /\d/.test(next)) ||
156
+ (current && /\d/.test(current) && next && /[a-z]/.test(next));
157
+ if (!isAlphaNumSwap) continue;
158
+ const swapped = queryLower.slice(0, i) + next + current + queryLower.slice(i + 2);
159
+ variants.add(swapped);
160
+ }
161
+ return [...variants];
162
+ }
163
+
164
+ function withPosition(score: number, index: number): number {
165
+ return score + index * 0.01;
166
+ }
167
+
168
+ function isWordBoundaryPhrase(normalized: string, index: number, length: number): boolean {
169
+ const before = index === 0 || normalized[index - 1] === " ";
170
+ const afterIndex = index + length;
171
+ const after = afterIndex === normalized.length || normalized[afterIndex] === " ";
172
+ return before && after;
173
+ }
174
+
175
+ function scoreTokenAgainstWord(token: string, word: SearchWord): FuzzyMatch | null {
176
+ if (word.text === token) {
177
+ return { matches: true, score: withPosition(-200, word.index) };
178
+ }
179
+
180
+ if (word.text.startsWith(token)) {
181
+ return { matches: true, score: withPosition(-170 + (word.text.length - token.length) * 0.5, word.index) };
182
+ }
183
+
184
+ if (token.startsWith(word.text) && token.length - word.text.length <= 2) {
185
+ return { matches: true, score: withPosition(-150 + token.length - word.text.length, word.index) };
186
+ }
187
+
188
+ const substringIndex = word.text.indexOf(token);
189
+ if (substringIndex >= 0) {
190
+ return { matches: true, score: withPosition(-20 + substringIndex, word.index) };
191
+ }
192
+
193
+ const characterMatch = scoreCharacters(token, word.text);
194
+ if (!characterMatch.matches) return null;
195
+
196
+ const maxSpan = Math.max(token.length + 2, Math.ceil(token.length * 1.8));
197
+ if (characterMatch.span > maxSpan) return null;
198
+
199
+ return { matches: true, score: withPosition(-40 + characterMatch.score, word.index) };
200
+ }
201
+
202
+ function scoreAcronym(token: string, index: SearchIndex): FuzzyMatch | null {
203
+ if (token.length < 2 || token.length > 4 || index.words.length === 0) return null;
204
+
205
+ let queryIndex = 0;
206
+ let firstOrdinal = -1;
207
+ let lastOrdinal = -1;
208
+ let firstTextIndex = 0;
209
+
210
+ for (const word of index.words) {
211
+ if (word.text[0] !== token[queryIndex]) continue;
212
+ if (firstOrdinal < 0) {
213
+ firstOrdinal = word.ordinal;
214
+ firstTextIndex = word.index;
215
+ }
216
+ lastOrdinal = word.ordinal;
217
+ queryIndex++;
218
+ if (queryIndex === token.length) break;
219
+ }
220
+
221
+ if (queryIndex < token.length || firstOrdinal < 0 || lastOrdinal < 0) return null;
222
+
223
+ const wordSpan = lastOrdinal - firstOrdinal + 1;
224
+ if (wordSpan > token.length + 2) return null;
225
+
226
+ return { matches: true, score: withPosition(-30 + wordSpan * 4 - token.length * 2, firstTextIndex) };
227
+ }
228
+
229
+ function scoreTokenDirect(token: string, index: SearchIndex): FuzzyMatch {
230
+ if (token.length === 0) return { matches: true, score: 0 };
231
+
232
+ let best: FuzzyMatch | null = null;
233
+ const compactIndex = index.compact.indexOf(token);
234
+ if (compactIndex >= 0 && index.compactWordStarts.has(compactIndex)) {
235
+ best = { matches: true, score: withPosition(-140, compactIndex) };
236
+ }
237
+
238
+ for (const word of index.words) {
239
+ const match = scoreTokenAgainstWord(token, word);
240
+ if (match && (!best || match.score < best.score)) {
241
+ best = match;
242
+ }
243
+ }
244
+
245
+ const acronym = scoreAcronym(token, index);
246
+ if (acronym && (!best || acronym.score < best.score)) {
247
+ best = acronym;
248
+ }
249
+
250
+ return best ?? { matches: false, score: 0 };
251
+ }
252
+
253
+ function scoreToken(token: string, index: SearchIndex): FuzzyMatch {
254
+ let best = scoreTokenDirect(token, index);
255
+ if (best.matches) return best;
256
+
257
+ for (const variant of buildAlphanumericSwapQueries(token)) {
258
+ const match = scoreTokenDirect(variant, index);
259
+ if (!match.matches) continue;
260
+ const score = match.score + ALPHANUMERIC_SWAP_PENALTY;
261
+ if (!best.matches || score < best.score) {
262
+ best = { matches: true, score };
263
+ }
264
+ }
265
+
266
+ return best;
267
+ }
268
+
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, index: SearchIndex): FuzzyMatch {
284
+ if (pq === null) {
285
+ return { matches: true, score: 0 };
286
+ }
287
+
288
+ if (index.words.length === 0) {
289
+ return { matches: false, score: 0 };
290
+ }
291
+
292
+ let totalScore = 0;
293
+ const phraseIndex = index.normalized.indexOf(pq.normalized);
294
+ if (phraseIndex >= 0 && isWordBoundaryPhrase(index.normalized, phraseIndex, pq.normalized.length)) {
295
+ totalScore -= PHRASE_BONUS;
296
+ totalScore += phraseIndex * 0.01;
297
+ }
298
+
299
+ const compactPhraseIndex = index.compact.indexOf(pq.compact);
300
+ if (compactPhraseIndex >= 0 && index.compactWordStarts.has(compactPhraseIndex)) {
301
+ totalScore -= COMPACT_PHRASE_BONUS;
302
+ totalScore += compactPhraseIndex * 0.01;
303
+ }
304
+
305
+ for (const token of pq.tokens) {
306
+ const match = scoreToken(token, index);
307
+ if (!match.matches) {
308
+ return { matches: false, score: 0 };
309
+ }
310
+ totalScore += match.score;
311
+ }
312
+
313
+ return { matches: true, score: totalScore };
314
+ }
315
+
316
+ export function fuzzyMatch(query: string, text: string): FuzzyMatch {
317
+ const pq = prepareQuery(query);
318
+ if (pq === null) return { matches: true, score: 0 };
319
+ return fuzzyMatchCore(pq, buildSearchIndex(text));
320
+ }
321
+
322
+ /**
323
+ * A text prepared once for repeated fuzzy matching.
324
+ *
325
+ * `fuzzyMatch` builds a search index per call; the module cache only admits
326
+ * texts up to {@link MAX_CACHED_TEXT_LEN}, so long corpora (session or
327
+ * transcript search) rebuild the index on every keystroke — the dominant cost
328
+ * when a selector re-filters a stable candidate list as the user types. Build
329
+ * one `FuzzyText` per candidate and call {@link match} per query instead; the
330
+ * index lives exactly as long as the caller's reference.
331
+ */
332
+ export class FuzzyText {
333
+ readonly #index: SearchIndex;
334
+
335
+ constructor(text: string) {
336
+ this.#index = buildUncachedSearchIndex(text);
337
+ }
338
+
339
+ /** Match `query` (space-separated tokens; all must match) against the prepared text. */
340
+ match(query: string): FuzzyMatch {
341
+ return fuzzyMatchCore(prepareQuery(query), this.#index);
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Filter and sort items by fuzzy match quality (best matches first).
347
+ * Supports space-separated tokens: all tokens must match.
348
+ */
349
+ export function fuzzyRank<T>(items: T[], query: string, getText: (item: T) => string): FuzzyFilterResult<T>[] {
350
+ if (!query.trim()) {
351
+ return items.map(item => ({ item, score: 0 }));
352
+ }
353
+
354
+ // A non-blank query that normalizes to empty (pure punctuation) matches
355
+ // everything with score 0, but still calls getText per item — consumers rely
356
+ // on its side effects (see fuzzy-cache.test.ts).
357
+ const pq = prepareQuery(query);
358
+ const results: FuzzyFilterResult<T>[] = [];
359
+ for (const item of items) {
360
+ const text = getText(item);
361
+ const match = pq === null ? { matches: true, score: 0 } : fuzzyMatchCore(pq, buildSearchIndex(text));
362
+ if (match.matches) {
363
+ results.push({ item, score: match.score });
364
+ }
365
+ }
366
+
367
+ results.sort((a, b) => a.score - b.score);
368
+ return results;
369
+ }
370
+
371
+ export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[] {
372
+ return fuzzyRank(items, query, getText).map(result => result.item);
373
+ }
374
+
375
+ /**
376
+ * Clear the fuzzy search-index cache. Intended for tests/benchmarks so a fresh
377
+ * cold-start typing session can be measured on demand; not part of the supported
378
+ * TUI API.
379
+ *
380
+ * @internal
381
+ */
382
+ export function resetFuzzyIndexCache(): void {
383
+ indexCache.clear();
384
+ }
package/src/index.ts ADDED
@@ -0,0 +1,51 @@
1
+ // Core TUI interfaces and classes
2
+
3
+ // Autocomplete support
4
+ export * from "./autocomplete";
5
+ // Components
6
+ export * from "./components/box";
7
+ export * from "./components/cancellable-loader";
8
+ export * from "./components/editor";
9
+ export * from "./components/image";
10
+ export * from "./components/input";
11
+ export * from "./components/loader";
12
+ export * from "./components/markdown";
13
+ export * from "./components/scroll-view";
14
+ export * from "./components/select-list";
15
+ export * from "./components/settings-list";
16
+ export * from "./components/spacer";
17
+ export * from "./components/tab-bar";
18
+ export * from "./components/text";
19
+ export * from "./components/truncated-text";
20
+ // DECCARA rectangular-SGR background-fill optimizer
21
+ export * from "./deccara";
22
+ // Desktop notifications via D-Bus (Linux freedesktop notifications)
23
+ export * from "./desktop-notify";
24
+ // Editor component interface (for custom editors)
25
+ export type * from "./editor-component";
26
+ // Fuzzy matching
27
+ export * from "./fuzzy";
28
+ // Keybindings
29
+ export * from "./keybindings";
30
+ // Kitty keyboard protocol helpers
31
+ export * from "./keys";
32
+ // Kitty graphics: Unicode placeholders
33
+ export * from "./kitty-graphics";
34
+ // LaTeX → Unicode/ANSI math rendering
35
+ export * from "./latex-block";
36
+ export * from "./latex-to-unicode";
37
+ // SGR mouse report parsing
38
+ export * from "./mouse";
39
+ // Mermaid diagram support
40
+ // Input buffering for batch splitting
41
+ export * from "./stdin-buffer";
42
+ export type * from "./symbols";
43
+ // Terminal interface and implementations
44
+ export * from "./terminal";
45
+ // Terminal image support
46
+ export * from "./terminal-capabilities";
47
+ // TTY ID
48
+ export * from "./ttyid";
49
+ export * from "./tui";
50
+ // Utilities
51
+ export * from "./utils";