@oh-my-pi/pi-tui 15.10.1 → 15.10.2
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 +16 -0
- 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 +79 -2
- package/src/tui.ts +285 -49
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.10.2] - 2026-06-08
|
|
6
|
+
### Added
|
|
7
|
+
|
|
8
|
+
- Added exported `canonicalKeyId` and `addKeyAliases` keybinding helpers so consumers can share the same canonical shortcut matching semantics as `KeybindingsManager`.
|
|
9
|
+
- 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)).
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
- 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
|
|
15
|
+
- 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
|
|
16
|
+
- 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)).
|
|
17
|
+
- 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)).
|
|
18
|
+
- 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)).
|
|
19
|
+
- 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))
|
|
20
|
+
|
|
5
21
|
## [15.10.1] - 2026-06-07
|
|
6
22
|
### Breaking Changes
|
|
7
23
|
|
|
@@ -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.2",
|
|
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.2",
|
|
41
|
+
"@oh-my-pi/pi-utils": "15.10.2",
|
|
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
|
*/
|
|
@@ -504,11 +556,20 @@ export class ProcessTerminal implements Terminal {
|
|
|
504
556
|
}
|
|
505
557
|
|
|
506
558
|
const match = sequence.match(kittyResponsePattern);
|
|
507
|
-
if (match
|
|
559
|
+
if (match) {
|
|
508
560
|
if (this.#modifyOtherKeysTimeout) {
|
|
509
561
|
clearTimeout(this.#modifyOtherKeysTimeout);
|
|
510
562
|
this.#modifyOtherKeysTimeout = undefined;
|
|
511
563
|
}
|
|
564
|
+
// A DA1 sentinel that beat the kitty reply may have already
|
|
565
|
+
// engaged the modifyOtherKeys fallback (terminals such as
|
|
566
|
+
// Superset/xterm-on-Electron answer DA1 before `\x1b[?u`).
|
|
567
|
+
// Kitty is strictly preferred — undo the fallback so the two
|
|
568
|
+
// modes do not stack. See #2042.
|
|
569
|
+
if (this.#modifyOtherKeysActive) {
|
|
570
|
+
this.#safeWrite("\x1b[>4;0m");
|
|
571
|
+
this.#modifyOtherKeysActive = false;
|
|
572
|
+
}
|
|
512
573
|
// Any reply to `\x1b[?u` means the terminal speaks the kitty keyboard
|
|
513
574
|
// protocol. The reported flag value is the *current* stack-top — fresh
|
|
514
575
|
// terminals report 0 — so support is implied by the reply itself, not by
|
|
@@ -978,7 +1039,23 @@ export class ProcessTerminal implements Terminal {
|
|
|
978
1039
|
// files). They serve no purpose there and would surface as visible noise.
|
|
979
1040
|
if (!process.stdout.isTTY) return;
|
|
980
1041
|
try {
|
|
981
|
-
|
|
1042
|
+
// Windows ConPTY drops viewport tracking when a single write exceeds
|
|
1043
|
+
// ~32-64 KB: the host UI's scroll position stays parked at wherever
|
|
1044
|
+
// the write began, even though every byte landed in scrollback. Split
|
|
1045
|
+
// large paints into newline-aligned chunks so each underlying
|
|
1046
|
+
// `WriteFile` stays well below the threshold. The gate also covers
|
|
1047
|
+
// WSL — `process.platform === "linux"` there, but stdout still
|
|
1048
|
+
// crosses into ConPTY at the `wslhost` boundary, so the same per-
|
|
1049
|
+
// WriteFile cap applies. Non-ConPTY PTYs keep the single-write fast
|
|
1050
|
+
// path. See #2034.
|
|
1051
|
+
const conptyHosted = process.platform === "win32" || isWindowsSubsystemForLinux();
|
|
1052
|
+
if (conptyHosted && data.length > MAX_CONPTY_WRITE_CHUNK) {
|
|
1053
|
+
for (const chunk of chunkForConPTY(data, MAX_CONPTY_WRITE_CHUNK)) {
|
|
1054
|
+
process.stdout.write(chunk);
|
|
1055
|
+
}
|
|
1056
|
+
} else {
|
|
1057
|
+
process.stdout.write(data);
|
|
1058
|
+
}
|
|
982
1059
|
} catch (err) {
|
|
983
1060
|
// Any write failure means terminal is dead - no recovery possible
|
|
984
1061
|
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:
|
|
@@ -433,6 +439,24 @@ type RenderIntent =
|
|
|
433
439
|
| { kind: "shrink" }
|
|
434
440
|
| { kind: "diff"; firstChanged: number; lastChanged: number; appendedLines: boolean };
|
|
435
441
|
|
|
442
|
+
interface HardwareCursorState {
|
|
443
|
+
row: number;
|
|
444
|
+
col: number;
|
|
445
|
+
visible: boolean;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
interface HardwareCursorUpdate {
|
|
449
|
+
toRow: number;
|
|
450
|
+
state: HardwareCursorState | null;
|
|
451
|
+
visible?: boolean;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
interface CursorControlResult extends HardwareCursorUpdate {
|
|
455
|
+
seq: string;
|
|
456
|
+
toCol: number;
|
|
457
|
+
visible: boolean;
|
|
458
|
+
}
|
|
459
|
+
|
|
436
460
|
interface PreparedLine {
|
|
437
461
|
raw: string;
|
|
438
462
|
width: number;
|
|
@@ -460,6 +484,9 @@ export class TUI extends Container {
|
|
|
460
484
|
static readonly #MIN_RENDER_INTERVAL_MS = 1000 / 30;
|
|
461
485
|
#cursorRow = 0; // Logical cursor row (end of rendered content)
|
|
462
486
|
#hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
|
|
487
|
+
#hardwareCursorState: HardwareCursorState | null = null;
|
|
488
|
+
#hardwareCursorVisibilityKnown = false;
|
|
489
|
+
#hardwareCursorVisible = false;
|
|
463
490
|
#viewportTopRow = 0; // Content row currently mapped to screen row 0
|
|
464
491
|
#sixelProbePendingDa = false;
|
|
465
492
|
#sixelProbePendingGraphics = false;
|
|
@@ -504,6 +531,9 @@ export class TUI extends Container {
|
|
|
504
531
|
#clearScrollbackOnNextRender = false;
|
|
505
532
|
#forceViewportRepaintOnNextRender = false;
|
|
506
533
|
#allowUnknownViewportMutationOnNextRender = false;
|
|
534
|
+
// Focus changes are local live chrome (menus/editor/cursor), so the next
|
|
535
|
+
// frame may repaint an unknown-at-bottom viewport without waiting for a checkpoint.
|
|
536
|
+
#focusChangedSinceLastRender = false;
|
|
507
537
|
#eagerNativeScrollbackRebuild = false;
|
|
508
538
|
// Set when eager mode is switched off; applied after the next frame is
|
|
509
539
|
// classified so teardown frames from the same event batch still render
|
|
@@ -612,6 +642,7 @@ export class TUI extends Container {
|
|
|
612
642
|
this.#syncTerminalCursorMode(this.#focusedComponent);
|
|
613
643
|
if (!enabled) {
|
|
614
644
|
this.terminal.hideCursor();
|
|
645
|
+
this.#recordHardwareCursorHidden();
|
|
615
646
|
}
|
|
616
647
|
this.requestRender();
|
|
617
648
|
}
|
|
@@ -687,12 +718,16 @@ export class TUI extends Container {
|
|
|
687
718
|
}
|
|
688
719
|
|
|
689
720
|
setFocus(component: Component | null): void {
|
|
721
|
+
const previousFocusedComponent = this.#focusedComponent;
|
|
690
722
|
// Clear focused flag on old component
|
|
691
|
-
if (isFocusable(
|
|
692
|
-
|
|
723
|
+
if (isFocusable(previousFocusedComponent)) {
|
|
724
|
+
previousFocusedComponent.focused = false;
|
|
693
725
|
}
|
|
694
726
|
|
|
695
727
|
this.#focusedComponent = component;
|
|
728
|
+
if (previousFocusedComponent !== component) {
|
|
729
|
+
this.#focusChangedSinceLastRender = true;
|
|
730
|
+
}
|
|
696
731
|
|
|
697
732
|
// Set focused flag on new component and keep its software/hardware cursor
|
|
698
733
|
// rendering mode aligned with TUI's single cursor-visibility preference.
|
|
@@ -714,6 +749,7 @@ export class TUI extends Container {
|
|
|
714
749
|
this.setFocus(component);
|
|
715
750
|
}
|
|
716
751
|
this.terminal.hideCursor();
|
|
752
|
+
this.#recordHardwareCursorHidden();
|
|
717
753
|
this.requestRender();
|
|
718
754
|
|
|
719
755
|
// Return handle for controlling this overlay
|
|
@@ -727,7 +763,10 @@ export class TUI extends Container {
|
|
|
727
763
|
const topVisible = this.#getTopmostVisibleOverlay();
|
|
728
764
|
this.setFocus(topVisible?.component ?? entry.preFocus);
|
|
729
765
|
}
|
|
730
|
-
if (this.overlayStack.length === 0)
|
|
766
|
+
if (this.overlayStack.length === 0) {
|
|
767
|
+
this.terminal.hideCursor();
|
|
768
|
+
this.#recordHardwareCursorHidden();
|
|
769
|
+
}
|
|
731
770
|
this.requestRender();
|
|
732
771
|
}
|
|
733
772
|
},
|
|
@@ -760,7 +799,10 @@ export class TUI extends Container {
|
|
|
760
799
|
// Find topmost visible overlay, or fall back to preFocus
|
|
761
800
|
const topVisible = this.#getTopmostVisibleOverlay();
|
|
762
801
|
this.setFocus(topVisible?.component ?? overlay.preFocus);
|
|
763
|
-
if (this.overlayStack.length === 0)
|
|
802
|
+
if (this.overlayStack.length === 0) {
|
|
803
|
+
this.terminal.hideCursor();
|
|
804
|
+
this.#recordHardwareCursorHidden();
|
|
805
|
+
}
|
|
764
806
|
this.requestRender();
|
|
765
807
|
}
|
|
766
808
|
|
|
@@ -832,6 +874,7 @@ export class TUI extends Container {
|
|
|
832
874
|
}
|
|
833
875
|
}
|
|
834
876
|
this.terminal.hideCursor();
|
|
877
|
+
this.#recordHardwareCursorHidden();
|
|
835
878
|
this.#querySixelSupport();
|
|
836
879
|
this.#queryCellSize();
|
|
837
880
|
this.requestRender(true, { clearScrollback: options?.clearScrollback === true });
|
|
@@ -1043,6 +1086,7 @@ export class TUI extends Container {
|
|
|
1043
1086
|
}
|
|
1044
1087
|
|
|
1045
1088
|
this.terminal.showCursor();
|
|
1089
|
+
this.#forgetHardwareCursorState();
|
|
1046
1090
|
this.terminal.stop();
|
|
1047
1091
|
}
|
|
1048
1092
|
|
|
@@ -1564,12 +1608,15 @@ export class TUI extends Container {
|
|
|
1564
1608
|
if (wantAlt && !this.#altActive) {
|
|
1565
1609
|
this.terminal.write(`\x1b[?1049h${MOUSE_TRACKING_ON}`);
|
|
1566
1610
|
this.terminal.hideCursor();
|
|
1611
|
+
this.#forgetHardwareCursorState();
|
|
1612
|
+
this.#recordHardwareCursorHidden();
|
|
1567
1613
|
this.#altActive = true;
|
|
1568
1614
|
this.#altPreviousLines = [];
|
|
1569
1615
|
this.#altEnterWidth = width;
|
|
1570
1616
|
this.#altEnterHeight = height;
|
|
1571
1617
|
} else if (!wantAlt && this.#altActive) {
|
|
1572
1618
|
this.terminal.write(`${MOUSE_TRACKING_OFF}\x1b[?1049l`);
|
|
1619
|
+
this.#forgetHardwareCursorState();
|
|
1573
1620
|
this.#altActive = false;
|
|
1574
1621
|
this.#altPreviousLines = [];
|
|
1575
1622
|
// A resize while on the alt buffer reflowed the terminal's saved normal
|
|
@@ -1611,6 +1658,9 @@ export class TUI extends Container {
|
|
|
1611
1658
|
const prevHardwareCursorRow = this.#hardwareCursorRow;
|
|
1612
1659
|
const resizeEventOccurred = this.#resizeEventPending;
|
|
1613
1660
|
this.#resizeEventPending = false;
|
|
1661
|
+
if (resizeEventOccurred) {
|
|
1662
|
+
this.#forgetHardwareCursorState();
|
|
1663
|
+
}
|
|
1614
1664
|
const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
|
|
1615
1665
|
// A resize event with net-unchanged dimensions still reflowed the terminal
|
|
1616
1666
|
// buffer; classify it as a height change so the geometry branches repaint
|
|
@@ -1620,7 +1670,9 @@ export class TUI extends Container {
|
|
|
1620
1670
|
(resizeEventOccurred && this.#previousHeight > 0);
|
|
1621
1671
|
const eagerEraseScrollbackRisk = this.#hasEagerEraseScrollbackRisk();
|
|
1622
1672
|
const eagerRebuildAllowed = this.#eagerNativeScrollbackRebuild && !eagerEraseScrollbackRisk;
|
|
1623
|
-
const
|
|
1673
|
+
const focusChanged = this.#focusChangedSinceLastRender;
|
|
1674
|
+
this.#focusChangedSinceLastRender = false;
|
|
1675
|
+
const explicitViewportMutation = this.#allowUnknownViewportMutationOnNextRender || focusChanged;
|
|
1624
1676
|
const allowUnknownViewportMutation = explicitViewportMutation || eagerRebuildAllowed;
|
|
1625
1677
|
this.#allowUnknownViewportMutationOnNextRender = false;
|
|
1626
1678
|
|
|
@@ -2049,7 +2101,9 @@ export class TUI extends Container {
|
|
|
2049
2101
|
newLines.length < this.#previousLines.length &&
|
|
2050
2102
|
naturalViewportTop !== prevViewportTop
|
|
2051
2103
|
) {
|
|
2052
|
-
return
|
|
2104
|
+
return this.#bottomAnchoredViewportUnchanged(newLines, height)
|
|
2105
|
+
? { kind: "deferredMutation" }
|
|
2106
|
+
: { kind: "viewportRepaint" };
|
|
2053
2107
|
}
|
|
2054
2108
|
|
|
2055
2109
|
// Direct-input shrink can also move the natural viewport upward even when
|
|
@@ -2437,6 +2491,17 @@ export class TUI extends Container {
|
|
|
2437
2491
|
return { kind: "liveRegionPinned", appendFrom, appendTo, renderViewportTop };
|
|
2438
2492
|
}
|
|
2439
2493
|
|
|
2494
|
+
#bottomAnchoredViewportUnchanged(newLines: string[], height: number): boolean {
|
|
2495
|
+
const previousViewportTop = Math.max(0, this.#previousLines.length - height);
|
|
2496
|
+
const newViewportTop = Math.max(0, newLines.length - height);
|
|
2497
|
+
for (let row = 0; row < height; row++) {
|
|
2498
|
+
if ((newLines[newViewportTop + row] ?? "") !== (this.#previousLines[previousViewportTop + row] ?? "")) {
|
|
2499
|
+
return false;
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
return true;
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2440
2505
|
#planDeferredTailRepaint(newLines: string[], prevViewportTop: number, height: number): RenderIntent {
|
|
2441
2506
|
const row = prevViewportTop + height - 1;
|
|
2442
2507
|
if (row < 0 || row >= this.#previousLines.length || newLines.length !== this.#previousLines.length) {
|
|
@@ -2478,7 +2543,8 @@ export class TUI extends Container {
|
|
|
2478
2543
|
if (TERMINAL.isImageLine(raw)) {
|
|
2479
2544
|
return { raw, width, line: raw };
|
|
2480
2545
|
}
|
|
2481
|
-
const
|
|
2546
|
+
const source = this.#lineFitSource(raw, width);
|
|
2547
|
+
const normalized = normalizeTerminalOutput(source);
|
|
2482
2548
|
const asciiWidth = this.#ansiAsciiLineWidth(normalized, width);
|
|
2483
2549
|
if ((asciiWidth ?? visibleWidth(normalized)) <= width) {
|
|
2484
2550
|
return { raw, width, line: normalized };
|
|
@@ -2487,6 +2553,91 @@ export class TUI extends Container {
|
|
|
2487
2553
|
return { raw, width, line };
|
|
2488
2554
|
}
|
|
2489
2555
|
|
|
2556
|
+
#lineFitSource(raw: string, width: number): string {
|
|
2557
|
+
const safeWidth = Number.isFinite(width) ? Math.max(1, Math.trunc(width)) : 1;
|
|
2558
|
+
const maxSourceLength = Math.min(
|
|
2559
|
+
LINE_FIT_MAX_SOURCE_CODE_UNITS,
|
|
2560
|
+
Math.max(LINE_FIT_MIN_SOURCE_CODE_UNITS, safeWidth * LINE_FIT_SOURCE_WIDTH_MULTIPLIER),
|
|
2561
|
+
);
|
|
2562
|
+
if (raw.length <= maxSourceLength) return raw;
|
|
2563
|
+
|
|
2564
|
+
let output = "";
|
|
2565
|
+
let cells = 0;
|
|
2566
|
+
for (let i = 0; i < raw.length && cells < safeWidth; ) {
|
|
2567
|
+
if (raw.charCodeAt(i) === 0x1b) {
|
|
2568
|
+
const end = this.#ansiSequenceEnd(raw, i);
|
|
2569
|
+
if (end < 0) break;
|
|
2570
|
+
if (this.#ansiSequenceHasVisiblePayload(raw, i)) {
|
|
2571
|
+
const sequence = raw.slice(i, end);
|
|
2572
|
+
if (output.length + sequence.length <= maxSourceLength) {
|
|
2573
|
+
output += sequence;
|
|
2574
|
+
cells += visibleWidth(sequence);
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
i = end;
|
|
2578
|
+
continue;
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
const code = raw.charCodeAt(i);
|
|
2582
|
+
const next = code >= 0xd800 && code <= 0xdbff && i + 1 < raw.length ? i + 2 : i + 1;
|
|
2583
|
+
const char = raw.slice(i, next);
|
|
2584
|
+
const charWidth = visibleWidth(char);
|
|
2585
|
+
if (charWidth > 0 && cells + charWidth > safeWidth) break;
|
|
2586
|
+
if (output.length + char.length > maxSourceLength) {
|
|
2587
|
+
if (charWidth > 0) break;
|
|
2588
|
+
i = next;
|
|
2589
|
+
continue;
|
|
2590
|
+
}
|
|
2591
|
+
if (charWidth === 0) {
|
|
2592
|
+
const remainingVisibleCells = safeWidth - cells;
|
|
2593
|
+
const reservedCodeUnits = remainingVisibleCells * 2;
|
|
2594
|
+
if (output.length + char.length > maxSourceLength - reservedCodeUnits) {
|
|
2595
|
+
i = next;
|
|
2596
|
+
continue;
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
output += char;
|
|
2600
|
+
cells += charWidth;
|
|
2601
|
+
i = next;
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
return output + SEGMENT_RESET;
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
#ansiSequenceEnd(line: string, start: number): number {
|
|
2608
|
+
const next = line.charCodeAt(start + 1);
|
|
2609
|
+
if (next === 0x5b) {
|
|
2610
|
+
let i = start + 2;
|
|
2611
|
+
while (i < line.length) {
|
|
2612
|
+
const final = line.charCodeAt(i);
|
|
2613
|
+
if (final >= 0x40 && final <= 0x7e) return i + 1;
|
|
2614
|
+
i++;
|
|
2615
|
+
}
|
|
2616
|
+
return -1;
|
|
2617
|
+
}
|
|
2618
|
+
if (next === 0x5d) {
|
|
2619
|
+
let i = start + 2;
|
|
2620
|
+
while (i < line.length) {
|
|
2621
|
+
const osc = line.charCodeAt(i);
|
|
2622
|
+
if (osc === 0x07) return i + 1;
|
|
2623
|
+
if (osc === 0x1b && line.charCodeAt(i + 1) === 0x5c) return i + 2;
|
|
2624
|
+
i++;
|
|
2625
|
+
}
|
|
2626
|
+
return -1;
|
|
2627
|
+
}
|
|
2628
|
+
return start + 2 <= line.length ? start + 2 : -1;
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
#ansiSequenceHasVisiblePayload(line: string, start: number): boolean {
|
|
2632
|
+
// OSC 66 (`\x1b]66;META;TEXT\x1b\\`) carries visible cells inside the payload.
|
|
2633
|
+
return (
|
|
2634
|
+
line.charCodeAt(start + 1) === 0x5d &&
|
|
2635
|
+
line.charCodeAt(start + 2) === 0x36 &&
|
|
2636
|
+
line.charCodeAt(start + 3) === 0x36 &&
|
|
2637
|
+
line.charCodeAt(start + 4) === 0x3b
|
|
2638
|
+
);
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2490
2641
|
#ansiAsciiLineWidth(line: string, maxWidth: number): number | undefined {
|
|
2491
2642
|
let col = 0;
|
|
2492
2643
|
for (let i = 0; i < line.length; ) {
|
|
@@ -2553,7 +2704,13 @@ export class TUI extends Container {
|
|
|
2553
2704
|
* the end so cursor/viewport/scrollback accounting stays consistent.
|
|
2554
2705
|
*/
|
|
2555
2706
|
|
|
2556
|
-
#commit(
|
|
2707
|
+
#commit(
|
|
2708
|
+
lines: string[],
|
|
2709
|
+
width: number,
|
|
2710
|
+
height: number,
|
|
2711
|
+
viewportTop: number,
|
|
2712
|
+
hardwareCursor: HardwareCursorUpdate,
|
|
2713
|
+
): void {
|
|
2557
2714
|
this.#deferredTailLine = undefined;
|
|
2558
2715
|
this.#previousLines = lines;
|
|
2559
2716
|
this.#previousVisibleOverlayComponents = this.#visibleOverlayComponentsThisRender;
|
|
@@ -2562,7 +2719,73 @@ export class TUI extends Container {
|
|
|
2562
2719
|
this.#previousHeight = height;
|
|
2563
2720
|
this.#cursorRow = Math.max(0, lines.length - 1);
|
|
2564
2721
|
this.#viewportTopRow = viewportTop;
|
|
2565
|
-
this.#
|
|
2722
|
+
this.#recordHardwareCursorUpdate(hardwareCursor);
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
#targetHardwareCursorState(
|
|
2726
|
+
cursorPos: { row: number; col: number } | null,
|
|
2727
|
+
totalLines: number,
|
|
2728
|
+
): HardwareCursorState | null {
|
|
2729
|
+
if (!cursorPos || totalLines <= 0) return null;
|
|
2730
|
+
return {
|
|
2731
|
+
row: Math.max(0, Math.min(cursorPos.row, totalLines - 1)),
|
|
2732
|
+
col: Math.max(0, cursorPos.col),
|
|
2733
|
+
visible: this.#showHardwareCursor,
|
|
2734
|
+
};
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
#recordHardwareCursorState(state: HardwareCursorState): void {
|
|
2738
|
+
this.#hardwareCursorRow = state.row;
|
|
2739
|
+
this.#hardwareCursorState = state;
|
|
2740
|
+
this.#hardwareCursorVisible = state.visible;
|
|
2741
|
+
this.#hardwareCursorVisibilityKnown = true;
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
#recordHardwareCursorRowOnly(row: number, visible?: boolean): void {
|
|
2745
|
+
this.#hardwareCursorRow = row;
|
|
2746
|
+
this.#hardwareCursorState = null;
|
|
2747
|
+
if (visible !== undefined) {
|
|
2748
|
+
this.#hardwareCursorVisible = visible;
|
|
2749
|
+
this.#hardwareCursorVisibilityKnown = true;
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
#recordHardwareCursorUpdate(update: HardwareCursorUpdate): void {
|
|
2754
|
+
if (update.state) {
|
|
2755
|
+
this.#recordHardwareCursorState(update.state);
|
|
2756
|
+
return;
|
|
2757
|
+
}
|
|
2758
|
+
this.#recordHardwareCursorRowOnly(update.toRow, update.visible);
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
#recordHardwareCursorHidden(): void {
|
|
2762
|
+
this.#hardwareCursorVisible = false;
|
|
2763
|
+
this.#hardwareCursorVisibilityKnown = true;
|
|
2764
|
+
if (!this.#hardwareCursorState) return;
|
|
2765
|
+
this.#hardwareCursorState = { ...this.#hardwareCursorState, visible: false };
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
#forgetHardwareCursorState(): void {
|
|
2769
|
+
this.#hardwareCursorState = null;
|
|
2770
|
+
this.#hardwareCursorVisibilityKnown = false;
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
#sameHardwareCursorState(state: HardwareCursorState): boolean {
|
|
2774
|
+
const current = this.#hardwareCursorState;
|
|
2775
|
+
return (
|
|
2776
|
+
current !== null && current.row === state.row && current.col === state.col && current.visible === state.visible
|
|
2777
|
+
);
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
#preserveHardwareCursorUpdate(row: number): HardwareCursorUpdate {
|
|
2781
|
+
if (this.#hardwareCursorState?.row === row) {
|
|
2782
|
+
return { toRow: row, state: this.#hardwareCursorState, visible: this.#hardwareCursorState.visible };
|
|
2783
|
+
}
|
|
2784
|
+
return {
|
|
2785
|
+
toRow: row,
|
|
2786
|
+
state: null,
|
|
2787
|
+
visible: this.#hardwareCursorVisibilityKnown ? this.#hardwareCursorVisible : undefined,
|
|
2788
|
+
};
|
|
2566
2789
|
}
|
|
2567
2790
|
|
|
2568
2791
|
/**
|
|
@@ -2625,8 +2848,8 @@ export class TUI extends Container {
|
|
|
2625
2848
|
}
|
|
2626
2849
|
buffer += fillSequence;
|
|
2627
2850
|
const finalRow = Math.max(0, lines.length - 1);
|
|
2628
|
-
const
|
|
2629
|
-
buffer += seq;
|
|
2851
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, lines.length, finalRow);
|
|
2852
|
+
buffer += cursorControl.seq;
|
|
2630
2853
|
buffer += this.#paintEndSequence;
|
|
2631
2854
|
this.terminal.write(buffer);
|
|
2632
2855
|
|
|
@@ -2639,7 +2862,7 @@ export class TUI extends Container {
|
|
|
2639
2862
|
if (pushedNow > this.#scrollbackHighWater) {
|
|
2640
2863
|
this.#scrollbackHighWater = pushedNow;
|
|
2641
2864
|
}
|
|
2642
|
-
this.#commit(lines, width, height, Math.max(0, this.#maxLinesRendered - height),
|
|
2865
|
+
this.#commit(lines, width, height, Math.max(0, this.#maxLinesRendered - height), cursorControl);
|
|
2643
2866
|
}
|
|
2644
2867
|
|
|
2645
2868
|
/**
|
|
@@ -2683,14 +2906,14 @@ export class TUI extends Container {
|
|
|
2683
2906
|
const contentBottomRow = Math.min(viewportBottomRow, Math.max(viewportTop, lines.length - 1));
|
|
2684
2907
|
const parkUp = viewportBottomRow - contentBottomRow;
|
|
2685
2908
|
if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
|
|
2686
|
-
const
|
|
2687
|
-
buffer += seq;
|
|
2909
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, lines.length, contentBottomRow);
|
|
2910
|
+
buffer += cursorControl.seq;
|
|
2688
2911
|
buffer += this.#paintEndSequence;
|
|
2689
2912
|
this.terminal.write(buffer);
|
|
2690
2913
|
|
|
2691
2914
|
this.#maxLinesRendered = Math.max(lines.length, viewportTop + height);
|
|
2692
2915
|
this.#scrollbackHighWater = appendTo;
|
|
2693
|
-
this.#commit(lines, width, height, viewportTop,
|
|
2916
|
+
this.#commit(lines, width, height, viewportTop, cursorControl);
|
|
2694
2917
|
}
|
|
2695
2918
|
/**
|
|
2696
2919
|
* Rewrite the visible viewport in place. Cursor home, clear each row,
|
|
@@ -2756,13 +2979,13 @@ export class TUI extends Container {
|
|
|
2756
2979
|
const contentBottomRow = Math.min(viewportBottomRow, Math.max(viewportTop, lines.length - 1));
|
|
2757
2980
|
const parkUp = viewportBottomRow - contentBottomRow;
|
|
2758
2981
|
if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
|
|
2759
|
-
const
|
|
2760
|
-
buffer += seq;
|
|
2982
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, lines.length, contentBottomRow);
|
|
2983
|
+
buffer += cursorControl.seq;
|
|
2761
2984
|
buffer += this.#paintEndSequence;
|
|
2762
2985
|
this.terminal.write(buffer);
|
|
2763
2986
|
|
|
2764
2987
|
this.#maxLinesRendered = lines.length;
|
|
2765
|
-
this.#commit(lines, width, height, viewportTop,
|
|
2988
|
+
this.#commit(lines, width, height, viewportTop, cursorControl);
|
|
2766
2989
|
}
|
|
2767
2990
|
|
|
2768
2991
|
/** Topmost visible overlay requests the alternate-screen buffer. */
|
|
@@ -2875,13 +3098,13 @@ export class TUI extends Container {
|
|
|
2875
3098
|
}
|
|
2876
3099
|
cursorFromRow = viewportTop + lastChangedScreenRow;
|
|
2877
3100
|
}
|
|
2878
|
-
const
|
|
2879
|
-
buffer += seq;
|
|
3101
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, lines.length, cursorFromRow);
|
|
3102
|
+
buffer += cursorControl.seq;
|
|
2880
3103
|
buffer += this.#paintEndSequence;
|
|
2881
3104
|
this.terminal.write(buffer);
|
|
2882
3105
|
|
|
2883
3106
|
this.#maxLinesRendered = Math.max(lines.length, viewportTop + height);
|
|
2884
|
-
this.#commit(lines, width, height, viewportTop,
|
|
3107
|
+
this.#commit(lines, width, height, viewportTop, cursorControl);
|
|
2885
3108
|
return;
|
|
2886
3109
|
}
|
|
2887
3110
|
|
|
@@ -2915,8 +3138,8 @@ export class TUI extends Container {
|
|
|
2915
3138
|
const contentBottomRow = Math.min(viewportBottomRow, Math.max(viewportTop, lines.length - 1));
|
|
2916
3139
|
const parkUp = viewportBottomRow - contentBottomRow;
|
|
2917
3140
|
if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
|
|
2918
|
-
const
|
|
2919
|
-
buffer += seq;
|
|
3141
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, lines.length, contentBottomRow);
|
|
3142
|
+
buffer += cursorControl.seq;
|
|
2920
3143
|
buffer += this.#paintEndSequence;
|
|
2921
3144
|
this.terminal.write(buffer);
|
|
2922
3145
|
|
|
@@ -2924,7 +3147,7 @@ export class TUI extends Container {
|
|
|
2924
3147
|
if (boundedAppendTo > this.#scrollbackHighWater) {
|
|
2925
3148
|
this.#scrollbackHighWater = boundedAppendTo;
|
|
2926
3149
|
}
|
|
2927
|
-
this.#commit(lines, width, height, viewportTop,
|
|
3150
|
+
this.#commit(lines, width, height, viewportTop, cursorControl);
|
|
2928
3151
|
}
|
|
2929
3152
|
|
|
2930
3153
|
/**
|
|
@@ -2993,7 +3216,7 @@ export class TUI extends Container {
|
|
|
2993
3216
|
this.#previousWidth = width;
|
|
2994
3217
|
this.#previousHeight = height;
|
|
2995
3218
|
this.#viewportTopRow = prevViewportTop;
|
|
2996
|
-
this.#
|
|
3219
|
+
this.#recordHardwareCursorRowOnly(row, false);
|
|
2997
3220
|
}
|
|
2998
3221
|
|
|
2999
3222
|
/**
|
|
@@ -3012,7 +3235,13 @@ export class TUI extends Container {
|
|
|
3012
3235
|
): void {
|
|
3013
3236
|
const extraLines = this.#previousLines.length - lines.length;
|
|
3014
3237
|
if (extraLines <= 0) {
|
|
3015
|
-
this.#commit(
|
|
3238
|
+
this.#commit(
|
|
3239
|
+
lines,
|
|
3240
|
+
width,
|
|
3241
|
+
height,
|
|
3242
|
+
Math.max(0, lines.length - height),
|
|
3243
|
+
this.#preserveHardwareCursorUpdate(prevHardwareCursorRow),
|
|
3244
|
+
);
|
|
3016
3245
|
this.#maxLinesRendered = lines.length;
|
|
3017
3246
|
return;
|
|
3018
3247
|
}
|
|
@@ -3047,13 +3276,13 @@ export class TUI extends Container {
|
|
|
3047
3276
|
buffer += `\x1b[${moveUp}A`;
|
|
3048
3277
|
}
|
|
3049
3278
|
|
|
3050
|
-
const
|
|
3051
|
-
buffer += seq;
|
|
3279
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, lines.length, targetRow);
|
|
3280
|
+
buffer += cursorControl.seq;
|
|
3052
3281
|
buffer += this.#paintEndSequence;
|
|
3053
3282
|
this.terminal.write(buffer);
|
|
3054
3283
|
|
|
3055
3284
|
this.#maxLinesRendered = lines.length;
|
|
3056
|
-
this.#commit(lines, width, height, Math.max(0, lines.length - height),
|
|
3285
|
+
this.#commit(lines, width, height, Math.max(0, lines.length - height), cursorControl);
|
|
3057
3286
|
}
|
|
3058
3287
|
|
|
3059
3288
|
/**
|
|
@@ -3165,8 +3394,8 @@ export class TUI extends Container {
|
|
|
3165
3394
|
// so emitting them after the trailing-shrink cursor moves is safe.
|
|
3166
3395
|
buffer += fillSequence;
|
|
3167
3396
|
|
|
3168
|
-
const
|
|
3169
|
-
buffer += seq;
|
|
3397
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, lines.length, finalCursorRow);
|
|
3398
|
+
buffer += cursorControl.seq;
|
|
3170
3399
|
buffer += this.#paintEndSequence;
|
|
3171
3400
|
|
|
3172
3401
|
this.#writeDiffDebug(
|
|
@@ -3179,7 +3408,7 @@ export class TUI extends Container {
|
|
|
3179
3408
|
renderEnd,
|
|
3180
3409
|
finalCursorRow,
|
|
3181
3410
|
cursorPos,
|
|
3182
|
-
toRow,
|
|
3411
|
+
cursorControl.toRow,
|
|
3183
3412
|
buffer,
|
|
3184
3413
|
);
|
|
3185
3414
|
this.terminal.write(buffer);
|
|
@@ -3191,7 +3420,7 @@ export class TUI extends Container {
|
|
|
3191
3420
|
this.#scrollbackHighWater = pushedNow;
|
|
3192
3421
|
}
|
|
3193
3422
|
}
|
|
3194
|
-
this.#commit(lines, width, height, Math.max(0, lines.length - height),
|
|
3423
|
+
this.#commit(lines, width, height, Math.max(0, lines.length - height), cursorControl);
|
|
3195
3424
|
}
|
|
3196
3425
|
|
|
3197
3426
|
/** Optional intent log under PI_DEBUG_REDRAW. */
|
|
@@ -3270,16 +3499,15 @@ export class TUI extends Container {
|
|
|
3270
3499
|
cursorPos: { row: number; col: number } | null,
|
|
3271
3500
|
totalLines: number,
|
|
3272
3501
|
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);
|
|
3502
|
+
): CursorControlResult {
|
|
3503
|
+
// No IME target or no content — hide cursor regardless of preference.
|
|
3504
|
+
const target = this.#targetHardwareCursorState(cursorPos, totalLines);
|
|
3505
|
+
if (!target) {
|
|
3506
|
+
return { seq: "\x1b[?25l", toRow: fromRow, toCol: 0, visible: false, state: null };
|
|
3507
|
+
}
|
|
3280
3508
|
|
|
3281
|
-
// Move cursor from current position to target
|
|
3282
|
-
const rowDelta =
|
|
3509
|
+
// Move cursor from current position to target.
|
|
3510
|
+
const rowDelta = target.row - fromRow;
|
|
3283
3511
|
let seq = "";
|
|
3284
3512
|
if (rowDelta > 0) {
|
|
3285
3513
|
seq += `\x1b[${rowDelta}B`; // Move down
|
|
@@ -3287,10 +3515,14 @@ export class TUI extends Container {
|
|
|
3287
3515
|
seq += `\x1b[${-rowDelta}A`; // Move up
|
|
3288
3516
|
}
|
|
3289
3517
|
// Move to absolute column (1-indexed)
|
|
3290
|
-
seq += `\x1b[${
|
|
3291
|
-
seq +=
|
|
3518
|
+
seq += `\x1b[${target.col + 1}G`;
|
|
3519
|
+
seq += target.visible ? "\x1b[?25h" : "\x1b[?25l";
|
|
3520
|
+
|
|
3521
|
+
return { seq, toRow: target.row, toCol: target.col, visible: target.visible, state: target };
|
|
3522
|
+
}
|
|
3292
3523
|
|
|
3293
|
-
|
|
3524
|
+
#isHiddenCursorKnown(): boolean {
|
|
3525
|
+
return this.#hardwareCursorVisibilityKnown && !this.#hardwareCursorVisible;
|
|
3294
3526
|
}
|
|
3295
3527
|
|
|
3296
3528
|
/**
|
|
@@ -3299,12 +3531,16 @@ export class TUI extends Container {
|
|
|
3299
3531
|
* to embed the sequences into.
|
|
3300
3532
|
*/
|
|
3301
3533
|
#writeCursorPosition(cursorPos: { row: number; col: number } | null, totalLines: number): void {
|
|
3302
|
-
|
|
3534
|
+
const target = this.#targetHardwareCursorState(cursorPos, totalLines);
|
|
3535
|
+
if (!target) {
|
|
3536
|
+
if (this.#isHiddenCursorKnown()) return;
|
|
3303
3537
|
this.terminal.hideCursor();
|
|
3538
|
+
this.#recordHardwareCursorHidden();
|
|
3304
3539
|
return;
|
|
3305
3540
|
}
|
|
3306
|
-
|
|
3307
|
-
this.#
|
|
3308
|
-
this.terminal.write(`${this.#cursorBeginSequence}${seq}${this.#cursorEndSequence}`);
|
|
3541
|
+
if (this.#sameHardwareCursorState(target)) return;
|
|
3542
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, totalLines, this.#hardwareCursorRow);
|
|
3543
|
+
this.terminal.write(`${this.#cursorBeginSequence}${cursorControl.seq}${this.#cursorEndSequence}`);
|
|
3544
|
+
this.#recordHardwareCursorUpdate(cursorControl);
|
|
3309
3545
|
}
|
|
3310
3546
|
}
|