Preserve terminal scrollback when restoring a hidden alternate-screen pane

On Windows, an alternate-screen TUI pane (Claude Code) could lose scroll-up
after being hidden (tab/worktree switch or app backgrounded) and shown again.

Root cause: on hidden->visible restore, applyMainBufferSnapshot
unconditionally wrote a destructive clear (\x1b[2J\x1b[3J\x1b[H) before
replaying the snapshot, wiping xterm's scrollback. The attach/reattach path
already guards this same clear behind !isAlternateScreen (pty-transport.ts);
the snapshot path did not.

Thread an alternateScreen flag from the backend headless serialization
through the pty:getMainBufferSnapshot IPC + preload types into
applyMainBufferSnapshot, and skip the destructive clear when restoring an
alternate-screen buffer. Add a regression test asserting the clear is NOT
emitted for an alt-screen restore (and the existing normal-buffer test still
asserts it IS).

Fixes #5723
This commit is contained in:
brennanb2025
2026-06-27 21:51:07 -07:00
parent e40ad6a93a
commit a0de430397
7 changed files with 81 additions and 3 deletions
+1
View File
@@ -2224,6 +2224,7 @@ export function registerPtyHandlers(
lastTitle?: string
seq?: number
source?: 'headless' | 'renderer'
alternateScreen?: boolean
} | null> => {
if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) {
return null
+12 -2
View File
@@ -5262,6 +5262,7 @@ export class OrcaRuntimeService {
seq?: number
source?: 'headless' | 'renderer'
oscLinks?: TerminalOscLinkRange[]
alternateScreen?: boolean
} | null> {
return this.serializeTerminalBufferFromAvailableState(ptyId, opts)
}
@@ -5278,6 +5279,7 @@ export class OrcaRuntimeService {
seq?: number
source?: 'headless' | 'renderer'
oscLinks?: TerminalOscLinkRange[]
alternateScreen?: boolean
} | null> {
return this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, includeEmpty: true })
}
@@ -5294,6 +5296,7 @@ export class OrcaRuntimeService {
seq?: number
source?: 'headless' | 'renderer'
oscLinks?: TerminalOscLinkRange[]
alternateScreen?: boolean
} | null> {
const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, {
...opts,
@@ -5540,6 +5543,7 @@ export class OrcaRuntimeService {
seq?: number
source?: 'headless' | 'renderer'
oscLinks?: TerminalOscLinkRange[]
alternateScreen?: boolean
} | null> {
const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, opts)
if (headlessSnapshot) {
@@ -5650,6 +5654,7 @@ export class OrcaRuntimeService {
seq?: number
source?: 'headless'
oscLinks?: TerminalOscLinkRange[]
alternateScreen?: boolean
} | null> {
const state = this.headlessTerminals.get(ptyId)
if (!state) {
@@ -5664,7 +5669,8 @@ export class OrcaRuntimeService {
// caller can request scrollback so the user can scroll up to see prior
// agent output.
const requested = opts.scrollbackRows ?? 0
const scrollbackRows = state.emulator.isAlternateScreen ? 0 : requested
const isAlternateScreen = state.emulator.isAlternateScreen
const scrollbackRows = isAlternateScreen ? 0 : requested
const snapshot = state.emulator.getSnapshot({ scrollbackRows })
const data = snapshot.rehydrateSequences + snapshot.snapshotAnsi
return data.length > 0 || opts.includeEmpty === true
@@ -5676,7 +5682,11 @@ export class OrcaRuntimeService {
lastTitle: snapshot.lastTitle,
seq: state.outputSequence,
source: 'headless',
oscLinks: snapshot.oscLinks
oscLinks: snapshot.oscLinks,
// Why: lets the renderer skip the destructive scrollback clear when
// restoring an alt-screen snapshot — clearing wipes xterm's own
// history that the TUI relies on for scroll-up after a tab return.
alternateScreen: isAlternateScreen
}
: null
}
+1
View File
@@ -1072,6 +1072,7 @@ export type PreloadApi = {
cwd?: string | null
seq?: number
source?: 'headless' | 'renderer'
alternateScreen?: boolean
} | null>
getRendererDeliveryDebugSnapshot: () => Promise<{
pendingPtyCount: number
+1
View File
@@ -785,6 +785,7 @@ const api = {
cwd?: string | null
seq?: number
source?: 'headless' | 'renderer'
alternateScreen?: boolean
} | null> => ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }),
getRendererDeliveryDebugSnapshot: (): Promise<{
@@ -6112,6 +6112,59 @@ describe('connectPanePty', () => {
disposable.dispose()
})
it('preserves scrollback by skipping the destructive clear when restoring an alternate-screen snapshot', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const capturedDataCallback: {
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
} = { current: null }
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'pty-id'
})
transportFactoryQueue.push(transport)
const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
typeof vi.fn
>
const hidden = 'x'.repeat(2 * 1024 * 1024 + 1)
const live = 'visible-after-altscreen\r\n'
getMainBufferSnapshot.mockResolvedValue({
data: 'altscreen-snapshot\r\n',
cols: 100,
rows: 30,
seq: hidden.length + live.length,
alternateScreen: true
})
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({
isVisibleRef: { current: false }
})
const disposable = connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(6)
capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length })
expect(pane.terminal.write).not.toHaveBeenCalled()
;(deps.isVisibleRef as { current: boolean }).current = true
capturedDataCallback.current?.(live, {
seq: hidden.length + live.length,
rawLength: live.length
})
await flushAsyncTicks(20)
expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 })
// Why: the destructive clear wipes xterm's scrollback. Alt-screen TUIs keep
// their history in xterm, so restoring one must NOT emit the clear.
expect(pane.terminal.write).not.toHaveBeenCalledWith(
'\x1b[2J\x1b[3J\x1b[H',
expect.any(Function)
)
expect(pane.terminal.write).toHaveBeenCalledWith('altscreen-snapshot\r\n', expect.any(Function))
disposable.dispose()
})
it('drains foreground output after a renderer-sourced hidden-backlog snapshot without seq', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
@@ -3174,6 +3174,7 @@ export function connectPanePty(
cols: number
rows: number
seq?: number
alternateScreen?: boolean
}): void {
const scrollIntent = captureTerminalWriteScrollIntent(pane.terminal)
const colsBeforeReplay = pane.terminal.cols
@@ -3198,7 +3199,13 @@ export function connectPanePty(
suppressSnapshotReplayPtyResize = false
}
}
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
if (!snapshot.alternateScreen) {
// Why: this clear (incl. \x1b[3J) wipes xterm's scrollback. Alt-screen
// TUIs (Claude Code, vim) keep their scroll history in xterm, so
// clearing on restore loses scroll-up after a hidden->visible return.
// Mirrors the attach-time guard in pty-transport.ts.
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
}
writeReplayData(snapshot.data)
writeReplayData(POST_REPLAY_LIVE_SNAPSHOT_RESET)
hiddenRendererStateDirty = false
@@ -36,6 +36,11 @@ export type PtyBufferSnapshot = {
rows: number
seq?: number
source?: 'headless' | 'renderer'
/** True when the snapshot captures an alternate-screen TUI (Claude Code,
* vim). Restore must NOT clear xterm's buffer in that case — the TUI's
* scrollback lives in xterm and a clear destroys scroll-up after a tab
* return. Mirrors the attach-time guard in pty-transport.ts. */
alternateScreen?: boolean
}
export type LocalPtySessionMetadata = { cwd?: string; shellOverride?: string }