@mariozechner/pi-tui 0.30.1 → 0.31.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 +365 -50
- package/dist/components/cancellable-loader.d.ts +22 -0
- package/dist/components/cancellable-loader.d.ts.map +1 -0
- package/dist/components/cancellable-loader.js +34 -0
- package/dist/components/cancellable-loader.js.map +1 -0
- package/dist/components/markdown.d.ts.map +1 -1
- package/dist/components/markdown.js +10 -1
- package/dist/components/markdown.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/keys.d.ts +10 -0
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +14 -0
- package/dist/keys.js.map +1 -1
- package/dist/tui.d.ts +2 -0
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +8 -0
- package/dist/tui.js.map +1 -1
- package/dist/utils.d.ts +4 -4
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +112 -10
- package/dist/utils.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -8,7 +8,8 @@ Minimal terminal UI framework with differential rendering and synchronized outpu
|
|
|
8
8
|
- **Synchronized Output**: Uses CSI 2026 for atomic screen updates (no flicker)
|
|
9
9
|
- **Bracketed Paste Mode**: Handles large pastes correctly with markers for >10 line pastes
|
|
10
10
|
- **Component-based**: Simple Component interface with render() method
|
|
11
|
-
- **
|
|
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
|
|
12
13
|
- **Inline Images**: Renders images in terminals that support Kitty or iTerm2 graphics protocols
|
|
13
14
|
- **Autocomplete Support**: File paths and slash commands
|
|
14
15
|
|
|
@@ -26,10 +27,10 @@ const tui = new TUI(terminal);
|
|
|
26
27
|
// Add components
|
|
27
28
|
tui.addChild(new Text("Welcome to my app!"));
|
|
28
29
|
|
|
29
|
-
const editor = new Editor();
|
|
30
|
+
const editor = new Editor(editorTheme);
|
|
30
31
|
editor.onSubmit = (text) => {
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
console.log("Submitted:", text);
|
|
33
|
+
tui.addChild(new Text(`You said: ${text}`));
|
|
33
34
|
};
|
|
34
35
|
tui.addChild(editor);
|
|
35
36
|
|
|
@@ -50,6 +51,9 @@ tui.removeChild(component);
|
|
|
50
51
|
tui.start();
|
|
51
52
|
tui.stop();
|
|
52
53
|
tui.requestRender(); // Request a re-render
|
|
54
|
+
|
|
55
|
+
// Global debug key handler (Shift+Ctrl+D)
|
|
56
|
+
tui.onDebug = () => console.log("Debug triggered");
|
|
53
57
|
```
|
|
54
58
|
|
|
55
59
|
### Component Interface
|
|
@@ -58,11 +62,18 @@ All components implement:
|
|
|
58
62
|
|
|
59
63
|
```typescript
|
|
60
64
|
interface Component {
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
render(width: number): string[];
|
|
66
|
+
handleInput?(data: string): void;
|
|
67
|
+
invalidate?(): void;
|
|
63
68
|
}
|
|
64
69
|
```
|
|
65
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
|
+
|
|
66
77
|
## Built-in Components
|
|
67
78
|
|
|
68
79
|
### Container
|
|
@@ -81,11 +92,11 @@ Container that applies padding and background color to all children.
|
|
|
81
92
|
|
|
82
93
|
```typescript
|
|
83
94
|
const box = new Box(
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
95
|
+
1, // paddingX (default: 1)
|
|
96
|
+
1, // paddingY (default: 1)
|
|
97
|
+
(text) => chalk.bgGray(text) // optional background function
|
|
87
98
|
);
|
|
88
|
-
box.addChild(new Text("Content"
|
|
99
|
+
box.addChild(new Text("Content"));
|
|
89
100
|
box.setBgFn((text) => chalk.bgBlue(text)); // Change background dynamically
|
|
90
101
|
```
|
|
91
102
|
|
|
@@ -94,8 +105,26 @@ box.setBgFn((text) => chalk.bgBlue(text)); // Change background dynamically
|
|
|
94
105
|
Displays multi-line text with word wrapping and padding.
|
|
95
106
|
|
|
96
107
|
```typescript
|
|
97
|
-
const text = new Text(
|
|
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
|
+
);
|
|
98
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
|
+
);
|
|
99
128
|
```
|
|
100
129
|
|
|
101
130
|
### Input
|
|
@@ -106,14 +135,17 @@ Single-line text input with horizontal scrolling.
|
|
|
106
135
|
const input = new Input();
|
|
107
136
|
input.onSubmit = (value) => console.log(value);
|
|
108
137
|
input.setValue("initial");
|
|
138
|
+
input.getValue();
|
|
109
139
|
```
|
|
110
140
|
|
|
111
141
|
**Key Bindings:**
|
|
112
142
|
- `Enter` - Submit
|
|
113
143
|
- `Ctrl+A` / `Ctrl+E` - Line start/end
|
|
114
|
-
- `Ctrl+W` or `
|
|
144
|
+
- `Ctrl+W` or `Alt+Backspace` - Delete word backwards
|
|
115
145
|
- `Ctrl+U` - Delete to start of line
|
|
116
146
|
- `Ctrl+K` - Delete to end of line
|
|
147
|
+
- `Ctrl+Left` / `Ctrl+Right` - Word navigation
|
|
148
|
+
- `Alt+Left` / `Alt+Right` - Word navigation
|
|
117
149
|
- Arrow keys, Backspace, Delete work as expected
|
|
118
150
|
|
|
119
151
|
### Editor
|
|
@@ -121,11 +153,17 @@ input.setValue("initial");
|
|
|
121
153
|
Multi-line text editor with autocomplete, file completion, and paste handling.
|
|
122
154
|
|
|
123
155
|
```typescript
|
|
124
|
-
|
|
156
|
+
interface EditorTheme {
|
|
157
|
+
borderColor: (str: string) => string;
|
|
158
|
+
selectList: SelectListTheme;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const editor = new Editor(theme);
|
|
125
162
|
editor.onSubmit = (text) => console.log(text);
|
|
126
163
|
editor.onChange = (text) => console.log("Changed:", text);
|
|
127
164
|
editor.disableSubmit = true; // Disable submit temporarily
|
|
128
165
|
editor.setAutocompleteProvider(provider);
|
|
166
|
+
editor.borderColor = (s) => chalk.blue(s); // Change border dynamically
|
|
129
167
|
```
|
|
130
168
|
|
|
131
169
|
**Features:**
|
|
@@ -146,24 +184,50 @@ editor.setAutocompleteProvider(provider);
|
|
|
146
184
|
|
|
147
185
|
### Markdown
|
|
148
186
|
|
|
149
|
-
Renders markdown with syntax highlighting and
|
|
187
|
+
Renders markdown with syntax highlighting and theming support.
|
|
150
188
|
|
|
151
189
|
```typescript
|
|
190
|
+
interface MarkdownTheme {
|
|
191
|
+
heading: (text: string) => string;
|
|
192
|
+
link: (text: string) => string;
|
|
193
|
+
linkUrl: (text: string) => string;
|
|
194
|
+
code: (text: string) => string;
|
|
195
|
+
codeBlock: (text: string) => string;
|
|
196
|
+
codeBlockBorder: (text: string) => string;
|
|
197
|
+
quote: (text: string) => string;
|
|
198
|
+
quoteBorder: (text: string) => string;
|
|
199
|
+
hr: (text: string) => string;
|
|
200
|
+
listBullet: (text: string) => string;
|
|
201
|
+
bold: (text: string) => string;
|
|
202
|
+
italic: (text: string) => string;
|
|
203
|
+
strikethrough: (text: string) => string;
|
|
204
|
+
underline: (text: string) => string;
|
|
205
|
+
highlightCode?: (code: string, lang?: string) => string[];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
interface DefaultTextStyle {
|
|
209
|
+
color?: (text: string) => string;
|
|
210
|
+
bgColor?: (text: string) => string;
|
|
211
|
+
bold?: boolean;
|
|
212
|
+
italic?: boolean;
|
|
213
|
+
strikethrough?: boolean;
|
|
214
|
+
underline?: boolean;
|
|
215
|
+
}
|
|
216
|
+
|
|
152
217
|
const md = new Markdown(
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
paddingY // optional: default 1
|
|
218
|
+
"# Hello\n\nSome **bold** text",
|
|
219
|
+
1, // paddingX
|
|
220
|
+
1, // paddingY
|
|
221
|
+
theme, // MarkdownTheme
|
|
222
|
+
defaultStyle // optional DefaultTextStyle
|
|
159
223
|
);
|
|
160
224
|
md.setText("Updated markdown");
|
|
161
225
|
```
|
|
162
226
|
|
|
163
227
|
**Features:**
|
|
164
228
|
- Headings, bold, italic, code blocks, lists, links, blockquotes
|
|
165
|
-
-
|
|
166
|
-
- Optional
|
|
229
|
+
- HTML tags rendered as plain text
|
|
230
|
+
- Optional syntax highlighting via `highlightCode`
|
|
167
231
|
- Padding support
|
|
168
232
|
- Render caching for performance
|
|
169
233
|
|
|
@@ -172,29 +236,114 @@ md.setText("Updated markdown");
|
|
|
172
236
|
Animated loading spinner.
|
|
173
237
|
|
|
174
238
|
```typescript
|
|
175
|
-
const loader = new Loader(
|
|
239
|
+
const loader = new Loader(
|
|
240
|
+
tui, // TUI instance for render updates
|
|
241
|
+
(s) => chalk.cyan(s), // spinner color function
|
|
242
|
+
(s) => chalk.gray(s), // message color function
|
|
243
|
+
"Loading..." // message (default: "Loading...")
|
|
244
|
+
);
|
|
176
245
|
loader.start();
|
|
246
|
+
loader.setMessage("Still loading...");
|
|
177
247
|
loader.stop();
|
|
178
248
|
```
|
|
179
249
|
|
|
250
|
+
### CancellableLoader
|
|
251
|
+
|
|
252
|
+
Extends Loader with Escape key handling and an AbortSignal for cancelling async operations.
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
const loader = new CancellableLoader(
|
|
256
|
+
tui, // TUI instance for render updates
|
|
257
|
+
(s) => chalk.cyan(s), // spinner color function
|
|
258
|
+
(s) => chalk.gray(s), // message color function
|
|
259
|
+
"Working..." // message
|
|
260
|
+
);
|
|
261
|
+
loader.onAbort = () => done(null); // Called when user presses Escape
|
|
262
|
+
doAsyncWork(loader.signal).then(done);
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
**Properties:**
|
|
266
|
+
- `signal: AbortSignal` - Aborted when user presses Escape
|
|
267
|
+
- `aborted: boolean` - Whether the loader was aborted
|
|
268
|
+
- `onAbort?: () => void` - Callback when user presses Escape
|
|
269
|
+
|
|
180
270
|
### SelectList
|
|
181
271
|
|
|
182
272
|
Interactive selection list with keyboard navigation.
|
|
183
273
|
|
|
184
274
|
```typescript
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
275
|
+
interface SelectItem {
|
|
276
|
+
value: string;
|
|
277
|
+
label: string;
|
|
278
|
+
description?: string;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
interface SelectListTheme {
|
|
282
|
+
selectedPrefix: (text: string) => string;
|
|
283
|
+
selectedText: (text: string) => string;
|
|
284
|
+
description: (text: string) => string;
|
|
285
|
+
scrollInfo: (text: string) => string;
|
|
286
|
+
noMatch: (text: string) => string;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const list = new SelectList(
|
|
290
|
+
[
|
|
291
|
+
{ value: "opt1", label: "Option 1", description: "First option" },
|
|
292
|
+
{ value: "opt2", label: "Option 2", description: "Second option" },
|
|
293
|
+
],
|
|
294
|
+
5, // maxVisible
|
|
295
|
+
theme // SelectListTheme
|
|
296
|
+
);
|
|
189
297
|
|
|
190
298
|
list.onSelect = (item) => console.log("Selected:", item);
|
|
191
299
|
list.onCancel = () => console.log("Cancelled");
|
|
300
|
+
list.onSelectionChange = (item) => console.log("Highlighted:", item);
|
|
192
301
|
list.setFilter("opt"); // Filter items
|
|
193
302
|
```
|
|
194
303
|
|
|
195
304
|
**Controls:**
|
|
196
305
|
- Arrow keys: Navigate
|
|
197
|
-
- Enter
|
|
306
|
+
- Enter: Select
|
|
307
|
+
- Escape: Cancel
|
|
308
|
+
|
|
309
|
+
### SettingsList
|
|
310
|
+
|
|
311
|
+
Settings panel with value cycling and submenus.
|
|
312
|
+
|
|
313
|
+
```typescript
|
|
314
|
+
interface SettingItem {
|
|
315
|
+
id: string;
|
|
316
|
+
label: string;
|
|
317
|
+
description?: string;
|
|
318
|
+
currentValue: string;
|
|
319
|
+
values?: string[]; // If provided, Enter/Space cycles through these
|
|
320
|
+
submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
interface SettingsListTheme {
|
|
324
|
+
label: (text: string, selected: boolean) => string;
|
|
325
|
+
value: (text: string, selected: boolean) => string;
|
|
326
|
+
description: (text: string) => string;
|
|
327
|
+
cursor: string;
|
|
328
|
+
hint: (text: string) => string;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const settings = new SettingsList(
|
|
332
|
+
[
|
|
333
|
+
{ id: "theme", label: "Theme", currentValue: "dark", values: ["dark", "light"] },
|
|
334
|
+
{ id: "model", label: "Model", currentValue: "gpt-4", submenu: (val, done) => modelSelector },
|
|
335
|
+
],
|
|
336
|
+
10, // maxVisible
|
|
337
|
+
theme, // SettingsListTheme
|
|
338
|
+
(id, newValue) => console.log(`${id} changed to ${newValue}`),
|
|
339
|
+
() => console.log("Cancelled")
|
|
340
|
+
);
|
|
341
|
+
settings.updateValue("theme", "light");
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
**Controls:**
|
|
345
|
+
- Arrow keys: Navigate
|
|
346
|
+
- Enter/Space: Activate (cycle value or open submenu)
|
|
198
347
|
- Escape: Cancel
|
|
199
348
|
|
|
200
349
|
### Spacer
|
|
@@ -210,13 +359,21 @@ const spacer = new Spacer(2); // 2 empty lines (default: 1)
|
|
|
210
359
|
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.
|
|
211
360
|
|
|
212
361
|
```typescript
|
|
213
|
-
|
|
362
|
+
interface ImageTheme {
|
|
363
|
+
fallbackColor: (str: string) => string;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
interface ImageOptions {
|
|
367
|
+
maxWidthCells?: number;
|
|
368
|
+
maxHeightCells?: number;
|
|
369
|
+
filename?: string;
|
|
370
|
+
}
|
|
214
371
|
|
|
215
372
|
const image = new Image(
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
373
|
+
base64Data, // base64-encoded image data
|
|
374
|
+
"image/png", // MIME type
|
|
375
|
+
theme, // ImageTheme
|
|
376
|
+
options // optional ImageOptions
|
|
220
377
|
);
|
|
221
378
|
tui.addChild(image);
|
|
222
379
|
```
|
|
@@ -233,12 +390,12 @@ Supports both slash commands and file paths.
|
|
|
233
390
|
import { CombinedAutocompleteProvider } from "@mariozechner/pi-tui";
|
|
234
391
|
|
|
235
392
|
const provider = new CombinedAutocompleteProvider(
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
393
|
+
[
|
|
394
|
+
{ name: "help", description: "Show help" },
|
|
395
|
+
{ name: "clear", description: "Clear screen" },
|
|
396
|
+
{ name: "delete", description: "Delete last message" },
|
|
397
|
+
],
|
|
398
|
+
process.cwd() // base path for file completion
|
|
242
399
|
);
|
|
243
400
|
|
|
244
401
|
editor.setAutocompleteProvider(provider);
|
|
@@ -250,6 +407,27 @@ editor.setAutocompleteProvider(provider);
|
|
|
250
407
|
- Works with `~/`, `./`, `../`, and `@` prefix
|
|
251
408
|
- Filters to attachable files for `@` prefix
|
|
252
409
|
|
|
410
|
+
## Key Detection
|
|
411
|
+
|
|
412
|
+
Helper functions for detecting keyboard input (supports Kitty keyboard protocol):
|
|
413
|
+
|
|
414
|
+
```typescript
|
|
415
|
+
import {
|
|
416
|
+
isEnter, isEscape, isTab, isShiftTab,
|
|
417
|
+
isArrowUp, isArrowDown, isArrowLeft, isArrowRight,
|
|
418
|
+
isCtrlA, isCtrlC, isCtrlE, isCtrlK, isCtrlO, isCtrlP,
|
|
419
|
+
isCtrlLeft, isCtrlRight, isAltLeft, isAltRight,
|
|
420
|
+
isShiftEnter, isAltEnter,
|
|
421
|
+
isShiftCtrlO, isShiftCtrlD, isShiftCtrlP,
|
|
422
|
+
isBackspace, isDelete, isHome, isEnd,
|
|
423
|
+
// ... and more
|
|
424
|
+
} from "@mariozechner/pi-tui";
|
|
425
|
+
|
|
426
|
+
if (isCtrlC(data)) {
|
|
427
|
+
process.exit(0);
|
|
428
|
+
}
|
|
429
|
+
```
|
|
430
|
+
|
|
253
431
|
## Differential Rendering
|
|
254
432
|
|
|
255
433
|
The TUI uses three rendering strategies:
|
|
@@ -266,17 +444,17 @@ The TUI works with any object implementing the `Terminal` interface:
|
|
|
266
444
|
|
|
267
445
|
```typescript
|
|
268
446
|
interface Terminal {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
447
|
+
start(onInput: (data: string) => void, onResize: () => void): void;
|
|
448
|
+
stop(): void;
|
|
449
|
+
write(data: string): void;
|
|
450
|
+
get columns(): number;
|
|
451
|
+
get rows(): number;
|
|
452
|
+
moveBy(lines: number): void;
|
|
453
|
+
hideCursor(): void;
|
|
454
|
+
showCursor(): void;
|
|
455
|
+
clearLine(): void;
|
|
456
|
+
clearFromCursor(): void;
|
|
457
|
+
clearScreen(): void;
|
|
280
458
|
}
|
|
281
459
|
```
|
|
282
460
|
|
|
@@ -284,6 +462,143 @@ interface Terminal {
|
|
|
284
462
|
- `ProcessTerminal` - Uses `process.stdin/stdout`
|
|
285
463
|
- `VirtualTerminal` - For testing (uses `@xterm/headless`)
|
|
286
464
|
|
|
465
|
+
## Utilities
|
|
466
|
+
|
|
467
|
+
```typescript
|
|
468
|
+
import { visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
|
469
|
+
|
|
470
|
+
// Get visible width of string (ignoring ANSI codes)
|
|
471
|
+
const width = visibleWidth("\x1b[31mHello\x1b[0m"); // 5
|
|
472
|
+
|
|
473
|
+
// Truncate string to width (preserving ANSI codes, adds ellipsis)
|
|
474
|
+
const truncated = truncateToWidth("Hello World", 8); // "Hello..."
|
|
475
|
+
|
|
476
|
+
// Truncate without ellipsis
|
|
477
|
+
const truncatedNoEllipsis = truncateToWidth("Hello World", 8, ""); // "Hello Wo"
|
|
478
|
+
|
|
479
|
+
// Wrap text to width (preserving ANSI codes across line breaks)
|
|
480
|
+
const lines = wrapTextWithAnsi("This is a long line that needs wrapping", 20);
|
|
481
|
+
// ["This is a long line", "that needs wrapping"]
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
## Creating Custom Components
|
|
485
|
+
|
|
486
|
+
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.
|
|
487
|
+
|
|
488
|
+
### Handling Input
|
|
489
|
+
|
|
490
|
+
Use the key detection utilities to handle keyboard input:
|
|
491
|
+
|
|
492
|
+
```typescript
|
|
493
|
+
import {
|
|
494
|
+
isEnter, isEscape, isArrowUp, isArrowDown,
|
|
495
|
+
isCtrlC, isTab, isBackspace
|
|
496
|
+
} from "@mariozechner/pi-tui";
|
|
497
|
+
import type { Component } from "@mariozechner/pi-tui";
|
|
498
|
+
|
|
499
|
+
class MyInteractiveComponent implements Component {
|
|
500
|
+
private selectedIndex = 0;
|
|
501
|
+
private items = ["Option 1", "Option 2", "Option 3"];
|
|
502
|
+
|
|
503
|
+
public onSelect?: (index: number) => void;
|
|
504
|
+
public onCancel?: () => void;
|
|
505
|
+
|
|
506
|
+
handleInput(data: string): void {
|
|
507
|
+
if (isArrowUp(data)) {
|
|
508
|
+
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
509
|
+
} else if (isArrowDown(data)) {
|
|
510
|
+
this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1);
|
|
511
|
+
} else if (isEnter(data)) {
|
|
512
|
+
this.onSelect?.(this.selectedIndex);
|
|
513
|
+
} else if (isEscape(data) || isCtrlC(data)) {
|
|
514
|
+
this.onCancel?.();
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
render(width: number): string[] {
|
|
519
|
+
return this.items.map((item, i) => {
|
|
520
|
+
const prefix = i === this.selectedIndex ? "> " : " ";
|
|
521
|
+
return truncateToWidth(prefix + item, width);
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
### Handling Line Width
|
|
528
|
+
|
|
529
|
+
Use the provided utilities to ensure lines fit:
|
|
530
|
+
|
|
531
|
+
```typescript
|
|
532
|
+
import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
|
|
533
|
+
import type { Component } from "@mariozechner/pi-tui";
|
|
534
|
+
|
|
535
|
+
class MyComponent implements Component {
|
|
536
|
+
private text: string;
|
|
537
|
+
|
|
538
|
+
constructor(text: string) {
|
|
539
|
+
this.text = text;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
render(width: number): string[] {
|
|
543
|
+
// Option 1: Truncate long lines
|
|
544
|
+
return [truncateToWidth(this.text, width)];
|
|
545
|
+
|
|
546
|
+
// Option 2: Check and pad to exact width
|
|
547
|
+
const line = this.text;
|
|
548
|
+
const visible = visibleWidth(line);
|
|
549
|
+
if (visible > width) {
|
|
550
|
+
return [truncateToWidth(line, width)];
|
|
551
|
+
}
|
|
552
|
+
// Pad to exact width (optional, for backgrounds)
|
|
553
|
+
return [line + " ".repeat(width - visible)];
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
### ANSI Code Considerations
|
|
559
|
+
|
|
560
|
+
Both `visibleWidth()` and `truncateToWidth()` correctly handle ANSI escape codes:
|
|
561
|
+
|
|
562
|
+
- `visibleWidth()` ignores ANSI codes when calculating width
|
|
563
|
+
- `truncateToWidth()` preserves ANSI codes and properly closes them when truncating
|
|
564
|
+
|
|
565
|
+
```typescript
|
|
566
|
+
import chalk from "chalk";
|
|
567
|
+
|
|
568
|
+
const styled = chalk.red("Hello") + " " + chalk.blue("World");
|
|
569
|
+
const width = visibleWidth(styled); // 11 (not counting ANSI codes)
|
|
570
|
+
const truncated = truncateToWidth(styled, 8); // Red "Hello" + " W..." with proper reset
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
### Caching
|
|
574
|
+
|
|
575
|
+
For performance, components should cache their rendered output and only re-render when necessary:
|
|
576
|
+
|
|
577
|
+
```typescript
|
|
578
|
+
class CachedComponent implements Component {
|
|
579
|
+
private text: string;
|
|
580
|
+
private cachedWidth?: number;
|
|
581
|
+
private cachedLines?: string[];
|
|
582
|
+
|
|
583
|
+
render(width: number): string[] {
|
|
584
|
+
if (this.cachedLines && this.cachedWidth === width) {
|
|
585
|
+
return this.cachedLines;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const lines = [truncateToWidth(this.text, width)];
|
|
589
|
+
|
|
590
|
+
this.cachedWidth = width;
|
|
591
|
+
this.cachedLines = lines;
|
|
592
|
+
return lines;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
invalidate(): void {
|
|
596
|
+
this.cachedWidth = undefined;
|
|
597
|
+
this.cachedLines = undefined;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
```
|
|
601
|
+
|
|
287
602
|
## Example
|
|
288
603
|
|
|
289
604
|
See `test/chat-simple.ts` for a complete chat interface example with:
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Loader } from "./loader.js";
|
|
2
|
+
/**
|
|
3
|
+
* Loader that can be cancelled with Escape.
|
|
4
|
+
* Extends Loader with an AbortSignal for cancelling async operations.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* const loader = new CancellableLoader(tui, cyan, dim, "Working...");
|
|
8
|
+
* loader.onAbort = () => done(null);
|
|
9
|
+
* doWork(loader.signal).then(done);
|
|
10
|
+
*/
|
|
11
|
+
export declare class CancellableLoader extends Loader {
|
|
12
|
+
private abortController;
|
|
13
|
+
/** Called when user presses Escape */
|
|
14
|
+
onAbort?: () => void;
|
|
15
|
+
/** AbortSignal that is aborted when user presses Escape */
|
|
16
|
+
get signal(): AbortSignal;
|
|
17
|
+
/** Whether the loader was aborted */
|
|
18
|
+
get aborted(): boolean;
|
|
19
|
+
handleInput(data: string): void;
|
|
20
|
+
dispose(): void;
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=cancellable-loader.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cancellable-loader.d.ts","sourceRoot":"","sources":["../../src/components/cancellable-loader.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAErC;;;;;;;;GAQG;AACH,qBAAa,iBAAkB,SAAQ,MAAM;IAC5C,OAAO,CAAC,eAAe,CAAyB;IAEhD,sCAAsC;IACtC,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IAErB,2DAA2D;IAC3D,IAAI,MAAM,IAAI,WAAW,CAExB;IAED,qCAAqC;IACrC,IAAI,OAAO,IAAI,OAAO,CAErB;IAED,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAK9B;IAED,OAAO,IAAI,IAAI,CAEd;CACD","sourcesContent":["import { isEscape } from \"../keys.js\";\nimport { Loader } from \"./loader.js\";\n\n/**\n * Loader that can be cancelled with Escape.\n * Extends Loader with an AbortSignal for cancelling async operations.\n *\n * @example\n * const loader = new CancellableLoader(tui, cyan, dim, \"Working...\");\n * loader.onAbort = () => done(null);\n * doWork(loader.signal).then(done);\n */\nexport class CancellableLoader extends Loader {\n\tprivate abortController = new AbortController();\n\n\t/** Called when user presses Escape */\n\tonAbort?: () => void;\n\n\t/** AbortSignal that is aborted when user presses Escape */\n\tget signal(): AbortSignal {\n\t\treturn this.abortController.signal;\n\t}\n\n\t/** Whether the loader was aborted */\n\tget aborted(): boolean {\n\t\treturn this.abortController.signal.aborted;\n\t}\n\n\thandleInput(data: string): void {\n\t\tif (isEscape(data)) {\n\t\t\tthis.abortController.abort();\n\t\t\tthis.onAbort?.();\n\t\t}\n\t}\n\n\tdispose(): void {\n\t\tthis.stop();\n\t}\n}\n"]}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { isEscape } from "../keys.js";
|
|
2
|
+
import { Loader } from "./loader.js";
|
|
3
|
+
/**
|
|
4
|
+
* Loader that can be cancelled with Escape.
|
|
5
|
+
* Extends Loader with an AbortSignal for cancelling async operations.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const loader = new CancellableLoader(tui, cyan, dim, "Working...");
|
|
9
|
+
* loader.onAbort = () => done(null);
|
|
10
|
+
* doWork(loader.signal).then(done);
|
|
11
|
+
*/
|
|
12
|
+
export class CancellableLoader extends Loader {
|
|
13
|
+
abortController = new AbortController();
|
|
14
|
+
/** Called when user presses Escape */
|
|
15
|
+
onAbort;
|
|
16
|
+
/** AbortSignal that is aborted when user presses Escape */
|
|
17
|
+
get signal() {
|
|
18
|
+
return this.abortController.signal;
|
|
19
|
+
}
|
|
20
|
+
/** Whether the loader was aborted */
|
|
21
|
+
get aborted() {
|
|
22
|
+
return this.abortController.signal.aborted;
|
|
23
|
+
}
|
|
24
|
+
handleInput(data) {
|
|
25
|
+
if (isEscape(data)) {
|
|
26
|
+
this.abortController.abort();
|
|
27
|
+
this.onAbort?.();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
dispose() {
|
|
31
|
+
this.stop();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=cancellable-loader.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cancellable-loader.js","sourceRoot":"","sources":["../../src/components/cancellable-loader.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAErC;;;;;;;;GAQG;AACH,MAAM,OAAO,iBAAkB,SAAQ,MAAM;IACpC,eAAe,GAAG,IAAI,eAAe,EAAE,CAAC;IAEhD,sCAAsC;IACtC,OAAO,CAAc;IAErB,2DAA2D;IAC3D,IAAI,MAAM,GAAgB;QACzB,OAAO,IAAI,CAAC,eAAe,CAAC,MAAM,CAAC;IAAA,CACnC;IAED,qCAAqC;IACrC,IAAI,OAAO,GAAY;QACtB,OAAO,IAAI,CAAC,eAAe,CAAC,MAAM,CAAC,OAAO,CAAC;IAAA,CAC3C;IAED,WAAW,CAAC,IAAY,EAAQ;QAC/B,IAAI,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;YACpB,IAAI,CAAC,eAAe,CAAC,KAAK,EAAE,CAAC;YAC7B,IAAI,CAAC,OAAO,EAAE,EAAE,CAAC;QAClB,CAAC;IAAA,CACD;IAED,OAAO,GAAS;QACf,IAAI,CAAC,IAAI,EAAE,CAAC;IAAA,CACZ;CACD","sourcesContent":["import { isEscape } from \"../keys.js\";\nimport { Loader } from \"./loader.js\";\n\n/**\n * Loader that can be cancelled with Escape.\n * Extends Loader with an AbortSignal for cancelling async operations.\n *\n * @example\n * const loader = new CancellableLoader(tui, cyan, dim, \"Working...\");\n * loader.onAbort = () => done(null);\n * doWork(loader.signal).then(done);\n */\nexport class CancellableLoader extends Loader {\n\tprivate abortController = new AbortController();\n\n\t/** Called when user presses Escape */\n\tonAbort?: () => void;\n\n\t/** AbortSignal that is aborted when user presses Escape */\n\tget signal(): AbortSignal {\n\t\treturn this.abortController.signal;\n\t}\n\n\t/** Whether the loader was aborted */\n\tget aborted(): boolean {\n\t\treturn this.abortController.signal.aborted;\n\t}\n\n\thandleInput(data: string): void {\n\t\tif (isEscape(data)) {\n\t\t\tthis.abortController.abort();\n\t\t\tthis.onAbort?.();\n\t\t}\n\t}\n\n\tdispose(): void {\n\t\tthis.stop();\n\t}\n}\n"]}
|