@fractaal/pi-tui 0.85.3 → 0.86.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 +175 -19
- package/dist/alt-screen-search.d.ts +46 -0
- package/dist/alt-screen-search.d.ts.map +1 -0
- package/dist/alt-screen-search.js +282 -0
- package/dist/alt-screen-search.js.map +1 -0
- package/dist/autocomplete.d.ts +2 -1
- package/dist/autocomplete.d.ts.map +1 -1
- package/dist/autocomplete.js +85 -24
- package/dist/autocomplete.js.map +1 -1
- package/dist/colors.d.ts +73 -0
- package/dist/colors.d.ts.map +1 -0
- package/dist/colors.js +273 -0
- package/dist/colors.js.map +1 -0
- 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/box.d.ts +3 -1
- package/dist/components/box.d.ts.map +1 -1
- package/dist/components/box.js +38 -8
- package/dist/components/box.js.map +1 -1
- package/dist/components/cancellable-loader.d.ts.map +1 -1
- package/dist/components/cancellable-loader.js.map +1 -1
- package/dist/components/editor.d.ts +6 -1
- package/dist/components/editor.d.ts.map +1 -1
- package/dist/components/editor.js +118 -24
- 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 +12 -0
- package/dist/components/image.d.ts.map +1 -1
- package/dist/components/image.js +36 -3
- package/dist/components/image.js.map +1 -1
- package/dist/components/input.d.ts +12 -1
- package/dist/components/input.d.ts.map +1 -1
- package/dist/components/input.js +44 -5
- package/dist/components/input.js.map +1 -1
- package/dist/components/loader.d.ts +2 -0
- package/dist/components/loader.d.ts.map +1 -1
- package/dist/components/loader.js +10 -3
- package/dist/components/loader.js.map +1 -1
- package/dist/components/markdown.d.ts +5 -0
- package/dist/components/markdown.d.ts.map +1 -1
- package/dist/components/markdown.js +150 -9
- package/dist/components/markdown.js.map +1 -1
- package/dist/components/mouse-region.d.ts +12 -0
- package/dist/components/mouse-region.d.ts.map +1 -0
- package/dist/components/mouse-region.js +21 -0
- package/dist/components/mouse-region.js.map +1 -0
- package/dist/components/scroll-view.d.ts +59 -0
- package/dist/components/scroll-view.d.ts.map +1 -0
- package/dist/components/scroll-view.js +195 -0
- package/dist/components/scroll-view.js.map +1 -0
- package/dist/components/select-list.d.ts +4 -1
- package/dist/components/select-list.d.ts.map +1 -1
- package/dist/components/select-list.js +49 -2
- package/dist/components/select-list.js.map +1 -1
- package/dist/components/settings-list.d.ts +13 -3
- package/dist/components/settings-list.d.ts.map +1 -1
- package/dist/components/settings-list.js +82 -15
- package/dist/components/settings-list.js.map +1 -1
- package/dist/components/spacer.d.ts.map +1 -1
- package/dist/components/spacer.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 +111 -0
- package/dist/components/stack.js.map +1 -0
- package/dist/components/text.d.ts.map +1 -1
- package/dist/components/text.js +7 -5
- package/dist/components/text.js.map +1 -1
- package/dist/components/truncated-text.d.ts.map +1 -1
- package/dist/components/truncated-text.js.map +1 -1
- 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 +26 -0
- package/dist/components/v-stack.js.map +1 -0
- package/dist/editor-component.d.ts.map +1 -1
- package/dist/fuzzy.d.ts.map +1 -1
- package/dist/fuzzy.js +23 -22
- package/dist/fuzzy.js.map +1 -1
- package/dist/index.d.ts +18 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -6
- package/dist/index.js.map +1 -1
- package/dist/keybindings.d.ts +82 -2
- package/dist/keybindings.d.ts.map +1 -1
- package/dist/keybindings.js +61 -2
- package/dist/keybindings.js.map +1 -1
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js.map +1 -1
- package/dist/kill-ring.d.ts.map +1 -1
- 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 +1378 -0
- package/dist/latex.js.map +1 -0
- package/dist/layout-node.d.ts +40 -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 +42 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +344 -0
- package/dist/layout.js.map +1 -0
- package/dist/native-modifiers.d.ts +2 -1
- package/dist/native-modifiers.d.ts.map +1 -1
- package/dist/native-modifiers.js +3 -43
- package/dist/native-modifiers.js.map +1 -1
- package/dist/native-module-path.d.ts +7 -0
- package/dist/native-module-path.d.ts.map +1 -0
- package/dist/native-module-path.js +19 -0
- package/dist/native-module-path.js.map +1 -0
- package/dist/native-platform.d.ts +20 -0
- package/dist/native-platform.d.ts.map +1 -0
- package/dist/native-platform.js +42 -0
- package/dist/native-platform.js.map +1 -0
- package/dist/oklab.d.ts +43 -0
- package/dist/oklab.d.ts.map +1 -0
- package/dist/oklab.js +210 -0
- package/dist/oklab.js.map +1 -0
- package/dist/stdin-buffer.d.ts +8 -2
- package/dist/stdin-buffer.d.ts.map +1 -1
- package/dist/stdin-buffer.js +7 -2
- package/dist/stdin-buffer.js.map +1 -1
- package/dist/terminal-colors.d.ts +19 -2
- package/dist/terminal-colors.d.ts.map +1 -1
- package/dist/terminal-colors.js +13 -8
- package/dist/terminal-colors.js.map +1 -1
- package/dist/terminal-image.d.ts +29 -1
- package/dist/terminal-image.d.ts.map +1 -1
- package/dist/terminal-image.js +223 -18
- package/dist/terminal-image.js.map +1 -1
- package/dist/terminal.d.ts +16 -0
- package/dist/terminal.d.ts.map +1 -1
- package/dist/terminal.js +62 -46
- package/dist/terminal.js.map +1 -1
- package/dist/tui-alt-screen.d.ts +166 -0
- package/dist/tui-alt-screen.d.ts.map +1 -0
- package/dist/tui-alt-screen.js +1546 -0
- package/dist/tui-alt-screen.js.map +1 -0
- package/dist/tui-main-screen.d.ts +39 -0
- package/dist/tui-main-screen.d.ts.map +1 -0
- package/dist/tui-main-screen.js +594 -0
- package/dist/tui-main-screen.js.map +1 -0
- package/dist/tui.d.ts +178 -60
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +303 -641
- package/dist/tui.js.map +1 -1
- package/dist/undo-stack.d.ts.map +1 -1
- package/dist/undo-stack.js.map +1 -1
- package/dist/utils.d.ts +22 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +245 -63
- package/dist/utils.js.map +1 -1
- package/dist/wheel-scroll.d.ts +23 -0
- package/dist/wheel-scroll.d.ts.map +1 -0
- package/dist/wheel-scroll.js +73 -0
- package/dist/wheel-scroll.js.map +1 -0
- package/dist/word-navigation.d.ts.map +1 -1
- package/dist/word-navigation.js.map +1 -1
- package/native/clipboard.h +208 -0
- package/native/darwin/README.md +22 -0
- package/native/darwin/build.sh +69 -0
- package/native/darwin/prebuilds/darwin-arm64/{darwin-modifiers.node → darwin-platform.node} +0 -0
- package/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
- package/native/darwin/src/darwin-platform.m +132 -0
- package/native/linux/README.md +23 -0
- package/native/linux/build.sh +65 -0
- package/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
- package/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
- package/native/linux/src/clipboard-worker.h +70 -0
- package/native/linux/src/linux-platform-x11.c +274 -0
- package/native/napi.h +91 -0
- package/native/win32/README.md +31 -0
- package/native/win32/build.mjs +232 -0
- package/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
- package/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
- package/native/win32/src/win32-platform.c +241 -0
- package/package.json +19 -4
- package/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
- package/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
- package/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
package/dist/tui.js
CHANGED
|
@@ -1,48 +1,61 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Minimal TUI implementation with differential rendering
|
|
3
3
|
*/
|
|
4
|
-
import * as fs from "node:fs";
|
|
5
|
-
import * as os from "node:os";
|
|
6
|
-
import * as path from "node:path";
|
|
7
4
|
import { performance } from "node:perf_hooks";
|
|
8
5
|
import { isKeyRelease, matchesKey } from "./keys.js";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
6
|
+
import { parseOscColorResponse, parseTerminalColorSchemeReport, } from "./terminal-colors.js";
|
|
7
|
+
import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
|
|
11
8
|
import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
if (paramsEnd === -1)
|
|
9
|
+
/**
|
|
10
|
+
* Dispatch an event to a component and retain the exact target and coordinate
|
|
11
|
+
* transform. Containers use this when forwarding events to nested children.
|
|
12
|
+
*/
|
|
13
|
+
export function dispatchMouseEvent(component, event) {
|
|
14
|
+
const result = component.handleMouse?.(event);
|
|
15
|
+
if (!result)
|
|
20
16
|
return undefined;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
continue;
|
|
28
|
-
const numberValue = Number(value);
|
|
29
|
-
if (!Number.isInteger(numberValue) || numberValue <= 0 || numberValue > 0xffffffff)
|
|
30
|
-
continue;
|
|
31
|
-
if (key === "i") {
|
|
32
|
-
ids.push(numberValue);
|
|
33
|
-
}
|
|
34
|
-
else if (key === "r") {
|
|
35
|
-
rows = numberValue;
|
|
36
|
-
}
|
|
17
|
+
if ("target" in result) {
|
|
18
|
+
// The component forwarded the event to a child it hosts. Like a delegating container, it routes
|
|
19
|
+
// keys to that child itself, so it keeps keyboard focus. Focusing the child directly would leave
|
|
20
|
+
// focus on a detached component once the host removes it, e.g. a closed settings submenu.
|
|
21
|
+
const forwarded = result;
|
|
22
|
+
return forwarded.focus && component.handleInput ? { ...forwarded, focusTarget: component } : forwarded;
|
|
37
23
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
24
|
+
if (!result.handled && !result.capture && !result.focus)
|
|
25
|
+
return undefined;
|
|
26
|
+
return {
|
|
27
|
+
...result,
|
|
28
|
+
handled: true,
|
|
29
|
+
...(result.focus ? { focusTarget: component } : {}),
|
|
30
|
+
target: {
|
|
31
|
+
component,
|
|
32
|
+
originX: event.screenX - event.x,
|
|
33
|
+
originY: event.screenY - event.y,
|
|
34
|
+
width: event.width,
|
|
35
|
+
height: event.height,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
42
38
|
}
|
|
43
|
-
|
|
44
|
-
|
|
39
|
+
/** Recreate local coordinates for a previously dispatched mouse target. */
|
|
40
|
+
export function retargetMouseEvent(event, target) {
|
|
41
|
+
return {
|
|
42
|
+
...event,
|
|
43
|
+
x: event.screenX - target.originX,
|
|
44
|
+
y: event.screenY - target.originY,
|
|
45
|
+
width: target.width,
|
|
46
|
+
height: target.height,
|
|
47
|
+
};
|
|
45
48
|
}
|
|
49
|
+
const TERMINAL_PALETTE_SIZE = 16;
|
|
50
|
+
/** OSC 10 and 11 plus OSC 4 for every palette color. */
|
|
51
|
+
const TERMINAL_COLOR_REPLY_COUNT = 2 + TERMINAL_PALETTE_SIZE;
|
|
52
|
+
/**
|
|
53
|
+
* Default colors, palette colors 0-15, and a trailing primary device attributes (DA1) request.
|
|
54
|
+
* Every terminal answers DA1 and terminals answer in order, so the DA1 reply marks the end of
|
|
55
|
+
* the color replies, including for terminals that ignore the color queries.
|
|
56
|
+
*/
|
|
57
|
+
const TERMINAL_COLOR_QUERY = `\x1b]10;?\x07\x1b]11;?\x07${Array.from({ length: TERMINAL_PALETTE_SIZE }, (_, index) => `\x1b]4;${index};?\x07`).join("")}\x1b[c`;
|
|
58
|
+
const DEVICE_ATTRIBUTES_RESPONSE_PATTERN = /^\x1b\[\?[\d;]*c$/;
|
|
46
59
|
/** Type guard to check if a component implements Focusable */
|
|
47
60
|
export function isFocusable(component) {
|
|
48
61
|
return component !== null && "focused" in component;
|
|
@@ -68,14 +81,12 @@ function parseSizeValue(value, referenceSize) {
|
|
|
68
81
|
}
|
|
69
82
|
return undefined;
|
|
70
83
|
}
|
|
71
|
-
function isTermuxSession() {
|
|
72
|
-
return Boolean(process.env.TERMUX_VERSION);
|
|
73
|
-
}
|
|
74
84
|
/**
|
|
75
85
|
* Container - a component that contains other components
|
|
76
86
|
*/
|
|
77
87
|
export class Container {
|
|
78
88
|
children = [];
|
|
89
|
+
mouseLayout;
|
|
79
90
|
addChild(component) {
|
|
80
91
|
this.children.push(component);
|
|
81
92
|
}
|
|
@@ -93,59 +104,118 @@ export class Container {
|
|
|
93
104
|
child.invalidate?.();
|
|
94
105
|
}
|
|
95
106
|
}
|
|
107
|
+
handleMouse(event) {
|
|
108
|
+
if (event.y < 0 || event.y >= event.height)
|
|
109
|
+
return undefined;
|
|
110
|
+
const mouseChildren = this.mouseLayout?.width === event.width
|
|
111
|
+
? this.mouseLayout.children
|
|
112
|
+
: this.children.map((component) => ({ component, height: component.render(event.width).length }));
|
|
113
|
+
let childY = 0;
|
|
114
|
+
for (const { component: child, height: childHeight } of mouseChildren) {
|
|
115
|
+
if (event.y >= childY && event.y < childY + childHeight) {
|
|
116
|
+
const result = dispatchMouseEvent(child, {
|
|
117
|
+
...event,
|
|
118
|
+
y: event.y - childY,
|
|
119
|
+
height: childHeight,
|
|
120
|
+
});
|
|
121
|
+
if (result?.focus && this.handleInput)
|
|
122
|
+
return { ...result, focusTarget: this };
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
childY += childHeight;
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
96
129
|
render(width) {
|
|
97
130
|
const lines = [];
|
|
131
|
+
const mouseChildren = [];
|
|
98
132
|
for (const child of this.children) {
|
|
99
133
|
const childLines = child.render(width);
|
|
134
|
+
mouseChildren.push({ component: child, height: childLines.length });
|
|
100
135
|
for (const line of childLines) {
|
|
101
136
|
lines.push(line);
|
|
102
137
|
}
|
|
103
138
|
}
|
|
139
|
+
this.mouseLayout = { width, children: mouseChildren };
|
|
104
140
|
return lines;
|
|
105
141
|
}
|
|
106
142
|
}
|
|
107
143
|
/**
|
|
108
144
|
* TUI - Main class for managing terminal UI with differential rendering
|
|
109
145
|
*/
|
|
110
|
-
|
|
146
|
+
const SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
|
|
147
|
+
/** Composite overlay content into a terminal line at a fixed column. */
|
|
148
|
+
export function compositeTuiLine(baseLine, overlayLine, startCol, overlayWidth, totalWidth) {
|
|
149
|
+
if (isImageLine(baseLine))
|
|
150
|
+
return baseLine;
|
|
151
|
+
const afterStart = startCol + overlayWidth;
|
|
152
|
+
const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
|
|
153
|
+
const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
|
|
154
|
+
const beforePad = Math.max(0, startCol - base.beforeWidth);
|
|
155
|
+
const overlayPad = Math.max(0, overlayWidth - overlay.width);
|
|
156
|
+
const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
|
|
157
|
+
const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
|
|
158
|
+
const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
|
|
159
|
+
const afterPad = Math.max(0, afterTarget - base.afterWidth);
|
|
160
|
+
const result = base.before +
|
|
161
|
+
" ".repeat(beforePad) +
|
|
162
|
+
SEGMENT_RESET +
|
|
163
|
+
overlay.text +
|
|
164
|
+
" ".repeat(overlayPad) +
|
|
165
|
+
SEGMENT_RESET +
|
|
166
|
+
base.after +
|
|
167
|
+
" ".repeat(afterPad);
|
|
168
|
+
return visibleWidth(result) <= totalWidth ? result : sliceByColumn(result, 0, totalWidth, true);
|
|
169
|
+
}
|
|
170
|
+
export const VIEWPORT_TUI = Symbol.for("@earendil-works/pi-tui/viewport");
|
|
171
|
+
export function isViewportTUI(tui) {
|
|
172
|
+
return tui[VIEWPORT_TUI] === true;
|
|
173
|
+
}
|
|
174
|
+
export class TuiBase extends Container {
|
|
111
175
|
terminal;
|
|
112
|
-
previousLines = [];
|
|
113
|
-
previousKittyImageIds = new Set();
|
|
114
|
-
previousWidth = 0;
|
|
115
|
-
previousHeight = 0;
|
|
116
176
|
focusedComponent = null;
|
|
117
177
|
inputListeners = new Set();
|
|
118
178
|
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
|
|
119
179
|
onDebug;
|
|
120
180
|
renderRequested = false;
|
|
181
|
+
immediateRenderScheduled = false;
|
|
121
182
|
renderTimer;
|
|
122
183
|
lastRenderAt = 0;
|
|
123
184
|
static MIN_RENDER_INTERVAL_MS = 16;
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";
|
|
127
|
-
clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off)
|
|
128
|
-
maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
|
|
129
|
-
previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
|
|
185
|
+
showHardwareCursor = false;
|
|
186
|
+
clearOnShrink = false;
|
|
130
187
|
fullRedrawCount = 0;
|
|
131
188
|
stopped = false;
|
|
132
|
-
|
|
133
|
-
|
|
189
|
+
/**
|
|
190
|
+
* Color queries waiting for their DA1 reply, oldest first. Terminals answer in order, so color
|
|
191
|
+
* replies belong to the oldest one. Queries stay here after a timeout to collect late replies.
|
|
192
|
+
*/
|
|
193
|
+
pendingTerminalColorQueries = [];
|
|
134
194
|
terminalColorSchemeListeners = new Set();
|
|
135
195
|
terminalColorSchemeNotificationsEnabled = false;
|
|
196
|
+
/** Directory for debug/crash logs. When undefined, debug logging is disabled and crash dumps fall back to the OS temp directory. */
|
|
136
197
|
logDirectory;
|
|
137
198
|
// Overlay stack for modal components rendered on top of base content
|
|
138
199
|
focusOrderCounter = 0;
|
|
139
200
|
overlayStack = [];
|
|
201
|
+
renderedOverlayLayouts = [];
|
|
202
|
+
get hasOverlayEntries() {
|
|
203
|
+
return this.overlayStack.length > 0;
|
|
204
|
+
}
|
|
140
205
|
overlayFocusRestore = { status: "inactive" };
|
|
141
206
|
constructor(terminal, showHardwareCursor, logDirectory) {
|
|
142
207
|
super();
|
|
143
208
|
this.terminal = terminal;
|
|
144
|
-
this.logDirectory = logDirectory
|
|
209
|
+
this.logDirectory = logDirectory;
|
|
145
210
|
if (showHardwareCursor !== undefined) {
|
|
146
211
|
this.showHardwareCursor = showHardwareCursor;
|
|
147
212
|
}
|
|
148
213
|
}
|
|
214
|
+
resetRenderState() { }
|
|
215
|
+
beforeTerminalStart() { }
|
|
216
|
+
afterTerminalStart() { }
|
|
217
|
+
beforeTerminalStop(_options) { }
|
|
218
|
+
afterTerminalStop(_options) { }
|
|
149
219
|
get fullRedraws() {
|
|
150
220
|
return this.fullRedrawCount;
|
|
151
221
|
}
|
|
@@ -157,7 +227,7 @@ export class TUI extends Container {
|
|
|
157
227
|
return;
|
|
158
228
|
this.showHardwareCursor = enabled;
|
|
159
229
|
if (!enabled) {
|
|
160
|
-
this.
|
|
230
|
+
this.hideTerminalCursor();
|
|
161
231
|
}
|
|
162
232
|
this.requestRender();
|
|
163
233
|
}
|
|
@@ -166,12 +236,15 @@ export class TUI extends Container {
|
|
|
166
236
|
}
|
|
167
237
|
/**
|
|
168
238
|
* Set whether to trigger full re-render when content shrinks.
|
|
169
|
-
* When true
|
|
170
|
-
* When false, empty rows remain (reduces redraws on slower terminals).
|
|
239
|
+
* When true, empty rows are cleared when content shrinks.
|
|
240
|
+
* When false (default), empty rows remain (reduces redraws on slower terminals).
|
|
171
241
|
*/
|
|
172
242
|
setClearOnShrink(enabled) {
|
|
173
243
|
this.clearOnShrink = enabled;
|
|
174
244
|
}
|
|
245
|
+
getFocusedComponent() {
|
|
246
|
+
return this.focusedComponent;
|
|
247
|
+
}
|
|
175
248
|
setFocus(component) {
|
|
176
249
|
this.setFocusInternal({ component, overlayFocusRestore: "clear" });
|
|
177
250
|
}
|
|
@@ -272,8 +345,11 @@ export class TUI extends Container {
|
|
|
272
345
|
}
|
|
273
346
|
}
|
|
274
347
|
}
|
|
348
|
+
getMountedRoots() {
|
|
349
|
+
return this.children;
|
|
350
|
+
}
|
|
275
351
|
isComponentMounted(component) {
|
|
276
|
-
return this.
|
|
352
|
+
return this.getMountedRoots().some((child) => this.containsComponent(child, component));
|
|
277
353
|
}
|
|
278
354
|
containsComponent(root, target) {
|
|
279
355
|
if (root === target)
|
|
@@ -299,7 +375,7 @@ export class TUI extends Container {
|
|
|
299
375
|
if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
|
|
300
376
|
this.setFocus(component);
|
|
301
377
|
}
|
|
302
|
-
this.
|
|
378
|
+
this.hideTerminalCursor();
|
|
303
379
|
this.requestRender();
|
|
304
380
|
// Return handle for controlling this overlay
|
|
305
381
|
return {
|
|
@@ -315,7 +391,7 @@ export class TUI extends Container {
|
|
|
315
391
|
this.setFocus(topVisible?.component ?? entry.preFocus);
|
|
316
392
|
}
|
|
317
393
|
if (this.overlayStack.length === 0)
|
|
318
|
-
this.
|
|
394
|
+
this.hideTerminalCursor();
|
|
319
395
|
this.requestRender();
|
|
320
396
|
}
|
|
321
397
|
},
|
|
@@ -381,6 +457,11 @@ export class TUI extends Container {
|
|
|
381
457
|
this.requestRender();
|
|
382
458
|
},
|
|
383
459
|
isFocused: () => this.focusedComponent === component,
|
|
460
|
+
getBounds: () => {
|
|
461
|
+
if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry) || !entry.bounds)
|
|
462
|
+
return undefined;
|
|
463
|
+
return { ...entry.bounds };
|
|
464
|
+
},
|
|
384
465
|
};
|
|
385
466
|
}
|
|
386
467
|
/** Hide the topmost overlay and restore previous focus. */
|
|
@@ -397,13 +478,58 @@ export class TUI extends Container {
|
|
|
397
478
|
this.setFocus(topVisible?.component ?? overlay.preFocus);
|
|
398
479
|
}
|
|
399
480
|
if (this.overlayStack.length === 0)
|
|
400
|
-
this.
|
|
481
|
+
this.hideTerminalCursor();
|
|
401
482
|
this.requestRender();
|
|
402
483
|
}
|
|
484
|
+
/** Hide the cursor while running. After stop(), the shell owns the cursor and it must stay visible. */
|
|
485
|
+
hideTerminalCursor() {
|
|
486
|
+
if (!this.stopped)
|
|
487
|
+
this.terminal.hideCursor();
|
|
488
|
+
}
|
|
403
489
|
/** Check if there are any visible overlays */
|
|
404
490
|
hasOverlay() {
|
|
405
491
|
return this.overlayStack.some((o) => this.isOverlayVisible(o));
|
|
406
492
|
}
|
|
493
|
+
/** Check if the focused component is a visible overlay */
|
|
494
|
+
isOverlayFocused() {
|
|
495
|
+
return this.overlayStack.some((entry) => entry.component === this.focusedComponent && this.isOverlayVisible(entry));
|
|
496
|
+
}
|
|
497
|
+
/** Keep overlay containers as keyboard focus owners when a nested control is clicked. */
|
|
498
|
+
resolveMouseFocusTarget(component) {
|
|
499
|
+
for (let index = this.overlayStack.length - 1; index >= 0; index--) {
|
|
500
|
+
const overlay = this.overlayStack[index];
|
|
501
|
+
if (this.isOverlayVisible(overlay) && this.containsComponent(overlay.component, component)) {
|
|
502
|
+
return overlay.component;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return component;
|
|
506
|
+
}
|
|
507
|
+
/** Dispatch to the visually topmost overlay under the pointer. */
|
|
508
|
+
dispatchMouseToOverlay(event) {
|
|
509
|
+
for (let index = this.renderedOverlayLayouts.length - 1; index >= 0; index--) {
|
|
510
|
+
const layout = this.renderedOverlayLayouts[index];
|
|
511
|
+
if (event.screenX < layout.col ||
|
|
512
|
+
event.screenX >= layout.col + layout.width ||
|
|
513
|
+
event.screenY < layout.row ||
|
|
514
|
+
event.screenY >= layout.row + layout.height) {
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
const result = dispatchMouseEvent(layout.entry.component, {
|
|
518
|
+
...event,
|
|
519
|
+
x: event.screenX - layout.col,
|
|
520
|
+
y: event.screenY - layout.row,
|
|
521
|
+
width: layout.width,
|
|
522
|
+
height: layout.height,
|
|
523
|
+
});
|
|
524
|
+
return result
|
|
525
|
+
? {
|
|
526
|
+
hit: true,
|
|
527
|
+
result: result.focus ? { ...result, focusTarget: layout.entry.component } : result,
|
|
528
|
+
}
|
|
529
|
+
: { hit: true };
|
|
530
|
+
}
|
|
531
|
+
return { hit: false };
|
|
532
|
+
}
|
|
407
533
|
/** Check if an overlay entry is currently visible */
|
|
408
534
|
isOverlayVisible(entry) {
|
|
409
535
|
if (entry.hidden)
|
|
@@ -426,13 +552,16 @@ export class TUI extends Container {
|
|
|
426
552
|
return topmost;
|
|
427
553
|
}
|
|
428
554
|
invalidate() {
|
|
429
|
-
|
|
555
|
+
for (const root of this.getMountedRoots())
|
|
556
|
+
root.invalidate();
|
|
430
557
|
for (const overlay of this.overlayStack)
|
|
431
|
-
overlay.component.invalidate
|
|
558
|
+
overlay.component.invalidate();
|
|
432
559
|
}
|
|
433
560
|
start() {
|
|
434
561
|
this.stopped = false;
|
|
435
|
-
this.
|
|
562
|
+
this.beforeTerminalStart();
|
|
563
|
+
this.terminal.start((data) => this.handleTerminalInput(data), () => this.requestRender());
|
|
564
|
+
this.afterTerminalStart();
|
|
436
565
|
this.terminal.hideCursor();
|
|
437
566
|
if (this.terminalColorSchemeNotificationsEnabled) {
|
|
438
567
|
this.terminal.write("\x1b[?2031h");
|
|
@@ -473,54 +602,29 @@ export class TUI extends Container {
|
|
|
473
602
|
// Response format: CSI 6 ; height ; width t
|
|
474
603
|
this.terminal.write("\x1b[16t");
|
|
475
604
|
}
|
|
476
|
-
stop() {
|
|
605
|
+
stop(options = {}) {
|
|
477
606
|
this.stopped = true;
|
|
478
|
-
|
|
479
|
-
clearTimeout(this.renderTimer);
|
|
480
|
-
this.renderTimer = undefined;
|
|
481
|
-
}
|
|
607
|
+
this.cancelRenderTimer();
|
|
482
608
|
if (this.terminalColorSchemeNotificationsEnabled) {
|
|
483
609
|
this.terminal.write("\x1b[?2031l");
|
|
484
610
|
}
|
|
485
|
-
|
|
486
|
-
if (this.previousLines.length > 0) {
|
|
487
|
-
// Overwrite the inverted cursor with a normal space to clear the artifact
|
|
488
|
-
this.terminal.write(" ");
|
|
489
|
-
const targetRow = this.previousLines.length; // Line after the last content
|
|
490
|
-
const lineDiff = targetRow - this.hardwareCursorRow;
|
|
491
|
-
if (lineDiff > 0) {
|
|
492
|
-
this.terminal.write(`\x1b[${lineDiff}B`);
|
|
493
|
-
}
|
|
494
|
-
else if (lineDiff < 0) {
|
|
495
|
-
this.terminal.write(`\x1b[${-lineDiff}A`);
|
|
496
|
-
}
|
|
497
|
-
this.terminal.write("\r\n");
|
|
498
|
-
}
|
|
611
|
+
this.beforeTerminalStop(options);
|
|
499
612
|
this.terminal.showCursor();
|
|
500
613
|
this.terminal.stop();
|
|
614
|
+
this.afterTerminalStop(options);
|
|
615
|
+
}
|
|
616
|
+
renderNow(force = false) {
|
|
617
|
+
if (force)
|
|
618
|
+
this.resetRenderState();
|
|
619
|
+
this.renderRequested = false;
|
|
620
|
+
this.cancelRenderTimer();
|
|
621
|
+
this.lastRenderAt = performance.now();
|
|
622
|
+
this.doRender();
|
|
501
623
|
}
|
|
502
624
|
requestRender(force = false) {
|
|
503
625
|
if (force) {
|
|
504
|
-
this.
|
|
505
|
-
this.
|
|
506
|
-
this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
|
|
507
|
-
this.cursorRow = 0;
|
|
508
|
-
this.hardwareCursorRow = 0;
|
|
509
|
-
this.maxLinesRendered = 0;
|
|
510
|
-
this.previousViewportTop = 0;
|
|
511
|
-
if (this.renderTimer) {
|
|
512
|
-
clearTimeout(this.renderTimer);
|
|
513
|
-
this.renderTimer = undefined;
|
|
514
|
-
}
|
|
515
|
-
this.renderRequested = true;
|
|
516
|
-
process.nextTick(() => {
|
|
517
|
-
if (this.stopped || !this.renderRequested) {
|
|
518
|
-
return;
|
|
519
|
-
}
|
|
520
|
-
this.renderRequested = false;
|
|
521
|
-
this.lastRenderAt = performance.now();
|
|
522
|
-
this.doRender();
|
|
523
|
-
});
|
|
626
|
+
this.resetRenderState();
|
|
627
|
+
this.requestImmediateRender();
|
|
524
628
|
return;
|
|
525
629
|
}
|
|
526
630
|
if (this.renderRequested)
|
|
@@ -528,12 +632,36 @@ export class TUI extends Container {
|
|
|
528
632
|
this.renderRequested = true;
|
|
529
633
|
process.nextTick(() => this.scheduleRender());
|
|
530
634
|
}
|
|
635
|
+
requestImmediateRender() {
|
|
636
|
+
this.cancelRenderTimer();
|
|
637
|
+
this.renderRequested = true;
|
|
638
|
+
if (this.immediateRenderScheduled)
|
|
639
|
+
return;
|
|
640
|
+
this.immediateRenderScheduled = true;
|
|
641
|
+
process.nextTick(() => {
|
|
642
|
+
this.immediateRenderScheduled = false;
|
|
643
|
+
if (this.stopped || !this.renderRequested)
|
|
644
|
+
return;
|
|
645
|
+
// A previously queued scheduleRender() can create a timer before this
|
|
646
|
+
// callback runs. User input must preempt that throttled frame.
|
|
647
|
+
this.cancelRenderTimer();
|
|
648
|
+
this.renderRequested = false;
|
|
649
|
+
this.lastRenderAt = performance.now();
|
|
650
|
+
this.doRender();
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
cancelRenderTimer() {
|
|
654
|
+
if (!this.renderTimer)
|
|
655
|
+
return;
|
|
656
|
+
clearTimeout(this.renderTimer);
|
|
657
|
+
this.renderTimer = undefined;
|
|
658
|
+
}
|
|
531
659
|
scheduleRender() {
|
|
532
660
|
if (this.stopped || this.renderTimer || !this.renderRequested) {
|
|
533
661
|
return;
|
|
534
662
|
}
|
|
535
663
|
const elapsed = performance.now() - this.lastRenderAt;
|
|
536
|
-
const delay = Math.max(0,
|
|
664
|
+
const delay = Math.max(0, TuiBase.MIN_RENDER_INTERVAL_MS - elapsed);
|
|
537
665
|
this.renderTimer = setTimeout(() => {
|
|
538
666
|
this.renderTimer = undefined;
|
|
539
667
|
if (this.stopped || !this.renderRequested) {
|
|
@@ -547,8 +675,8 @@ export class TUI extends Container {
|
|
|
547
675
|
}
|
|
548
676
|
}, delay);
|
|
549
677
|
}
|
|
550
|
-
|
|
551
|
-
if (this.
|
|
678
|
+
handleTerminalInput(data) {
|
|
679
|
+
if (this.consumeTerminalColorResponse(data)) {
|
|
552
680
|
return;
|
|
553
681
|
}
|
|
554
682
|
if (this.consumeTerminalColorSchemeReport(data)) {
|
|
@@ -616,30 +744,55 @@ export class TUI extends Container {
|
|
|
616
744
|
return;
|
|
617
745
|
}
|
|
618
746
|
this.focusedComponent.handleInput(data);
|
|
619
|
-
|
|
747
|
+
// Keyboard input is latency-sensitive. Avoid the throttled timer path,
|
|
748
|
+
// where even setTimeout(0) can take a full 16 ms tick on Windows.
|
|
749
|
+
this.requestImmediateRender();
|
|
620
750
|
}
|
|
621
751
|
}
|
|
622
|
-
|
|
623
|
-
|
|
752
|
+
consumeTerminalColorResponse(data) {
|
|
753
|
+
const query = this.pendingTerminalColorQueries[0];
|
|
754
|
+
if (!query) {
|
|
624
755
|
return false;
|
|
625
756
|
}
|
|
626
|
-
if (
|
|
757
|
+
if (DEVICE_ATTRIBUTES_RESPONSE_PATTERN.test(data)) {
|
|
758
|
+
this.pendingTerminalColorQueries.shift();
|
|
759
|
+
this.completeTerminalColorQuery(query);
|
|
760
|
+
return true;
|
|
761
|
+
}
|
|
762
|
+
const response = parseOscColorResponse(data);
|
|
763
|
+
if (!response) {
|
|
627
764
|
return false;
|
|
628
765
|
}
|
|
629
|
-
const rgb =
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
query.
|
|
766
|
+
const { target, rgb } = response;
|
|
767
|
+
const key = String(target);
|
|
768
|
+
if (!query.deliver || query.replied.has(key)) {
|
|
769
|
+
return true;
|
|
770
|
+
}
|
|
771
|
+
query.replied.add(key);
|
|
772
|
+
if (target === "foreground") {
|
|
773
|
+
query.foreground = rgb;
|
|
774
|
+
}
|
|
775
|
+
else if (target === "background") {
|
|
776
|
+
query.background = rgb;
|
|
777
|
+
}
|
|
778
|
+
else if (target < TERMINAL_PALETTE_SIZE) {
|
|
779
|
+
query.palette[target] = rgb;
|
|
780
|
+
}
|
|
781
|
+
if (query.replied.size === TERMINAL_COLOR_REPLY_COUNT) {
|
|
782
|
+
this.completeTerminalColorQuery(query);
|
|
640
783
|
}
|
|
641
784
|
return true;
|
|
642
785
|
}
|
|
786
|
+
terminalColorQueryResult(query) {
|
|
787
|
+
const palette = query.palette.every((color) => color !== undefined) ? query.palette : undefined;
|
|
788
|
+
return { foreground: query.foreground, background: query.background, palette };
|
|
789
|
+
}
|
|
790
|
+
completeTerminalColorQuery(query) {
|
|
791
|
+
const deliver = query.deliver;
|
|
792
|
+
query.deliver = undefined;
|
|
793
|
+
clearTimeout(query.timer);
|
|
794
|
+
deliver?.(this.terminalColorQueryResult(query));
|
|
795
|
+
}
|
|
643
796
|
consumeTerminalColorSchemeReport(data) {
|
|
644
797
|
const scheme = parseTerminalColorSchemeReport(data);
|
|
645
798
|
if (!scheme) {
|
|
@@ -795,9 +948,13 @@ export class TUI extends Container {
|
|
|
795
948
|
}
|
|
796
949
|
/** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */
|
|
797
950
|
compositeOverlays(lines, termWidth, termHeight) {
|
|
798
|
-
if (this.overlayStack.length === 0)
|
|
951
|
+
if (this.overlayStack.length === 0) {
|
|
952
|
+
this.renderedOverlayLayouts = [];
|
|
799
953
|
return lines;
|
|
954
|
+
}
|
|
800
955
|
const result = [...lines];
|
|
956
|
+
for (const entry of this.overlayStack)
|
|
957
|
+
entry.bounds = undefined;
|
|
801
958
|
// Pre-render all visible overlays and calculate positions
|
|
802
959
|
const rendered = [];
|
|
803
960
|
let minLinesNeeded = result.length;
|
|
@@ -816,9 +973,17 @@ export class TUI extends Container {
|
|
|
816
973
|
}
|
|
817
974
|
// Get final row/col with actual overlay height
|
|
818
975
|
const { row, col } = this.resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight);
|
|
819
|
-
|
|
976
|
+
entry.bounds = { row, col, width, height: overlayLines.length };
|
|
977
|
+
rendered.push({ entry, overlayLines, row, col, w: width });
|
|
820
978
|
minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
|
|
821
979
|
}
|
|
980
|
+
this.renderedOverlayLayouts = rendered.map(({ entry, row, col, w, overlayLines }) => ({
|
|
981
|
+
entry,
|
|
982
|
+
row,
|
|
983
|
+
col,
|
|
984
|
+
width: w,
|
|
985
|
+
height: overlayLines.length,
|
|
986
|
+
}));
|
|
822
987
|
// Pad to at least terminal height so overlays have screen-relative positions.
|
|
823
988
|
// Excludes maxLinesRendered: the historical high-water mark caused self-reinforcing
|
|
824
989
|
// inflation that pushed content into scrollback on terminal widen.
|
|
@@ -842,9 +1007,8 @@ export class TUI extends Container {
|
|
|
842
1007
|
}
|
|
843
1008
|
return result;
|
|
844
1009
|
}
|
|
845
|
-
static SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
|
|
846
1010
|
applyLineResets(lines) {
|
|
847
|
-
const reset =
|
|
1011
|
+
const reset = SEGMENT_RESET;
|
|
848
1012
|
for (let i = 0; i < lines.length; i++) {
|
|
849
1013
|
const line = lines[i];
|
|
850
1014
|
if (!isImageLine(line)) {
|
|
@@ -853,104 +1017,8 @@ export class TUI extends Container {
|
|
|
853
1017
|
}
|
|
854
1018
|
return lines;
|
|
855
1019
|
}
|
|
856
|
-
collectKittyImageIds(lines) {
|
|
857
|
-
const ids = new Set();
|
|
858
|
-
for (const line of lines) {
|
|
859
|
-
for (const id of extractKittyImageIds(line)) {
|
|
860
|
-
ids.add(id);
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
return ids;
|
|
864
|
-
}
|
|
865
|
-
deleteKittyImages(ids) {
|
|
866
|
-
let buffer = "";
|
|
867
|
-
for (const id of ids) {
|
|
868
|
-
buffer += deleteKittyImage(id);
|
|
869
|
-
}
|
|
870
|
-
return buffer;
|
|
871
|
-
}
|
|
872
|
-
getKittyImageReservedRows(lines, index, maxIndex = lines.length - 1) {
|
|
873
|
-
const rows = extractKittyImageRows(lines[index] ?? "");
|
|
874
|
-
if (rows <= 1)
|
|
875
|
-
return 1;
|
|
876
|
-
const maxRows = Math.min(rows, maxIndex - index + 1, lines.length - index);
|
|
877
|
-
let reservedRows = 1;
|
|
878
|
-
while (reservedRows < maxRows) {
|
|
879
|
-
const line = lines[index + reservedRows] ?? "";
|
|
880
|
-
if (isImageLine(line) || visibleWidth(line) > 0)
|
|
881
|
-
break;
|
|
882
|
-
reservedRows++;
|
|
883
|
-
}
|
|
884
|
-
return reservedRows;
|
|
885
|
-
}
|
|
886
|
-
expandChangedRangeForKittyImages(firstChanged, lastChanged, newLines) {
|
|
887
|
-
let expandedFirstChanged = firstChanged;
|
|
888
|
-
let expandedLastChanged = lastChanged;
|
|
889
|
-
const expandForLines = (lines) => {
|
|
890
|
-
for (let i = 0; i < lines.length; i++) {
|
|
891
|
-
if (extractKittyImageIds(lines[i]).length === 0)
|
|
892
|
-
continue;
|
|
893
|
-
const blockEnd = i + this.getKittyImageReservedRows(lines, i) - 1;
|
|
894
|
-
if (i >= firstChanged || (i <= lastChanged && blockEnd >= firstChanged)) {
|
|
895
|
-
expandedFirstChanged = Math.min(expandedFirstChanged, i);
|
|
896
|
-
expandedLastChanged = Math.max(expandedLastChanged, blockEnd);
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
};
|
|
900
|
-
expandForLines(this.previousLines);
|
|
901
|
-
expandForLines(newLines);
|
|
902
|
-
return { firstChanged: expandedFirstChanged, lastChanged: expandedLastChanged };
|
|
903
|
-
}
|
|
904
|
-
deleteChangedKittyImages(firstChanged, lastChanged) {
|
|
905
|
-
if (firstChanged < 0 || lastChanged < firstChanged)
|
|
906
|
-
return "";
|
|
907
|
-
const ids = new Set();
|
|
908
|
-
const maxLine = Math.min(lastChanged, this.previousLines.length - 1);
|
|
909
|
-
for (let i = firstChanged; i <= maxLine; i++) {
|
|
910
|
-
for (const id of extractKittyImageIds(this.previousLines[i] ?? "")) {
|
|
911
|
-
ids.add(id);
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
return this.deleteKittyImages(ids);
|
|
915
|
-
}
|
|
916
|
-
/** Splice overlay content into a base line at a specific column. Single-pass optimized. */
|
|
917
1020
|
compositeLineAt(baseLine, overlayLine, startCol, overlayWidth, totalWidth) {
|
|
918
|
-
|
|
919
|
-
return baseLine;
|
|
920
|
-
// Single pass through baseLine extracts both before and after segments
|
|
921
|
-
const afterStart = startCol + overlayWidth;
|
|
922
|
-
const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
|
|
923
|
-
// Extract overlay with width tracking (strict=true to exclude wide chars at boundary)
|
|
924
|
-
const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
|
|
925
|
-
// Pad segments to target widths
|
|
926
|
-
const beforePad = Math.max(0, startCol - base.beforeWidth);
|
|
927
|
-
const overlayPad = Math.max(0, overlayWidth - overlay.width);
|
|
928
|
-
const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
|
|
929
|
-
const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
|
|
930
|
-
const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
|
|
931
|
-
const afterPad = Math.max(0, afterTarget - base.afterWidth);
|
|
932
|
-
// Compose result
|
|
933
|
-
const r = TUI.SEGMENT_RESET;
|
|
934
|
-
const result = base.before +
|
|
935
|
-
" ".repeat(beforePad) +
|
|
936
|
-
r +
|
|
937
|
-
overlay.text +
|
|
938
|
-
" ".repeat(overlayPad) +
|
|
939
|
-
r +
|
|
940
|
-
base.after +
|
|
941
|
-
" ".repeat(afterPad);
|
|
942
|
-
// CRITICAL: Always verify and truncate to terminal width.
|
|
943
|
-
// This is the final safeguard against width overflow which would crash the TUI.
|
|
944
|
-
// Width tracking can drift from actual visible width due to:
|
|
945
|
-
// - Complex ANSI/OSC sequences (hyperlinks, colors)
|
|
946
|
-
// - Wide characters at segment boundaries
|
|
947
|
-
// - Edge cases in segment extraction
|
|
948
|
-
const resultWidth = visibleWidth(result);
|
|
949
|
-
if (resultWidth <= totalWidth) {
|
|
950
|
-
return result;
|
|
951
|
-
}
|
|
952
|
-
// Truncate with strict=true to ensure we don't exceed totalWidth
|
|
953
|
-
return sliceByColumn(result, 0, totalWidth, true);
|
|
1021
|
+
return compositeTuiLine(baseLine, overlayLine, startCol, overlayWidth, totalWidth);
|
|
954
1022
|
}
|
|
955
1023
|
/**
|
|
956
1024
|
* Find and extract cursor position from rendered lines.
|
|
@@ -977,435 +1045,29 @@ export class TUI extends Container {
|
|
|
977
1045
|
}
|
|
978
1046
|
return null;
|
|
979
1047
|
}
|
|
980
|
-
doRender() {
|
|
981
|
-
if (this.stopped)
|
|
982
|
-
return;
|
|
983
|
-
const width = this.terminal.columns;
|
|
984
|
-
const height = this.terminal.rows;
|
|
985
|
-
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
|
|
986
|
-
const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height;
|
|
987
|
-
const previousBufferLength = this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height;
|
|
988
|
-
let prevViewportTop = heightChanged ? Math.max(0, previousBufferLength - height) : this.previousViewportTop;
|
|
989
|
-
let viewportTop = prevViewportTop;
|
|
990
|
-
let hardwareCursorRow = this.hardwareCursorRow;
|
|
991
|
-
const computeLineDiff = (targetRow) => {
|
|
992
|
-
const currentScreenRow = hardwareCursorRow - prevViewportTop;
|
|
993
|
-
const targetScreenRow = targetRow - viewportTop;
|
|
994
|
-
return targetScreenRow - currentScreenRow;
|
|
995
|
-
};
|
|
996
|
-
// Render all components to get new lines
|
|
997
|
-
let newLines = this.render(width);
|
|
998
|
-
// Composite overlays into the rendered lines (before differential compare)
|
|
999
|
-
if (this.overlayStack.length > 0) {
|
|
1000
|
-
newLines = this.compositeOverlays(newLines, width, height);
|
|
1001
|
-
}
|
|
1002
|
-
// Extract cursor position before applying line resets (marker must be found first)
|
|
1003
|
-
const cursorPos = this.extractCursorPosition(newLines, height);
|
|
1004
|
-
newLines = this.applyLineResets(newLines);
|
|
1005
|
-
// Helper to clear scrollback and viewport and render all new lines
|
|
1006
|
-
const fullRender = (clear) => {
|
|
1007
|
-
this.fullRedrawCount += 1;
|
|
1008
|
-
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
|
1009
|
-
if (clear) {
|
|
1010
|
-
buffer += this.deleteKittyImages(this.previousKittyImageIds);
|
|
1011
|
-
buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback
|
|
1012
|
-
}
|
|
1013
|
-
for (let i = 0; i < newLines.length; i++) {
|
|
1014
|
-
if (i > 0)
|
|
1015
|
-
buffer += "\r\n";
|
|
1016
|
-
const line = newLines[i];
|
|
1017
|
-
const isImage = isImageLine(line);
|
|
1018
|
-
const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i) : 1;
|
|
1019
|
-
if (imageReservedRows > 1 && imageReservedRows <= height) {
|
|
1020
|
-
for (let row = 1; row < imageReservedRows; row++) {
|
|
1021
|
-
buffer += "\r\n";
|
|
1022
|
-
}
|
|
1023
|
-
buffer += `\x1b[${imageReservedRows - 1}A`;
|
|
1024
|
-
buffer += line;
|
|
1025
|
-
buffer += `\x1b[${imageReservedRows - 1}B`;
|
|
1026
|
-
i += imageReservedRows - 1;
|
|
1027
|
-
continue;
|
|
1028
|
-
}
|
|
1029
|
-
buffer += line;
|
|
1030
|
-
}
|
|
1031
|
-
buffer += "\x1b[?2026l"; // End synchronized output
|
|
1032
|
-
this.terminal.write(buffer);
|
|
1033
|
-
this.cursorRow = Math.max(0, newLines.length - 1);
|
|
1034
|
-
this.hardwareCursorRow = this.cursorRow;
|
|
1035
|
-
// Reset max lines when clearing, otherwise track growth
|
|
1036
|
-
if (clear) {
|
|
1037
|
-
this.maxLinesRendered = newLines.length;
|
|
1038
|
-
}
|
|
1039
|
-
else {
|
|
1040
|
-
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
|
1041
|
-
}
|
|
1042
|
-
const bufferLength = Math.max(height, newLines.length);
|
|
1043
|
-
this.previousViewportTop = Math.max(0, bufferLength - height);
|
|
1044
|
-
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
1045
|
-
this.previousLines = newLines;
|
|
1046
|
-
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
|
|
1047
|
-
this.previousWidth = width;
|
|
1048
|
-
this.previousHeight = height;
|
|
1049
|
-
};
|
|
1050
|
-
const debugRedraw = process.env.PI_DEBUG_REDRAW === "1";
|
|
1051
|
-
const logRedraw = (reason) => {
|
|
1052
|
-
if (!debugRedraw)
|
|
1053
|
-
return;
|
|
1054
|
-
const logPath = path.join(this.logDirectory, "pi-debug.log");
|
|
1055
|
-
const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\n`;
|
|
1056
|
-
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
1057
|
-
fs.appendFileSync(logPath, msg);
|
|
1058
|
-
};
|
|
1059
|
-
// First render - just output everything without clearing (assumes clean screen)
|
|
1060
|
-
if (this.previousLines.length === 0 && !widthChanged && !heightChanged) {
|
|
1061
|
-
logRedraw("first render");
|
|
1062
|
-
fullRender(false);
|
|
1063
|
-
return;
|
|
1064
|
-
}
|
|
1065
|
-
// Width changes always need a full re-render because wrapping changes.
|
|
1066
|
-
if (widthChanged) {
|
|
1067
|
-
logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`);
|
|
1068
|
-
fullRender(true);
|
|
1069
|
-
return;
|
|
1070
|
-
}
|
|
1071
|
-
// Height changes normally need a full re-render to keep the visible viewport aligned,
|
|
1072
|
-
// but Termux changes height when the software keyboard shows or hides.
|
|
1073
|
-
// In that environment, a full redraw causes the entire history to replay on every toggle.
|
|
1074
|
-
if (heightChanged && !isTermuxSession()) {
|
|
1075
|
-
logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`);
|
|
1076
|
-
fullRender(true);
|
|
1077
|
-
return;
|
|
1078
|
-
}
|
|
1079
|
-
// Content shrunk below the working area and no overlays - re-render to clear empty rows
|
|
1080
|
-
// (overlays need the padding, so only do this when no overlays are active)
|
|
1081
|
-
// Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
|
|
1082
|
-
if (this.clearOnShrink && newLines.length < this.maxLinesRendered && this.overlayStack.length === 0) {
|
|
1083
|
-
logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`);
|
|
1084
|
-
fullRender(true);
|
|
1085
|
-
return;
|
|
1086
|
-
}
|
|
1087
|
-
// Find first and last changed lines
|
|
1088
|
-
let firstChanged = -1;
|
|
1089
|
-
let lastChanged = -1;
|
|
1090
|
-
const maxLines = Math.max(newLines.length, this.previousLines.length);
|
|
1091
|
-
for (let i = 0; i < maxLines; i++) {
|
|
1092
|
-
const oldLine = i < this.previousLines.length ? this.previousLines[i] : "";
|
|
1093
|
-
const newLine = i < newLines.length ? newLines[i] : "";
|
|
1094
|
-
if (oldLine !== newLine) {
|
|
1095
|
-
if (firstChanged === -1) {
|
|
1096
|
-
firstChanged = i;
|
|
1097
|
-
}
|
|
1098
|
-
lastChanged = i;
|
|
1099
|
-
}
|
|
1100
|
-
}
|
|
1101
|
-
const appendedLines = newLines.length > this.previousLines.length;
|
|
1102
|
-
if (appendedLines) {
|
|
1103
|
-
if (firstChanged === -1) {
|
|
1104
|
-
firstChanged = this.previousLines.length;
|
|
1105
|
-
}
|
|
1106
|
-
lastChanged = newLines.length - 1;
|
|
1107
|
-
}
|
|
1108
|
-
if (firstChanged !== -1) {
|
|
1109
|
-
const expandedRange = this.expandChangedRangeForKittyImages(firstChanged, lastChanged, newLines);
|
|
1110
|
-
firstChanged = expandedRange.firstChanged;
|
|
1111
|
-
lastChanged = expandedRange.lastChanged;
|
|
1112
|
-
}
|
|
1113
|
-
const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0;
|
|
1114
|
-
// No changes - but still need to update hardware cursor position if it moved
|
|
1115
|
-
if (firstChanged === -1) {
|
|
1116
|
-
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
1117
|
-
this.previousViewportTop = prevViewportTop;
|
|
1118
|
-
this.previousHeight = height;
|
|
1119
|
-
return;
|
|
1120
|
-
}
|
|
1121
|
-
// All changes are in deleted lines (nothing to render, just clear)
|
|
1122
|
-
if (firstChanged >= newLines.length) {
|
|
1123
|
-
if (this.previousLines.length > newLines.length) {
|
|
1124
|
-
let buffer = "\x1b[?2026h";
|
|
1125
|
-
buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
|
|
1126
|
-
// Move to end of new content (clamp to 0 for empty content)
|
|
1127
|
-
const targetRow = Math.max(0, newLines.length - 1);
|
|
1128
|
-
if (targetRow < prevViewportTop) {
|
|
1129
|
-
logRedraw(`deleted lines moved viewport up (${targetRow} < ${prevViewportTop})`);
|
|
1130
|
-
fullRender(true);
|
|
1131
|
-
return;
|
|
1132
|
-
}
|
|
1133
|
-
const lineDiff = computeLineDiff(targetRow);
|
|
1134
|
-
if (lineDiff > 0)
|
|
1135
|
-
buffer += `\x1b[${lineDiff}B`;
|
|
1136
|
-
else if (lineDiff < 0)
|
|
1137
|
-
buffer += `\x1b[${-lineDiff}A`;
|
|
1138
|
-
buffer += "\r";
|
|
1139
|
-
// Clear extra lines without scrolling
|
|
1140
|
-
const extraLines = this.previousLines.length - newLines.length;
|
|
1141
|
-
if (extraLines > height) {
|
|
1142
|
-
logRedraw(`extraLines > height (${extraLines} > ${height})`);
|
|
1143
|
-
fullRender(true);
|
|
1144
|
-
return;
|
|
1145
|
-
}
|
|
1146
|
-
const clearStartOffset = newLines.length === 0 ? 0 : 1;
|
|
1147
|
-
if (extraLines > 0 && clearStartOffset > 0) {
|
|
1148
|
-
buffer += `\x1b[${clearStartOffset}B`;
|
|
1149
|
-
}
|
|
1150
|
-
for (let i = 0; i < extraLines; i++) {
|
|
1151
|
-
buffer += "\r\x1b[2K";
|
|
1152
|
-
if (i < extraLines - 1)
|
|
1153
|
-
buffer += "\x1b[1B";
|
|
1154
|
-
}
|
|
1155
|
-
const moveBack = Math.max(0, extraLines - 1 + clearStartOffset);
|
|
1156
|
-
if (moveBack > 0) {
|
|
1157
|
-
buffer += `\x1b[${moveBack}A`;
|
|
1158
|
-
}
|
|
1159
|
-
buffer += "\x1b[?2026l";
|
|
1160
|
-
this.terminal.write(buffer);
|
|
1161
|
-
this.cursorRow = targetRow;
|
|
1162
|
-
this.hardwareCursorRow = targetRow;
|
|
1163
|
-
}
|
|
1164
|
-
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
1165
|
-
this.previousLines = newLines;
|
|
1166
|
-
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
|
|
1167
|
-
this.previousWidth = width;
|
|
1168
|
-
this.previousHeight = height;
|
|
1169
|
-
this.previousViewportTop = prevViewportTop;
|
|
1170
|
-
return;
|
|
1171
|
-
}
|
|
1172
|
-
// Differential rendering can only touch what was actually visible.
|
|
1173
|
-
// If the first changed line is above the previous viewport, we need a full redraw.
|
|
1174
|
-
if (firstChanged < prevViewportTop) {
|
|
1175
|
-
logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
|
|
1176
|
-
fullRender(true);
|
|
1177
|
-
return;
|
|
1178
|
-
}
|
|
1179
|
-
// Render from first changed line to end
|
|
1180
|
-
// Build buffer with all updates wrapped in synchronized output
|
|
1181
|
-
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
|
1182
|
-
buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
|
|
1183
|
-
const prevViewportBottom = prevViewportTop + height - 1;
|
|
1184
|
-
const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
|
|
1185
|
-
if (moveTargetRow > prevViewportBottom) {
|
|
1186
|
-
const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
|
|
1187
|
-
const moveToBottom = height - 1 - currentScreenRow;
|
|
1188
|
-
if (moveToBottom > 0) {
|
|
1189
|
-
buffer += `\x1b[${moveToBottom}B`;
|
|
1190
|
-
}
|
|
1191
|
-
const scroll = moveTargetRow - prevViewportBottom;
|
|
1192
|
-
buffer += "\r\n".repeat(scroll);
|
|
1193
|
-
prevViewportTop += scroll;
|
|
1194
|
-
viewportTop += scroll;
|
|
1195
|
-
hardwareCursorRow = moveTargetRow;
|
|
1196
|
-
}
|
|
1197
|
-
// Move cursor to first changed line (use hardwareCursorRow for actual position)
|
|
1198
|
-
const lineDiff = computeLineDiff(moveTargetRow);
|
|
1199
|
-
if (lineDiff > 0) {
|
|
1200
|
-
buffer += `\x1b[${lineDiff}B`; // Move down
|
|
1201
|
-
}
|
|
1202
|
-
else if (lineDiff < 0) {
|
|
1203
|
-
buffer += `\x1b[${-lineDiff}A`; // Move up
|
|
1204
|
-
}
|
|
1205
|
-
buffer += appendStart ? "\r\n" : "\r"; // Move to column 0
|
|
1206
|
-
// Only render changed lines (firstChanged to lastChanged), not all lines to end
|
|
1207
|
-
// This reduces flicker when only a single line changes (e.g., spinner animation)
|
|
1208
|
-
const renderEnd = Math.min(lastChanged, newLines.length - 1);
|
|
1209
|
-
for (let i = firstChanged; i <= renderEnd; i++) {
|
|
1210
|
-
if (i > firstChanged)
|
|
1211
|
-
buffer += "\r\n";
|
|
1212
|
-
const line = newLines[i];
|
|
1213
|
-
const isImage = isImageLine(line);
|
|
1214
|
-
const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i, renderEnd) : 1;
|
|
1215
|
-
if (imageReservedRows > 1) {
|
|
1216
|
-
const imageStartScreenRow = i - viewportTop;
|
|
1217
|
-
if (imageStartScreenRow < 0 || imageStartScreenRow + imageReservedRows > height) {
|
|
1218
|
-
logRedraw(`kitty image pre-clear would scroll (${imageStartScreenRow} + ${imageReservedRows} > ${height})`);
|
|
1219
|
-
fullRender(true);
|
|
1220
|
-
return;
|
|
1221
|
-
}
|
|
1222
|
-
buffer += "\x1b[2K";
|
|
1223
|
-
for (let row = 1; row < imageReservedRows; row++) {
|
|
1224
|
-
buffer += "\r\n\x1b[2K";
|
|
1225
|
-
}
|
|
1226
|
-
buffer += `\x1b[${imageReservedRows - 1}A`;
|
|
1227
|
-
buffer += line;
|
|
1228
|
-
buffer += `\x1b[${imageReservedRows - 1}B`;
|
|
1229
|
-
i += imageReservedRows - 1;
|
|
1230
|
-
continue;
|
|
1231
|
-
}
|
|
1232
|
-
buffer += "\x1b[2K"; // Clear current line
|
|
1233
|
-
if (!isImage && visibleWidth(line) > width) {
|
|
1234
|
-
// Log all lines to crash file for debugging
|
|
1235
|
-
const crashLogPath = path.join(this.logDirectory, "pi-crash.log");
|
|
1236
|
-
const crashData = [
|
|
1237
|
-
`Crash at ${new Date().toISOString()}`,
|
|
1238
|
-
`Terminal width: ${width}`,
|
|
1239
|
-
`Line ${i} visible width: ${visibleWidth(line)}`,
|
|
1240
|
-
"",
|
|
1241
|
-
"=== All rendered lines ===",
|
|
1242
|
-
...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),
|
|
1243
|
-
"",
|
|
1244
|
-
].join("\n");
|
|
1245
|
-
fs.mkdirSync(path.dirname(crashLogPath), { recursive: true });
|
|
1246
|
-
fs.writeFileSync(crashLogPath, crashData);
|
|
1247
|
-
// Clean up terminal state before throwing
|
|
1248
|
-
this.stop();
|
|
1249
|
-
const errorMsg = [
|
|
1250
|
-
`Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,
|
|
1251
|
-
"",
|
|
1252
|
-
"This is likely caused by a custom TUI component not truncating its output.",
|
|
1253
|
-
"Use visibleWidth() to measure and truncateToWidth() to truncate lines.",
|
|
1254
|
-
"",
|
|
1255
|
-
`Debug log written to: ${crashLogPath}`,
|
|
1256
|
-
].join("\n");
|
|
1257
|
-
throw new Error(errorMsg);
|
|
1258
|
-
}
|
|
1259
|
-
buffer += line;
|
|
1260
|
-
}
|
|
1261
|
-
// Track where cursor ended up after rendering
|
|
1262
|
-
let finalCursorRow = renderEnd;
|
|
1263
|
-
// If we had more lines before, clear them and move cursor back
|
|
1264
|
-
if (this.previousLines.length > newLines.length) {
|
|
1265
|
-
// Move to end of new content first if we stopped before it
|
|
1266
|
-
if (renderEnd < newLines.length - 1) {
|
|
1267
|
-
const moveDown = newLines.length - 1 - renderEnd;
|
|
1268
|
-
buffer += `\x1b[${moveDown}B`;
|
|
1269
|
-
finalCursorRow = newLines.length - 1;
|
|
1270
|
-
}
|
|
1271
|
-
const extraLines = this.previousLines.length - newLines.length;
|
|
1272
|
-
for (let i = newLines.length; i < this.previousLines.length; i++) {
|
|
1273
|
-
buffer += "\r\n\x1b[2K";
|
|
1274
|
-
}
|
|
1275
|
-
// Move cursor back to end of new content
|
|
1276
|
-
buffer += `\x1b[${extraLines}A`;
|
|
1277
|
-
}
|
|
1278
|
-
buffer += "\x1b[?2026l"; // End synchronized output
|
|
1279
|
-
if (process.env.PI_TUI_DEBUG === "1") {
|
|
1280
|
-
const debugDir = "/tmp/tui";
|
|
1281
|
-
fs.mkdirSync(debugDir, { recursive: true });
|
|
1282
|
-
const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
|
|
1283
|
-
const debugData = [
|
|
1284
|
-
`firstChanged: ${firstChanged}`,
|
|
1285
|
-
`viewportTop: ${viewportTop}`,
|
|
1286
|
-
`cursorRow: ${this.cursorRow}`,
|
|
1287
|
-
`height: ${height}`,
|
|
1288
|
-
`lineDiff: ${lineDiff}`,
|
|
1289
|
-
`hardwareCursorRow: ${hardwareCursorRow}`,
|
|
1290
|
-
`renderEnd: ${renderEnd}`,
|
|
1291
|
-
`finalCursorRow: ${finalCursorRow}`,
|
|
1292
|
-
`cursorPos: ${JSON.stringify(cursorPos)}`,
|
|
1293
|
-
`newLines.length: ${newLines.length}`,
|
|
1294
|
-
`previousLines.length: ${this.previousLines.length}`,
|
|
1295
|
-
"",
|
|
1296
|
-
"=== newLines ===",
|
|
1297
|
-
JSON.stringify(newLines, null, 2),
|
|
1298
|
-
"",
|
|
1299
|
-
"=== previousLines ===",
|
|
1300
|
-
JSON.stringify(this.previousLines, null, 2),
|
|
1301
|
-
"",
|
|
1302
|
-
"=== buffer ===",
|
|
1303
|
-
JSON.stringify(buffer),
|
|
1304
|
-
].join("\n");
|
|
1305
|
-
fs.writeFileSync(debugPath, debugData);
|
|
1306
|
-
}
|
|
1307
|
-
// Write entire buffer at once
|
|
1308
|
-
this.terminal.write(buffer);
|
|
1309
|
-
// Track cursor position for next render
|
|
1310
|
-
// cursorRow tracks end of content (for viewport calculation)
|
|
1311
|
-
// hardwareCursorRow tracks actual terminal cursor position (for movement)
|
|
1312
|
-
this.cursorRow = Math.max(0, newLines.length - 1);
|
|
1313
|
-
this.hardwareCursorRow = finalCursorRow;
|
|
1314
|
-
// Track terminal's working area (grows but doesn't shrink unless cleared)
|
|
1315
|
-
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
|
1316
|
-
this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1);
|
|
1317
|
-
// Position hardware cursor for IME
|
|
1318
|
-
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
1319
|
-
this.previousLines = newLines;
|
|
1320
|
-
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
|
|
1321
|
-
this.previousWidth = width;
|
|
1322
|
-
this.previousHeight = height;
|
|
1323
|
-
}
|
|
1324
1048
|
/**
|
|
1325
|
-
*
|
|
1326
|
-
*
|
|
1327
|
-
*
|
|
1049
|
+
* Query the terminal's theme colors: the default foreground (OSC 10), the default background
|
|
1050
|
+
* (OSC 11), and ANSI colors 0-15 (OSC 4), followed by a DA1 request that marks the end of the
|
|
1051
|
+
* replies. Resolves when the DA1 reply or all color replies arrive, or when the timeout expires.
|
|
1052
|
+
* Colors the terminal did not report are undefined; the palette is only set when all 16 arrived.
|
|
1053
|
+
* @param timeoutMs Query timeout in milliseconds, for terminals that do not answer DA1 either.
|
|
1054
|
+
* @param onLateReply Receives the replies if the query completes after the timeout, e.g. over slow links.
|
|
1328
1055
|
*/
|
|
1329
|
-
|
|
1330
|
-
if (!cursorPos || totalLines <= 0) {
|
|
1331
|
-
this.terminal.hideCursor();
|
|
1332
|
-
return;
|
|
1333
|
-
}
|
|
1334
|
-
// Clamp cursor position to valid range
|
|
1335
|
-
const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));
|
|
1336
|
-
const targetCol = Math.max(0, cursorPos.col);
|
|
1337
|
-
// Move cursor from current position to target
|
|
1338
|
-
const rowDelta = targetRow - this.hardwareCursorRow;
|
|
1339
|
-
let buffer = "";
|
|
1340
|
-
if (rowDelta > 0) {
|
|
1341
|
-
buffer += `\x1b[${rowDelta}B`; // Move down
|
|
1342
|
-
}
|
|
1343
|
-
else if (rowDelta < 0) {
|
|
1344
|
-
buffer += `\x1b[${-rowDelta}A`; // Move up
|
|
1345
|
-
}
|
|
1346
|
-
// Move to absolute column (1-indexed)
|
|
1347
|
-
buffer += `\x1b[${targetCol + 1}G`;
|
|
1348
|
-
if (buffer) {
|
|
1349
|
-
this.terminal.write(buffer);
|
|
1350
|
-
}
|
|
1351
|
-
this.hardwareCursorRow = targetRow;
|
|
1352
|
-
if (this.showHardwareCursor) {
|
|
1353
|
-
this.terminal.showCursor();
|
|
1354
|
-
}
|
|
1355
|
-
else {
|
|
1356
|
-
this.terminal.hideCursor();
|
|
1357
|
-
}
|
|
1358
|
-
}
|
|
1359
|
-
/**
|
|
1360
|
-
* Query the terminal's default background color with OSC 11 (`ESC ] 11 ; ? BEL`).
|
|
1361
|
-
* @param timeoutMs Query timeout in milliseconds.
|
|
1362
|
-
* @returns Promise containing the parsed RGB color, or undefined if it times out or fails to parse.
|
|
1363
|
-
*/
|
|
1364
|
-
queryTerminalBackgroundColor({ timeoutMs }) {
|
|
1056
|
+
queryTerminalColors({ timeoutMs, onLateReply, }) {
|
|
1365
1057
|
return new Promise((resolve) => {
|
|
1366
1058
|
const query = {
|
|
1367
|
-
|
|
1368
|
-
|
|
1059
|
+
palette: Array.from({ length: TERMINAL_PALETTE_SIZE }, () => undefined),
|
|
1060
|
+
replied: new Set(),
|
|
1061
|
+
deliver: resolve,
|
|
1369
1062
|
timer: undefined,
|
|
1370
1063
|
};
|
|
1064
|
+
// Resolve with the replies so far, and keep collecting late replies for `onLateReply`.
|
|
1371
1065
|
query.timer = setTimeout(() => {
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
}
|
|
1375
|
-
query.settled = true;
|
|
1376
|
-
query.timer = undefined;
|
|
1377
|
-
query.resolve?.(undefined);
|
|
1378
|
-
query.resolve = undefined;
|
|
1066
|
+
query.deliver = onLateReply;
|
|
1067
|
+
resolve(this.terminalColorQueryResult(query));
|
|
1379
1068
|
}, timeoutMs);
|
|
1380
|
-
this.
|
|
1381
|
-
this.
|
|
1382
|
-
this.terminal.write("\x1b]11;?\x07");
|
|
1383
|
-
});
|
|
1384
|
-
}
|
|
1385
|
-
/**
|
|
1386
|
-
* Query the terminal's color-scheme preference with DSR (`CSI ? 996 n`).
|
|
1387
|
-
* Terminals that support the color palette notification protocol reply with
|
|
1388
|
-
* `CSI ? 997 ; 1 n` for dark or `CSI ? 997 ; 2 n` for light.
|
|
1389
|
-
*/
|
|
1390
|
-
queryTerminalColorScheme({ timeoutMs }) {
|
|
1391
|
-
return new Promise((resolve) => {
|
|
1392
|
-
let settled = false;
|
|
1393
|
-
let timer;
|
|
1394
|
-
let unsubscribe = () => { };
|
|
1395
|
-
const settle = (scheme) => {
|
|
1396
|
-
if (settled)
|
|
1397
|
-
return;
|
|
1398
|
-
settled = true;
|
|
1399
|
-
if (timer) {
|
|
1400
|
-
clearTimeout(timer);
|
|
1401
|
-
timer = undefined;
|
|
1402
|
-
}
|
|
1403
|
-
unsubscribe();
|
|
1404
|
-
resolve(scheme);
|
|
1405
|
-
};
|
|
1406
|
-
unsubscribe = this.onTerminalColorSchemeChange(settle);
|
|
1407
|
-
timer = setTimeout(() => settle(undefined), timeoutMs);
|
|
1408
|
-
this.terminal.write("\x1b[?996n");
|
|
1069
|
+
this.pendingTerminalColorQueries.push(query);
|
|
1070
|
+
this.terminal.write(TERMINAL_COLOR_QUERY);
|
|
1409
1071
|
});
|
|
1410
1072
|
}
|
|
1411
1073
|
}
|