@oh-my-pi/pi-tui 17.0.1 → 17.0.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,6 +2,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.0.3] - 2026-07-17
6
+
7
+ ### Fixed
8
+
9
+ - Fixed multiline pastes arriving without bracketed-paste markers (e.g. Cmd+V in the Codex desktop embedded terminal on macOS) being split into one submit per line: `StdinBuffer` now collects adjacent ESC-free, CR/LF-bearing stdin reads in a fixed 10 ms classification window and coalesces three or more lines into one paste event, while ambiguous one-break input (including Enter batched with a following keystroke) is replayed unchanged ([#5841](https://github.com/can1357/oh-my-pi/issues/5841)).
10
+ - Fixed wrapped OSC 8 links in Markdown tables making cell padding, separators, and adjacent cells clickable ([#5885](https://github.com/can1357/oh-my-pi/issues/5885)).
11
+ - Fixed interactive sessions surviving terminal closure and entering a runaway render loop by stopping the TUI and raising SIGHUP when terminal input closes or output fails ([#5835](https://github.com/can1357/oh-my-pi/issues/5835)).
12
+ - Fixed native cmux SSH pane resizes inserting blank rows into terminal scrollback by routing remote-transport sessions through the in-place repaint path ([#5857](https://github.com/can1357/oh-my-pi/issues/5857)).
13
+ - Fixed the terminal flickering when leaving a fullscreen overlay (e.g. `/settings`) on terminals that re-report their size when the alternate screen buffer toggles: the alt-toggle SIGWINCH echo is height-only, so the resize fast path no longer borrows the alternate screen for it ([#5854](https://github.com/can1357/oh-my-pi/issues/5854)).
14
+
15
+ ## [17.0.2] - 2026-07-17
16
+
17
+ ### Added
18
+
19
+ - Added a fullscreen overlay mouse-tracking opt-out to allow selection-first dialogs to preserve native terminal text selection.
20
+ - Added `Terminal.refreshAppearance()` to allow consumers to manually trigger a refresh of the detected dark/light terminal appearance without periodic polling.
21
+
22
+ ### Fixed
23
+
24
+ - 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.
25
+ - 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.
26
+ - 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.
27
+ - 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.
28
+
5
29
  ## [17.0.1] - 2026-07-16
6
30
 
7
31
  ### Added
package/README.md CHANGED
@@ -522,7 +522,7 @@ The TUI works with any object implementing the `Terminal` interface:
522
522
 
523
523
  ```typescript
524
524
  interface Terminal {
525
- start(onInput: (data: string) => void, onResize: () => void): void;
525
+ start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void;
526
526
  stop(): void;
527
527
  write(data: string): void;
528
528
  get columns(): number;
@@ -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;
@@ -31,7 +31,7 @@ export declare function emergencyTerminalRestore(): void;
31
31
  /** Terminal-reported appearance (dark/light mode). */
32
32
  export type TerminalAppearance = "dark" | "light";
33
33
  export interface Terminal {
34
- start(onInput: (data: string) => void, onResize: () => void): void;
34
+ start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void;
35
35
  stop(): void;
36
36
  /**
37
37
  * Drain stdin before exiting to prevent Kitty key release events from
@@ -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,8 +103,17 @@ 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
- start(onInput: (data: string) => void, onResize: () => void): void;
116
+ start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void;
98
117
  drainInput(maxMs?: number, idleMs?: number): Promise<void>;
99
118
  stop(): void;
100
119
  write(data: string): 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.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": "17.0.1",
41
- "@oh-my-pi/pi-utils": "17.0.1",
40
+ "@oh-my-pi/pi-natives": "17.0.3",
41
+ "@oh-my-pi/pi-utils": "17.0.3",
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
@@ -2155,8 +2190,8 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
2155
2190
  if (token.text === token.href || token.text === hrefForComparison)
2156
2191
  result += clickableLinkText + stylePrefix;
2157
2192
  else {
2158
- const styledLinkUrl = this.#theme.linkUrl(` (${token.href})`);
2159
- result += clickableLinkText + formatHyperlink(styledLinkUrl, token.href) + stylePrefix;
2193
+ const styledLinkUrl = this.#theme.linkUrl(`(${token.href})`);
2194
+ result += `${clickableLinkText} ${formatHyperlink(styledLinkUrl, token.href)}${stylePrefix}`;
2160
2195
  }
2161
2196
  break;
2162
2197
  }
@@ -2353,7 +2388,14 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
2353
2388
  */
2354
2389
  #wrapCellText(text: string, maxWidth: number): string[] {
2355
2390
  const cellWidth = Math.max(1, maxWidth);
2356
- return splitTerminalLines(text).flatMap(line => wrapTextWithAnsi(line, cellWidth));
2391
+ // Wrap the whole cell in one call so wrapTextWithAnsi() balances OSC 8
2392
+ // hyperlink state across explicit newlines (e.g. `<br>` rendered as \n);
2393
+ // per-fragment wrapping would drop the reopened link on later rows.
2394
+ const wrapped = wrapTextWithAnsi(text, cellWidth);
2395
+ while (wrapped.length > 1 && wrapped[wrapped.length - 1] === "") {
2396
+ wrapped.pop();
2397
+ }
2398
+ return wrapped;
2357
2399
  }
2358
2400
 
2359
2401
  /**
@@ -62,6 +62,39 @@ const MAX_STRING_SEQ_BYTES = 16 * 1024 * 1024;
62
62
  // runs at most once per resolved report — never inside the growth loop.
63
63
  const SGR_MOUSE_COMPLETE = /^<\d+;\d+;\d+[Mm]$/;
64
64
 
65
+ // Raw-paste classification holds CR/LF-bearing, ESC-free input briefly so
66
+ // adjacent stdin reads from one unmarked paste can be considered together.
67
+ // Fixed from the first break-bearing read (not an inactivity debounce): normal
68
+ // Enter latency and candidate memory remain bounded even under a continuous
69
+ // stream. Ten milliseconds spans adjacent PTY reads without becoming perceptible.
70
+ const RAW_PASTE_CLASSIFICATION_TIMEOUT_MS = 10;
71
+
72
+ /**
73
+ * Whether `text` has two completed logical line breaks (three line segments).
74
+ *
75
+ * A single Enter may be batched with surrounding keystrokes in one stdin read,
76
+ * so one break is ambiguous and must stay on the key path. CRLF counts as one
77
+ * logical break. Content after the second break completes the third segment;
78
+ * until then the classification window keeps buffering.
79
+ */
80
+ function isRawMultilineBurst(text: string): boolean {
81
+ let breaks = 0;
82
+ for (let i = 0; i < text.length; i++) {
83
+ const code = text.charCodeAt(i);
84
+ if (code === 0x0d) {
85
+ breaks++;
86
+ if (text.charCodeAt(i + 1) === 0x0a) i++;
87
+ continue;
88
+ }
89
+ if (code === 0x0a) {
90
+ breaks++;
91
+ continue;
92
+ }
93
+ if (breaks >= 2) return true;
94
+ }
95
+ return false;
96
+ }
97
+
65
98
  /**
66
99
  * Resolve the exclusive-end index of the escape sequence starting at `pos`
67
100
  * (`buffer.charCodeAt(pos)` must be ESC). `resumeSearchFrom` is honored only
@@ -364,6 +397,8 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
364
397
  #pendingKittyPrintableCodepoint: number | undefined;
365
398
  #pendingKittyPrintableAtMs = 0;
366
399
  #escapeSearchOffset = 0;
400
+ #rawPasteCandidate = "";
401
+ #rawPasteTimer?: NodeJS.Timeout;
367
402
 
368
403
  constructor(options: StdinBufferOptions = {}) {
369
404
  super();
@@ -398,20 +433,49 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
398
433
  this.#clearFlushTimer();
399
434
  }
400
435
 
401
- if (str.length === 0 && this.#buffer.length === 0) {
436
+ if (str.length === 0 && this.#buffer.length === 0 && this.#rawPasteCandidate.length === 0) {
402
437
  this.#emitDataSequence("");
403
438
  return;
404
439
  }
405
440
 
406
- this.#buffer += str;
407
-
408
441
  if (this.#pasteMode) {
409
- const chunk = this.#buffer;
410
- this.#buffer = "";
411
- this.#consumePasteChunk(chunk);
442
+ this.#consumePasteChunk(str);
443
+ return;
444
+ }
445
+
446
+ if (this.#rawPasteCandidate.length > 0) {
447
+ if (str.indexOf(ESC) !== -1) {
448
+ // Escape-bearing input cannot belong to an unmarked raw paste.
449
+ // Replay the ambiguous prefix as keys before parsing the escape.
450
+ this.#flushRawPasteCandidate();
451
+ } else {
452
+ this.#rawPasteCandidate += str;
453
+ if (isRawMultilineBurst(this.#rawPasteCandidate)) {
454
+ this.#emitRawPasteCandidate();
455
+ }
456
+ return;
457
+ }
458
+ }
459
+
460
+ if (
461
+ this.#buffer.length === 0 &&
462
+ str.indexOf(ESC) === -1 &&
463
+ (str.indexOf("\r") !== -1 || str.indexOf("\n") !== -1)
464
+ ) {
465
+ // Hold the first break-bearing read briefly. A split raw paste can
466
+ // then accumulate enough logical lines to classify; an ordinary
467
+ // Enter is replayed unchanged when the fixed window expires.
468
+ this.#rawPasteCandidate = str;
469
+ if (isRawMultilineBurst(str)) {
470
+ this.#emitRawPasteCandidate();
471
+ } else {
472
+ this.#armRawPasteTimer();
473
+ }
412
474
  return;
413
475
  }
414
476
 
477
+ this.#buffer += str;
478
+
415
479
  const startIndex = this.#buffer.indexOf(BRACKETED_PASTE_START);
416
480
  if (startIndex !== -1) {
417
481
  if (startIndex > 0) {
@@ -526,6 +590,46 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
526
590
  this.emit("paste", content);
527
591
  }
528
592
 
593
+ /** Start one fixed window from the first break-bearing raw read. */
594
+ #armRawPasteTimer(): void {
595
+ if (this.#rawPasteTimer) return;
596
+ this.#rawPasteTimer = setTimeout(() => {
597
+ this.#rawPasteTimer = undefined;
598
+ this.#flushRawPasteCandidate();
599
+ }, RAW_PASTE_CLASSIFICATION_TIMEOUT_MS);
600
+ }
601
+
602
+ #clearRawPasteTimer(): void {
603
+ if (this.#rawPasteTimer) {
604
+ clearTimeout(this.#rawPasteTimer);
605
+ this.#rawPasteTimer = undefined;
606
+ }
607
+ }
608
+
609
+ #takeRawPasteCandidate(): string {
610
+ this.#clearRawPasteTimer();
611
+ const content = this.#rawPasteCandidate;
612
+ this.#rawPasteCandidate = "";
613
+ return content;
614
+ }
615
+
616
+ /** Emit a classified raw multiline burst through the paste channel. */
617
+ #emitRawPasteCandidate(): void {
618
+ const content = this.#takeRawPasteCandidate();
619
+ this.#pendingKittyPrintableCodepoint = undefined;
620
+ this.emit("paste", content);
621
+ }
622
+
623
+ /** Replay an ambiguous raw candidate as the original per-key data events. */
624
+ #flushRawPasteCandidate(): void {
625
+ const content = this.#takeRawPasteCandidate();
626
+ if (content.length === 0) return;
627
+ const result = extractCompleteSequences(content, 0);
628
+ for (const sequence of result.sequences) {
629
+ this.#emitDataSequence(sequence);
630
+ }
631
+ }
632
+
529
633
  #emitDataSequence(sequence: string): void {
530
634
  const rawCodepoint = sequence.length === 1 ? sequence.codePointAt(0) : undefined;
531
635
  if (
@@ -627,8 +731,12 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
627
731
  flush(): string[] {
628
732
  this.#clearFlushTimer();
629
733
 
734
+ const rawCandidate = this.#takeRawPasteCandidate();
735
+ const sequences = rawCandidate.length > 0 ? extractCompleteSequences(rawCandidate, 0).sequences : [];
736
+
630
737
  if (this.#buffer.length === 0) {
631
- return [];
738
+ this.#pendingKittyPrintableCodepoint = undefined;
739
+ return sequences;
632
740
  }
633
741
 
634
742
  const buffered = this.#buffer;
@@ -641,15 +749,19 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
641
749
  // emission swallows the double-escape gesture (#3857). Mirror the inline
642
750
  // split in `extractCompleteSequences` and deliver two ESC events.
643
751
  if (buffered === `${ESC}${ESC}`) {
644
- return [ESC, ESC];
752
+ sequences.push(ESC, ESC);
753
+ } else {
754
+ sequences.push(buffered);
645
755
  }
646
- return [buffered];
756
+ return sequences;
647
757
  }
648
758
 
649
759
  clear(): void {
650
760
  this.#clearFlushTimer();
651
761
  this.#clearPasteWatchdog();
762
+ this.#clearRawPasteTimer();
652
763
  this.#buffer = "";
764
+ this.#rawPasteCandidate = "";
653
765
  this.#pasteMode = false;
654
766
  this.#pasteChunks = [];
655
767
  this.#pasteOverlap = "";
@@ -660,7 +772,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
660
772
  }
661
773
 
662
774
  getBuffer(): string {
663
- return this.#buffer;
775
+ return `${this.#rawPasteCandidate}${this.#buffer}`;
664
776
  }
665
777
 
666
778
  destroy(): void {
@@ -180,12 +180,13 @@ export class TerminalInfo {
180
180
 
181
181
  /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
182
182
  export function isInsideTerminalMultiplexer(env: NodeJS.ProcessEnv = Bun.env): boolean {
183
- // TMUX/STY/ZELLIJ/CMUX workspace+surface ids are authoritative session
184
- // signals. TERM can also survive when those are stripped (`sudo` without -E,
185
- // `su`, env-sanitizing launchers/ssh). Do not use CMUX_SOCKET_PATH here: it is
186
- // a CLI socket override and can be set outside a CMUX terminal.
183
+ // TMUX/STY/ZELLIJ and CMUX workspace/surface/remote-transport markers are
184
+ // authoritative session signals. TERM can also survive when those are
185
+ // stripped (`sudo` without -E, `su`, env-sanitizing launchers/ssh). Do not
186
+ // use CMUX_SOCKET_PATH here: it is a CLI socket override and can be set
187
+ // outside a CMUX terminal.
187
188
  if (env.TMUX || env.STY || env.ZELLIJ) return true;
188
- if (env.CMUX_WORKSPACE_ID || env.CMUX_SURFACE_ID) return true;
189
+ if (env.CMUX_WORKSPACE_ID || env.CMUX_SURFACE_ID || env.CMUX_REMOTE_TRANSPORT) return true;
189
190
  const term = env.TERM?.toLowerCase() ?? "";
190
191
  return term.startsWith("tmux") || term.startsWith("screen");
191
192
  }
package/src/terminal.ts CHANGED
@@ -337,8 +337,8 @@ export function emergencyTerminalRestore(): void {
337
337
  /** Terminal-reported appearance (dark/light mode). */
338
338
  export type TerminalAppearance = "dark" | "light";
339
339
  export interface Terminal {
340
- // Start the terminal with input and resize handlers
341
- start(onInput: (data: string) => void, onResize: () => void): void;
340
+ // Start the terminal with input, resize, and host-disconnect handlers.
341
+ start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void;
342
342
 
343
343
  // Stop the terminal and restore state
344
344
  stop(): void;
@@ -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
  /**
@@ -473,6 +483,16 @@ export class ProcessTerminal implements Terminal {
473
483
  #modifyOtherKeysTimeout?: Timer;
474
484
  #stdinBuffer?: StdinBuffer;
475
485
  #stdinDataHandler?: (data: string) => void;
486
+ #disconnectHandler?: () => void;
487
+ #stdinEndHandler = () => {
488
+ this.#markTerminalDisconnected("stdin ended");
489
+ };
490
+ #stdinCloseHandler = () => {
491
+ this.#markTerminalDisconnected("stdin closed");
492
+ };
493
+ #stdinErrorHandler = (err: Error) => {
494
+ this.#markTerminalDisconnected("stdin failed", err);
495
+ };
476
496
  #dead = false;
477
497
  // Captured at construction and re-read at start(): when true, every real
478
498
  // terminal side effect (writes, probes, raw mode, SIGWINCH, timers) is
@@ -481,7 +501,7 @@ export class ProcessTerminal implements Terminal {
481
501
  #writeLogPath = $env.PI_TUI_WRITE_LOG || "";
482
502
  #stdoutErrorCleanup?: () => void;
483
503
  #stdoutErrorHandler = (err: Error) => {
484
- this.#markTerminalWriteFailed(err);
504
+ this.#markTerminalDisconnected("stdout failed", err);
485
505
  };
486
506
 
487
507
  #windowsVTInputRestore?: () => void;
@@ -550,13 +570,27 @@ export class ProcessTerminal implements Terminal {
550
570
  }
551
571
  }
552
572
 
573
+ /**
574
+ * Re-query the terminal background via a single OSC 11 probe. Reuses the
575
+ * startup query path — same DA1-sentinel FIFO, pending/queued gating, parsing,
576
+ * dedup, and appearance callbacks — so a light/dark switch is picked up
577
+ * without a restart on terminals lacking end-to-end Mode 2031 notifications.
578
+ * Bounded to one probe per call; no timers are armed. Suppressed while headless
579
+ * or after the terminal is torn down.
580
+ */
581
+ refreshAppearance(): void {
582
+ if (this.#headless || this.#dead) return;
583
+ this.#queryBackgroundColor();
584
+ }
585
+
553
586
  onPrivateModeReport(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void {
554
587
  this.#privateModeCallbacks.push(callback);
555
588
  }
556
589
 
557
- start(onInput: (data: string) => void, onResize: () => void): void {
590
+ start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void {
558
591
  this.#inputHandler = onInput;
559
592
  this.#resizeHandler = onResize;
593
+ this.#disconnectHandler = onDisconnect;
560
594
 
561
595
  // Headless (tests): suppress every real-terminal side effect. Skip raw
562
596
  // mode, stdin listeners, capability probes, SIGWINCH, and emergency-restore
@@ -581,6 +615,9 @@ export class ProcessTerminal implements Terminal {
581
615
  process.stdin.setRawMode(true);
582
616
  }
583
617
  process.stdin.setEncoding("utf8");
618
+ process.stdin.on("end", this.#stdinEndHandler);
619
+ process.stdin.on("close", this.#stdinCloseHandler);
620
+ process.stdin.on("error", this.#stdinErrorHandler);
584
621
  process.stdin.resume();
585
622
 
586
623
  // Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
@@ -1402,6 +1439,10 @@ export class ProcessTerminal implements Terminal {
1402
1439
  process.stdin.removeListener("data", this.#stdinDataHandler);
1403
1440
  this.#stdinDataHandler = undefined;
1404
1441
  }
1442
+ process.stdin.removeListener("end", this.#stdinEndHandler);
1443
+ process.stdin.removeListener("close", this.#stdinCloseHandler);
1444
+ process.stdin.removeListener("error", this.#stdinErrorHandler);
1445
+ this.#disconnectHandler = undefined;
1405
1446
  this.#inputHandler = undefined;
1406
1447
  this.#appearance = undefined;
1407
1448
  if (this.#stdoutResizeListener) {
@@ -1427,10 +1468,26 @@ export class ProcessTerminal implements Terminal {
1427
1468
  this.#stdoutErrorCleanup ??= registerStdoutErrorHandler(this.#stdoutErrorHandler);
1428
1469
  }
1429
1470
 
1430
- #markTerminalWriteFailed(err: unknown): void {
1471
+ #markTerminalDisconnected(reason: string, err?: unknown): void {
1431
1472
  if (this.#dead) return;
1432
1473
  this.#dead = true;
1433
- logger.warn("terminal write failed; disabling terminal rendering", { err });
1474
+ logger.warn("terminal disconnected; stopping interactive rendering", { reason, err });
1475
+
1476
+ const disconnectHandler = this.#disconnectHandler;
1477
+ this.#disconnectHandler = undefined;
1478
+ if (!disconnectHandler) return;
1479
+ disconnectHandler();
1480
+
1481
+ if (process.platform === "win32") {
1482
+ void postmortem.quit(129);
1483
+ return;
1484
+ }
1485
+ try {
1486
+ process.kill(process.pid, "SIGHUP");
1487
+ } catch (signalErr) {
1488
+ logger.error("Failed to deliver terminal disconnect signal; exiting directly", { err: signalErr });
1489
+ void postmortem.quit(129);
1490
+ }
1434
1491
  }
1435
1492
 
1436
1493
  write(data: string): void {
@@ -1477,7 +1534,7 @@ export class ProcessTerminal implements Terminal {
1477
1534
  process.stdout.write(data);
1478
1535
  }
1479
1536
  } catch (err) {
1480
- this.#markTerminalWriteFailed(err);
1537
+ this.#markTerminalDisconnected("stdout failed", err);
1481
1538
  }
1482
1539
  }
1483
1540
 
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;
@@ -1558,7 +1571,9 @@ export class TUI extends Container {
1558
1571
  }
1559
1572
  this.#armMultiplexerResizeTimer(false);
1560
1573
  },
1574
+ () => this.stop(),
1561
1575
  );
1576
+ if (this.#stopped) return;
1562
1577
  for (const listener of this.#startListeners) {
1563
1578
  try {
1564
1579
  listener();
@@ -1741,12 +1756,18 @@ export class TUI extends Container {
1741
1756
  }
1742
1757
 
1743
1758
  stop(): void {
1759
+ // Leave the resize alt buffer first so the teardown cursor math below runs
1760
+ // against the restored normal screen (which #previousLines still describes).
1761
+ if (this.#resizeAltActive) {
1762
+ this.terminal.write(this.#leaveResizeAltSequence());
1763
+ }
1744
1764
  if (this.#altActive || this.#pendingAltExit) {
1745
- const exitSequence =
1746
- this.#pendingAltExit || `${MOUSE_TRACKING_OFF}${this.#keyboardEnhancementExit()}\x1b[?1049l`;
1765
+ const mouseExit = this.#altMouseTrackingActive ? MOUSE_TRACKING_OFF : "";
1766
+ const exitSequence = this.#pendingAltExit || `${mouseExit}${this.#keyboardEnhancementExit()}\x1b[?1049l`;
1747
1767
  this.terminal.write(exitSequence);
1748
1768
  setAltScreenActive(false);
1749
1769
  this.#altActive = false;
1770
+ this.#altMouseTrackingActive = false;
1750
1771
  this.#altPreviousLines = [];
1751
1772
  this.#pendingAltExit = "";
1752
1773
  }
@@ -2705,24 +2726,29 @@ export class TUI extends Container {
2705
2726
  // requests it, borrow the terminal's alternate buffer and paint only the
2706
2727
  // modal there; the normal screen and all accounting stay untouched.
2707
2728
  let deferredAltExit = this.#pendingAltExit;
2708
- const wantAlt = this.#wantsAltScreen();
2729
+ const topOverlay = this.#getTopmostVisibleOverlay();
2730
+ const wantAlt = topOverlay?.options?.fullscreen === true;
2731
+ const wantMouseTracking = wantAlt && topOverlay.options?.mouseTracking !== false;
2709
2732
  if (wantAlt && !this.#altActive) {
2710
2733
  // Enhanced keyboard modes can be buffer-local: re-push the active
2711
2734
  // modified-key reporting sequence on the freshly entered alternate
2712
2735
  // screen, or Esc/modified keys revert to legacy encoding inside
2713
2736
  // fullscreen overlays (Ghostty/kitty/iTerm2).
2714
- this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}${MOUSE_TRACKING_ON}`);
2737
+ const mouseEnter = wantMouseTracking ? MOUSE_TRACKING_ON : "";
2738
+ this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}${mouseEnter}`);
2715
2739
  setAltScreenActive(true);
2716
2740
  this.terminal.hideCursor();
2717
2741
  this.#forgetHardwareCursorState();
2718
2742
  this.#recordHardwareCursorHidden();
2719
2743
  this.#altActive = true;
2744
+ this.#altMouseTrackingActive = wantMouseTracking;
2720
2745
  this.#altPreviousLines = [];
2721
2746
  this.#altEnterWidth = width;
2722
2747
  this.#altEnterHeight = height;
2723
2748
  } else if (!wantAlt && this.#altActive) {
2749
+ const mouseExit = this.#altMouseTrackingActive ? MOUSE_TRACKING_OFF : "";
2724
2750
  const enhancementExit = this.#keyboardEnhancementExit();
2725
- const exitSequence = `${MOUSE_TRACKING_OFF}${enhancementExit}\x1b[?1049l`;
2751
+ const exitSequence = `${mouseExit}${enhancementExit}\x1b[?1049l`;
2726
2752
  // Session replacement can finish while a fullscreen selector is still
2727
2753
  // covering the old normal buffer. Keep the overlay visible until the
2728
2754
  // replacement is ready, then fuse the buffer restore into that full paint;
@@ -2734,6 +2760,7 @@ export class TUI extends Container {
2734
2760
  setAltScreenActive(false);
2735
2761
  this.#forgetHardwareCursorState();
2736
2762
  this.#altActive = false;
2763
+ this.#altMouseTrackingActive = false;
2737
2764
  this.#altPreviousLines = [];
2738
2765
  // A resize while on the alt buffer reflowed the terminal's saved
2739
2766
  // normal screen; it no longer matches our accounting, so force the
@@ -2741,6 +2768,9 @@ export class TUI extends Container {
2741
2768
  if (width !== this.#altEnterWidth || height !== this.#altEnterHeight) {
2742
2769
  this.#resizeEventPending = true;
2743
2770
  }
2771
+ } else if (wantMouseTracking !== this.#altMouseTrackingActive) {
2772
+ this.terminal.write(wantMouseTracking ? MOUSE_TRACKING_ON : MOUSE_TRACKING_OFF);
2773
+ this.#altMouseTrackingActive = wantMouseTracking;
2744
2774
  }
2745
2775
  if (this.#altActive) {
2746
2776
  this.#componentRenderTargets.clear();
@@ -3478,7 +3508,7 @@ export class TUI extends Container {
3478
3508
  paintCursorPos = paint.cursorPos;
3479
3509
  }
3480
3510
  }
3481
- let buffer = this.#paintBeginSequence + options.leadingSequence + purgeSequence;
3511
+ let buffer = this.#paintBeginSequence + this.#leaveResizeAltSequence() + options.leadingSequence + purgeSequence;
3482
3512
  if (options.clearScrollback) {
3483
3513
  // Clear native history without blanking the live viewport first. The
3484
3514
  // replay below rewrites every visible row from home, including blanks,
@@ -3678,15 +3708,42 @@ export class TUI extends Container {
3678
3708
  return this.terminal.kittyEnableSequence ? "\x1b[<u" : "";
3679
3709
  }
3680
3710
 
3711
+ #enterResizeAltSequence(): string {
3712
+ if (this.#resizeAltActive || this.#altActive) return "";
3713
+ this.#resizeAltActive = true;
3714
+ setAltScreenActive(true);
3715
+ this.#forgetHardwareCursorState();
3716
+ this.#recordHardwareCursorHidden();
3717
+ return `${ALT_SCREEN_ENTER}${this.#keyboardEnhancementEnter()}`;
3718
+ }
3719
+
3720
+ #leaveResizeAltSequence(): string {
3721
+ if (!this.#resizeAltActive) return "";
3722
+ const enhancementExit = this.#keyboardEnhancementExit();
3723
+ this.#resizeAltActive = false;
3724
+ setAltScreenActive(false);
3725
+ this.#forgetHardwareCursorState();
3726
+ return `${enhancementExit}${ALT_SCREEN_EXIT}`;
3727
+ }
3728
+
3681
3729
  /**
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.
3730
+ * Emit a throwaway viewport repaint for the resize fast path as a per-row
3731
+ * overwrite. A width change can make the terminal's normal buffer reflow
3732
+ * full-width rows before the app repaints, so a width drag borrows the
3733
+ * alternate screen: transient resizes truncate the viewport instead of
3734
+ * pushing wrapped fragments into native scrollback. A height-only resize
3735
+ * reflows nothing, so it repaints the normal screen in place — borrowing the
3736
+ * alt buffer there is pure flicker, and on terminals that re-report their
3737
+ * size when the alt buffer toggles it is self-sustaining: leaving a
3738
+ * fullscreen overlay's alt screen fires a height-only SIGWINCH echo, which
3739
+ * would otherwise re-borrow the alt buffer for one frame (the settings-exit
3740
+ * flash, #5854). Normal-screen history is rebuilt once at settle via
3741
+ * `#emitFullPaint`.
3687
3742
  */
3688
3743
  #emitResizeViewport(window: readonly string[], height: number, contentRows: number, width: number): void {
3689
- let buffer = `${this.#paintBeginSequence}\x1b[H`;
3744
+ const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
3745
+ const altEnter = widthChanged ? this.#enterResizeAltSequence() : "";
3746
+ let buffer = `${this.#paintBeginSequence + altEnter}\x1b[H`;
3690
3747
  for (let r = 0; r < height; r++) {
3691
3748
  if (r > 0) buffer += "\r\n";
3692
3749
  buffer += this.#lineRewriteSequence(window[r] ?? "", width);
@@ -3701,16 +3758,6 @@ export class TUI extends Container {
3701
3758
  this.terminal.write(buffer);
3702
3759
  }
3703
3760
 
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
3761
  /**
3715
3762
  * Compose and paint a single fullscreen overlay frame on the alt buffer.
3716
3763
  * Cursor markers are stripped (the modal draws its own in-band caret and