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:
roboomp
2026-06-06 20:28:15 +00:00
parent 5fc443f4af
commit 67e6324ace
4 changed files with 59 additions and 21 deletions
+1 -1
View File
@@ -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 |
+4
View File
@@ -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
View File
@@ -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.
+34 -7
View File
@@ -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);