mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 02:27:43 +00:00
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
This commit is contained in:
@@ -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<ITerminalOptions, 'windowsPty'> & {
|
||||
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()
|
||||
|
||||
@@ -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<string[]> {
|
||||
const terminal = new Terminal({
|
||||
cols: COLS,
|
||||
rows: ROWS,
|
||||
scrollback: 5000,
|
||||
allowProposedApi: true
|
||||
})
|
||||
await new Promise<void>((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<string[]> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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<ITerminalOptions['windowsPty']> {
|
||||
// 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<ITerminalOptions> {
|
||||
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. */
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user