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