Merge PR #5033: fix(tui): prevent destructive paint flicker

This commit is contained in:
can1357
2026-07-10 12:37:37 +02:00
6 changed files with 36 additions and 20 deletions
+6 -4
View File
@@ -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.
+4
View File
@@ -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
+16 -11
View File
@@ -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;
+2 -1
View File
@@ -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行");
+4 -3
View File
@@ -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();
@@ -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);