@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/utils.ts ADDED
@@ -0,0 +1,619 @@
1
+ import {
2
+ Ellipsis,
3
+ type ExtractSegmentsResult,
4
+ extractSegments as nativeExtractSegments,
5
+ setHangulCompatJamoWidthOverride as nativeSetHangulCompatJamoWidthOverride,
6
+ sliceWithWidth as nativeSliceWithWidth,
7
+ truncateToWidth as nativeTruncateToWidth,
8
+ wrapTextWithAnsi as nativeWrapTextWithAnsi,
9
+ type SliceResult,
10
+ } from "@linxiraos/pi-natives";
11
+ import { DEFAULT_TAB_WIDTH } from "@linxiraos/pi-utils";
12
+
13
+ export { Ellipsis } from "@linxiraos/pi-natives";
14
+
15
+ export { DEFAULT_TAB_WIDTH } from "@linxiraos/pi-utils";
16
+
17
+ export type HangulCompatibilityJamoWidth = "platform" | "unicode" | 1 | 2;
18
+
19
+ let hangulCompatibilityJamoWidth: HangulCompatibilityJamoWidth = "platform";
20
+
21
+ // Wire encoding for the native override (see crates/pi-natives text.rs):
22
+ // 0 = platform default, 1 = narrow, 2 = wide, 3 = unicode (no correction).
23
+ function nativeHangulCompatibilityJamoOverride(width: HangulCompatibilityJamoWidth): number {
24
+ if (width === "unicode") return 3;
25
+ if (typeof width === "number") return width;
26
+ return 0;
27
+ }
28
+
29
+ export function getHangulCompatibilityJamoWidth(): HangulCompatibilityJamoWidth {
30
+ return hangulCompatibilityJamoWidth;
31
+ }
32
+
33
+ // Monotonic epoch for width-affecting runtime configuration. Any cache or
34
+ // carried-width sidecar derived from `visibleWidth` results must be stamped
35
+ // with the epoch at computation time and discarded on mismatch, so a Hangul
36
+ // Compatibility Jamo width change invalidates every derived width.
37
+ let widthConfigEpoch = 0;
38
+
39
+ export function getWidthConfigEpoch(): number {
40
+ return widthConfigEpoch;
41
+ }
42
+
43
+ interface LineWidthsEntry {
44
+ epoch: number;
45
+ lines: readonly string[];
46
+ widths: readonly number[];
47
+ }
48
+
49
+ // Per-render-result visible widths, keyed by the exact lines array a component
50
+ // returned. The copied strings and widths are the single publication snapshot:
51
+ // they cannot be changed through either publisher array and do not retain the
52
+ // WeakMap key. Entries therefore die with their lines-array owners.
53
+ const lineWidthSidecar = new WeakMap<readonly string[], LineWidthsEntry>();
54
+
55
+ /** Publish exact per-line visible widths for a rendered lines array. */
56
+ export function publishLineWidths(lines: readonly string[], widths: readonly number[]): void {
57
+ if (lines.length !== widths.length) {
58
+ throw new RangeError(`Cannot publish ${widths.length} widths for ${lines.length} lines`);
59
+ }
60
+ lineWidthSidecar.set(lines, {
61
+ epoch: widthConfigEpoch,
62
+ lines: [...lines],
63
+ widths: Object.freeze([...widths]),
64
+ });
65
+ }
66
+
67
+ /** Exact per-line visible widths for an unchanged `lines` array under the current width config. */
68
+ export function getPublishedLineWidths(lines: readonly string[]): readonly number[] | undefined {
69
+ const entry = lineWidthSidecar.get(lines);
70
+ if (entry === undefined || entry.epoch !== widthConfigEpoch || entry.lines.length !== lines.length) {
71
+ return undefined;
72
+ }
73
+ for (let i = 0; i < lines.length; i++) {
74
+ if (entry.lines[i] !== lines[i]) return undefined;
75
+ }
76
+ return entry.widths;
77
+ }
78
+
79
+ export function setHangulCompatibilityJamoWidth(width: HangulCompatibilityJamoWidth): boolean {
80
+ const changed = hangulCompatibilityJamoWidth !== width;
81
+ hangulCompatibilityJamoWidth = width;
82
+ if (changed) widthConfigEpoch++;
83
+ nativeSetHangulCompatJamoWidthOverride(nativeHangulCompatibilityJamoOverride(width));
84
+ return changed;
85
+ }
86
+
87
+ export function resetHangulCompatibilityJamoWidthForTests(): void {
88
+ if (hangulCompatibilityJamoWidth !== "platform") widthConfigEpoch++;
89
+ hangulCompatibilityJamoWidth = "platform";
90
+ nativeSetHangulCompatJamoWidthOverride(0);
91
+ }
92
+
93
+ export type TextSizingScale = 1 | 2 | 3;
94
+ export type TextSizingVerticalAlign = "top" | "bottom" | "center";
95
+ export type TextSizingHorizontalAlign = "left" | "right" | "center";
96
+
97
+ export interface TextSizingOptions {
98
+ scale?: TextSizingScale;
99
+ widthCells?: number;
100
+ verticalAlign?: TextSizingVerticalAlign;
101
+ horizontalAlign?: TextSizingHorizontalAlign;
102
+ }
103
+
104
+ const OSC66_UNSAFE = /[\x00-\x1f\x7f-\x9f]/u;
105
+ const OSC66_UNSAFE_GLOBAL = /[\x00-\x1f\x7f-\x9f]/gu;
106
+
107
+ function textSizingVerticalAlignValue(align: TextSizingVerticalAlign | undefined): number | undefined {
108
+ switch (align) {
109
+ case "top":
110
+ return 0;
111
+ case "bottom":
112
+ return 1;
113
+ case "center":
114
+ return 2;
115
+ default:
116
+ return undefined;
117
+ }
118
+ }
119
+
120
+ function textSizingHorizontalAlignValue(align: TextSizingHorizontalAlign | undefined): number | undefined {
121
+ switch (align) {
122
+ case "left":
123
+ return 0;
124
+ case "right":
125
+ return 1;
126
+ case "center":
127
+ return 2;
128
+ default:
129
+ return undefined;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Encode a plain-text span using Kitty's OSC 66 text-sizing protocol. The TUI
135
+ * emits only safe UTF-8 payloads and ST terminators so its ANSI parser and the
136
+ * terminal agree on span boundaries.
137
+ */
138
+ export function encodeTextSized(text: string, options: TextSizingOptions = {}): string {
139
+ const metadata: string[] = [];
140
+ if (options.scale !== undefined) metadata.push(`s=${options.scale}`);
141
+ if (options.widthCells !== undefined && Number.isFinite(options.widthCells)) {
142
+ metadata.push(`w=${Math.max(0, Math.trunc(options.widthCells))}`);
143
+ }
144
+ const verticalAlign = textSizingVerticalAlignValue(options.verticalAlign);
145
+ if (verticalAlign !== undefined) metadata.push(`v=${verticalAlign}`);
146
+ const horizontalAlign = textSizingHorizontalAlignValue(options.horizontalAlign);
147
+ if (horizontalAlign !== undefined) metadata.push(`h=${horizontalAlign}`);
148
+
149
+ const safeText = OSC66_UNSAFE.test(text) ? text.replace(OSC66_UNSAFE_GLOBAL, " ") : text;
150
+ return `\x1b]66;${metadata.join(":")};${safeText}\x1b\\`;
151
+ }
152
+
153
+ export function sliceWithWidth(line: string, startCol: number, length: number, strict?: boolean | null): SliceResult {
154
+ return nativeSliceWithWidth(line, startCol, length, strict ?? null, DEFAULT_TAB_WIDTH);
155
+ }
156
+
157
+ export function truncateToWidth(
158
+ text: string,
159
+ maxWidth: number,
160
+ ellipsisKind?: Ellipsis | null | "",
161
+ pad?: boolean | null,
162
+ ): string {
163
+ maxWidth = Math.max(0, maxWidth | 0);
164
+ // Fast path: every UTF-16 unit is at most 3 cells wide, so a string whose
165
+ // `length * 3` already fits within `safeWidth` cannot need truncation.
166
+ if (!pad && text.length * 3 <= maxWidth) {
167
+ return text;
168
+ }
169
+ return nativeTruncateToWidth(
170
+ text,
171
+ maxWidth,
172
+ (typeof ellipsisKind === "string" ? Ellipsis.Omit : ellipsisKind) ?? Ellipsis.Unicode,
173
+ pad ?? false,
174
+ DEFAULT_TAB_WIDTH,
175
+ );
176
+ }
177
+
178
+ export function wrapTextWithAnsi(text: string, width: number): string[] {
179
+ return nativeWrapTextWithAnsi(text, width, DEFAULT_TAB_WIDTH);
180
+ }
181
+
182
+ export function extractSegments(
183
+ line: string,
184
+ beforeEnd: number,
185
+ afterStart: number,
186
+ afterLen: number,
187
+ strictAfter: boolean,
188
+ ): ExtractSegmentsResult {
189
+ return nativeExtractSegments(line, beforeEnd, afterStart, afterLen, strictAfter, DEFAULT_TAB_WIDTH);
190
+ }
191
+
192
+ // Pre-allocated space buffer for padding
193
+ const SPACE_BUFFER = " ".repeat(512);
194
+ const TAB_SPACES = " ".repeat(DEFAULT_TAB_WIDTH);
195
+
196
+ /*
197
+ * Replace tabs with the fixed display tab width for consistent rendering.
198
+ */
199
+ export function replaceTabs(text: string): string {
200
+ return text.replaceAll("\t", TAB_SPACES);
201
+ }
202
+
203
+ /**
204
+ * Returns a string of n spaces. Uses a pre-allocated buffer for efficiency.
205
+ */
206
+ export function padding(n: number): string {
207
+ if (n <= 0) return "";
208
+ if (n <= 512) return SPACE_BUFFER.slice(0, n);
209
+ return " ".repeat(n);
210
+ }
211
+
212
+ // Grapheme segmenter (shared instance)
213
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
214
+
215
+ /**
216
+ * Get the shared grapheme segmenter instance.
217
+ */
218
+ export function getSegmenter(): Intl.Segmenter {
219
+ return segmenter;
220
+ }
221
+
222
+ // Kitty OSC 66 text-sizing spans: `\x1b]66;<meta>;<payload>` terminated by BEL
223
+ // or ST. `Bun.stringWidth` strips the whole span (payload included) to zero
224
+ // cells, but the payload is visible and scales by the `s=` factor, so each is
225
+ // added back so width matches the native truncate/slice/wrap helpers.
226
+ const OSC66_SPAN_REGEX = /\x1b\]66;([^;]*);([\s\S]*?)(?:\x07|\x1b\\)/g;
227
+ const OSC66_PREFIX = "\x1b]66;";
228
+ const ESC = "\x1b";
229
+ const TAB = "\t";
230
+ const LONG_WIDTH_FAST_PATH_MIN = 128;
231
+
232
+ // Pin Bun.stringWidth semantics to the native width engine and guard against Bun
233
+ // default drift: strip ANSI/OSC (don't count escape bytes) and treat
234
+ // ambiguous-width East Asian chars as narrow (1 cell), matching `unicode-width`'s
235
+ // non-CJK tables that back truncate/slice/wrap. Hoisted so no per-call alloc.
236
+ const STRING_WIDTH_OPTS = { countAnsiEscapeCodes: false, ambiguousIsNarrow: true } as const;
237
+
238
+ // Hangul Compatibility Jamo (U+3131..=U+318E). `Bun.stringWidth` follows UAX#11
239
+ // and reports these at 2 cells (the U+3164 HANGUL FILLER at 0), but the actual
240
+ // rendered width is decided by the *client* terminal (1 cell on Terminal.app /
241
+ // iTerm2, 2 on Ghostty and most Linux terminals). The width is resolved from
242
+ // the terminal identity and pushed into the native engine through
243
+ // `setHangulCompatibilityJamoWidth`; mirror the same correction here so the TS
244
+ // width stays in parity with the native truncate/slice/wrap model — and so the
245
+ // hardware cursor column lands on the actual glyph during Korean IME input.
246
+ const HANGUL_COMPAT_JAMO_REGEX = /[\u3131-\u318e]/;
247
+ const HANGUL_COMPAT_JAMO_GLOBAL_REGEX = /[\u3131-\u318e]/g;
248
+ const HANGUL_FILLER_CODE_POINT = 0x3164;
249
+ // `Bun.stringWidth` counts every code point in the Compatibility Jamo block as
250
+ // 2 cells (even the U+3164 filler that `unicode-width` treats as zero-width).
251
+ const HANGUL_COMPAT_JAMO_BUN_WIDTH = 2;
252
+
253
+ // Effective target cell width for Compatibility Jamo, or `null` to follow the
254
+ // Unicode width (no correction). Mirrors `hangul_compat_jamo_target_width` in
255
+ // crates/pi-natives/src/text.rs.
256
+ function hangulCompatibilityJamoTargetWidth(): 1 | 2 | null {
257
+ switch (hangulCompatibilityJamoWidth) {
258
+ case 1:
259
+ return 1;
260
+ case 2:
261
+ return 2;
262
+ case "unicode":
263
+ return null;
264
+ default:
265
+ // "platform": macOS terminals historically render these narrow.
266
+ return process.platform === "darwin" ? 1 : null;
267
+ }
268
+ }
269
+
270
+ // Reconcile the `Bun.stringWidth` count for Compatibility Jamo to the native
271
+ // width engine: subtract Bun's per-jamo cell count and add back the effective
272
+ // width — the runtime target when one is active, otherwise the `unicode-width`
273
+ // value. Mirrors `char_width_corrected` / `apply_hangul_compat_jamo_delta` in
274
+ // crates/pi-natives/src/text.rs, including the rule that the zero-width filler
275
+ // (U+3164) is never widened past the narrow correction (a wide terminal still
276
+ // renders it at its Unicode width of 0).
277
+ function correctHangulCompatibilityJamoWidth(width: number, str: string): number {
278
+ if (!HANGUL_COMPAT_JAMO_REGEX.test(str)) return width;
279
+ const target = hangulCompatibilityJamoTargetWidth();
280
+ let corrected = width;
281
+ HANGUL_COMPAT_JAMO_GLOBAL_REGEX.lastIndex = 0;
282
+ for (let m = HANGUL_COMPAT_JAMO_GLOBAL_REGEX.exec(str); m !== null; m = HANGUL_COMPAT_JAMO_GLOBAL_REGEX.exec(str)) {
283
+ const unicodeWidth = m[0].codePointAt(0) === HANGUL_FILLER_CODE_POINT ? 0 : 2;
284
+ const finalWidth = target === null || (unicodeWidth === 0 && target > 1) ? unicodeWidth : target;
285
+ corrected += finalWidth - HANGUL_COMPAT_JAMO_BUN_WIDTH;
286
+ }
287
+ return corrected;
288
+ }
289
+
290
+ /**
291
+ * Visible width of a string in terminal columns, excluding ANSI/OSC escapes.
292
+ *
293
+ * `Bun.stringWidth` does the heavy lifting (UAX#11 width tables + ANSI/OSC
294
+ * stripping); this adds the two corrections it omits — tabs (expanded to
295
+ * `tabWidth` cells) and OSC 66 text-sizing payloads (scaled by `s=`).
296
+ */
297
+ export function visibleWidth(str: string): number {
298
+ if (!str) return 0;
299
+
300
+ // Long non-escape text is faster through Bun's native scanner than through
301
+ // a JS printable-ASCII prepass. Escape-bearing strings stay on the scanner
302
+ // below so CSI/OSC-heavy render output can still bail out at the first ESC.
303
+ if (str.length >= LONG_WIDTH_FAST_PATH_MIN && !str.includes(ESC)) {
304
+ let width = Bun.stringWidth(str, STRING_WIDTH_OPTS);
305
+ let tabCount = 0;
306
+ for (let tabIndex = str.indexOf(TAB); tabIndex !== -1; tabIndex = str.indexOf(TAB, tabIndex + 1)) {
307
+ tabCount++;
308
+ }
309
+ if (tabCount > 0) width += tabCount * DEFAULT_TAB_WIDTH;
310
+ return correctHangulCompatibilityJamoWidth(width, str);
311
+ }
312
+
313
+ let tabCount = 0;
314
+ let i = 0;
315
+ for (; i < str.length; i++) {
316
+ const code = str.charCodeAt(i);
317
+ if (code < 0x20 || code > 0x7e) {
318
+ if (code === 0x09) {
319
+ tabCount++;
320
+ continue;
321
+ }
322
+ break;
323
+ }
324
+ }
325
+ if (i === str.length) {
326
+ return tabCount === 0 ? str.length : str.length + tabCount * (DEFAULT_TAB_WIDTH - 1);
327
+ }
328
+
329
+ if (tabCount === 0) {
330
+ let tabIndex = str.indexOf(TAB, i + 1);
331
+ if (tabIndex !== -1) {
332
+ tabCount = 1;
333
+ for (tabIndex = str.indexOf(TAB, tabIndex + 1); tabIndex !== -1; tabIndex = str.indexOf(TAB, tabIndex + 1)) {
334
+ tabCount++;
335
+ }
336
+ }
337
+ } else {
338
+ for (let tabIndex = str.indexOf(TAB, i + 1); tabIndex !== -1; tabIndex = str.indexOf(TAB, tabIndex + 1)) {
339
+ tabCount++;
340
+ }
341
+ }
342
+
343
+ // `Bun.stringWidth` is a JSC builtin (no per-call N-API number box, unlike
344
+ // the native scanner that traps under Bun 1.3.x GC/N-API load). It strips
345
+ // CSI/OSC to zero cells and shares the native engine's UAX#11 width tables.
346
+ let width = Bun.stringWidth(str, STRING_WIDTH_OPTS);
347
+ if (tabCount > 0) width += tabCount * DEFAULT_TAB_WIDTH;
348
+
349
+ // OSC 66: add back each stripped span as `scale * (explicit w ?? payload
350
+ // width)`. Matched rather than replaced to avoid reallocating the string.
351
+ if (str.includes(OSC66_PREFIX, i)) {
352
+ OSC66_SPAN_REGEX.lastIndex = 0;
353
+ for (let m = OSC66_SPAN_REGEX.exec(str); m !== null; m = OSC66_SPAN_REGEX.exec(str)) {
354
+ let scale = 1;
355
+ let explicit: number | undefined;
356
+ for (const part of m[1].split(":")) {
357
+ // metadata keys are single chars, e.g. `s=2`, `w=5`
358
+ if (part.indexOf("=") !== 1) continue;
359
+ const value = Number.parseInt(part.slice(2), 10);
360
+ if (!Number.isFinite(value)) continue;
361
+ if (part[0] === "s") {
362
+ if (value >= 1 && value <= 7) scale = value;
363
+ } else if (part[0] === "w" && value > 0) {
364
+ explicit = value;
365
+ }
366
+ }
367
+ width += scale * (explicit ?? Bun.stringWidth(m[2], STRING_WIDTH_OPTS));
368
+ }
369
+ }
370
+
371
+ return correctHangulCompatibilityJamoWidth(width, str);
372
+ }
373
+
374
+ const THAI_LAO_AM_GLOBAL_REGEX = /[\u0e33\u0eb3]/g;
375
+
376
+ /**
377
+ * Normalize text for terminal output without changing logical editor content.
378
+ * Some terminals render precomposed Thai/Lao AM vowels inconsistently during
379
+ * differential repaint. Their compatibility decompositions have the same cell
380
+ * width but avoid stale-cell artifacts in terminal renderers.
381
+ */
382
+ export function normalizeTerminalOutput(str: string): string {
383
+ if (str.indexOf("\u0e33") === -1 && str.indexOf("\u0eb3") === -1) return str;
384
+ return str.replace(THAI_LAO_AM_GLOBAL_REGEX, char => (char === "\u0e33" ? "\u0e4d\u0e32" : "\u0ecd\u0eb2"));
385
+ }
386
+
387
+ const makeBoolArray = (chars: string): Uint8Array => {
388
+ const table = new Uint8Array(128);
389
+ for (let i = 0; i < chars.length; i++) {
390
+ const code = chars.charCodeAt(i);
391
+ if (code < table.length) {
392
+ table[code] = 1;
393
+ }
394
+ }
395
+ return table;
396
+ };
397
+
398
+ const ASCII_WHITESPACE = makeBoolArray("\x09\x0a\x0b\x0c\x0d\x20");
399
+
400
+ /**
401
+ * Check if a character is whitespace.
402
+ */
403
+ export function isWhitespaceChar(char: string): boolean {
404
+ const code = char.codePointAt(0) ?? 0;
405
+ return code < 128 && ASCII_WHITESPACE[code] === 1;
406
+ }
407
+
408
+ const ASCII_PUNCTUATION = makeBoolArray("(){}[]<>.,;:'\"!?+-=*/\\|&%^$#@~`");
409
+
410
+ /**
411
+ * Check if a character is punctuation.
412
+ */
413
+ export function isPunctuationChar(char: string): boolean {
414
+ const code = char.codePointAt(0) ?? 0;
415
+ return code < 128 && ASCII_PUNCTUATION[code] === 1;
416
+ }
417
+
418
+ export type WordNavKind = "whitespace" | "delimiter" | "cjk" | "word" | "other";
419
+
420
+ const WORD_NAV_RE_WHITESPACE = /^\p{White_Space}$/u;
421
+ const WORD_NAV_RE_PUNCT = /^\p{P}$/u;
422
+ const WORD_NAV_RE_SYMBOL = /^\p{S}$/u;
423
+ const WORD_NAV_RE_LETTER = /^\p{L}$/u;
424
+ const WORD_NAV_RE_NUMBER = /^\p{N}$/u;
425
+ const WORD_NAV_RE_HAN = /^\p{Script=Han}$/u;
426
+ const WORD_NAV_RE_HIRAGANA = /^\p{Script=Hiragana}$/u;
427
+ const WORD_NAV_RE_KATAKANA = /^\p{Script=Katakana}$/u;
428
+ const WORD_NAV_RE_HANGUL = /^\p{Script=Hangul}$/u;
429
+
430
+ function firstCodePointChar(str: string): string {
431
+ const cp = str.codePointAt(0);
432
+ if (cp === undefined) return "";
433
+ return String.fromCodePoint(cp);
434
+ }
435
+
436
+ /**
437
+ * Coarse Unicode-aware character classification for word navigation (Option/Alt + Left/Right).
438
+ * This intentionally avoids language-specific word segmentation for predictability across scripts.
439
+ */
440
+ export function getWordNavKind(grapheme: string): WordNavKind {
441
+ if (!grapheme) return "other";
442
+ const ch = firstCodePointChar(grapheme);
443
+ if (!ch) return "other";
444
+ if (WORD_NAV_RE_WHITESPACE.test(ch)) return "whitespace";
445
+ if (ch === "_") return "word";
446
+ if (WORD_NAV_RE_PUNCT.test(ch) || WORD_NAV_RE_SYMBOL.test(ch)) return "delimiter";
447
+ if (
448
+ WORD_NAV_RE_HAN.test(ch) ||
449
+ WORD_NAV_RE_HIRAGANA.test(ch) ||
450
+ WORD_NAV_RE_KATAKANA.test(ch) ||
451
+ WORD_NAV_RE_HANGUL.test(ch)
452
+ ) {
453
+ return "cjk";
454
+ }
455
+ if (WORD_NAV_RE_LETTER.test(ch) || WORD_NAV_RE_NUMBER.test(ch)) return "word";
456
+ return "other";
457
+ }
458
+
459
+ const WORD_NAV_JOINERS = new Set(["'", "’", "-", "‐", "‑"]);
460
+
461
+ export function isWordNavJoiner(grapheme: string): boolean {
462
+ const ch = firstCodePointChar(grapheme);
463
+ return WORD_NAV_JOINERS.has(ch);
464
+ }
465
+
466
+ /**
467
+ * Move the cursor one "word" to the left using Unicode-aware coarse navigation.
468
+ *
469
+ * Returns a new cursor index in the range [0, text.length].
470
+ */
471
+ export function moveWordLeft(text: string, cursor: number): number {
472
+ const len = text.length;
473
+ if (len === 0) return 0;
474
+ let i = Math.min(Math.max(cursor, 0), len);
475
+ if (i === 0) return 0;
476
+
477
+ const graphemes = [...segmenter.segment(text.slice(0, i))];
478
+ if (graphemes.length === 0) return 0;
479
+
480
+ // Skip trailing whitespace.
481
+ while (graphemes.length > 0 && getWordNavKind(graphemes[graphemes.length - 1]?.segment || "") === "whitespace") {
482
+ i -= graphemes.pop()?.segment.length || 0;
483
+ }
484
+ if (i === 0 || graphemes.length === 0) return i;
485
+
486
+ const kind = getWordNavKind(graphemes[graphemes.length - 1]?.segment || "");
487
+ if (kind === "delimiter" || kind === "cjk") {
488
+ while (graphemes.length > 0 && getWordNavKind(graphemes[graphemes.length - 1]?.segment || "") === kind) {
489
+ i -= graphemes.pop()?.segment.length || 0;
490
+ }
491
+ return i;
492
+ }
493
+
494
+ if (kind === "word") {
495
+ // Skip word run (letters/numbers/underscore), keeping common joiners inside words.
496
+ let hasRightWord = false;
497
+ while (graphemes.length > 0) {
498
+ const g = graphemes[graphemes.length - 1]?.segment || "";
499
+ const k = getWordNavKind(g);
500
+ if (k === "word") {
501
+ hasRightWord = true;
502
+ i -= graphemes.pop()?.segment.length || 0;
503
+ continue;
504
+ }
505
+ if (hasRightWord && k === "delimiter" && isWordNavJoiner(g)) {
506
+ const left = graphemes[graphemes.length - 2]?.segment || "";
507
+ if (getWordNavKind(left) === "word") {
508
+ i -= graphemes.pop()?.segment.length || 0;
509
+ continue;
510
+ }
511
+ }
512
+ break;
513
+ }
514
+ return i;
515
+ }
516
+
517
+ // Fallback: move by one grapheme.
518
+ i -= graphemes.pop()?.segment.length || 0;
519
+ return Math.max(0, i);
520
+ }
521
+
522
+ /**
523
+ * Move the cursor one "word" to the right using Unicode-aware coarse navigation.
524
+ *
525
+ * Returns a new cursor index in the range [0, text.length].
526
+ */
527
+ export function moveWordRight(text: string, cursor: number): number {
528
+ const len = text.length;
529
+ if (len === 0) return 0;
530
+ let i = Math.min(Math.max(cursor, 0), len);
531
+ if (i === len) return len;
532
+
533
+ const iterator = segmenter.segment(text.slice(i))[Symbol.iterator]();
534
+ let next = iterator.next();
535
+
536
+ // Skip leading whitespace.
537
+ while (!next.done && getWordNavKind(next.value.segment) === "whitespace") {
538
+ i += next.value.segment.length;
539
+ next = iterator.next();
540
+ }
541
+ if (next.done) return i;
542
+
543
+ const firstKind = getWordNavKind(next.value.segment);
544
+ if (firstKind === "delimiter" || firstKind === "cjk") {
545
+ while (!next.done && getWordNavKind(next.value.segment) === firstKind) {
546
+ i += next.value.segment.length;
547
+ next = iterator.next();
548
+ }
549
+ return i;
550
+ }
551
+
552
+ if (firstKind === "word") {
553
+ let hasLeftWord = false;
554
+ while (!next.done) {
555
+ const segment = next.value.segment;
556
+ const k = getWordNavKind(segment);
557
+ if (k === "word") {
558
+ hasLeftWord = true;
559
+ i += segment.length;
560
+ next = iterator.next();
561
+ continue;
562
+ }
563
+ if (hasLeftWord && k === "delimiter" && isWordNavJoiner(segment)) {
564
+ const lookahead = iterator.next();
565
+ if (!lookahead.done && getWordNavKind(lookahead.value.segment) === "word") {
566
+ i += segment.length;
567
+ next = lookahead;
568
+ continue;
569
+ }
570
+ }
571
+ break;
572
+ }
573
+ return i;
574
+ }
575
+
576
+ // Fallback: move by one grapheme.
577
+ return i + next.value.segment.length;
578
+ }
579
+
580
+ /**
581
+ * Apply background color to a line, padding to full width.
582
+ *
583
+ * @param line - Line of text (may contain ANSI codes)
584
+ * @param width - Total width to pad to
585
+ * @param bgFn - Background color function
586
+ * @returns Line with background applied and padded to width
587
+ */
588
+ export function applyBackgroundToLine(line: string, width: number, bgFn: (text: string) => string): string {
589
+ // Calculate padding needed
590
+ const visibleLen = visibleWidth(line);
591
+ const paddingNeeded = Math.max(0, width - visibleLen);
592
+
593
+ // Apply background to content + padding
594
+ const withPadding = line + padding(paddingNeeded);
595
+ return bgFn(withPadding);
596
+ }
597
+
598
+ /**
599
+ * Extract a range of visible columns from a line. Handles ANSI codes and wide chars.
600
+ *
601
+ * @param strict - If true, exclude wide chars at boundary that would extend past the range
602
+ */
603
+ export function sliceByColumn(line: string, startCol: number, length: number, strict = false): string {
604
+ return sliceWithWidth(line, startCol, length, strict).text;
605
+ }
606
+
607
+ let globalTight = false;
608
+
609
+ export function setTuiTight(tight: boolean): void {
610
+ globalTight = tight;
611
+ }
612
+
613
+ export function isTuiTight(): boolean {
614
+ return globalTight;
615
+ }
616
+
617
+ export function getPaddingX(basePadding: number): number {
618
+ return globalTight ? Math.max(0, basePadding - 1) : basePadding;
619
+ }