@oh-my-pi/pi-tui 17.0.1 → 17.0.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,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.0.2] - 2026-07-17
6
+
7
+ ### Added
8
+
9
+ - Added a fullscreen overlay mouse-tracking opt-out to allow selection-first dialogs to preserve native terminal text selection.
10
+ - Added `Terminal.refreshAppearance()` to allow consumers to manually trigger a refresh of the detected dark/light terminal appearance without periodic polling.
11
+
12
+ ### Fixed
13
+
14
+ - Fixed an issue where pressing Enter to accept a mid-prompt `/skill:<name>` autocomplete would submit and clear the draft; it now correctly inserts the skill token and leaves the prompt open.
15
+ - Fixed Markdown rendering incorrectly turning local file paths containing `www.` or protocol sequences into HTTP links by requiring a valid GFM left boundary for autolinks.
16
+ - Fixed terminal resize behavior by restoring alternate-screen rendering during drag frames, preventing wrapped fragments from polluting native scrollback while preserving the overlay-exit flicker fix.
17
+ - Added optional right-border scrollbar to the `Editor` component (`setScrollbarVisible`): shows a thumb glyph on the right border when content overflows `maxHeight`, enabling scrollable multi-line editors (e.g. advisor instructions) without losing the submit hint off-screen.
18
+
5
19
  ## [17.0.1] - 2026-07-16
6
20
 
7
21
  ### Added
