From 1e63a0bbe4b4b41208b498e798942e017a1ab311 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:42:32 -0700 Subject: [PATCH] fix(terminal): don't rewrite live bytes a renderer hydration seed already painted (#33766) (#33880) Main seeds its terminal model from the renderer's xterm and queues live chunks behind the seed. The renderer keeps parsing those chunks while it serializes, so they were in the seed and then written again, doubling TUI frames and splitting escapes (literal ';242m'). Hidden-pane reveals repaint from this model. Skip queued chunks at or below the seed's seq and carry any unfinished escape into the next chunk. --- .../headless-hydration-live-overlap.test.ts | 28 +++++++++++++++++++ .../runtime/runtime-terminal-state-records.ts | 4 +++ .../output/headless-terminal-hydration.ts | 21 ++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 src/main/runtime/headless-hydration-live-overlap.test.ts 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)