diff --git a/src/main/runtime/orca-runtime-serialize-terminal-buffer-from-available-state.ts b/src/main/runtime/orca-runtime-serialize-terminal-buffer-from-available-state.ts index 7dea2fc4369..57ded8b19e3 100644 --- a/src/main/runtime/orca-runtime-serialize-terminal-buffer-from-available-state.ts +++ b/src/main/runtime/orca-runtime-serialize-terminal-buffer-from-available-state.ts @@ -23,7 +23,9 @@ export class OrcaRuntimeWithSerializeTerminalBufferFromAvailableState extends Or kittyKeyboardFlags?: number terminalOwner?: 'shell' } | null> { - const restoredSnapshot = await this.serializePreferredRestoredTerminalBuffer(ptyId, opts) + const restoredSnapshot = await this.serializePreferredRestoredTerminalBuffer(ptyId, opts, { + seedModelFromRenderer: true + }) if (restoredSnapshot) { return restoredSnapshot } @@ -32,7 +34,7 @@ export class OrcaRuntimeWithSerializeTerminalBufferFromAvailableState extends Or return headlessSnapshot } - const rendererSnapshot = await this.serializeRendererTerminalBuffer(ptyId, opts) + const rendererSnapshot = await this.serializeRendererScreenOntoModel(ptyId, opts) if (!rendererSnapshot) { return this.serializeProviderTerminalBuffer(ptyId, opts) } @@ -51,7 +53,8 @@ export class OrcaRuntimeWithSerializeTerminalBufferFromAvailableState extends Or protected async serializePreferredRestoredTerminalBuffer( ptyId: string, - opts: { scrollbackRows?: number } = {} + opts: { scrollbackRows?: number } = {}, + fallback: { seedModelFromRenderer?: boolean } = {} ) { if (!this.providerSnapshotPreferredPtys.has(ptyId)) { return null @@ -59,10 +62,27 @@ export class OrcaRuntimeWithSerializeTerminalBufferFromAvailableState extends Or // Pre-attach bytes are only a suffix; older providers can fall back to the renderer. return ( (await this.serializeProviderTerminalBuffer(ptyId, opts)) ?? - (await this.serializeRendererTerminalBuffer(ptyId, opts)) + (await (fallback.seedModelFromRenderer + ? this.serializeRendererScreenOntoModel(ptyId, opts) + : this.serializeRendererTerminalBuffer(ptyId, opts))) ) } + // Why: a pane answers at its own size; seed the model onto the PTY grid and serve that instead. + protected async serializeRendererScreenOntoModel( + ptyId: string, + opts: { scrollbackRows?: number } + ) { + const outputSequence = this.getPtyOutputSequence(ptyId) + const screen = await this.serializeRendererTerminalBuffer(ptyId, opts) + // Why: output that landed during the read is in neither the screen nor a fresh model. + if (!screen?.data.length || this.getPtyOutputSequence(ptyId) !== outputSequence) { + return screen + } + await this.replaceHeadlessTerminalFromRendererSnapshotForRecovery(ptyId, screen) + return (await this.serializeHeadlessTerminalBuffer(ptyId, opts)) ?? screen + } + async serializeRendererTerminalBuffer( ptyId: string, opts: { scrollbackRows?: number } = {} diff --git a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts-part-02.spec.ts index 3d3f90f1200..8036ddd1691 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts-part-02.spec.ts @@ -52,7 +52,8 @@ describe('terminal side-effect fact channel', () => { runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Cursor Agent\x07') const snapshot = await runtime.serializeTerminalBuffer('pty-1', { scrollbackRows: 10 }) - expect(snapshot?.source).toBe('renderer') + // The renderer screen is seeded into the host model, which then answers. + expect(snapshot?.source).toBe('headless') expect(snapshot?.lastTitle).toBe('⠋ Cursor Agent') }) diff --git a/src/main/runtime/terminal-buffer-renderer-fallback-grid.test.ts b/src/main/runtime/terminal-buffer-renderer-fallback-grid.test.ts new file mode 100644 index 00000000000..fa08eade528 --- /dev/null +++ b/src/main/runtime/terminal-buffer-renderer-fallback-grid.test.ts @@ -0,0 +1,42 @@ +/** + * With no host model, a terminal buffer read falls back to the desktop pane. A hidden pane answers + * at its own size, so the read must seed the model onto the PTY grid and answer from it. This read + * backs the phone's resize restream and pending-output recovery. + */ +import { describe, expect, it } from 'vitest' +import { + DESKTOP, + EXPECTED_PHONE_ROWS, + PHONE, + PTY_ID, + internals, + paintedRows, + setupPhoneSubscribe +} from './mobile-phone-subscribe-test-fixture' + +async function readOnPhoneGrid(opts: { providerPreferred: boolean }) { + const { runtime, sizes } = setupPhoneSubscribe({ paneMounted: true }) + sizes.set(PTY_ID, { ...PHONE }) + if (opts.providerPreferred) { + internals(runtime).providerSnapshotPreferredPtys.add(PTY_ID) + } + const snapshot = await runtime.serializeTerminalBuffer(PTY_ID, { scrollbackRows: 100 }) + return { runtime, snapshot } +} + +describe('terminal buffer read with no host model and a desktop-sized pane', () => { + it('answers from a model seeded onto the PTY grid', async () => { + const { runtime, snapshot } = await readOnPhoneGrid({ providerPreferred: false }) + + expect(snapshot).toMatchObject({ source: 'headless', ...PHONE }) + expect(snapshot && (await paintedRows(snapshot)).slice(0, 4)).toEqual(EXPECTED_PHONE_ROWS) + expect(runtime.hasHeadlessTerminalState(PTY_ID)).toBe(true) + }) + + it('answers from the model when a restored snapshot is preferred but the provider has none', async () => { + const { snapshot } = await readOnPhoneGrid({ providerPreferred: true }) + + expect(snapshot).toMatchObject({ source: 'headless', ...PHONE }) + expect(snapshot?.cols).not.toBe(DESKTOP.cols) + }) +})