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