fix(tui): reduced row flicker while typing
Changed text row repaints to overwrite first and clear only stale suffixes so non-synchronized WSL/Windows Terminal paints do not visibly blank already-rendered rows. Kept full-line pre-clears for image protocol rows and preserved exact-width row handling.\n\nFixes #2011
This commit is contained in:
@@ -78,7 +78,7 @@ the bytes written and the state update. All state flows through a single
|
||||
| `sessionReplace` | clear viewport **+ ED3** (outside multiplexers) | caller forced `{ clearScrollback: true }` (switch/branch/reload/resume) |
|
||||
| `historyRebuild` | clear viewport **+ ED3** (outside multiplexers) | geometry change rewrapped history, or a proven-at-tail rebuild |
|
||||
| `overlayRebuild` | rebuild viewport with overlay composite | overlay visibility changed |
|
||||
| `liveRegionPinned` | relative moves + per-line `\x1b[2K` + `\r\n` | foreground streaming on an ED3-risk host, commit-as-you-go |
|
||||
| `liveRegionPinned` | relative moves + per-row rewrite/suffix-clear + `\r\n` | foreground streaming on an ED3-risk host, commit-as-you-go |
|
||||
| `viewportRepaint` | rewrite the visible viewport in place (optional `appendFrom` tail first) | safe non-destructive repaint |
|
||||
| `deferredShrink` | padded viewport repaint, history left dirty | bottom-anchored shrink, viewport unobservable |
|
||||
| `deferredMutation` | **zero bytes**, history left dirty | row-reindexing edit while possibly scrolled |
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed WSL/Windows Terminal row flicker while typing by repainting changed text rows before clearing only their stale suffix ([#2011](https://github.com/can1357/oh-my-pi/issues/2011)).
|
||||
|
||||
## [15.9.69] - 2026-06-06
|
||||
|
||||
### Added
|
||||
|
||||
+20
-13
@@ -44,6 +44,8 @@ const SEGMENT_RESET = "\x1b[0m";
|
||||
* diffing so `#previousLines` mirrors what was actually written.
|
||||
*/
|
||||
const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07";
|
||||
const ERASE_LINE = "\x1b[2K";
|
||||
const ERASE_TO_END_OF_LINE = "\x1b[K";
|
||||
// Hide the hardware cursor before each paint/move write. Ghostty-style bar
|
||||
// cursors can otherwise leave visual afterimages while the TUI repaints the
|
||||
// row under a visible cursor. Paint writes also disable terminal autowrap:
|
||||
@@ -2276,8 +2278,8 @@ export class TUI extends Container {
|
||||
// Multiplexers (tmux/screen/zellij) cannot erase pane history with `\x1b[3J`
|
||||
// and cannot answer a viewport-position probe, so the destructive checkpoint
|
||||
// rebuild path is forever unavailable. The pinned emitter is built from the
|
||||
// opposite primitives — relative cursor moves, per-line `\x1b[2K`, and
|
||||
// `\r\n` to scroll sealed rows past the viewport bottom — which are exactly
|
||||
// opposite primitives — relative cursor moves, per-row rewrite/suffix-clear,
|
||||
// and `\r\n` to scroll sealed rows past the viewport bottom — which are exactly
|
||||
// what tmux pane history accepts. Without this commit-as-you-go path, the
|
||||
// streaming cap below clipped every frame to the visible tail and the
|
||||
// scrolled-off head was committed nowhere (issue #1974).
|
||||
@@ -2349,6 +2351,12 @@ export class TUI extends Container {
|
||||
return truncated + (truncated.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
|
||||
}
|
||||
|
||||
#lineRewriteSequence(line: string, width: number): string {
|
||||
const fitted = this.#fitLineToWidth(line, width);
|
||||
if (TERMINAL.isImageLine(fitted)) return ERASE_LINE + fitted;
|
||||
return visibleWidth(fitted) >= width ? fitted : fitted + ERASE_TO_END_OF_LINE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single state-transition point. Every emitter calls this exactly once at
|
||||
* the end so cursor/viewport/scrollback accounting stays consistent.
|
||||
@@ -2515,8 +2523,7 @@ export class TUI extends Container {
|
||||
let buffer = `${this.#paintBeginSequence}\x1b[H`;
|
||||
for (let screenRow = 0; screenRow < height; screenRow++) {
|
||||
if (screenRow > 0) buffer += "\r\n";
|
||||
buffer += "\x1b[2K";
|
||||
buffer += texts[screenRow];
|
||||
buffer += this.#lineRewriteSequence(texts[screenRow], width);
|
||||
}
|
||||
// DECCARA rectangles paint the visible fills before cursor positioning;
|
||||
// the cleared cells written above are what the rectangles repaint.
|
||||
@@ -2554,8 +2561,8 @@ export class TUI extends Container {
|
||||
* leaving the transient live region out of saved lines.
|
||||
*
|
||||
* Uses only the no-scroll-snap vocabulary of {@link #emitDiff}: relative
|
||||
* cursor moves, per-line `\x1b[2K`, and `\r\n` to push the sealed chunk into
|
||||
* history. It deliberately avoids a full-screen erase (`\x1b[2J`) and absolute
|
||||
* cursor moves, per-row rewrite/suffix-clear, and `\r\n` to push the sealed
|
||||
* chunk into history. It deliberately avoids a full-screen erase (`\x1b[2J`) and absolute
|
||||
* cursor home (`\x1b[H`): on Ghostty those snap a reader scrolled into history
|
||||
* back to the bottom on every frame.
|
||||
*/
|
||||
@@ -2587,17 +2594,18 @@ export class TUI extends Container {
|
||||
|
||||
// Write the sealed chunk followed by the full viewport from the top row.
|
||||
// The first (boundedAppendTo - boundedAppendFrom) rows scroll into native
|
||||
// history; the trailing `height` rows fill the viewport. Each row clears
|
||||
// itself with `\x1b[2K` instead of relying on a screen-wide erase.
|
||||
// history; the trailing `height` rows fill the viewport. Text rows overwrite
|
||||
// first and clear only the suffix so non-synchronized hosts do not visibly
|
||||
// blank stable content before repainting it.
|
||||
let wroteLine = false;
|
||||
for (let i = boundedAppendFrom; i < boundedAppendTo; i++) {
|
||||
if (wroteLine) buffer += "\r\n";
|
||||
buffer += `\x1b[2K${this.#fitLineToWidth(lines[i] ?? "", width)}`;
|
||||
buffer += this.#lineRewriteSequence(lines[i] ?? "", width);
|
||||
wroteLine = true;
|
||||
}
|
||||
for (let screenRow = 0; screenRow < height; screenRow++) {
|
||||
if (wroteLine) buffer += "\r\n";
|
||||
buffer += `\x1b[2K${this.#fitLineToWidth(lines[viewportTop + screenRow] ?? "", width)}`;
|
||||
buffer += this.#lineRewriteSequence(lines[viewportTop + screenRow] ?? "", width);
|
||||
wroteLine = true;
|
||||
}
|
||||
|
||||
@@ -2676,7 +2684,7 @@ export class TUI extends Container {
|
||||
const currentScreenRow = Math.max(0, Math.min(height - 1, clampedCursor - prevViewportTop));
|
||||
const moveDown = height - 1 - currentScreenRow;
|
||||
if (moveDown > 0) buffer += `\x1b[${moveDown}B`;
|
||||
buffer += `\r\x1b[2K${this.#fitLineToWidth(line, width)}\x1b[?25l`;
|
||||
buffer += `\r${this.#lineRewriteSequence(line, width)}\x1b[?25l`;
|
||||
buffer += this.#paintEndSequence;
|
||||
this.terminal.write(buffer);
|
||||
|
||||
@@ -2835,8 +2843,7 @@ export class TUI extends Container {
|
||||
}
|
||||
for (let i = firstChanged; i <= renderEnd; i++) {
|
||||
if (i > firstChanged) buffer += "\r\n";
|
||||
buffer += "\x1b[2K";
|
||||
buffer += fillTexts && i >= fillStart ? fillTexts[i - fillStart] : this.#fitLineToWidth(lines[i], width);
|
||||
buffer += this.#lineRewriteSequence(fillTexts && i >= fillStart ? fillTexts[i - fillStart] : lines[i], width);
|
||||
}
|
||||
|
||||
// If the prior frame was taller, clear the trailing rows.
|
||||
|
||||
@@ -247,6 +247,33 @@ describe("TUI terminal-state regressions", () => {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
it("rewrites changed rows before clearing suffixes for non-synchronized hosts", async () => {
|
||||
const term = new VirtualTerminal(40, 8);
|
||||
const tui = new TUI(term);
|
||||
const component = new MutableLinesComponent([
|
||||
"assistant output already rendered",
|
||||
"tool output already rendered",
|
||||
"todos/status already rendered",
|
||||
]);
|
||||
tui.addChild(component);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
const writes = captureWrites(term);
|
||||
|
||||
component.setLines(["assistant output already rendered", "tool", "todos/status already rendered"]);
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
|
||||
const paint = writes.at(-1) ?? "";
|
||||
expect(paint).toContain("tool\x1b[0m\x1b[K");
|
||||
expect(paint).not.toContain("\x1b[2Ktool");
|
||||
expect(visible(term)[1]).toBe("tool");
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("clears removed tail lines after shrink", async () => {
|
||||
const term = new VirtualTerminal(40, 10);
|
||||
@@ -2142,7 +2169,7 @@ describe("TUI terminal-state regressions", () => {
|
||||
expect(viewport.at(-1)).toBe("spinner-b");
|
||||
expect(term.getScrollBuffer().join("\n")).not.toContain("edited-0");
|
||||
const paint = writes.at(-1) ?? "";
|
||||
expect(paint).toContain("\r\x1b[2Kspinner-b");
|
||||
expect(paint).toContain("\rspinner-b\x1b[0m\x1b[K");
|
||||
expect(paint).not.toContain("\x1b[H");
|
||||
expect(paint).not.toContain("\x1b[3J");
|
||||
} finally {
|
||||
@@ -2170,7 +2197,7 @@ describe("TUI terminal-state regressions", () => {
|
||||
expect(visible(scrolledTerm).map(line => line.trim())).toEqual(beforeViewport);
|
||||
expect(scrolledTerm.getScrollBuffer().join("\n")).not.toContain("edited-0");
|
||||
const paint = writes.at(-1) ?? "";
|
||||
expect(paint).toContain("\r\x1b[2Kspinner-b");
|
||||
expect(paint).toContain("\rspinner-b\x1b[0m\x1b[K");
|
||||
expect(paint).not.toContain("\x1b[H");
|
||||
expect(paint).not.toContain("\x1b[3J");
|
||||
} finally {
|
||||
@@ -3426,8 +3453,8 @@ describe("TUI terminal-state regressions", () => {
|
||||
// Initial paint: only the styled row carries background cells.
|
||||
expect(backgroundRows(term, height)).toEqual([1]);
|
||||
|
||||
// Diff path: rewriting the row below starts with \x1b[2K — with leaked
|
||||
// background, BCE would paint that whole row red.
|
||||
// Diff path: rewriting the row below clears only after the row reset;
|
||||
// with leaked background, BCE would otherwise paint that row red.
|
||||
component.setLines(["plain-0", UNRESET_BG_ROW, "EDITED-2"]);
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
@@ -3459,8 +3486,8 @@ describe("TUI terminal-state regressions", () => {
|
||||
expect(foregroundRows(term, height)).toEqual([1]);
|
||||
expect(underlineRows(term, height)).toEqual([1]);
|
||||
|
||||
// Rewriting the next row starts with an erase; leaked SGR would make
|
||||
// the edited row green/underlined despite containing plain text.
|
||||
// Rewriting the next row clears only after the row reset; leaked SGR
|
||||
// would make the edited row green/underlined despite containing plain text.
|
||||
component.setLines(["plain-0", UNRESET_FG_UNDERLINE_ROW, "EDITED-2"]);
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
@@ -3495,7 +3522,7 @@ describe("TUI terminal-state regressions", () => {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
|
||||
// Force a full repaint (viewport rewrite path emits \x1b[2K per row).
|
||||
// Force a full repaint (viewport rewrite path suffix-clears each text row).
|
||||
tui.requestRender(true);
|
||||
await settle(term);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user