diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index fc860ae39..929acd26a 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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. diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 1b7ea3f8f..dd5f20491 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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(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); } diff --git a/packages/tui/test/render-regressions.test.ts b/packages/tui/test/render-regressions.test.ts index e34a7b30f..3c9632512 100644 --- a/packages/tui/test/render-regressions.test.ts +++ b/packages/tui/test/render-regressions.test.ts @@ -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)