@oh-my-pi/pi-tui 15.10.1 → 15.10.3
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/CHANGELOG.md +27 -1
- package/dist/types/components/text.d.ts +1 -1
- package/dist/types/keybindings.d.ts +4 -2
- package/dist/types/terminal.d.ts +14 -0
- package/package.json +3 -3
- package/src/components/editor.ts +10 -4
- package/src/components/loader.ts +2 -2
- package/src/components/text.ts +3 -2
- package/src/keybindings.ts +4 -4
- package/src/terminal.ts +122 -2
- package/src/tui.ts +396 -59
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.10.3] - 2026-06-08
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed DEC 2048 in-band resize reports (`CSI 48;rows;cols;hpx;wpx t`) leaking into the focused editor as literal text during a rapid resize. When the window is resized quickly the event loop stays busy long enough for the `StdinBuffer` flush timeout to fire mid-report; the `\x1b[48;…` prefix was emitted as one event and the tail (e.g. `8;125;1156;1125t`) arrived as bare printable characters that the editor inserted. `ProcessTerminal` now reassembles a split in-band report (including a split at the bare `\x1b[4` type field) until its terminator and then drives the resize. A reassembled sequence that turns out not to be a resize report — such as a split kitty key like `\x1b[48;5u` (codepoint 48 = `0`) — is forwarded to the input handler as a single escape sequence rather than dropped or leaked.
|
|
10
|
+
- Coalesced terminal-multiplexer SIGWINCH events into a single forced render once the pane stops resizing so closing/dragging a tmux/screen/zellij split no longer flashes the viewport blank before the new geometry repaints ([#2088](https://github.com/can1357/oh-my-pi/issues/2088)).
|
|
11
|
+
|
|
12
|
+
## [15.10.2] - 2026-06-08
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- Added exported `canonicalKeyId` and `addKeyAliases` keybinding helpers so consumers can share the same canonical shortcut matching semantics as `KeybindingsManager`.
|
|
17
|
+
- Added `super` modifier support to native key parsing/matching and bound `super+alt+backspace` / `super+alt+delete` (and `super+alt+d`) into the word-delete defaults so Ghostty's default macOS Option+Backspace wire (`ESC [127;11u` — kitty modifier 11 = super|alt) deletes a word instead of falling through to single-char delete ([#2064](https://github.com/can1357/oh-my-pi/issues/2064)).
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Fixed focus-changing in-place menus leaving stale Working/menu rows and parking the hardware cursor in the old menu viewport on terminals without a scroll-position oracle.
|
|
22
|
+
- Fixed redundant terminal cursor updates so repeated renders that do not change the cursor row, column, or visibility no longer emit ANSI move/hide sequences
|
|
23
|
+
- Fixed repeated cursor updates during no-op re-renders by reusing the last known cursor state, preventing unnecessary cursor position changes and hide/show sequences
|
|
24
|
+
- Fixed the kitty keyboard progressive-enhancement probe to honor the `CSI ? <flags> u` reply even when the terminal answers the DA1 sentinel first. Previously the kitty reply was discarded once the DA1-driven `modifyOtherKeys` fallback engaged, so terminals like Superset/xterm-on-Electron stayed on the fallback and delivered Shift+Enter as a bare `\r` ([#2042](https://github.com/can1357/oh-my-pi/issues/2042)).
|
|
25
|
+
- Bounded TUI line fitting for oversized raw rows so ANSI-heavy subagent output and zero-width-heavy text cannot grow render buffers independently of the viewport or hide visible suffix text ([#2045](https://github.com/can1357/oh-my-pi/issues/2045)).
|
|
26
|
+
- Fixed tmux offscreen-shrink frames to skip repainting when the visible tail is unchanged, avoiding intermittent blank/refresh flashes in pane terminals ([#2046](https://github.com/can1357/oh-my-pi/issues/2046)).
|
|
27
|
+
- Fixed Windows ConPTY hosts (Windows Terminal, Tabby, Hyper, VS Code) parking the viewport at the top of a full paint after a `/resume` or any long-session repaint. `ProcessTerminal#safeWrite` now splits oversized writes into ≤ 8 KiB pieces at line boundaries on `win32` and inside WSL (where stdout still crosses ConPTY at the `wslhost` boundary) so each underlying `WriteFile` stays below the ~32 KiB threshold where ConPTY stops tracking the cursor; the data was always delivered, but the host UI's scroll position would not follow until any focus event forced a re-query. ([#2034](https://github.com/can1357/oh-my-pi/issues/2034))
|
|
28
|
+
|
|
5
29
|
## [15.10.1] - 2026-06-07
|
|
30
|
+
|
|
6
31
|
### Breaking Changes
|
|
7
32
|
|
|
8
33
|
- Removed Kitty temp-file image transmission, its startup support probe, the `PI_KITTY_IMAGE_TRANSMISSION` override, and the temp-file helper exports. Kitty/Ghostty image payloads now stay on in-band base64 before placeholder/direct placement, avoiding blank first renders from temp-file load races.
|
|
@@ -69,6 +94,7 @@
|
|
|
69
94
|
- Fixed DECCARA background-fill optimization running when synchronized output is disabled, which could expose default-background gaps during rapidly updating tool-use panels ([#2000](https://github.com/can1357/oh-my-pi/issues/2000)).
|
|
70
95
|
|
|
71
96
|
## [15.9.67] - 2026-06-06
|
|
97
|
+
|
|
72
98
|
### Added
|
|
73
99
|
|
|
74
100
|
- Added `setPaddingX` to `Box` so horizontal padding can be updated programmatically after creation
|
|
@@ -1157,4 +1183,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon
|
|
|
1157
1183
|
|
|
1158
1184
|
### Fixed
|
|
1159
1185
|
|
|
1160
|
-
- **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
|
|
1186
|
+
- **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
|
|
@@ -6,7 +6,7 @@ export declare class Text implements Component {
|
|
|
6
6
|
#private;
|
|
7
7
|
constructor(text?: string, paddingX?: number, paddingY?: number, customBgFn?: (text: string) => string);
|
|
8
8
|
getText(): string;
|
|
9
|
-
setText(text: string):
|
|
9
|
+
setText(text: string): boolean;
|
|
10
10
|
setCustomBgFn(customBgFn?: (text: string) => string): void;
|
|
11
11
|
invalidate(): void;
|
|
12
12
|
render(width: number): string[];
|
|
@@ -102,11 +102,11 @@ export declare const TUI_KEYBINDINGS: {
|
|
|
102
102
|
readonly description: "Delete character forward";
|
|
103
103
|
};
|
|
104
104
|
readonly "tui.editor.deleteWordBackward": {
|
|
105
|
-
readonly defaultKeys: ["ctrl+w", "alt+backspace", "ctrl+backspace"];
|
|
105
|
+
readonly defaultKeys: ["ctrl+w", "alt+backspace", "ctrl+backspace", "super+alt+backspace"];
|
|
106
106
|
readonly description: "Delete word backward";
|
|
107
107
|
};
|
|
108
108
|
readonly "tui.editor.deleteWordForward": {
|
|
109
|
-
readonly defaultKeys: ["alt+delete", "alt+d"];
|
|
109
|
+
readonly defaultKeys: ["alt+delete", "alt+d", "super+alt+delete", "super+alt+d"];
|
|
110
110
|
readonly description: "Delete word forward";
|
|
111
111
|
};
|
|
112
112
|
readonly "tui.editor.deleteToLineStart": {
|
|
@@ -174,6 +174,8 @@ export interface KeybindingConflict {
|
|
|
174
174
|
key: KeyId;
|
|
175
175
|
keybindings: string[];
|
|
176
176
|
}
|
|
177
|
+
export declare function canonicalKeyId(key: string): string;
|
|
178
|
+
export declare function addKeyAliases(keys: Set<string>, key: KeyId): void;
|
|
177
179
|
export declare class KeybindingsManager {
|
|
178
180
|
#private;
|
|
179
181
|
constructor(definitions: KeybindingDefinitions, userBindings?: KeybindingsConfig);
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split `data` into chunks no larger than `maxChunkSize`, preferring a line
|
|
3
|
+
* boundary (`\n`) as the cut point so escape sequences (which never contain
|
|
4
|
+
* `\n`) stay intact. The TUI's full-paint buffers are line-structured
|
|
5
|
+
* (`buffer += "\r\n"` between rows), so a newline almost always exists within
|
|
6
|
+
* the window. The fallback for a buffer with no newline in range is a hard
|
|
7
|
+
* cut at `maxChunkSize`: the ConPTY viewport bug from a single oversized
|
|
8
|
+
* write is strictly worse than a one-frame escape-sequence glitch on a buffer
|
|
9
|
+
* the renderer effectively never produces.
|
|
10
|
+
*
|
|
11
|
+
* Exported for unit testing of the chunking contract; `#safeWrite` is the
|
|
12
|
+
* sole production caller.
|
|
13
|
+
*/
|
|
14
|
+
export declare function chunkForConPTY(data: string, maxChunkSize?: number): string[];
|
|
1
15
|
/**
|
|
2
16
|
* Emergency terminal restore - call this from signal/crash handlers
|
|
3
17
|
* Resets terminal state without requiring access to the ProcessTerminal instance
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "15.10.
|
|
4
|
+
"version": "15.10.3",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-natives": "15.10.
|
|
41
|
-
"@oh-my-pi/pi-utils": "15.10.
|
|
40
|
+
"@oh-my-pi/pi-natives": "15.10.3",
|
|
41
|
+
"@oh-my-pi/pi-utils": "15.10.3",
|
|
42
42
|
"lru-cache": "11.5.1",
|
|
43
43
|
"marked": "^18.0.4"
|
|
44
44
|
},
|
package/src/components/editor.ts
CHANGED
|
@@ -1109,12 +1109,18 @@ export class Editor implements Component, Focusable {
|
|
|
1109
1109
|
else if (matchesKey(data, "ctrl+w")) {
|
|
1110
1110
|
this.#deleteWordBackwards();
|
|
1111
1111
|
}
|
|
1112
|
-
// Option/Alt+Backspace - Delete word backwards
|
|
1113
|
-
|
|
1112
|
+
// Option/Alt+Backspace - Delete word backwards.
|
|
1113
|
+
// Ghostty on macOS reports Option+Backspace as super+alt (kitty mod 11) — see #2064.
|
|
1114
|
+
else if (matchesKey(data, "alt+backspace") || matchesKey(data, "super+alt+backspace")) {
|
|
1114
1115
|
this.#deleteWordBackwards();
|
|
1115
1116
|
}
|
|
1116
|
-
// Option/Alt+D - Delete word forwards
|
|
1117
|
-
else if (
|
|
1117
|
+
// Option/Alt+D and Option+Delete - Delete word forwards. Same Ghostty quirk applies.
|
|
1118
|
+
else if (
|
|
1119
|
+
matchesKey(data, "alt+d") ||
|
|
1120
|
+
matchesKey(data, "alt+delete") ||
|
|
1121
|
+
matchesKey(data, "super+alt+d") ||
|
|
1122
|
+
matchesKey(data, "super+alt+delete")
|
|
1123
|
+
) {
|
|
1118
1124
|
this.#deleteWordForwards();
|
|
1119
1125
|
}
|
|
1120
1126
|
// Ctrl+Y - Yank from kill ring
|
package/src/components/loader.ts
CHANGED
|
@@ -88,8 +88,8 @@ export class Loader extends Text {
|
|
|
88
88
|
|
|
89
89
|
#updateDisplay() {
|
|
90
90
|
const frame = this.#frames[this.#currentFrame];
|
|
91
|
-
|
|
92
|
-
if (this.#ui) {
|
|
91
|
+
const text = `${this.spinnerColorFn(frame)} ${this.messageColorFn(this.message)}`;
|
|
92
|
+
if (this.setText(text) && this.#ui) {
|
|
93
93
|
this.#ui.requestRender();
|
|
94
94
|
}
|
|
95
95
|
}
|
package/src/components/text.ts
CHANGED
|
@@ -26,14 +26,15 @@ export class Text implements Component {
|
|
|
26
26
|
return this.#text;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
setText(text: string):
|
|
29
|
+
setText(text: string): boolean {
|
|
30
30
|
if (text === this.#text) {
|
|
31
|
-
return;
|
|
31
|
+
return false;
|
|
32
32
|
}
|
|
33
33
|
this.#text = text;
|
|
34
34
|
this.#cachedText = undefined;
|
|
35
35
|
this.#cachedWidth = undefined;
|
|
36
36
|
this.#cachedLines = undefined;
|
|
37
|
+
return true;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
setCustomBgFn(customBgFn?: (text: string) => string): void {
|
package/src/keybindings.ts
CHANGED
|
@@ -100,11 +100,11 @@ export const TUI_KEYBINDINGS = {
|
|
|
100
100
|
description: "Delete character forward",
|
|
101
101
|
},
|
|
102
102
|
"tui.editor.deleteWordBackward": {
|
|
103
|
-
defaultKeys: ["ctrl+w", "alt+backspace", "ctrl+backspace"],
|
|
103
|
+
defaultKeys: ["ctrl+w", "alt+backspace", "ctrl+backspace", "super+alt+backspace"],
|
|
104
104
|
description: "Delete word backward",
|
|
105
105
|
},
|
|
106
106
|
"tui.editor.deleteWordForward": {
|
|
107
|
-
defaultKeys: ["alt+delete", "alt+d"],
|
|
107
|
+
defaultKeys: ["alt+delete", "alt+d", "super+alt+delete", "super+alt+d"],
|
|
108
108
|
description: "Delete word forward",
|
|
109
109
|
},
|
|
110
110
|
"tui.editor.deleteToLineStart": {
|
|
@@ -182,7 +182,7 @@ function isAsciiUppercaseLetter(key: string): boolean {
|
|
|
182
182
|
return code >= 65 && code <= 90;
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
-
function canonicalKeyId(key: string): string {
|
|
185
|
+
export function canonicalKeyId(key: string): string {
|
|
186
186
|
let offset = 0;
|
|
187
187
|
const modifiers: string[] = [];
|
|
188
188
|
let foundModifier = true;
|
|
@@ -214,7 +214,7 @@ function canonicalKeyId(key: string): string {
|
|
|
214
214
|
return `${modifiers.join("+")}+${base}`;
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
function addKeyAliases(keys: Set<string>, key: KeyId): void {
|
|
217
|
+
export function addKeyAliases(keys: Set<string>, key: KeyId): void {
|
|
218
218
|
const canonical = canonicalKeyId(key);
|
|
219
219
|
keys.add(canonical);
|
|
220
220
|
if (SHIFTED_SYMBOL_KEYS.has(canonical)) {
|
package/src/terminal.ts
CHANGED
|
@@ -9,6 +9,58 @@ const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000;
|
|
|
9
9
|
const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
|
|
10
10
|
const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07";
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Maximum bytes per `process.stdout.write` call on Windows.
|
|
14
|
+
*
|
|
15
|
+
* Windows ConPTY ties viewport tracking to per-`WriteFile` boundaries: when a
|
|
16
|
+
* single write exceeds ~32-64 KB, the pseudo-console stops following the
|
|
17
|
+
* cursor and the host UI's viewport stays parked at whatever scroll position
|
|
18
|
+
* the write started from. The visible symptom is that a full-paint of a long
|
|
19
|
+
* session (resume, history rebuild, large permission dialog) shows only the
|
|
20
|
+
* first ~30 lines until any focus event forces the host to re-query the
|
|
21
|
+
* cursor. The data is delivered correctly — it's purely a viewport-sync bug.
|
|
22
|
+
*
|
|
23
|
+
* 8 KiB is well below the 32 KiB threshold reported on Windows Terminal and
|
|
24
|
+
* leaves headroom for the other ConPTY hosts (Tabby, Hyper, VS Code) where
|
|
25
|
+
* the exact limit is undocumented. The cost is a handful of extra syscalls
|
|
26
|
+
* per full paint — invisible compared to the cost of the paint itself.
|
|
27
|
+
*/
|
|
28
|
+
const MAX_CONPTY_WRITE_CHUNK = 8 * 1024;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Split `data` into chunks no larger than `maxChunkSize`, preferring a line
|
|
32
|
+
* boundary (`\n`) as the cut point so escape sequences (which never contain
|
|
33
|
+
* `\n`) stay intact. The TUI's full-paint buffers are line-structured
|
|
34
|
+
* (`buffer += "\r\n"` between rows), so a newline almost always exists within
|
|
35
|
+
* the window. The fallback for a buffer with no newline in range is a hard
|
|
36
|
+
* cut at `maxChunkSize`: the ConPTY viewport bug from a single oversized
|
|
37
|
+
* write is strictly worse than a one-frame escape-sequence glitch on a buffer
|
|
38
|
+
* the renderer effectively never produces.
|
|
39
|
+
*
|
|
40
|
+
* Exported for unit testing of the chunking contract; `#safeWrite` is the
|
|
41
|
+
* sole production caller.
|
|
42
|
+
*/
|
|
43
|
+
export function chunkForConPTY(data: string, maxChunkSize: number = MAX_CONPTY_WRITE_CHUNK): string[] {
|
|
44
|
+
if (data.length <= maxChunkSize) return [data];
|
|
45
|
+
const chunks: string[] = [];
|
|
46
|
+
let pos = 0;
|
|
47
|
+
while (pos < data.length) {
|
|
48
|
+
const remaining = data.length - pos;
|
|
49
|
+
if (remaining <= maxChunkSize) {
|
|
50
|
+
chunks.push(data.slice(pos));
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
const windowEnd = pos + maxChunkSize;
|
|
54
|
+
// Prefer the last newline inside the window so escape sequences stay
|
|
55
|
+
// intact within their chunk; hard-cut at `windowEnd` otherwise.
|
|
56
|
+
const nl = data.lastIndexOf("\n", windowEnd - 1);
|
|
57
|
+
const cut = nl >= pos ? nl + 1 : windowEnd;
|
|
58
|
+
chunks.push(data.slice(pos, cut));
|
|
59
|
+
pos = cut;
|
|
60
|
+
}
|
|
61
|
+
return chunks;
|
|
62
|
+
}
|
|
63
|
+
|
|
12
64
|
/**
|
|
13
65
|
* Minimal terminal interface for TUI
|
|
14
66
|
*/
|
|
@@ -203,6 +255,8 @@ export class ProcessTerminal implements Terminal {
|
|
|
203
255
|
#privateModeCallbacks: Array<(mode: number, supported: boolean) => void> = [];
|
|
204
256
|
/** Whether DEC 2048 in-band resize notifications are currently enabled. */
|
|
205
257
|
#inBandResizeActive = false;
|
|
258
|
+
/** Reassembly buffer for a DEC 2048 in-band resize report split across stdin reads. */
|
|
259
|
+
#inBandResizeBuffer = "";
|
|
206
260
|
#reportedColumns?: number;
|
|
207
261
|
#reportedRows?: number;
|
|
208
262
|
#osc11PollTimer?: Timer;
|
|
@@ -436,6 +490,46 @@ export class ProcessTerminal implements Terminal {
|
|
|
436
490
|
}
|
|
437
491
|
}
|
|
438
492
|
|
|
493
|
+
// In-band resize report (DEC 2048) split across stdin reads. The report
|
|
494
|
+
// is `\x1b[48;rows;cols;yPx;xPx t`; when the StdinBuffer flush timeout
|
|
495
|
+
// elapses mid-sequence — common during a rapid resize that keeps the
|
|
496
|
+
// event loop busy — the `\x1b[48;…` prefix arrives as one event and the
|
|
497
|
+
// tail (`…;xPx t`) arrives as bare character events that would otherwise
|
|
498
|
+
// leak into the prompt as literal keystrokes. Reassemble until the
|
|
499
|
+
// terminator, then fall through to the resize handler below. A
|
|
500
|
+
// reassembled sequence that turns out not to be a resize report (e.g. a
|
|
501
|
+
// split kitty `\x1b[48;…u` for a digit key) is forwarded to the input
|
|
502
|
+
// handler rather than dropped.
|
|
503
|
+
const inBandResizePartialPattern = /^\x1b\[4[\d;]*$/;
|
|
504
|
+
const isInBandResizePartial = this.#inBandResizeActive && inBandResizePartialPattern.test(sequence);
|
|
505
|
+
if (this.#inBandResizeBuffer && sequence.startsWith("\x1b")) {
|
|
506
|
+
// A new escape interrupted the partial; the stale partial is
|
|
507
|
+
// unrecoverable. If the new escape is itself an in-band prefix,
|
|
508
|
+
// restart reassembly with it; otherwise let it flow through below.
|
|
509
|
+
this.#inBandResizeBuffer = isInBandResizePartial ? sequence : "";
|
|
510
|
+
if (isInBandResizePartial) return;
|
|
511
|
+
} else if (this.#inBandResizeBuffer || isInBandResizePartial) {
|
|
512
|
+
this.#inBandResizeBuffer += sequence;
|
|
513
|
+
if (this.#inBandResizeBuffer.length > 256) {
|
|
514
|
+
this.#inBandResizeBuffer = "";
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const lastCode = this.#inBandResizeBuffer.charCodeAt(this.#inBandResizeBuffer.length - 1);
|
|
518
|
+
if (lastCode >= 0x40 && lastCode <= 0x7e) {
|
|
519
|
+
// Terminator arrived: let the resize handler below claim it, or
|
|
520
|
+
// fall through to the input handler if it is not a resize report.
|
|
521
|
+
sequence = this.#inBandResizeBuffer;
|
|
522
|
+
this.#inBandResizeBuffer = "";
|
|
523
|
+
} else if (!inBandResizePartialPattern.test(this.#inBandResizeBuffer)) {
|
|
524
|
+
// Diverged from a valid in-band prefix — drop the garbled report.
|
|
525
|
+
this.#inBandResizeBuffer = "";
|
|
526
|
+
return;
|
|
527
|
+
} else {
|
|
528
|
+
// Still accumulating the report.
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
439
533
|
// In-band resize report (DEC mode 2048). Unsolicited and not tied to a
|
|
440
534
|
// sentinel: update reported geometry + cell size, then drive the resize
|
|
441
535
|
// handler so the renderer reflows.
|
|
@@ -504,11 +598,20 @@ export class ProcessTerminal implements Terminal {
|
|
|
504
598
|
}
|
|
505
599
|
|
|
506
600
|
const match = sequence.match(kittyResponsePattern);
|
|
507
|
-
if (match
|
|
601
|
+
if (match) {
|
|
508
602
|
if (this.#modifyOtherKeysTimeout) {
|
|
509
603
|
clearTimeout(this.#modifyOtherKeysTimeout);
|
|
510
604
|
this.#modifyOtherKeysTimeout = undefined;
|
|
511
605
|
}
|
|
606
|
+
// A DA1 sentinel that beat the kitty reply may have already
|
|
607
|
+
// engaged the modifyOtherKeys fallback (terminals such as
|
|
608
|
+
// Superset/xterm-on-Electron answer DA1 before `\x1b[?u`).
|
|
609
|
+
// Kitty is strictly preferred — undo the fallback so the two
|
|
610
|
+
// modes do not stack. See #2042.
|
|
611
|
+
if (this.#modifyOtherKeysActive) {
|
|
612
|
+
this.#safeWrite("\x1b[>4;0m");
|
|
613
|
+
this.#modifyOtherKeysActive = false;
|
|
614
|
+
}
|
|
512
615
|
// Any reply to `\x1b[?u` means the terminal speaks the kitty keyboard
|
|
513
616
|
// protocol. The reported flag value is the *current* stack-top — fresh
|
|
514
617
|
// terminals report 0 — so support is implied by the reply itself, not by
|
|
@@ -909,6 +1012,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
909
1012
|
this.#osc99Capabilities.clear();
|
|
910
1013
|
setOsc99Supported(false);
|
|
911
1014
|
this.#privateCsiResponseBuffer = "";
|
|
1015
|
+
this.#inBandResizeBuffer = "";
|
|
912
1016
|
this.#da1SentinelOwners.length = 0;
|
|
913
1017
|
this.#privateModeCallbacks = [];
|
|
914
1018
|
this.#privateModeSupport.clear();
|
|
@@ -978,7 +1082,23 @@ export class ProcessTerminal implements Terminal {
|
|
|
978
1082
|
// files). They serve no purpose there and would surface as visible noise.
|
|
979
1083
|
if (!process.stdout.isTTY) return;
|
|
980
1084
|
try {
|
|
981
|
-
|
|
1085
|
+
// Windows ConPTY drops viewport tracking when a single write exceeds
|
|
1086
|
+
// ~32-64 KB: the host UI's scroll position stays parked at wherever
|
|
1087
|
+
// the write began, even though every byte landed in scrollback. Split
|
|
1088
|
+
// large paints into newline-aligned chunks so each underlying
|
|
1089
|
+
// `WriteFile` stays well below the threshold. The gate also covers
|
|
1090
|
+
// WSL — `process.platform === "linux"` there, but stdout still
|
|
1091
|
+
// crosses into ConPTY at the `wslhost` boundary, so the same per-
|
|
1092
|
+
// WriteFile cap applies. Non-ConPTY PTYs keep the single-write fast
|
|
1093
|
+
// path. See #2034.
|
|
1094
|
+
const conptyHosted = process.platform === "win32" || isWindowsSubsystemForLinux();
|
|
1095
|
+
if (conptyHosted && data.length > MAX_CONPTY_WRITE_CHUNK) {
|
|
1096
|
+
for (const chunk of chunkForConPTY(data, MAX_CONPTY_WRITE_CHUNK)) {
|
|
1097
|
+
process.stdout.write(chunk);
|
|
1098
|
+
}
|
|
1099
|
+
} else {
|
|
1100
|
+
process.stdout.write(data);
|
|
1101
|
+
}
|
|
982
1102
|
} catch (err) {
|
|
983
1103
|
// Any write failure means terminal is dead - no recovery possible
|
|
984
1104
|
this.#dead = true;
|
package/src/tui.ts
CHANGED
|
@@ -47,6 +47,12 @@ const SEGMENT_RESET = "\x1b[0m";
|
|
|
47
47
|
const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07";
|
|
48
48
|
const ERASE_LINE = "\x1b[2K";
|
|
49
49
|
const ERASE_TO_END_OF_LINE = "\x1b[K";
|
|
50
|
+
// Keep the common short-row path out of native width/truncation. Longer rows
|
|
51
|
+
// are fit by visible cells, not source code units, so zero-width-heavy prefixes
|
|
52
|
+
// cannot hide visible suffix text that still belongs in the viewport.
|
|
53
|
+
const LINE_FIT_MIN_SOURCE_CODE_UNITS = 4096;
|
|
54
|
+
const LINE_FIT_MAX_SOURCE_CODE_UNITS = 65536;
|
|
55
|
+
const LINE_FIT_SOURCE_WIDTH_MULTIPLIER = 64;
|
|
50
56
|
// Hide the hardware cursor before each paint/move write. Ghostty-style bar
|
|
51
57
|
// cursors can otherwise leave visual afterimages while the TUI repaints the
|
|
52
58
|
// row under a visible cursor. Paint writes also disable terminal autowrap:
|
|
@@ -68,11 +74,13 @@ const CURSOR_BEGIN = `${HIDE_CURSOR}${SYNC_OUTPUT_BEGIN}`;
|
|
|
68
74
|
const CURSOR_BEGIN_NO_SYNC = HIDE_CURSOR;
|
|
69
75
|
const CURSOR_END = SYNC_OUTPUT_END;
|
|
70
76
|
const CURSOR_END_NO_SYNC = "";
|
|
71
|
-
// Mouse reporting
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
77
|
+
// Mouse reporting, enabled only for the lifetime of a fullscreen overlay so the
|
|
78
|
+
// rest of the app keeps the terminal's native text selection. 1000h = button
|
|
79
|
+
// click tracking, 1003h = any-motion tracking so overlays can light up hover
|
|
80
|
+
// targets (the pointer moving with no button held), 1006h = SGR extended
|
|
81
|
+
// coordinates so columns/rows past 223 are reported.
|
|
82
|
+
const MOUSE_TRACKING_ON = "\x1b[?1000h\x1b[?1003h\x1b[?1006h";
|
|
83
|
+
const MOUSE_TRACKING_OFF = "\x1b[?1006l\x1b[?1003l\x1b[?1000l";
|
|
76
84
|
|
|
77
85
|
type InputListenerResult = { consume?: boolean; data?: string } | undefined;
|
|
78
86
|
type InputListener = (data: string) => InputListenerResult;
|
|
@@ -433,6 +441,24 @@ type RenderIntent =
|
|
|
433
441
|
| { kind: "shrink" }
|
|
434
442
|
| { kind: "diff"; firstChanged: number; lastChanged: number; appendedLines: boolean };
|
|
435
443
|
|
|
444
|
+
interface HardwareCursorState {
|
|
445
|
+
row: number;
|
|
446
|
+
col: number;
|
|
447
|
+
visible: boolean;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
interface HardwareCursorUpdate {
|
|
451
|
+
toRow: number;
|
|
452
|
+
state: HardwareCursorState | null;
|
|
453
|
+
visible?: boolean;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
interface CursorControlResult extends HardwareCursorUpdate {
|
|
457
|
+
seq: string;
|
|
458
|
+
toCol: number;
|
|
459
|
+
visible: boolean;
|
|
460
|
+
}
|
|
461
|
+
|
|
436
462
|
interface PreparedLine {
|
|
437
463
|
raw: string;
|
|
438
464
|
width: number;
|
|
@@ -458,8 +484,21 @@ export class TUI extends Container {
|
|
|
458
484
|
#renderScheduler: RenderScheduler;
|
|
459
485
|
#lastRenderAt = 0;
|
|
460
486
|
static readonly #MIN_RENDER_INTERVAL_MS = 1000 / 30;
|
|
487
|
+
// Pane-reflow settle window for tmux/screen/zellij. The host process gets
|
|
488
|
+
// SIGWINCH (and `process.stdout` already reports the new geometry) before
|
|
489
|
+
// the multiplexer finishes repainting the pane at the new size, and
|
|
490
|
+
// drag-resize/pane-close animations fire several events in flight. A forced
|
|
491
|
+
// render on each SIGWINCH races those mid-reflow paints — the multiplexer's
|
|
492
|
+
// catch-up paint then partially overwrites the TUI output, which the user
|
|
493
|
+
// sees as a viewport flash or blank screen before the next throttled frame
|
|
494
|
+
// arrives (issue #2088). Coalescing every SIGWINCH inside this window into
|
|
495
|
+
// a single forced render lets the multiplexer settle first.
|
|
496
|
+
static readonly #MULTIPLEXER_RESIZE_DEBOUNCE_MS = 50;
|
|
461
497
|
#cursorRow = 0; // Logical cursor row (end of rendered content)
|
|
462
498
|
#hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
|
|
499
|
+
#hardwareCursorState: HardwareCursorState | null = null;
|
|
500
|
+
#hardwareCursorVisibilityKnown = false;
|
|
501
|
+
#hardwareCursorVisible = false;
|
|
463
502
|
#viewportTopRow = 0; // Content row currently mapped to screen row 0
|
|
464
503
|
#sixelProbePendingDa = false;
|
|
465
504
|
#sixelProbePendingGraphics = false;
|
|
@@ -504,6 +543,9 @@ export class TUI extends Container {
|
|
|
504
543
|
#clearScrollbackOnNextRender = false;
|
|
505
544
|
#forceViewportRepaintOnNextRender = false;
|
|
506
545
|
#allowUnknownViewportMutationOnNextRender = false;
|
|
546
|
+
// Focus changes are local live chrome (menus/editor/cursor), so the next
|
|
547
|
+
// frame may repaint an unknown-at-bottom viewport without waiting for a checkpoint.
|
|
548
|
+
#focusChangedSinceLastRender = false;
|
|
507
549
|
#eagerNativeScrollbackRebuild = false;
|
|
508
550
|
// Set when eager mode is switched off; applied after the next frame is
|
|
509
551
|
// classified so teardown frames from the same event batch still render
|
|
@@ -519,6 +561,13 @@ export class TUI extends Container {
|
|
|
519
561
|
// between the viewport and scrollback, so the previous frame no longer
|
|
520
562
|
// describes the screen. Tracking only the dimension delta misses this.
|
|
521
563
|
#resizeEventPending = false;
|
|
564
|
+
// Active multiplexer SIGWINCH debounce. Reset on each event so the timer
|
|
565
|
+
// only fires once the pane stops resizing. Forced renders (resetDisplay,
|
|
566
|
+
// finishSixelProbe, …) issued during the settle window route through the
|
|
567
|
+
// same timer; their `clearScrollback` intent is OR'd into the deferred
|
|
568
|
+
// flag below so the settled paint still honours every caller's request.
|
|
569
|
+
#multiplexerResizeTimer: RenderTimer | undefined;
|
|
570
|
+
#deferredForcedClearScrollback = false;
|
|
522
571
|
#stopped = false;
|
|
523
572
|
|
|
524
573
|
// Transient alternate-screen state for a fullscreen overlay. While active, the
|
|
@@ -612,6 +661,7 @@ export class TUI extends Container {
|
|
|
612
661
|
this.#syncTerminalCursorMode(this.#focusedComponent);
|
|
613
662
|
if (!enabled) {
|
|
614
663
|
this.terminal.hideCursor();
|
|
664
|
+
this.#recordHardwareCursorHidden();
|
|
615
665
|
}
|
|
616
666
|
this.requestRender();
|
|
617
667
|
}
|
|
@@ -687,12 +737,16 @@ export class TUI extends Container {
|
|
|
687
737
|
}
|
|
688
738
|
|
|
689
739
|
setFocus(component: Component | null): void {
|
|
740
|
+
const previousFocusedComponent = this.#focusedComponent;
|
|
690
741
|
// Clear focused flag on old component
|
|
691
|
-
if (isFocusable(
|
|
692
|
-
|
|
742
|
+
if (isFocusable(previousFocusedComponent)) {
|
|
743
|
+
previousFocusedComponent.focused = false;
|
|
693
744
|
}
|
|
694
745
|
|
|
695
746
|
this.#focusedComponent = component;
|
|
747
|
+
if (previousFocusedComponent !== component) {
|
|
748
|
+
this.#focusChangedSinceLastRender = true;
|
|
749
|
+
}
|
|
696
750
|
|
|
697
751
|
// Set focused flag on new component and keep its software/hardware cursor
|
|
698
752
|
// rendering mode aligned with TUI's single cursor-visibility preference.
|
|
@@ -714,6 +768,7 @@ export class TUI extends Container {
|
|
|
714
768
|
this.setFocus(component);
|
|
715
769
|
}
|
|
716
770
|
this.terminal.hideCursor();
|
|
771
|
+
this.#recordHardwareCursorHidden();
|
|
717
772
|
this.requestRender();
|
|
718
773
|
|
|
719
774
|
// Return handle for controlling this overlay
|
|
@@ -727,7 +782,10 @@ export class TUI extends Container {
|
|
|
727
782
|
const topVisible = this.#getTopmostVisibleOverlay();
|
|
728
783
|
this.setFocus(topVisible?.component ?? entry.preFocus);
|
|
729
784
|
}
|
|
730
|
-
if (this.overlayStack.length === 0)
|
|
785
|
+
if (this.overlayStack.length === 0) {
|
|
786
|
+
this.terminal.hideCursor();
|
|
787
|
+
this.#recordHardwareCursorHidden();
|
|
788
|
+
}
|
|
731
789
|
this.requestRender();
|
|
732
790
|
}
|
|
733
791
|
},
|
|
@@ -760,7 +818,10 @@ export class TUI extends Container {
|
|
|
760
818
|
// Find topmost visible overlay, or fall back to preFocus
|
|
761
819
|
const topVisible = this.#getTopmostVisibleOverlay();
|
|
762
820
|
this.setFocus(topVisible?.component ?? overlay.preFocus);
|
|
763
|
-
if (this.overlayStack.length === 0)
|
|
821
|
+
if (this.overlayStack.length === 0) {
|
|
822
|
+
this.terminal.hideCursor();
|
|
823
|
+
this.#recordHardwareCursorHidden();
|
|
824
|
+
}
|
|
764
825
|
this.requestRender();
|
|
765
826
|
}
|
|
766
827
|
|
|
@@ -816,12 +877,29 @@ export class TUI extends Container {
|
|
|
816
877
|
this.terminal.start(
|
|
817
878
|
data => this.#handleInput(data),
|
|
818
879
|
() => {
|
|
819
|
-
//
|
|
820
|
-
//
|
|
821
|
-
//
|
|
822
|
-
//
|
|
880
|
+
// Real terminals deliver SIGWINCH (and the equivalent ConPTY
|
|
881
|
+
// notification) atomically with the new `process.stdout` geometry, so
|
|
882
|
+
// a forced render must fire immediately: it clears and replays at the
|
|
883
|
+
// fresh size before the terminal's reflow settles into a state a
|
|
884
|
+
// throttled frame would race. Multiplexer panes (tmux/screen/zellij)
|
|
885
|
+
// do not give that guarantee. The host receives SIGWINCH while the
|
|
886
|
+
// multiplexer is still mid-reflow — it has not finished repainting
|
|
887
|
+
// the pane buffer at the new size — and a drag-resize or pane-close
|
|
888
|
+
// animation fires several events in flight. Forcing a render on each
|
|
889
|
+
// event races those mid-reflow paints: the multiplexer's catch-up
|
|
890
|
+
// paint then partially overwrites the TUI output, which the user sees
|
|
891
|
+
// as a viewport flash or blank screen before the next throttled
|
|
892
|
+
// frame arrives (issue #2088). `#armMultiplexerResizeTimer` coalesces
|
|
893
|
+
// SIGWINCHes (and any forced repaints arriving during the settle
|
|
894
|
+
// window) into a single render once the pane is quiet —
|
|
895
|
+
// `#resizeEventPending` is set first so the eventual render still
|
|
896
|
+
// classifies as a resize.
|
|
823
897
|
this.#resizeEventPending = true;
|
|
824
|
-
|
|
898
|
+
if (!isMultiplexerSession()) {
|
|
899
|
+
this.requestRender(true);
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
this.#armMultiplexerResizeTimer(false);
|
|
825
903
|
},
|
|
826
904
|
);
|
|
827
905
|
for (const listener of this.#startListeners) {
|
|
@@ -832,6 +910,7 @@ export class TUI extends Container {
|
|
|
832
910
|
}
|
|
833
911
|
}
|
|
834
912
|
this.terminal.hideCursor();
|
|
913
|
+
this.#recordHardwareCursorHidden();
|
|
835
914
|
this.#querySixelSupport();
|
|
836
915
|
this.#queryCellSize();
|
|
837
916
|
this.requestRender(true, { clearScrollback: options?.clearScrollback === true });
|
|
@@ -1023,6 +1102,11 @@ export class TUI extends Container {
|
|
|
1023
1102
|
this.#renderTimer.cancel();
|
|
1024
1103
|
this.#renderTimer = undefined;
|
|
1025
1104
|
}
|
|
1105
|
+
if (this.#multiplexerResizeTimer) {
|
|
1106
|
+
this.#multiplexerResizeTimer.cancel();
|
|
1107
|
+
this.#multiplexerResizeTimer = undefined;
|
|
1108
|
+
}
|
|
1109
|
+
this.#deferredForcedClearScrollback = false;
|
|
1026
1110
|
// Place the parent shell on the first line after the rendered content. When
|
|
1027
1111
|
// that line is still inside the viewport, moving there and writing `\r` is
|
|
1028
1112
|
// enough; emitting `\r\n` would create an extra blank row. If the content
|
|
@@ -1043,6 +1127,7 @@ export class TUI extends Container {
|
|
|
1043
1127
|
}
|
|
1044
1128
|
|
|
1045
1129
|
this.terminal.showCursor();
|
|
1130
|
+
this.#forgetHardwareCursorState();
|
|
1046
1131
|
this.terminal.stop();
|
|
1047
1132
|
}
|
|
1048
1133
|
|
|
@@ -1096,6 +1181,15 @@ export class TUI extends Container {
|
|
|
1096
1181
|
resetDisplay(): void {
|
|
1097
1182
|
if (this.#stopped) return;
|
|
1098
1183
|
this.invalidate();
|
|
1184
|
+
// A reset that lands inside a tmux/screen/zellij resize burst would
|
|
1185
|
+
// paint mid-reflow and re-introduce the flash race (issue #2088).
|
|
1186
|
+
// Fold it into the in-flight debounce instead; the settled paint runs
|
|
1187
|
+
// the same `#prepareForcedRender(!isMultiplexerSession())` path via
|
|
1188
|
+
// `requestRender(true)`, so the clear-scrollback intent is preserved.
|
|
1189
|
+
if (this.#multiplexerResizeTimer) {
|
|
1190
|
+
this.#armMultiplexerResizeTimer(!isMultiplexerSession());
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1099
1193
|
this.#prepareForcedRender(!isMultiplexerSession());
|
|
1100
1194
|
this.#resizeEventPending = true;
|
|
1101
1195
|
this.#renderRequested = false;
|
|
@@ -1107,6 +1201,19 @@ export class TUI extends Container {
|
|
|
1107
1201
|
const allowUnknownViewportMutation = options?.allowUnknownViewportMutation === true;
|
|
1108
1202
|
this.#allowUnknownViewportMutationOnNextRender ||= allowUnknownViewportMutation;
|
|
1109
1203
|
if (force) {
|
|
1204
|
+
// Forced repaints landing inside the multiplexer resize debounce
|
|
1205
|
+
// (e.g. `#finishSixelProbe`, image-budget eviction, a programmatic
|
|
1206
|
+
// `requestRender(true)`) would paint into a still-reflowing pane
|
|
1207
|
+
// and reintroduce the flash race. Fold them into the in-flight
|
|
1208
|
+
// debounce while preserving the caller's `clearScrollback` intent
|
|
1209
|
+
// for the settled paint. The timer's own callback clears
|
|
1210
|
+
// `#multiplexerResizeTimer` before re-entering `requestRender(true)`,
|
|
1211
|
+
// so this guard only catches external callers — the deferred render
|
|
1212
|
+
// itself proceeds straight to `#prepareForcedRender`.
|
|
1213
|
+
if (this.#multiplexerResizeTimer) {
|
|
1214
|
+
this.#armMultiplexerResizeTimer(options?.clearScrollback === true);
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1110
1217
|
this.#prepareForcedRender(options?.clearScrollback === true);
|
|
1111
1218
|
this.#renderRequested = true;
|
|
1112
1219
|
this.#renderScheduler.scheduleImmediate(() => {
|
|
@@ -1124,6 +1231,37 @@ export class TUI extends Container {
|
|
|
1124
1231
|
this.#renderScheduler.scheduleImmediate(() => this.#scheduleRender());
|
|
1125
1232
|
}
|
|
1126
1233
|
|
|
1234
|
+
/**
|
|
1235
|
+
* Arm or extend the multiplexer-resize debounce so a single forced render
|
|
1236
|
+
* fires once the pane is quiet. Called by the SIGWINCH callback on every
|
|
1237
|
+
* resize event, and by `requestRender(true)` / `resetDisplay()` when they
|
|
1238
|
+
* land inside an in-flight settle window. Each call cancels the prior
|
|
1239
|
+
* timer, supersedes any queued throttled render (otherwise it would race
|
|
1240
|
+
* tmux's mid-reflow paint), and OR's the caller's `clearScrollback`
|
|
1241
|
+
* intent into `#deferredForcedClearScrollback` — the timer's callback
|
|
1242
|
+
* consumes that flag exactly once when it re-enters `requestRender(true)`.
|
|
1243
|
+
*/
|
|
1244
|
+
#armMultiplexerResizeTimer(clearScrollback: boolean): void {
|
|
1245
|
+
this.#deferredForcedClearScrollback ||= clearScrollback;
|
|
1246
|
+
if (this.#renderTimer) {
|
|
1247
|
+
this.#renderTimer.cancel();
|
|
1248
|
+
this.#renderTimer = undefined;
|
|
1249
|
+
}
|
|
1250
|
+
this.#renderRequested = false;
|
|
1251
|
+
if (this.#multiplexerResizeTimer) {
|
|
1252
|
+
this.#multiplexerResizeTimer.cancel();
|
|
1253
|
+
}
|
|
1254
|
+
this.#multiplexerResizeTimer = this.#renderScheduler.scheduleRender(() => {
|
|
1255
|
+
this.#multiplexerResizeTimer = undefined;
|
|
1256
|
+
if (this.#stopped) {
|
|
1257
|
+
this.#deferredForcedClearScrollback = false;
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
const deferredClearScrollback = this.#deferredForcedClearScrollback;
|
|
1261
|
+
this.#deferredForcedClearScrollback = false;
|
|
1262
|
+
this.requestRender(true, { clearScrollback: deferredClearScrollback });
|
|
1263
|
+
}, TUI.#MULTIPLEXER_RESIZE_DEBOUNCE_MS);
|
|
1264
|
+
}
|
|
1127
1265
|
#prepareForcedRender(clearScrollback: boolean): void {
|
|
1128
1266
|
const geometryChanged =
|
|
1129
1267
|
(this.#previousWidth > 0 && this.#previousWidth !== this.terminal.columns) ||
|
|
@@ -1147,6 +1285,13 @@ export class TUI extends Container {
|
|
|
1147
1285
|
if (this.#stopped || this.#renderTimer || !this.#renderRequested) {
|
|
1148
1286
|
return;
|
|
1149
1287
|
}
|
|
1288
|
+
// Defer any new throttled render scheduled inside the multiplexer
|
|
1289
|
+
// resize settle window: it would race tmux's mid-reflow pane repaint.
|
|
1290
|
+
// `#renderRequested` stays set so the eventual forced render — armed
|
|
1291
|
+
// by the SIGWINCH callback — picks up the latest component state.
|
|
1292
|
+
if (this.#multiplexerResizeTimer) {
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1150
1295
|
const elapsed = this.#renderScheduler.now() - this.#lastRenderAt;
|
|
1151
1296
|
const delay = Math.max(0, TUI.#MIN_RENDER_INTERVAL_MS - elapsed);
|
|
1152
1297
|
this.#renderTimer = this.#renderScheduler.scheduleRender(() => {
|
|
@@ -1564,12 +1709,15 @@ export class TUI extends Container {
|
|
|
1564
1709
|
if (wantAlt && !this.#altActive) {
|
|
1565
1710
|
this.terminal.write(`\x1b[?1049h${MOUSE_TRACKING_ON}`);
|
|
1566
1711
|
this.terminal.hideCursor();
|
|
1712
|
+
this.#forgetHardwareCursorState();
|
|
1713
|
+
this.#recordHardwareCursorHidden();
|
|
1567
1714
|
this.#altActive = true;
|
|
1568
1715
|
this.#altPreviousLines = [];
|
|
1569
1716
|
this.#altEnterWidth = width;
|
|
1570
1717
|
this.#altEnterHeight = height;
|
|
1571
1718
|
} else if (!wantAlt && this.#altActive) {
|
|
1572
1719
|
this.terminal.write(`${MOUSE_TRACKING_OFF}\x1b[?1049l`);
|
|
1720
|
+
this.#forgetHardwareCursorState();
|
|
1573
1721
|
this.#altActive = false;
|
|
1574
1722
|
this.#altPreviousLines = [];
|
|
1575
1723
|
// A resize while on the alt buffer reflowed the terminal's saved normal
|
|
@@ -1611,6 +1759,9 @@ export class TUI extends Container {
|
|
|
1611
1759
|
const prevHardwareCursorRow = this.#hardwareCursorRow;
|
|
1612
1760
|
const resizeEventOccurred = this.#resizeEventPending;
|
|
1613
1761
|
this.#resizeEventPending = false;
|
|
1762
|
+
if (resizeEventOccurred) {
|
|
1763
|
+
this.#forgetHardwareCursorState();
|
|
1764
|
+
}
|
|
1614
1765
|
const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
|
|
1615
1766
|
// A resize event with net-unchanged dimensions still reflowed the terminal
|
|
1616
1767
|
// buffer; classify it as a height change so the geometry branches repaint
|
|
@@ -1620,7 +1771,9 @@ export class TUI extends Container {
|
|
|
1620
1771
|
(resizeEventOccurred && this.#previousHeight > 0);
|
|
1621
1772
|
const eagerEraseScrollbackRisk = this.#hasEagerEraseScrollbackRisk();
|
|
1622
1773
|
const eagerRebuildAllowed = this.#eagerNativeScrollbackRebuild && !eagerEraseScrollbackRisk;
|
|
1623
|
-
const
|
|
1774
|
+
const focusChanged = this.#focusChangedSinceLastRender;
|
|
1775
|
+
this.#focusChangedSinceLastRender = false;
|
|
1776
|
+
const explicitViewportMutation = this.#allowUnknownViewportMutationOnNextRender || focusChanged;
|
|
1624
1777
|
const allowUnknownViewportMutation = explicitViewportMutation || eagerRebuildAllowed;
|
|
1625
1778
|
this.#allowUnknownViewportMutationOnNextRender = false;
|
|
1626
1779
|
|
|
@@ -2049,7 +2202,9 @@ export class TUI extends Container {
|
|
|
2049
2202
|
newLines.length < this.#previousLines.length &&
|
|
2050
2203
|
naturalViewportTop !== prevViewportTop
|
|
2051
2204
|
) {
|
|
2052
|
-
return
|
|
2205
|
+
return this.#bottomAnchoredViewportUnchanged(newLines, height)
|
|
2206
|
+
? { kind: "deferredMutation" }
|
|
2207
|
+
: { kind: "viewportRepaint" };
|
|
2053
2208
|
}
|
|
2054
2209
|
|
|
2055
2210
|
// Direct-input shrink can also move the natural viewport upward even when
|
|
@@ -2437,6 +2592,17 @@ export class TUI extends Container {
|
|
|
2437
2592
|
return { kind: "liveRegionPinned", appendFrom, appendTo, renderViewportTop };
|
|
2438
2593
|
}
|
|
2439
2594
|
|
|
2595
|
+
#bottomAnchoredViewportUnchanged(newLines: string[], height: number): boolean {
|
|
2596
|
+
const previousViewportTop = Math.max(0, this.#previousLines.length - height);
|
|
2597
|
+
const newViewportTop = Math.max(0, newLines.length - height);
|
|
2598
|
+
for (let row = 0; row < height; row++) {
|
|
2599
|
+
if ((newLines[newViewportTop + row] ?? "") !== (this.#previousLines[previousViewportTop + row] ?? "")) {
|
|
2600
|
+
return false;
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
return true;
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2440
2606
|
#planDeferredTailRepaint(newLines: string[], prevViewportTop: number, height: number): RenderIntent {
|
|
2441
2607
|
const row = prevViewportTop + height - 1;
|
|
2442
2608
|
if (row < 0 || row >= this.#previousLines.length || newLines.length !== this.#previousLines.length) {
|
|
@@ -2478,7 +2644,8 @@ export class TUI extends Container {
|
|
|
2478
2644
|
if (TERMINAL.isImageLine(raw)) {
|
|
2479
2645
|
return { raw, width, line: raw };
|
|
2480
2646
|
}
|
|
2481
|
-
const
|
|
2647
|
+
const source = this.#lineFitSource(raw, width);
|
|
2648
|
+
const normalized = normalizeTerminalOutput(source);
|
|
2482
2649
|
const asciiWidth = this.#ansiAsciiLineWidth(normalized, width);
|
|
2483
2650
|
if ((asciiWidth ?? visibleWidth(normalized)) <= width) {
|
|
2484
2651
|
return { raw, width, line: normalized };
|
|
@@ -2487,6 +2654,91 @@ export class TUI extends Container {
|
|
|
2487
2654
|
return { raw, width, line };
|
|
2488
2655
|
}
|
|
2489
2656
|
|
|
2657
|
+
#lineFitSource(raw: string, width: number): string {
|
|
2658
|
+
const safeWidth = Number.isFinite(width) ? Math.max(1, Math.trunc(width)) : 1;
|
|
2659
|
+
const maxSourceLength = Math.min(
|
|
2660
|
+
LINE_FIT_MAX_SOURCE_CODE_UNITS,
|
|
2661
|
+
Math.max(LINE_FIT_MIN_SOURCE_CODE_UNITS, safeWidth * LINE_FIT_SOURCE_WIDTH_MULTIPLIER),
|
|
2662
|
+
);
|
|
2663
|
+
if (raw.length <= maxSourceLength) return raw;
|
|
2664
|
+
|
|
2665
|
+
let output = "";
|
|
2666
|
+
let cells = 0;
|
|
2667
|
+
for (let i = 0; i < raw.length && cells < safeWidth; ) {
|
|
2668
|
+
if (raw.charCodeAt(i) === 0x1b) {
|
|
2669
|
+
const end = this.#ansiSequenceEnd(raw, i);
|
|
2670
|
+
if (end < 0) break;
|
|
2671
|
+
if (this.#ansiSequenceHasVisiblePayload(raw, i)) {
|
|
2672
|
+
const sequence = raw.slice(i, end);
|
|
2673
|
+
if (output.length + sequence.length <= maxSourceLength) {
|
|
2674
|
+
output += sequence;
|
|
2675
|
+
cells += visibleWidth(sequence);
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
i = end;
|
|
2679
|
+
continue;
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
const code = raw.charCodeAt(i);
|
|
2683
|
+
const next = code >= 0xd800 && code <= 0xdbff && i + 1 < raw.length ? i + 2 : i + 1;
|
|
2684
|
+
const char = raw.slice(i, next);
|
|
2685
|
+
const charWidth = visibleWidth(char);
|
|
2686
|
+
if (charWidth > 0 && cells + charWidth > safeWidth) break;
|
|
2687
|
+
if (output.length + char.length > maxSourceLength) {
|
|
2688
|
+
if (charWidth > 0) break;
|
|
2689
|
+
i = next;
|
|
2690
|
+
continue;
|
|
2691
|
+
}
|
|
2692
|
+
if (charWidth === 0) {
|
|
2693
|
+
const remainingVisibleCells = safeWidth - cells;
|
|
2694
|
+
const reservedCodeUnits = remainingVisibleCells * 2;
|
|
2695
|
+
if (output.length + char.length > maxSourceLength - reservedCodeUnits) {
|
|
2696
|
+
i = next;
|
|
2697
|
+
continue;
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
output += char;
|
|
2701
|
+
cells += charWidth;
|
|
2702
|
+
i = next;
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
return output + SEGMENT_RESET;
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
#ansiSequenceEnd(line: string, start: number): number {
|
|
2709
|
+
const next = line.charCodeAt(start + 1);
|
|
2710
|
+
if (next === 0x5b) {
|
|
2711
|
+
let i = start + 2;
|
|
2712
|
+
while (i < line.length) {
|
|
2713
|
+
const final = line.charCodeAt(i);
|
|
2714
|
+
if (final >= 0x40 && final <= 0x7e) return i + 1;
|
|
2715
|
+
i++;
|
|
2716
|
+
}
|
|
2717
|
+
return -1;
|
|
2718
|
+
}
|
|
2719
|
+
if (next === 0x5d) {
|
|
2720
|
+
let i = start + 2;
|
|
2721
|
+
while (i < line.length) {
|
|
2722
|
+
const osc = line.charCodeAt(i);
|
|
2723
|
+
if (osc === 0x07) return i + 1;
|
|
2724
|
+
if (osc === 0x1b && line.charCodeAt(i + 1) === 0x5c) return i + 2;
|
|
2725
|
+
i++;
|
|
2726
|
+
}
|
|
2727
|
+
return -1;
|
|
2728
|
+
}
|
|
2729
|
+
return start + 2 <= line.length ? start + 2 : -1;
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
#ansiSequenceHasVisiblePayload(line: string, start: number): boolean {
|
|
2733
|
+
// OSC 66 (`\x1b]66;META;TEXT\x1b\\`) carries visible cells inside the payload.
|
|
2734
|
+
return (
|
|
2735
|
+
line.charCodeAt(start + 1) === 0x5d &&
|
|
2736
|
+
line.charCodeAt(start + 2) === 0x36 &&
|
|
2737
|
+
line.charCodeAt(start + 3) === 0x36 &&
|
|
2738
|
+
line.charCodeAt(start + 4) === 0x3b
|
|
2739
|
+
);
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2490
2742
|
#ansiAsciiLineWidth(line: string, maxWidth: number): number | undefined {
|
|
2491
2743
|
let col = 0;
|
|
2492
2744
|
for (let i = 0; i < line.length; ) {
|
|
@@ -2553,7 +2805,13 @@ export class TUI extends Container {
|
|
|
2553
2805
|
* the end so cursor/viewport/scrollback accounting stays consistent.
|
|
2554
2806
|
*/
|
|
2555
2807
|
|
|
2556
|
-
#commit(
|
|
2808
|
+
#commit(
|
|
2809
|
+
lines: string[],
|
|
2810
|
+
width: number,
|
|
2811
|
+
height: number,
|
|
2812
|
+
viewportTop: number,
|
|
2813
|
+
hardwareCursor: HardwareCursorUpdate,
|
|
2814
|
+
): void {
|
|
2557
2815
|
this.#deferredTailLine = undefined;
|
|
2558
2816
|
this.#previousLines = lines;
|
|
2559
2817
|
this.#previousVisibleOverlayComponents = this.#visibleOverlayComponentsThisRender;
|
|
@@ -2562,7 +2820,73 @@ export class TUI extends Container {
|
|
|
2562
2820
|
this.#previousHeight = height;
|
|
2563
2821
|
this.#cursorRow = Math.max(0, lines.length - 1);
|
|
2564
2822
|
this.#viewportTopRow = viewportTop;
|
|
2565
|
-
this.#
|
|
2823
|
+
this.#recordHardwareCursorUpdate(hardwareCursor);
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
#targetHardwareCursorState(
|
|
2827
|
+
cursorPos: { row: number; col: number } | null,
|
|
2828
|
+
totalLines: number,
|
|
2829
|
+
): HardwareCursorState | null {
|
|
2830
|
+
if (!cursorPos || totalLines <= 0) return null;
|
|
2831
|
+
return {
|
|
2832
|
+
row: Math.max(0, Math.min(cursorPos.row, totalLines - 1)),
|
|
2833
|
+
col: Math.max(0, cursorPos.col),
|
|
2834
|
+
visible: this.#showHardwareCursor,
|
|
2835
|
+
};
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
#recordHardwareCursorState(state: HardwareCursorState): void {
|
|
2839
|
+
this.#hardwareCursorRow = state.row;
|
|
2840
|
+
this.#hardwareCursorState = state;
|
|
2841
|
+
this.#hardwareCursorVisible = state.visible;
|
|
2842
|
+
this.#hardwareCursorVisibilityKnown = true;
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
#recordHardwareCursorRowOnly(row: number, visible?: boolean): void {
|
|
2846
|
+
this.#hardwareCursorRow = row;
|
|
2847
|
+
this.#hardwareCursorState = null;
|
|
2848
|
+
if (visible !== undefined) {
|
|
2849
|
+
this.#hardwareCursorVisible = visible;
|
|
2850
|
+
this.#hardwareCursorVisibilityKnown = true;
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
#recordHardwareCursorUpdate(update: HardwareCursorUpdate): void {
|
|
2855
|
+
if (update.state) {
|
|
2856
|
+
this.#recordHardwareCursorState(update.state);
|
|
2857
|
+
return;
|
|
2858
|
+
}
|
|
2859
|
+
this.#recordHardwareCursorRowOnly(update.toRow, update.visible);
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
#recordHardwareCursorHidden(): void {
|
|
2863
|
+
this.#hardwareCursorVisible = false;
|
|
2864
|
+
this.#hardwareCursorVisibilityKnown = true;
|
|
2865
|
+
if (!this.#hardwareCursorState) return;
|
|
2866
|
+
this.#hardwareCursorState = { ...this.#hardwareCursorState, visible: false };
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
#forgetHardwareCursorState(): void {
|
|
2870
|
+
this.#hardwareCursorState = null;
|
|
2871
|
+
this.#hardwareCursorVisibilityKnown = false;
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
#sameHardwareCursorState(state: HardwareCursorState): boolean {
|
|
2875
|
+
const current = this.#hardwareCursorState;
|
|
2876
|
+
return (
|
|
2877
|
+
current !== null && current.row === state.row && current.col === state.col && current.visible === state.visible
|
|
2878
|
+
);
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
#preserveHardwareCursorUpdate(row: number): HardwareCursorUpdate {
|
|
2882
|
+
if (this.#hardwareCursorState?.row === row) {
|
|
2883
|
+
return { toRow: row, state: this.#hardwareCursorState, visible: this.#hardwareCursorState.visible };
|
|
2884
|
+
}
|
|
2885
|
+
return {
|
|
2886
|
+
toRow: row,
|
|
2887
|
+
state: null,
|
|
2888
|
+
visible: this.#hardwareCursorVisibilityKnown ? this.#hardwareCursorVisible : undefined,
|
|
2889
|
+
};
|
|
2566
2890
|
}
|
|
2567
2891
|
|
|
2568
2892
|
/**
|
|
@@ -2625,8 +2949,8 @@ export class TUI extends Container {
|
|
|
2625
2949
|
}
|
|
2626
2950
|
buffer += fillSequence;
|
|
2627
2951
|
const finalRow = Math.max(0, lines.length - 1);
|
|
2628
|
-
const
|
|
2629
|
-
buffer += seq;
|
|
2952
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, lines.length, finalRow);
|
|
2953
|
+
buffer += cursorControl.seq;
|
|
2630
2954
|
buffer += this.#paintEndSequence;
|
|
2631
2955
|
this.terminal.write(buffer);
|
|
2632
2956
|
|
|
@@ -2639,7 +2963,7 @@ export class TUI extends Container {
|
|
|
2639
2963
|
if (pushedNow > this.#scrollbackHighWater) {
|
|
2640
2964
|
this.#scrollbackHighWater = pushedNow;
|
|
2641
2965
|
}
|
|
2642
|
-
this.#commit(lines, width, height, Math.max(0, this.#maxLinesRendered - height),
|
|
2966
|
+
this.#commit(lines, width, height, Math.max(0, this.#maxLinesRendered - height), cursorControl);
|
|
2643
2967
|
}
|
|
2644
2968
|
|
|
2645
2969
|
/**
|
|
@@ -2683,14 +3007,14 @@ export class TUI extends Container {
|
|
|
2683
3007
|
const contentBottomRow = Math.min(viewportBottomRow, Math.max(viewportTop, lines.length - 1));
|
|
2684
3008
|
const parkUp = viewportBottomRow - contentBottomRow;
|
|
2685
3009
|
if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
|
|
2686
|
-
const
|
|
2687
|
-
buffer += seq;
|
|
3010
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, lines.length, contentBottomRow);
|
|
3011
|
+
buffer += cursorControl.seq;
|
|
2688
3012
|
buffer += this.#paintEndSequence;
|
|
2689
3013
|
this.terminal.write(buffer);
|
|
2690
3014
|
|
|
2691
3015
|
this.#maxLinesRendered = Math.max(lines.length, viewportTop + height);
|
|
2692
3016
|
this.#scrollbackHighWater = appendTo;
|
|
2693
|
-
this.#commit(lines, width, height, viewportTop,
|
|
3017
|
+
this.#commit(lines, width, height, viewportTop, cursorControl);
|
|
2694
3018
|
}
|
|
2695
3019
|
/**
|
|
2696
3020
|
* Rewrite the visible viewport in place. Cursor home, clear each row,
|
|
@@ -2756,13 +3080,13 @@ export class TUI extends Container {
|
|
|
2756
3080
|
const contentBottomRow = Math.min(viewportBottomRow, Math.max(viewportTop, lines.length - 1));
|
|
2757
3081
|
const parkUp = viewportBottomRow - contentBottomRow;
|
|
2758
3082
|
if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
|
|
2759
|
-
const
|
|
2760
|
-
buffer += seq;
|
|
3083
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, lines.length, contentBottomRow);
|
|
3084
|
+
buffer += cursorControl.seq;
|
|
2761
3085
|
buffer += this.#paintEndSequence;
|
|
2762
3086
|
this.terminal.write(buffer);
|
|
2763
3087
|
|
|
2764
3088
|
this.#maxLinesRendered = lines.length;
|
|
2765
|
-
this.#commit(lines, width, height, viewportTop,
|
|
3089
|
+
this.#commit(lines, width, height, viewportTop, cursorControl);
|
|
2766
3090
|
}
|
|
2767
3091
|
|
|
2768
3092
|
/** Topmost visible overlay requests the alternate-screen buffer. */
|
|
@@ -2875,13 +3199,13 @@ export class TUI extends Container {
|
|
|
2875
3199
|
}
|
|
2876
3200
|
cursorFromRow = viewportTop + lastChangedScreenRow;
|
|
2877
3201
|
}
|
|
2878
|
-
const
|
|
2879
|
-
buffer += seq;
|
|
3202
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, lines.length, cursorFromRow);
|
|
3203
|
+
buffer += cursorControl.seq;
|
|
2880
3204
|
buffer += this.#paintEndSequence;
|
|
2881
3205
|
this.terminal.write(buffer);
|
|
2882
3206
|
|
|
2883
3207
|
this.#maxLinesRendered = Math.max(lines.length, viewportTop + height);
|
|
2884
|
-
this.#commit(lines, width, height, viewportTop,
|
|
3208
|
+
this.#commit(lines, width, height, viewportTop, cursorControl);
|
|
2885
3209
|
return;
|
|
2886
3210
|
}
|
|
2887
3211
|
|
|
@@ -2915,8 +3239,8 @@ export class TUI extends Container {
|
|
|
2915
3239
|
const contentBottomRow = Math.min(viewportBottomRow, Math.max(viewportTop, lines.length - 1));
|
|
2916
3240
|
const parkUp = viewportBottomRow - contentBottomRow;
|
|
2917
3241
|
if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
|
|
2918
|
-
const
|
|
2919
|
-
buffer += seq;
|
|
3242
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, lines.length, contentBottomRow);
|
|
3243
|
+
buffer += cursorControl.seq;
|
|
2920
3244
|
buffer += this.#paintEndSequence;
|
|
2921
3245
|
this.terminal.write(buffer);
|
|
2922
3246
|
|
|
@@ -2924,7 +3248,7 @@ export class TUI extends Container {
|
|
|
2924
3248
|
if (boundedAppendTo > this.#scrollbackHighWater) {
|
|
2925
3249
|
this.#scrollbackHighWater = boundedAppendTo;
|
|
2926
3250
|
}
|
|
2927
|
-
this.#commit(lines, width, height, viewportTop,
|
|
3251
|
+
this.#commit(lines, width, height, viewportTop, cursorControl);
|
|
2928
3252
|
}
|
|
2929
3253
|
|
|
2930
3254
|
/**
|
|
@@ -2993,7 +3317,7 @@ export class TUI extends Container {
|
|
|
2993
3317
|
this.#previousWidth = width;
|
|
2994
3318
|
this.#previousHeight = height;
|
|
2995
3319
|
this.#viewportTopRow = prevViewportTop;
|
|
2996
|
-
this.#
|
|
3320
|
+
this.#recordHardwareCursorRowOnly(row, false);
|
|
2997
3321
|
}
|
|
2998
3322
|
|
|
2999
3323
|
/**
|
|
@@ -3012,7 +3336,13 @@ export class TUI extends Container {
|
|
|
3012
3336
|
): void {
|
|
3013
3337
|
const extraLines = this.#previousLines.length - lines.length;
|
|
3014
3338
|
if (extraLines <= 0) {
|
|
3015
|
-
this.#commit(
|
|
3339
|
+
this.#commit(
|
|
3340
|
+
lines,
|
|
3341
|
+
width,
|
|
3342
|
+
height,
|
|
3343
|
+
Math.max(0, lines.length - height),
|
|
3344
|
+
this.#preserveHardwareCursorUpdate(prevHardwareCursorRow),
|
|
3345
|
+
);
|
|
3016
3346
|
this.#maxLinesRendered = lines.length;
|
|
3017
3347
|
return;
|
|
3018
3348
|
}
|
|
@@ -3047,13 +3377,13 @@ export class TUI extends Container {
|
|
|
3047
3377
|
buffer += `\x1b[${moveUp}A`;
|
|
3048
3378
|
}
|
|
3049
3379
|
|
|
3050
|
-
const
|
|
3051
|
-
buffer += seq;
|
|
3380
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, lines.length, targetRow);
|
|
3381
|
+
buffer += cursorControl.seq;
|
|
3052
3382
|
buffer += this.#paintEndSequence;
|
|
3053
3383
|
this.terminal.write(buffer);
|
|
3054
3384
|
|
|
3055
3385
|
this.#maxLinesRendered = lines.length;
|
|
3056
|
-
this.#commit(lines, width, height, Math.max(0, lines.length - height),
|
|
3386
|
+
this.#commit(lines, width, height, Math.max(0, lines.length - height), cursorControl);
|
|
3057
3387
|
}
|
|
3058
3388
|
|
|
3059
3389
|
/**
|
|
@@ -3165,8 +3495,8 @@ export class TUI extends Container {
|
|
|
3165
3495
|
// so emitting them after the trailing-shrink cursor moves is safe.
|
|
3166
3496
|
buffer += fillSequence;
|
|
3167
3497
|
|
|
3168
|
-
const
|
|
3169
|
-
buffer += seq;
|
|
3498
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, lines.length, finalCursorRow);
|
|
3499
|
+
buffer += cursorControl.seq;
|
|
3170
3500
|
buffer += this.#paintEndSequence;
|
|
3171
3501
|
|
|
3172
3502
|
this.#writeDiffDebug(
|
|
@@ -3179,7 +3509,7 @@ export class TUI extends Container {
|
|
|
3179
3509
|
renderEnd,
|
|
3180
3510
|
finalCursorRow,
|
|
3181
3511
|
cursorPos,
|
|
3182
|
-
toRow,
|
|
3512
|
+
cursorControl.toRow,
|
|
3183
3513
|
buffer,
|
|
3184
3514
|
);
|
|
3185
3515
|
this.terminal.write(buffer);
|
|
@@ -3191,7 +3521,7 @@ export class TUI extends Container {
|
|
|
3191
3521
|
this.#scrollbackHighWater = pushedNow;
|
|
3192
3522
|
}
|
|
3193
3523
|
}
|
|
3194
|
-
this.#commit(lines, width, height, Math.max(0, lines.length - height),
|
|
3524
|
+
this.#commit(lines, width, height, Math.max(0, lines.length - height), cursorControl);
|
|
3195
3525
|
}
|
|
3196
3526
|
|
|
3197
3527
|
/** Optional intent log under PI_DEBUG_REDRAW. */
|
|
@@ -3270,16 +3600,15 @@ export class TUI extends Container {
|
|
|
3270
3600
|
cursorPos: { row: number; col: number } | null,
|
|
3271
3601
|
totalLines: number,
|
|
3272
3602
|
fromRow: number,
|
|
3273
|
-
):
|
|
3274
|
-
// No IME target or no content — hide cursor regardless of preference
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
const targetCol = Math.max(0, cursorPos.col);
|
|
3603
|
+
): CursorControlResult {
|
|
3604
|
+
// No IME target or no content — hide cursor regardless of preference.
|
|
3605
|
+
const target = this.#targetHardwareCursorState(cursorPos, totalLines);
|
|
3606
|
+
if (!target) {
|
|
3607
|
+
return { seq: "\x1b[?25l", toRow: fromRow, toCol: 0, visible: false, state: null };
|
|
3608
|
+
}
|
|
3280
3609
|
|
|
3281
|
-
// Move cursor from current position to target
|
|
3282
|
-
const rowDelta =
|
|
3610
|
+
// Move cursor from current position to target.
|
|
3611
|
+
const rowDelta = target.row - fromRow;
|
|
3283
3612
|
let seq = "";
|
|
3284
3613
|
if (rowDelta > 0) {
|
|
3285
3614
|
seq += `\x1b[${rowDelta}B`; // Move down
|
|
@@ -3287,10 +3616,14 @@ export class TUI extends Container {
|
|
|
3287
3616
|
seq += `\x1b[${-rowDelta}A`; // Move up
|
|
3288
3617
|
}
|
|
3289
3618
|
// Move to absolute column (1-indexed)
|
|
3290
|
-
seq += `\x1b[${
|
|
3291
|
-
seq +=
|
|
3619
|
+
seq += `\x1b[${target.col + 1}G`;
|
|
3620
|
+
seq += target.visible ? "\x1b[?25h" : "\x1b[?25l";
|
|
3621
|
+
|
|
3622
|
+
return { seq, toRow: target.row, toCol: target.col, visible: target.visible, state: target };
|
|
3623
|
+
}
|
|
3292
3624
|
|
|
3293
|
-
|
|
3625
|
+
#isHiddenCursorKnown(): boolean {
|
|
3626
|
+
return this.#hardwareCursorVisibilityKnown && !this.#hardwareCursorVisible;
|
|
3294
3627
|
}
|
|
3295
3628
|
|
|
3296
3629
|
/**
|
|
@@ -3299,12 +3632,16 @@ export class TUI extends Container {
|
|
|
3299
3632
|
* to embed the sequences into.
|
|
3300
3633
|
*/
|
|
3301
3634
|
#writeCursorPosition(cursorPos: { row: number; col: number } | null, totalLines: number): void {
|
|
3302
|
-
|
|
3635
|
+
const target = this.#targetHardwareCursorState(cursorPos, totalLines);
|
|
3636
|
+
if (!target) {
|
|
3637
|
+
if (this.#isHiddenCursorKnown()) return;
|
|
3303
3638
|
this.terminal.hideCursor();
|
|
3639
|
+
this.#recordHardwareCursorHidden();
|
|
3304
3640
|
return;
|
|
3305
3641
|
}
|
|
3306
|
-
|
|
3307
|
-
this.#
|
|
3308
|
-
this.terminal.write(`${this.#cursorBeginSequence}${seq}${this.#cursorEndSequence}`);
|
|
3642
|
+
if (this.#sameHardwareCursorState(target)) return;
|
|
3643
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, totalLines, this.#hardwareCursorRow);
|
|
3644
|
+
this.terminal.write(`${this.#cursorBeginSequence}${cursorControl.seq}${this.#cursorEndSequence}`);
|
|
3645
|
+
this.#recordHardwareCursorUpdate(cursorControl);
|
|
3309
3646
|
}
|
|
3310
3647
|
}
|