@fleetagent/pi-tui 0.1.9 → 0.2.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 +47 -7
- package/dist/autocomplete.d.ts +17 -23
- package/dist/autocomplete.d.ts.map +1 -1
- package/dist/autocomplete.js +129 -195
- package/dist/autocomplete.js.map +1 -1
- package/dist/components/alt-screen-flash.d.ts +13 -0
- package/dist/components/alt-screen-flash.d.ts.map +1 -0
- package/dist/components/alt-screen-flash.js +37 -0
- package/dist/components/alt-screen-flash.js.map +1 -0
- package/dist/components/editor.d.ts +47 -4
- package/dist/components/editor.d.ts.map +1 -1
- package/dist/components/editor.js +615 -585
- package/dist/components/editor.js.map +1 -1
- package/dist/components/h-stack.d.ts +7 -0
- package/dist/components/h-stack.d.ts.map +1 -0
- package/dist/components/h-stack.js +43 -0
- package/dist/components/h-stack.js.map +1 -0
- package/dist/components/image.d.ts +4 -0
- package/dist/components/image.d.ts.map +1 -1
- package/dist/components/image.js +38 -51
- package/dist/components/image.js.map +1 -1
- package/dist/components/input.d.ts +9 -0
- package/dist/components/input.d.ts.map +1 -1
- package/dist/components/input.js +121 -147
- package/dist/components/input.js.map +1 -1
- package/dist/components/markdown.d.ts +34 -0
- package/dist/components/markdown.d.ts.map +1 -1
- package/dist/components/markdown.js +491 -429
- package/dist/components/markdown.js.map +1 -1
- package/dist/components/scroll-view.d.ts +53 -0
- package/dist/components/scroll-view.d.ts.map +1 -0
- package/dist/components/scroll-view.js +173 -0
- package/dist/components/scroll-view.js.map +1 -0
- package/dist/components/select-list.d.ts.map +1 -1
- package/dist/components/select-list.js.map +1 -1
- package/dist/components/settings-list.d.ts +2 -0
- package/dist/components/settings-list.d.ts.map +1 -1
- package/dist/components/settings-list.js +31 -29
- package/dist/components/settings-list.js.map +1 -1
- package/dist/components/stack.d.ts +32 -0
- package/dist/components/stack.d.ts.map +1 -0
- package/dist/components/stack.js +122 -0
- package/dist/components/stack.js.map +1 -0
- package/dist/components/v-stack.d.ts +8 -0
- package/dist/components/v-stack.d.ts.map +1 -0
- package/dist/components/v-stack.js +27 -0
- package/dist/components/v-stack.js.map +1 -0
- package/dist/fuzzy.d.ts.map +1 -1
- package/dist/fuzzy.js +37 -46
- package/dist/fuzzy.js.map +1 -1
- package/dist/index.d.ts +10 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/dist/keybindings.d.ts +54 -4
- package/dist/keybindings.d.ts.map +1 -1
- package/dist/keybindings.js +39 -4
- package/dist/keybindings.js.map +1 -1
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +359 -479
- package/dist/keys.js.map +1 -1
- package/dist/kill-ring.d.ts +6 -4
- package/dist/kill-ring.d.ts.map +1 -1
- package/dist/kill-ring.js +0 -7
- package/dist/kill-ring.js.map +1 -1
- package/dist/latex.d.ts +10 -0
- package/dist/latex.d.ts.map +1 -0
- package/dist/latex.js +1406 -0
- package/dist/latex.js.map +1 -0
- package/dist/layout-node.d.ts +43 -0
- package/dist/layout-node.d.ts.map +1 -0
- package/dist/layout-node.js +6 -0
- package/dist/layout-node.js.map +1 -0
- package/dist/layout.d.ts +40 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +386 -0
- package/dist/layout.js.map +1 -0
- package/dist/stdin-buffer.d.ts +8 -0
- package/dist/stdin-buffer.d.ts.map +1 -1
- package/dist/stdin-buffer.js +116 -159
- package/dist/stdin-buffer.js.map +1 -1
- package/dist/terminal-image.d.ts +41 -19
- package/dist/terminal-image.d.ts.map +1 -1
- package/dist/terminal-image.js +171 -32
- package/dist/terminal-image.js.map +1 -1
- package/dist/terminal.d.ts +3 -0
- package/dist/terminal.d.ts.map +1 -1
- package/dist/terminal.js +27 -23
- package/dist/terminal.js.map +1 -1
- package/dist/tui-alt-screen.d.ts +114 -0
- package/dist/tui-alt-screen.d.ts.map +1 -0
- package/dist/tui-alt-screen.js +950 -0
- package/dist/tui-alt-screen.js.map +1 -0
- package/dist/tui-main-screen.d.ts +52 -0
- package/dist/tui-main-screen.d.ts.map +1 -0
- package/dist/tui-main-screen.js +511 -0
- package/dist/tui-main-screen.js.map +1 -0
- package/dist/tui.d.ts +112 -44
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +489 -699
- package/dist/tui.js.map +1 -1
- package/dist/utils.d.ts +29 -17
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +605 -528
- package/dist/utils.js.map +1 -1
- package/dist/word-navigation.d.ts.map +1 -1
- package/dist/word-navigation.js +37 -39
- package/dist/word-navigation.js.map +1 -1
- package/package.json +1 -1
package/dist/tui.js
CHANGED
|
@@ -1,34 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Minimal TUI implementation with differential rendering
|
|
3
3
|
*/
|
|
4
|
-
import * as fs from "node:fs";
|
|
5
4
|
import * as os from "node:os";
|
|
6
5
|
import * as path from "node:path";
|
|
7
6
|
import { performance } from "node:perf_hooks";
|
|
8
7
|
import { isKeyRelease, matchesKey } from "./keys.js";
|
|
9
|
-
import {
|
|
8
|
+
import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
|
|
10
9
|
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
|
-
}
|
|
32
10
|
/** Type guard to check if a component implements Focusable */
|
|
33
11
|
export function isFocusable(component) {
|
|
34
12
|
return component !== null && "focused" in component;
|
|
@@ -54,9 +32,6 @@ function parseSizeValue(value, referenceSize) {
|
|
|
54
32
|
}
|
|
55
33
|
return undefined;
|
|
56
34
|
}
|
|
57
|
-
function isTermuxSession() {
|
|
58
|
-
return Boolean(process.env.TERMUX_VERSION);
|
|
59
|
-
}
|
|
60
35
|
/**
|
|
61
36
|
* Container - a component that contains other components
|
|
62
37
|
*/
|
|
@@ -93,38 +68,72 @@ export class Container {
|
|
|
93
68
|
/**
|
|
94
69
|
* TUI - Main class for managing terminal UI with differential rendering
|
|
95
70
|
*/
|
|
96
|
-
|
|
71
|
+
const SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
|
|
72
|
+
/** Composite overlay content into a terminal line at a fixed column. */
|
|
73
|
+
export function compositeTuiLine(baseLine, overlayLine, startCol, overlayWidth, totalWidth) {
|
|
74
|
+
if (isImageLine(baseLine))
|
|
75
|
+
return baseLine;
|
|
76
|
+
const afterStart = startCol + overlayWidth;
|
|
77
|
+
const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
|
|
78
|
+
const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
|
|
79
|
+
const beforePad = Math.max(0, startCol - base.beforeWidth);
|
|
80
|
+
const overlayPad = Math.max(0, overlayWidth - overlay.width);
|
|
81
|
+
const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
|
|
82
|
+
const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
|
|
83
|
+
const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
|
|
84
|
+
const afterPad = Math.max(0, afterTarget - base.afterWidth);
|
|
85
|
+
const result = base.before +
|
|
86
|
+
" ".repeat(beforePad) +
|
|
87
|
+
SEGMENT_RESET +
|
|
88
|
+
overlay.text +
|
|
89
|
+
" ".repeat(overlayPad) +
|
|
90
|
+
SEGMENT_RESET +
|
|
91
|
+
base.after +
|
|
92
|
+
" ".repeat(afterPad);
|
|
93
|
+
return visibleWidth(result) <= totalWidth ? result : sliceByColumn(result, 0, totalWidth, true);
|
|
94
|
+
}
|
|
95
|
+
export const VIEWPORT_TUI = Symbol.for("@fleetagent/pi-tui/viewport");
|
|
96
|
+
export function isViewportTUI(tui) {
|
|
97
|
+
return tui[VIEWPORT_TUI] === true;
|
|
98
|
+
}
|
|
99
|
+
export class TuiBase extends Container {
|
|
97
100
|
terminal;
|
|
98
|
-
previousLines = [];
|
|
99
|
-
previousKittyImageIds = new Set();
|
|
100
|
-
previousWidth = 0;
|
|
101
|
-
previousHeight = 0;
|
|
102
101
|
focusedComponent = null;
|
|
103
102
|
inputListeners = new Set();
|
|
104
103
|
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
|
|
105
104
|
onDebug;
|
|
106
105
|
renderRequested = false;
|
|
106
|
+
immediateRenderScheduled = false;
|
|
107
107
|
renderTimer;
|
|
108
108
|
lastRenderAt = 0;
|
|
109
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
110
|
showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";
|
|
113
|
-
clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1";
|
|
114
|
-
maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
|
|
115
|
-
previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
|
|
111
|
+
clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1";
|
|
116
112
|
fullRedrawCount = 0;
|
|
117
113
|
stopped = false;
|
|
114
|
+
logDirectory;
|
|
118
115
|
// Overlay stack for modal components rendered on top of base content
|
|
119
116
|
focusOrderCounter = 0;
|
|
120
117
|
overlayStack = [];
|
|
121
|
-
|
|
118
|
+
get hasOverlayEntries() {
|
|
119
|
+
return this.overlayStack.length > 0;
|
|
120
|
+
}
|
|
121
|
+
overlayFocusRestore = { status: "inactive" };
|
|
122
|
+
constructor(terminal, showHardwareCursor, logDirectory) {
|
|
122
123
|
super();
|
|
123
124
|
this.terminal = terminal;
|
|
125
|
+
this.logDirectory = logDirectory ?? process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
|
|
124
126
|
if (showHardwareCursor !== undefined) {
|
|
125
127
|
this.showHardwareCursor = showHardwareCursor;
|
|
126
128
|
}
|
|
127
129
|
}
|
|
130
|
+
resetRenderState() { }
|
|
131
|
+
beforeTerminalStart() { }
|
|
132
|
+
afterTerminalStart() { }
|
|
133
|
+
onTerminalResize() { }
|
|
134
|
+
onOverlayStackChanged() { }
|
|
135
|
+
beforeTerminalStop(_options) { }
|
|
136
|
+
afterTerminalStop(_options) { }
|
|
128
137
|
get fullRedraws() {
|
|
129
138
|
return this.fullRedrawCount;
|
|
130
139
|
}
|
|
@@ -151,16 +160,170 @@ export class TUI extends Container {
|
|
|
151
160
|
setClearOnShrink(enabled) {
|
|
152
161
|
this.clearOnShrink = enabled;
|
|
153
162
|
}
|
|
163
|
+
getFocusedComponent() {
|
|
164
|
+
return this.focusedComponent;
|
|
165
|
+
}
|
|
154
166
|
setFocus(component) {
|
|
155
|
-
|
|
156
|
-
|
|
167
|
+
this.setFocusInternal({ component, overlayFocusRestore: "clear" });
|
|
168
|
+
}
|
|
169
|
+
resolveNonOverlayFocus(nextFocus, previousFocus, restoreState) {
|
|
170
|
+
if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {
|
|
171
|
+
if (restoreState.resume.status === "focus-target" || !this.isComponentMounted(restoreState.blockedBy)) {
|
|
172
|
+
return this.resolveBlockedOverlayFocusResume(restoreState);
|
|
173
|
+
}
|
|
174
|
+
this.overlayFocusRestore = {
|
|
175
|
+
status: "blocked",
|
|
176
|
+
overlay: restoreState.overlay,
|
|
177
|
+
blockedBy: nextFocus,
|
|
178
|
+
resume: restoreState.resume,
|
|
179
|
+
};
|
|
180
|
+
return nextFocus;
|
|
181
|
+
}
|
|
182
|
+
const previousFocusedOverlay = previousFocus
|
|
183
|
+
? this.overlayStack.find((entry) => entry.component === previousFocus && this.isOverlayVisible(entry))
|
|
184
|
+
: undefined;
|
|
185
|
+
if (previousFocusedOverlay &&
|
|
186
|
+
restoreState.status !== "inactive" &&
|
|
187
|
+
restoreState.overlay === previousFocusedOverlay &&
|
|
188
|
+
!this.isOverlayFocusAncestor(previousFocusedOverlay, nextFocus)) {
|
|
189
|
+
this.overlayFocusRestore = {
|
|
190
|
+
status: "blocked",
|
|
191
|
+
overlay: previousFocusedOverlay,
|
|
192
|
+
blockedBy: nextFocus,
|
|
193
|
+
resume: { status: "restore-overlay" },
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return nextFocus;
|
|
197
|
+
}
|
|
198
|
+
resolveNullFocus(previousFocus, restoreState, overlayFocusRestore) {
|
|
199
|
+
if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {
|
|
200
|
+
return this.resolveBlockedOverlayFocusResume(restoreState);
|
|
201
|
+
}
|
|
202
|
+
if (overlayFocusRestore === "clear")
|
|
203
|
+
this.clearOverlayFocusRestore();
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
resolveFocusTarget(component, overlayFocusRestore) {
|
|
207
|
+
const previousFocus = this.focusedComponent;
|
|
208
|
+
const restoreState = this.getVisibleOverlayFocusRestore();
|
|
209
|
+
if (component === null)
|
|
210
|
+
return this.resolveNullFocus(previousFocus, restoreState, overlayFocusRestore);
|
|
211
|
+
const isOverlay = this.overlayStack.some((entry) => entry.component === component);
|
|
212
|
+
if (isOverlay)
|
|
213
|
+
return component;
|
|
214
|
+
return this.resolveNonOverlayFocus(component, previousFocus, restoreState);
|
|
215
|
+
}
|
|
216
|
+
applyFocus(nextFocus) {
|
|
217
|
+
if (isFocusable(this.focusedComponent))
|
|
157
218
|
this.focusedComponent.focused = false;
|
|
219
|
+
this.focusedComponent = nextFocus;
|
|
220
|
+
if (isFocusable(nextFocus))
|
|
221
|
+
nextFocus.focused = true;
|
|
222
|
+
const focusedOverlay = nextFocus
|
|
223
|
+
? this.overlayStack.find((entry) => entry.component === nextFocus && this.isOverlayVisible(entry))
|
|
224
|
+
: undefined;
|
|
225
|
+
if (focusedOverlay)
|
|
226
|
+
this.overlayFocusRestore = { status: "eligible", overlay: focusedOverlay };
|
|
227
|
+
}
|
|
228
|
+
setFocusInternal({ component, overlayFocusRestore }) {
|
|
229
|
+
const nextFocus = this.resolveFocusTarget(component, overlayFocusRestore);
|
|
230
|
+
this.applyFocus(nextFocus);
|
|
231
|
+
}
|
|
232
|
+
clearOverlayFocusRestore() {
|
|
233
|
+
this.overlayFocusRestore = { status: "inactive" };
|
|
234
|
+
}
|
|
235
|
+
clearOverlayFocusRestoreFor(overlay) {
|
|
236
|
+
if (this.overlayFocusRestore.status !== "inactive" && this.overlayFocusRestore.overlay === overlay) {
|
|
237
|
+
this.clearOverlayFocusRestore();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
resolveBlockedOverlayFocusResume(restoreState) {
|
|
241
|
+
if (restoreState.resume.status === "restore-overlay")
|
|
242
|
+
return restoreState.overlay.component;
|
|
243
|
+
this.clearOverlayFocusRestore();
|
|
244
|
+
return restoreState.resume.target;
|
|
245
|
+
}
|
|
246
|
+
getVisibleOverlayFocusRestore() {
|
|
247
|
+
const restoreState = this.overlayFocusRestore;
|
|
248
|
+
if (restoreState.status === "inactive")
|
|
249
|
+
return restoreState;
|
|
250
|
+
if (!this.overlayStack.includes(restoreState.overlay) || !this.isOverlayVisible(restoreState.overlay)) {
|
|
251
|
+
return { status: "inactive" };
|
|
252
|
+
}
|
|
253
|
+
return restoreState;
|
|
254
|
+
}
|
|
255
|
+
isOverlayFocusAncestor(entry, component) {
|
|
256
|
+
const preFocusByComponent = new Map();
|
|
257
|
+
for (const overlay of this.overlayStack) {
|
|
258
|
+
if (!preFocusByComponent.has(overlay.component)) {
|
|
259
|
+
preFocusByComponent.set(overlay.component, overlay.preFocus);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const visited = new Set();
|
|
263
|
+
let current = entry.preFocus;
|
|
264
|
+
while (current && !visited.has(current)) {
|
|
265
|
+
visited.add(current);
|
|
266
|
+
if (current === component)
|
|
267
|
+
return true;
|
|
268
|
+
current = preFocusByComponent.get(current) ?? null;
|
|
269
|
+
}
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
retargetOverlayPreFocus(removed) {
|
|
273
|
+
for (const overlay of this.overlayStack) {
|
|
274
|
+
if (overlay !== removed && overlay.preFocus === removed.component) {
|
|
275
|
+
overlay.preFocus = removed.preFocus;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
getMountedRoots() {
|
|
280
|
+
return this.children;
|
|
281
|
+
}
|
|
282
|
+
isComponentMounted(component) {
|
|
283
|
+
return this.getMountedRoots().some((child) => this.containsComponent(child, component));
|
|
284
|
+
}
|
|
285
|
+
containsComponent(root, target) {
|
|
286
|
+
if (root === target)
|
|
287
|
+
return true;
|
|
288
|
+
if (!(root instanceof Container))
|
|
289
|
+
return false;
|
|
290
|
+
return root.children.some((child) => this.containsComponent(child, target));
|
|
291
|
+
}
|
|
292
|
+
applyBlockedOverlayUnfocus(entry, restoreState, options) {
|
|
293
|
+
if (restoreState.status !== "blocked" ||
|
|
294
|
+
restoreState.overlay !== entry ||
|
|
295
|
+
this.focusedComponent !== restoreState.blockedBy) {
|
|
296
|
+
return false;
|
|
158
297
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
298
|
+
if (options) {
|
|
299
|
+
this.overlayFocusRestore = {
|
|
300
|
+
status: "blocked",
|
|
301
|
+
overlay: entry,
|
|
302
|
+
blockedBy: restoreState.blockedBy,
|
|
303
|
+
resume: { status: "focus-target", target: options.target },
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
this.clearOverlayFocusRestore();
|
|
163
308
|
}
|
|
309
|
+
this.requestRender();
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
unfocusOverlay(entry, options) {
|
|
313
|
+
const isFocused = this.focusedComponent === entry.component;
|
|
314
|
+
const restoreState = this.overlayFocusRestore;
|
|
315
|
+
const hasPendingRestore = restoreState.status !== "inactive" && restoreState.overlay === entry;
|
|
316
|
+
if (!isFocused && !hasPendingRestore)
|
|
317
|
+
return;
|
|
318
|
+
if (this.applyBlockedOverlayUnfocus(entry, restoreState, options))
|
|
319
|
+
return;
|
|
320
|
+
this.clearOverlayFocusRestoreFor(entry);
|
|
321
|
+
if (isFocused || options) {
|
|
322
|
+
const topVisible = this.getTopmostVisibleOverlay();
|
|
323
|
+
const fallbackTarget = topVisible && topVisible !== entry ? topVisible.component : entry.preFocus;
|
|
324
|
+
this.setFocus(options ? options.target : fallbackTarget);
|
|
325
|
+
}
|
|
326
|
+
this.requestRender();
|
|
164
327
|
}
|
|
165
328
|
/**
|
|
166
329
|
* Show an overlay component with configurable positioning and sizing.
|
|
@@ -169,12 +332,15 @@ export class TUI extends Container {
|
|
|
169
332
|
showOverlay(component, options) {
|
|
170
333
|
const entry = {
|
|
171
334
|
component,
|
|
172
|
-
options,
|
|
335
|
+
...(options === undefined ? {} : { options }),
|
|
173
336
|
preFocus: this.focusedComponent,
|
|
174
337
|
hidden: false,
|
|
175
338
|
focusOrder: ++this.focusOrderCounter,
|
|
339
|
+
visible: false,
|
|
176
340
|
};
|
|
177
341
|
this.overlayStack.push(entry);
|
|
342
|
+
entry.visible = this.isOverlayVisible(entry);
|
|
343
|
+
this.onOverlayStackChanged();
|
|
178
344
|
// Only focus if overlay is actually visible
|
|
179
345
|
if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
|
|
180
346
|
this.setFocus(component);
|
|
@@ -186,7 +352,10 @@ export class TUI extends Container {
|
|
|
186
352
|
hide: () => {
|
|
187
353
|
const index = this.overlayStack.indexOf(entry);
|
|
188
354
|
if (index !== -1) {
|
|
355
|
+
this.clearOverlayFocusRestoreFor(entry);
|
|
356
|
+
this.retargetOverlayPreFocus(entry);
|
|
189
357
|
this.overlayStack.splice(index, 1);
|
|
358
|
+
this.onOverlayStackChanged();
|
|
190
359
|
// Restore focus if this overlay had focus
|
|
191
360
|
if (this.focusedComponent === component) {
|
|
192
361
|
const topVisible = this.getTopmostVisibleOverlay();
|
|
@@ -201,8 +370,10 @@ export class TUI extends Container {
|
|
|
201
370
|
if (entry.hidden === hidden)
|
|
202
371
|
return;
|
|
203
372
|
entry.hidden = hidden;
|
|
373
|
+
entry.visible = this.isOverlayVisible(entry);
|
|
204
374
|
// Update focus when hiding/showing
|
|
205
375
|
if (hidden) {
|
|
376
|
+
this.clearOverlayFocusRestoreFor(entry);
|
|
206
377
|
// If this overlay had focus, move focus to next visible or preFocus
|
|
207
378
|
if (this.focusedComponent === component) {
|
|
208
379
|
const topVisible = this.getTopmostVisibleOverlay();
|
|
@@ -216,33 +387,30 @@ export class TUI extends Container {
|
|
|
216
387
|
this.setFocus(component);
|
|
217
388
|
}
|
|
218
389
|
}
|
|
390
|
+
this.onOverlayStackChanged();
|
|
219
391
|
this.requestRender();
|
|
220
392
|
},
|
|
221
393
|
isHidden: () => entry.hidden,
|
|
222
394
|
focus: () => {
|
|
223
395
|
if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry))
|
|
224
396
|
return;
|
|
225
|
-
if (this.focusedComponent !== component) {
|
|
226
|
-
this.setFocus(component);
|
|
227
|
-
}
|
|
228
397
|
entry.focusOrder = ++this.focusOrderCounter;
|
|
398
|
+
this.setFocus(component);
|
|
229
399
|
this.requestRender();
|
|
230
400
|
},
|
|
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
|
-
},
|
|
401
|
+
unfocus: (unfocusOptions) => this.unfocusOverlay(entry, unfocusOptions),
|
|
238
402
|
isFocused: () => this.focusedComponent === component,
|
|
239
403
|
};
|
|
240
404
|
}
|
|
241
405
|
/** Hide the topmost overlay and restore previous focus. */
|
|
242
406
|
hideOverlay() {
|
|
243
|
-
const overlay = this.overlayStack.
|
|
407
|
+
const overlay = this.overlayStack[this.overlayStack.length - 1];
|
|
244
408
|
if (!overlay)
|
|
245
409
|
return;
|
|
410
|
+
this.clearOverlayFocusRestoreFor(overlay);
|
|
411
|
+
this.retargetOverlayPreFocus(overlay);
|
|
412
|
+
this.overlayStack.pop();
|
|
413
|
+
this.onOverlayStackChanged();
|
|
246
414
|
if (this.focusedComponent === overlay.component) {
|
|
247
415
|
// Find topmost visible overlay, or fall back to preFocus
|
|
248
416
|
const topVisible = this.getTopmostVisibleOverlay();
|
|
@@ -256,6 +424,9 @@ export class TUI extends Container {
|
|
|
256
424
|
hasOverlay() {
|
|
257
425
|
return this.overlayStack.some((o) => this.isOverlayVisible(o));
|
|
258
426
|
}
|
|
427
|
+
hasCapturingOverlay() {
|
|
428
|
+
return this.overlayStack.some((o) => !o.options?.nonCapturing && this.isOverlayVisible(o));
|
|
429
|
+
}
|
|
259
430
|
/** Check if an overlay entry is currently visible */
|
|
260
431
|
isOverlayVisible(entry) {
|
|
261
432
|
if (entry.hidden)
|
|
@@ -265,28 +436,103 @@ export class TUI extends Container {
|
|
|
265
436
|
}
|
|
266
437
|
return true;
|
|
267
438
|
}
|
|
268
|
-
/** Find the
|
|
439
|
+
/** Find the visual-frontmost visible capturing overlay, if any */
|
|
269
440
|
getTopmostVisibleOverlay() {
|
|
270
|
-
|
|
271
|
-
|
|
441
|
+
let topmost;
|
|
442
|
+
for (const overlay of this.overlayStack) {
|
|
443
|
+
if (overlay.options?.nonCapturing || !this.isOverlayVisible(overlay))
|
|
272
444
|
continue;
|
|
273
|
-
if (
|
|
274
|
-
|
|
445
|
+
if (!topmost || overlay.focusOrder > topmost.focusOrder) {
|
|
446
|
+
topmost = overlay;
|
|
275
447
|
}
|
|
276
448
|
}
|
|
277
|
-
return
|
|
449
|
+
return topmost;
|
|
450
|
+
}
|
|
451
|
+
refreshOverlayVisibility() {
|
|
452
|
+
let newlyVisible;
|
|
453
|
+
for (const overlay of this.overlayStack) {
|
|
454
|
+
const visible = this.isOverlayVisible(overlay);
|
|
455
|
+
if (visible && !overlay.visible && !overlay.options?.nonCapturing) {
|
|
456
|
+
if (!newlyVisible || overlay.focusOrder > newlyVisible.focusOrder)
|
|
457
|
+
newlyVisible = overlay;
|
|
458
|
+
}
|
|
459
|
+
overlay.visible = visible;
|
|
460
|
+
}
|
|
461
|
+
return newlyVisible;
|
|
462
|
+
}
|
|
463
|
+
retargetFocusFromHiddenOverlay() {
|
|
464
|
+
const focusedOverlay = this.overlayStack.find((overlay) => overlay.component === this.focusedComponent);
|
|
465
|
+
if (!focusedOverlay || this.isOverlayVisible(focusedOverlay))
|
|
466
|
+
return;
|
|
467
|
+
const topVisible = this.getTopmostVisibleOverlay();
|
|
468
|
+
if (topVisible) {
|
|
469
|
+
this.setFocus(topVisible.component);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
this.setFocusInternal({ component: focusedOverlay.preFocus, overlayFocusRestore: "preserve" });
|
|
473
|
+
}
|
|
474
|
+
focusNewlyVisibleOverlay(newlyVisible) {
|
|
475
|
+
if (!newlyVisible)
|
|
476
|
+
return;
|
|
477
|
+
const currentFocusedOverlay = this.overlayStack.find((overlay) => overlay.component === this.focusedComponent && this.isOverlayVisible(overlay));
|
|
478
|
+
if (!currentFocusedOverlay || newlyVisible.focusOrder > currentFocusedOverlay.focusOrder) {
|
|
479
|
+
this.setFocus(newlyVisible.component);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
restoreVisibleOverlayFocus() {
|
|
483
|
+
if (this.overlayStack.some((overlay) => overlay.component === this.focusedComponent))
|
|
484
|
+
return;
|
|
485
|
+
const restoreState = this.getVisibleOverlayFocusRestore();
|
|
486
|
+
if (restoreState.status === "eligible") {
|
|
487
|
+
this.setFocus(restoreState.overlay.component);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (restoreState.status !== "blocked" || restoreState.blockedBy === this.focusedComponent)
|
|
491
|
+
return;
|
|
492
|
+
if (restoreState.resume.status === "restore-overlay") {
|
|
493
|
+
this.setFocus(restoreState.overlay.component);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
this.clearOverlayFocusRestore();
|
|
497
|
+
this.setFocus(restoreState.resume.target);
|
|
498
|
+
}
|
|
499
|
+
reconcileOverlayFocus() {
|
|
500
|
+
const newlyVisible = this.refreshOverlayVisibility();
|
|
501
|
+
this.retargetFocusFromHiddenOverlay();
|
|
502
|
+
this.focusNewlyVisibleOverlay(newlyVisible);
|
|
503
|
+
this.restoreVisibleOverlayFocus();
|
|
278
504
|
}
|
|
279
505
|
invalidate() {
|
|
280
|
-
|
|
506
|
+
for (const root of this.getMountedRoots())
|
|
507
|
+
root.invalidate();
|
|
281
508
|
for (const overlay of this.overlayStack)
|
|
282
509
|
overlay.component.invalidate?.();
|
|
283
510
|
}
|
|
284
511
|
start() {
|
|
285
512
|
this.stopped = false;
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
513
|
+
try {
|
|
514
|
+
this.beforeTerminalStart();
|
|
515
|
+
this.terminal.start((data) => this.handleTerminalInput(data), () => {
|
|
516
|
+
this.reconcileOverlayFocus();
|
|
517
|
+
this.onTerminalResize();
|
|
518
|
+
this.requestRender();
|
|
519
|
+
});
|
|
520
|
+
this.afterTerminalStart();
|
|
521
|
+
this.terminal.hideCursor();
|
|
522
|
+
this.queryCellSize();
|
|
523
|
+
this.requestRender();
|
|
524
|
+
}
|
|
525
|
+
catch (error) {
|
|
526
|
+
// A terminal may throw after partially entering raw or alternate-screen mode.
|
|
527
|
+
// Route startup failures through the same complete cleanup path as signals.
|
|
528
|
+
try {
|
|
529
|
+
this.stop();
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
// Preserve the startup failure; cleanup is best-effort but exhaustive.
|
|
533
|
+
}
|
|
534
|
+
throw error;
|
|
535
|
+
}
|
|
290
536
|
}
|
|
291
537
|
addInputListener(listener) {
|
|
292
538
|
this.inputListeners.add(listener);
|
|
@@ -306,49 +552,40 @@ export class TUI extends Container {
|
|
|
306
552
|
// Response format: CSI 6 ; height ; width t
|
|
307
553
|
this.terminal.write("\x1b[16t");
|
|
308
554
|
}
|
|
309
|
-
stop() {
|
|
555
|
+
stop(options) {
|
|
310
556
|
this.stopped = true;
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
const targetRow = this.previousLines.length; // Line after the last content
|
|
318
|
-
const lineDiff = targetRow - this.hardwareCursorRow;
|
|
319
|
-
if (lineDiff > 0) {
|
|
320
|
-
this.terminal.write(`\x1b[${lineDiff}B`);
|
|
557
|
+
this.renderRequested = false;
|
|
558
|
+
this.cancelRenderTimer();
|
|
559
|
+
let failure;
|
|
560
|
+
const cleanup = (action) => {
|
|
561
|
+
try {
|
|
562
|
+
action();
|
|
321
563
|
}
|
|
322
|
-
|
|
323
|
-
|
|
564
|
+
catch (error) {
|
|
565
|
+
failure ??= error;
|
|
324
566
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
this.terminal.showCursor();
|
|
328
|
-
this.terminal.stop();
|
|
567
|
+
};
|
|
568
|
+
cleanup(() => this.beforeTerminalStop(options));
|
|
569
|
+
cleanup(() => this.terminal.showCursor());
|
|
570
|
+
cleanup(() => this.terminal.stop());
|
|
571
|
+
// Alternate-screen exit and capability restoration must run even when
|
|
572
|
+
// keyboard/raw-mode shutdown reports an error.
|
|
573
|
+
cleanup(() => this.afterTerminalStop(options));
|
|
574
|
+
if (failure !== undefined)
|
|
575
|
+
throw failure;
|
|
576
|
+
}
|
|
577
|
+
renderNow(force = false) {
|
|
578
|
+
if (force)
|
|
579
|
+
this.resetRenderState();
|
|
580
|
+
this.renderRequested = false;
|
|
581
|
+
this.cancelRenderTimer();
|
|
582
|
+
this.lastRenderAt = performance.now();
|
|
583
|
+
this.doRender();
|
|
329
584
|
}
|
|
330
585
|
requestRender(force = false) {
|
|
331
586
|
if (force) {
|
|
332
|
-
this.
|
|
333
|
-
this.
|
|
334
|
-
this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
|
|
335
|
-
this.cursorRow = 0;
|
|
336
|
-
this.hardwareCursorRow = 0;
|
|
337
|
-
this.maxLinesRendered = 0;
|
|
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
|
-
});
|
|
587
|
+
this.resetRenderState();
|
|
588
|
+
this.requestImmediateRender();
|
|
352
589
|
return;
|
|
353
590
|
}
|
|
354
591
|
if (this.renderRequested)
|
|
@@ -356,75 +593,80 @@ export class TUI extends Container {
|
|
|
356
593
|
this.renderRequested = true;
|
|
357
594
|
process.nextTick(() => this.scheduleRender());
|
|
358
595
|
}
|
|
596
|
+
requestImmediateRender() {
|
|
597
|
+
this.cancelRenderTimer();
|
|
598
|
+
this.renderRequested = true;
|
|
599
|
+
if (this.immediateRenderScheduled)
|
|
600
|
+
return;
|
|
601
|
+
this.immediateRenderScheduled = true;
|
|
602
|
+
process.nextTick(() => {
|
|
603
|
+
this.immediateRenderScheduled = false;
|
|
604
|
+
if (this.stopped || !this.renderRequested)
|
|
605
|
+
return;
|
|
606
|
+
this.cancelRenderTimer();
|
|
607
|
+
this.renderRequested = false;
|
|
608
|
+
this.lastRenderAt = performance.now();
|
|
609
|
+
this.doRender();
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
cancelRenderTimer() {
|
|
613
|
+
if (!this.renderTimer)
|
|
614
|
+
return;
|
|
615
|
+
clearTimeout(this.renderTimer);
|
|
616
|
+
this.renderTimer = undefined;
|
|
617
|
+
}
|
|
359
618
|
scheduleRender() {
|
|
360
|
-
if (this.stopped || this.renderTimer || !this.renderRequested)
|
|
619
|
+
if (this.stopped || this.renderTimer || !this.renderRequested)
|
|
361
620
|
return;
|
|
362
|
-
}
|
|
363
621
|
const elapsed = performance.now() - this.lastRenderAt;
|
|
364
|
-
const delay = Math.max(0,
|
|
622
|
+
const delay = Math.max(0, TuiBase.MIN_RENDER_INTERVAL_MS - elapsed);
|
|
365
623
|
this.renderTimer = setTimeout(() => {
|
|
366
624
|
this.renderTimer = undefined;
|
|
367
|
-
if (this.stopped || !this.renderRequested)
|
|
625
|
+
if (this.stopped || !this.renderRequested)
|
|
368
626
|
return;
|
|
369
|
-
}
|
|
370
627
|
this.renderRequested = false;
|
|
371
628
|
this.lastRenderAt = performance.now();
|
|
372
629
|
this.doRender();
|
|
373
|
-
if (this.renderRequested)
|
|
630
|
+
if (this.renderRequested)
|
|
374
631
|
this.scheduleRender();
|
|
375
|
-
}
|
|
376
632
|
}, delay);
|
|
377
633
|
}
|
|
378
|
-
|
|
379
|
-
if (this.inputListeners.size
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
634
|
+
applyInputListeners(data) {
|
|
635
|
+
if (this.inputListeners.size === 0)
|
|
636
|
+
return data;
|
|
637
|
+
let current = data;
|
|
638
|
+
for (const listener of this.inputListeners) {
|
|
639
|
+
const result = listener(current);
|
|
640
|
+
if (result?.consume)
|
|
641
|
+
return undefined;
|
|
642
|
+
if (result?.data !== undefined)
|
|
643
|
+
current = result.data;
|
|
644
|
+
}
|
|
645
|
+
return current.length === 0 ? undefined : current;
|
|
646
|
+
}
|
|
647
|
+
handleTerminalInput(data) {
|
|
648
|
+
this.reconcileOverlayFocus();
|
|
649
|
+
const filteredData = this.applyInputListeners(data);
|
|
650
|
+
if (filteredData === undefined)
|
|
651
|
+
return;
|
|
652
|
+
data = filteredData;
|
|
395
653
|
// Consume terminal cell size responses without blocking unrelated input.
|
|
396
|
-
if (this.consumeCellSizeResponse(data))
|
|
654
|
+
if (this.consumeCellSizeResponse(data))
|
|
397
655
|
return;
|
|
398
|
-
}
|
|
399
656
|
// Global debug key handler (Shift+Ctrl+D)
|
|
400
657
|
if (matchesKey(data, "shift+ctrl+d") && this.onDebug) {
|
|
401
658
|
this.onDebug();
|
|
402
659
|
return;
|
|
403
660
|
}
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
else {
|
|
414
|
-
// No visible overlays, restore to preFocus
|
|
415
|
-
this.setFocus(focusedOverlay.preFocus);
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
// Pass input to focused component (including Ctrl+C)
|
|
419
|
-
// The focused component can decide how to handle Ctrl+C
|
|
420
|
-
if (this.focusedComponent?.handleInput) {
|
|
421
|
-
// Filter out key release events unless component opts in
|
|
422
|
-
if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
|
-
this.focusedComponent.handleInput(data);
|
|
426
|
-
this.requestRender();
|
|
427
|
-
}
|
|
661
|
+
// Pass input to focused component (including Ctrl+C).
|
|
662
|
+
// The focused component can decide how to handle Ctrl+C.
|
|
663
|
+
if (!this.focusedComponent?.handleInput)
|
|
664
|
+
return;
|
|
665
|
+
// Filter out key release events unless component opts in.
|
|
666
|
+
if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease)
|
|
667
|
+
return;
|
|
668
|
+
this.focusedComponent.handleInput(data);
|
|
669
|
+
this.requestImmediateRender();
|
|
428
670
|
}
|
|
429
671
|
consumeCellSizeResponse(data) {
|
|
430
672
|
// Response format: ESC [ 6 ; height ; width t
|
|
@@ -443,98 +685,68 @@ export class TUI extends Container {
|
|
|
443
685
|
this.requestRender();
|
|
444
686
|
return true;
|
|
445
687
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
resolveOverlayLayout(options, overlayHeight, termWidth, termHeight) {
|
|
451
|
-
const opt = options ?? {};
|
|
452
|
-
// Parse margin (clamp to non-negative)
|
|
453
|
-
const margin = typeof opt.margin === "number"
|
|
454
|
-
? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin }
|
|
455
|
-
: (opt.margin ?? {});
|
|
688
|
+
resolveOverlayBounds(options, termWidth, termHeight) {
|
|
689
|
+
const margin = typeof options.margin === "number"
|
|
690
|
+
? { top: options.margin, right: options.margin, bottom: options.margin, left: options.margin }
|
|
691
|
+
: (options.margin ?? {});
|
|
456
692
|
const marginTop = Math.max(0, margin.top ?? 0);
|
|
457
693
|
const marginRight = Math.max(0, margin.right ?? 0);
|
|
458
694
|
const marginBottom = Math.max(0, margin.bottom ?? 0);
|
|
459
695
|
const marginLeft = Math.max(0, margin.left ?? 0);
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
width =
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
}
|
|
515
|
-
else {
|
|
516
|
-
// Invalid format, fall back to center
|
|
517
|
-
col = this.resolveAnchorCol("center", width, availWidth, marginLeft);
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
else {
|
|
521
|
-
// Absolute column position
|
|
522
|
-
col = opt.col;
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
else {
|
|
526
|
-
// Anchor-based (default: center)
|
|
527
|
-
const anchor = opt.anchor ?? "center";
|
|
528
|
-
col = this.resolveAnchorCol(anchor, width, availWidth, marginLeft);
|
|
529
|
-
}
|
|
530
|
-
// Apply offsets
|
|
531
|
-
if (opt.offsetY !== undefined)
|
|
532
|
-
row += opt.offsetY;
|
|
533
|
-
if (opt.offsetX !== undefined)
|
|
534
|
-
col += opt.offsetX;
|
|
535
|
-
// Clamp to terminal bounds (respecting margins)
|
|
536
|
-
row = Math.max(marginTop, Math.min(row, termHeight - marginBottom - effectiveHeight));
|
|
537
|
-
col = Math.max(marginLeft, Math.min(col, termWidth - marginRight - width));
|
|
696
|
+
return {
|
|
697
|
+
marginTop,
|
|
698
|
+
marginRight,
|
|
699
|
+
marginBottom,
|
|
700
|
+
marginLeft,
|
|
701
|
+
availableWidth: Math.max(1, termWidth - marginLeft - marginRight),
|
|
702
|
+
availableHeight: Math.max(1, termHeight - marginTop - marginBottom),
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
resolveOverlayWidth(options, termWidth, bounds) {
|
|
706
|
+
let width = parseSizeValue(options.width, termWidth) ?? Math.min(80, bounds.availableWidth);
|
|
707
|
+
if (options.minWidth !== undefined)
|
|
708
|
+
width = Math.max(width, options.minWidth);
|
|
709
|
+
return Math.max(1, Math.min(width, bounds.availableWidth));
|
|
710
|
+
}
|
|
711
|
+
resolveOverlayMaxHeight(options, termHeight, bounds) {
|
|
712
|
+
const maxHeight = parseSizeValue(options.maxHeight, termHeight);
|
|
713
|
+
return maxHeight === undefined ? undefined : Math.max(1, Math.min(maxHeight, bounds.availableHeight));
|
|
714
|
+
}
|
|
715
|
+
resolveOverlayRow(options, overlayHeight, bounds) {
|
|
716
|
+
if (options.row === undefined) {
|
|
717
|
+
return this.resolveAnchorRow(options.anchor ?? "center", overlayHeight, bounds.availableHeight, bounds.marginTop);
|
|
718
|
+
}
|
|
719
|
+
if (typeof options.row === "number")
|
|
720
|
+
return options.row;
|
|
721
|
+
const match = options.row.match(/^(\d+(?:\.\d+)?)%$/);
|
|
722
|
+
if (!match)
|
|
723
|
+
return this.resolveAnchorRow("center", overlayHeight, bounds.availableHeight, bounds.marginTop);
|
|
724
|
+
const maxRow = Math.max(0, bounds.availableHeight - overlayHeight);
|
|
725
|
+
return bounds.marginTop + Math.floor(maxRow * (parseFloat(match[1]) / 100));
|
|
726
|
+
}
|
|
727
|
+
resolveOverlayColumn(options, width, bounds) {
|
|
728
|
+
if (options.col === undefined) {
|
|
729
|
+
return this.resolveAnchorCol(options.anchor ?? "center", width, bounds.availableWidth, bounds.marginLeft);
|
|
730
|
+
}
|
|
731
|
+
if (typeof options.col === "number")
|
|
732
|
+
return options.col;
|
|
733
|
+
const match = options.col.match(/^(\d+(?:\.\d+)?)%$/);
|
|
734
|
+
if (!match)
|
|
735
|
+
return this.resolveAnchorCol("center", width, bounds.availableWidth, bounds.marginLeft);
|
|
736
|
+
const maxCol = Math.max(0, bounds.availableWidth - width);
|
|
737
|
+
return bounds.marginLeft + Math.floor(maxCol * (parseFloat(match[1]) / 100));
|
|
738
|
+
}
|
|
739
|
+
/** Resolve overlay layout from options. */
|
|
740
|
+
resolveOverlayLayout(options, overlayHeight, termWidth, termHeight) {
|
|
741
|
+
const resolvedOptions = options ?? {};
|
|
742
|
+
const bounds = this.resolveOverlayBounds(resolvedOptions, termWidth, termHeight);
|
|
743
|
+
const width = this.resolveOverlayWidth(resolvedOptions, termWidth, bounds);
|
|
744
|
+
const maxHeight = this.resolveOverlayMaxHeight(resolvedOptions, termHeight, bounds);
|
|
745
|
+
const effectiveHeight = maxHeight === undefined ? overlayHeight : Math.min(overlayHeight, maxHeight);
|
|
746
|
+
let row = this.resolveOverlayRow(resolvedOptions, effectiveHeight, bounds) + (resolvedOptions.offsetY ?? 0);
|
|
747
|
+
let col = this.resolveOverlayColumn(resolvedOptions, width, bounds) + (resolvedOptions.offsetX ?? 0);
|
|
748
|
+
row = Math.max(bounds.marginTop, Math.min(row, termHeight - bounds.marginBottom - effectiveHeight));
|
|
749
|
+
col = Math.max(bounds.marginLeft, Math.min(col, termWidth - bounds.marginRight - width));
|
|
538
750
|
return { width, row, col, maxHeight };
|
|
539
751
|
}
|
|
540
752
|
resolveAnchorRow(anchor, height, availHeight, marginTop) {
|
|
@@ -569,58 +781,52 @@ export class TUI extends Container {
|
|
|
569
781
|
return marginLeft + Math.floor((availWidth - width) / 2);
|
|
570
782
|
}
|
|
571
783
|
}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
// Pre-render all visible overlays and calculate positions
|
|
578
|
-
const rendered = [];
|
|
579
|
-
let minLinesNeeded = result.length;
|
|
580
|
-
const visibleEntries = this.overlayStack.filter((e) => this.isOverlayVisible(e));
|
|
581
|
-
visibleEntries.sort((a, b) => a.focusOrder - b.focusOrder);
|
|
784
|
+
prepareOverlayRenderPlan(baseLineCount, termWidth, termHeight) {
|
|
785
|
+
const visibleEntries = this.overlayStack.filter((entry) => this.isOverlayVisible(entry));
|
|
786
|
+
visibleEntries.sort((left, right) => left.focusOrder - right.focusOrder);
|
|
787
|
+
const overlays = [];
|
|
788
|
+
let minimumLineCount = baseLineCount;
|
|
582
789
|
for (const entry of visibleEntries) {
|
|
583
|
-
const {
|
|
584
|
-
|
|
585
|
-
// (width and maxHeight don't depend on overlay height)
|
|
586
|
-
const { width, maxHeight } = this.resolveOverlayLayout(options, 0, termWidth, termHeight);
|
|
587
|
-
// Render component at calculated width
|
|
588
|
-
let overlayLines = component.render(width);
|
|
589
|
-
// Apply maxHeight if specified
|
|
790
|
+
const { width, maxHeight } = this.resolveOverlayLayout(entry.options, 0, termWidth, termHeight);
|
|
791
|
+
let overlayLines = entry.component.render(width);
|
|
590
792
|
if (maxHeight !== undefined && overlayLines.length > maxHeight) {
|
|
591
793
|
overlayLines = overlayLines.slice(0, maxHeight);
|
|
592
794
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
|
|
795
|
+
const { row, col } = this.resolveOverlayLayout(entry.options, overlayLines.length, termWidth, termHeight);
|
|
796
|
+
overlays.push({ lines: overlayLines, row, col, width });
|
|
797
|
+
minimumLineCount = Math.max(minimumLineCount, row + overlayLines.length);
|
|
597
798
|
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
799
|
+
return { overlays, minimumLineCount };
|
|
800
|
+
}
|
|
801
|
+
compositeRenderedOverlay(result, overlay, viewportStart, termWidth) {
|
|
802
|
+
for (let lineIndex = 0; lineIndex < overlay.lines.length; lineIndex++) {
|
|
803
|
+
const targetIndex = viewportStart + overlay.row + lineIndex;
|
|
804
|
+
if (targetIndex < 0 || targetIndex >= result.length)
|
|
805
|
+
continue;
|
|
806
|
+
const overlayLine = overlay.lines[lineIndex];
|
|
807
|
+
const truncatedLine = visibleWidth(overlayLine) > overlay.width
|
|
808
|
+
? sliceByColumn(overlayLine, 0, overlay.width, true)
|
|
809
|
+
: overlayLine;
|
|
810
|
+
result[targetIndex] = this.compositeLineAt(result[targetIndex], truncatedLine, overlay.col, overlay.width, termWidth);
|
|
605
811
|
}
|
|
812
|
+
}
|
|
813
|
+
/** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */
|
|
814
|
+
compositeOverlays(lines, termWidth, termHeight) {
|
|
815
|
+
if (this.overlayStack.length === 0)
|
|
816
|
+
return lines;
|
|
817
|
+
const result = [...lines];
|
|
818
|
+
const plan = this.prepareOverlayRenderPlan(result.length, termWidth, termHeight);
|
|
819
|
+
const workingHeight = Math.max(result.length, termHeight, plan.minimumLineCount);
|
|
820
|
+
while (result.length < workingHeight)
|
|
821
|
+
result.push("");
|
|
606
822
|
const viewportStart = Math.max(0, workingHeight - termHeight);
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
for (let i = 0; i < overlayLines.length; i++) {
|
|
610
|
-
const idx = viewportStart + row + i;
|
|
611
|
-
if (idx >= 0 && idx < result.length) {
|
|
612
|
-
// Defensive: truncate overlay line to declared width before compositing
|
|
613
|
-
// (components should already respect width, but this ensures it)
|
|
614
|
-
const truncatedOverlayLine = visibleWidth(overlayLines[i]) > w ? sliceByColumn(overlayLines[i], 0, w, true) : overlayLines[i];
|
|
615
|
-
result[idx] = this.compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth);
|
|
616
|
-
}
|
|
617
|
-
}
|
|
823
|
+
for (const overlay of plan.overlays) {
|
|
824
|
+
this.compositeRenderedOverlay(result, overlay, viewportStart, termWidth);
|
|
618
825
|
}
|
|
619
826
|
return result;
|
|
620
827
|
}
|
|
621
|
-
static SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
|
|
622
828
|
applyLineResets(lines) {
|
|
623
|
-
const reset =
|
|
829
|
+
const reset = SEGMENT_RESET;
|
|
624
830
|
for (let i = 0; i < lines.length; i++) {
|
|
625
831
|
const line = lines[i];
|
|
626
832
|
if (!isImageLine(line)) {
|
|
@@ -629,81 +835,8 @@ export class TUI extends Container {
|
|
|
629
835
|
}
|
|
630
836
|
return lines;
|
|
631
837
|
}
|
|
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
|
-
}
|
|
669
|
-
/** Splice overlay content into a base line at a specific column. Single-pass optimized. */
|
|
670
838
|
compositeLineAt(baseLine, overlayLine, startCol, overlayWidth, totalWidth) {
|
|
671
|
-
|
|
672
|
-
return baseLine;
|
|
673
|
-
// Single pass through baseLine extracts both before and after segments
|
|
674
|
-
const afterStart = startCol + overlayWidth;
|
|
675
|
-
const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
|
|
676
|
-
// Extract overlay with width tracking (strict=true to exclude wide chars at boundary)
|
|
677
|
-
const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
|
|
678
|
-
// Pad segments to target widths
|
|
679
|
-
const beforePad = Math.max(0, startCol - base.beforeWidth);
|
|
680
|
-
const overlayPad = Math.max(0, overlayWidth - overlay.width);
|
|
681
|
-
const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
|
|
682
|
-
const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
|
|
683
|
-
const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
|
|
684
|
-
const afterPad = Math.max(0, afterTarget - base.afterWidth);
|
|
685
|
-
// Compose result
|
|
686
|
-
const r = TUI.SEGMENT_RESET;
|
|
687
|
-
const result = base.before +
|
|
688
|
-
" ".repeat(beforePad) +
|
|
689
|
-
r +
|
|
690
|
-
overlay.text +
|
|
691
|
-
" ".repeat(overlayPad) +
|
|
692
|
-
r +
|
|
693
|
-
base.after +
|
|
694
|
-
" ".repeat(afterPad);
|
|
695
|
-
// CRITICAL: Always verify and truncate to terminal width.
|
|
696
|
-
// This is the final safeguard against width overflow which would crash the TUI.
|
|
697
|
-
// Width tracking can drift from actual visible width due to:
|
|
698
|
-
// - Complex ANSI/OSC sequences (hyperlinks, colors)
|
|
699
|
-
// - Wide characters at segment boundaries
|
|
700
|
-
// - Edge cases in segment extraction
|
|
701
|
-
const resultWidth = visibleWidth(result);
|
|
702
|
-
if (resultWidth <= totalWidth) {
|
|
703
|
-
return result;
|
|
704
|
-
}
|
|
705
|
-
// Truncate with strict=true to ensure we don't exceed totalWidth
|
|
706
|
-
return sliceByColumn(result, 0, totalWidth, true);
|
|
839
|
+
return compositeTuiLine(baseLine, overlayLine, startCol, overlayWidth, totalWidth);
|
|
707
840
|
}
|
|
708
841
|
/**
|
|
709
842
|
* Find and extract cursor position from rendered lines.
|
|
@@ -730,348 +863,5 @@ export class TUI extends Container {
|
|
|
730
863
|
}
|
|
731
864
|
return null;
|
|
732
865
|
}
|
|
733
|
-
doRender() {
|
|
734
|
-
if (this.stopped)
|
|
735
|
-
return;
|
|
736
|
-
const width = this.terminal.columns;
|
|
737
|
-
const height = this.terminal.rows;
|
|
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;
|
|
743
|
-
let hardwareCursorRow = this.hardwareCursorRow;
|
|
744
|
-
const computeLineDiff = (targetRow) => {
|
|
745
|
-
const currentScreenRow = hardwareCursorRow - prevViewportTop;
|
|
746
|
-
const targetScreenRow = targetRow - viewportTop;
|
|
747
|
-
return targetScreenRow - currentScreenRow;
|
|
748
|
-
};
|
|
749
|
-
// Render all components to get new lines
|
|
750
|
-
let newLines = this.render(width);
|
|
751
|
-
// Composite overlays into the rendered lines (before differential compare)
|
|
752
|
-
if (this.overlayStack.length > 0) {
|
|
753
|
-
newLines = this.compositeOverlays(newLines, width, height);
|
|
754
|
-
}
|
|
755
|
-
// Extract cursor position before applying line resets (marker must be found first)
|
|
756
|
-
const cursorPos = this.extractCursorPosition(newLines, height);
|
|
757
|
-
newLines = this.applyLineResets(newLines);
|
|
758
|
-
// Helper to clear scrollback and viewport and render all new lines
|
|
759
|
-
const fullRender = (clear) => {
|
|
760
|
-
this.fullRedrawCount += 1;
|
|
761
|
-
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
|
762
|
-
if (clear) {
|
|
763
|
-
buffer += this.deleteKittyImages(this.previousKittyImageIds);
|
|
764
|
-
buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback
|
|
765
|
-
}
|
|
766
|
-
for (let i = 0; i < newLines.length; i++) {
|
|
767
|
-
if (i > 0)
|
|
768
|
-
buffer += "\r\n";
|
|
769
|
-
buffer += newLines[i];
|
|
770
|
-
}
|
|
771
|
-
buffer += "\x1b[?2026l"; // End synchronized output
|
|
772
|
-
this.terminal.write(buffer);
|
|
773
|
-
this.cursorRow = Math.max(0, newLines.length - 1);
|
|
774
|
-
this.hardwareCursorRow = this.cursorRow;
|
|
775
|
-
// Reset max lines when clearing, otherwise track growth
|
|
776
|
-
if (clear) {
|
|
777
|
-
this.maxLinesRendered = newLines.length;
|
|
778
|
-
}
|
|
779
|
-
else {
|
|
780
|
-
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
|
781
|
-
}
|
|
782
|
-
const bufferLength = Math.max(height, newLines.length);
|
|
783
|
-
this.previousViewportTop = Math.max(0, bufferLength - height);
|
|
784
|
-
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
785
|
-
this.previousLines = newLines;
|
|
786
|
-
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
|
|
787
|
-
this.previousWidth = width;
|
|
788
|
-
this.previousHeight = height;
|
|
789
|
-
};
|
|
790
|
-
const debugRedraw = process.env.PI_DEBUG_REDRAW === "1";
|
|
791
|
-
const logRedraw = (reason) => {
|
|
792
|
-
if (!debugRedraw)
|
|
793
|
-
return;
|
|
794
|
-
const logPath = path.join(os.homedir(), ".pi", "agent", "pi-debug.log");
|
|
795
|
-
const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\n`;
|
|
796
|
-
fs.appendFileSync(logPath, msg);
|
|
797
|
-
};
|
|
798
|
-
// First render - just output everything without clearing (assumes clean screen)
|
|
799
|
-
if (this.previousLines.length === 0 && !widthChanged && !heightChanged) {
|
|
800
|
-
logRedraw("first render");
|
|
801
|
-
fullRender(false);
|
|
802
|
-
return;
|
|
803
|
-
}
|
|
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})`);
|
|
815
|
-
fullRender(true);
|
|
816
|
-
return;
|
|
817
|
-
}
|
|
818
|
-
// Content shrunk below the working area and no overlays - re-render to clear empty rows
|
|
819
|
-
// (overlays need the padding, so only do this when no overlays are active)
|
|
820
|
-
// Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
|
|
821
|
-
if (this.clearOnShrink && newLines.length < this.maxLinesRendered && this.overlayStack.length === 0) {
|
|
822
|
-
logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`);
|
|
823
|
-
fullRender(true);
|
|
824
|
-
return;
|
|
825
|
-
}
|
|
826
|
-
// Find first and last changed lines
|
|
827
|
-
let firstChanged = -1;
|
|
828
|
-
let lastChanged = -1;
|
|
829
|
-
const maxLines = Math.max(newLines.length, this.previousLines.length);
|
|
830
|
-
for (let i = 0; i < maxLines; i++) {
|
|
831
|
-
const oldLine = i < this.previousLines.length ? this.previousLines[i] : "";
|
|
832
|
-
const newLine = i < newLines.length ? newLines[i] : "";
|
|
833
|
-
if (oldLine !== newLine) {
|
|
834
|
-
if (firstChanged === -1) {
|
|
835
|
-
firstChanged = i;
|
|
836
|
-
}
|
|
837
|
-
lastChanged = i;
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
const appendedLines = newLines.length > this.previousLines.length;
|
|
841
|
-
if (appendedLines) {
|
|
842
|
-
if (firstChanged === -1) {
|
|
843
|
-
firstChanged = this.previousLines.length;
|
|
844
|
-
}
|
|
845
|
-
lastChanged = newLines.length - 1;
|
|
846
|
-
}
|
|
847
|
-
if (firstChanged !== -1) {
|
|
848
|
-
lastChanged = this.expandLastChangedForKittyImages(firstChanged, lastChanged);
|
|
849
|
-
}
|
|
850
|
-
const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0;
|
|
851
|
-
// No changes - but still need to update hardware cursor position if it moved
|
|
852
|
-
if (firstChanged === -1) {
|
|
853
|
-
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
854
|
-
this.previousViewportTop = prevViewportTop;
|
|
855
|
-
this.previousHeight = height;
|
|
856
|
-
return;
|
|
857
|
-
}
|
|
858
|
-
// All changes are in deleted lines (nothing to render, just clear)
|
|
859
|
-
if (firstChanged >= newLines.length) {
|
|
860
|
-
if (this.previousLines.length > newLines.length) {
|
|
861
|
-
let buffer = "\x1b[?2026h";
|
|
862
|
-
buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
|
|
863
|
-
// Move to end of new content (clamp to 0 for empty content)
|
|
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
|
-
}
|
|
870
|
-
const lineDiff = computeLineDiff(targetRow);
|
|
871
|
-
if (lineDiff > 0)
|
|
872
|
-
buffer += `\x1b[${lineDiff}B`;
|
|
873
|
-
else if (lineDiff < 0)
|
|
874
|
-
buffer += `\x1b[${-lineDiff}A`;
|
|
875
|
-
buffer += "\r";
|
|
876
|
-
// Clear extra lines without scrolling
|
|
877
|
-
const extraLines = this.previousLines.length - newLines.length;
|
|
878
|
-
if (extraLines > height) {
|
|
879
|
-
logRedraw(`extraLines > height (${extraLines} > ${height})`);
|
|
880
|
-
fullRender(true);
|
|
881
|
-
return;
|
|
882
|
-
}
|
|
883
|
-
if (extraLines > 0) {
|
|
884
|
-
buffer += "\x1b[1B";
|
|
885
|
-
}
|
|
886
|
-
for (let i = 0; i < extraLines; i++) {
|
|
887
|
-
buffer += "\r\x1b[2K";
|
|
888
|
-
if (i < extraLines - 1)
|
|
889
|
-
buffer += "\x1b[1B";
|
|
890
|
-
}
|
|
891
|
-
if (extraLines > 0) {
|
|
892
|
-
buffer += `\x1b[${extraLines}A`;
|
|
893
|
-
}
|
|
894
|
-
buffer += "\x1b[?2026l";
|
|
895
|
-
this.terminal.write(buffer);
|
|
896
|
-
this.cursorRow = targetRow;
|
|
897
|
-
this.hardwareCursorRow = targetRow;
|
|
898
|
-
}
|
|
899
|
-
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
900
|
-
this.previousLines = newLines;
|
|
901
|
-
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
|
|
902
|
-
this.previousWidth = width;
|
|
903
|
-
this.previousHeight = height;
|
|
904
|
-
this.previousViewportTop = prevViewportTop;
|
|
905
|
-
return;
|
|
906
|
-
}
|
|
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})`);
|
|
911
|
-
fullRender(true);
|
|
912
|
-
return;
|
|
913
|
-
}
|
|
914
|
-
// Render from first changed line to end
|
|
915
|
-
// Build buffer with all updates wrapped in synchronized output
|
|
916
|
-
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
|
917
|
-
buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
|
|
918
|
-
const prevViewportBottom = prevViewportTop + height - 1;
|
|
919
|
-
const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
|
|
920
|
-
if (moveTargetRow > prevViewportBottom) {
|
|
921
|
-
const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
|
|
922
|
-
const moveToBottom = height - 1 - currentScreenRow;
|
|
923
|
-
if (moveToBottom > 0) {
|
|
924
|
-
buffer += `\x1b[${moveToBottom}B`;
|
|
925
|
-
}
|
|
926
|
-
const scroll = moveTargetRow - prevViewportBottom;
|
|
927
|
-
buffer += "\r\n".repeat(scroll);
|
|
928
|
-
prevViewportTop += scroll;
|
|
929
|
-
viewportTop += scroll;
|
|
930
|
-
hardwareCursorRow = moveTargetRow;
|
|
931
|
-
}
|
|
932
|
-
// Move cursor to first changed line (use hardwareCursorRow for actual position)
|
|
933
|
-
const lineDiff = computeLineDiff(moveTargetRow);
|
|
934
|
-
if (lineDiff > 0) {
|
|
935
|
-
buffer += `\x1b[${lineDiff}B`; // Move down
|
|
936
|
-
}
|
|
937
|
-
else if (lineDiff < 0) {
|
|
938
|
-
buffer += `\x1b[${-lineDiff}A`; // Move up
|
|
939
|
-
}
|
|
940
|
-
buffer += appendStart ? "\r\n" : "\r"; // Move to column 0
|
|
941
|
-
// Only render changed lines (firstChanged to lastChanged), not all lines to end
|
|
942
|
-
// This reduces flicker when only a single line changes (e.g., spinner animation)
|
|
943
|
-
const renderEnd = Math.min(lastChanged, newLines.length - 1);
|
|
944
|
-
for (let i = firstChanged; i <= renderEnd; i++) {
|
|
945
|
-
if (i > firstChanged)
|
|
946
|
-
buffer += "\r\n";
|
|
947
|
-
buffer += "\x1b[2K"; // Clear current line
|
|
948
|
-
const line = newLines[i];
|
|
949
|
-
const isImage = isImageLine(line);
|
|
950
|
-
if (!isImage && visibleWidth(line) > width) {
|
|
951
|
-
// Log all lines to crash file for debugging
|
|
952
|
-
const crashLogPath = path.join(os.homedir(), ".pi", "agent", "pi-crash.log");
|
|
953
|
-
const crashData = [
|
|
954
|
-
`Crash at ${new Date().toISOString()}`,
|
|
955
|
-
`Terminal width: ${width}`,
|
|
956
|
-
`Line ${i} visible width: ${visibleWidth(line)}`,
|
|
957
|
-
"",
|
|
958
|
-
"=== All rendered lines ===",
|
|
959
|
-
...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),
|
|
960
|
-
"",
|
|
961
|
-
].join("\n");
|
|
962
|
-
fs.mkdirSync(path.dirname(crashLogPath), { recursive: true });
|
|
963
|
-
fs.writeFileSync(crashLogPath, crashData);
|
|
964
|
-
// Clean up terminal state before throwing
|
|
965
|
-
this.stop();
|
|
966
|
-
const errorMsg = [
|
|
967
|
-
`Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,
|
|
968
|
-
"",
|
|
969
|
-
"This is likely caused by a custom TUI component not truncating its output.",
|
|
970
|
-
"Use visibleWidth() to measure and truncateToWidth() to truncate lines.",
|
|
971
|
-
"",
|
|
972
|
-
`Debug log written to: ${crashLogPath}`,
|
|
973
|
-
].join("\n");
|
|
974
|
-
throw new Error(errorMsg);
|
|
975
|
-
}
|
|
976
|
-
buffer += line;
|
|
977
|
-
}
|
|
978
|
-
// Track where cursor ended up after rendering
|
|
979
|
-
let finalCursorRow = renderEnd;
|
|
980
|
-
// If we had more lines before, clear them and move cursor back
|
|
981
|
-
if (this.previousLines.length > newLines.length) {
|
|
982
|
-
// Move to end of new content first if we stopped before it
|
|
983
|
-
if (renderEnd < newLines.length - 1) {
|
|
984
|
-
const moveDown = newLines.length - 1 - renderEnd;
|
|
985
|
-
buffer += `\x1b[${moveDown}B`;
|
|
986
|
-
finalCursorRow = newLines.length - 1;
|
|
987
|
-
}
|
|
988
|
-
const extraLines = this.previousLines.length - newLines.length;
|
|
989
|
-
for (let i = newLines.length; i < this.previousLines.length; i++) {
|
|
990
|
-
buffer += "\r\n\x1b[2K";
|
|
991
|
-
}
|
|
992
|
-
// Move cursor back to end of new content
|
|
993
|
-
buffer += `\x1b[${extraLines}A`;
|
|
994
|
-
}
|
|
995
|
-
buffer += "\x1b[?2026l"; // End synchronized output
|
|
996
|
-
if (process.env.PI_TUI_DEBUG === "1") {
|
|
997
|
-
const debugDir = "/tmp/tui";
|
|
998
|
-
fs.mkdirSync(debugDir, { recursive: true });
|
|
999
|
-
const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
|
|
1000
|
-
const debugData = [
|
|
1001
|
-
`firstChanged: ${firstChanged}`,
|
|
1002
|
-
`viewportTop: ${viewportTop}`,
|
|
1003
|
-
`cursorRow: ${this.cursorRow}`,
|
|
1004
|
-
`height: ${height}`,
|
|
1005
|
-
`lineDiff: ${lineDiff}`,
|
|
1006
|
-
`hardwareCursorRow: ${hardwareCursorRow}`,
|
|
1007
|
-
`renderEnd: ${renderEnd}`,
|
|
1008
|
-
`finalCursorRow: ${finalCursorRow}`,
|
|
1009
|
-
`cursorPos: ${JSON.stringify(cursorPos)}`,
|
|
1010
|
-
`newLines.length: ${newLines.length}`,
|
|
1011
|
-
`previousLines.length: ${this.previousLines.length}`,
|
|
1012
|
-
"",
|
|
1013
|
-
"=== newLines ===",
|
|
1014
|
-
JSON.stringify(newLines, null, 2),
|
|
1015
|
-
"",
|
|
1016
|
-
"=== previousLines ===",
|
|
1017
|
-
JSON.stringify(this.previousLines, null, 2),
|
|
1018
|
-
"",
|
|
1019
|
-
"=== buffer ===",
|
|
1020
|
-
JSON.stringify(buffer),
|
|
1021
|
-
].join("\n");
|
|
1022
|
-
fs.writeFileSync(debugPath, debugData);
|
|
1023
|
-
}
|
|
1024
|
-
// Write entire buffer at once
|
|
1025
|
-
this.terminal.write(buffer);
|
|
1026
|
-
// Track cursor position for next render
|
|
1027
|
-
// cursorRow tracks end of content (for viewport calculation)
|
|
1028
|
-
// hardwareCursorRow tracks actual terminal cursor position (for movement)
|
|
1029
|
-
this.cursorRow = Math.max(0, newLines.length - 1);
|
|
1030
|
-
this.hardwareCursorRow = finalCursorRow;
|
|
1031
|
-
// Track terminal's working area (grows but doesn't shrink unless cleared)
|
|
1032
|
-
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
|
1033
|
-
this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1);
|
|
1034
|
-
// Position hardware cursor for IME
|
|
1035
|
-
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
1036
|
-
this.previousLines = newLines;
|
|
1037
|
-
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
|
|
1038
|
-
this.previousWidth = width;
|
|
1039
|
-
this.previousHeight = height;
|
|
1040
|
-
}
|
|
1041
|
-
/**
|
|
1042
|
-
* Position the hardware cursor for IME candidate window.
|
|
1043
|
-
* @param cursorPos The cursor position extracted from rendered output, or null
|
|
1044
|
-
* @param totalLines Total number of rendered lines
|
|
1045
|
-
*/
|
|
1046
|
-
positionHardwareCursor(cursorPos, totalLines) {
|
|
1047
|
-
if (!cursorPos || totalLines <= 0) {
|
|
1048
|
-
this.terminal.hideCursor();
|
|
1049
|
-
return;
|
|
1050
|
-
}
|
|
1051
|
-
// Clamp cursor position to valid range
|
|
1052
|
-
const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));
|
|
1053
|
-
const targetCol = Math.max(0, cursorPos.col);
|
|
1054
|
-
// Move cursor from current position to target
|
|
1055
|
-
const rowDelta = targetRow - this.hardwareCursorRow;
|
|
1056
|
-
let buffer = "";
|
|
1057
|
-
if (rowDelta > 0) {
|
|
1058
|
-
buffer += `\x1b[${rowDelta}B`; // Move down
|
|
1059
|
-
}
|
|
1060
|
-
else if (rowDelta < 0) {
|
|
1061
|
-
buffer += `\x1b[${-rowDelta}A`; // Move up
|
|
1062
|
-
}
|
|
1063
|
-
// Move to absolute column (1-indexed)
|
|
1064
|
-
buffer += `\x1b[${targetCol + 1}G`;
|
|
1065
|
-
if (buffer) {
|
|
1066
|
-
this.terminal.write(buffer);
|
|
1067
|
-
}
|
|
1068
|
-
this.hardwareCursorRow = targetRow;
|
|
1069
|
-
if (this.showHardwareCursor) {
|
|
1070
|
-
this.terminal.showCursor();
|
|
1071
|
-
}
|
|
1072
|
-
else {
|
|
1073
|
-
this.terminal.hideCursor();
|
|
1074
|
-
}
|
|
1075
|
-
}
|
|
1076
866
|
}
|
|
1077
867
|
//# sourceMappingURL=tui.js.map
|