@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 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): void;
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);
@@ -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.1",
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.1",
41
- "@oh-my-pi/pi-utils": "15.10.1",
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
  },
@@ -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
- else if (matchesKey(data, "alt+backspace")) {
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 (matchesKey(data, "alt+d") || matchesKey(data, "alt+delete")) {
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
@@ -88,8 +88,8 @@ export class Loader extends Text {
88
88
 
89
89
  #updateDisplay() {
90
90
  const frame = this.#frames[this.#currentFrame];
91
- this.setText(`${this.spinnerColorFn(frame)} ${this.messageColorFn(this.message)}`);
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
  }
@@ -26,14 +26,15 @@ export class Text implements Component {
26
26
  return this.#text;
27
27
  }
28
28
 
29
- setText(text: string): void {
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 {
@@ -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 && !this.#modifyOtherKeysActive) {
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
- process.stdout.write(data);
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 (normal click tracking + SGR extended coordinates), enabled
72
- // only for the lifetime of a fullscreen overlay so the rest of the app keeps the
73
- // terminal's native text selection.
74
- const MOUSE_TRACKING_ON = "\x1b[?1000h\x1b[?1006h";
75
- const MOUSE_TRACKING_OFF = "\x1b[?1006l\x1b[?1000l";
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(this.#focusedComponent)) {
692
- this.#focusedComponent.focused = false;
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) this.terminal.hideCursor();
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) this.terminal.hideCursor();
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
- // Repaint immediately rather than via the throttled path: a resize must
820
- // clear and replay at the fresh geometry before the terminal's reflow
821
- // settles into a state a throttled frame would race. Forced render skips
822
- // the 30fps coalescing window, matching resetDisplay()'s prompt repaint.
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
- this.requestRender(true);
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 explicitViewportMutation = this.#allowUnknownViewportMutationOnNextRender;
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 { kind: "viewportRepaint" };
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 normalized = normalizeTerminalOutput(raw);
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(lines: string[], width: number, height: number, viewportTop: number, hardwareCursorRow: number): void {
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.#hardwareCursorRow = hardwareCursorRow;
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 { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, finalRow);
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), toRow);
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 { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, contentBottomRow);
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, toRow);
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 { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, contentBottomRow);
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, toRow);
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 { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, cursorFromRow);
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, toRow);
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 { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, contentBottomRow);
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, toRow);
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.#hardwareCursorRow = row;
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(lines, width, height, Math.max(0, lines.length - height), prevHardwareCursorRow);
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 { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, targetRow);
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), toRow);
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 { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, finalCursorRow);
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), toRow);
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
- ): { seq: string; toRow: number } {
3274
- // No IME target or no content — hide cursor regardless of preference
3275
- if (!cursorPos || totalLines <= 0) return { seq: "\x1b[?25l", toRow: fromRow };
3276
-
3277
- // Clamp cursor position to valid range
3278
- const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));
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 = targetRow - fromRow;
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[${targetCol + 1}G`;
3291
- seq += this.#showHardwareCursor ? "\x1b[?25h" : "\x1b[?25l";
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
- return { seq, toRow: targetRow };
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
- if (!cursorPos || totalLines <= 0) {
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
- const { seq, toRow } = this.#cursorControlSequence(cursorPos, totalLines, this.#hardwareCursorRow);
3307
- this.#hardwareCursorRow = toRow;
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
  }