diff --git a/docs/tui-core-renderer.md b/docs/tui-core-renderer.md index dd984a830..3cc17f8b7 100644 --- a/docs/tui-core-renderer.md +++ b/docs/tui-core-renderer.md @@ -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 | diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index f5151aefa..81cfd3272 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index c568d09ea..4e2e78a49 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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. diff --git a/packages/tui/test/render-regressions.test.ts b/packages/tui/test/render-regressions.test.ts index 1693cc817..bdd77ef2c 100644 --- a/packages/tui/test/render-regressions.test.ts +++ b/packages/tui/test/render-regressions.test.ts @@ -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);