@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 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): 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.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.1",
41
- "@oh-my-pi/pi-utils": "15.10.1",
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
  },
@@ -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
  */
@@ -504,11 +556,20 @@ export class ProcessTerminal implements Terminal {
504
556
  }
505
557
 
506
558
  const match = sequence.match(kittyResponsePattern);
507
- if (match && !this.#modifyOtherKeysActive) {
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
- process.stdout.write(data);
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(this.#focusedComponent)) {
692
- this.#focusedComponent.focused = false;
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) this.terminal.hideCursor();
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) this.terminal.hideCursor();
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 explicitViewportMutation = this.#allowUnknownViewportMutationOnNextRender;
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 { kind: "viewportRepaint" };
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 normalized = normalizeTerminalOutput(raw);
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(lines: string[], width: number, height: number, viewportTop: number, hardwareCursorRow: number): void {
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.#hardwareCursorRow = hardwareCursorRow;
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 { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, finalRow);
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), toRow);
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 { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, contentBottomRow);
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, toRow);
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 { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, contentBottomRow);
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, toRow);
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 { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, cursorFromRow);
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, toRow);
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 { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, contentBottomRow);
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, toRow);
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.#hardwareCursorRow = row;
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(lines, width, height, Math.max(0, lines.length - height), prevHardwareCursorRow);
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 { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, targetRow);
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), toRow);
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 { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, finalCursorRow);
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), toRow);
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
- ): { 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);
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 = targetRow - fromRow;
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[${targetCol + 1}G`;
3291
- seq += this.#showHardwareCursor ? "\x1b[?25h" : "\x1b[?25l";
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
- return { seq, toRow: targetRow };
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
- if (!cursorPos || totalLines <= 0) {
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
- const { seq, toRow } = this.#cursorControlSequence(cursorPos, totalLines, this.#hardwareCursorRow);
3307
- this.#hardwareCursorRow = toRow;
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
  }