diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 90069139515..74180bee1a7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -12103,6 +12103,28 @@ describe('connectPanePty', () => { return { transport, pane, dataCallback: capturedDataCallback.current!, binding } } + // STA-4042 root: the restore-needed marker is the ONE point where "bytes + // were dropped" is known. The emulator carries state across chunks just like + // the cross-chunk parser the handler already resets, so the gap is closed + // here rather than left to each recovery path to remember. + it('closes the emulator state gap when a drop is announced', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { pane, dataCallback } = await connectHiddenPane(deps) + dataCallback('hidden output\r\n', { seq: 16, rawLength: 16 }) + pane.terminal.write.mockClear() + + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'hidden-drop', markerSeq: 64 }) + await flushAsyncTicks(4) + + const written = pane.terminal.write.mock.calls.map(([data]) => data as string) + const gapReset = written.find((data) => data.startsWith('\x1b[0m')) + expect(gapReset).toBeDefined() + // charset too: a dropped ESC(B otherwise renders text as box characters + expect(gapReset).toContain('\x1b(B') + }) + it('marks the PTY hidden on hidden output and clears it before requesting restore on reveal', async () => { enableMainAuthority() const deps = createDeps({ isVisibleRef: { current: false } }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 8c770f48fdc..fefcd24816a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -6101,6 +6101,14 @@ export function connectPanePty( }) // Why: dropped bytes invalidate cross-chunk carry — a partial OSC-9999 prefix spanning the gap would corrupt the next live chunk. transport.resetCrossChunkParserState?.() + // Why the emulator too: it carries state across chunks exactly like the + // parser does. If the gap swallowed the `ESC[22m` closing a bold run (or + // the `ESC(B` leaving line-drawing), every cell written afterwards + // inherits it. This marker is the one point where "bytes were dropped" is + // known, so close the state gap here instead of relying on each recovery + // path to remember — restore, abandon and overflow then all start from a + // known pen (STA-4042). + writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true) // Why gated (rc.7.perf loop): on a visible pane these markers come from our own restore starving ACKs; re-arming per marker kept the fetch loop alive all flood, so defer to one post-flood repaint. if (isForegroundRestoreBackpressureContext()) { noteHiddenOutputRestoreFloodBackpressure()