From 6db9ab606a6db7d141ac2189d9b192f4f4f39856 Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 10 Jul 2026 06:00:31 +0000 Subject: [PATCH] fix(tui): prevented destructive paint flicker Replayed destructive full paints from home without issuing ED2 before the transcript rows, so resume and resize settles do not expose a blank viewport on terminals without synchronized output. Kept ED3 as the single native-history clear path and updated renderer regression coverage for the new byte contract. Fixes #5028 --- docs/tui-core-renderer.md | 10 ++++--- packages/tui/CHANGELOG.md | 4 +++ packages/tui/src/tui.ts | 27 +++++++++++-------- packages/tui/test/issue-2115-repro.test.ts | 3 ++- packages/tui/test/render-regressions.test.ts | 7 ++--- .../tui/test/resize-viewport-defer.test.ts | 5 +++- 6 files changed, 36 insertions(+), 20 deletions(-) diff --git a/docs/tui-core-renderer.md b/docs/tui-core-renderer.md index 10fe737ce..25dfb4ad0 100644 --- a/docs/tui-core-renderer.md +++ b/docs/tui-core-renderer.md @@ -110,7 +110,7 @@ updates never rewrite anything a scrolled reader could be looking at. | Emitter | Bytes | When | |---|---|---| -| `#emitFullPaint` | clears + `frame[0, C')` + window rows | gestures only. `clearScrollback` ⇒ `\x1b[2J\x1b[H\x1b[3J`; otherwise ED22 (when supported) + `\x1b[2J\x1b[H` | +| `#emitFullPaint` | home + `frame[0, C')` + window rows; with `clearScrollback`, ED3 clears history without an ED2 viewport blank | gestures only | | `#emitUpdate` scroll-append | `\r\n` + new bottom rows + changed-row range | the rows leaving the screen are exactly the chunk, content untouched since painted | | `#emitUpdate` in-window diff | relative move + changed-row range rewrite | nothing scrolls, nothing commits (cursor-only when nothing changed) | | `#emitUpdate` seam rewrite | chunk rows + full window rewrite | commit advance, window re-anchor, hidden-gap backfill, mux resize | @@ -118,9 +118,11 @@ updates never rewrite anything a scrolled reader could be looking at. **ED3 (`CSI 3 J`) is emitted in exactly one place** — `#emitFullPaint` with `clearScrollback: true` — and is reached only by user gestures: session replace/branch/resume (`requestRender(true, { clearScrollback: true })`), -resize outside a multiplexer, `resetDisplay()` (Ctrl+L). A gesture pins the -user to the tail, so the snap is acceptable; multiplexers never get ED3 (it is -a no-op there and a replay would duplicate pane history). +resize outside a multiplexer, `resetDisplay()` (Ctrl+L). It clears native +history without `ED2` first; the replay overwrites every row from home so +terminals without synchronized output do not expose a blank viewport. A gesture +pins the user to the tail, so the history snap is acceptable; multiplexers never +get ED3 (it is a no-op there and a replay would duplicate pane history). The ordinary update path never emits ED2/ED3 or an absolute cursor home — several terminal families snap a scrolled reader to the bottom on those. diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index cb2b07009..8106893df 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed resume/session-replace and resize-settle full paints blanking the live viewport before replaying the transcript, preventing flicker on terminals without effective synchronized output ([#5028](https://github.com/can1357/oh-my-pi/issues/5028)). + ## [16.3.14] - 2026-07-09 ### Fixed diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index a916af1d5..955b0e39a 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -561,10 +561,9 @@ export class Container implements Component { * method owns the bytes written and the state update. * * - `fullPaint`: gesture-driven replay — initial paint, session replacement, - * resize, resetDisplay. Clears the viewport and (for destructive replaces, - * outside multiplexers) native scrollback via ED3, then writes the - * committed prefix and the visible window. The only ED3 callsite in the - * engine. + * resize, resetDisplay. Rewrites the frame from home; destructive replaces + * clear native scrollback via ED3 without first blanking the viewport. The + * only ED3 callsite in the engine. * - `update`: ordinary frame. Commits the newly settled chunk at the * scrollback seam (if any) and repaints the window with relative moves. */ @@ -3221,7 +3220,10 @@ export class TUI extends Container { } let buffer = this.#paintBeginSequence + this.#leaveResizeAltSequence() + purgeSequence; if (options.clearScrollback) { - buffer += "\x1b[2J\x1b[H\x1b[3J"; + // Clear native history without blanking the live viewport first. The + // replay below rewrites every visible row from home, including blanks, + // so terminals without DEC 2026 never expose an ED2-cleared frame. + buffer += "\x1b[H\x1b[3J"; } else { // Best-effort: push the pre-paint screen into scrollback on // terminals that implement kitty's ED 22 @@ -3254,21 +3256,24 @@ export class TUI extends Container { if (paintLines === null) { // Common path: emit straight from the source arrays (the // pre-merge two-loop form); byte-identical to replaying the - // merged array. + // merged array. Destructive history clears deliberately avoid ED2, so + // each row must self-clear stale cells left by the previous viewport. for (let i = 0; i < chunkTo; i++) { if (i > 0) buffer += "\r\n"; - buffer += this.#terminalLine(frame[i] ?? ""); + buffer += options.clearScrollback + ? this.#lineRewriteSequence(frame[i] ?? "", width) + : this.#terminalLine(frame[i] ?? ""); } for (let screenRow = 0; screenRow < height; screenRow++) { if (chunkTo + screenRow > 0) buffer += "\r\n"; - buffer += this.#terminalLine(visibleTexts ? (visibleTexts[screenRow] ?? "") : (window[screenRow] ?? "")); + const line = visibleTexts ? (visibleTexts[screenRow] ?? "") : (window[screenRow] ?? ""); + buffer += options.clearScrollback ? this.#lineRewriteSequence(line, width) : this.#terminalLine(line); } } else { for (let i = 0; i < paintLines.length; i++) { if (i > 0) buffer += "\r\n"; - buffer += this.#terminalLine( - visibleTexts && i >= visibleStart ? visibleTexts[i - visibleStart] : (paintLines[i] ?? ""), - ); + const line = visibleTexts && i >= visibleStart ? visibleTexts[i - visibleStart] : (paintLines[i] ?? ""); + buffer += options.clearScrollback ? this.#lineRewriteSequence(line, width) : this.#terminalLine(line); } } buffer += fillSequence; diff --git a/packages/tui/test/issue-2115-repro.test.ts b/packages/tui/test/issue-2115-repro.test.ts index 6ff224d48..b6672f4a9 100644 --- a/packages/tui/test/issue-2115-repro.test.ts +++ b/packages/tui/test/issue-2115-repro.test.ts @@ -107,8 +107,9 @@ describe("issue #2115: ConPTY large-session resume truncates at logical lines", tui.start({ clearScrollback: true }); await term.waitForRender(); - const fullPaint = writes.find(write => write.includes("\x1b[2J")); + const fullPaint = writes.find(write => write.includes("\x1b[3J")); expect(fullPaint).toBeDefined(); + expect(fullPaint).not.toContain("\x1b[2J"); expect(Buffer.byteLength(fullPaint ?? "", "utf8")).toBeLessThan(128 * 1024); expect(fullPaint).toContain("older lines hidden"); expect(fullPaint).not.toContain("第00000行"); diff --git a/packages/tui/test/render-regressions.test.ts b/packages/tui/test/render-regressions.test.ts index 83f8a0aa4..6d39fa08c 100644 --- a/packages/tui/test/render-regressions.test.ts +++ b/packages/tui/test/render-regressions.test.ts @@ -432,7 +432,7 @@ describe("TUI terminal-state regressions", () => { tui.resetDisplay(); await settle(term); - expect(writes.some(write => write.includes("\x1b[2J\x1b[H\x1b[3J"))).toBe(true); + expect(writes.some(write => write.includes("\x1b[H\x1b[3J") && !write.includes("\x1b[2J"))).toBe(true); expect(term.getScrollBuffer().map(line => line.trimEnd())).toEqual(rows("L", 8)); expect(visible(term)).toEqual(["L5", "L6", "L7"]); } finally { @@ -1357,7 +1357,7 @@ describe("TUI terminal-state regressions", () => { } }); - it("uses ED3 for destructive rebuilds even when CSI 22 J is supported", async () => { + it("uses ED3 without blanking the viewport for destructive rebuilds even when CSI 22 J is supported", async () => { const saved = TERMINAL.supportsScreenToScrollback; setTerminalScreenToScrollback(true); const term = new VirtualTerminal(20, 3); @@ -1373,7 +1373,8 @@ describe("TUI terminal-state regressions", () => { tui.requestRender(true, { clearScrollback: true }); await settle(term); const out = writes.join(""); - expect(out).toContain("\x1b[2J\x1b[H\x1b[3J"); + expect(out).toContain("\x1b[H\x1b[3J"); + expect(out).not.toContain("\x1b[2J"); expect(out).not.toContain("\x1b[22J"); } finally { tui.stop(); diff --git a/packages/tui/test/resize-viewport-defer.test.ts b/packages/tui/test/resize-viewport-defer.test.ts index a0ceac04d..622f8d212 100644 --- a/packages/tui/test/resize-viewport-defer.test.ts +++ b/packages/tui/test/resize-viewport-defer.test.ts @@ -263,7 +263,9 @@ describe("non-multiplexer resize viewport fast path", () => { await scheduler.flushImmediates(term); // Settle window elapses: exactly one authoritative full paint that - // erases native scrollback (ED3) and replays every block. + // clears native scrollback (ED3) and replays every block. It must not + // blank the live viewport with ED2 first; terminals without DEC 2026 + // expose that blank frame as resize/session-replace flicker. for (const b of blocks) b.renderCount = 0; await scheduler.flushAll(term); @@ -273,6 +275,7 @@ describe("non-multiplexer resize viewport fast path", () => { // full replay or a stray scrollback erase into the settle. expect(tui.fullRedraws).toBe(baselineFull + 1); expect(eraseScrollbackCount(writes)).toBe(1); + expect(writes.join("")).not.toContain("\x1b[2J"); // The full replay lays out the whole transcript, off-screen blocks // included. expect(blocks.every(b => b.renderCount > 0)).toBe(true);