diff --git a/src/main/runtime/headless-hydration-live-overlap.test.ts b/src/main/runtime/headless-hydration-live-overlap.test.ts new file mode 100644 index 00000000000..0f05f06f4ff --- /dev/null +++ b/src/main/runtime/headless-hydration-live-overlap.test.ts @@ -0,0 +1,28 @@ +import { expect, it } from 'vitest' +import { deferred } from './orca-runtime-test-fixtures.spec' +import { createHydrationRuntime, PTY_ID, SIZE } from './headless-hydration-ownership-test-fixture' + +it('does not replay live bytes the renderer seed already painted', async () => { + const runtime = createHydrationRuntime() + const rendered = deferred<{ data: string; cols: number; rows: number; seq: number }>() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + getSize: () => SIZE, + hasRendererSerializer: () => true, + serializeBuffer: () => rendered.promise + }) + + // The renderer parses these while main awaits its serialization. + runtime.onPtyData(PTY_ID, 'LIVE-ONE\r\n', 1) + runtime.onPtyData(PTY_ID, '\x1b[38;5', 2) + runtime.onPtyData(PTY_ID, ';242mLIVE-TWO\x1b[0m\r\n', 3) + rendered.resolve({ data: 'HISTORY\r\nLIVE-ONE\r\n', ...SIZE, seq: 16 }) + await runtime.model().writeChain + + const screen = runtime.model().emulator.getVisibleLines().join('\n') + expect(screen.match(/LIVE-ONE/g)).toHaveLength(1) + expect(screen).toContain('LIVE-TWO') + expect(screen).not.toContain(';242m') +}) diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index b4841e2c8e1..e1cd456abed 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -158,6 +158,10 @@ export type RuntimeHeadlessTerminal = { outputSequence: number writeChain: Promise ownership: PtyShellOwnershipMirror + /** Output seq a renderer hydration seed already painted; queued live chunks at/below it are not rewritten. */ + rendererSeedSeq?: number + /** Unfinished escape from skipped seed-covered chunks, written before the next uncovered chunk. */ + rendererSeedEscapeTail?: string /** The grid a reattach reflowed the model onto, until a PTY resize off it repaints the TUI. */ unrepaintedReflowGrid?: { cols: number; rows: number } } diff --git a/src/main/runtime/terminal/output/headless-terminal-hydration.ts b/src/main/runtime/terminal/output/headless-terminal-hydration.ts index 6c8549cee89..62f26925f6a 100644 --- a/src/main/runtime/terminal/output/headless-terminal-hydration.ts +++ b/src/main/runtime/terminal/output/headless-terminal-hydration.ts @@ -12,6 +12,7 @@ import { normalizeTerminalTitle } from '../../../../shared/agent-detection' import { shouldModelAnswerHiddenPtyQueries } from '../../terminal-model-query-authority' +import { advancePartialEscapeTail } from '../../../../shared/terminal-partial-escape-tail' export abstract class OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer extends OrcaRuntimeWithSerializeMainTerminalBuffer { protected abstract getOrCreateHeadlessTerminal(ptyId: string): RuntimeHeadlessTerminal @@ -84,6 +85,10 @@ export abstract class OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer extends Or if (this.headlessTerminals.get(ptyId) !== state) { return } + // Why: the renderer parsed live bytes queued behind this seed while serializing; writing them again doubles TUI frames and splits escapes. + if (typeof rendered.seq === 'number') { + state.rendererSeedSeq = rendered.seq + } const ptyDims = this.getTerminalSize(ptyId) if (ptyDims && (ptyDims.cols !== rendered.cols || ptyDims.rows !== rendered.rows)) { state.emulator.resize(ptyDims.cols, ptyDims.rows) @@ -187,6 +192,22 @@ export abstract class OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer extends Or const completion = state.writeChain.then(async () => { // Capture reply ownership at ingestion, then scan inside the chain so live bytes and seeds retain total order. state.ownership.scan(data) + if (state.rendererSeedSeq !== undefined) { + if (outputSequence <= state.rendererSeedSeq) { + state.rendererSeedEscapeTail = advancePartialEscapeTail( + state.rendererSeedEscapeTail ?? '', + data + ) + state.outputSequence = outputSequence + return + } + const escapeTail = state.rendererSeedEscapeTail + state.rendererSeedSeq = undefined + state.rendererSeedEscapeTail = undefined + if (escapeTail) { + await state.emulator.write(escapeTail) + } + } for (const chunk of splitFreebuffScreenUpdates(data, state.emulator.partialEscapeTailAnsi)) { await state.emulator.write(chunk, { forwardQueryReplies }) const pty = this.ptysById.get(ptyId)