diff --git a/docs/environment-variables.md b/docs/environment-variables.md index adf3d310c..cb34ed6b8 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -407,7 +407,7 @@ These are read as runtime signals; they are usually set by the terminal/OS rathe | `PI_NO_DECCARA` | If set (truthy), disables Kitty DECCARA rectangular-SGR background fills (forces padded-string rendering) | | `PI_DEBUG_REDRAW` | If `1`, enables redraw debug logging | | `PI_FORCE_IMAGE_PROTOCOL` | Forces terminal image protocol detection (`kitty`, `iterm2`/`iterm`, `sixel`, `none`) | -| `PI_TUI_RESIZE_IN_PLACE` | `1`/`true` force in-place resize (no alt-screen borrow, no ED3 rewrap); `0`/`false` force the alt-screen fast path. Default-on for Warp, which re-reports its size on alt-screen toggles | +| `PI_TUI_RESIZE_IN_PLACE` | `1`/`true` preserves terminal-managed history and repaints after resize settle; `0`/`false` uses viewport-only drag paints followed by one ED3 history rewrap. Neither path switches terminal buffers. Default-on for Warp and multiplexers | --- diff --git a/docs/tui-core-renderer.md b/docs/tui-core-renderer.md index 25dfb4ad0..d56b18cda 100644 --- a/docs/tui-core-renderer.md +++ b/docs/tui-core-renderer.md @@ -349,7 +349,7 @@ default-on only for kitty/ghostty (`PI_NO_KITTY_PLACEHOLDERS` / | `PI_HARDWARE_CURSOR=1` | Show the real hardware cursor instead of a rendered one. | | `PI_NOTIFICATIONS=off\|0\|false` | Suppress terminal notifications. | | `PI_DEBUG_REDRAW=1` | Log the chosen render intent + ledger state per frame to the debug log. | -| `PI_TUI_RESIZE_IN_PLACE=1\|0` | Force resize to repaint in place (no alt-screen borrow, no ED3 rewrap) on / off. Default-on for terminals that re-report size on alt-screen toggles (Warp). | +| `PI_TUI_RESIZE_IN_PLACE=1\|0` | `1` preserves terminal-managed history and repaints after settle; `0` uses viewport-only drag paints plus one settled ED3 history rewrap. Neither path borrows the alternate screen. Default-on for terminals that re-report size on buffer toggles (Warp). | Removed with the old engine: `PI_TUI_ED3_SAFE` (no ED3-risk lever exists), `PI_CLEAR_ON_SHRINK` (shrinks always clear exactly), `PI_TUI_DEBUG` (per-render diff --git a/docs/tui-runtime-internals.md b/docs/tui-runtime-internals.md index 8040cdc15..d8960f0f0 100644 --- a/docs/tui-runtime-internals.md +++ b/docs/tui-runtime-internals.md @@ -141,9 +141,9 @@ Resize events are event-driven from `ProcessTerminal` to `TUI.requestRender()`. Effects: -- A resize is an explicit user gesture: outside multiplexers the engine erases and replays (`ED3` + full paint) so history rewraps at the new geometry; the commit ledger restarts from the replayed frame. +- A resize is an explicit user gesture: outside multiplexers the engine rewrites only the visible viewport during the drag, directly on the normal buffer, then erases and replays once (`ED3` + full paint) after the drag settles so history rewraps at the new geometry. Avoiding alternate-screen switches prevents the saved pre-TUI normal buffer from flashing at settle on terminals without effective synchronized output. - Inside terminal multiplexers, resize repaints the visible window in place after a settle debounce (issue #2088); pane history keeps its old wrap, like any shell output, because pane scrollback cannot be erased safely. -- Terminals that re-report their size when the alternate screen buffer is toggled (Warp reports a height one row different for the alt buffer) take the in-place path too. The non-multiplexer fast path borrows the alternate screen for drag frames, so on these terminals each alt enter/leave emits a fresh resize event, which re-enters the fast path — a self-sustaining loop that floods ED3 full repaints with stable geometry. `resizeRepaintsInPlace()` (covering multiplexers and these terminals; overridable via `PI_TUI_RESIZE_IN_PLACE`) routes them through the in-place repaint, which never touches the alt buffer. +- Terminals that re-report their size when the alternate screen buffer is toggled (Warp reports a height one row different for the alt buffer) take the same history-preserving in-place path. `resizeRepaintsInPlace()` covers multiplexers and these terminals and remains overridable via `PI_TUI_RESIZE_IN_PLACE`; the viewport-only direct-terminal path no longer toggles buffers, so the override controls settled history rewrap only. - Overlay visibility can depend on terminal dimensions (`OverlayOptions.visible`); focus is corrected when overlays become non-visible after resize. ## Streaming and incremental UI updates diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c25af880a..e069b35d5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -25,6 +25,7 @@ ### Fixed +- Fixed `/resume` and plan approval exposing the previous session while their asynchronous session replacement was still loading by keeping fullscreen overlays mounted until the rebuilt transcript is ready ([#5319](https://github.com/can1357/oh-my-pi/issues/5319)). - Fixed inconsistent history rendering when toggling the display setting for compacted items - Fixed configured `retry.fallbackChains` never engaging on non-retryable provider errors (e.g. "Cloud Code Assist API returned an empty response"): a hard error on a model covered by a fallback chain now switches to the next candidate instead of failing the turn, while still never backoff-retrying the failing model itself - Fixed transcript rebuilds (compaction, `/compact`, and toggling history display) repainting content below stale scrollback when collapsing history; rebuilds now correctly clear the scrollback buffer when history is collapsed diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index adbd74d38..dcd3f9d9a 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -1102,12 +1102,10 @@ export class SelectorController { // every project's history when the cwd has nothing to resume. See #3099. const historyStorage = this.ctx.historyStorage; const historyMatcher = historyStorage ? (query: string) => historyStorage.matchingSessionIds(query) : undefined; - // Fullscreen session picker on the alternate screen (the /settings idiom): - // the overlay borrows the alt buffer and enables mouse tracking (wheel - // scroll + click-to-resume) for its lifetime, leaving the transcript - // untouched underneath. Anchored top-left at full size so a mouse row maps - // directly to a rendered line (the overlay paints from screen row 0), and - // `fillHeight` pads the body so the footer pins to the screen bottom. + // Keep the fullscreen picker on the alternate buffer while a selected + // session is loaded and its transcript is rebuilt. Closing it first exposes + // the stale normal buffer for the entire async switch on terminals without + // effective synchronized output. let overlayHandle: OverlayHandle | undefined; const done = () => { overlayHandle?.hide(); @@ -1117,8 +1115,11 @@ export class SelectorController { const selector = new SessionSelectorComponent( sessions, async (session: SessionInfo) => { - done(); - await this.handleResumeSession(session.path); + try { + await this.handleResumeSession(session.path); + } finally { + done(); + } }, () => { done(); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 8311acbc2..3dae7aa2e 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -2503,8 +2503,6 @@ export class InteractiveMode implements InteractiveModeContext { const finish = (choice: string | undefined): void => { if (settled) return; settled = true; - this.#hidePlanReview(); - this.ui.requestRender(); resolve(choice); }; const overlay = new PlanReviewOverlay( @@ -3424,6 +3422,10 @@ export class InteractiveMode implements InteractiveModeContext { }, { slider }, ); + const closePlanReview = (): void => { + this.#hidePlanReview(); + this.ui.requestRender(); + }; if (choice === "Approve and execute" || choice === "Approve and compact context" || choice === keepContextLabel) { try { @@ -3436,6 +3438,7 @@ export class InteractiveMode implements InteractiveModeContext { } if (!latestPlanContent) { this.showError(`Plan file not found at ${planFilePath}`); + closePlanReview(); return; } // Capture the operator's tier choice and hand it to #approvePlan, which @@ -3478,6 +3481,7 @@ export class InteractiveMode implements InteractiveModeContext { `Failed to finalize approved plan: ${error instanceof Error ? error.message : String(error)}`, ); } + closePlanReview(); return; } @@ -3496,8 +3500,10 @@ export class InteractiveMode implements InteractiveModeContext { } catch (error) { this.showError(`Failed to refine plan: ${error instanceof Error ? error.message : String(error)}`); } + closePlanReview(); return; } + closePlanReview(); } /** diff --git a/packages/coding-agent/src/prompts/system/tan-context-switch.md b/packages/coding-agent/src/prompts/system/tan-context-switch.md index 55468b15a..88cd57291 100644 --- a/packages/coding-agent/src/prompts/system/tan-context-switch.md +++ b/packages/coding-agent/src/prompts/system/tan-context-switch.md @@ -1,5 +1,5 @@ -The conversation above belongs to your parent session. +The conversation above belongs to your parent session. You are a fork created solely to handle the user's request below. Your parent agent is still working on the original task — that responsibility is diff --git a/packages/coding-agent/test/modes/controllers/selector-controller-overlay-focus.test.ts b/packages/coding-agent/test/modes/controllers/selector-controller-overlay-focus.test.ts index 539dfb3c7..6017e8c51 100644 --- a/packages/coding-agent/test/modes/controllers/selector-controller-overlay-focus.test.ts +++ b/packages/coding-agent/test/modes/controllers/selector-controller-overlay-focus.test.ts @@ -1,12 +1,19 @@ -import { beforeAll, describe, expect, it, vi } from "bun:test"; +import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; +import type { SessionSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/session-selector"; import { SelectorController } from "@oh-my-pi/pi-coding-agent/modes/controllers/selector-controller"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import type { SessionInfo } from "@oh-my-pi/pi-coding-agent/session/session-listing"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; beforeAll(async () => { await initTheme(); }); +afterEach(() => { + vi.restoreAllMocks(); +}); + interface EditorSlot { children: unknown[]; clear: () => void; @@ -83,3 +90,63 @@ describe("SelectorController.focusActiveEditorArea", () => { expect(setFocus).toHaveBeenCalledWith(editor); }); }); + +describe("SelectorController session replacement overlay", () => { + it("keeps the fullscreen selector visible until the resumed transcript is ready", async () => { + const session: SessionInfo = { + path: "/tmp/resume.jsonl", + id: "resume", + cwd: "/tmp", + title: "Resume target", + created: new Date("2026-01-01T00:00:00Z"), + modified: new Date("2026-01-02T00:00:00Z"), + messageCount: 2, + size: 1, + firstMessage: "first", + allMessagesText: "first second", + }; + vi.spyOn(SessionManager, "list").mockResolvedValue([session]); + + const overlayHidden = Promise.withResolvers(); + const hide = vi.fn(() => overlayHidden.resolve()); + let selector: SessionSelectorComponent | undefined; + const editor = { id: "editor" }; + const editorContainer = createEditorSlot(editor); + const ctx = { + editor, + editorContainer, + sessionManager: { + getCwd: () => "/tmp", + getSessionDir: () => "/tmp", + }, + ui: { + showOverlay: vi.fn(component => { + selector = component as SessionSelectorComponent; + return { hide, setHidden: vi.fn(), isHidden: () => false }; + }), + setFocus: vi.fn(), + requestRender: vi.fn(), + terminal: { rows: 24 }, + }, + } as unknown as InteractiveModeContext; + const controller = new SelectorController(ctx); + const resumeStarted = Promise.withResolvers(); + const resumed = Promise.withResolvers(); + const handleResume = vi.spyOn(controller, "handleResumeSession").mockImplementation(() => { + resumeStarted.resolve(); + return resumed.promise; + }); + + await controller.showSessionSelector(); + expect(selector).toBeDefined(); + selector!.handleInput("\n"); + await resumeStarted.promise; + + expect(handleResume).toHaveBeenCalledWith(session.path); + expect(hide).not.toHaveBeenCalled(); + + resumed.resolve(); + await overlayHidden.promise; + expect(hide).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 3143afbfc..df7609ee9 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed fullscreen session-replacement overlays and resize drags exposing stale normal-buffer frames on terminals without effective DEC 2026: asynchronous replacements now keep their overlay visible until the rebuilt transcript is ready, overlay exit is fused into the destructive paint, and resize viewport frames rewrite the normal buffer without alternate-screen switches. Inconclusive DECRQM probes also no longer disable statically detected synchronized output ([#5319](https://github.com/can1357/oh-my-pi/issues/5319)). + ## [16.4.7] - 2026-07-12 ### Fixed diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index b35dfa1f5..982db597d 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -390,9 +390,11 @@ export interface Terminal { get appearance(): TerminalAppearance | undefined; /** * Register a callback fired once per DEC private mode when its DECRQM support - * status resolves. Optional: only real terminals implement capability probing. + * status resolves. `confirmed` is false when the terminal answered the DA1 + * sentinel without answering DECRQM, which proves only that querying support + * is unavailable — not that the private mode itself is unsupported. */ - onPrivateModeReport?(callback: (mode: number, supported: boolean) => void): void; + onPrivateModeReport?(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void; } /** @@ -480,7 +482,7 @@ export class ProcessTerminal implements Terminal { #da1SentinelOwners: Da1SentinelOwner[] = []; /** Resolved DECRQM support per private mode (mode → supported). */ #privateModeSupport = new Map(); - #privateModeCallbacks: Array<(mode: number, supported: boolean) => void> = []; + #privateModeCallbacks: Array<(mode: number, supported: boolean, confirmed: boolean) => void> = []; /** Whether DEC 2048 in-band resize notifications are currently enabled. */ #inBandResizeActive = false; /** Reassembly buffer for a DEC 2048 in-band resize report split across stdin reads. */ @@ -531,7 +533,7 @@ export class ProcessTerminal implements Terminal { } } - onPrivateModeReport(callback: (mode: number, supported: boolean) => void): void { + onPrivateModeReport(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void { this.#privateModeCallbacks.push(callback); } @@ -866,8 +868,10 @@ export class ProcessTerminal implements Terminal { break; } case "privateMode": { - // DA1 beat the DECRPM reply for this mode → treat as unsupported. - this.#resolvePrivateMode(owner.mode, false); + // DA1 beat the DECRPM reply. The terminal cannot report this + // capability, but may still implement it; keep that distinction + // so static terminal detection is not incorrectly downgraded. + this.#resolvePrivateMode(owner.mode, false, false); break; } case "keyboard": { @@ -1129,7 +1133,7 @@ export class ProcessTerminal implements Terminal { } #handlePrivateModeReport(mode: number, status: string): void { - this.#resolvePrivateMode(mode, isPrivateModeSupported(status)); + this.#resolvePrivateMode(mode, isPrivateModeSupported(status), true); if (isXtermScrollToBottomMode(mode) && isPrivateModeSet(status)) { this.#disableXtermScrollToBottomMode(mode); } @@ -1137,15 +1141,16 @@ export class ProcessTerminal implements Terminal { /** * Record DECRQM support for a private mode (idempotent — first result wins) - * and notify subscribers. Enables DEC 2048 in-band resize when 2048 resolves - * supported. + * and notify subscribers. `confirmed` distinguishes an explicit DECRPM + * unsupported response from an absent response followed by the DA1 sentinel. + * Enables DEC 2048 in-band resize only after positive confirmation. */ - #resolvePrivateMode(mode: number, supported: boolean): void { + #resolvePrivateMode(mode: number, supported: boolean, confirmed: boolean): void { if (this.#privateModeSupport.has(mode)) return; this.#privateModeSupport.set(mode, supported); for (const cb of this.#privateModeCallbacks) { try { - cb(mode, supported); + cb(mode, supported, confirmed); } catch { // Ignore subscriber errors — capability reporting must not crash input. } diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 885f07af2..ac60d8330 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -83,8 +83,6 @@ const CURSOR_END_NO_SYNC = ""; // coordinates so columns/rows past 223 are reported. const MOUSE_TRACKING_ON = "\x1b[?1000h\x1b[?1003h\x1b[?1006h"; const MOUSE_TRACKING_OFF = "\x1b[?1006l\x1b[?1003l\x1b[?1000l"; -const ALT_SCREEN_ENTER = "\x1b[?1049h"; -const ALT_SCREEN_EXIT = "\x1b[?1049l"; type InputListenerResult = { consume?: boolean; data?: string } | undefined; type InputListener = (data: string) => InputListenerResult; @@ -1028,11 +1026,6 @@ export class TUI extends Container { // `#fullRedrawCount`: these never enter native scrollback and exist only for // the lifetime of the drag. Exposed for tests/diagnostics. #resizeViewportPaintCount = 0; - // During a live resize drag the terminal's normal buffer may reflow full-width - // rows before our repaint lands. Borrow the alternate screen for throwaway - // resize frames so width changes truncate the transient viewport instead of - // pushing wrapped fragments into native scrollback. - #resizeAltActive = false; #stopped = false; // Always-on event-loop lag probe. The high default threshold keeps it quiet; // it only logs `ui.loop-blocked` (with the current loop phase) when a frame @@ -1454,13 +1447,15 @@ export class TUI extends Container { this.#watchdog.start(); this.#ghosttyInitialImageDelayDone = false; this.#ghosttyImageReadyAtMs = this.#renderScheduler.now() + TUI.#GHOSTTY_INITIAL_IMAGE_DELAY_MS; - // A DECRQM report for mode 2026 is authoritative: enable synchronized - // output when the terminal reports support (upgrading conservatively - // defaulted-off hosts like zellij/tmux-master/foot) and disable it when - // the terminal reports it unsupported. An explicit user opt-out/force - // (resolved at construction) still wins, so skip the probe in that case. - this.terminal.onPrivateModeReport?.((mode, supported) => { - if (mode !== 2026) return; + // A confirmed DECRPM report for mode 2026 is authoritative: enable + // synchronized output when the terminal reports support and disable it for + // an explicit unsupported status. A DA1 sentinel without a DECRPM reply is + // inconclusive: many terminals implement synchronized output without + // implementing DECRQM, so retain the statically detected default instead of + // exposing destructive full paints. An explicit user opt-out/force still + // wins, so skip every probe result in that case. + this.terminal.onPrivateModeReport?.((mode, supported, confirmed = true) => { + if (mode !== 2026 || !confirmed) return; if (synchronizedOutputUserOverride() !== null) return; this.#setSynchronizedOutput(supported); }); @@ -1678,11 +1673,6 @@ export class TUI extends Container { } stop(): void { - // Leave the alt buffer first so the teardown cursor math below runs against - // the restored normal screen (which #previousLines still describes). - if (this.#resizeAltActive) { - this.terminal.write(this.#leaveResizeAltSequence()); - } if (this.#altActive) { const enhancementExit = this.#keyboardEnhancementExit(); this.terminal.write(`${MOUSE_TRACKING_OFF}${enhancementExit}\x1b[?1049l`); @@ -2644,6 +2634,7 @@ export class TUI extends Container { // Fullscreen alt-screen short-circuit. While the topmost visible overlay // requests it, borrow the terminal's alternate buffer and paint only the // modal there; the normal screen and all accounting stay untouched. + let deferredAltExit = ""; const wantAlt = this.#wantsAltScreen(); if (wantAlt && !this.#altActive) { // Enhanced keyboard modes can be buffer-local: re-push the active @@ -2661,7 +2652,13 @@ export class TUI extends Container { this.#altEnterHeight = height; } else if (!wantAlt && this.#altActive) { const enhancementExit = this.#keyboardEnhancementExit(); - this.terminal.write(`${MOUSE_TRACKING_OFF}${enhancementExit}\x1b[?1049l`); + const exitSequence = `${MOUSE_TRACKING_OFF}${enhancementExit}\x1b[?1049l`; + // Session replacement can finish while a fullscreen selector is still + // covering the old normal buffer. Keep the overlay visible until the + // replacement is ready, then fuse the buffer restore into that full paint; + // a standalone exit exposes the stale session for one terminal frame. + if (this.#clearScrollbackOnNextRender) deferredAltExit = exitSequence; + else this.terminal.write(exitSequence); setAltScreenActive(false); this.#forgetHardwareCursorState(); this.#altActive = false; @@ -2955,6 +2952,7 @@ export class TUI extends Container { chunkTo, windowTop, cursorTrackingLineCount, + leadingSequence: deferredAltExit, }); this.#committedPrefix = rawFrame.slice(0, chunkTo); this.#committedPrefixAuditRows = Math.min(chunkTo, finalBoundary); @@ -3334,6 +3332,7 @@ export class TUI extends Container { chunkTo: number; windowTop: number; cursorTrackingLineCount: number; + leadingSequence: string; }, ): void { this.#fullRedrawCount += 1; @@ -3371,7 +3370,7 @@ export class TUI extends Container { paintCursorPos = paint.cursorPos; } } - let buffer = this.#paintBeginSequence + this.#leaveResizeAltSequence() + purgeSequence; + let buffer = this.#paintBeginSequence + options.leadingSequence + purgeSequence; if (options.clearScrollback) { // Clear native history without blanking the live viewport first. The // replay below rewrites every visible row from home, including blanks, @@ -3571,34 +3570,15 @@ export class TUI extends Container { return this.terminal.kittyEnableSequence ? "\x1b[ 0) buffer += "\r\n"; buffer += this.#lineRewriteSequence(window[r] ?? "", width); diff --git a/packages/tui/test/render-regressions.test.ts b/packages/tui/test/render-regressions.test.ts index 526c73d6d..d9814dc7b 100644 --- a/packages/tui/test/render-regressions.test.ts +++ b/packages/tui/test/render-regressions.test.ts @@ -127,6 +127,18 @@ class LegacyKeyboardVirtualTerminal extends VirtualTerminal { } } +class PrivateModeProbeTerminal extends VirtualTerminal { + #callback: ((mode: number, supported: boolean, confirmed?: boolean) => void) | undefined; + + onPrivateModeReport(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void { + this.#callback = callback; + } + + reportPrivateMode(mode: number, supported: boolean, confirmed: boolean): void { + this.#callback?.(mode, supported, confirmed); + } +} + function rows(prefix: string, count: number): string[] { return Array.from({ length: count }, (_v, i) => `${prefix}${i}`); } @@ -1384,6 +1396,83 @@ describe("TUI terminal-state regressions", () => { setTerminalScreenToScrollback(saved); } }); + + it("keeps destructive paints synchronized when DECRQM is unavailable", async () => { + await withEnvPatch( + { + TERM_FEATURES: "Sy", + PI_NO_SYNC_OUTPUT: undefined, + PI_FORCE_SYNC_OUTPUT: undefined, + PI_TUI_SYNC_OUTPUT: undefined, + }, + async () => { + const term = new PrivateModeProbeTerminal(20, 3); + const component = new MutableLinesComponent(rows("old-", 6)); + const tui = new TUI(term); + tui.addChild(component); + + try { + tui.start(); + await settle(term); + const writes = captureWrites(term); + + // A DA1 sentinel without DECRPM is inconclusive. Terminals such as + // xterm.js can implement synchronized output without implementing + // the query, so the static TERM_FEATURES capability must survive. + term.reportPrivateMode(2026, false, false); + expect(tui.synchronizedOutput).toBe(true); + + component.setLines(rows("resumed-", 8)); + tui.requestRender(true, { clearScrollback: true }); + await settle(term); + + const paint = writes.find(write => write.includes("\x1b[3J")); + expect(paint).toBeDefined(); + expect(paint).toContain("\x1b[?2026h"); + expect(paint).toContain("\x1b[?2026l"); + expect(visible(term)).toEqual(["resumed-5", "resumed-6", "resumed-7"]); + } finally { + tui.stop(); + } + }, + ); + }); + + it("fuses fullscreen overlay exit into a pending session replacement paint", async () => { + const term = new VirtualTerminal(24, 4); + const component = new MutableLinesComponent(rows("old-session-", 8)); + const tui = new TUI(term); + tui.addChild(component); + + try { + tui.start(); + await settle(term); + const overlay = tui.showOverlay(new MutableLinesComponent(["session selector"]), { + width: "100%", + maxHeight: "100%", + fullscreen: true, + }); + await settle(term); + + // Session loading finishes behind the still-visible selector. The forced + // replacement remains pending while the fullscreen path owns the frame. + component.setLines(rows("resumed-", 9)); + tui.requestRender(true, { clearScrollback: true }); + await settle(term); + + const writes = captureWrites(term); + overlay.hide(); + await settle(term); + + const exits = writes.filter(write => write.includes("\x1b[?1049l")); + expect(exits).toHaveLength(1); + expect(exits[0]).toContain("\x1b[3J"); + expect(exits[0]).toContain("resumed-8"); + expect(visible(term)).toEqual(["resumed-5", "resumed-6", "resumed-7", "resumed-8"]); + } finally { + tui.stop(); + } + }); }); describe("scrollback integrity", () => { diff --git a/packages/tui/test/resize-viewport-defer.test.ts b/packages/tui/test/resize-viewport-defer.test.ts index 622f8d212..2ef431906 100644 --- a/packages/tui/test/resize-viewport-defer.test.ts +++ b/packages/tui/test/resize-viewport-defer.test.ts @@ -21,9 +21,8 @@ const NO_MULTIPLEXER_ENV: Record = { TMUX: undefined, STY: undefined, ZELLIJ: undefined, - // Pin terminal identity so the alt-screen fast-path assertions below are - // deterministic even when the suite runs inside Warp (which otherwise takes - // the in-place path — see the Warp describe block at the bottom). + // Pin terminal identity so resize classification is deterministic even when + // the suite runs inside Warp (which takes the debounced in-place path below). TERM_PROGRAM: undefined, PI_TUI_RESIZE_IN_PLACE: undefined, }; @@ -302,7 +301,7 @@ describe("non-multiplexer resize viewport fast path", () => { tui.start(); await scheduler.flushImmediates(term); - // One drag SIGWINCH enters the fast path and borrows the alt screen. + // One drag SIGWINCH enters the viewport-only fast path. term.resize(60, 10); await scheduler.flushImmediates(term); expect(tui.resizeViewportActive).toBe(true); @@ -314,15 +313,13 @@ describe("non-multiplexer resize viewport fast path", () => { // A live block keeps animating mid-drag: a spinner tick / streamed // token fires an ordinary (non-forced) render before the 120ms settle // elapses. It must stay on the viewport fast path. Without the guard it - // falls through to the geometry-rebuild full paint, which leaves the - // borrowed alternate screen (ALT_SCREEN_EXIT) and erases native - // scrollback (ED3) to repaint the whole transcript on the normal screen - // for one frame — the flash — before the next SIGWINCH hides it again. + // falls through to an authoritative full paint and erases/replays the + // whole transcript for one frame before the next resize event. tui.requestRender(); await scheduler.flushOrdinaryRenders(term); - // Still mid-drag, still on the alternate screen: a viewport-only paint, - // no authoritative full redraw, no scrollback erase, no alt-screen exit. + // Still mid-drag: a viewport-only paint, no authoritative full redraw, + // no scrollback erase, and no terminal buffer switch. expect(tui.resizeViewportActive).toBe(true); expect(tui.resizeViewportPaints).toBeGreaterThan(baselinePaints); expect(tui.fullRedraws).toBe(baselineFull); @@ -360,7 +357,7 @@ describe("non-multiplexer resize viewport fast path", () => { }); }); - it("uses the alternate screen during width-drag frames so terminal reflow cannot show wrapped fragments", async () => { + it("repaints the normal screen during width drags without switching buffers", async () => { await withEnvPatch(NO_MULTIPLEXER_ENV, async () => { const term = new VirtualTerminal(40, 10, 1000); const scheduler = new DeferScheduler(); @@ -377,17 +374,16 @@ describe("non-multiplexer resize viewport fast path", () => { const writes = captureWrites(term); - // Shrinking full-width normal-screen rows makes Ghostty reflow them - // into wrapped fragments before the app writes again. The resize - // handler must synchronously switch to the alternate screen and - // repaint the new-width viewport in that same write. + // The resize handler rewrites the new-width viewport synchronously on + // the normal buffer. Borrowing the alternate buffer exposes the saved + // pre-TUI screen when the drag settles on terminals without DEC 2026. term.resize(20, 10); await term.flush(); expect(tui.resizeViewportActive).toBe(true); expect(tui.resizeViewportPaints).toBe(1); const drag = writes.join(""); - expect(drag).toContain(ALT_SCREEN_ENTER); + expect(drag).not.toContain(ALT_SCREEN_ENTER); expect(drag).not.toContain("\x1b[2J"); expect(drag).not.toContain("\x1b[3J"); expect(visible(term)).toEqual(expected); @@ -396,8 +392,8 @@ describe("non-multiplexer resize viewport fast path", () => { await scheduler.flushAll(term); const settle = writes.slice(dragWrites).join(""); - expect(settle).toContain(ALT_SCREEN_EXIT); - expect(settle.indexOf(ALT_SCREEN_EXIT)).toBeLessThan(settle.indexOf("\x1b[3J")); + expect(settle).not.toContain(ALT_SCREEN_EXIT); + expect(settle).toContain("\x1b[3J"); expect(visible(term)).toEqual(expected); } finally { tui.stop(); @@ -420,11 +416,10 @@ describe("non-multiplexer resize viewport fast path", () => { expect(tui.resizeViewportActive).toBe(true); const drag = writes.join(""); - // The drag frame borrows the alternate screen and performs per-row - // self-clearing rewrites there. It must not clear/replay the normal - // screen, so even terminals that expose resize reflow between app - // writes cannot show a blanked normal-screen frame. - expect(drag).toContain(ALT_SCREEN_ENTER); + // The drag frame performs per-row self-clearing rewrites directly on + // the normal screen. It must not clear/replay or switch buffers, because + // either transition is visible on terminals without synchronized output. + expect(drag).not.toContain(ALT_SCREEN_ENTER); expect(drag).not.toContain("\x1b[2J"); expect(drag).not.toContain("\x1b[3J"); expect(drag).toContain("\x1b[H"); @@ -501,7 +496,7 @@ describe("resize repaints in place on terminals that re-report size on alt-scree }); }); - it("PI_TUI_RESIZE_IN_PLACE=0 opts Warp back into the alt-screen fast path", async () => { + it("PI_TUI_RESIZE_IN_PLACE=0 opts Warp into the viewport-only fast path", async () => { await withEnvPatch({ ...WARP_ENV, PI_TUI_RESIZE_IN_PLACE: "0" }, async () => { const term = new VirtualTerminal(40, 10, 1000); const { tui, scheduler } = makeTui(term); @@ -514,7 +509,7 @@ describe("resize repaints in place on terminals that re-report size on alt-scree await scheduler.flushImmediates(term); expect(tui.resizeViewportActive).toBe(true); - expect(writes.join("")).toContain(ALT_SCREEN_ENTER); + expect(writes.join("")).not.toContain(ALT_SCREEN_ENTER); } finally { tui.stop(); } diff --git a/packages/tui/test/terminal-appearance.test.ts b/packages/tui/test/terminal-appearance.test.ts index ea1067c1b..d54f79d79 100644 --- a/packages/tui/test/terminal-appearance.test.ts +++ b/packages/tui/test/terminal-appearance.test.ts @@ -562,13 +562,18 @@ describe("ProcessTerminal DECRQM + in-band resize (DEC 2026/2048)", () => { expect(writes).not.toContain("\x1b[?2048l"); }); - it("falls back to unsupported when the DA1 sentinel beats the DECRPM reply", () => { + it("marks a missing DECRPM response as inconclusive when the DA1 sentinel arrives", () => { const { terminal, reports } = setup(); + const confirmations: boolean[] = []; + terminal.onPrivateModeReport?.((mode, _supported, confirmed) => { + if (mode === 2026) confirmations.push(confirmed ?? true); + }); // Drain keyboard + osc11 sentinels, then 2026's DA1 (no DECRPM arrived). process.stdin.emit("data", "\x1b[?1;2c"); process.stdin.emit("data", "\x1b[?1;2c"); process.stdin.emit("data", "\x1b[?1;2c"); expect(reports).toContainEqual({ mode: 2026, supported: false }); + expect(confirmations).toEqual([false]); terminal.stop(); });