phi-code-tui 0.56.3 → 0.74.1
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 +29 -11
- package/dist/autocomplete.d.ts +18 -14
- package/dist/autocomplete.d.ts.map +1 -1
- package/dist/autocomplete.js +151 -112
- package/dist/autocomplete.js.map +1 -1
- package/dist/components/box.d.ts.map +1 -1
- package/dist/components/box.js +6 -1
- package/dist/components/box.js.map +1 -1
- package/dist/components/cancellable-loader.d.ts.map +1 -1
- package/dist/components/cancellable-loader.js +6 -7
- package/dist/components/cancellable-loader.js.map +1 -1
- package/dist/components/editor.d.ts +45 -1
- package/dist/components/editor.d.ts.map +1 -1
- package/dist/components/editor.js +505 -221
- package/dist/components/editor.js.map +1 -1
- package/dist/components/image.d.ts.map +1 -1
- package/dist/components/image.js +22 -7
- package/dist/components/image.js.map +1 -1
- package/dist/components/input.d.ts.map +1 -1
- package/dist/components/input.js +57 -74
- package/dist/components/input.js.map +1 -1
- package/dist/components/loader.d.ts +12 -2
- package/dist/components/loader.d.ts.map +1 -1
- package/dist/components/loader.js +36 -13
- package/dist/components/loader.js.map +1 -1
- package/dist/components/markdown.d.ts +0 -5
- package/dist/components/markdown.d.ts.map +1 -1
- package/dist/components/markdown.js +101 -114
- package/dist/components/markdown.js.map +1 -1
- package/dist/components/select-list.d.ts +19 -1
- package/dist/components/select-list.d.ts.map +1 -1
- package/dist/components/select-list.js +82 -71
- package/dist/components/select-list.js.map +1 -1
- package/dist/components/settings-list.d.ts.map +1 -1
- package/dist/components/settings-list.js +18 -10
- package/dist/components/settings-list.js.map +1 -1
- package/dist/components/spacer.d.ts.map +1 -1
- package/dist/components/spacer.js +1 -0
- package/dist/components/spacer.js.map +1 -1
- package/dist/components/text.d.ts.map +1 -1
- package/dist/components/text.js +8 -0
- package/dist/components/text.js.map +1 -1
- package/dist/components/truncated-text.d.ts.map +1 -1
- package/dist/components/truncated-text.js +3 -0
- package/dist/components/truncated-text.js.map +1 -1
- package/dist/editor-component.d.ts.map +1 -1
- package/dist/fuzzy.d.ts.map +1 -1
- package/dist/fuzzy.js +3 -0
- package/dist/fuzzy.js.map +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/keybindings.d.ts +187 -33
- package/dist/keybindings.d.ts.map +1 -1
- package/dist/keybindings.js +156 -95
- package/dist/keybindings.js.map +1 -1
- package/dist/keys.d.ts +21 -12
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +270 -112
- package/dist/keys.js.map +1 -1
- package/dist/kill-ring.d.ts.map +1 -1
- package/dist/kill-ring.js +1 -3
- package/dist/kill-ring.js.map +1 -1
- package/dist/stdin-buffer.d.ts +2 -0
- package/dist/stdin-buffer.d.ts.map +1 -1
- package/dist/stdin-buffer.js +31 -8
- package/dist/stdin-buffer.js.map +1 -1
- package/dist/terminal-image.d.ts +17 -0
- package/dist/terminal-image.d.ts.map +1 -1
- package/dist/terminal-image.js +41 -5
- package/dist/terminal-image.js.map +1 -1
- package/dist/terminal.d.ts +4 -0
- package/dist/terminal.d.ts.map +1 -1
- package/dist/terminal.js +56 -8
- package/dist/terminal.js.map +1 -1
- package/dist/tui.d.ts +21 -5
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +234 -118
- package/dist/tui.js.map +1 -1
- package/dist/undo-stack.d.ts.map +1 -1
- package/dist/undo-stack.js +1 -3
- package/dist/undo-stack.js.map +1 -1
- package/dist/utils.d.ts +1 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +281 -81
- package/dist/utils.js.map +1 -1
- package/package.json +3 -3
package/dist/tui.js
CHANGED
|
@@ -4,9 +4,31 @@
|
|
|
4
4
|
import * as fs from "node:fs";
|
|
5
5
|
import * as os from "node:os";
|
|
6
6
|
import * as path from "node:path";
|
|
7
|
+
import { performance } from "node:perf_hooks";
|
|
7
8
|
import { isKeyRelease, matchesKey } from "./keys.js";
|
|
8
|
-
import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
|
|
9
|
-
import { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
|
|
9
|
+
import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
|
|
10
|
+
import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
|
|
11
|
+
const KITTY_SEQUENCE_PREFIX = "\x1b_G";
|
|
12
|
+
function extractKittyImageIds(line) {
|
|
13
|
+
const sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX);
|
|
14
|
+
if (sequenceStart === -1)
|
|
15
|
+
return [];
|
|
16
|
+
const paramsStart = sequenceStart + KITTY_SEQUENCE_PREFIX.length;
|
|
17
|
+
const paramsEnd = line.indexOf(";", paramsStart);
|
|
18
|
+
if (paramsEnd === -1)
|
|
19
|
+
return [];
|
|
20
|
+
const params = line.slice(paramsStart, paramsEnd);
|
|
21
|
+
for (const param of params.split(",")) {
|
|
22
|
+
const [key, value] = param.split("=", 2);
|
|
23
|
+
if (key !== "i" || value === undefined)
|
|
24
|
+
continue;
|
|
25
|
+
const id = Number(value);
|
|
26
|
+
if (Number.isInteger(id) && id > 0 && id <= 0xffffffff) {
|
|
27
|
+
return [id];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
10
32
|
/** Type guard to check if a component implements Focusable */
|
|
11
33
|
export function isFocusable(component) {
|
|
12
34
|
return component !== null && "focused" in component;
|
|
@@ -32,13 +54,14 @@ function parseSizeValue(value, referenceSize) {
|
|
|
32
54
|
}
|
|
33
55
|
return undefined;
|
|
34
56
|
}
|
|
57
|
+
function isTermuxSession() {
|
|
58
|
+
return Boolean(process.env.TERMUX_VERSION);
|
|
59
|
+
}
|
|
35
60
|
/**
|
|
36
61
|
* Container - a component that contains other components
|
|
37
62
|
*/
|
|
38
63
|
export class Container {
|
|
39
|
-
|
|
40
|
-
this.children = [];
|
|
41
|
-
}
|
|
64
|
+
children = [];
|
|
42
65
|
addChild(component) {
|
|
43
66
|
this.children.push(component);
|
|
44
67
|
}
|
|
@@ -59,7 +82,10 @@ export class Container {
|
|
|
59
82
|
render(width) {
|
|
60
83
|
const lines = [];
|
|
61
84
|
for (const child of this.children) {
|
|
62
|
-
|
|
85
|
+
const childLines = child.render(width);
|
|
86
|
+
for (const line of childLines) {
|
|
87
|
+
lines.push(line);
|
|
88
|
+
}
|
|
63
89
|
}
|
|
64
90
|
return lines;
|
|
65
91
|
}
|
|
@@ -68,26 +94,32 @@ export class Container {
|
|
|
68
94
|
* TUI - Main class for managing terminal UI with differential rendering
|
|
69
95
|
*/
|
|
70
96
|
export class TUI extends Container {
|
|
97
|
+
terminal;
|
|
98
|
+
previousLines = [];
|
|
99
|
+
previousKittyImageIds = new Set();
|
|
100
|
+
previousWidth = 0;
|
|
101
|
+
previousHeight = 0;
|
|
102
|
+
focusedComponent = null;
|
|
103
|
+
inputListeners = new Set();
|
|
104
|
+
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
|
|
105
|
+
onDebug;
|
|
106
|
+
renderRequested = false;
|
|
107
|
+
renderTimer;
|
|
108
|
+
lastRenderAt = 0;
|
|
109
|
+
static MIN_RENDER_INTERVAL_MS = 16;
|
|
110
|
+
cursorRow = 0; // Logical cursor row (end of rendered content)
|
|
111
|
+
hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
|
|
112
|
+
showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";
|
|
113
|
+
clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off)
|
|
114
|
+
maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
|
|
115
|
+
previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
|
|
116
|
+
fullRedrawCount = 0;
|
|
117
|
+
stopped = false;
|
|
118
|
+
// Overlay stack for modal components rendered on top of base content
|
|
119
|
+
focusOrderCounter = 0;
|
|
120
|
+
overlayStack = [];
|
|
71
121
|
constructor(terminal, showHardwareCursor) {
|
|
72
122
|
super();
|
|
73
|
-
this.previousLines = [];
|
|
74
|
-
this.previousWidth = 0;
|
|
75
|
-
this.previousHeight = 0;
|
|
76
|
-
this.focusedComponent = null;
|
|
77
|
-
this.inputListeners = new Set();
|
|
78
|
-
this.renderRequested = false;
|
|
79
|
-
this.cursorRow = 0; // Logical cursor row (end of rendered content)
|
|
80
|
-
this.hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
|
|
81
|
-
this.inputBuffer = ""; // Buffer for parsing terminal responses
|
|
82
|
-
this.cellSizeQueryPending = false;
|
|
83
|
-
this.showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";
|
|
84
|
-
this.clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off)
|
|
85
|
-
this.maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
|
|
86
|
-
this.previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
|
|
87
|
-
this.fullRedrawCount = 0;
|
|
88
|
-
this.stopped = false;
|
|
89
|
-
// Overlay stack for modal components rendered on top of base content
|
|
90
|
-
this.overlayStack = [];
|
|
91
123
|
this.terminal = terminal;
|
|
92
124
|
if (showHardwareCursor !== undefined) {
|
|
93
125
|
this.showHardwareCursor = showHardwareCursor;
|
|
@@ -135,10 +167,16 @@ export class TUI extends Container {
|
|
|
135
167
|
* Returns a handle to control the overlay's visibility.
|
|
136
168
|
*/
|
|
137
169
|
showOverlay(component, options) {
|
|
138
|
-
const entry = {
|
|
170
|
+
const entry = {
|
|
171
|
+
component,
|
|
172
|
+
options,
|
|
173
|
+
preFocus: this.focusedComponent,
|
|
174
|
+
hidden: false,
|
|
175
|
+
focusOrder: ++this.focusOrderCounter,
|
|
176
|
+
};
|
|
139
177
|
this.overlayStack.push(entry);
|
|
140
178
|
// Only focus if overlay is actually visible
|
|
141
|
-
if (this.isOverlayVisible(entry)) {
|
|
179
|
+
if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
|
|
142
180
|
this.setFocus(component);
|
|
143
181
|
}
|
|
144
182
|
this.terminal.hideCursor();
|
|
@@ -173,13 +211,31 @@ export class TUI extends Container {
|
|
|
173
211
|
}
|
|
174
212
|
else {
|
|
175
213
|
// Restore focus to this overlay when showing (if it's actually visible)
|
|
176
|
-
if (this.isOverlayVisible(entry)) {
|
|
214
|
+
if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
|
|
215
|
+
entry.focusOrder = ++this.focusOrderCounter;
|
|
177
216
|
this.setFocus(component);
|
|
178
217
|
}
|
|
179
218
|
}
|
|
180
219
|
this.requestRender();
|
|
181
220
|
},
|
|
182
221
|
isHidden: () => entry.hidden,
|
|
222
|
+
focus: () => {
|
|
223
|
+
if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry))
|
|
224
|
+
return;
|
|
225
|
+
if (this.focusedComponent !== component) {
|
|
226
|
+
this.setFocus(component);
|
|
227
|
+
}
|
|
228
|
+
entry.focusOrder = ++this.focusOrderCounter;
|
|
229
|
+
this.requestRender();
|
|
230
|
+
},
|
|
231
|
+
unfocus: () => {
|
|
232
|
+
if (this.focusedComponent !== component)
|
|
233
|
+
return;
|
|
234
|
+
const topVisible = this.getTopmostVisibleOverlay();
|
|
235
|
+
this.setFocus(topVisible && topVisible !== entry ? topVisible.component : entry.preFocus);
|
|
236
|
+
this.requestRender();
|
|
237
|
+
},
|
|
238
|
+
isFocused: () => this.focusedComponent === component,
|
|
183
239
|
};
|
|
184
240
|
}
|
|
185
241
|
/** Hide the topmost overlay and restore previous focus. */
|
|
@@ -187,9 +243,11 @@ export class TUI extends Container {
|
|
|
187
243
|
const overlay = this.overlayStack.pop();
|
|
188
244
|
if (!overlay)
|
|
189
245
|
return;
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
246
|
+
if (this.focusedComponent === overlay.component) {
|
|
247
|
+
// Find topmost visible overlay, or fall back to preFocus
|
|
248
|
+
const topVisible = this.getTopmostVisibleOverlay();
|
|
249
|
+
this.setFocus(topVisible?.component ?? overlay.preFocus);
|
|
250
|
+
}
|
|
193
251
|
if (this.overlayStack.length === 0)
|
|
194
252
|
this.terminal.hideCursor();
|
|
195
253
|
this.requestRender();
|
|
@@ -207,9 +265,11 @@ export class TUI extends Container {
|
|
|
207
265
|
}
|
|
208
266
|
return true;
|
|
209
267
|
}
|
|
210
|
-
/** Find the topmost visible overlay, if any */
|
|
268
|
+
/** Find the topmost visible capturing overlay, if any */
|
|
211
269
|
getTopmostVisibleOverlay() {
|
|
212
270
|
for (let i = this.overlayStack.length - 1; i >= 0; i--) {
|
|
271
|
+
if (this.overlayStack[i].options?.nonCapturing)
|
|
272
|
+
continue;
|
|
213
273
|
if (this.isOverlayVisible(this.overlayStack[i])) {
|
|
214
274
|
return this.overlayStack[i];
|
|
215
275
|
}
|
|
@@ -244,11 +304,14 @@ export class TUI extends Container {
|
|
|
244
304
|
}
|
|
245
305
|
// Query terminal for cell size in pixels: CSI 16 t
|
|
246
306
|
// Response format: CSI 6 ; height ; width t
|
|
247
|
-
this.cellSizeQueryPending = true;
|
|
248
307
|
this.terminal.write("\x1b[16t");
|
|
249
308
|
}
|
|
250
309
|
stop() {
|
|
251
310
|
this.stopped = true;
|
|
311
|
+
if (this.renderTimer) {
|
|
312
|
+
clearTimeout(this.renderTimer);
|
|
313
|
+
this.renderTimer = undefined;
|
|
314
|
+
}
|
|
252
315
|
// Move cursor to the end of the content to prevent overwriting/artifacts on exit
|
|
253
316
|
if (this.previousLines.length > 0) {
|
|
254
317
|
const targetRow = this.previousLines.length; // Line after the last content
|
|
@@ -273,14 +336,44 @@ export class TUI extends Container {
|
|
|
273
336
|
this.hardwareCursorRow = 0;
|
|
274
337
|
this.maxLinesRendered = 0;
|
|
275
338
|
this.previousViewportTop = 0;
|
|
339
|
+
if (this.renderTimer) {
|
|
340
|
+
clearTimeout(this.renderTimer);
|
|
341
|
+
this.renderTimer = undefined;
|
|
342
|
+
}
|
|
343
|
+
this.renderRequested = true;
|
|
344
|
+
process.nextTick(() => {
|
|
345
|
+
if (this.stopped || !this.renderRequested) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
this.renderRequested = false;
|
|
349
|
+
this.lastRenderAt = performance.now();
|
|
350
|
+
this.doRender();
|
|
351
|
+
});
|
|
352
|
+
return;
|
|
276
353
|
}
|
|
277
354
|
if (this.renderRequested)
|
|
278
355
|
return;
|
|
279
356
|
this.renderRequested = true;
|
|
280
|
-
process.nextTick(() =>
|
|
357
|
+
process.nextTick(() => this.scheduleRender());
|
|
358
|
+
}
|
|
359
|
+
scheduleRender() {
|
|
360
|
+
if (this.stopped || this.renderTimer || !this.renderRequested) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const elapsed = performance.now() - this.lastRenderAt;
|
|
364
|
+
const delay = Math.max(0, TUI.MIN_RENDER_INTERVAL_MS - elapsed);
|
|
365
|
+
this.renderTimer = setTimeout(() => {
|
|
366
|
+
this.renderTimer = undefined;
|
|
367
|
+
if (this.stopped || !this.renderRequested) {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
281
370
|
this.renderRequested = false;
|
|
371
|
+
this.lastRenderAt = performance.now();
|
|
282
372
|
this.doRender();
|
|
283
|
-
|
|
373
|
+
if (this.renderRequested) {
|
|
374
|
+
this.scheduleRender();
|
|
375
|
+
}
|
|
376
|
+
}, delay);
|
|
284
377
|
}
|
|
285
378
|
handleInput(data) {
|
|
286
379
|
if (this.inputListeners.size > 0) {
|
|
@@ -299,13 +392,9 @@ export class TUI extends Container {
|
|
|
299
392
|
}
|
|
300
393
|
data = current;
|
|
301
394
|
}
|
|
302
|
-
//
|
|
303
|
-
if (this.
|
|
304
|
-
|
|
305
|
-
const filtered = this.parseCellSizeResponse();
|
|
306
|
-
if (filtered.length === 0)
|
|
307
|
-
return;
|
|
308
|
-
data = filtered;
|
|
395
|
+
// Consume terminal cell size responses without blocking unrelated input.
|
|
396
|
+
if (this.consumeCellSizeResponse(data)) {
|
|
397
|
+
return;
|
|
309
398
|
}
|
|
310
399
|
// Global debug key handler (Shift+Ctrl+D)
|
|
311
400
|
if (matchesKey(data, "shift+ctrl+d") && this.onDebug) {
|
|
@@ -337,41 +426,22 @@ export class TUI extends Container {
|
|
|
337
426
|
this.requestRender();
|
|
338
427
|
}
|
|
339
428
|
}
|
|
340
|
-
|
|
429
|
+
consumeCellSizeResponse(data) {
|
|
341
430
|
// Response format: ESC [ 6 ; height ; width t
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
this.cellSizeQueryPending = false;
|
|
357
|
-
}
|
|
358
|
-
// Check if we have a partial cell size response starting (wait for more data)
|
|
359
|
-
// Patterns that could be incomplete cell size response: \x1b, \x1b[, \x1b[6, \x1b[6;...(no t yet)
|
|
360
|
-
const partialCellSizePattern = /\x1b(\[6?;?[\d;]*)?$/;
|
|
361
|
-
if (partialCellSizePattern.test(this.inputBuffer)) {
|
|
362
|
-
// Check if it's actually a complete different escape sequence (ends with a letter)
|
|
363
|
-
// Cell size response ends with 't', Kitty keyboard ends with 'u', arrows end with A-D, etc.
|
|
364
|
-
const lastChar = this.inputBuffer[this.inputBuffer.length - 1];
|
|
365
|
-
if (!/[a-zA-Z~]/.test(lastChar)) {
|
|
366
|
-
// Doesn't end with a terminator, might be incomplete - wait for more
|
|
367
|
-
return "";
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
// No cell size response found, return buffered data as user input
|
|
371
|
-
const result = this.inputBuffer;
|
|
372
|
-
this.inputBuffer = "";
|
|
373
|
-
this.cellSizeQueryPending = false; // Give up waiting
|
|
374
|
-
return result;
|
|
431
|
+
const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
|
|
432
|
+
if (!match) {
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
const heightPx = parseInt(match[1], 10);
|
|
436
|
+
const widthPx = parseInt(match[2], 10);
|
|
437
|
+
if (heightPx <= 0 || widthPx <= 0) {
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
setCellDimensions({ widthPx, heightPx });
|
|
441
|
+
// Invalidate all components so images re-render with correct dimensions.
|
|
442
|
+
this.invalidate();
|
|
443
|
+
this.requestRender();
|
|
444
|
+
return true;
|
|
375
445
|
}
|
|
376
446
|
/**
|
|
377
447
|
* Resolve overlay layout from options.
|
|
@@ -499,7 +569,7 @@ export class TUI extends Container {
|
|
|
499
569
|
return marginLeft + Math.floor((availWidth - width) / 2);
|
|
500
570
|
}
|
|
501
571
|
}
|
|
502
|
-
/** Composite all overlays into content lines (
|
|
572
|
+
/** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */
|
|
503
573
|
compositeOverlays(lines, termWidth, termHeight) {
|
|
504
574
|
if (this.overlayStack.length === 0)
|
|
505
575
|
return lines;
|
|
@@ -507,10 +577,9 @@ export class TUI extends Container {
|
|
|
507
577
|
// Pre-render all visible overlays and calculate positions
|
|
508
578
|
const rendered = [];
|
|
509
579
|
let minLinesNeeded = result.length;
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
continue;
|
|
580
|
+
const visibleEntries = this.overlayStack.filter((e) => this.isOverlayVisible(e));
|
|
581
|
+
visibleEntries.sort((a, b) => a.focusOrder - b.focusOrder);
|
|
582
|
+
for (const entry of visibleEntries) {
|
|
514
583
|
const { component, options } = entry;
|
|
515
584
|
// Get layout with height=0 first to determine width and maxHeight
|
|
516
585
|
// (width and maxHeight don't depend on overlay height)
|
|
@@ -526,16 +595,15 @@ export class TUI extends Container {
|
|
|
526
595
|
rendered.push({ overlayLines, row, col, w: width });
|
|
527
596
|
minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
|
|
528
597
|
}
|
|
529
|
-
//
|
|
530
|
-
// maxLinesRendered
|
|
531
|
-
|
|
598
|
+
// Pad to at least terminal height so overlays have screen-relative positions.
|
|
599
|
+
// Excludes maxLinesRendered: the historical high-water mark caused self-reinforcing
|
|
600
|
+
// inflation that pushed content into scrollback on terminal widen.
|
|
601
|
+
const workingHeight = Math.max(result.length, termHeight, minLinesNeeded);
|
|
532
602
|
// Extend result with empty lines if content is too short for overlay placement or working area
|
|
533
603
|
while (result.length < workingHeight) {
|
|
534
604
|
result.push("");
|
|
535
605
|
}
|
|
536
606
|
const viewportStart = Math.max(0, workingHeight - termHeight);
|
|
537
|
-
// Track which lines were modified for final verification
|
|
538
|
-
const modifiedLines = new Set();
|
|
539
607
|
// Composite each overlay
|
|
540
608
|
for (const { overlayLines, row, col, w } of rendered) {
|
|
541
609
|
for (let i = 0; i < overlayLines.length; i++) {
|
|
@@ -545,33 +613,59 @@ export class TUI extends Container {
|
|
|
545
613
|
// (components should already respect width, but this ensures it)
|
|
546
614
|
const truncatedOverlayLine = visibleWidth(overlayLines[i]) > w ? sliceByColumn(overlayLines[i], 0, w, true) : overlayLines[i];
|
|
547
615
|
result[idx] = this.compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth);
|
|
548
|
-
modifiedLines.add(idx);
|
|
549
616
|
}
|
|
550
617
|
}
|
|
551
618
|
}
|
|
552
|
-
// Final verification: ensure no composited line exceeds terminal width
|
|
553
|
-
// This is a belt-and-suspenders safeguard - compositeLineAt should already
|
|
554
|
-
// guarantee this, but we verify here to prevent crashes from any edge cases
|
|
555
|
-
// Only check lines that were actually modified (optimization)
|
|
556
|
-
for (const idx of modifiedLines) {
|
|
557
|
-
const lineWidth = visibleWidth(result[idx]);
|
|
558
|
-
if (lineWidth > termWidth) {
|
|
559
|
-
result[idx] = sliceByColumn(result[idx], 0, termWidth, true);
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
619
|
return result;
|
|
563
620
|
}
|
|
564
|
-
static
|
|
621
|
+
static SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
|
|
565
622
|
applyLineResets(lines) {
|
|
566
623
|
const reset = TUI.SEGMENT_RESET;
|
|
567
624
|
for (let i = 0; i < lines.length; i++) {
|
|
568
625
|
const line = lines[i];
|
|
569
626
|
if (!isImageLine(line)) {
|
|
570
|
-
lines[i] = line + reset;
|
|
627
|
+
lines[i] = normalizeTerminalOutput(line) + reset;
|
|
571
628
|
}
|
|
572
629
|
}
|
|
573
630
|
return lines;
|
|
574
631
|
}
|
|
632
|
+
collectKittyImageIds(lines) {
|
|
633
|
+
const ids = new Set();
|
|
634
|
+
for (const line of lines) {
|
|
635
|
+
for (const id of extractKittyImageIds(line)) {
|
|
636
|
+
ids.add(id);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return ids;
|
|
640
|
+
}
|
|
641
|
+
deleteKittyImages(ids) {
|
|
642
|
+
let buffer = "";
|
|
643
|
+
for (const id of ids) {
|
|
644
|
+
buffer += deleteKittyImage(id);
|
|
645
|
+
}
|
|
646
|
+
return buffer;
|
|
647
|
+
}
|
|
648
|
+
expandLastChangedForKittyImages(firstChanged, lastChanged) {
|
|
649
|
+
let expandedLastChanged = lastChanged;
|
|
650
|
+
for (let i = firstChanged; i < this.previousLines.length; i++) {
|
|
651
|
+
if (extractKittyImageIds(this.previousLines[i]).length > 0) {
|
|
652
|
+
expandedLastChanged = Math.max(expandedLastChanged, i);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
return expandedLastChanged;
|
|
656
|
+
}
|
|
657
|
+
deleteChangedKittyImages(firstChanged, lastChanged) {
|
|
658
|
+
if (firstChanged < 0 || lastChanged < firstChanged)
|
|
659
|
+
return "";
|
|
660
|
+
const ids = new Set();
|
|
661
|
+
const maxLine = Math.min(lastChanged, this.previousLines.length - 1);
|
|
662
|
+
for (let i = firstChanged; i <= maxLine; i++) {
|
|
663
|
+
for (const id of extractKittyImageIds(this.previousLines[i] ?? "")) {
|
|
664
|
+
ids.add(id);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return this.deleteKittyImages(ids);
|
|
668
|
+
}
|
|
575
669
|
/** Splice overlay content into a base line at a specific column. Single-pass optimized. */
|
|
576
670
|
compositeLineAt(baseLine, overlayLine, startCol, overlayWidth, totalWidth) {
|
|
577
671
|
if (isImageLine(baseLine))
|
|
@@ -641,8 +735,11 @@ export class TUI extends Container {
|
|
|
641
735
|
return;
|
|
642
736
|
const width = this.terminal.columns;
|
|
643
737
|
const height = this.terminal.rows;
|
|
644
|
-
|
|
645
|
-
|
|
738
|
+
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
|
|
739
|
+
const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height;
|
|
740
|
+
const previousBufferLength = this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height;
|
|
741
|
+
let prevViewportTop = heightChanged ? Math.max(0, previousBufferLength - height) : this.previousViewportTop;
|
|
742
|
+
let viewportTop = prevViewportTop;
|
|
646
743
|
let hardwareCursorRow = this.hardwareCursorRow;
|
|
647
744
|
const computeLineDiff = (targetRow) => {
|
|
648
745
|
const currentScreenRow = hardwareCursorRow - prevViewportTop;
|
|
@@ -658,15 +755,14 @@ export class TUI extends Container {
|
|
|
658
755
|
// Extract cursor position before applying line resets (marker must be found first)
|
|
659
756
|
const cursorPos = this.extractCursorPosition(newLines, height);
|
|
660
757
|
newLines = this.applyLineResets(newLines);
|
|
661
|
-
// Width or height changed - need full re-render
|
|
662
|
-
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
|
|
663
|
-
const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height;
|
|
664
758
|
// Helper to clear scrollback and viewport and render all new lines
|
|
665
759
|
const fullRender = (clear) => {
|
|
666
760
|
this.fullRedrawCount += 1;
|
|
667
761
|
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
|
668
|
-
if (clear)
|
|
669
|
-
buffer +=
|
|
762
|
+
if (clear) {
|
|
763
|
+
buffer += this.deleteKittyImages(this.previousKittyImageIds);
|
|
764
|
+
buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback
|
|
765
|
+
}
|
|
670
766
|
for (let i = 0; i < newLines.length; i++) {
|
|
671
767
|
if (i > 0)
|
|
672
768
|
buffer += "\r\n";
|
|
@@ -683,9 +779,11 @@ export class TUI extends Container {
|
|
|
683
779
|
else {
|
|
684
780
|
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
|
685
781
|
}
|
|
686
|
-
|
|
782
|
+
const bufferLength = Math.max(height, newLines.length);
|
|
783
|
+
this.previousViewportTop = Math.max(0, bufferLength - height);
|
|
687
784
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
688
785
|
this.previousLines = newLines;
|
|
786
|
+
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
|
|
689
787
|
this.previousWidth = width;
|
|
690
788
|
this.previousHeight = height;
|
|
691
789
|
};
|
|
@@ -703,9 +801,17 @@ export class TUI extends Container {
|
|
|
703
801
|
fullRender(false);
|
|
704
802
|
return;
|
|
705
803
|
}
|
|
706
|
-
// Width
|
|
707
|
-
if (widthChanged
|
|
708
|
-
logRedraw(`terminal
|
|
804
|
+
// Width changes always need a full re-render because wrapping changes.
|
|
805
|
+
if (widthChanged) {
|
|
806
|
+
logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`);
|
|
807
|
+
fullRender(true);
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
// Height changes normally need a full re-render to keep the visible viewport aligned,
|
|
811
|
+
// but Termux changes height when the software keyboard shows or hides.
|
|
812
|
+
// In that environment, a full redraw causes the entire history to replay on every toggle.
|
|
813
|
+
if (heightChanged && !isTermuxSession()) {
|
|
814
|
+
logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`);
|
|
709
815
|
fullRender(true);
|
|
710
816
|
return;
|
|
711
817
|
}
|
|
@@ -738,11 +844,14 @@ export class TUI extends Container {
|
|
|
738
844
|
}
|
|
739
845
|
lastChanged = newLines.length - 1;
|
|
740
846
|
}
|
|
847
|
+
if (firstChanged !== -1) {
|
|
848
|
+
lastChanged = this.expandLastChangedForKittyImages(firstChanged, lastChanged);
|
|
849
|
+
}
|
|
741
850
|
const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0;
|
|
742
851
|
// No changes - but still need to update hardware cursor position if it moved
|
|
743
852
|
if (firstChanged === -1) {
|
|
744
853
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
745
|
-
this.previousViewportTop =
|
|
854
|
+
this.previousViewportTop = prevViewportTop;
|
|
746
855
|
this.previousHeight = height;
|
|
747
856
|
return;
|
|
748
857
|
}
|
|
@@ -750,8 +859,14 @@ export class TUI extends Container {
|
|
|
750
859
|
if (firstChanged >= newLines.length) {
|
|
751
860
|
if (this.previousLines.length > newLines.length) {
|
|
752
861
|
let buffer = "\x1b[?2026h";
|
|
862
|
+
buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
|
|
753
863
|
// Move to end of new content (clamp to 0 for empty content)
|
|
754
864
|
const targetRow = Math.max(0, newLines.length - 1);
|
|
865
|
+
if (targetRow < prevViewportTop) {
|
|
866
|
+
logRedraw(`deleted lines moved viewport up (${targetRow} < ${prevViewportTop})`);
|
|
867
|
+
fullRender(true);
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
755
870
|
const lineDiff = computeLineDiff(targetRow);
|
|
756
871
|
if (lineDiff > 0)
|
|
757
872
|
buffer += `\x1b[${lineDiff}B`;
|
|
@@ -783,23 +898,23 @@ export class TUI extends Container {
|
|
|
783
898
|
}
|
|
784
899
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
785
900
|
this.previousLines = newLines;
|
|
901
|
+
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
|
|
786
902
|
this.previousWidth = width;
|
|
787
903
|
this.previousHeight = height;
|
|
788
|
-
this.previousViewportTop =
|
|
904
|
+
this.previousViewportTop = prevViewportTop;
|
|
789
905
|
return;
|
|
790
906
|
}
|
|
791
|
-
//
|
|
792
|
-
//
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
// First change is above previous viewport - need full re-render
|
|
796
|
-
logRedraw(`firstChanged < viewportTop (${firstChanged} < ${previousContentViewportTop})`);
|
|
907
|
+
// Differential rendering can only touch what was actually visible.
|
|
908
|
+
// If the first changed line is above the previous viewport, we need a full redraw.
|
|
909
|
+
if (firstChanged < prevViewportTop) {
|
|
910
|
+
logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
|
|
797
911
|
fullRender(true);
|
|
798
912
|
return;
|
|
799
913
|
}
|
|
800
914
|
// Render from first changed line to end
|
|
801
915
|
// Build buffer with all updates wrapped in synchronized output
|
|
802
916
|
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
|
917
|
+
buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
|
|
803
918
|
const prevViewportBottom = prevViewportTop + height - 1;
|
|
804
919
|
const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
|
|
805
920
|
if (moveTargetRow > prevViewportBottom) {
|
|
@@ -915,10 +1030,11 @@ export class TUI extends Container {
|
|
|
915
1030
|
this.hardwareCursorRow = finalCursorRow;
|
|
916
1031
|
// Track terminal's working area (grows but doesn't shrink unless cleared)
|
|
917
1032
|
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
|
918
|
-
this.previousViewportTop = Math.max(
|
|
1033
|
+
this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1);
|
|
919
1034
|
// Position hardware cursor for IME
|
|
920
1035
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
921
1036
|
this.previousLines = newLines;
|
|
1037
|
+
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
|
|
922
1038
|
this.previousWidth = width;
|
|
923
1039
|
this.previousHeight = height;
|
|
924
1040
|
}
|