diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index a4b2dd3e6..fc860ae39 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -5,9 +5,14 @@ ### Added - Added `ScrollView`, a fixed-height viewport component for pre-rendered lines with optional right-edge scrollbars and imperative scroll/page controls. + +### Changed + +- Changed `SelectList` to render its visible window through `ScrollView`, replacing the `(N/M)` text scroll indicator with a uniform right-edge scrollbar (the type-to-search hint line is preserved). + ### Fixed -- Fixed autocomplete popups freezing live repaint on ED3-risk macOS/POSIX terminals with unknown native viewport position; direct autocomplete shrink frames now repaint the visible viewport non-destructively instead of deferring behind stale popup rows. +- 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 e79e5b905..b323748d1 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1837,21 +1837,26 @@ export class TUI extends Container { // const paddedViewportTop = Math.max(0, this.#previousLines.length - height); // ED3-risk terminals with an unobservable viewport cannot safely clear - // saved lines. Direct user-input frames (autocomplete/IME) are still - // allowed to repaint the live viewport in place: the user action pins the - // host to the tail, and deferring the shrink leaves stale autocomplete rows - // on screen until a later checkpoint. Active eager streaming uses the same - // non-destructive repaint so the live tail keeps moving. Native history - // stays dirty and reconciles at the next checkpoint. With neither a direct - // input opt-in nor active eager streaming, the reader may be scrolled; even - // a padded shrink repaint can move ED3-risk unknown host scrollback - // (WSL/Ghostty-style), so defer completely rather than repainting over their - // history. + // saved lines. Direct user-input frames (autocomplete/IME) may still + // repaint the live viewport: the user action pins the host to the tail, and + // emitting zero bytes leaves stale autocomplete rows on screen until a later + // checkpoint. When the changed rows are at or below the previous viewport + // top, keep the old bottom anchor by padding the frame to its previous + // length; that clears stale popup rows without re-exposing rows already + // 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. 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(); - return allowUnknownViewportMutation || this.#eagerNativeScrollbackRebuild - ? { kind: "viewportRepaint" } - : { kind: "deferredMutation" }; + if (allowUnknownViewportMutation) { + return diff.firstChanged < prevViewportTop + ? { kind: "viewportRepaint" } + : { kind: "deferredShrink", paddedLength: this.#previousLines.length }; + } + return this.#eagerNativeScrollbackRebuild ? { kind: "viewportRepaint" } : { kind: "deferredMutation" }; } // Non-ED3-risk POSIX with an unobservable viewport. `deferredShrink` is @@ -1962,21 +1967,30 @@ export class TUI extends Container { const contentGrew = newLines.length > this.#previousLines.length; const pureAppend = diff.appendedLines && diff.firstChanged === this.#previousLines.length; const structuralMutation = newLines.length !== this.#previousLines.length || diff.firstChanged < prevViewportTop; - if (pureAppend && contentGrew && this.#previousLines.length > height && !isMultiplexerSession()) { + if (pureAppend && contentGrew && this.#previousLines.length >= height && !isMultiplexerSession()) { const nativeViewportAtBottom = this.#readNativeViewportAtBottom(); + if (this.#nativeViewportIsKnownScrolled(nativeViewportAtBottom)) { + this.#markNativeScrollbackDirty(); + return { kind: "deferredMutation" }; + } + if (nativeViewportAtBottom === undefined && allowUnknownViewportMutation) { + // Direct input can grow transient live UI (autocomplete/IME/editor + // wraps) while the previous frame already touched the viewport bottom. + // A diff append would `\r\n`-scroll those transient rows into native + // history, and a later popup shrink would duplicate the stable prefix at + // the scrollback seam. Repaint the live viewport in place instead; the + // dirty checkpoint owns native-history reconciliation. + this.#markNativeScrollbackDirty(); + return { kind: "viewportRepaint" }; + } if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) { this.#markNativeScrollbackDirty(); - // Confirmed scrolled (probe returned `false`): the reader is parked in - // scrollback and writing the live frame is wasted bytes — defer until - // the next checkpoint reconciles. Unknown viewport (e.g. native Windows - // Terminal where the probe cannot see WT host scrollback) is a - // different case: a no-op there freezes the editor on the keystroke - // that grows `lines.length` past the viewport (the wrap keystroke). - // Fall through to a non-destructive viewport repaint instead so the - // live UI keeps updating without yanking a possibly-scrolled reader. - if (this.#nativeViewportIsKnownScrolled(nativeViewportAtBottom)) { - return { kind: "deferredMutation" }; - } + // Unknown viewport (e.g. native Windows Terminal where the probe cannot + // see WT host scrollback) is a different case: a no-op there freezes the + // editor on the keystroke that grows `lines.length` past the viewport + // (the wrap keystroke). Fall through to a non-destructive viewport + // repaint instead so the live UI keeps updating without yanking a + // possibly-scrolled reader. return { kind: "viewportRepaint" }; } } diff --git a/packages/tui/test/slash-autocomplete-viewport.test.ts b/packages/tui/test/slash-autocomplete-viewport.test.ts index c7d3fb2ae..4f1ae076a 100644 --- a/packages/tui/test/slash-autocomplete-viewport.test.ts +++ b/packages/tui/test/slash-autocomplete-viewport.test.ts @@ -128,6 +128,22 @@ describe("slash command autocomplete with unknown native viewport state", () => expect(editor.getText()).toBe("/st"); expect(viewport).toContain("/st"); expect(viewport).not.toContain("/s\n"); + expect(term.getScrollBuffer()).toEqual([ + "chat-0", + "chat-1", + "chat-2", + "chat-3", + "chat-4", + "chat-5", + "chat-6", + "+--------------------------------------+", + "+- /st| -+", + "> status", + " stats", + " stop", + "", + "", + ]); term.sendInput("\r"); await settle(term); @@ -136,6 +152,22 @@ describe("slash command autocomplete with unknown native viewport state", () => expect(editor.getText()).toBe(""); expect(viewport).not.toContain("status"); expect(viewport).not.toContain("/st"); + expect(term.getScrollBuffer()).toEqual([ + "chat-0", + "chat-1", + "chat-2", + "chat-3", + "chat-4", + "chat-5", + "chat-6", + "+--------------------------------------+", + "+- | -+", + "", + "", + "", + "", + "", + ]); } finally { tui?.stop(); setTerminalEagerEraseScrollbackRisk(originalRisk);