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