diff --git a/src/main/runtime/mobile-subscribe-idle-pane-hydrate.test.ts b/src/main/runtime/mobile-subscribe-idle-pane-hydrate.test.ts index 023d6d3ba09..219ec7a94e6 100644 --- a/src/main/runtime/mobile-subscribe-idle-pane-hydrate.test.ts +++ b/src/main/runtime/mobile-subscribe-idle-pane-hydrate.test.ts @@ -48,12 +48,19 @@ async function screenOn(grid: { cols: number; rows: number }): Promise { } } -function setup(opts: { paneMounted: boolean; providerSnapshot?: boolean }) { +function setup(opts: { + paneMounted: boolean + providerSnapshot?: boolean + repaintOnResize?: boolean +}) { const sizes = new Map([[PTY_ID, { ...DESKTOP }]]) - const serializeBuffer = vi.fn(async () => - opts.paneMounted ? { data: await screenOn(DESKTOP), ...DESKTOP } : null - ) const runtime = new OrcaRuntimeService() + // The pane orders its screen against PTY output, as a mounted desktop xterm does. + const serializeBuffer = vi.fn(async () => + opts.paneMounted + ? { data: await screenOn(DESKTOP), ...DESKTOP, seq: runtime.getPtyOutputSequence(PTY_ID) } + : null + ) runtime.setPtyController({ write: () => true, kill: () => true, @@ -61,6 +68,10 @@ function setup(opts: { paneMounted: boolean; providerSnapshot?: boolean }) { getSize: (ptyId: string) => sizes.get(ptyId) ?? null, resize: (ptyId: string, cols: number, rows: number) => { sizes.set(ptyId, { cols, rows }) + if (opts.repaintOnResize) { + // A TUI answering SIGWINCH before the subscribe reaches its own hydrate. + runtime.onPtyData(ptyId, '\x1b[?25h', Date.now()) + } return true }, hasRendererSerializer: () => opts.paneMounted, @@ -178,6 +189,16 @@ describe('phone subscribe to an idle PTY whose hidden pane sits at desktop size' expect({ cols: resubscribed.cols, rows: resubscribed.rows }).toEqual(PHONE) }) + it('joins a hydrate the resize repaint already started', async () => { + const { runtime, handle, serializeBuffer } = setup({ paneMounted: true, repaintOnResize: true }) + + const snapshot = await firstSnapshot(runtime, handle) + + expect({ cols: snapshot.cols, rows: snapshot.rows }).toEqual(PHONE) + expect((await paintedRows(snapshot)).slice(0, 4)).toEqual(EXPECTED_PHONE_ROWS) + expect(serializeBuffer).toHaveBeenCalledTimes(1) + }) + it('keeps serving the model on resubscribe without re-reading the pane', async () => { const { runtime, handle, serializeBuffer } = setup({ paneMounted: true }) await firstSnapshot(runtime, handle) diff --git a/src/main/runtime/orca-runtime-fit-override-listeners.ts b/src/main/runtime/orca-runtime-fit-override-listeners.ts index 5ef17ce4c2e..f738c81eec5 100644 --- a/src/main/runtime/orca-runtime-fit-override-listeners.ts +++ b/src/main/runtime/orca-runtime-fit-override-listeners.ts @@ -130,6 +130,8 @@ export class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWithStopRequ // Absent → hydration has not been considered yet for this PTY. // See docs/mobile-prefer-renderer-scrollback.md. protected headlessHydrationState = new Map() + /** The model's renderer seed, resolving true once written; lives as long as the model. */ + protected rendererSeedsByPtyId = new Map>() // Why: mobile-fit overrides are keyed by ptyId (not terminal handle) because // handles can be reissued while the PTY identity is stable. In-memory only — diff --git a/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts b/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts index 20139787b3c..37eaf950d82 100644 --- a/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts +++ b/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts @@ -110,13 +110,20 @@ export class OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer extends OrcaRuntime } } }) - return state.writeChain.then(() => seeded) + const seed = state.writeChain.then(() => seeded) + this.rendererSeedsByPtyId.set(ptyId, seed) + return seed } /** Public: a phone fit resizes the PTY, but an idle TUI sends no byte to hydrate on. Resolves * true once the model holds the pane's screen reflowed onto the PTY's current grid. */ hydrateHeadlessTerminalFromRenderer(ptyId: string): Promise { - return this.maybeHydrateHeadlessFromRenderer(ptyId) ?? Promise.resolve(false) + // Why join: a resize repaint can run the on-data seed first; its result answers this caller too. + return ( + this.maybeHydrateHeadlessFromRenderer(ptyId) ?? + this.rendererSeedsByPtyId.get(ptyId) ?? + Promise.resolve(false) + ) } // Why: seed-derived agent status reflects historical state. Orchestration diff --git a/src/main/runtime/orca-runtime-serialize-headless-terminal-buffer.ts b/src/main/runtime/orca-runtime-serialize-headless-terminal-buffer.ts index 673c31167f4..5d985f2c78a 100644 --- a/src/main/runtime/orca-runtime-serialize-headless-terminal-buffer.ts +++ b/src/main/runtime/orca-runtime-serialize-headless-terminal-buffer.ts @@ -75,6 +75,7 @@ export class OrcaRuntimeWithSerializeHeadlessTerminalBuffer extends OrcaRuntimeW protected disposeHeadlessTerminal(ptyId: string): void { this.headlessHydrationState.delete(ptyId) + this.rendererSeedsByPtyId.delete(ptyId) const state = this.headlessTerminals.get(ptyId) if (!state) { return diff --git a/src/main/runtime/pty-exit-per-pty-map-reaper-ratchet.test.ts b/src/main/runtime/pty-exit-per-pty-map-reaper-ratchet.test.ts index 8f1f6035dd8..e8362c0fb94 100644 --- a/src/main/runtime/pty-exit-per-pty-map-reaper-ratchet.test.ts +++ b/src/main/runtime/pty-exit-per-pty-map-reaper-ratchet.test.ts @@ -41,6 +41,10 @@ const CLEARED_BY_REAPER_HELPER: Record