From 55d12e13087bedbabf3393335130bb4067a53e45 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:05:29 -0700 Subject: [PATCH] fix(terminal): keep restored history when ConPTY repaints after a resize (#33577) * fix(terminal): keep restored history when ConPTY repaints after a resize The Windows daemon's emulator grew rows the way a Unix terminal does, pulling scrollback back into the screen. ConPTY then repaints the grid it owns from the top and overwrote those rows, so a relaunched window's first fit lost about a screen of restored history and printed the new shell's prompt onto the restored prompt's row. Give ConPTY-backed daemon sessions (WSL shells included) xterm's windowsPty resize model, as the window already does for native Windows panes. * test(terminal): model ConPTY's repaint at the height it was resized to ConPTY's screen holds only what the shell wrote at its current height, so a shrink drops the rows above the cursor and each resize repaints that screen. Assert every restored and shell row appears once, in order, across shrink, shrink-then-grow and grow-past-original heights. * test(terminal): show the Unix grow model loses restored history under ConPTY --- src/main/daemon/headless-emulator.ts | 8 +- .../session-conpty-resize-history.test.ts | 134 ++++++++++++++++++ src/main/daemon/session-output-pipeline.ts | 10 ++ src/main/daemon/session-output-plane.ts | 5 +- src/main/daemon/session.ts | 1 + .../pane-manager/windows-pty-compatibility.ts | 25 +--- src/shared/xterm-conpty-options.ts | 19 +++ 7 files changed, 178 insertions(+), 24 deletions(-) create mode 100644 src/main/daemon/session-conpty-resize-history.test.ts create mode 100644 src/shared/xterm-conpty-options.ts diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 4b0107a30fa..5d7bf9d4b51 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -1,5 +1,5 @@ import './xterm-env-polyfill' -import { Terminal } from '@xterm/headless' +import { Terminal, type ITerminalOptions } from '@xterm/headless' import { SerializeAddon } from '@xterm/addon-serialize' import { Unicode11Addon } from '@xterm/addon-unicode11' import { activateOrcaTerminalUnicodeProvider } from '../../shared/terminal-unicode-provider' @@ -31,7 +31,8 @@ import { type HeadlessTerminalRow } from './headless-terminal-rows' -export type HeadlessEmulatorOptions = { +/** windowsPty: ConPTY repaints its own grid after a resize, so growing rows must not pull scrollback. */ +export type HeadlessEmulatorOptions = Pick & { cols: number rows: number scrollback?: number @@ -90,7 +91,8 @@ export class HeadlessEmulator { allowProposedApi: true, logLevel: 'off', // Match renderer kitty flag parsing and query responses. - vtExtensions: { kittyKeyboard: true } + vtExtensions: { kittyKeyboard: true }, + windowsPty: opts.windowsPty ?? {} }) this.serializer = new SerializeAddon() diff --git a/src/main/daemon/session-conpty-resize-history.test.ts b/src/main/daemon/session-conpty-resize-history.test.ts new file mode 100644 index 00000000000..72fcbcb842c --- /dev/null +++ b/src/main/daemon/session-conpty-resize-history.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/headless' +import type { PtyOwnerBackend } from '../../shared/pty-owner-backend' +import { buildFreshShellViewportBlankingSequence } from '../../shared/terminal-fresh-shell-viewport' +import { PROCESS_BOUNDARY_GROUND } from '../../shared/terminal-mode-reset-profiles' +import { Session } from './session' + +const COLS = 119 +const ROWS = 58 +const PROMPT = 'orca@awin:~/projects/p8/deep$ ' +const RESTORED = `${Array.from({ length: 6 }, (_, i) => `${PROMPT}echo L${i}\r\nL${i}`).join('\r\n')}\r\n${PROMPT}echo PRESLEEP\r\nPRESLEEP\r\n${PROMPT}` +// The fresh shell's rows, which are all ConPTY knows about. +const SHELL_ROWS = [PROMPT, `${PROMPT}echo WAKE`, 'WAKE', PROMPT] + +function createMockSubprocess() { + let onData: ((data: string) => void) | null = null + return { + pid: 12345, + getForegroundProcess: (): string | null => null, + write(_data: string) {}, + resize(_cols: number, _rows: number) {}, + kill() {}, + terminateOwnedTree: () => 'unavailable' as const, + forceKill() {}, + signal(_sig: string) {}, + onData(cb: (data: string) => void) { + onData = cb + }, + onExit(_cb: (code: number) => void) {}, + dispose() {}, + simulateData(data: string) { + onData?.(data) + } + } +} + +let session: Session | null = null + +afterEach(() => { + session?.dispose() + session = null +}) + +async function renderRows(ansi: string): Promise { + const terminal = new Terminal({ + cols: COLS, + rows: ROWS, + scrollback: 5000, + allowProposedApi: true + }) + await new Promise((resolve) => terminal.write(ansi, resolve)) + const rows: string[] = [] + for (let i = 0; i < terminal.buffer.active.length; i++) { + rows.push(terminal.buffer.active.getLine(i)?.translateToString(true) ?? '') + } + terminal.dispose() + return rows +} + +/** + * ConPTY's own screen holds only what the shell wrote, at ConPTY's current height: shrinking keeps + * the cursor row and drops rows above it, and every resize repaints that screen from the top. + */ +class FakeConpty { + private lines = [...SHELL_ROWS] + private cursor = SHELL_ROWS.length - 1 + + resize(rows: number): string { + const dropped = Math.max(0, this.cursor - rows + 1) + this.lines = this.lines.slice(dropped, dropped + rows) + this.cursor -= dropped + const screen = Array.from({ length: rows }, (_, i) => `${this.lines[i] ?? ''}\x1b[K`) + return `\x1b[H${screen.join('\r\n')}\x1b[${this.cursor + 1};${PROMPT.length + 1}H` + } +} + +async function checkpointAfterResizes( + ownerBackend: PtyOwnerBackend, + heights: readonly number[] +): Promise { + const subprocess = createMockSubprocess() + session = new Session({ + sessionId: 'conpty-resize', + cols: COLS, + rows: ROWS, + subprocess, + shellReadySupported: false, + ownerBackend, + historySeedChunks: [ + RESTORED, + PROCESS_BOUNDARY_GROUND, + buildFreshShellViewportBlankingSequence(ROWS) + ] + }) + subprocess.simulateData(SHELL_ROWS.join('\r\n')) + const conpty = new FakeConpty() + for (const rows of heights) { + session.resize(COLS, rows) + subprocess.simulateData(conpty.resize(rows)) + } + await session.settleShellOwnershipConfirmation() + const snapshot = session.takePendingOutput(true, { teardownSnapshot: true })?.snapshot + expect(snapshot).toBeTruthy() + return renderRows(`${snapshot!.rehydrateSequences}${snapshot!.snapshotAnsi}`) +} + +const RESTORED_ROWS = [ + ...Array.from({ length: 6 }, (_, i) => [`${PROMPT}echo L${i}`, `L${i}`]).flat(), + `${PROMPT}echo PRESLEEP`, + 'PRESLEEP', + PROMPT +].map((row) => row.trimEnd()) + +describe('daemon checkpoint after a relaunch resize under ConPTY', () => { + it.each([[[2, ROWS]], [[10, ROWS]], [[2, 30, ROWS]], [[ROWS + 12, ROWS]]])( + 'keeps every restored and shell row once, in order, through heights %j', + async (heights) => { + const rows = (await checkpointAfterResizes('windows-wsl', heights)).map((row) => + row.trimEnd() + ) + const written = rows.filter((row) => row !== '') + expect(written).toEqual([...RESTORED_ROWS, ...SHELL_ROWS.map((row) => row.trimEnd())]) + // The fresh-shell padding still separates the restored prompt from the new shell. + const restoredPrompt = rows.indexOf('PRESLEEP') + 1 + expect(rows[restoredPrompt + 1]).toBe('') + } + ) + + // Control: the Unix grow model pulls history into ConPTY's grid, and its repaint overwrites it. + it('loses restored history without the ConPTY model', async () => { + const rows = await checkpointAfterResizes('posix-pty', [2, ROWS]) + expect(rows.filter((row) => /^L\d$/.test(row.trimEnd())).length).toBeLessThan(6) + }) +}) diff --git a/src/main/daemon/session-output-pipeline.ts b/src/main/daemon/session-output-pipeline.ts index be10d100bbe..1df096e1b09 100644 --- a/src/main/daemon/session-output-pipeline.ts +++ b/src/main/daemon/session-output-pipeline.ts @@ -1,6 +1,9 @@ import { SessionOutputPlane } from './session-output-plane' import { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' import type { SubprocessHandle } from './session-subprocess-handle' +import { release } from 'node:os' +import type { PtyOwnerBackend } from '../../shared/pty-owner-backend' +import { buildXtermConptyOptions, parseWindowsBuildNumber } from '../../shared/xterm-conpty-options' /** Couple barrier emissions and snapshot ownership so neither can lag the output plane. */ export function createSessionOutputPipeline(opts: { @@ -9,6 +12,7 @@ export function createSessionOutputPipeline(opts: { scrollback?: number | undefined wslDistro?: string | undefined historySeedChunks?: readonly string[] | undefined + ownerBackend?: PtyOwnerBackend | undefined subprocess: SubprocessHandle isAlive: () => boolean }): { output: SessionOutputPlane; recoveryBarrier: TerminalShellRecoveryBarrier } { @@ -20,6 +24,12 @@ export function createSessionOutputPipeline(opts: { scrollback: opts.scrollback, wslDistro: opts.wslDistro, historySeedChunks: opts.historySeedChunks, + // Every Windows daemon PTY is ConPTY, WSL shells included; ConPTY repaints its own grid after a + // resize, so the emulator must not pull scrollback into it (xterm's windowsPty resize model). + windowsPty: + opts.ownerBackend && opts.ownerBackend !== 'posix-pty' + ? buildXtermConptyOptions(parseWindowsBuildNumber(release())) + : undefined, getTerminalOwner: () => barrier?.getOwner() }) const recoveryBarrier = new TerminalShellRecoveryBarrier({ diff --git a/src/main/daemon/session-output-plane.ts b/src/main/daemon/session-output-plane.ts index fb4fe798362..71941b27594 100644 --- a/src/main/daemon/session-output-plane.ts +++ b/src/main/daemon/session-output-plane.ts @@ -1,4 +1,5 @@ import { resolveProcessExitCause, type TerminalExitCause } from '../../shared/terminal-exit-cause' +import type { XtermConptyOptions } from '../../shared/xterm-conpty-options' import { HeadlessEmulator } from './headless-emulator' import { StartupDeviceAttributesQueryFilter } from './startup-device-attributes-responder' import { normalizePtySize } from './daemon-pty-size' @@ -23,6 +24,7 @@ export type SessionOutputPlaneOptions = { scrollback?: number | undefined wslDistro?: string | undefined historySeedChunks?: readonly string[] | undefined + windowsPty?: XtermConptyOptions | undefined /** Read ownership after the barrier scans bytes, keeping snapshot metadata aligned with the emulator. */ getTerminalOwner?: (() => TerminalOwner | undefined) | undefined } @@ -46,7 +48,8 @@ export class SessionOutputPlane { cols: size.cols, rows: size.rows, scrollback: opts.scrollback, - wslDistro: opts.wslDistro + wslDistro: opts.wslDistro, + ...(opts.windowsPty ? { windowsPty: opts.windowsPty } : {}) // Keep the emulator silent; the shell-ready barrier separately owns startup DA1 replies. }) // Seed before subscribing to synchronous shell output; stop at any failed chunk to avoid a torn stream. diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 9dfe17792e0..0a4f4d495ea 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -55,6 +55,7 @@ export class Session { scrollback: opts.scrollback, wslDistro: opts.wslDistro, historySeedChunks: opts.historySeedChunks, + ownerBackend: opts.ownerBackend, subprocess: this.subprocess, isAlive: () => !this._disposed && this._state !== 'exited' }) diff --git a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts index 6bacf4dc36f..2223fd79c6d 100644 --- a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts +++ b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts @@ -1,6 +1,10 @@ import type { ITerminalOptions } from '@xterm/xterm' import { isWslUncPath } from '../../../../shared/wsl-paths' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' +import { + buildXtermConptyOptions, + parseWindowsBuildNumber +} from '../../../../shared/xterm-conpty-options' export type WindowsPtyCompatibilityContext = { userAgent?: string @@ -22,25 +26,6 @@ function isWslShellOverride(shellOverride: string | null | undefined): boolean { return /(?:^|[/\\])wsl(?:\.exe)?$/i.test(shellOverride ?? '') } -function parseWindowsBuildNumber(osRelease: string | null | undefined): number | undefined { - const build = osRelease?.split('.')[2] - if (!build) { - return undefined - } - const parsed = Number.parseInt(build, 10) - return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined -} - -function buildXtermWindowsPtyOptions( - buildNumber: number | undefined -): NonNullable { - // Old ConPTY lacks reliable wrap markers; low build numbers make xterm misclassify full-width rows as wrapped. - if (buildNumber === undefined || buildNumber < 21376) { - return { backend: 'conpty' } - } - return { backend: 'conpty', buildNumber } -} - /** Apply ConPTY options only to genuine local Windows panes. executionHostId excludes serve/runtime panes that look local to raw connectionId/cwd heuristics. */ export function buildWindowsPtyCompatibilityOptions( context: WindowsPtyCompatibilityContext & { executionHostId: ExecutionHostId } @@ -55,7 +40,7 @@ export function buildWindowsPtyCompatibilityOptions( export function buildLocalConptyTerminalOptions( osRelease: string | null | undefined ): Partial { - return { windowsPty: buildXtermWindowsPtyOptions(parseWindowsBuildNumber(osRelease)) } + return { windowsPty: buildXtermConptyOptions(parseWindowsBuildNumber(osRelease)) } } /** Match main’s effectiveShellOverride/isNativeWindowsLocalPtySpawn: tab override wins, else global Windows setting, so global WSL classifies identically. */ diff --git a/src/shared/xterm-conpty-options.ts b/src/shared/xterm-conpty-options.ts new file mode 100644 index 00000000000..a797ba40da3 --- /dev/null +++ b/src/shared/xterm-conpty-options.ts @@ -0,0 +1,19 @@ +/** xterm `windowsPty` for a ConPTY-backed terminal, shared by the window and the daemon's emulator. */ +export type XtermConptyOptions = { backend: 'conpty'; buildNumber?: number } + +export function parseWindowsBuildNumber(osRelease: string | null | undefined): number | undefined { + const build = osRelease?.split('.')[2] + if (!build) { + return undefined + } + const parsed = Number.parseInt(build, 10) + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined +} + +export function buildXtermConptyOptions(buildNumber: number | undefined): XtermConptyOptions { + // Old ConPTY lacks reliable wrap markers; low build numbers make xterm misclassify full-width rows as wrapped. + if (buildNumber === undefined || buildNumber < 21376) { + return { backend: 'conpty' } + } + return { backend: 'conpty', buildNumber } +}