diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index b771e82acaf..65290d01177 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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 diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 4c421345834..a53b5af2084 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 7ae75a59aa3..17945e471d8 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1072,6 +1072,7 @@ export type PreloadApi = { cwd?: string | null seq?: number source?: 'headless' | 'renderer' + alternateScreen?: boolean } | null> getRendererDeliveryDebugSnapshot: () => Promise<{ pendingPtyCount: number diff --git a/src/preload/index.ts b/src/preload/index.ts index a3adca161ba..96f6797ffb8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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<{ diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index ac1c52a4174..06343705b15 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -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') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index e6930cfbd9d..77f3eb02d67 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index 2ef4be23c3f..c1fb2e63059 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -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 }