feat(tui): added deferred bottom-row repaint for unknown viewports

- Repainted only the active-grid tail row relative to the tracked hardware cursor so a scrolled reader's history stays intact.
- Let bottom-anchored spinner/status chrome advance while the real scrollback mutation stays deferred.
- Deferred completely instead of repainting when there is no direct input or eager streaming.
This commit is contained in:
can1357
2026-06-06 15:06:06 +02:00
parent c281f817ac
commit 4b36eb2da4
3 changed files with 147 additions and 11 deletions
+1
View File
@@ -12,6 +12,7 @@
### Fixed
- Fixed unknown-viewport deferred renders freezing bottom-anchored live chrome; deferred history mutations can now repaint only the active-grid bottom row with relative cursor movement, so spinner/status tails keep advancing without rewriting rows a scrolled reader can still see.
- Fixed autocomplete popups freezing live repaint on ED3-risk macOS/POSIX terminals with unknown native viewport position; direct autocomplete shrink frames now repaint the live viewport without zero-byte deferral and preserve the old bottom anchor when padding can clear stale popup rows without duplicating committed scrollback.
- Fixed focused Up/Down navigation on ED3-risk macOS/POSIX terminals replaying the whole transcript after dirty foreground-stream renders; selector/editor frames now repaint non-destructively instead of emitting `CSI 3 J` on every arrow-key move ([#1962](https://github.com/can1357/oh-my-pi/issues/1962)).
- Fixed tmux (and screen/zellij) pane scrollback losing the head of a long streamed assistant reply once it grew past the visible pane, and stranding the chrome/footer in pane history after a later collapse — producing the "repeating chunks and missing sections" reporters saw when scrolling back through tmux pane history ([#1974](https://github.com/can1357/oh-my-pi/issues/1974)). The renderer's foreground-streaming cap-to-viewport branch (introduced in 15.9.2 for ED3-risk hosts that can checkpoint-rebuild later) also activated inside multiplexers, where checkpoint reconcile is a no-op (`refreshNativeScrollbackIfDirty` short-circuits because `\x1b[3J` cannot erase pane history). Every streaming frame clipped `lines` to the visible tail and reset `#scrollbackHighWater` to 0, so any row that scrolled above the viewport top was committed nowhere — pane history stayed empty until streaming ended. Meanwhile `#planLiveRegionPinnedRender` was explicitly disabled for multiplexers, but its `#emitLiveRegionPinnedRepaint` is built from the exact primitives tmux accepts (relative cursor moves, per-line `\x1b[2K`, `\r\n` to scroll the sealed prefix past the viewport bottom) and never emits `\x1b[2J`/`\x1b[3J`. The pinned planner now runs in multiplexers too, the cap branch skips them, and the diff/append path commits incrementally into pane history; the actively-mutating live tail stays in the visible viewport only.
+86 -11
View File
@@ -366,6 +366,10 @@ export class Container implements Component {
* - `deferredShrink`: pure content shrink would re-expose rows already in
* native history. Keep row indices stable with blank tail padding, repaint
* only the viewport, and defer the real shorter replay to a checkpoint.
* - `deferredTailRepaint`: a deferred history mutation also changed the active
* grid's bottom row; repaint only that row relative to the tracked hardware
* cursor so a bottom-anchored spinner can advance without rewriting rows that
* a slightly-scrolled reader can still see.
* - `deferredMutation`: a row-inserting edit would reindex native scrollback
* while the user is scrolled. Defer all bytes until a safe rebuild checkpoint.
* - `shrink`: trailing rows were dropped — clear extras inline.
@@ -380,6 +384,7 @@ type RenderIntent =
| { kind: "liveRegionPinned"; appendFrom: number; appendTo: number; renderViewportTop: number }
| { kind: "viewportRepaint"; appendFrom?: number }
| { kind: "deferredShrink"; paddedLength: number }
| { kind: "deferredTailRepaint"; row: number; line: string }
| { kind: "deferredMutation" }
| { kind: "shrink" }
| { kind: "diff"; firstChanged: number; lastChanged: number; appendedLines: boolean };
@@ -430,6 +435,7 @@ export class TUI extends Container {
#nativeScrollbackLiveRegionStart: number | undefined;
#nativeScrollbackCommitSafeEnd: number | undefined;
#nativeScrollbackDirty = false;
#deferredTailLine: string | undefined;
// Highest `#maxLinesRendered` reached during a foreground tool turn while
// intermediate frames were prevented from committing to terminal scrollback.
// Used after the tool finishes to push the settled content into scrollback
@@ -1655,6 +1661,16 @@ export class TUI extends Container {
}
this.#emitViewportRepaint(lines, width, height, cursorPos);
return;
case "deferredTailRepaint":
this.#emitDeferredTailRepaint(
intent.line,
width,
height,
intent.row,
prevViewportTop,
prevHardwareCursorRow,
);
return;
case "deferredMutation":
return;
case "deferredShrink":
@@ -1846,19 +1862,19 @@ export class TUI extends Container {
// committed to native history. If an offscreen edit shifted rows above the
// viewport, padding would repaint the wrong seam, so use a viewport repaint
// for liveness and keep history dirty. Active eager streaming also uses a
// viewport repaint so the live tail keeps moving. An unobservable
// viewport is treated as at-bottom here: emitting zero bytes froze the
// live region (spinner/footer) and poisoned the diff basis until the
// next keystroke. Repaint for liveness and keep history dirty; a
// *known*-scrolled reader was already deferred above.
// viewport repaint so the live tail keeps moving. With neither direct input
// nor active eager streaming, the reader may be scrolled, so defer
// completely rather than repainting over their history.
if (nativeViewportAtBottom === undefined && eagerEraseScrollbackRisk) {
this.#markNativeScrollbackDirty();
if (this.#eagerNativeScrollbackRebuild) {
return { kind: "viewportRepaint" };
if (allowUnknownViewportMutation) {
return diff.firstChanged < prevViewportTop
? { kind: "viewportRepaint" }
: { kind: "deferredShrink", paddedLength: this.#previousLines.length };
}
return diff.firstChanged < prevViewportTop
return this.#eagerNativeScrollbackRebuild
? { kind: "viewportRepaint" }
: { kind: "deferredShrink", paddedLength: this.#previousLines.length };
: this.#planDeferredTailRepaint(newLines, prevViewportTop, height);
}
// Non-ED3-risk POSIX with an unobservable viewport. `deferredShrink` is
@@ -1870,7 +1886,7 @@ export class TUI extends Container {
}
this.#markNativeScrollbackDirty();
if (diff.firstChanged < prevViewportTop) {
return { kind: "viewportRepaint" };
return this.#planDeferredTailRepaint(newLines, prevViewportTop, height);
}
return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
}
@@ -2070,6 +2086,14 @@ export class TUI extends Container {
return { kind: "historyRebuild" };
}
this.#markNativeScrollbackDirty();
if (
nativeViewportAtBottom === undefined &&
eagerEraseScrollbackRisk &&
!cleanTailAppend &&
!this.#eagerNativeScrollbackRebuild
) {
return this.#planDeferredTailRepaint(newLines, prevViewportTop, height);
}
return { kind: "viewportRepaint", appendFrom: cleanTailAppend ? this.#previousLines.length : undefined };
}
@@ -2262,6 +2286,19 @@ export class TUI extends Container {
return { kind: "liveRegionPinned", appendFrom, appendTo, renderViewportTop };
}
#planDeferredTailRepaint(newLines: string[], prevViewportTop: number, height: number): RenderIntent {
const row = prevViewportTop + height - 1;
if (row < 0 || row >= this.#previousLines.length || newLines.length !== this.#previousLines.length) {
return { kind: "deferredMutation" };
}
const line = newLines[newLines.length - 1] ?? "";
const previousLine = this.#deferredTailLine ?? this.#previousLines[row] ?? "";
if (line === previousLine) {
return { kind: "deferredMutation" };
}
return { kind: "deferredTailRepaint", row, line };
}
#padDeferredShrinkLines(lines: string[], paddedLength: number): string[] {
if (lines.length >= paddedLength) return lines;
return [...lines, ...new Array<string>(paddedLength - lines.length).fill("")];
@@ -2293,6 +2330,7 @@ export class TUI extends Container {
*/
#commit(lines: string[], width: number, height: number, viewportTop: number, hardwareCursorRow: number): void {
this.#deferredTailLine = undefined;
this.#previousLines = lines;
this.#previousVisibleOverlayComponents = this.#visibleOverlayComponentsThisRender;
this.#forceViewportRepaintOnNextRender = false;
@@ -2589,6 +2627,41 @@ export class TUI extends Container {
}
}
/**
* Paint only the active-grid bottom row while a scrollback mutation remains
* deferred. If the native viewport is unknown and the user is scrolled up by a
* single line, every active-grid row except the bottom can still be visible in
* their scrollback window; touching only this row keeps that reader's viewport
* unchanged while allowing bottom-anchored live chrome (spinner/status tail) to
* advance for users at the tail.
*/
#emitDeferredTailRepaint(
line: string,
width: number,
height: number,
row: number,
prevViewportTop: number,
prevHardwareCursorRow: number,
): void {
const viewportBottom = prevViewportTop + height - 1;
if (row !== viewportBottom) return;
let buffer = this.#paintBeginSequence;
const clampedCursor = Math.min(prevHardwareCursorRow, viewportBottom);
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 += this.#paintEndSequence;
this.terminal.write(buffer);
this.#deferredTailLine = line;
this.#previousWidth = width;
this.#previousHeight = height;
this.#viewportTopRow = prevViewportTop;
this.#hardwareCursorRow = row;
}
/**
* Trailing-shrink: prior content shared a prefix with the new content; the
* extra rows below the new tail need to be cleared without scrolling. Falls
@@ -2793,7 +2866,9 @@ export class TUI extends Container {
? `${intent.kind}(append=${intent.appendFrom}..${intent.appendTo}, viewportTop=${intent.renderViewportTop})`
: intent.kind === "viewportRepaint" && intent.appendFrom !== undefined
? `${intent.kind}(appendFrom=${intent.appendFrom})`
: intent.kind;
: intent.kind === "deferredTailRepaint"
? `${intent.kind}(row=${intent.row})`
: intent.kind;
const msg = `[${new Date().toISOString()}] render: ${detail} (prev=${this.#previousLines.length}, new=${newLength}, height=${height})\n`;
fs.appendFileSync(getDebugLogPath(), msg);
}
@@ -2096,6 +2096,66 @@ describe("TUI terminal-state regressions", () => {
tui.stop();
}
});
it("repaints only the active-grid bottom row while unknown viewport mutation is deferred", async () => {
const initial = [...rows("line-", 12), "spinner-a"];
const updated = ["edited-0", ...rows("line-", 12).slice(1), "spinner-b"];
await withTerminalRisk(true, async () => {
const term = new UnknownViewportTerminal(40, 6);
const tui = new TUI(term);
const component = new MutableLinesComponent(initial);
tui.addChild(component);
try {
tui.start();
await settle(term);
const writes = captureWrites(term);
component.setLines(updated);
tui.requestRender();
await settle(term);
const viewport = visible(term).map(line => line.trim());
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).not.toContain("\x1b[H");
expect(paint).not.toContain("\x1b[3J");
} finally {
tui.stop();
}
const scrolledTerm = new UnknownViewportTerminal(40, 6);
const scrolledTui = new TUI(scrolledTerm);
const scrolledComponent = new MutableLinesComponent(initial);
scrolledTui.addChild(scrolledComponent);
try {
scrolledTui.start();
await settle(scrolledTerm);
scrolledTerm.scrollLines(-1);
const before = scrolledTerm.getBufferPosition();
const beforeViewport = visible(scrolledTerm).map(line => line.trim());
const writes = captureWrites(scrolledTerm);
scrolledComponent.setLines(updated);
scrolledTui.requestRender();
await settle(scrolledTerm);
expect(scrolledTerm.getBufferPosition()).toEqual(before);
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).not.toContain("\x1b[H");
expect(paint).not.toContain("\x1b[3J");
} finally {
scrolledTui.stop();
}
});
});
it("rebuilds history when a shrink leaves no real rows above the scrollback boundary", async () => {
// Reviewer scenario (#1599): a large completion-style collapse (e.g. a 100-row
// streamed transcript shrinking to a 20-row final cell in a 10-row viewport)