Merge PR #5033: fix(tui): prevent destructive paint flicker
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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行");
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user