diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 6539de568..8f72fa003 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -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(":") ); } diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 81475bf4f..d32b3081f 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 1e419110a..f8fab21f8 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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[], diff --git a/packages/tui/test/render-regressions.test.ts b/packages/tui/test/render-regressions.test.ts index 53e1e0b7f..e0e55d3b9 100644 --- a/packages/tui/test/render-regressions.test.ts +++ b/packages/tui/test/render-regressions.test.ts @@ -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);