@ponythewhite/base-context-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 (135) hide show
  1. package/LICENSE +23 -0
  2. package/NOTICE +22 -0
  3. package/README.md +784 -0
  4. package/dist/LICENSE +23 -0
  5. package/dist/NOTICE +22 -0
  6. package/dist/autocomplete.d.ts +61 -0
  7. package/dist/autocomplete.d.ts.map +1 -0
  8. package/dist/autocomplete.js +601 -0
  9. package/dist/autocomplete.js.map +1 -0
  10. package/dist/build-info.json +14 -0
  11. package/dist/components/box.d.ts +24 -0
  12. package/dist/components/box.d.ts.map +1 -0
  13. package/dist/components/box.js +114 -0
  14. package/dist/components/box.js.map +1 -0
  15. package/dist/components/cancellable-loader.d.ts +22 -0
  16. package/dist/components/cancellable-loader.d.ts.map +1 -0
  17. package/dist/components/cancellable-loader.js +35 -0
  18. package/dist/components/cancellable-loader.js.map +1 -0
  19. package/dist/components/editor.d.ts +275 -0
  20. package/dist/components/editor.d.ts.map +1 -0
  21. package/dist/components/editor.js +1968 -0
  22. package/dist/components/editor.js.map +1 -0
  23. package/dist/components/image.d.ts +34 -0
  24. package/dist/components/image.d.ts.map +1 -0
  25. package/dist/components/image.js +96 -0
  26. package/dist/components/image.js.map +1 -0
  27. package/dist/components/input.d.ts +37 -0
  28. package/dist/components/input.d.ts.map +1 -0
  29. package/dist/components/input.js +385 -0
  30. package/dist/components/input.js.map +1 -0
  31. package/dist/components/loader.d.ts +29 -0
  32. package/dist/components/loader.d.ts.map +1 -0
  33. package/dist/components/loader.js +69 -0
  34. package/dist/components/loader.js.map +1 -0
  35. package/dist/components/markdown.d.ts +105 -0
  36. package/dist/components/markdown.d.ts.map +1 -0
  37. package/dist/components/markdown.js +776 -0
  38. package/dist/components/markdown.js.map +1 -0
  39. package/dist/components/select-list.d.ts +61 -0
  40. package/dist/components/select-list.d.ts.map +1 -0
  41. package/dist/components/select-list.js +206 -0
  42. package/dist/components/select-list.js.map +1 -0
  43. package/dist/components/settings-list.d.ts +49 -0
  44. package/dist/components/settings-list.d.ts.map +1 -0
  45. package/dist/components/settings-list.js +170 -0
  46. package/dist/components/settings-list.js.map +1 -0
  47. package/dist/components/spacer.d.ts +12 -0
  48. package/dist/components/spacer.d.ts.map +1 -0
  49. package/dist/components/spacer.js +21 -0
  50. package/dist/components/spacer.js.map +1 -0
  51. package/dist/components/text.d.ts +19 -0
  52. package/dist/components/text.d.ts.map +1 -0
  53. package/dist/components/text.js +77 -0
  54. package/dist/components/text.js.map +1 -0
  55. package/dist/components/truncated-text.d.ts +14 -0
  56. package/dist/components/truncated-text.d.ts.map +1 -0
  57. package/dist/components/truncated-text.js +44 -0
  58. package/dist/components/truncated-text.js.map +1 -0
  59. package/dist/editor-component.d.ts +51 -0
  60. package/dist/editor-component.d.ts.map +1 -0
  61. package/dist/editor-component.js +2 -0
  62. package/dist/editor-component.js.map +1 -0
  63. package/dist/fullscreen.d.ts +105 -0
  64. package/dist/fullscreen.d.ts.map +1 -0
  65. package/dist/fullscreen.js +597 -0
  66. package/dist/fullscreen.js.map +1 -0
  67. package/dist/fuzzy.d.ts +26 -0
  68. package/dist/fuzzy.d.ts.map +1 -0
  69. package/dist/fuzzy.js +114 -0
  70. package/dist/fuzzy.js.map +1 -0
  71. package/dist/index.d.ts +29 -0
  72. package/dist/index.d.ts.map +1 -0
  73. package/dist/index.js +27 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/keybindings.d.ts +239 -0
  76. package/dist/keybindings.d.ts.map +1 -0
  77. package/dist/keybindings.js +222 -0
  78. package/dist/keybindings.js.map +1 -0
  79. package/dist/keys.d.ts +184 -0
  80. package/dist/keys.d.ts.map +1 -0
  81. package/dist/keys.js +1163 -0
  82. package/dist/keys.js.map +1 -0
  83. package/dist/kill-ring.d.ts +26 -0
  84. package/dist/kill-ring.d.ts.map +1 -0
  85. package/dist/kill-ring.js +42 -0
  86. package/dist/kill-ring.js.map +1 -0
  87. package/dist/latex.d.ts +21 -0
  88. package/dist/latex.d.ts.map +1 -0
  89. package/dist/latex.js +797 -0
  90. package/dist/latex.js.map +1 -0
  91. package/dist/mouse.d.ts +24 -0
  92. package/dist/mouse.d.ts.map +1 -0
  93. package/dist/mouse.js +34 -0
  94. package/dist/mouse.js.map +1 -0
  95. package/dist/render-cache.d.ts +9 -0
  96. package/dist/render-cache.d.ts.map +1 -0
  97. package/dist/render-cache.js +23 -0
  98. package/dist/render-cache.js.map +1 -0
  99. package/dist/selection-metadata.d.ts +22 -0
  100. package/dist/selection-metadata.d.ts.map +1 -0
  101. package/dist/selection-metadata.js +141 -0
  102. package/dist/selection-metadata.js.map +1 -0
  103. package/dist/slash-command-context.d.ts +12 -0
  104. package/dist/slash-command-context.d.ts.map +1 -0
  105. package/dist/slash-command-context.js +32 -0
  106. package/dist/slash-command-context.js.map +1 -0
  107. package/dist/stdin-buffer.d.ts +50 -0
  108. package/dist/stdin-buffer.d.ts.map +1 -0
  109. package/dist/stdin-buffer.js +313 -0
  110. package/dist/stdin-buffer.js.map +1 -0
  111. package/dist/terminal-colors.d.ts +31 -0
  112. package/dist/terminal-colors.d.ts.map +1 -0
  113. package/dist/terminal-colors.js +161 -0
  114. package/dist/terminal-colors.js.map +1 -0
  115. package/dist/terminal-image.d.ts +85 -0
  116. package/dist/terminal-image.d.ts.map +1 -0
  117. package/dist/terminal-image.js +324 -0
  118. package/dist/terminal-image.js.map +1 -0
  119. package/dist/terminal.d.ts +113 -0
  120. package/dist/terminal.d.ts.map +1 -0
  121. package/dist/terminal.js +486 -0
  122. package/dist/terminal.js.map +1 -0
  123. package/dist/tui.d.ts +313 -0
  124. package/dist/tui.d.ts.map +1 -0
  125. package/dist/tui.js +1683 -0
  126. package/dist/tui.js.map +1 -0
  127. package/dist/undo-stack.d.ts +16 -0
  128. package/dist/undo-stack.d.ts.map +1 -0
  129. package/dist/undo-stack.js +26 -0
  130. package/dist/undo-stack.js.map +1 -0
  131. package/dist/utils.d.ts +98 -0
  132. package/dist/utils.d.ts.map +1 -0
  133. package/dist/utils.js +1233 -0
  134. package/dist/utils.js.map +1 -0
  135. package/package.json +63 -0
