@prometheus-ai/tui 0.5.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 (65) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +704 -0
  3. package/dist/types/autocomplete.d.ts +76 -0
  4. package/dist/types/bracketed-paste.d.ts +26 -0
  5. package/dist/types/components/box.d.ts +17 -0
  6. package/dist/types/components/cancellable-loader.d.ts +21 -0
  7. package/dist/types/components/editor.d.ts +105 -0
  8. package/dist/types/components/image.d.ts +84 -0
  9. package/dist/types/components/input.d.ts +18 -0
  10. package/dist/types/components/loader.d.ts +13 -0
  11. package/dist/types/components/markdown.d.ts +61 -0
  12. package/dist/types/components/scroll-view.d.ts +40 -0
  13. package/dist/types/components/select-list.d.ts +48 -0
  14. package/dist/types/components/settings-list.d.ts +41 -0
  15. package/dist/types/components/spacer.d.ts +11 -0
  16. package/dist/types/components/tab-bar.d.ts +56 -0
  17. package/dist/types/components/text.d.ts +13 -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/editor-component.d.ts +36 -0
  21. package/dist/types/fuzzy.d.ts +15 -0
  22. package/dist/types/index.d.ts +28 -0
  23. package/dist/types/keybindings.d.ts +189 -0
  24. package/dist/types/keys.d.ts +208 -0
  25. package/dist/types/kill-ring.d.ts +27 -0
  26. package/dist/types/kitty-graphics.d.ts +94 -0
  27. package/dist/types/stdin-buffer.d.ts +43 -0
  28. package/dist/types/symbols.d.ts +25 -0
  29. package/dist/types/terminal-capabilities.d.ts +196 -0
  30. package/dist/types/terminal.d.ts +103 -0
  31. package/dist/types/ttyid.d.ts +9 -0
  32. package/dist/types/tui.d.ts +275 -0
  33. package/dist/types/utils.d.ts +89 -0
  34. package/package.json +73 -0
  35. package/src/autocomplete.ts +871 -0
  36. package/src/bracketed-paste.ts +47 -0
  37. package/src/components/box.ts +156 -0
  38. package/src/components/cancellable-loader.ts +40 -0
  39. package/src/components/editor.ts +2695 -0
  40. package/src/components/image.ts +318 -0
  41. package/src/components/input.ts +459 -0
  42. package/src/components/loader.ts +86 -0
  43. package/src/components/markdown.ts +1189 -0
  44. package/src/components/scroll-view.ts +166 -0
  45. package/src/components/select-list.ts +331 -0
  46. package/src/components/settings-list.ts +212 -0
  47. package/src/components/spacer.ts +28 -0
  48. package/src/components/tab-bar.ts +175 -0
  49. package/src/components/text.ts +110 -0
  50. package/src/components/truncated-text.ts +61 -0
  51. package/src/deccara.ts +314 -0
  52. package/src/editor-component.ts +71 -0
  53. package/src/fuzzy.ts +143 -0
  54. package/src/index.ts +44 -0
  55. package/src/keybindings.ts +279 -0
  56. package/src/keys.ts +537 -0
  57. package/src/kill-ring.ts +46 -0
  58. package/src/kitty-graphics.ts +270 -0
  59. package/src/stdin-buffer.ts +423 -0
  60. package/src/symbols.ts +26 -0
  61. package/src/terminal-capabilities.ts +1009 -0
  62. package/src/terminal.ts +1114 -0
  63. package/src/ttyid.ts +70 -0
  64. package/src/tui.ts +2988 -0
  65. package/src/utils.ts +452 -0
