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:
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user