@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/src/tui.ts ADDED
@@ -0,0 +1,379 @@
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";
9
+ import type { Terminal } from "./terminal";
10
+ import { getCapabilities, setCellDimensions } from "./terminal-image";
11
+ import { visibleWidth } from "./utils";
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
+ private inputQueue: string[] = []; // Queue input during cell size query to avoid interleaving
90
+
91
+ constructor(terminal: Terminal) {
92
+ super();
93
+ this.terminal = terminal;
94
+ }
95
+
96
+ setFocus(component: Component | null): void {
97
+ this.focusedComponent = component;
98
+ }
99
+
100
+ start(): void {
101
+ this.terminal.start(
102
+ (data) => this.handleInput(data),
103
+ () => this.requestRender(),
104
+ );
105
+ this.terminal.hideCursor();
106
+ this.queryCellSize();
107
+ this.requestRender();
108
+ }
109
+
110
+ private queryCellSize(): void {
111
+ // Only query if terminal supports images (cell size is only used for image rendering)
112
+ if (!getCapabilities().images) {
113
+ return;
114
+ }
115
+ // Query terminal for cell size in pixels: CSI 16 t
116
+ // Response format: CSI 6 ; height ; width t
117
+ this.cellSizeQueryPending = true;
118
+ this.terminal.write("\x1b[16t");
119
+ }
120
+
121
+ stop(): void {
122
+ this.terminal.showCursor();
123
+ this.terminal.stop();
124
+ }
125
+
126
+ getWidth(): number {
127
+ return this.terminal.columns;
128
+ }
129
+
130
+ requestRender(force = false): void {
131
+ if (force) {
132
+ this.previousLines = [];
133
+ this.previousWidth = 0;
134
+ this.cursorRow = 0;
135
+ }
136
+ if (this.renderRequested) return;
137
+ this.renderRequested = true;
138
+ process.nextTick(() => {
139
+ this.renderRequested = false;
140
+ this.doRender();
141
+ });
142
+ }
143
+
144
+ private handleInput(data: string): void {
145
+ // If we're waiting for cell size response, buffer input and parse
146
+ if (this.cellSizeQueryPending) {
147
+ this.inputBuffer += data;
148
+ const filtered = this.parseCellSizeResponse();
149
+ if (filtered.length === 0) return;
150
+ if (filtered.length > 0) {
151
+ this.inputQueue.push(filtered);
152
+ }
153
+ // Process queued input after cell size response completes
154
+ if (!this.cellSizeQueryPending && this.inputQueue.length > 0) {
155
+ const queued = this.inputQueue;
156
+ this.inputQueue = [];
157
+ for (const item of queued) {
158
+ this.processInput(item);
159
+ }
160
+ }
161
+ return;
162
+ }
163
+
164
+ this.processInput(data);
165
+ }
166
+
167
+ private processInput(data: string): void {
168
+ // Global debug key handler (Shift+Ctrl+D)
169
+ if (isShiftCtrlD(data) && this.onDebug) {
170
+ this.onDebug();
171
+ return;
172
+ }
173
+
174
+ // Pass input to focused component (including Ctrl+C)
175
+ // The focused component can decide how to handle Ctrl+C
176
+ if (this.focusedComponent?.handleInput) {
177
+ this.focusedComponent.handleInput(data);
178
+ this.requestRender();
179
+ }
180
+ }
181
+
182
+ private parseCellSizeResponse(): string {
183
+ // Response format: ESC [ 6 ; height ; width t
184
+ // Match the response pattern
185
+ const responsePattern = /\x1b\[6;(\d+);(\d+)t/;
186
+ const match = this.inputBuffer.match(responsePattern);
187
+
188
+ if (match) {
189
+ const heightPx = parseInt(match[1], 10);
190
+ const widthPx = parseInt(match[2], 10);
191
+
192
+ // Remove the response from buffer first
193
+ this.inputBuffer = this.inputBuffer.replace(responsePattern, "");
194
+ this.cellSizeQueryPending = false;
195
+
196
+ if (heightPx > 0 && widthPx > 0) {
197
+ setCellDimensions({ widthPx, heightPx });
198
+ // Invalidate all components so images re-render with correct dimensions
199
+ // This is safe now because cellSizeQueryPending=false prevents race with render
200
+ this.invalidate();
201
+ this.requestRender();
202
+ }
203
+ }
204
+
205
+ // Check if we have a partial cell size response starting (wait for more data)
206
+ // Patterns that could be incomplete cell size response: \x1b, \x1b[, \x1b[6, \x1b[6;...(no t yet)
207
+ const partialCellSizePattern = /\x1b(\[6?;?[\d;]*)?$/;
208
+ if (partialCellSizePattern.test(this.inputBuffer)) {
209
+ // Check if it's actually a complete different escape sequence (ends with a letter)
210
+ // Cell size response ends with 't', Kitty keyboard ends with 'u', arrows end with A-D, etc.
211
+ const lastChar = this.inputBuffer[this.inputBuffer.length - 1];
212
+ if (!/[a-zA-Z~]/.test(lastChar)) {
213
+ // Doesn't end with a terminator, might be incomplete - wait for more
214
+ return "";
215
+ }
216
+ }
217
+
218
+ // No cell size response found, return buffered data as user input
219
+ const result = this.inputBuffer;
220
+ this.inputBuffer = "";
221
+ this.cellSizeQueryPending = false; // Give up waiting
222
+ return result;
223
+ }
224
+
225
+ private containsImage(line: string): boolean {
226
+ return line.includes("\x1b_G") || line.includes("\x1b]1337;File=");
227
+ }
228
+
229
+ private doRender(): void {
230
+ // Capture terminal dimensions at start to ensure consistency throughout render
231
+ const width = this.terminal.columns;
232
+ const height = this.terminal.rows;
233
+ // Snapshot cursor position at start of render for consistent viewport calculations
234
+ const currentCursorRow = this.cursorRow;
235
+
236
+ // Render all components to get new lines
237
+ const newLines = this.render(width);
238
+
239
+ // Width changed - need full re-render
240
+ const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
241
+
242
+ // First render - just output everything without clearing
243
+ if (this.previousLines.length === 0) {
244
+ let buffer = "\x1b[?2026h"; // Begin synchronized output
245
+ for (let i = 0; i < newLines.length; i++) {
246
+ if (i > 0) buffer += "\r\n";
247
+ buffer += newLines[i];
248
+ }
249
+ buffer += "\x1b[?2026l"; // End synchronized output
250
+ this.terminal.write(buffer);
251
+ // After rendering N lines, cursor is at end of last line (line N-1)
252
+ this.cursorRow = newLines.length - 1;
253
+ this.previousLines = newLines;
254
+ this.previousWidth = width;
255
+ return;
256
+ }
257
+
258
+ // Width changed - full re-render
259
+ if (widthChanged) {
260
+ let buffer = "\x1b[?2026h"; // Begin synchronized output
261
+ buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home
262
+ for (let i = 0; i < newLines.length; i++) {
263
+ if (i > 0) buffer += "\r\n";
264
+ buffer += newLines[i];
265
+ }
266
+ buffer += "\x1b[?2026l"; // End synchronized output
267
+ this.terminal.write(buffer);
268
+ this.cursorRow = newLines.length - 1;
269
+ this.previousLines = newLines;
270
+ this.previousWidth = width;
271
+ return;
272
+ }
273
+
274
+ // Find first and last changed lines
275
+ let firstChanged = -1;
276
+ const maxLines = Math.max(newLines.length, this.previousLines.length);
277
+ for (let i = 0; i < maxLines; i++) {
278
+ const oldLine = i < this.previousLines.length ? this.previousLines[i] : "";
279
+ const newLine = i < newLines.length ? newLines[i] : "";
280
+
281
+ if (oldLine !== newLine) {
282
+ if (firstChanged === -1) {
283
+ firstChanged = i;
284
+ }
285
+ }
286
+ }
287
+
288
+ // No changes
289
+ if (firstChanged === -1) {
290
+ return;
291
+ }
292
+
293
+ // Check if firstChanged is outside the viewport
294
+ // Use snapshotted cursor position for consistent viewport calculation
295
+ // Viewport shows lines from (currentCursorRow - height + 1) to currentCursorRow
296
+ // If firstChanged < viewportTop, we need full re-render
297
+ const viewportTop = currentCursorRow - height + 1;
298
+ if (firstChanged < viewportTop) {
299
+ // First change is above viewport - need full re-render
300
+ let buffer = "\x1b[?2026h"; // Begin synchronized output
301
+ buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home
302
+ for (let i = 0; i < newLines.length; i++) {
303
+ if (i > 0) buffer += "\r\n";
304
+ buffer += newLines[i];
305
+ }
306
+ buffer += "\x1b[?2026l"; // End synchronized output
307
+ this.terminal.write(buffer);
308
+ this.cursorRow = newLines.length - 1;
309
+ this.previousLines = newLines;
310
+ this.previousWidth = width;
311
+ return;
312
+ }
313
+
314
+ // Render from first changed line to end
315
+ // Build buffer with all updates wrapped in synchronized output
316
+ let buffer = "\x1b[?2026h"; // Begin synchronized output
317
+
318
+ // Move cursor to first changed line using snapshotted position
319
+ const lineDiff = firstChanged - currentCursorRow;
320
+ if (lineDiff > 0) {
321
+ buffer += `\x1b[${lineDiff}B`; // Move down
322
+ } else if (lineDiff < 0) {
323
+ buffer += `\x1b[${-lineDiff}A`; // Move up
324
+ }
325
+
326
+ buffer += "\r"; // Move to column 0
327
+
328
+ // Render from first changed line to end, clearing each line before writing
329
+ // This avoids the \x1b[J clear-to-end which can cause flicker in xterm.js
330
+ for (let i = firstChanged; i < newLines.length; i++) {
331
+ if (i > firstChanged) buffer += "\r\n";
332
+ buffer += "\x1b[2K"; // Clear current line
333
+ const line = newLines[i];
334
+ const isImageLine = this.containsImage(line);
335
+ if (!isImageLine && visibleWidth(line) > width) {
336
+ // Log all lines to crash file for debugging
337
+ const crashLogPath = path.join(os.homedir(), ".omp", "agent", "omp-crash.log");
338
+ const crashData = [
339
+ `Crash at ${new Date().toISOString()}`,
340
+ `Terminal width: ${width}`,
341
+ `Line ${i} visible width: ${visibleWidth(line)}`,
342
+ "",
343
+ "=== All rendered lines ===",
344
+ ...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),
345
+ "",
346
+ ].join("\n");
347
+ try {
348
+ fs.mkdirSync(path.dirname(crashLogPath), { recursive: true });
349
+ fs.writeFileSync(crashLogPath, crashData);
350
+ } catch {
351
+ // Ignore - crash log is best-effort
352
+ }
353
+ throw new Error(`Rendered line ${i} exceeds terminal width. Debug log written to ${crashLogPath}`);
354
+ }
355
+ buffer += line;
356
+ }
357
+
358
+ // If we had more lines before, clear them and move cursor back
359
+ if (this.previousLines.length > newLines.length) {
360
+ const extraLines = this.previousLines.length - newLines.length;
361
+ for (let i = newLines.length; i < this.previousLines.length; i++) {
362
+ buffer += "\r\n\x1b[2K";
363
+ }
364
+ // Move cursor back to end of new content
365
+ buffer += `\x1b[${extraLines}A`;
366
+ }
367
+
368
+ buffer += "\x1b[?2026l"; // End synchronized output
369
+
370
+ // Write entire buffer at once
371
+ this.terminal.write(buffer);
372
+
373
+ // Cursor is now at end of last line
374
+ this.cursorRow = newLines.length - 1;
375
+
376
+ this.previousLines = newLines;
377
+ this.previousWidth = width;
378
+ }
379
+ }