package/README.md ADDED
@@ -0,0 +1,784 @@
1
+ # Synerise base-context TUI
2
+
3
+ Terminal UI primitives with differential rendering.
4
+
5
+ Part of **[Synerise base-context](https://github.com/BaseModelAI/base-context)**. Forked from Prime Intellect's [Prime Agent](https://github.com/PrimeIntellect-ai/prime-agent), built on Mario Zechner's Pi. MIT; see [LICENSE](LICENSE) and [NOTICE](NOTICE).
6
+
7
+ Minimal terminal UI framework with differential rendering and synchronized output for flicker-free interactive CLI applications.
8
+
9
+ ## Features
10
+
11
+ - **Differential Rendering**: Three-strategy rendering system that only updates what changed
12
+ - **Synchronized Output**: Uses CSI 2026 for atomic screen updates (no flicker)
13
+ - **Bracketed Paste Mode**: Handles large pastes correctly with markers for >10 line pastes
14
+ - **Component-based**: Simple Component interface with render() method
15
+ - **Theme Support**: Components accept theme interfaces for customizable styling
16
+ - **Built-in Components**: Text, TruncatedText, Input, Editor, Markdown, Loader, SelectList, SettingsList, Spacer, Image, Box, Container
17
+ - **Image Support**: Renders terminal graphics or compact image metadata
18
+ - **Autocomplete Support**: File paths and slash commands
19
+
20
+ ## Quick Start
21
+
22
+ ```typescript
23
+ import { TUI, Text, Editor, ProcessTerminal, matchesKey } from "@ponythewhite/base-context-tui";
24
+
25
+ // Create terminal
26
+ const terminal = new ProcessTerminal();
27
+
28
+ // Create TUI
29
+ const tui = new TUI(terminal);
30
+
31
+ // Add components
32
+ tui.addChild(new Text("Welcome to my app!"));
33
+
34
+ import { defaultEditorTheme as editorTheme } from './test/test-themes.ts';
35
+ const editor = new Editor(tui, editorTheme);
36
+ editor.onSubmit = (text) => {
37
+ console.log("Submitted:", text);
38
+ tui.addChild(new Text(`You said: ${text}`));
39
+ };
40
+ tui.addChild(editor);
41
+
42
+ // Focus the editor so it receives keyboard input
43
+ tui.setFocus(editor);
44
+
45
+ // In raw mode Ctrl+C doesn't send SIGINT — intercept it here to allow exit
46
+ tui.addInputListener((data) => {
47
+ if (matchesKey(data, 'ctrl+c')) {
48
+ tui.stop();
49
+ process.exit(0);
50
+ }
51
+ });
52
+
53
+ // Start
54
+ tui.start();
55
+ ```
56
+
57
+ ## Core API
58
+
59
+ ### TUI
60
+
61
+ Main container that manages components and rendering.
62
+
63
+ ```typescript
64
+ const tui = new TUI(terminal);
65
+ tui.addChild(component);
66
+ tui.removeChild(component);
67
+ tui.start();
68
+ tui.stop();
69
+ tui.requestRender(); // Request a re-render
70
+
71
+ // Global debug key handler (Shift+Ctrl+D)
72
+ tui.onDebug = () => console.log("Debug triggered");
73
+ ```
74
+
75
+ ### Overlays
76
+
77
+ Overlays render components on top of existing content without replacing it. Useful for dialogs, menus, and modal UI.
78
+
79
+ ```typescript
80
+ // Show overlay with default options (centered, max 80 cols)
81
+ const handle = tui.showOverlay(component);
82
+
83
+ // Show overlay with custom positioning and sizing
84
+ // Values can be numbers (absolute) or percentage strings (e.g., "50%")
85
+ const handle = tui.showOverlay(component, {
86
+ // Sizing
87
+ width: 60, // Fixed width in columns
88
+ width: "80%", // Width as percentage of terminal
89
+ minWidth: 40, // Minimum width floor
90
+ maxHeight: 20, // Maximum height in rows
91
+ maxHeight: "50%", // Maximum height as percentage of terminal
92
+
93
+ // Anchor-based positioning (default: 'center')
94
+ anchor: 'bottom-right', // Position relative to anchor point
95
+ offsetX: 2, // Horizontal offset from anchor
96
+ offsetY: -1, // Vertical offset from anchor
97
+
98
+ // Percentage-based positioning (alternative to anchor)
99
+ row: "25%", // Vertical position (0%=top, 100%=bottom)
100
+ col: "50%", // Horizontal position (0%=left, 100%=right)
101
+
102
+ // Absolute positioning (overrides anchor/percent)
103
+ row: 5, // Exact row position
104
+ col: 10, // Exact column position
105
+
106
+ // Margin from terminal edges
107
+ margin: 2, // All sides
108
+ margin: { top: 1, right: 2, bottom: 1, left: 2 },
109
+
110
+ // Responsive visibility
111
+ visible: (termWidth, termHeight) => termWidth >= 100 // Hide on narrow terminals
112
+
113
+ // Focus behavior
114
+ nonCapturing: true // Don't auto-focus when shown
115
+ });
116
+
117
+ // OverlayHandle methods
118
+ handle.hide(); // Permanently remove the overlay
119
+ handle.setHidden(true); // Temporarily hide (can show again)
120
+ handle.setHidden(false); // Show again after hiding
121
+ handle.isHidden(); // Check if temporarily hidden
122
+ handle.focus(); // Focus and bring to visual front
123
+ handle.unfocus(); // Release focus to previous target
124
+ handle.isFocused(); // Check if overlay has focus
125
+
126
+ // Hide topmost overlay
127
+ tui.hideOverlay();
128
+
129
+ // Check if any visible overlay is active
130
+ tui.hasOverlay();
131
+ ```
132
+
133
+ **Anchor values**: `'center'`, `'top-left'`, `'top-right'`, `'bottom-left'`, `'bottom-right'`, `'top-center'`, `'bottom-center'`, `'left-center'`, `'right-center'`
134
+
135
+ **Resolution order**:
136
+ 1. `minWidth` is applied as a floor after width calculation
137
+ 2. For position: absolute `row`/`col` > percentage `row`/`col` > `anchor`
138
+ 3. `margin` clamps final position to stay within terminal bounds
139
+ 4. `visible` callback controls whether overlay renders (called each frame)
140
+
141
+ ### Component Interface
142
+
143
+ All components implement:
144
+
145
+ ```typescript
146
+ interface Component {
147
+ render(width: number): string[];
148
+ handleInput?(data: string): void;
149
+ invalidate?(): void;
150
+ }
151
+ ```
152
+
153
+ | Method | Description |
154
+ |--------|-------------|
155
+ | `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. |
156
+ | `handleInput?(data)` | Called when the component has focus and receives keyboard input. The `data` string contains raw terminal input (may include ANSI escape sequences). |
157
+ | `invalidate?()` | Called to clear any cached render state. Components should re-render from scratch on the next `render()` call. |
158
+
159
+ The TUI appends a full SGR reset and OSC 8 reset at the end of each rendered line. Styles do not carry across lines. If you emit multi-line text with styling, reapply styles per line or use `wrapTextWithAnsi()` so styles are preserved for each wrapped line.
160
+
161
+ ### Focusable Interface (IME Support)
162
+
163
+ Components that display a text cursor and need IME (Input Method Editor) support should implement the `Focusable` interface:
164
+
165
+ ```typescript
166
+ import { CURSOR_MARKER, type Component, type Focusable } from "@ponythewhite/base-context-tui";
167
+
168
+ class MyInput implements Component, Focusable {
169
+ focused: boolean = false; // Set by TUI when focus changes
170
+
171
+ render(width: number): string[] {
172
+ const marker = this.focused ? CURSOR_MARKER : "";
173
+ // Emit marker right before the fake cursor
174
+ return [`> ${beforeCursor}${marker}\x1b[7m${atCursor}\x1b[27m${afterCursor}`];
175
+ }
176
+ }
177
+ ```
178
+
179
+ When a `Focusable` component has focus, TUI:
180
+ 1. Sets `focused = true` on the component
181
+ 2. Scans rendered output for `CURSOR_MARKER` (a zero-width APC escape sequence)
182
+ 3. Positions the hardware terminal cursor at that location
183
+ 4. Shows the hardware cursor
184
+
185
+ This enables IME candidate windows to appear at the correct position for CJK input methods. The `Editor` and `Input` built-in components already implement this interface.
186
+
187
+ **Container components with embedded inputs:** When a container component (dialog, selector, etc.) contains an `Input` or `Editor` child, the container must implement `Focusable` and propagate the focus state to the child:
188
+
189
+ ```typescript
190
+ import { Container, type Focusable, Input } from "@ponythewhite/base-context-tui";
191
+
192
+ class SearchDialog extends Container implements Focusable {
193
+ private searchInput: Input;
194
+
195
+ // Propagate focus to child input for IME cursor positioning
196
+ private _focused = false;
197
+ get focused(): boolean { return this._focused; }
198
+ set focused(value: boolean) {
199
+ this._focused = value;
200
+ this.searchInput.focused = value;
201
+ }
202
+
203
+ constructor() {
204
+ super();
205
+ this.searchInput = new Input();
206
+ this.addChild(this.searchInput);
207
+ }
208
+ }
209
+ ```
210
+
211
+ Without this propagation, typing with an IME (Chinese, Japanese, Korean, etc.) will show the candidate window in the wrong position.
212
+
213
+ ## Built-in Components
214
+
215
+ ### Container
216
+
217
+ Groups child components.
218
+
219
+ ```typescript
220
+ const container = new Container();
221
+ container.addChild(component);
222
+ container.removeChild(component);
223
+ ```
224
+
225
+ ### Box
226
+
227
+ Container that applies padding and background color to all children.
228
+
229
+ ```typescript
230
+ const box = new Box(
231
+ 1, // paddingX (default: 1)
232
+ 1, // paddingY (default: 1)
233
+ (text) => chalk.bgGray(text) // optional background function
234
+ );
235
+ box.addChild(new Text("Content"));
236
+ box.setBgFn((text) => chalk.bgBlue(text)); // Change background dynamically
237
+ ```
238
+
239
+ ### Text
240
+
241
+ Displays multi-line text with word wrapping and padding.
242
+
243
+ ```typescript
244
+ const text = new Text(
245
+ "Hello World", // text content
246
+ 1, // paddingX (default: 1)
247
+ 1, // paddingY (default: 1)
248
+ (text) => chalk.bgGray(text) // optional background function
249
+ );
250
+ text.setText("Updated text");
251
+ text.setCustomBgFn((text) => chalk.bgBlue(text));
252
+ ```
253
+
254
+ ### TruncatedText
255
+
256
+ Single-line text that truncates to fit viewport width. Useful for status lines and headers.
257
+
258
+ ```typescript
259
+ const truncated = new TruncatedText(
260
+ "This is a very long line that will be truncated...",
261
+ 0, // paddingX (default: 0)
262
+ 0 // paddingY (default: 0)
263
+ );
264
+ ```
265
+
266
+ ### Input
267
+
268
+ Single-line text input with horizontal scrolling.
269
+
270
+ ```typescript
271
+ const input = new Input();
272
+ input.onSubmit = (value) => console.log(value);
273
+ input.setValue("initial");
274
+ input.getValue();
275
+ ```
276
+
277
+ **Key Bindings:**
278
+ - `Enter` - Submit
279
+ - `Ctrl+A` / `Ctrl+E` - Line start/end
280
+ - `Ctrl+W` or `Alt+Backspace` - Delete word backwards
281
+ - `Ctrl+U` - Delete to start of line
282
+ - `Ctrl+K` - Delete to end of line
283
+ - `Ctrl+Left` / `Ctrl+Right` - Word navigation
284
+ - `Alt+Left` / `Alt+Right` - Word navigation
285
+ - Arrow keys, Backspace, Delete work as expected
286
+
287
+ ### Editor
288
+
289
+ Multi-line text editor with autocomplete, file completion, paste handling, and vertical scrolling when content exceeds terminal height.
290
+
291
+ ```typescript
292
+ interface EditorTheme {
293
+ borderColor: (str: string) => string;
294
+ selectList: SelectListTheme;
295
+ }
296
+
297
+ interface EditorOptions {
298
+ paddingX?: number; // Horizontal padding (default: 0)
299
+ }
300
+
301
+ const editor = new Editor(tui, theme, options?); // tui is required for height-aware scrolling
302
+ editor.onSubmit = (text) => console.log(text);
303
+ editor.onChange = (text) => console.log("Changed:", text);
304
+ editor.disableSubmit = true; // Disable submit temporarily
305
+ editor.setAutocompleteProvider(provider);
306
+ editor.borderColor = (s) => chalk.blue(s); // Change border dynamically
307
+ editor.setPaddingX(1); // Update horizontal padding dynamically
308
+ editor.getPaddingX(); // Get current padding
309
+ ```
310
+
311
+ **Features:**
312
+ - Multi-line editing with word wrap
313
+ - Slash command autocomplete (type `/`)
314
+ - File path autocomplete (press `Tab`)
315
+ - Large paste handling (>10 lines creates `[paste #1 +50 lines]` marker)
316
+ - Horizontal lines above/below editor
317
+ - Fake cursor rendering (hidden real cursor)
318
+
319
+ **Key Bindings:**
320
+ - `Enter` - Submit
321
+ - `Shift+Enter`, `Ctrl+Enter`, or `Alt+Enter` - New line (terminal-dependent, Alt+Enter most reliable)
322
+ - `Tab` - Autocomplete
323
+ - `Ctrl+K` - Delete to end of line
324
+ - `Ctrl+U` - Delete to start of line
325
+ - `Ctrl+W` or `Alt+Backspace` - Delete word backwards
326
+ - `Alt+D` or `Alt+Delete` - Delete word forwards
327
+ - `Ctrl+A` / `Ctrl+E` - Line start/end
328
+ - `Ctrl+]` - Jump forward to character (awaits next keypress, then moves cursor to first occurrence)
329
+ - `Ctrl+Alt+]` - Jump backward to character
330
+ - Arrow keys, Backspace, Delete work as expected
331
+
332
+ ### Markdown
333
+
334
+ Renders markdown with syntax highlighting and theming support.
335
+
336
+ ```typescript
337
+ interface MarkdownTheme {
338
+ heading: (text: string) => string;
339
+ link: (text: string) => string;
340
+ linkUrl: (text: string) => string;
341
+ code: (text: string) => string;
342
+ codeBlock: (text: string) => string;
343
+ codeBlockBorder: (text: string) => string;
344
+ quote: (text: string) => string;
345
+ quoteBorder: (text: string) => string;
346
+ hr: (text: string) => string;
347
+ listBullet: (text: string) => string;
348
+ bold: (text: string) => string;
349
+ italic: (text: string) => string;
350
+ strikethrough: (text: string) => string;
351
+ underline: (text: string) => string;
352
+ highlightCode?: (code: string, lang?: string) => string[];
353
+ }
354
+
355
+ interface DefaultTextStyle {
356
+ color?: (text: string) => string;
357
+ bgColor?: (text: string) => string;
358
+ bold?: boolean;
359
+ italic?: boolean;
360
+ strikethrough?: boolean;
361
+ underline?: boolean;
362
+ }
363
+
364
+ const md = new Markdown(
365
+ "# Hello\n\nSome **bold** text",
366
+ 1, // paddingX
367
+ 1, // paddingY
368
+ theme, // MarkdownTheme
369
+ defaultStyle // optional DefaultTextStyle
370
+ );
371
+ md.setText("Updated markdown");
372
+ ```
373
+
374
+ **Features:**
375
+ - Headings, bold, italic, code blocks, lists, links, blockquotes
376
+ - HTML tags rendered as plain text
377
+ - Optional syntax highlighting via `highlightCode`
378
+ - Padding support
379
+ - Render caching for performance
380
+
381
+ ### Loader
382
+
383
+ Animated loading spinner.
384
+
385
+ ```typescript
386
+ const loader = new Loader(
387
+ tui, // TUI instance for render updates
388
+ (s) => chalk.cyan(s), // spinner color function
389
+ (s) => chalk.gray(s), // message color function
390
+ "Loading..." // message (default: "Loading...")
391
+ );
392
+ loader.start();
393
+ loader.setMessage("Still loading...");
394
+ loader.stop();
395
+ ```
396
+
397
+ ### CancellableLoader
398
+
399
+ Extends Loader with Escape key handling and an AbortSignal for cancelling async operations.
400
+
401
+ ```typescript
402
+ const loader = new CancellableLoader(
403
+ tui, // TUI instance for render updates
404
+ (s) => chalk.cyan(s), // spinner color function
405
+ (s) => chalk.gray(s), // message color function
406
+ "Working..." // message
407
+ );
408
+ loader.onAbort = () => done(null); // Called when user presses Escape
409
+ doAsyncWork(loader.signal).then(done);
410
+ ```
411
+
412
+ **Properties:**
413
+ - `signal: AbortSignal` - Aborted when user presses Escape
414
+ - `aborted: boolean` - Whether the loader was aborted
415
+ - `onAbort?: () => void` - Callback when user presses Escape
416
+
417
+ ### SelectList
418
+
419
+ Interactive selection list with keyboard navigation.
420
+
421
+ ```typescript
422
+ interface SelectItem {
423
+ value: string;
424
+ label: string;
425
+ description?: string;
426
+ }
427
+
428
+ interface SelectListTheme {
429
+ selectedPrefix: (text: string) => string;
430
+ selectedText: (text: string) => string;
431
+ description: (text: string) => string;
432
+ scrollInfo: (text: string) => string;
433
+ noMatch: (text: string) => string;
434
+ }
435
+
436
+ const list = new SelectList(
437
+ [
438
+ { value: "opt1", label: "Option 1", description: "First option" },
439
+ { value: "opt2", label: "Option 2", description: "Second option" },
440
+ ],
441
+ 5, // maxVisible
442
+ theme // SelectListTheme
443
+ );
444
+
445
+ list.onSelect = (item) => console.log("Selected:", item);
446
+ list.onCancel = () => console.log("Cancelled");
447
+ list.onSelectionChange = (item) => console.log("Highlighted:", item);
448
+ list.setFilter("opt"); // Filter items
449
+ ```
450
+
451
+ **Controls:**
452
+ - Arrow keys: Navigate
453
+ - Enter: Select
454
+ - Escape: Cancel
455
+
456
+ ### SettingsList
457
+
458
+ Settings panel with value cycling and submenus.
459
+
460
+ ```typescript
461
+ interface SettingItem {
462
+ id: string;
463
+ label: string;
464
+ description?: string;
465
+ currentValue: string;
466
+ values?: string[]; // If provided, Enter/Space cycles through these
467
+ submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component;
468
+ }
469
+
470
+ interface SettingsListTheme {
471
+ label: (text: string, selected: boolean) => string;
472
+ value: (text: string, selected: boolean) => string;
473
+ description: (text: string) => string;
474
+ cursor: string;
475
+ hint: (text: string) => string;
476
+ }
477
+
478
+ const settings = new SettingsList(
479
+ [
480
+ { id: "theme", label: "Theme", currentValue: "dark", values: ["dark", "light"] },
481
+ { id: "model", label: "Model", currentValue: "gpt-4", submenu: (val, done) => modelSelector },
482
+ ],
483
+ 10, // maxVisible
484
+ theme, // SettingsListTheme
485
+ (id, newValue) => console.log(`${id} changed to ${newValue}`),
486
+ () => console.log("Cancelled")
487
+ );
488
+ settings.updateValue("theme", "light");
489
+ ```
490
+
491
+ **Controls:**
492
+ - Arrow keys: Navigate
493
+ - Enter/Space: Activate (cycle value or open submenu)
494
+ - Escape: Cancel
495
+
496
+ ### Spacer
497
+
498
+ Empty lines for vertical spacing.
499
+
500
+ ```typescript
501
+ const spacer = new Spacer(2); // 2 empty lines (default: 1)
502
+ ```
503
+
504
+ ### Image
505
+
506
+ Renders Kitty or iTerm2 terminal graphics when supported. Set `fallbackOnly: true` to show compact image metadata instead; Prime Agent uses this metadata-only mode.
507
+
508
+ ```typescript
509
+ interface ImageTheme {
510
+ fallbackColor: (str: string) => string;
511
+ }
512
+
513
+ interface ImageOptions {
514
+ maxWidthCells?: number;
515
+ maxHeightCells?: number;
516
+ filename?: string;
517
+ fallbackOnly?: boolean;
518
+ }
519
+
520
+ const image = new Image(
521
+ base64Data, // base64-encoded image data
522
+ "image/png", // MIME type
523
+ theme, // ImageTheme
524
+ options // optional ImageOptions
525
+ );
526
+ tui.addChild(image);
527
+ ```
528
+
529
+ Supported formats: PNG, JPEG, GIF, WebP. Dimensions are parsed from the image headers automatically.
530
+
531
+ ## Autocomplete
532
+
533
+ ### CombinedAutocompleteProvider
534
+
535
+ Supports both slash commands and file paths.
536
+
537
+ ```typescript
538
+ import { CombinedAutocompleteProvider } from "@ponythewhite/base-context-tui";
539
+
540
+ const provider = new CombinedAutocompleteProvider(
541
+ [
542
+ { name: "help", description: "Show help" },
543
+ { name: "clear", description: "Clear screen" },
544
+ { name: "delete", description: "Delete last message" },
545
+ ],
546
+ process.cwd() // base path for file completion
547
+ );
548
+
549
+ editor.setAutocompleteProvider(provider);
550
+ ```
551
+
552
+ **Features:**
553
+ - Type `/` to see slash commands
554
+ - Press `Tab` for file path completion
555
+ - Works with `~/`, `./`, `../`, and `@` prefix
556
+ - Filters to attachable files for `@` prefix
557
+
558
+ ## Key Detection
559
+
560
+ Use `matchesKey()` with the `Key` helper for detecting keyboard input (supports Kitty keyboard protocol):
561
+
562
+ ```typescript
563
+ import { matchesKey, Key } from "@ponythewhite/base-context-tui";
564
+
565
+ if (matchesKey(data, Key.ctrl("c"))) {
566
+ process.exit(0);
567
+ }
568
+
569
+ if (matchesKey(data, Key.enter)) {
570
+ submit();
571
+ } else if (matchesKey(data, Key.escape)) {
572
+ cancel();
573
+ } else if (matchesKey(data, Key.up)) {
574
+ moveUp();
575
+ }
576
+ ```
577
+
578
+ **Key identifiers** (use `Key.*` for autocomplete, or string literals):
579
+ - Basic keys: `Key.enter`, `Key.escape`, `Key.tab`, `Key.space`, `Key.backspace`, `Key.delete`, `Key.home`, `Key.end`
580
+ - Arrow keys: `Key.up`, `Key.down`, `Key.left`, `Key.right`
581
+ - With modifiers: `Key.ctrl("c")`, `Key.shift("tab")`, `Key.alt("left")`, `Key.ctrlShift("p")`
582
+ - String format also works: `"enter"`, `"ctrl+c"`, `"shift+tab"`, `"ctrl+shift+p"`
583
+
584
+ ## Differential Rendering
585
+
586
+ The TUI uses three rendering strategies:
587
+
588
+ 1. **First Render**: Output all lines without clearing scrollback
589
+ 2. **Width Changed or Change Above Viewport**: Clear screen and full re-render
590
+ 3. **Normal Update**: Move cursor to first changed line, clear to end, render changed lines
591
+
592
+ All updates are wrapped in **synchronized output** (`\x1b[?2026h` ... `\x1b[?2026l`) for atomic, flicker-free rendering.
593
+
594
+ ## Terminal Interface
595
+
596
+ The TUI works with any object implementing the `Terminal` interface:
597
+
598
+ ```typescript
599
+ interface Terminal {
600
+ start(onInput: (data: string) => void, onResize: () => void): void;
601
+ stop(): void;
602
+ write(data: string): void;
603
+ get columns(): number;
604
+ get rows(): number;
605
+ moveBy(lines: number): void;
606
+ hideCursor(): void;
607
+ showCursor(): void;
608
+ clearLine(): void;
609
+ clearFromCursor(): void;
610
+ clearScreen(): void;
611
+ }
612
+ ```
613
+
614
+ **Built-in implementations:**
615
+ - `ProcessTerminal` - Uses `process.stdin/stdout`
616
+ - `VirtualTerminal` - For testing (uses `@xterm/headless`)
617
+
618
+ ## Utilities
619
+
620
+ ```typescript
621
+ import { visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@ponythewhite/base-context-tui";
622
+
623
+ // Get visible width of string (ignoring ANSI codes)
624
+ const width = visibleWidth("\x1b[31mHello\x1b[0m"); // 5
625
+
626
+ // Truncate string to width (preserving ANSI codes, adds ellipsis)
627
+ const truncated = truncateToWidth("Hello World", 8); // "Hello..."
628
+
629
+ // Truncate without ellipsis
630
+ const truncatedNoEllipsis = truncateToWidth("Hello World", 8, ""); // "Hello Wo"
631
+
632
+ // Wrap text to width (preserving ANSI codes across line breaks)
633
+ const lines = wrapTextWithAnsi("This is a long line that needs wrapping", 20);
634
+ // ["This is a long line", "that needs wrapping"]
635
+ ```
636
+
637
+ ## Creating Custom Components
638
+
639
+ 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.
640
+
641
+ ### Handling Input
642
+
643
+ Use `matchesKey()` with the `Key` helper for keyboard input:
644
+
645
+ ```typescript
646
+ import { matchesKey, Key, truncateToWidth } from "@ponythewhite/base-context-tui";
647
+ import type { Component } from "@ponythewhite/base-context-tui";
648
+
649
+ class MyInteractiveComponent implements Component {
650
+ private selectedIndex = 0;
651
+ private items = ["Option 1", "Option 2", "Option 3"];
652
+
653
+ public onSelect?: (index: number) => void;
654
+ public onCancel?: () => void;
655
+
656
+ handleInput(data: string): void {
657
+ if (matchesKey(data, Key.up)) {
658
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
659
+ } else if (matchesKey(data, Key.down)) {
660
+ this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1);
661
+ } else if (matchesKey(data, Key.enter)) {
662
+ this.onSelect?.(this.selectedIndex);
663
+ } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
664
+ this.onCancel?.();
665
+ }
666
+ }
667
+
668
+ render(width: number): string[] {
669
+ return this.items.map((item, i) => {
670
+ const prefix = i === this.selectedIndex ? "> " : " ";
671
+ return truncateToWidth(prefix + item, width);
672
+ });
673
+ }
674
+ }
675
+ ```
676
+
677
+ ### Handling Line Width
678
+
679
+ Use the provided utilities to ensure lines fit:
680
+
681
+ ```typescript
682
+ import { visibleWidth, truncateToWidth } from "@ponythewhite/base-context-tui";
683
+ import type { Component } from "@ponythewhite/base-context-tui";
684
+
685
+ class MyComponent implements Component {
686
+ private text: string;
687
+
688
+ constructor(text: string) {
689
+ this.text = text;
690
+ }
691
+
692
+ render(width: number): string[] {
693
+ // Option 1: Truncate long lines
694
+ return [truncateToWidth(this.text, width)];
695
+
696
+ // Option 2: Check and pad to exact width
697
+ const line = this.text;
698
+ const visible = visibleWidth(line);
699
+ if (visible > width) {
700
+ return [truncateToWidth(line, width)];
701
+ }
702
+ // Pad to exact width (optional, for backgrounds)
703
+ return [line + " ".repeat(width - visible)];
704
+ }
705
+ }
706
+ ```
707
+
708
+ ### ANSI Code Considerations
709
+
710
+ Both `visibleWidth()` and `truncateToWidth()` correctly handle ANSI escape codes:
711
+
712
+ - `visibleWidth()` ignores ANSI codes when calculating width
713
+ - `truncateToWidth()` preserves ANSI codes and properly closes them when truncating
714
+
715
+ ```typescript
716
+ import chalk from "chalk";
717
+
718
+ const styled = chalk.red("Hello") + " " + chalk.blue("World");
719
+ const width = visibleWidth(styled); // 11 (not counting ANSI codes)
720
+ const truncated = truncateToWidth(styled, 8); // Red "Hello" + " W..." with proper reset
721
+ ```
722
+
723
+ ### Caching
724
+
725
+ For performance, components should cache their rendered output and only re-render when necessary:
726
+
727
+ ```typescript
728
+ class CachedComponent implements Component {
729
+ private text: string;
730
+ private cachedWidth?: number;
731
+ private cachedLines?: string[];
732
+
733
+ render(width: number): string[] {
734
+ if (this.cachedLines && this.cachedWidth === width) {
735
+ return this.cachedLines;
736
+ }
737
+
738
+ const lines = [truncateToWidth(this.text, width)];
739
+
740
+ this.cachedWidth = width;
741
+ this.cachedLines = lines;
742
+ return lines;
743
+ }
744
+
745
+ invalidate(): void {
746
+ this.cachedWidth = undefined;
747
+ this.cachedLines = undefined;
748
+ }
749
+ }
750
+ ```
751
+
752
+ ## Example
753
+
754
+ See `test/chat-simple.ts` for a complete chat interface example with:
755
+ - Markdown messages with custom background colors
756
+ - Loading spinner during responses
757
+ - Editor with autocomplete and slash commands
758
+ - Spacers between messages
759
+
760
+ Run it:
761
+ ```bash
762
+ npx tsx test/chat-simple.ts
763
+ ```
764
+
765
+ ## Development
766
+
767
+ ```bash
768
+ # Install dependencies (from monorepo root)
769
+ npm install
770
+
771
+ # Run type checking
772
+ npm run check
773
+
774
+ # Run the demo
775
+ npx tsx test/chat-simple.ts
776
+ ```
777
+
778
+ ### Debug logging
779
+
780
+ Set `PI_TUI_WRITE_LOG` to capture the raw ANSI stream written to stdout.
781
+
782
+ ```bash
783
+ PI_TUI_WRITE_LOG=/tmp/tui-ansi.log npx tsx test/chat-simple.ts
784
+ ```