@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/README.md ADDED
@@ -0,0 +1,705 @@
1
+ # @linxiraos/pi-tui
2
+
3
+ Minimal terminal UI framework with differential rendering and synchronized output for flicker-free interactive CLI applications.
4
+
5
+ ## Features
6
+
7
+ - **Differential Rendering**: Three-strategy rendering system that only updates what changed
8
+ - **Synchronized Output**: Uses CSI 2026 for atomic screen updates (no flicker)
9
+ - **Bracketed Paste Mode**: Handles large pastes correctly with markers for >10 line pastes
10
+ - **Component-based**: Simple Component interface with render() method
11
+ - **Theme Support**: Components accept theme interfaces for customizable styling
12
+ - **Built-in Components**: Text, TruncatedText, Input, Editor, Markdown, Loader, SelectList, SettingsList, Spacer, Image, Box, Container
13
+ - **Inline Images**: Renders images in terminals that support Kitty or iTerm2 graphics protocols
14
+ - **Autocomplete Support**: File paths and slash commands
15
+
16
+ ## Quick Start
17
+
18
+ ```typescript
19
+ import { TUI, Text, Editor, ProcessTerminal } from "@linxiraos/pi-tui";
20
+
21
+ // Create terminal
22
+ const terminal = new ProcessTerminal();
23
+
24
+ // Create TUI
25
+ const tui = new TUI(terminal);
26
+
27
+ // Add components
28
+ tui.addChild(new Text("Welcome to my app!"));
29
+
30
+ const editor = new Editor(editorTheme);
31
+ editor.onSubmit = (text) => {
32
+ console.log("Submitted:", text);
33
+ tui.addChild(new Text(`You said: ${text}`));
34
+ };
35
+ tui.addChild(editor);
36
+
37
+ // Start
38
+ tui.start();
39
+ ```
40
+
41
+ ## Core API
42
+
43
+ ### TUI
44
+
45
+ Main container that manages components and rendering.
46
+
47
+ ```typescript
48
+ const tui = new TUI(terminal);
49
+ tui.addChild(component);
50
+ tui.removeChild(component);
51
+ tui.start();
52
+ tui.stop();
53
+ tui.requestRender(); // Request a re-render
54
+ tui.requestComponentRender(component); // Re-render only the root subtree containing `component` when safe (falls back to a full render on resize, overlays, images, or concurrent full requests)
55
+
56
+ // Global debug key handler (Shift+Ctrl+D)
57
+ tui.onDebug = () => console.log("Debug triggered");
58
+ ```
59
+
60
+ ### Component Interface
61
+
62
+ All components implement:
63
+
64
+ ```typescript
65
+ interface Component {
66
+ render(width: number): readonly string[];
67
+ handleInput?(data: string): void;
68
+ invalidate?(): void;
69
+ }
70
+ ```
71
+
72
+ | Method | Description |
73
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
74
+ | `render(width)` | Returns an array of strings, one per line. Each line **must not exceed `width`** or the TUI will error. Use `truncateToWidth()` or manual wrapping to ensure this. The result is component-owned and immutable to callers; return the same array reference when unchanged (enables renderer memoization) and a new array when content changed. |
75
+ | `handleInput?(data)` | Called when the component has focus and receives keyboard input. The `data` string contains raw terminal input (may include ANSI escape sequences). |
76
+ | `invalidate?()` | Called to clear any cached render state. Components should re-render from scratch on the next `render()` call. |
77
+
78
+ ## Built-in Components
79
+
80
+ ### Container
81
+
82
+ Groups child components.
83
+
84
+ ```typescript
85
+ const container = new Container();
86
+ container.addChild(component);
87
+ container.removeChild(component);
88
+ ```
89
+
90
+ ### Box
91
+
92
+ Container that applies padding and background color to all children.
93
+
94
+ ```typescript
95
+ const box = new Box(
96
+ 1, // paddingX (default: 1)
97
+ 1, // paddingY (default: 1)
98
+ (text) => chalk.bgGray(text), // optional background function
99
+ );
100
+ box.addChild(new Text("Content"));
101
+ box.setBgFn((text) => chalk.bgBlue(text)); // Change background dynamically
102
+ ```
103
+
104
+ ### Text
105
+
106
+ Displays multi-line text with word wrapping and padding.
107
+
108
+ ```typescript
109
+ const text = new Text(
110
+ "Hello World", // text content
111
+ 1, // paddingX (default: 1)
112
+ 1, // paddingY (default: 1)
113
+ (text) => chalk.bgGray(text), // optional background function
114
+ );
115
+ text.setText("Updated text");
116
+ text.setCustomBgFn((text) => chalk.bgBlue(text));
117
+ ```
118
+
119
+ ### TruncatedText
120
+
121
+ Single-line text that truncates to fit viewport width. Useful for status lines and headers.
122
+
123
+ ```typescript
124
+ const truncated = new TruncatedText(
125
+ "This is a very long line that will be truncated...",
126
+ 0, // paddingX (default: 0)
127
+ 0, // paddingY (default: 0)
128
+ );
129
+ ```
130
+
131
+ ### Input
132
+
133
+ Single-line text input with horizontal scrolling.
134
+
135
+ ```typescript
136
+ const input = new Input();
137
+ input.onSubmit = (value) => console.log(value);
138
+ input.setValue("initial");
139
+ input.getValue();
140
+ ```
141
+
142
+ **Key Bindings:**
143
+
144
+ - `Enter` - Submit
145
+ - `Ctrl+A` / `Ctrl+E` - Line start/end
146
+ - `Ctrl+W` or `Alt+Backspace` - Delete word backwards
147
+ - `Ctrl+U` - Delete to start of line
148
+ - `Ctrl+K` - Delete to end of line
149
+ - `Ctrl+Left` / `Ctrl+Right` - Word navigation
150
+ - `Alt+Left` / `Alt+Right` - Word navigation
151
+ - Arrow keys, Backspace, Delete work as expected
152
+
153
+ ### Editor
154
+
155
+ Multi-line text editor with autocomplete, file completion, and paste handling.
156
+
157
+ ```typescript
158
+ interface SymbolTheme {
159
+ cursor: string;
160
+ ellipsis: string;
161
+ boxRound: {
162
+ topLeft: string;
163
+ topRight: string;
164
+ bottomLeft: string;
165
+ bottomRight: string;
166
+ horizontal: string;
167
+ vertical: string;
168
+ };
169
+ boxSharp: {
170
+ topLeft: string;
171
+ topRight: string;
172
+ bottomLeft: string;
173
+ bottomRight: string;
174
+ horizontal: string;
175
+ vertical: string;
176
+ teeDown: string;
177
+ teeUp: string;
178
+ teeLeft: string;
179
+ teeRight: string;
180
+ cross: string;
181
+ };
182
+ table: {
183
+ topLeft: string;
184
+ topRight: string;
185
+ bottomLeft: string;
186
+ bottomRight: string;
187
+ horizontal: string;
188
+ vertical: string;
189
+ teeDown: string;
190
+ teeUp: string;
191
+ teeLeft: string;
192
+ teeRight: string;
193
+ cross: string;
194
+ };
195
+ quoteBorder: string;
196
+ hrChar: string;
197
+ spinnerFrames: string[];
198
+ }
199
+
200
+ interface EditorTheme {
201
+ borderColor: (str: string) => string;
202
+ selectList: SelectListTheme;
203
+ symbols: SymbolTheme;
204
+ }
205
+
206
+ const editor = new Editor(theme);
207
+ editor.onSubmit = (text) => console.log(text);
208
+ editor.onChange = (text) => console.log("Changed:", text);
209
+ editor.disableSubmit = true; // Disable submit temporarily
210
+ editor.setAutocompleteProvider(provider);
211
+ editor.borderColor = (s) => chalk.blue(s); // Change border dynamically
212
+ ```
213
+
214
+ **Features:**
215
+
216
+ - Multi-line editing with word wrap
217
+ - Slash command autocomplete (type `/`)
218
+ - File path autocomplete (press `Tab`)
219
+ - Large paste handling (>10 lines creates `[paste #1 +50 lines]` marker)
220
+ - Horizontal lines above/below editor
221
+ - Fake cursor rendering (hidden real cursor)
222
+
223
+ **Key Bindings:**
224
+
225
+ - `Enter` - Submit
226
+ - `Shift+Enter`, `Ctrl+Enter`, or `Alt+Enter` - New line (terminal-dependent, Alt+Enter most reliable)
227
+ - `Tab` - Autocomplete
228
+ - `Ctrl+K` - Delete line
229
+ - `Alt+D` / `Alt+Delete` - Delete word forward
230
+ - `Ctrl+A` / `Ctrl+E` - Line start/end
231
+ - `Ctrl+-` - Undo last edit
232
+ - Arrow keys, Backspace, Delete work as expected
233
+
234
+ ### Markdown
235
+
236
+ Renders markdown with syntax highlighting and theming support.
237
+
238
+ ```typescript
239
+ interface MarkdownTheme {
240
+ heading: (text: string) => string;
241
+ link: (text: string) => string;
242
+ linkUrl: (text: string) => string;
243
+ code: (text: string) => string;
244
+ codeBlock: (text: string) => string;
245
+ codeBlockBorder: (text: string) => string;
246
+ quote: (text: string) => string;
247
+ quoteBorder: (text: string) => string;
248
+ hr: (text: string) => string;
249
+ listBullet: (text: string) => string;
250
+ bold: (text: string) => string;
251
+ italic: (text: string) => string;
252
+ strikethrough: (text: string) => string;
253
+ underline: (text: string) => string;
254
+ highlightCode?: (code: string, lang?: string) => string[];
255
+ symbols: SymbolTheme;
256
+ }
257
+
258
+ interface DefaultTextStyle {
259
+ color?: (text: string) => string;
260
+ bgColor?: (text: string) => string;
261
+ bold?: boolean;
262
+ italic?: boolean;
263
+ strikethrough?: boolean;
264
+ underline?: boolean;
265
+ }
266
+
267
+ const md = new Markdown(
268
+ "# Hello\n\nSome **bold** text",
269
+ 1, // paddingX
270
+ 1, // paddingY
271
+ theme, // MarkdownTheme
272
+ defaultStyle, // optional DefaultTextStyle
273
+ 2, // optional code block indent (spaces)
274
+ );
275
+ md.setText("Updated markdown");
276
+ ```
277
+
278
+ **Features:**
279
+
280
+ - Headings, bold, italic, code blocks, lists, links, blockquotes
281
+ - HTML tags rendered as plain text
282
+ - Optional syntax highlighting via `highlightCode`
283
+ - Padding support
284
+ - Render caching for performance
285
+
286
+ ### Loader
287
+
288
+ Animated loading spinner.
289
+
290
+ ```typescript
291
+ const loader = new Loader(
292
+ tui, // TUI instance for render updates
293
+ (s) => chalk.cyan(s), // spinner color function
294
+ (s) => chalk.gray(s), // message color function
295
+ "Loading...", // message (default: "Loading...")
296
+ );
297
+ loader.start();
298
+ loader.setMessage("Still loading...");
299
+ loader.stop();
300
+ ```
301
+
302
+ ### CancellableLoader
303
+
304
+ Extends Loader with Escape key handling and an AbortSignal for cancelling async operations.
305
+
306
+ ```typescript
307
+ const loader = new CancellableLoader(
308
+ tui, // TUI instance for render updates
309
+ (s) => chalk.cyan(s), // spinner color function
310
+ (s) => chalk.gray(s), // message color function
311
+ "Working...", // message
312
+ );
313
+ loader.onAbort = () => done(null); // Called when user presses Escape
314
+ doAsyncWork(loader.signal).then(done);
315
+ ```
316
+
317
+ **Properties:**
318
+
319
+ - `signal: AbortSignal` - Aborted when user presses Escape
320
+ - `aborted: boolean` - Whether the loader was aborted
321
+ - `onAbort?: () => void` - Callback when user presses Escape
322
+
323
+ ### SelectList
324
+
325
+ Interactive selection list with keyboard navigation.
326
+
327
+ ```typescript
328
+ interface SelectItem {
329
+ value: string;
330
+ label: string;
331
+ description?: string;
332
+ }
333
+
334
+ interface SelectListTheme {
335
+ selectedPrefix: (text: string) => string;
336
+ selectedText: (text: string) => string;
337
+ description: (text: string) => string;
338
+ scrollInfo: (text: string) => string;
339
+ noMatch: (text: string) => string;
340
+ symbols: SymbolTheme;
341
+ }
342
+
343
+ const list = new SelectList(
344
+ [
345
+ { value: "opt1", label: "Option 1", description: "First option" },
346
+ { value: "opt2", label: "Option 2", description: "Second option" },
347
+ ],
348
+ 5, // maxVisible
349
+ theme, // SelectListTheme
350
+ );
351
+
352
+ list.onSelect = (item) => console.log("Selected:", item);
353
+ list.onCancel = () => console.log("Cancelled");
354
+ list.onSelectionChange = (item) => console.log("Highlighted:", item);
355
+ list.setFilter("opt"); // Filter items
356
+ ```
357
+
358
+ **Controls:**
359
+
360
+ - Arrow keys: Navigate
361
+ - Enter: Select
362
+ - Escape: Cancel
363
+
364
+ ### SettingsList
365
+
366
+ Settings panel with value cycling and submenus.
367
+
368
+ ```typescript
369
+ interface SettingItem {
370
+ id: string;
371
+ label: string;
372
+ description?: string;
373
+ currentValue: string;
374
+ values?: string[]; // If provided, Enter/Space cycles through these
375
+ submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component;
376
+ }
377
+
378
+ interface SettingsListTheme {
379
+ label: (text: string, selected: boolean) => string;
380
+ value: (text: string, selected: boolean) => string;
381
+ description: (text: string) => string;
382
+ cursor: string;
383
+ hint: (text: string) => string;
384
+ }
385
+
386
+ const settings = new SettingsList(
387
+ [
388
+ { id: "theme", label: "Theme", currentValue: "dark", values: ["dark", "light"] },
389
+ { id: "model", label: "Model", currentValue: "gpt-4", submenu: (val, done) => modelSelector },
390
+ ],
391
+ 10, // maxVisible
392
+ theme, // SettingsListTheme
393
+ (id, newValue) => console.log(`${id} changed to ${newValue}`),
394
+ () => console.log("Cancelled"),
395
+ );
396
+ settings.updateValue("theme", "light");
397
+ ```
398
+
399
+ **Controls:**
400
+
401
+ - Arrow keys: Navigate
402
+ - Enter/Space: Activate (cycle value or open submenu)
403
+ - Escape: Cancel
404
+
405
+ ### Spacer
406
+
407
+ Empty lines for vertical spacing.
408
+
409
+ ```typescript
410
+ const spacer = new Spacer(2); // 2 empty lines (default: 1)
411
+ ```
412
+
413
+ ### Image
414
+
415
+ Renders images inline for terminals that support the Kitty graphics protocol (Kitty, Ghostty, WezTerm, and Warp on macOS/Linux) or iTerm2 inline images. Falls back to a text placeholder on unsupported terminals.
416
+
417
+ ```typescript
418
+ interface ImageTheme {
419
+ fallbackColor: (str: string) => string;
420
+ }
421
+
422
+ interface ImageOptions {
423
+ maxWidthCells?: number;
424
+ maxHeightCells?: number;
425
+ filename?: string;
426
+ }
427
+
428
+ const image = new Image(
429
+ base64Data, // base64-encoded image data
430
+ "image/png", // MIME type
431
+ theme, // ImageTheme
432
+ options, // optional ImageOptions
433
+ );
434
+ tui.addChild(image);
435
+ ```
436
+
437
+ Supported formats: PNG, JPEG, GIF, WebP. Dimensions are parsed from the image headers automatically.
438
+
439
+ ## Autocomplete
440
+
441
+ ### CombinedAutocompleteProvider
442
+
443
+ Supports both slash commands and file paths.
444
+
445
+ ```typescript
446
+ import { CombinedAutocompleteProvider } from "@linxiraos/pi-tui";
447
+ import { getProjectDir } from "@linxiraos/pi-utils";
448
+
449
+ const provider = new CombinedAutocompleteProvider(
450
+ [
451
+ { name: "help", description: "Show help" },
452
+ { name: "clear", description: "Clear screen" },
453
+ { name: "delete", description: "Delete last message" },
454
+ ],
455
+ getProjectDir(), // base path for file completion
456
+ );
457
+
458
+ editor.setAutocompleteProvider(provider);
459
+ ```
460
+
461
+ **Features:**
462
+
463
+ - Type `/` to see slash commands
464
+ - Press `Tab` for file path completion
465
+ - Works with `~/`, `./`, `../`, and `@` prefix
466
+ - Filters to attachable files for `@` prefix
467
+
468
+ ## Key Detection
469
+
470
+ Helper functions for detecting keyboard input (supports Kitty keyboard protocol):
471
+
472
+ ```typescript
473
+ import {
474
+ isEnter,
475
+ isEscape,
476
+ isTab,
477
+ isShiftTab,
478
+ isArrowUp,
479
+ isArrowDown,
480
+ isArrowLeft,
481
+ isArrowRight,
482
+ isCtrlA,
483
+ isCtrlC,
484
+ isCtrlE,
485
+ isCtrlK,
486
+ isCtrlO,
487
+ isCtrlP,
488
+ isCtrlLeft,
489
+ isCtrlRight,
490
+ isAltLeft,
491
+ isAltRight,
492
+ isShiftEnter,
493
+ isAltEnter,
494
+ isShiftCtrlO,
495
+ isShiftCtrlD,
496
+ isShiftCtrlP,
497
+ isBackspace,
498
+ isDelete,
499
+ isHome,
500
+ isEnd,
501
+ // ... and more
502
+ } from "@linxiraos/pi-tui";
503
+
504
+ if (isCtrlC(data)) {
505
+ process.exit(0);
506
+ }
507
+ ```
508
+
509
+ ## Differential Rendering
510
+
511
+ The TUI uses three rendering strategies:
512
+
513
+ 1. **First Render**: Output all lines without clearing scrollback
514
+ 2. **Width Changed or Change Above Viewport**: Clear screen and full re-render
515
+ 3. **Normal Update**: Move cursor to first changed line, clear to end, render changed lines
516
+
517
+ All updates are wrapped in **synchronized output** (`\x1b[?2026h` ... `\x1b[?2026l`) for atomic, flicker-free rendering unless `PI_NO_SYNC_OUTPUT=1` is set. The opt-out removes only the DEC 2026 wrapper; paint writes still guard terminal autowrap to avoid pending-wrap cursor artifacts.
518
+
519
+ ## Terminal Interface
520
+
521
+ The TUI works with any object implementing the `Terminal` interface:
522
+
523
+ ```typescript
524
+ interface Terminal {
525
+ start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void;
526
+ stop(): void;
527
+ write(data: string): void;
528
+ get columns(): number;
529
+ get rows(): number;
530
+ moveBy(lines: number): void;
531
+ hideCursor(force?: boolean): void;
532
+ showCursor(force?: boolean): void;
533
+ clearLine(): void;
534
+ clearFromCursor(): void;
535
+ clearScreen(): void;
536
+ }
537
+ ```
538
+
539
+ **Built-in implementations:**
540
+
541
+ - `ProcessTerminal` - Uses `process.stdin/stdout`
542
+ - `VirtualTerminal` - For testing (uses ghostty-web)
543
+
544
+ ## Utilities
545
+
546
+ ```typescript
547
+ import { Ellipsis, visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@linxiraos/pi-tui";
548
+
549
+ // Get visible width of string (ignoring ANSI codes, uses Bun.stringWidth)
550
+ const width = visibleWidth("\x1b[31mHello\x1b[0m"); // 5
551
+
552
+ // Truncate string to width (preserving ANSI codes, adds ellipsis)
553
+ const truncated = truncateToWidth("Hello World", 8); // "Hello…" (default: Ellipsis.Unicode)
554
+
555
+ // Truncate without ellipsis
556
+ const truncatedNoEllipsis = truncateToWidth("Hello World", 8, Ellipsis.Omit); // "Hello Wo"
557
+
558
+ // Wrap text to width (Bun.wrapAnsi word wrap, trims line ends, preserves ANSI)
559
+ const lines = wrapTextWithAnsi("This is a long line that needs wrapping", 20);
560
+ // ["This is a long line", "that needs wrapping"]
561
+ ```
562
+
563
+ ## Creating Custom Components
564
+
565
+ When creating custom components, **each line returned by `render()` must not exceed the `width` parameter**. The TUI will error if any line is wider than the terminal.
566
+
567
+ ### Handling Input
568
+
569
+ Use the key detection utilities to handle keyboard input:
570
+
571
+ ```typescript
572
+ import { isEnter, isEscape, isArrowUp, isArrowDown, isCtrlC, isTab, isBackspace } from "@linxiraos/pi-tui";
573
+ import type { Component } from "@linxiraos/pi-tui";
574
+
575
+ class MyInteractiveComponent implements Component {
576
+ private selectedIndex = 0;
577
+ private items = ["Option 1", "Option 2", "Option 3"];
578
+
579
+ onSelect?: (index: number) => void;
580
+ onCancel?: () => void;
581
+
582
+ handleInput(data: string): void {
583
+ if (isArrowUp(data)) {
584
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
585
+ } else if (isArrowDown(data)) {
586
+ this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1);
587
+ } else if (isEnter(data)) {
588
+ this.onSelect?.(this.selectedIndex);
589
+ } else if (isEscape(data) || isCtrlC(data)) {
590
+ this.onCancel?.();
591
+ }
592
+ }
593
+
594
+ render(width: number): readonly string[] {
595
+ return this.items.map((item, i) => {
596
+ const prefix = i === this.selectedIndex ? "> " : " ";
597
+ return truncateToWidth(prefix + item, width);
598
+ });
599
+ }
600
+ }
601
+ ```
602
+
603
+ ### Handling Line Width
604
+
605
+ Use the provided utilities to ensure lines fit:
606
+
607
+ ```typescript
608
+ import { visibleWidth, truncateToWidth } from "@linxiraos/pi-tui";
609
+ import type { Component } from "@linxiraos/pi-tui";
610
+
611
+ class MyComponent implements Component {
612
+ private text: string;
613
+
614
+ constructor(text: string) {
615
+ this.text = text;
616
+ }
617
+
618
+ render(width: number): readonly string[] {
619
+ // Option 1: Truncate long lines
620
+ return [truncateToWidth(this.text, width)];
621
+
622
+ // Option 2: Check and pad to exact width
623
+ const line = this.text;
624
+ const visible = visibleWidth(line);
625
+ if (visible > width) {
626
+ return [truncateToWidth(line, width)];
627
+ }
628
+ // Pad to exact width (optional, for backgrounds)
629
+ return [line + " ".repeat(width - visible)];
630
+ }
631
+ }
632
+ ```
633
+
634
+ ### ANSI Code Considerations
635
+
636
+ `visibleWidth()`, `truncateToWidth()`, and `wrapTextWithAnsi()` correctly handle ANSI escape codes:
637
+
638
+ - `visibleWidth()` ignores ANSI codes when calculating width (via `Bun.stringWidth`)
639
+ - `truncateToWidth()` preserves ANSI codes and properly closes them when truncating
640
+ - `wrapTextWithAnsi()` preserves ANSI codes while word-wrapping and trimming line ends
641
+
642
+ ```typescript
643
+ import chalk from "@oh-my-pi/pi-utils/chalk";
644
+
645
+ const styled = chalk.red("Hello") + " " + chalk.blue("World");
646
+ const width = visibleWidth(styled); // 11 (not counting ANSI codes)
647
+ const truncated = truncateToWidth(styled, 8); // Red "Hello" + " W..." with proper reset
648
+ ```
649
+
650
+ ### Caching
651
+
652
+ For performance, components should cache their rendered output and only re-render when necessary:
653
+
654
+ ```typescript
655
+ class CachedComponent implements Component {
656
+ private text: string;
657
+ private cachedWidth?: number;
658
+ private cachedLines?: string[];
659
+
660
+ render(width: number): readonly string[] {
661
+ if (this.cachedLines && this.cachedWidth === width) {
662
+ return this.cachedLines;
663
+ }
664
+
665
+ const lines = [truncateToWidth(this.text, width)];
666
+
667
+ this.cachedWidth = width;
668
+ this.cachedLines = lines;
669
+ return lines;
670
+ }
671
+
672
+ invalidate(): void {
673
+ this.cachedWidth = undefined;
674
+ this.cachedLines = undefined;
675
+ }
676
+ }
677
+ ```
678
+
679
+ ## Example
680
+
681
+ See `test/chat-simple.ts` for a complete chat interface example with:
682
+
683
+ - Markdown messages with custom background colors
684
+ - Loading spinner during responses
685
+ - Editor with autocomplete and slash commands
686
+ - Spacers between messages
687
+
688
+ Run it:
689
+
690
+ ```bash
691
+ npx tsx test/chat-simple.ts
692
+ ```
693
+
694
+ ## Development
695
+
696
+ ```bash
697
+ # Install dependencies (from monorepo root)
698
+ npm install
699
+
700
+ # Run type checking
701
+ npm run check
702
+
703
+ # Run the demo
704
+ npx tsx test/chat-simple.ts
705
+ ```