@@ -96,6 +96,8 @@ export declare class Editor implements Component, Focusable {
96
96
  setImeSafeCursorLayout(enabled: boolean): void;
97
97
  getUseTerminalCursor(): boolean;
98
98
  setMaxHeight(maxHeight: number | undefined): void;
99
+ /** Enable/disable the right-border scrollbar. Only shown when content overflows. */
100
+ setScrollbarVisible(visible: boolean): void;
99
101
  setPaddingX(paddingX: number): void;
100
102
  getAutocompleteMaxVisible(): number;
101
103
  setAutocompleteMaxVisible(maxVisible: number): void;
@@ -63,6 +63,16 @@ export interface Terminal {
63
63
  * already-detected appearance so late subscribers never miss it.
64
64
  */
65
65
  onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
66
+ /**
67
+ * Issue a single OSC 11 background-color re-query, driving the appearance
68
+ * callbacks through the same parse/dedup pipeline used at startup and on Mode
69
+ * 2031 notifications. Bounded: one probe per call, no timers. Invoked on the
70
+ * user's explicit display-reset gesture (Ctrl+L) so terminals that cannot
71
+ * deliver end-to-end Mode 2031 notifications still pick up a light/dark switch
72
+ * without a restart. Optional so custom Terminals built against older pi-tui
73
+ * versions keep working.
74
+ */
75
+ refreshAppearance?(): void;
66
76
  /** The last detected terminal appearance, or undefined if not yet known. */
67
77
  get appearance(): TerminalAppearance | undefined;
68
78
  /**
@@ -93,6 +103,15 @@ export declare class ProcessTerminal implements Terminal {
93
103
  get keyboardEnhancementExitSequence(): string | null;
94
104
  get appearance(): TerminalAppearance | undefined;
95
105
  onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
106
+ /**
107
+ * Re-query the terminal background via a single OSC 11 probe. Reuses the
108
+ * startup query path — same DA1-sentinel FIFO, pending/queued gating, parsing,
109
+ * dedup, and appearance callbacks — so a light/dark switch is picked up
110
+ * without a restart on terminals lacking end-to-end Mode 2031 notifications.
111
+ * Bounded to one probe per call; no timers are armed. Suppressed while headless
112
+ * or after the terminal is torn down.
113
+ */
114
+ refreshAppearance(): void;
96
115
  onPrivateModeReport(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void;
97
116
  start(onInput: (data: string) => void, onResize: () => void): void;
98
117
  drainInput(maxMs?: number, idleMs?: number): Promise<void>;
@@ -236,6 +236,11 @@ export interface OverlayOptions {
236
236
  * unchanged and still draw over the transcript on the normal screen.
237
237
  */
238
238
  fullscreen?: boolean;
239
+ /**
240
+ * Enable terminal mouse reporting while fullscreen. Defaults on; disable it
241
+ * when native terminal text selection takes precedence over pointer events.
242
+ */
243
+ mouseTracking?: boolean;
239
244
  }
240
245
  /**
241
246
  * Handle returned by showOverlay for controlling the overlay
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "17.0.1",
4
+ "version": "17.0.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": "17.0.1",
41
- "@oh-my-pi/pi-utils": "17.0.1",
40
+ "@oh-my-pi/pi-natives": "17.0.2",
41
+ "@oh-my-pi/pi-utils": "17.0.2",
42
42
  "lru-cache": "11.5.2",
43
43
  "marked": "^18.0.6"
44
44
  },
@@ -452,7 +452,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
452
452
  // Preserve the full text-before-cursor for submitted slash
453
453
  // commands so the editor's Enter-staleness check still applies
454
454
  // completion for ` /sk`. Mid-prompt skill lookup keeps only
455
- // the slash token because accepting it replaces the whole draft.
455
+ // the slash token because acceptance replaces only that token.
456
456
  prefix: isMidPromptSkillLookup ? commandText : textBeforeCursor,
457
457
  };
458
458
  }
@@ -404,6 +404,10 @@ export class Editor implements Component, Focusable {
404
404
  #paddingXOverride: number | undefined;
405
405
  #maxHeight?: number;
406
406
  #scrollOffset: number = 0;
407
+ /** When true, the right border shows a scrollbar track/thumb when content
408
+ * overflows {@link #maxHeight}. Enabled by {@link HookEditorComponent} and
409
+ * other multi-line consumers; single-line consumers are unaffected. */
410
+ #scrollbarVisible = false;
407
411
 
408
412
  // Emacs-style kill ring
409
413
  #killRing = new KillRing();
@@ -555,6 +559,11 @@ export class Editor implements Component, Focusable {
555
559
  // Don't reset scrollOffset — #updateScrollOffset will clamp it on next render
556
560
  }
557
561
 
562
+ /** Enable/disable the right-border scrollbar. Only shown when content overflows. */
563
+ setScrollbarVisible(visible: boolean): void {
564
+ this.#scrollbarVisible = visible;
565
+ }
566
+
558
567
  setPaddingX(paddingX: number): void {
559
568
  this.#paddingXOverride = Math.max(0, paddingX);
560
569
  }
@@ -831,6 +840,22 @@ export class Editor implements Component, Focusable {
831
840
  const visibleLayoutLines = layoutLines.slice(this.#scrollOffset, this.#scrollOffset + visibleContentHeight);
832
841
 
833
842
  const result: string[] = [];
843
+ // Scrollbar: shown only when content overflows and the caller opted in.
844
+ const needsScrollbar = this.#scrollbarVisible && layoutLines.length > visibleContentHeight;
845
+ let scrollbarThumb: { start: number; end: number } | null = null;
846
+ if (needsScrollbar && visibleContentHeight > 0) {
847
+ const thumbSize = Math.max(
848
+ 1,
849
+ Math.min(
850
+ Math.floor((visibleContentHeight * visibleContentHeight) / layoutLines.length),
851
+ visibleContentHeight,
852
+ ),
853
+ );
854
+ const travel = visibleContentHeight - thumbSize;
855
+ const maxOffset = Math.max(0, layoutLines.length - visibleContentHeight);
856
+ const start = maxOffset === 0 ? 0 : Math.round((this.#scrollOffset / maxOffset) * travel);
857
+ scrollbarThumb = { start, end: start + thumbSize };
858
+ }
834
859
 
835
860
  if (borderVisible) {
836
861
  // Render top border: ╭─ [status content] ────────────────╮
@@ -1054,7 +1079,11 @@ export class Editor implements Component, Focusable {
1054
1079
  result.push(`${bottomLeft}${displayText}${linePad}${bottomRightAdjusted}`);
1055
1080
  } else {
1056
1081
  const leftBorder = this.borderColor(`${box.vertical}${padding(paddingX)}`);
1057
- const rightBorder = this.borderColor(`${padding(Math.max(0, rightChromeCells - 1))}${box.vertical}`);
1082
+ // When scrollbar is active, replace the right border vertical with a
1083
+ // thumb glyph (█) on lines inside the thumb range, keeping the track (│) elsewhere.
1084
+ const inThumb = scrollbarThumb && visibleIndex >= scrollbarThumb.start && visibleIndex < scrollbarThumb.end;
1085
+ const rightGlyph = inThumb ? "█" : box.vertical;
1086
+ const rightBorder = this.borderColor(`${padding(Math.max(0, rightChromeCells - 1))}${rightGlyph}`);
1058
1087
  result.push(leftBorder + displayText + linePad + rightBorder);
1059
1088
  }
1060
1089
  }
@@ -1189,11 +1218,12 @@ export class Editor implements Component, Focusable {
1189
1218
  return;
1190
1219
  }
1191
1220
 
1192
- // If Enter was pressed on a slash command (not an absolute-path
1193
- // completion sharing the leading-slash prefix), apply and submit
1221
+ // If Enter was pressed on a submitted slash command (not an absolute-path
1222
+ // completion sharing the leading-slash prefix), apply and submit.
1194
1223
  if (
1195
1224
  (kb.matches(data, "tui.input.submit") || data === "\n") &&
1196
1225
  findLeadingSlashCommandStart(this.#autocompletePrefix) !== null &&
1226
+ this.#isInSubmittedSlashCommandContext() &&
1197
1227
  !this.#selectedCompletionIsPath()
1198
1228
  ) {
1199
1229
  const selected = this.#autocompleteList.getSelectedItem();
@@ -1222,7 +1252,7 @@ export class Editor implements Component, Focusable {
1222
1252
  }
1223
1253
  // Don't return - fall through to submission logic
1224
1254
  }
1225
- // If Enter was pressed on a file path, apply completion
1255
+ // Otherwise, apply the completion without submitting the surrounding draft.
1226
1256
  else if (kb.matches(data, "tui.input.submit") || data === "\n") {
1227
1257
  const selected = this.#autocompleteList.getSelectedItem();
1228
1258
  // Check for stale autocomplete state due to buffer edits since last refresh.
@@ -2915,8 +2945,8 @@ export class Editor implements Component, Focusable {
2915
2945
  * via the no-command-match fall-through) share the leading-slash prefix shape
2916
2946
  * but must use the live-suffix path rule so the apply slice stays anchored.
2917
2947
  * - Mid-prompt skill branch re-anchors when the popup item is a skill and the
2918
- * current text still ends in a trailing slash token, matching the provider's
2919
- * mid-prompt replacement branch.
2948
+ * current text still ends in a matching trailing slash token, preventing a
2949
+ * stale selection from replacing a newer skill prefix.
2920
2950
  * - `@`-file branch re-anchors via `#extractAtPrefix`; safe when the current text
2921
2951
  * still ends in a whitespace-anchored `@<token>`.
2922
2952
  * - Everything else is stale — accepting it would corrupt the buffer (issue #4295).
@@ -592,7 +592,42 @@ const mathEnvBlockExtension: TokenizerAndRendererExtension = {
592
592
  return (token as { text?: string }).text ?? "";
593
593
  },
594
594
  };
595
- markdownParser.use({ extensions: [customHrExtension, mathBlockExtension, mathEnvBlockExtension, mathExtension] });
595
+
596
+ // GFM's extended autolinks (`www.`, `http://`, `https://`, `ftp://`) may only
597
+ // begin at a valid left boundary: start of line, whitespace, or one of `* _ ~ (`
598
+ // (https://github.github.com/gfm/#autolinks-extension-). marked's bundled `url`
599
+ // tokenizer instead fires after ANY character, so a local path such as
600
+ // `~/meta/www.share/blog/index.dj` is mangled into a `http://www.share/...`
601
+ // link. This inline extension runs before the built-in tokenizer: when an
602
+ // autolink candidate is glued to an invalid preceding character it emits the
603
+ // bare scheme prefix as literal text, so the remainder never reaches the `url`
604
+ // tokenizer at a valid start. Candidates at a legal boundary fall through
605
+ // (return undefined) to marked's own autolink handling unchanged.
606
+ const AUTOLINK_SCHEME_REGEX = /^(?:www\.|https?:\/\/|ftp:\/\/)/i;
607
+ const AUTOLINK_SCHEME_SCAN = /www\.|https?:\/\/|ftp:\/\//i;
608
+ const VALID_AUTOLINK_LEFT_BOUNDARY = /[\s*_~(]/;
609
+ const boundedAutolinkExtension: TokenizerAndRendererExtension = {
610
+ name: "boundedAutolink",
611
+ level: "inline",
612
+ start(src) {
613
+ const m = AUTOLINK_SCHEME_SCAN.exec(src);
614
+ return m ? m.index : undefined;
615
+ },
616
+ tokenizer(src, tokens) {
617
+ const match = AUTOLINK_SCHEME_REGEX.exec(src);
618
+ if (!match) return undefined;
619
+ const prevChar = tokens.at(-1)?.raw?.at(-1);
620
+ // Start of line or a legal delimiter → let marked autolink it.
621
+ if (prevChar === undefined || VALID_AUTOLINK_LEFT_BOUNDARY.test(prevChar)) return undefined;
622
+ // Glued to an invalid character (e.g. `/`, a letter, `.`): consume only
623
+ // the scheme prefix as text so the built-in `url` tokenizer cannot match.
624
+ const raw = match[0];
625
+ return { type: "text", raw, text: raw };
626
+ },
627
+ };
628
+ markdownParser.use({
629
+ extensions: [customHrExtension, mathBlockExtension, mathEnvBlockExtension, mathExtension, boundedAutolinkExtension],
630
+ });
596
631
 
597
632
  // ---------------------------------------------------------------------------
598
633
  // Module-level LRU render cache
package/src/terminal.ts CHANGED
@@ -402,6 +402,16 @@ export interface Terminal {
402
402
  * already-detected appearance so late subscribers never miss it.
403
403
  */
404
404
  onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
405
+ /**
406
+ * Issue a single OSC 11 background-color re-query, driving the appearance
407
+ * callbacks through the same parse/dedup pipeline used at startup and on Mode
408
+ * 2031 notifications. Bounded: one probe per call, no timers. Invoked on the
409
+ * user's explicit display-reset gesture (Ctrl+L) so terminals that cannot
410
+ * deliver end-to-end Mode 2031 notifications still pick up a light/dark switch
411
+ * without a restart. Optional so custom Terminals built against older pi-tui
412
+ * versions keep working.
413
+ */
414
+ refreshAppearance?(): void;
405
415
  /** The last detected terminal appearance, or undefined if not yet known. */
406
416
  get appearance(): TerminalAppearance | undefined;
407
417
  /**
@@ -550,6 +560,19 @@ export class ProcessTerminal implements Terminal {
550
560
  }
551
561
  }
552
562
 
563
+ /**
564
+ * Re-query the terminal background via a single OSC 11 probe. Reuses the
565
+ * startup query path — same DA1-sentinel FIFO, pending/queued gating, parsing,
566
+ * dedup, and appearance callbacks — so a light/dark switch is picked up
567
+ * without a restart on terminals lacking end-to-end Mode 2031 notifications.
568
+ * Bounded to one probe per call; no timers are armed. Suppressed while headless
569
+ * or after the terminal is torn down.
570
+ */
571
+ refreshAppearance(): void {
572
+ if (this.#headless || this.#dead) return;
573
+ this.#queryBackgroundColor();
574
+ }
575
+
553
576
  onPrivateModeReport(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void {
554
577
  this.#privateModeCallbacks.push(callback);
555
578
  }
package/src/tui.ts CHANGED
@@ -80,13 +80,15 @@ const CURSOR_BEGIN = `${HIDE_CURSOR}${SYNC_OUTPUT_BEGIN}`;
80
80
  const CURSOR_BEGIN_NO_SYNC = HIDE_CURSOR;
81
81
  const CURSOR_END = SYNC_OUTPUT_END;
82
82
  const CURSOR_END_NO_SYNC = "";
83
- // Mouse reporting, enabled only for the lifetime of a fullscreen overlay so the
84
- // rest of the app keeps the terminal's native text selection. 1000h = button
85
- // click tracking, 1003h = any-motion tracking so overlays can light up hover
86
- // targets (the pointer moving with no button held), 1006h = SGR extended
87
- // coordinates so columns/rows past 223 are reported.
83
+ // Mouse reporting is scoped to fullscreen overlays that opt into pointer
84
+ // interaction. 1000h = button click tracking, 1003h = any-motion tracking for
85
+ // hover targets, and 1006h = SGR extended coordinates past column/row 223.
86
+ // Selection-first overlays leave these modes disabled so the terminal retains
87
+ // native text selection.
88
88
  const MOUSE_TRACKING_ON = "\x1b[?1000h\x1b[?1003h\x1b[?1006h";
89
89
  const MOUSE_TRACKING_OFF = "\x1b[?1006l\x1b[?1003l\x1b[?1000l";
90
+ const ALT_SCREEN_ENTER = "\x1b[?1049h";
91
+ const ALT_SCREEN_EXIT = "\x1b[?1049l";
90
92
 
91
93
  type InputListenerResult = { consume?: boolean; data?: string } | undefined;
92
94
  type InputListener = (data: string) => InputListenerResult;
@@ -454,6 +456,11 @@ export interface OverlayOptions {
454
456
  * unchanged and still draw over the transcript on the normal screen.
455
457
  */
456
458
  fullscreen?: boolean;
459
+ /**
460
+ * Enable terminal mouse reporting while fullscreen. Defaults on; disable it
461
+ * when native terminal text selection takes precedence over pointer events.
462
+ */
463
+ mouseTracking?: boolean;
457
464
  }
458
465
 
459
466
  /**
@@ -1074,6 +1081,11 @@ export class TUI extends Container {
1074
1081
  // `#fullRedrawCount`: these never enter native scrollback and exist only for
1075
1082
  // the lifetime of the drag. Exposed for tests/diagnostics.
1076
1083
  #resizeViewportPaintCount = 0;
1084
+ // During a live resize drag the terminal's normal buffer may reflow full-width
1085
+ // rows before our repaint lands. Borrow the alternate screen for throwaway
1086
+ // resize frames so width changes truncate the transient viewport instead of
1087
+ // pushing wrapped fragments into native scrollback.
1088
+ #resizeAltActive = false;
1077
1089
  #stopped = false;
1078
1090
  // Always-on event-loop lag probe. The high default threshold keeps it quiet;
1079
1091
  // it only logs `ui.loop-blocked` (with the current loop phase) when a frame
@@ -1086,6 +1098,7 @@ export class TUI extends Container {
1086
1098
  // untouched, so exiting reconciles cleanly against the terminal-restored
1087
1099
  // normal screen. #altPreviousLines is the last alt frame, for repaint-skip.
1088
1100
  #altActive = false;
1101
+ #altMouseTrackingActive = false;
1089
1102
  #altPreviousLines: string[] = [];
1090
1103
  #altEnterWidth = 0;
1091
1104
  #altEnterHeight = 0;
@@ -1741,12 +1754,18 @@ export class TUI extends Container {
1741
1754
  }
1742
1755
 
1743
1756
  stop(): void {
1757
+ // Leave the resize alt buffer first so the teardown cursor math below runs
1758
+ // against the restored normal screen (which #previousLines still describes).
1759
+ if (this.#resizeAltActive) {
1760
+ this.terminal.write(this.#leaveResizeAltSequence());
1761
+ }
1744
1762
  if (this.#altActive || this.#pendingAltExit) {
1745
- const exitSequence =
1746
- this.#pendingAltExit || `${MOUSE_TRACKING_OFF}${this.#keyboardEnhancementExit()}\x1b[?1049l`;
1763
+ const mouseExit = this.#altMouseTrackingActive ? MOUSE_TRACKING_OFF : "";
1764
+ const exitSequence = this.#pendingAltExit || `${mouseExit}${this.#keyboardEnhancementExit()}\x1b[?1049l`;
1747
1765
  this.terminal.write(exitSequence);
1748
1766
  setAltScreenActive(false);
1749
1767
  this.#altActive = false;
1768
+ this.#altMouseTrackingActive = false;
1750
1769
  this.#altPreviousLines = [];
1751
1770
  this.#pendingAltExit = "";
1752
1771
  }
@@ -2705,24 +2724,29 @@ export class TUI extends Container {
2705
2724
  // requests it, borrow the terminal's alternate buffer and paint only the
2706
2725
  // modal there; the normal screen and all accounting stay untouched.
2707
2726
  let deferredAltExit = this.#pendingAltExit;
2708
- const wantAlt = this.#wantsAltScreen();
2727
+ const topOverlay = this.#getTopmostVisibleOverlay();
2728
+ const wantAlt = topOverlay?.options?.fullscreen === true;
2729
+ const wantMouseTracking = wantAlt && topOverlay.options?.mouseTracking !== false;
2709
2730
  if (wantAlt && !this.#altActive) {
2710
2731
  // Enhanced keyboard modes can be buffer-local: re-push the active
2711
2732
  // modified-key reporting sequence on the freshly entered alternate
2712
2733
  // screen, or Esc/modified keys revert to legacy encoding inside
2713
2734
  // fullscreen overlays (Ghostty/kitty/iTerm2).
2714
- this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}${MOUSE_TRACKING_ON}`);
2735
+ const mouseEnter = wantMouseTracking ? MOUSE_TRACKING_ON : "";
2736
+ this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}${mouseEnter}`);
2715
2737
  setAltScreenActive(true);
2716
2738
  this.terminal.hideCursor();
2717
2739
  this.#forgetHardwareCursorState();
2718
2740
  this.#recordHardwareCursorHidden();
2719
2741
  this.#altActive = true;
2742
+ this.#altMouseTrackingActive = wantMouseTracking;
2720
2743
  this.#altPreviousLines = [];
2721
2744
  this.#altEnterWidth = width;
2722
2745
  this.#altEnterHeight = height;
2723
2746
  } else if (!wantAlt && this.#altActive) {
2747
+ const mouseExit = this.#altMouseTrackingActive ? MOUSE_TRACKING_OFF : "";
2724
2748
  const enhancementExit = this.#keyboardEnhancementExit();
2725
- const exitSequence = `${MOUSE_TRACKING_OFF}${enhancementExit}\x1b[?1049l`;
2749
+ const exitSequence = `${mouseExit}${enhancementExit}\x1b[?1049l`;
2726
2750
  // Session replacement can finish while a fullscreen selector is still
2727
2751
  // covering the old normal buffer. Keep the overlay visible until the
2728
2752
  // replacement is ready, then fuse the buffer restore into that full paint;
@@ -2734,6 +2758,7 @@ export class TUI extends Container {
2734
2758
  setAltScreenActive(false);
2735
2759
  this.#forgetHardwareCursorState();
2736
2760
  this.#altActive = false;
2761
+ this.#altMouseTrackingActive = false;
2737
2762
  this.#altPreviousLines = [];
2738
2763
  // A resize while on the alt buffer reflowed the terminal's saved
2739
2764
  // normal screen; it no longer matches our accounting, so force the
@@ -2741,6 +2766,9 @@ export class TUI extends Container {
2741
2766
  if (width !== this.#altEnterWidth || height !== this.#altEnterHeight) {
2742
2767
  this.#resizeEventPending = true;
2743
2768
  }
2769
+ } else if (wantMouseTracking !== this.#altMouseTrackingActive) {
2770
+ this.terminal.write(wantMouseTracking ? MOUSE_TRACKING_ON : MOUSE_TRACKING_OFF);
2771
+ this.#altMouseTrackingActive = wantMouseTracking;
2744
2772
  }
2745
2773
  if (this.#altActive) {
2746
2774
  this.#componentRenderTargets.clear();
@@ -3478,7 +3506,7 @@ export class TUI extends Container {
3478
3506
  paintCursorPos = paint.cursorPos;
3479
3507
  }
3480
3508
  }
3481
- let buffer = this.#paintBeginSequence + options.leadingSequence + purgeSequence;
3509
+ let buffer = this.#paintBeginSequence + this.#leaveResizeAltSequence() + options.leadingSequence + purgeSequence;
3482
3510
  if (options.clearScrollback) {
3483
3511
  // Clear native history without blanking the live viewport first. The
3484
3512
  // replay below rewrites every visible row from home, including blanks,
@@ -3678,15 +3706,34 @@ export class TUI extends Container {
3678
3706
  return this.terminal.kittyEnableSequence ? "\x1b[<u" : "";
3679
3707
  }
3680
3708
 
3709
+ #enterResizeAltSequence(): string {
3710
+ if (this.#resizeAltActive || this.#altActive) return "";
3711
+ this.#resizeAltActive = true;
3712
+ setAltScreenActive(true);
3713
+ this.#forgetHardwareCursorState();
3714
+ this.#recordHardwareCursorHidden();
3715
+ return `${ALT_SCREEN_ENTER}${this.#keyboardEnhancementEnter()}`;
3716
+ }
3717
+
3718
+ #leaveResizeAltSequence(): string {
3719
+ if (!this.#resizeAltActive) return "";
3720
+ const enhancementExit = this.#keyboardEnhancementExit();
3721
+ this.#resizeAltActive = false;
3722
+ setAltScreenActive(false);
3723
+ this.#forgetHardwareCursorState();
3724
+ return `${enhancementExit}${ALT_SCREEN_EXIT}`;
3725
+ }
3726
+
3681
3727
  /**
3682
- * Emit a throwaway viewport repaint for the resize fast path as an in-place
3683
- * per-row overwrite. Switching buffers exposes the saved normal screen before
3684
- * the authoritative settle paint on terminals without effective DEC 2026,
3685
- * which is the resize flicker this fast path exists to avoid. The normal
3686
- * screen is therefore rewritten directly; history is rebuilt once at settle.
3728
+ * Emit a throwaway viewport repaint for the resize fast path as an alternate-
3729
+ * screen per-row overwrite. The normal buffer may reflow full-width rows on a
3730
+ * width change before the app can repaint; keeping the drag on the alternate
3731
+ * screen makes those transient resizes truncate instead of pushing wrapped
3732
+ * fragments into native scrollback. Normal-screen history is rebuilt once at
3733
+ * settle via `#emitFullPaint`.
3687
3734
  */
3688
3735
  #emitResizeViewport(window: readonly string[], height: number, contentRows: number, width: number): void {
3689
- let buffer = `${this.#paintBeginSequence}\x1b[H`;
3736
+ let buffer = `${this.#paintBeginSequence + this.#enterResizeAltSequence()}\x1b[H`;
3690
3737
  for (let r = 0; r < height; r++) {
3691
3738
  if (r > 0) buffer += "\r\n";
3692
3739
  buffer += this.#lineRewriteSequence(window[r] ?? "", width);
@@ -3701,16 +3748,6 @@ export class TUI extends Container {
3701
3748
  this.terminal.write(buffer);
3702
3749
  }
3703
3750
 
3704
- /** Topmost visible overlay requests the alternate-screen buffer. */
3705
- #wantsAltScreen(): boolean {
3706
- for (let i = this.overlayStack.length - 1; i >= 0; i--) {
3707
- const entry = this.overlayStack[i]!;
3708
- if (!this.#isOverlayVisible(entry)) continue;
3709
- return entry.options?.fullscreen === true;
3710
- }
3711
- return false;
3712
- }
3713
-
3714
3751
  /**
3715
3752
  * Compose and paint a single fullscreen overlay frame on the alt buffer.
3716
3753
  * Cursor markers are stripped (the modal draws its own in-band caret and