@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/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
+ }