mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1072,6 +1072,7 @@ export type PreloadApi = {
|
||||
cwd?: string | null
|
||||
seq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
alternateScreen?: boolean
|
||||
} | null>
|
||||
getRendererDeliveryDebugSnapshot: () => Promise<{
|
||||
pendingPtyCount: number
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user