@oh-my-pi/pi-tui 16.0.0 → 16.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,25 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.0.2] - 2026-06-16
6
+
7
+ ### Fixed
8
+
9
+ - Fixed VS Code integrated terminal keypad digit CSI-u input being handled as navigation instead of text.
10
+ - Fixed xterm-compatible terminals scrolling the native viewport to the bottom on prompt-editor keypresses by disabling `?1010`/`?1011` while the TUI owns the TTY and restoring the prior set modes on exit ([#2732](https://github.com/can1357/oh-my-pi/issues/2732)).
11
+ - Fixed CMUX sessions being treated as direct terminals during resize/reset because they do not set `TMUX`/`STY`/`ZELLIJ` and may run with `TERM=dumb`; the renderer now treats CMUX workspace/surface env markers as multiplexer signals and preserves pane scrollback instead of emitting ED3 (`CSI 3 J`).
12
+ - Fixed a self-sustaining resize-redraw storm in Warp: the non-multiplexer resize fast path borrows the alternate screen, and Warp re-reports a one-row-different size whenever the alt buffer is toggled, so each drag frame fed back a fresh resize event and the TUI flooded ED3 full repaints with stable geometry. Resize now repaints in place (no alt-screen borrow, no ED3 rewrap) on terminals that re-report size on alt-screen toggles, matching the multiplexer path. Overridable with `PI_TUI_RESIZE_IN_PLACE=1|0`.
13
+
14
+ ## [16.0.1] - 2026-06-15
15
+
16
+ ### Added
17
+
18
+ - Added Zellij and WezTerm pane environment fallbacks for terminal-specific session continuation when no TTY path is available.
19
+
20
+ ### Fixed
21
+
22
+ - Fixed slash command autocomplete acceptance replacing only a stale rendered prefix, which could leave fast-typed characters before `/skills:` completions and corrupt the submitted command ([#1745](https://github.com/can1357/oh-my-pi/issues/1745)).
23
+
5
24
  ## [15.13.1] - 2026-06-15
6
25
 
7
26
  ### Added
@@ -382,7 +401,7 @@
382
401
 
383
402
  ### Changed
384
403
 
385
- - Changed native-scrollback safety defaults to treat unknown POSIX, SSH, and multiplexer-shaped terminals as ED3-risk for passive rendering; checkpoint replay now requires a positive at-tail viewport proof instead of assuming prompt submit makes host scrollback safe.
404
+ - Changed native-scrollback safety defaults to treat unknown POSIX, SSH, and multiplexer-shaped terminals as ED3-risk for passive rendering; checkpoint replay now requires a positive at-tail viewport proof instead of assuming prompt submit makes host scrollback safe ([#1799](https://github.com/can1357/oh-my-pi/issues/1799)).
386
405
  - Changed synchronized-output defaults to a conservative opt-in profile: DEC 2026 paint wrappers stay disabled for remote/multiplexer/VTE/unknown terminals unless explicitly forced, while the autowrap guards remain active.
387
406
 
388
407
  ### Fixed
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "16.0.0",
4
+ "version": "16.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": "16.0.0",
41
- "@oh-my-pi/pi-utils": "16.0.0",
40
+ "@oh-my-pi/pi-natives": "16.0.2",
41
+ "@oh-my-pi/pi-utils": "16.0.2",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.5"
44
44
  },
@@ -413,27 +413,39 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
413
413
  prefix: string,
414
414
  ): { lines: string[]; cursorLine: number; cursorCol: number } {
415
415
  const currentLine = lines[cursorLine] || "";
416
- const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
416
+ const textBeforeCursor = currentLine.slice(0, cursorCol);
417
417
  const afterCursor = currentLine.slice(cursorCol);
418
418
 
419
- // Check if we're completing a slash command (prefix starts with "/" but NOT a file path)
420
- // Slash commands are at the start of the line and don't contain path separators after the first /
421
- const isSlashCommand = prefix.startsWith("/") && beforePrefix.trim() === "" && !prefix.slice(1).includes("/");
422
- if (isSlashCommand) {
423
- // This is a command name completion
424
- const newLine = `${beforePrefix}/${item.value} ${afterCursor}`;
425
- const newLines = [...lines];
426
- newLines[cursorLine] = newLine;
419
+ const slashStart = textBeforeCursor.indexOf("/");
420
+ const hasOnlyWhitespaceBeforeSlash = slashStart >= 0 && textBeforeCursor.slice(0, slashStart).trim() === "";
427
421
 
428
- return {
429
- lines: newLines,
430
- cursorLine,
431
- cursorCol: beforePrefix.length + item.value.length + 2, // +2 for "/" and space
432
- };
422
+ // Slash command suggestions can be accepted before the debounced refresh
423
+ // catches up to newly typed characters. Replace the live command token,
424
+ // not only the prefix captured when the suggestion list was rendered.
425
+ if (prefix.startsWith("/") && hasOnlyWhitespaceBeforeSlash) {
426
+ const slashPrefix = textBeforeCursor.slice(slashStart);
427
+ if (!slashPrefix.includes(" ") && !slashPrefix.slice(1).includes("/")) {
428
+ const beforeSlash = currentLine.slice(0, slashStart);
429
+ const newLine = `${beforeSlash}/${item.value} ${afterCursor}`;
430
+ const newLines = [...lines];
431
+ newLines[cursorLine] = newLine;
432
+
433
+ return {
434
+ lines: newLines,
435
+ cursorLine,
436
+ cursorCol: beforeSlash.length + item.value.length + 2, // +2 for "/" and space
437
+ };
438
+ }
433
439
  }
434
440
 
441
+ let beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
442
+
435
443
  // Check if we're completing a file attachment (prefix starts with "@")
436
444
  if (prefix.startsWith("@")) {
445
+ const liveAtPrefix = this.#extractAtPrefix(textBeforeCursor);
446
+ if (liveAtPrefix) {
447
+ beforePrefix = currentLine.slice(0, cursorCol - liveAtPrefix.length);
448
+ }
437
449
  // This is a file attachment completion
438
450
  const newLine = `${beforePrefix + item.value} ${afterCursor}`;
439
451
  const newLines = [...lines];
@@ -446,21 +458,10 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
446
458
  };
447
459
  }
448
460
 
449
- // Check if we're in a slash command context (beforePrefix contains "/command ")
450
- const textBeforeCursor = currentLine.slice(0, cursorCol);
451
- if (textBeforeCursor.includes("/") && textBeforeCursor.includes(" ")) {
452
- // This is likely a command argument completion
453
- const newLine = beforePrefix + item.value + afterCursor;
454
- const newLines = [...lines];
455
- newLines[cursorLine] = newLine;
456
-
457
- return {
458
- lines: newLines,
459
- cursorLine,
460
- cursorCol: beforePrefix.length + item.value.length,
461
- };
462
- }
463
-
461
+ // Slash command argument and plain file path completion both fall through
462
+ // to the path-completion tail below — `beforePrefix` already covers the
463
+ // rendered prefix, which preserves earlier arguments (e.g. accepting
464
+ // `package.json` for `/swarm run pac<Tab>` keeps the `run` token intact).
464
465
  // For file paths, complete the path
465
466
  const newLine = beforePrefix + item.value + afterCursor;
466
467
  const newLines = [...lines];
package/src/keys.ts CHANGED
@@ -303,7 +303,7 @@ const KITTY_MOD_ALT = 2;
303
303
  const KITTY_MOD_CTRL = 4;
304
304
  const KITTY_MOD_SUPER = 8;
305
305
  const KITTY_MOD_NUM_LOCK = 128;
306
- const KITTY_LOCK_MASK = 64 + 128; // Caps Lock + Num Lock
306
+ const KITTY_LOCK_MASK = 64 + KITTY_MOD_NUM_LOCK; // Caps Lock + Num Lock
307
307
  const MODIFY_OTHER_KEYS_PATTERN = /^\x1b\[27;(\d+);(\d+)~$/;
308
308
  const KITTY_KEYPAD_OPERATOR_TEXT: Record<number, string> = {
309
309
  57410: "/",
@@ -409,7 +409,7 @@ function decodeKittyPrintable(data: string): string | undefined {
409
409
  .split(":")
410
410
  .filter(Boolean)
411
411
  .map(value => Number.parseInt(value, 10))
412
- .filter(value => Number.isFinite(value) && value >= 32);
412
+ .filter(value => Number.isFinite(value) && value >= 32 && value !== 127);
413
413
  if (codepoints.length > 0) {
414
414
  try {
415
415
  return String.fromCodePoint(...codepoints);
@@ -421,7 +421,7 @@ function decodeKittyPrintable(data: string): string | undefined {
421
421
  const keypadOperatorText = KITTY_KEYPAD_OPERATOR_TEXT[codepoint];
422
422
  if (keypadOperatorText) return keypadOperatorText;
423
423
 
424
- if (effectiveMod === 0 && modifier & KITTY_MOD_NUM_LOCK) {
424
+ if (effectiveMod === 0) {
425
425
  const numpadText = KITTY_NUMPAD_TEXT[codepoint];
426
426
  if (numpadText) return numpadText;
427
427
  }
@@ -435,7 +435,7 @@ function decodeKittyPrintable(data: string): string | undefined {
435
435
  return undefined;
436
436
  }
437
437
 
438
- if (!Number.isFinite(effectiveCodepoint) || effectiveCodepoint < 32) return undefined;
438
+ if (!Number.isFinite(effectiveCodepoint) || effectiveCodepoint < 32 || effectiveCodepoint === 127) return undefined;
439
439
 
440
440
  try {
441
441
  return String.fromCodePoint(effectiveCodepoint);
@@ -486,7 +486,7 @@ function decodeModifyOtherKeysPrintable(data: string): string | undefined {
486
486
  if (!parsed) return undefined;
487
487
  const modifier = parsed.modifier & ~KITTY_LOCK_MASK;
488
488
  if ((modifier & ~KITTY_MOD_SHIFT) !== 0) return undefined;
489
- if (!Number.isFinite(parsed.codepoint) || parsed.codepoint < 32) return undefined;
489
+ if (!Number.isFinite(parsed.codepoint) || parsed.codepoint < 32 || parsed.codepoint === 127) return undefined;
490
490
  try {
491
491
  return String.fromCodePoint(parsed.codepoint);
492
492
  } catch {
@@ -504,6 +504,30 @@ export function decodePrintableKey(data: string): string | undefined {
504
504
  return decodeKittyPrintable(data) ?? decodeModifyOtherKeysPrintable(data);
505
505
  }
506
506
 
507
+ /**
508
+ * Decode a Kitty CSI-u keypad sequence (numpad digits / keypad operators) into the
509
+ * text it produces, or `undefined` for any non-keypad sequence.
510
+ *
511
+ * The native key matcher classifies bare numpad codepoints (those without a NumLock
512
+ * modifier bit) as navigation keys, but terminals such as the VS Code integrated
513
+ * terminal emit those codepoints for real digit input. Restricting the fast path to
514
+ * keypad codepoints keeps canonical named keys (space, backspace, shifted keys, and
515
+ * modifyOtherKeys sequences) flowing through native normalization.
516
+ */
517
+ function decodeKittyKeypadText(data: string): string | undefined {
518
+ const match = data.match(KITTY_CSI_U_PATTERN);
519
+ if (!match) return undefined;
520
+ const codepoint = Number.parseInt(match[1] ?? "", 10);
521
+ if (!(codepoint in KITTY_NUMPAD_TEXT) && !(codepoint in KITTY_KEYPAD_OPERATOR_TEXT)) return undefined;
522
+ return decodeKittyPrintable(data);
523
+ }
524
+
525
+ function matchesKeypadKey(data: string, keyId: KeyId): boolean | undefined {
526
+ const printable = decodeKittyKeypadText(data);
527
+ if (printable === undefined) return undefined;
528
+ return printable === keyId;
529
+ }
530
+
507
531
  /**
508
532
  * Match input data against a key identifier string.
509
533
  *
@@ -521,7 +545,7 @@ export function decodePrintableKey(data: string): string | undefined {
521
545
  * @param keyId - Key identifier (e.g., "ctrl+c", "escape", Key.ctrl("c"))
522
546
  */
523
547
  export function matchesKey(data: string, keyId: KeyId): boolean {
524
- return matchesKeyNative(data, keyId, kittyProtocolActive);
548
+ return matchesKeypadKey(data, keyId) ?? matchesKeyNative(data, keyId, kittyProtocolActive);
525
549
  }
526
550
 
527
551
  /**
@@ -533,5 +557,5 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
533
557
  * @param data - Raw input data from terminal
534
558
  */
535
559
  export function parseKey(data: string): string | undefined {
536
- return parseKeyNative(data, kittyProtocolActive) ?? undefined;
560
+ return decodeKittyKeypadText(data) ?? parseKeyNative(data, kittyProtocolActive) ?? undefined;
537
561
  }
package/src/terminal.ts CHANGED
@@ -375,6 +375,20 @@ function parseOsc99KeyValues(section: string): Map<string, string> {
375
375
  }
376
376
  return values;
377
377
  }
378
+ const XTERM_SCROLL_TO_BOTTOM_MODES = [1010, 1011] as const;
379
+
380
+ function isXtermScrollToBottomMode(mode: number): boolean {
381
+ return mode === 1010 || mode === 1011;
382
+ }
383
+
384
+ function isPrivateModeSet(status: string): boolean {
385
+ return status === "1" || status === "3";
386
+ }
387
+
388
+ function isPrivateModeSupported(status: string): boolean {
389
+ return status !== "0" && status !== "4";
390
+ }
391
+
378
392
  /**
379
393
  * Real terminal using process.stdin/stdout
380
394
  */
@@ -397,6 +411,7 @@ export class ProcessTerminal implements Terminal {
397
411
  };
398
412
 
399
413
  #windowsVTInputRestore?: () => void;
414
+ #xtermScrollToBottomRestoreModes = new Set<number>();
400
415
  #appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = [];
401
416
  #appearance: TerminalAppearance | undefined;
402
417
  #osc11Pending = false;
@@ -518,13 +533,17 @@ export class ProcessTerminal implements Terminal {
518
533
  // Probe DEC private-mode support via DECRQM. 2026 (synchronized output)
519
534
  // gates the renderer's begin/end markers; 2048 (in-band resize) is enabled
520
535
  // only after the terminal confirms support; 2031 (appearance change
521
- // notifications) stops the OSC 11 poll once confirmed, since push
522
- // notifications make polling redundant. Each probe rides the shared DA1
523
- // sentinel FIFO, so a terminal that ignores DECRQM still resolves (as
524
- // unsupported) when the DA1 reply arrives.
536
+ // notifications) stops the OSC 11 poll once confirmed. Xterm ?1010/?1011
537
+ // are disabled while OMP owns the TTY so typing in the editor does not
538
+ // force a reader scrolled into native history back to the tail. Each probe
539
+ // rides the shared DA1 sentinel, so terminals that ignore DECRQM resolve as
540
+ // unsupported when the DA1 reply arrives.
525
541
  this.#queryPrivateMode(2026);
526
542
  this.#queryPrivateMode(2048);
527
543
  this.#queryPrivateMode(2031);
544
+ for (const mode of XTERM_SCROLL_TO_BOTTOM_MODES) {
545
+ this.#queryPrivateMode(mode);
546
+ }
528
547
  }
529
548
 
530
549
  /**
@@ -709,11 +728,10 @@ export class ProcessTerminal implements Terminal {
709
728
  // DECRPM private-mode report. Resolves the matching probe by mode; the
710
729
  // owner stays in the FIFO and is drained by its DA1 sentinel (a no-op
711
730
  // once resolved). Per DECRPM, status 0 = unrecognized, 1/2 =
712
- // set/reset, 3 = permanently set, and 4 = permanently reset. Only
713
- // settable or permanently-set modes are useful for features we enable.
731
+ // set/reset, 3 = permanently set, and 4 = permanently reset.
714
732
  const decrpmMatch = sequence.match(decrpmResponsePattern);
715
733
  if (decrpmMatch) {
716
- this.#resolvePrivateMode(parseInt(decrpmMatch[1]!, 10), decrpmMatch[2] !== "0" && decrpmMatch[2] !== "4");
734
+ this.#handlePrivateModeReport(parseInt(decrpmMatch[1]!, 10), decrpmMatch[2]!);
717
735
  return;
718
736
  }
719
737
 
@@ -1021,6 +1039,13 @@ export class ProcessTerminal implements Terminal {
1021
1039
  this.#safeWrite(`\x1b[?${mode}$p\x1b[c`);
1022
1040
  }
1023
1041
 
1042
+ #handlePrivateModeReport(mode: number, status: string): void {
1043
+ this.#resolvePrivateMode(mode, isPrivateModeSupported(status));
1044
+ if (isXtermScrollToBottomMode(mode) && isPrivateModeSet(status)) {
1045
+ this.#disableXtermScrollToBottomMode(mode);
1046
+ }
1047
+ }
1048
+
1024
1049
  /**
1025
1050
  * Record DECRQM support for a private mode (idempotent — first result wins)
1026
1051
  * and notify subscribers. Enables DEC 2048 in-band resize when 2048 resolves
@@ -1042,6 +1067,12 @@ export class ProcessTerminal implements Terminal {
1042
1067
  if (mode === 2031 && supported) this.#stopOsc11Poll();
1043
1068
  }
1044
1069
 
1070
+ #disableXtermScrollToBottomMode(mode: number): void {
1071
+ if (this.#xtermScrollToBottomRestoreModes.has(mode) || this.#dead) return;
1072
+ this.#xtermScrollToBottomRestoreModes.add(mode);
1073
+ this.#safeWrite(`\x1b[?${mode}l`);
1074
+ }
1075
+
1045
1076
  /**
1046
1077
  * Enable DEC 2048 in-band resize notifications. The terminal emits an initial
1047
1078
  * report immediately, seeding reported geometry and cell dimensions.
@@ -1170,7 +1201,12 @@ export class ProcessTerminal implements Terminal {
1170
1201
  // Disable Mode 2031 appearance change notifications
1171
1202
  this.#safeWrite("\x1b[?2031l");
1172
1203
 
1173
- // Disable DEC 2048 in-band resize notifications if we enabled them.
1204
+ // Restore xterm scroll-to-bottom modes that were set before startup.
1205
+ for (const mode of this.#xtermScrollToBottomRestoreModes) {
1206
+ this.#safeWrite(`\x1b[?${mode}h`);
1207
+ }
1208
+ this.#xtermScrollToBottomRestoreModes.clear();
1209
+
1174
1210
  if (this.#inBandResizeActive) {
1175
1211
  this.#safeWrite("\x1b[?2048l");
1176
1212
  this.#inBandResizeActive = false;
@@ -1193,6 +1229,7 @@ export class ProcessTerminal implements Terminal {
1193
1229
  this.#da1SentinelOwners.length = 0;
1194
1230
  this.#privateModeCallbacks = [];
1195
1231
  this.#privateModeSupport.clear();
1232
+ this.#xtermScrollToBottomRestoreModes.clear();
1196
1233
  this.#reportedColumns = undefined;
1197
1234
  this.#reportedRows = undefined;
1198
1235
 
package/src/ttyid.ts CHANGED
@@ -51,15 +51,29 @@ export function getTerminalId(): string | null {
51
51
 
52
52
  // Fallback to terminal-specific env vars
53
53
  // Prefer inner multiplexers over host terminal emulators when stdin has no TTY path.
54
+ const zellijPane = process.env.ZELLIJ_PANE_ID;
55
+ if (zellijPane) {
56
+ // Session names are user-chosen (`zellij -s …`) and the id is used as a
57
+ // breadcrumb filename — normalize path separators like the TTY branch does.
58
+ const zellijSession = process.env.ZELLIJ_SESSION_NAME?.replace(/[\\/]/g, "-");
59
+ return zellijSession ? `zellij-${zellijSession}-${zellijPane}` : `zellij-${zellijPane}`;
60
+ }
61
+
54
62
  const tmuxPane = process.env.TMUX_PANE;
55
63
  if (tmuxPane) return `tmux-${tmuxPane}`;
56
64
 
57
65
  const cmuxSurface = process.env.CMUX_SURFACE_ID;
58
66
  if (cmuxSurface) return `cmux-${cmuxSurface}`;
59
67
 
68
+ // Kitty before WezTerm/others, matching terminal-capabilities.ts detection
69
+ // order. Inherited env makes either order wrong for some nesting; staying
70
+ // consistent with the capability detector keeps the two answers aligned.
60
71
  const kittyId = process.env.KITTY_WINDOW_ID;
61
72
  if (kittyId) return `kitty-${kittyId}`;
62
73
 
74
+ const weztermPane = process.env.WEZTERM_PANE;
75
+ if (weztermPane) return `wezterm-${weztermPane}`;
76
+
63
77
  const terminalSessionId = process.env.TERM_SESSION_ID; // macOS Terminal.app
64
78
  if (terminalSessionId) return `apple-${terminalSessionId}`;
65
79
 
package/src/tui.ts CHANGED
@@ -370,15 +370,48 @@ function parseSizeValue(value: SizeValue | undefined, referenceSize: number): nu
370
370
 
371
371
  /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
372
372
  function isMultiplexerSession(): boolean {
373
- // TMUX/STY/ZELLIJ are the authoritative signals, but they can be stripped while
374
- // TERM survives (`sudo` without -E, `su`, env-sanitizing launchers/ssh). Fall back to
375
- // the TERM prefix like every sibling multiplexer check (terminal-capabilities.ts) so a
376
- // resize never emits ED3 into a tmux/screen pane and wipes its scrollback history.
373
+ // TMUX/STY/ZELLIJ/CMUX workspace+surface ids are authoritative session
374
+ // signals. TERM can also survive when those are stripped (`sudo` without -E,
375
+ // `su`, env-sanitizing launchers/ssh), so keep the TERM prefix fallback aligned
376
+ // with sibling multiplexer checks (terminal-capabilities.ts). Misclassifying a
377
+ // multiplexer as a direct terminal lets resize/reset paths emit ED3 (`CSI 3 J`)
378
+ // and wipe pane scrollback. Do not use CMUX_SOCKET_PATH here: it is a CLI socket
379
+ // override and can be set outside a CMUX terminal.
377
380
  if (Bun.env.TMUX || Bun.env.STY || Bun.env.ZELLIJ) return true;
381
+ if (Bun.env.CMUX_WORKSPACE_ID || Bun.env.CMUX_SURFACE_ID) return true;
378
382
  const term = Bun.env.TERM?.toLowerCase() ?? "";
379
383
  return term.startsWith("tmux") || term.startsWith("screen");
380
384
  }
381
385
 
386
+ /**
387
+ * Terminals that re-report their size whenever the alternate screen buffer is
388
+ * toggled. The non-multiplexer resize fast path ({@link TUI.#beginResizeViewport})
389
+ * borrows the alternate screen for throwaway drag frames; on these terminals
390
+ * entering/leaving the alt buffer emits a fresh SIGWINCH (Warp reports a height
391
+ * one row different for the alt buffer), which re-enters the fast path — a
392
+ * self-sustaining resize loop that floods ED3 full repaints even though the
393
+ * geometry never actually changes. Routing them through the in-place
394
+ * (multiplexer) resize path never touches the alt buffer, breaking the loop.
395
+ *
396
+ * `PI_TUI_RESIZE_IN_PLACE=1|0` forces this on/off for any terminal.
397
+ */
398
+ function reportsSizeOnAltScreenToggle(): boolean {
399
+ const override = Bun.env.PI_TUI_RESIZE_IN_PLACE;
400
+ if (override === "0" || override === "false") return false;
401
+ if (override === "1" || override === "true") return true;
402
+ return Bun.env.TERM_PROGRAM?.toLowerCase() === "warpterminal";
403
+ }
404
+
405
+ /**
406
+ * Resize should repaint the visible window in place — no alternate-screen
407
+ * borrow, no ED3 scrollback rewrap — for multiplexer panes and for terminals
408
+ * that loop on alt-screen toggles. The tradeoff is identical to a multiplexer:
409
+ * scrollback above the window keeps its old wrap instead of being re-flowed.
410
+ */
411
+ function resizeRepaintsInPlace(): boolean {
412
+ return isMultiplexerSession() || reportsSizeOnAltScreenToggle();
413
+ }
414
+
382
415
  /**
383
416
  * Options for overlay positioning and sizing.
384
417
  * Values can be absolute numbers or percentage strings (e.g., "50%").
@@ -1293,7 +1326,7 @@ export class TUI extends Container {
1293
1326
  // `#resizeEventPending` is set first so the eventual render still
1294
1327
  // classifies as a resize.
1295
1328
  this.#resizeEventPending = true;
1296
- if (!isMultiplexerSession()) {
1329
+ if (!resizeRepaintsInPlace()) {
1297
1330
  // Enter the viewport fast path and (re)arm the settle timer, then
1298
1331
  // request the cheap viewport-only paint. The authoritative full
1299
1332
  // replay fires from the settle timer once the drag goes quiet.
@@ -2377,13 +2410,15 @@ export class TUI extends Container {
2377
2410
  Math.min(frameLength, snapshotSafeEnd ?? byteStableBoundary),
2378
2411
  );
2379
2412
 
2380
- // 4. Classify. A resize is an explicit user gesture: outside a
2381
- // multiplexer it erases and replays so history rewraps at the new
2382
- // geometry (the reader snapped to the bottom just dragged the window);
2383
- // inside one the pane reflows its own history, so repaint in place.
2413
+ // 4. Classify. A resize is an explicit user gesture: normally the engine
2414
+ // erases and replays so history rewraps at the new geometry (the reader
2415
+ // snapped to the bottom just dragged the window). Multiplexer panes — and
2416
+ // terminals that re-report size on alt-screen toggles instead repaint in
2417
+ // place, because an ED3 rewrap is unsafe (pane scrollback / alt-screen
2418
+ // feedback loop), so committed history keeps its old wrap.
2384
2419
  const firstPaint = !this.#hasEverRendered;
2385
2420
  const replaceRequested = this.#clearScrollbackOnNextRender;
2386
- const geometryRebuild = geometryChanged && !isMultiplexerSession();
2421
+ const geometryRebuild = geometryChanged && !resizeRepaintsInPlace();
2387
2422
  const fullPaint = firstPaint || replaceRequested || geometryRebuild;
2388
2423
  let windowTop: number;
2389
2424
  let chunkTo: number;
@@ -2504,7 +2539,7 @@ export class TUI extends Container {
2504
2539
  windowTop,
2505
2540
  prevWindowTop,
2506
2541
  prevHardwareCursorRow,
2507
- forceWindowRewrite: this.#forceViewportRepaintOnNextRender || (geometryChanged && isMultiplexerSession()),
2542
+ forceWindowRewrite: this.#forceViewportRepaintOnNextRender || (geometryChanged && resizeRepaintsInPlace()),
2508
2543
  });
2509
2544
  for (let i = this.#committedPrefix.length; i < chunkTo; i++) {
2510
2545
  this.#committedPrefix.push(rawFrame[i] ?? "");