@oh-my-pi/pi-tui 1.337.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 +654 -0
- package/package.json +45 -0
- package/src/autocomplete.ts +575 -0
- package/src/components/box.ts +134 -0
- package/src/components/cancellable-loader.ts +39 -0
- package/src/components/editor.ts +1342 -0
- package/src/components/image.ts +87 -0
- package/src/components/input.ts +344 -0
- package/src/components/loader.ts +55 -0
- package/src/components/markdown.ts +646 -0
- package/src/components/select-list.ts +184 -0
- package/src/components/settings-list.ts +188 -0
- package/src/components/spacer.ts +28 -0
- package/src/components/tab-bar.ts +140 -0
- package/src/components/text.ts +106 -0
- package/src/components/truncated-text.ts +65 -0
- package/src/index.ts +91 -0
- package/src/keys.ts +560 -0
- package/src/terminal-image.ts +340 -0
- package/src/terminal.ts +163 -0
- package/src/tui.ts +353 -0
- package/src/utils.ts +712 -0
package/src/tui.ts
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal TUI implementation with differential rendering
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as os from "node:os";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import { isShiftCtrlD } from "./keys.js";
|
|
9
|
+
import type { Terminal } from "./terminal.js";
|
|
10
|
+
import { getCapabilities, setCellDimensions } from "./terminal-image.js";
|
|
11
|
+
import { visibleWidth } from "./utils.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Component interface - all components must implement this
|
|
15
|
+
*/
|
|
16
|
+
export interface Component {
|
|
17
|
+
/**
|
|
18
|
+
* Render the component to lines for the given viewport width
|
|
19
|
+
* @param width - Current viewport width
|
|
20
|
+
* @returns Array of strings, each representing a line
|
|
21
|
+
*/
|
|
22
|
+
render(width: number): string[];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Optional handler for keyboard input when component has focus
|
|
26
|
+
*/
|
|
27
|
+
handleInput?(data: string): void;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Invalidate any cached rendering state.
|
|
31
|
+
* Called when theme changes or when component needs to re-render from scratch.
|
|
32
|
+
*/
|
|
33
|
+
invalidate(): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export { visibleWidth };
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Container - a component that contains other components
|
|
40
|
+
*/
|
|
41
|
+
export class Container implements Component {
|
|
42
|
+
children: Component[] = [];
|
|
43
|
+
|
|
44
|
+
addChild(component: Component): void {
|
|
45
|
+
this.children.push(component);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
removeChild(component: Component): void {
|
|
49
|
+
const index = this.children.indexOf(component);
|
|
50
|
+
if (index !== -1) {
|
|
51
|
+
this.children.splice(index, 1);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
clear(): void {
|
|
56
|
+
this.children = [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
invalidate(): void {
|
|
60
|
+
for (const child of this.children) {
|
|
61
|
+
child.invalidate?.();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
render(width: number): string[] {
|
|
66
|
+
const lines: string[] = [];
|
|
67
|
+
for (const child of this.children) {
|
|
68
|
+
lines.push(...child.render(width));
|
|
69
|
+
}
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* TUI - Main class for managing terminal UI with differential rendering
|
|
76
|
+
*/
|
|
77
|
+
export class TUI extends Container {
|
|
78
|
+
public terminal: Terminal;
|
|
79
|
+
private previousLines: string[] = [];
|
|
80
|
+
private previousWidth = 0;
|
|
81
|
+
private focusedComponent: Component | null = null;
|
|
82
|
+
|
|
83
|
+
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
|
|
84
|
+
public onDebug?: () => void;
|
|
85
|
+
private renderRequested = false;
|
|
86
|
+
private cursorRow = 0; // Track where cursor is (0-indexed, relative to our first line)
|
|
87
|
+
private inputBuffer = ""; // Buffer for parsing terminal responses
|
|
88
|
+
private cellSizeQueryPending = false;
|
|
89
|
+
|
|
90
|
+
constructor(terminal: Terminal) {
|
|
91
|
+
super();
|
|
92
|
+
this.terminal = terminal;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
setFocus(component: Component | null): void {
|
|
96
|
+
this.focusedComponent = component;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
start(): void {
|
|
100
|
+
this.terminal.start(
|
|
101
|
+
(data) => this.handleInput(data),
|
|
102
|
+
() => this.requestRender(),
|
|
103
|
+
);
|
|
104
|
+
this.terminal.hideCursor();
|
|
105
|
+
this.queryCellSize();
|
|
106
|
+
this.requestRender();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private queryCellSize(): void {
|
|
110
|
+
// Only query if terminal supports images (cell size is only used for image rendering)
|
|
111
|
+
if (!getCapabilities().images) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// Query terminal for cell size in pixels: CSI 16 t
|
|
115
|
+
// Response format: CSI 6 ; height ; width t
|
|
116
|
+
this.cellSizeQueryPending = true;
|
|
117
|
+
this.terminal.write("\x1b[16t");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
stop(): void {
|
|
121
|
+
this.terminal.showCursor();
|
|
122
|
+
this.terminal.stop();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
requestRender(force = false): void {
|
|
126
|
+
if (force) {
|
|
127
|
+
this.previousLines = [];
|
|
128
|
+
this.previousWidth = 0;
|
|
129
|
+
this.cursorRow = 0;
|
|
130
|
+
}
|
|
131
|
+
if (this.renderRequested) return;
|
|
132
|
+
this.renderRequested = true;
|
|
133
|
+
process.nextTick(() => {
|
|
134
|
+
this.renderRequested = false;
|
|
135
|
+
this.doRender();
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private handleInput(data: string): void {
|
|
140
|
+
// If we're waiting for cell size response, buffer input and parse
|
|
141
|
+
if (this.cellSizeQueryPending) {
|
|
142
|
+
this.inputBuffer += data;
|
|
143
|
+
const filtered = this.parseCellSizeResponse();
|
|
144
|
+
if (filtered.length === 0) return;
|
|
145
|
+
data = filtered;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Global debug key handler (Shift+Ctrl+D)
|
|
149
|
+
if (isShiftCtrlD(data) && this.onDebug) {
|
|
150
|
+
this.onDebug();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Pass input to focused component (including Ctrl+C)
|
|
155
|
+
// The focused component can decide how to handle Ctrl+C
|
|
156
|
+
if (this.focusedComponent?.handleInput) {
|
|
157
|
+
this.focusedComponent.handleInput(data);
|
|
158
|
+
this.requestRender();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private parseCellSizeResponse(): string {
|
|
163
|
+
// Response format: ESC [ 6 ; height ; width t
|
|
164
|
+
// Match the response pattern
|
|
165
|
+
const responsePattern = /\x1b\[6;(\d+);(\d+)t/;
|
|
166
|
+
const match = this.inputBuffer.match(responsePattern);
|
|
167
|
+
|
|
168
|
+
if (match) {
|
|
169
|
+
const heightPx = parseInt(match[1], 10);
|
|
170
|
+
const widthPx = parseInt(match[2], 10);
|
|
171
|
+
|
|
172
|
+
if (heightPx > 0 && widthPx > 0) {
|
|
173
|
+
setCellDimensions({ widthPx, heightPx });
|
|
174
|
+
// Invalidate all components so images re-render with correct dimensions
|
|
175
|
+
this.invalidate();
|
|
176
|
+
this.requestRender();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Remove the response from buffer
|
|
180
|
+
this.inputBuffer = this.inputBuffer.replace(responsePattern, "");
|
|
181
|
+
this.cellSizeQueryPending = false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Check if we have a partial cell size response starting (wait for more data)
|
|
185
|
+
// Patterns that could be incomplete cell size response: \x1b, \x1b[, \x1b[6, \x1b[6;...(no t yet)
|
|
186
|
+
const partialCellSizePattern = /\x1b(\[6?;?[\d;]*)?$/;
|
|
187
|
+
if (partialCellSizePattern.test(this.inputBuffer)) {
|
|
188
|
+
// Check if it's actually a complete different escape sequence (ends with a letter)
|
|
189
|
+
// Cell size response ends with 't', Kitty keyboard ends with 'u', arrows end with A-D, etc.
|
|
190
|
+
const lastChar = this.inputBuffer[this.inputBuffer.length - 1];
|
|
191
|
+
if (!/[a-zA-Z~]/.test(lastChar)) {
|
|
192
|
+
// Doesn't end with a terminator, might be incomplete - wait for more
|
|
193
|
+
return "";
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// No cell size response found, return buffered data as user input
|
|
198
|
+
const result = this.inputBuffer;
|
|
199
|
+
this.inputBuffer = "";
|
|
200
|
+
this.cellSizeQueryPending = false; // Give up waiting
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private containsImage(line: string): boolean {
|
|
205
|
+
return line.includes("\x1b_G") || line.includes("\x1b]1337;File=");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private doRender(): void {
|
|
209
|
+
const width = this.terminal.columns;
|
|
210
|
+
const height = this.terminal.rows;
|
|
211
|
+
|
|
212
|
+
// Render all components to get new lines
|
|
213
|
+
const newLines = this.render(width);
|
|
214
|
+
|
|
215
|
+
// Width changed - need full re-render
|
|
216
|
+
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
|
|
217
|
+
|
|
218
|
+
// First render - just output everything without clearing
|
|
219
|
+
if (this.previousLines.length === 0) {
|
|
220
|
+
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
|
221
|
+
for (let i = 0; i < newLines.length; i++) {
|
|
222
|
+
if (i > 0) buffer += "\r\n";
|
|
223
|
+
buffer += newLines[i];
|
|
224
|
+
}
|
|
225
|
+
buffer += "\x1b[?2026l"; // End synchronized output
|
|
226
|
+
this.terminal.write(buffer);
|
|
227
|
+
// After rendering N lines, cursor is at end of last line (line N-1)
|
|
228
|
+
this.cursorRow = newLines.length - 1;
|
|
229
|
+
this.previousLines = newLines;
|
|
230
|
+
this.previousWidth = width;
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Width changed - full re-render
|
|
235
|
+
if (widthChanged) {
|
|
236
|
+
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
|
237
|
+
buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home
|
|
238
|
+
for (let i = 0; i < newLines.length; i++) {
|
|
239
|
+
if (i > 0) buffer += "\r\n";
|
|
240
|
+
buffer += newLines[i];
|
|
241
|
+
}
|
|
242
|
+
buffer += "\x1b[?2026l"; // End synchronized output
|
|
243
|
+
this.terminal.write(buffer);
|
|
244
|
+
this.cursorRow = newLines.length - 1;
|
|
245
|
+
this.previousLines = newLines;
|
|
246
|
+
this.previousWidth = width;
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Find first and last changed lines
|
|
251
|
+
let firstChanged = -1;
|
|
252
|
+
const maxLines = Math.max(newLines.length, this.previousLines.length);
|
|
253
|
+
for (let i = 0; i < maxLines; i++) {
|
|
254
|
+
const oldLine = i < this.previousLines.length ? this.previousLines[i] : "";
|
|
255
|
+
const newLine = i < newLines.length ? newLines[i] : "";
|
|
256
|
+
|
|
257
|
+
if (oldLine !== newLine) {
|
|
258
|
+
if (firstChanged === -1) {
|
|
259
|
+
firstChanged = i;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// No changes
|
|
265
|
+
if (firstChanged === -1) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Check if firstChanged is outside the viewport
|
|
270
|
+
// cursorRow is the line where cursor is (0-indexed)
|
|
271
|
+
// Viewport shows lines from (cursorRow - height + 1) to cursorRow
|
|
272
|
+
// If firstChanged < viewportTop, we need full re-render
|
|
273
|
+
const viewportTop = this.cursorRow - height + 1;
|
|
274
|
+
if (firstChanged < viewportTop) {
|
|
275
|
+
// First change is above viewport - need full re-render
|
|
276
|
+
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
|
277
|
+
buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home
|
|
278
|
+
for (let i = 0; i < newLines.length; i++) {
|
|
279
|
+
if (i > 0) buffer += "\r\n";
|
|
280
|
+
buffer += newLines[i];
|
|
281
|
+
}
|
|
282
|
+
buffer += "\x1b[?2026l"; // End synchronized output
|
|
283
|
+
this.terminal.write(buffer);
|
|
284
|
+
this.cursorRow = newLines.length - 1;
|
|
285
|
+
this.previousLines = newLines;
|
|
286
|
+
this.previousWidth = width;
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Render from first changed line to end
|
|
291
|
+
// Build buffer with all updates wrapped in synchronized output
|
|
292
|
+
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
|
293
|
+
|
|
294
|
+
// Move cursor to first changed line
|
|
295
|
+
const lineDiff = firstChanged - this.cursorRow;
|
|
296
|
+
if (lineDiff > 0) {
|
|
297
|
+
buffer += `\x1b[${lineDiff}B`; // Move down
|
|
298
|
+
} else if (lineDiff < 0) {
|
|
299
|
+
buffer += `\x1b[${-lineDiff}A`; // Move up
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
buffer += "\r"; // Move to column 0
|
|
303
|
+
|
|
304
|
+
// Render from first changed line to end, clearing each line before writing
|
|
305
|
+
// This avoids the \x1b[J clear-to-end which can cause flicker in xterm.js
|
|
306
|
+
for (let i = firstChanged; i < newLines.length; i++) {
|
|
307
|
+
if (i > firstChanged) buffer += "\r\n";
|
|
308
|
+
buffer += "\x1b[2K"; // Clear current line
|
|
309
|
+
const line = newLines[i];
|
|
310
|
+
const isImageLine = this.containsImage(line);
|
|
311
|
+
if (!isImageLine && visibleWidth(line) > width) {
|
|
312
|
+
// Log all lines to crash file for debugging
|
|
313
|
+
const crashLogPath = path.join(os.homedir(), ".pi", "agent", "pi-crash.log");
|
|
314
|
+
const crashData = [
|
|
315
|
+
`Crash at ${new Date().toISOString()}`,
|
|
316
|
+
`Terminal width: ${width}`,
|
|
317
|
+
`Line ${i} visible width: ${visibleWidth(line)}`,
|
|
318
|
+
"",
|
|
319
|
+
"=== All rendered lines ===",
|
|
320
|
+
...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),
|
|
321
|
+
"",
|
|
322
|
+
].join("\n");
|
|
323
|
+
try {
|
|
324
|
+
fs.mkdirSync(path.dirname(crashLogPath), { recursive: true });
|
|
325
|
+
fs.writeFileSync(crashLogPath, crashData);
|
|
326
|
+
} catch {}
|
|
327
|
+
throw new Error(`Rendered line ${i} exceeds terminal width. Debug log written to ${crashLogPath}`);
|
|
328
|
+
}
|
|
329
|
+
buffer += line;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// If we had more lines before, clear them and move cursor back
|
|
333
|
+
if (this.previousLines.length > newLines.length) {
|
|
334
|
+
const extraLines = this.previousLines.length - newLines.length;
|
|
335
|
+
for (let i = newLines.length; i < this.previousLines.length; i++) {
|
|
336
|
+
buffer += "\r\n\x1b[2K";
|
|
337
|
+
}
|
|
338
|
+
// Move cursor back to end of new content
|
|
339
|
+
buffer += `\x1b[${extraLines}A`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
buffer += "\x1b[?2026l"; // End synchronized output
|
|
343
|
+
|
|
344
|
+
// Write entire buffer at once
|
|
345
|
+
this.terminal.write(buffer);
|
|
346
|
+
|
|
347
|
+
// Cursor is now at end of last line
|
|
348
|
+
this.cursorRow = newLines.length - 1;
|
|
349
|
+
|
|
350
|
+
this.previousLines = newLines;
|
|
351
|
+
this.previousWidth = width;
|
|
352
|
+
}
|
|
353
|
+
}
|