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.
This commit is contained in:
Neil
2026-10-26 02:42:32 -07:00
committed by GitHub
parent 80d1038354
commit 1e63a0bbe4
3 changed files with 53 additions and 0 deletions
@@ -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')
})
@@ -158,6 +158,10 @@ export type RuntimeHeadlessTerminal = {
outputSequence: number
writeChain: Promise<void>
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 }
}
@@ -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)