fix(tui): preserved bottom anchor on ED3-risk autocomplete shrink

- Padded direct-input shrink frames to clear stale popup rows without re-exposing committed scrollback.
- Repainted live viewport when offscreen edits shift rows above the viewport top.
- Prevented diff-append from duplicating the prefix at the scrollback seam on transient UI growth.
- Added scroll-buffer assertions to the autocomplete viewport test.
This commit is contained in:
can1357
2026-06-06 14:35:03 +02:00
parent 2d0f62eb69
commit d6b40bf4f2
3 changed files with 77 additions and 26 deletions
+6 -1
View File
@@ -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.
+39 -25
View File
@@ -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" };
}
}
@@ -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);