package/src/utils.ts ADDED
@@ -0,0 +1,452 @@
1
+ import {
2
+ Ellipsis,
3
+ type ExtractSegmentsResult,
4
+ extractSegments as nativeExtractSegments,
5
+ sliceWithWidth as nativeSliceWithWidth,
6
+ truncateToWidth as nativeTruncateToWidth,
7
+ visibleWidth as nativeVisibleWidth,
8
+ wrapTextWithAnsi as nativeWrapTextWithAnsi,
9
+ type SliceResult,
10
+ } from "@prometheus-ai/natives";
11
+ import { getDefaultTabWidth, getIndentation } from "@prometheus-ai/utils";
12
+
13
+ export { Ellipsis } from "@prometheus-ai/natives";
14
+
15
+ export { getDefaultTabWidth, getIndentation } from "@prometheus-ai/utils";
16
+
17
+ export type TextSizingScale = 1 | 2 | 3;
18
+ export type TextSizingVerticalAlign = "top" | "bottom" | "center";
19
+ export type TextSizingHorizontalAlign = "left" | "right" | "center";
20
+
21
+ export interface TextSizingOptions {
22
+ scale?: TextSizingScale;
23
+ widthCells?: number;
24
+ verticalAlign?: TextSizingVerticalAlign;
25
+ horizontalAlign?: TextSizingHorizontalAlign;
26
+ }
27
+
28
+ const OSC66_UNSAFE = /[\x00-\x1f\x7f-\x9f]/u;
29
+ const OSC66_UNSAFE_GLOBAL = /[\x00-\x1f\x7f-\x9f]/gu;
30
+
31
+ function textSizingVerticalAlignValue(align: TextSizingVerticalAlign | undefined): number | undefined {
32
+ switch (align) {
33
+ case "top":
34
+ return 0;
35
+ case "bottom":
36
+ return 1;
37
+ case "center":
38
+ return 2;
39
+ default:
40
+ return undefined;
41
+ }
42
+ }
43
+
44
+ function textSizingHorizontalAlignValue(align: TextSizingHorizontalAlign | undefined): number | undefined {
45
+ switch (align) {
46
+ case "left":
47
+ return 0;
48
+ case "right":
49
+ return 1;
50
+ case "center":
51
+ return 2;
52
+ default:
53
+ return undefined;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Encode a plain-text span using Kitty's OSC 66 text-sizing protocol. The TUI
59
+ * emits only safe UTF-8 payloads and ST terminators so its ANSI parser and the
60
+ * terminal agree on span boundaries.
61
+ */
62
+ export function encodeTextSized(text: string, options: TextSizingOptions = {}): string {
63
+ const metadata: string[] = [];
64
+ if (options.scale !== undefined) metadata.push(`s=${options.scale}`);
65
+ if (options.widthCells !== undefined && Number.isFinite(options.widthCells)) {
66
+ metadata.push(`w=${Math.max(0, Math.trunc(options.widthCells))}`);
67
+ }
68
+ const verticalAlign = textSizingVerticalAlignValue(options.verticalAlign);
69
+ if (verticalAlign !== undefined) metadata.push(`v=${verticalAlign}`);
70
+ const horizontalAlign = textSizingHorizontalAlignValue(options.horizontalAlign);
71
+ if (horizontalAlign !== undefined) metadata.push(`h=${horizontalAlign}`);
72
+
73
+ const safeText = OSC66_UNSAFE.test(text) ? text.replace(OSC66_UNSAFE_GLOBAL, " ") : text;
74
+ return `\x1b]66;${metadata.join(":")};${safeText}\x1b\\`;
75
+ }
76
+
77
+ export function sliceWithWidth(line: string, startCol: number, length: number, strict?: boolean | null): SliceResult {
78
+ return nativeSliceWithWidth(line, startCol, length, strict ?? null, getDefaultTabWidth());
79
+ }
80
+
81
+ export function truncateToWidth(
82
+ text: string,
83
+ maxWidth: number,
84
+ ellipsisKind?: Ellipsis | null,
85
+ pad?: boolean | null,
86
+ ): string {
87
+ // Guard nullish napi inputs: napi-rs 3 on the Windows prebuilt rejects
88
+ // `null` for `Option<u8>` (Ellipsis) / `Option<bool>` (pad) (issue #848),
89
+ // and `maxWidth` is a required `u32` that throws on `null`/`undefined`
90
+ // everywhere. Pass concrete defaults that mirror the Rust `unwrap_or`s.
91
+ const safeWidth = Number.isFinite(maxWidth) ? Math.max(0, Math.trunc(maxWidth)) : 0;
92
+ let resolvedEllipsis: Ellipsis | null | undefined | string = ellipsisKind;
93
+ if (typeof resolvedEllipsis === "string") {
94
+ resolvedEllipsis = resolvedEllipsis === "" ? Ellipsis.Omit : Ellipsis.Unicode;
95
+ }
96
+ return nativeTruncateToWidth(
97
+ text,
98
+ safeWidth,
99
+ resolvedEllipsis ?? Ellipsis.Unicode,
100
+ pad ?? false,
101
+ getDefaultTabWidth(),
102
+ );
103
+ }
104
+
105
+ export function wrapTextWithAnsi(text: string, width: number): string[] {
106
+ return nativeWrapTextWithAnsi(text, width, getDefaultTabWidth());
107
+ }
108
+
109
+ export function extractSegments(
110
+ line: string,
111
+ beforeEnd: number,
112
+ afterStart: number,
113
+ afterLen: number,
114
+ strictAfter: boolean,
115
+ ): ExtractSegmentsResult {
116
+ return nativeExtractSegments(line, beforeEnd, afterStart, afterLen, strictAfter, getDefaultTabWidth());
117
+ }
118
+
119
+ // Pre-allocated space buffer for padding
120
+ const SPACE_BUFFER = " ".repeat(512);
121
+
122
+ /**
123
+ * Tab width in columns for `file`, using `process.cwd()` as the project root for relative paths.
124
+ */
125
+ export function getIndentationNoescape(file?: string): number {
126
+ return getIndentation(file, process.cwd());
127
+ }
128
+
129
+ /*
130
+ * Replace tabs with configured spacing for consistent rendering.
131
+ */
132
+ export function replaceTabs(text: string, file?: string): string {
133
+ return text.replaceAll("\t", " ".repeat(getIndentation(file)));
134
+ }
135
+
136
+ /**
137
+ * Returns a string of n spaces. Uses a pre-allocated buffer for efficiency.
138
+ */
139
+ export function padding(n: number): string {
140
+ if (n <= 0) return "";
141
+ if (n <= 512) return SPACE_BUFFER.slice(0, n);
142
+ return " ".repeat(n);
143
+ }
144
+
145
+ // Grapheme segmenter (shared instance)
146
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
147
+
148
+ const EXTENDED_PICTOGRAPHIC_REGEX = /\p{Extended_Pictographic}/u;
149
+
150
+ // Matches CSI (`\x1b[…`) and OSC (`\x1b]…` terminated by BEL/ST) escape
151
+ // sequences. Mirrors the standard ansi-regex coverage so visible-span
152
+ // segmentation lines up with the native ANSI scanner.
153
+ const ANSI_ESCAPE_REGEX =
154
+ /[\u001b\u009b][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
155
+
156
+ function pictographicSpanWidth(span: string): number {
157
+ let width = 0;
158
+ for (const { segment } of segmenter.segment(span)) {
159
+ width += EXTENDED_PICTOGRAPHIC_REGEX.test(segment) ? 2 : nativeVisibleWidth(segment, getDefaultTabWidth());
160
+ }
161
+ return width;
162
+ }
163
+
164
+ // Width fallback for strings that mix ANSI styling with ZWJ pictographic
165
+ // emoji. `Intl.Segmenter` would split an escape sequence into individual
166
+ // graphemes, so the native scanner (which only skips ANSI when handed the
167
+ // complete sequence) double-counts the printable SGR bytes. Excise the ANSI
168
+ // spans first — they contribute zero cells — and apply the pictographic
169
+ // grapheme override only to the visible spans, then sum.
170
+ function visibleWidthByGrapheme(str: string): number {
171
+ let width = 0;
172
+ let lastIndex = 0;
173
+ ANSI_ESCAPE_REGEX.lastIndex = 0;
174
+ for (let match = ANSI_ESCAPE_REGEX.exec(str); match !== null; match = ANSI_ESCAPE_REGEX.exec(str)) {
175
+ if (match.index > lastIndex) {
176
+ width += pictographicSpanWidth(str.slice(lastIndex, match.index));
177
+ }
178
+ lastIndex = ANSI_ESCAPE_REGEX.lastIndex;
179
+ }
180
+ if (lastIndex < str.length) {
181
+ width += lastIndex === 0 ? pictographicSpanWidth(str) : pictographicSpanWidth(str.slice(lastIndex));
182
+ }
183
+ return width;
184
+ }
185
+
186
+ /**
187
+ * Get the shared grapheme segmenter instance.
188
+ */
189
+ export function getSegmenter(): Intl.Segmenter {
190
+ return segmenter;
191
+ }
192
+
193
+ export function visibleWidthRaw(str: string): number {
194
+ if (!str) {
195
+ return 0;
196
+ }
197
+
198
+ // Fast path: printable ASCII has one cell per code unit. Defer every
199
+ // control/non-ASCII case (tabs, ANSI/OSC, combining marks, CJK) to the
200
+ // native text engine so all width/slice/wrap helpers share one Unicode
201
+ // model instead of mixing Bun.stringWidth quirks with Rust truncation.
202
+ for (let i = 0; i < str.length; i++) {
203
+ const code = str.charCodeAt(i);
204
+ if (code < 0x20 || code > 0x7e) {
205
+ const tabWidth = getDefaultTabWidth();
206
+ if (str.includes("\x1b]66;")) return nativeVisibleWidth(str, tabWidth);
207
+ return str.includes("\u200d") ? visibleWidthByGrapheme(str) : nativeVisibleWidth(str, tabWidth);
208
+ }
209
+ }
210
+ return str.length;
211
+ }
212
+
213
+ /**
214
+ * Calculate the visible width of a string in terminal columns.
215
+ */
216
+ export function visibleWidth(str: string): number {
217
+ if (!str) return 0;
218
+ return visibleWidthRaw(str);
219
+ }
220
+
221
+ const THAI_LAO_AM_REGEX = /[\u0e33\u0eb3]/;
222
+ const THAI_LAO_AM_GLOBAL_REGEX = /[\u0e33\u0eb3]/g;
223
+
224
+ /**
225
+ * Normalize text for terminal output without changing logical editor content.
226
+ * Some terminals render precomposed Thai/Lao AM vowels inconsistently during
227
+ * differential repaint. Their compatibility decompositions have the same cell
228
+ * width but avoid stale-cell artifacts in terminal renderers.
229
+ */
230
+ export function normalizeTerminalOutput(str: string): string {
231
+ if (!THAI_LAO_AM_REGEX.test(str)) return str;
232
+ return str.replace(THAI_LAO_AM_GLOBAL_REGEX, char => (char === "\u0e33" ? "\u0e4d\u0e32" : "\u0ecd\u0eb2"));
233
+ }
234
+
235
+ const makeBoolArray = (chars: string): Uint8Array => {
236
+ const table = new Uint8Array(128);
237
+ for (let i = 0; i < chars.length; i++) {
238
+ const code = chars.charCodeAt(i);
239
+ if (code < table.length) {
240
+ table[code] = 1;
241
+ }
242
+ }
243
+ return table;
244
+ };
245
+
246
+ const ASCII_WHITESPACE = makeBoolArray("\x09\x0a\x0b\x0c\x0d\x20");
247
+
248
+ /**
249
+ * Check if a character is whitespace.
250
+ */
251
+ export function isWhitespaceChar(char: string): boolean {
252
+ const code = char.codePointAt(0) ?? 0;
253
+ return code < 128 && ASCII_WHITESPACE[code] === 1;
254
+ }
255
+
256
+ const ASCII_PUNCTUATION = makeBoolArray("(){}[]<>.,;:'\"!?+-=*/\\|&%^$#@~`");
257
+
258
+ /**
259
+ * Check if a character is punctuation.
260
+ */
261
+ export function isPunctuationChar(char: string): boolean {
262
+ const code = char.codePointAt(0) ?? 0;
263
+ return code < 128 && ASCII_PUNCTUATION[code] === 1;
264
+ }
265
+
266
+ export type WordNavKind = "whitespace" | "delimiter" | "cjk" | "word" | "other";
267
+
268
+ const WORD_NAV_RE_WHITESPACE = /^\p{White_Space}$/u;
269
+ const WORD_NAV_RE_PUNCT = /^\p{P}$/u;
270
+ const WORD_NAV_RE_SYMBOL = /^\p{S}$/u;
271
+ const WORD_NAV_RE_LETTER = /^\p{L}$/u;
272
+ const WORD_NAV_RE_NUMBER = /^\p{N}$/u;
273
+ const WORD_NAV_RE_HAN = /^\p{Script=Han}$/u;
274
+ const WORD_NAV_RE_HIRAGANA = /^\p{Script=Hiragana}$/u;
275
+ const WORD_NAV_RE_KATAKANA = /^\p{Script=Katakana}$/u;
276
+ const WORD_NAV_RE_HANGUL = /^\p{Script=Hangul}$/u;
277
+
278
+ function firstCodePointChar(str: string): string {
279
+ const cp = str.codePointAt(0);
280
+ if (cp === undefined) return "";
281
+ return String.fromCodePoint(cp);
282
+ }
283
+
284
+ /**
285
+ * Coarse Unicode-aware character classification for word navigation (Option/Alt + Left/Right).
286
+ * This intentionally avoids language-specific word segmentation for predictability across scripts.
287
+ */
288
+ export function getWordNavKind(grapheme: string): WordNavKind {
289
+ if (!grapheme) return "other";
290
+ const ch = firstCodePointChar(grapheme);
291
+ if (!ch) return "other";
292
+ if (WORD_NAV_RE_WHITESPACE.test(ch)) return "whitespace";
293
+ if (WORD_NAV_RE_PUNCT.test(ch) || WORD_NAV_RE_SYMBOL.test(ch)) return "delimiter";
294
+ if (
295
+ WORD_NAV_RE_HAN.test(ch) ||
296
+ WORD_NAV_RE_HIRAGANA.test(ch) ||
297
+ WORD_NAV_RE_KATAKANA.test(ch) ||
298
+ WORD_NAV_RE_HANGUL.test(ch)
299
+ ) {
300
+ return "cjk";
301
+ }
302
+ if (ch === "_" || WORD_NAV_RE_LETTER.test(ch) || WORD_NAV_RE_NUMBER.test(ch)) return "word";
303
+ return "other";
304
+ }
305
+
306
+ const WORD_NAV_JOINERS = new Set(["'", "’", "-", "‐", "‑"]);
307
+
308
+ export function isWordNavJoiner(grapheme: string): boolean {
309
+ const ch = firstCodePointChar(grapheme);
310
+ return WORD_NAV_JOINERS.has(ch);
311
+ }
312
+
313
+ /**
314
+ * Move the cursor one "word" to the left using Unicode-aware coarse navigation.
315
+ *
316
+ * Returns a new cursor index in the range [0, text.length].
317
+ */
318
+ export function moveWordLeft(text: string, cursor: number): number {
319
+ const len = text.length;
320
+ if (len === 0) return 0;
321
+ let i = Math.min(Math.max(cursor, 0), len);
322
+ if (i === 0) return 0;
323
+
324
+ const graphemes = [...segmenter.segment(text.slice(0, i))];
325
+ if (graphemes.length === 0) return 0;
326
+
327
+ // Skip trailing whitespace.
328
+ while (graphemes.length > 0 && getWordNavKind(graphemes[graphemes.length - 1]?.segment || "") === "whitespace") {
329
+ i -= graphemes.pop()?.segment.length || 0;
330
+ }
331
+ if (i === 0 || graphemes.length === 0) return i;
332
+
333
+ const kind = getWordNavKind(graphemes[graphemes.length - 1]?.segment || "");
334
+ if (kind === "delimiter" || kind === "cjk") {
335
+ while (graphemes.length > 0 && getWordNavKind(graphemes[graphemes.length - 1]?.segment || "") === kind) {
336
+ i -= graphemes.pop()?.segment.length || 0;
337
+ }
338
+ return i;
339
+ }
340
+
341
+ if (kind === "word") {
342
+ // Skip word run (letters/numbers/underscore), keeping common joiners inside words.
343
+ let hasRightWord = false;
344
+ while (graphemes.length > 0) {
345
+ const g = graphemes[graphemes.length - 1]?.segment || "";
346
+ const k = getWordNavKind(g);
347
+ if (k === "word") {
348
+ hasRightWord = true;
349
+ i -= graphemes.pop()?.segment.length || 0;
350
+ continue;
351
+ }
352
+ if (hasRightWord && k === "delimiter" && isWordNavJoiner(g)) {
353
+ const left = graphemes[graphemes.length - 2]?.segment || "";
354
+ if (getWordNavKind(left) === "word") {
355
+ i -= graphemes.pop()?.segment.length || 0;
356
+ continue;
357
+ }
358
+ }
359
+ break;
360
+ }
361
+ return i;
362
+ }
363
+
364
+ // Fallback: move by one grapheme.
365
+ i -= graphemes.pop()?.segment.length || 0;
366
+ return Math.max(0, i);
367
+ }
368
+
369
+ /**
370
+ * Move the cursor one "word" to the right using Unicode-aware coarse navigation.
371
+ *
372
+ * Returns a new cursor index in the range [0, text.length].
373
+ */
374
+ export function moveWordRight(text: string, cursor: number): number {
375
+ const len = text.length;
376
+ if (len === 0) return 0;
377
+ let i = Math.min(Math.max(cursor, 0), len);
378
+ if (i === len) return len;
379
+
380
+ const iterator = segmenter.segment(text.slice(i))[Symbol.iterator]();
381
+ let next = iterator.next();
382
+
383
+ // Skip leading whitespace.
384
+ while (!next.done && getWordNavKind(next.value.segment) === "whitespace") {
385
+ i += next.value.segment.length;
386
+ next = iterator.next();
387
+ }
388
+ if (next.done) return i;
389
+
390
+ const firstKind = getWordNavKind(next.value.segment);
391
+ if (firstKind === "delimiter" || firstKind === "cjk") {
392
+ while (!next.done && getWordNavKind(next.value.segment) === firstKind) {
393
+ i += next.value.segment.length;
394
+ next = iterator.next();
395
+ }
396
+ return i;
397
+ }
398
+
399
+ if (firstKind === "word") {
400
+ let hasLeftWord = false;
401
+ while (!next.done) {
402
+ const segment = next.value.segment;
403
+ const k = getWordNavKind(segment);
404
+ if (k === "word") {
405
+ hasLeftWord = true;
406
+ i += segment.length;
407
+ next = iterator.next();
408
+ continue;
409
+ }
410
+ if (hasLeftWord && k === "delimiter" && isWordNavJoiner(segment)) {
411
+ const lookahead = iterator.next();
412
+ if (!lookahead.done && getWordNavKind(lookahead.value.segment) === "word") {
413
+ i += segment.length;
414
+ next = lookahead;
415
+ continue;
416
+ }
417
+ }
418
+ break;
419
+ }
420
+ return i;
421
+ }
422
+
423
+ // Fallback: move by one grapheme.
424
+ return i + next.value.segment.length;
425
+ }
426
+
427
+ /**
428
+ * Apply background color to a line, padding to full width.
429
+ *
430
+ * @param line - Line of text (may contain ANSI codes)
431
+ * @param width - Total width to pad to
432
+ * @param bgFn - Background color function
433
+ * @returns Line with background applied and padded to width
434
+ */
435
+ export function applyBackgroundToLine(line: string, width: number, bgFn: (text: string) => string): string {
436
+ // Calculate padding needed
437
+ const visibleLen = visibleWidth(line);
438
+ const paddingNeeded = Math.max(0, width - visibleLen);
439
+
440
+ // Apply background to content + padding
441
+ const withPadding = line + padding(paddingNeeded);
442
+ return bgFn(withPadding);
443
+ }
444
+
445
+ /**
446
+ * Extract a range of visible columns from a line. Handles ANSI codes and wide chars.
447
+ *
448
+ * @param strict - If true, exclude wide chars at boundary that would extend past the range
449
+ */
450
+ export function sliceByColumn(line: string, startCol: number, length: number, strict = false): string {
451
+ return sliceWithWidth(line, startCol, length, strict).text;
452
+ }