fix(tui): added a historyRebuild render intent to clear

- Added a `historyRebuild` render intent to clear viewport and scrollback and emit a full repaint when geometry change invalidated terminal history.
- Updated render planning so width and height changes now rebuild native history immediately, while non-size content-only shrink updates only repaint the viewport to avoid yanking users in existing scrollback.
This commit is contained in:
can1357
2026-05-29 12:42:43 +02:00
parent 7b1bcfd13d
commit 729836f226
4 changed files with 70 additions and 23 deletions
@@ -97,10 +97,7 @@ function normalizeMistralToolId(id: string, isMistral: boolean): string {
// fail before streaming. `:tools` selects its documented tools-capable route.
function shouldUseNanoGptToolsRoute(model: Model<"openai-completions">, context: Context): boolean {
return (
model.provider === "nanogpt" &&
!!context.tools?.length &&
/deepseek/i.test(model.id) &&
!model.id.includes(":")
model.provider === "nanogpt" && !!context.tools?.length && /deepseek/i.test(model.id) && !model.id.includes(":")
);
}
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed terminal resizes corrupting native scrollback with duplicated rows. The 15.4.0 change that defers a destructive scrollback clear+replay (so a user scrolled into history is not yanked while a streaming tail cell mutates) also caught genuine width/height resizes: a resize reflows the terminal's own committed scrollback at the new geometry, but repainting only the viewport left the stale old-size rows in history, so every overflowed row showed up twice (old-size wrap + new-size copy) when scrolling back, until the next prompt submit cleaned it up. `#planRender` now rebuilds history synchronously when the frame's geometry actually changed (`widthChanged || heightChanged`) via the restored `historyRebuild` intent, and defers the rebuild only for pure content mutations where the user may be reading scrollback mid-stream.
## [15.5.0] - 2026-05-26
### Fixed
+28 -19
View File
@@ -236,6 +236,9 @@ export class Container implements Component {
* - `initial`: first paint after `start()` — clear viewport, emit transcript.
* - `sessionReplace`: caller asked for `{ clearScrollback: true }` on a forced
* render — clear viewport, clear scrollback (outside multiplexers).
* - `historyRebuild`: a geometry change (terminal resize) left native history
* wrapped at the old size — clear viewport and scrollback so it rewraps at the
* new geometry. Also flushes deferred content-only rewrites.
* - `viewportRepaint`: rewrite the visible viewport in place. If `appendFrom`
* is set, emit those tail rows as scrollback growth first so streaming
* output reaches terminal history before the corrected viewport is drawn.
@@ -246,6 +249,7 @@ type RenderIntent =
| { kind: "noop" }
| { kind: "initial" }
| { kind: "sessionReplace" }
| { kind: "historyRebuild" }
| { kind: "viewportRepaint"; appendFrom?: number }
| { kind: "shrink" }
| { kind: "diff"; firstChanged: number; lastChanged: number; appendedLines: boolean };
@@ -1133,6 +1137,13 @@ export class TUI extends Container {
clearScrollback: !isMultiplexerSession(),
});
return;
case "historyRebuild":
this.#nativeScrollbackDirty = false;
this.#emitFullPaint(lines, width, height, cursorPos, {
clearViewport: true,
clearScrollback: !isMultiplexerSession(),
});
return;
case "viewportRepaint":
if (intent.appendFrom !== undefined) {
this.#emitAppendTail(lines, intent.appendFrom, height, prevViewportTop, prevHardwareCursorRow);
@@ -1160,10 +1171,11 @@ export class TUI extends Container {
/**
* Map the current frame onto a single render intent. Order matters: forced
* resets and session replacement short-circuit before any diff work. Frames
* that would require rewriting native scrollback mark it dirty and repaint
* the viewport instead; the destructive clear+replay is deferred to an
* explicit checkpoint.
* resets and session replacement short-circuit before any diff work. A real
* resize (geometry change) that invalidates native scrollback rebuilds it now;
* a pure content mutation that does the same marks scrollback dirty and
* repaints only the viewport, deferring the destructive clear+replay to an
* explicit checkpoint so users scrolled into history are not yanked.
*/
#planRender(
newLines: string[],
@@ -1187,11 +1199,11 @@ export class TUI extends Container {
const diff = this.#diffLines(newLines);
// Shrink-across-viewport-boundary: if a shrink would place the new
// viewport above rows already committed to terminal scrollback, those
// rows can become stale or duplicated in native history. Preserve native
// scrollback for users reading it now, and defer the destructive
// clear+replay to the next checkpoint.
// Shrink across the viewport boundary: the new transcript would re-expose
// rows already committed to native scrollback. A real resize already
// reflowed history, so rebuild it now; a pure content shrink (e.g. a
// streaming tail cell collapsing) defers the clear+replay so a user
// scrolled into history is not yanked to the bottom mid-stream.
const naturalViewportTop = Math.max(0, newLines.length - height);
if (
diff.firstChanged !== -1 &&
@@ -1199,6 +1211,7 @@ export class TUI extends Container {
naturalViewportTop < this.#scrollbackHighWater &&
!isMultiplexerSession()
) {
if (widthChanged || heightChanged) return { kind: "historyRebuild" };
this.#nativeScrollbackDirty = true;
return { kind: "viewportRepaint" };
}
@@ -1223,16 +1236,12 @@ export class TUI extends Container {
return { kind: "noop" };
}
// Width changes alter wrapping for the whole transcript. Offscreen edits
// make native history stale at the old geometry; mark it dirty and repaint
// only the viewport so users scrolled into history are not yanked mid-stream.
// Pure appends fall through to the diff path so the append handler scrolls
// them into history correctly.
// Width changes rewrap the whole transcript. An offscreen edit leaves
// native history at the old width, so rebuild it now — the terminal already
// reflowed and the user is at the terminal to resize. Pure appends fall
// through to the diff path so the append handler scrolls them into history.
if (widthChanged) {
if (diff.firstChanged < prevViewportTop) {
this.#nativeScrollbackDirty = true;
return { kind: "viewportRepaint" };
}
if (diff.firstChanged < prevViewportTop) return { kind: "historyRebuild" };
const pureAppend = diff.appendedLines && diff.firstChanged === this.#previousLines.length;
if (!pureAppend) return { kind: "viewportRepaint" };
}
@@ -1357,7 +1366,7 @@ export class TUI extends Container {
/**
* Clear the viewport (optionally scrollback) and emit the full transcript.
* Backs `initial` and `sessionReplace` intents.
* Backs `initial`, `sessionReplace`, and `historyRebuild` intents.
*/
#emitFullPaint(
lines: string[],
@@ -733,6 +733,43 @@ describe("TUI terminal-state regressions", () => {
}
}, 15_000);
it("rebuilds native scrollback on a width resize without duplicating rows", async () => {
// A width resize makes the terminal reflow its own committed scrollback
// at the new size. Repainting only the viewport leaves those stale
// old-width rows in history, so overflowed rows show up twice (old-width
// wrap + new-width copy) when the user scrolls back. A real resize must
// rebuild history synchronously, unlike a pure content mutation which is
// deferred to the next checkpoint.
const term = new VirtualTerminal(32, 5);
const tui = new TUI(term);
// Rows wider than the post-resize width so the committed scrollback
// reflows (wraps) at the narrower size; short rows would not regress.
const filler = "x".repeat(24);
const component = new MutableLinesComponent(
Array.from({ length: 12 }, (_v, i) => `line-${i}-${filler}`),
);
tui.addChild(component);
try {
tui.start();
await settle(term);
// User sits at the bottom (not scrolled) and narrows the terminal.
term.resize(28, 5);
await settle(term);
const scrollback = term.getScrollBuffer();
for (let i = 0; i < 12; i++) {
const pattern = new RegExp(`\\bline-${i}\\b`);
expect(countMatches(scrollback, pattern), `line-${i} should appear once after resize`).toBe(1);
}
// The resize rebuilt history in place; nothing is left deferred.
expect(tui.refreshNativeScrollbackIfDirty()).toBe(false);
} finally {
tui.stop();
}
});
it("keeps viewport aligned when offscreen header changes during overflow growth", async () => {
const term = new VirtualTerminal(32, 6);
const tui = new TUI(term);