From 0ba1424b6a488c8f4700b73a533c6aecb726e6f1 Mon Sep 17 00:00:00 2001 From: gaojin Date: Wed, 21 Oct 2026 20:51:56 +0800 Subject: [PATCH] fix(tui): keep scrollback when a settled frame changes rows above the viewport In regular mode the terminal owns scrolling, but a frame that changed a row above the viewport and also changed the document length replayed the whole document: the 2J/H/3J clear sequence erases scrollback, so a reader who had scrolled up was thrown back to the top as soon as generation settled. The settled-message frame is exactly that shape. With hideThinkingBlock enabled renderThinkingRows inserts a summary row and a blank row above the message, and the same frame usually turns the dock's status line into "Done" and updates the footer usage. Slide the window instead of replaying it. A length change above the viewport leaves the rows below it aligned: - no visible row changed: the frame writes nothing and only catches the bookkeeping up, moving the hardware cursor's document row with the window; - a few visible rows changed (the dock lines): the frame rewrites exactly those rows with absolute cursor moves, so nothing scrolls and scrollback survives; - the first visible row changed, the window moved as a whole, or rows entered or left it: the frame still replays the document, because the rows that left cannot be put into scrollback in order without writing them again. The in-place repair is bounded by IN_PLACE_WINDOW_EDIT_LIMIT and refuses a changed first row, which is how a slide looks; a misjudged frame therefore falls back to a replay instead of dropping rows. The full replay now runs the same over-wide line guard as the differential writer, so an over-wide frame fails loudly with tui-crash.log instead of silently wrapping. Trade-offs are documented in packages/tui/README.md and docs/tui-rendering-pipeline.md: rows changed above the window never reach terminal history, and a document that shrinks leaves its removed rows in scrollback. Tests cover the settle shape with a dock status change, a window that really moves, growth beyond one screen, a shrink below one screen, and the over-wide line. The regression test flushes the terminal before scrolling it, since scrollLines() used to move a buffer that did not hold the document yet. --- apps/cli/src/ui/view/chrome/step-logo.ts | 9 +- docs/tui-rendering-pipeline.md | 35 +-- packages/tui/README.md | 7 +- packages/tui/src/tui-main-screen.ts | 229 ++++++++++++++---- .../test/main-screen-offscreen-change.test.ts | 189 ++++++++++++++- packages/tui/test/virtual-terminal.ts | 13 + 6 files changed, 412 insertions(+), 70 deletions(-) diff --git a/apps/cli/src/ui/view/chrome/step-logo.ts b/apps/cli/src/ui/view/chrome/step-logo.ts index 5521b263..2766ec00 100644 --- a/apps/cli/src/ui/view/chrome/step-logo.ts +++ b/apps/cli/src/ui/view/chrome/step-logo.ts @@ -17,10 +17,11 @@ * too narrow for the sprite. * * Why the bird is terminal cells and not a Kitty/iTerm2 inline image: - * `scrollback-guard.ts` clips full redraws to the viewport, and an inline image - * makes pi-tui reserve extra rows with cursor moves so its output no longer maps - * one row per `\r\n`. Block glyphs are ordinary text cells and cost - * the differential renderer nothing. + * `TuiMainScreen` keeps scrollback when a change above the viewport leaves the + * visible rows unchanged, and an inline image makes + * pi-tui reserve extra rows with cursor moves so its output no longer maps one + * row per `\r\n`. Block glyphs are ordinary text cells and cost the differential + * renderer nothing. * * The CP437 mark's own design constraints (four glyphs, no SGR, why the axes * step differently) are documented inline at `renderStepMark` below — they are diff --git a/docs/tui-rendering-pipeline.md b/docs/tui-rendering-pipeline.md index b2479fd1..1c23e503 100644 --- a/docs/tui-rendering-pipeline.md +++ b/docs/tui-rendering-pipeline.md @@ -117,23 +117,28 @@ ## 5. 主屏差分渲染(regular) -`TuiMainScreen.doRender`(`tui-main-screen.ts:329-750`)每帧的完整顺序: - -1. 读取 `terminal.columns/rows`,判定宽/高变化与 overlay 开合(`tui-main-screen.ts:331-340`)。overlay 状态变化意味着上一帧镜像的是合成后的画面,前缀不可跨过这次转换复用(`resetFrom = 0`)。 -2. `Container.render(width)` 得到新行(`tui-main-screen.ts:353`);`renderDirtyStart` 经 `setIncrementalRenderDisabled()` 可强制为 0(`tui.ts:227-233`)。 -3. 有 overlay 时先 `compositeOverlays`(`tui.ts:1202-1261`):按 anchor/margin/百分比解析位置,把各 overlay 的行按列合成进基线,必要时补空行使 overlay 有屏幕相对坐标。 -4. 定位光标标记 `CURSOR_MARKER`(`\x1b_pi:c\x07`,`tui.ts:90`):焦点组件在光标处发出这个 APC 序列,渲染器找到后把它从行里剥掉,再用它定位硬件光标(IME 候选窗定位),`findCursorPosition`(`tui-main-screen.ts:250-260`)。 -5. 行归一化 `applyLineResetsFrom`(`tui-main-screen.ts:224-247`):从 `resetFrom` 起的行做 ANSI 归一化并追加 `SEGMENT_RESET`(`\x1b[0m\x1b]8;;\x07`,`tui.ts:353`),防止样式跨行泄漏;`resetFrom` 之前的行直接引用上一帧。首帧、宽高变化、overlay 切换都会把 `resetFrom` 压到 0。 -6. 全量重绘判定(`fullRender(clear)`,`tui-main-screen.ts:386-428`):首帧不擦屏直接写;宽度变化必擦(换行位置全变);高度变化必擦(Termux 软键盘场景例外,否则每次键盘弹出都重放整段历史,`tui-main-screen.ts:456-460`);内容收缩超过历史最高水位且无 overlay 时擦除空行(`clearOnShrink`,`tui.ts:513-515`、`tui-main-screen.ts:465-469`)。擦屏序列是 `\x1b[2J\x1b[H\x1b[3J`(含 scrollback)。 -7. 差分区间扫描(`tui-main-screen.ts:473-498`):从 `resetFrom` 扫到两帧最大行数,记 `firstChanged/lastChanged`;纯追加时区间锁在旧长度处(`appendStart` 快速路径)。区间经过 Kitty 图像块时向外扩展,避免从图像块中间重画(`tui-main-screen.ts:284-305`)。 -8. 视口夹取(`tui-main-screen.ts:568-589`):`firstChanged` 落在上一帧视口之上且文档长度没变——只有 scrollback 副本过期,屏幕上行没动——只重绘可见部分;任何长度变化都可能让行在视口下位移,仍走全量重绘。 -9. 写入(`tui-main-screen.ts:593-680`):`\x1b[?2026h` 开同步输出 → 视口滚动对齐(必要时 `\r\n` 推进滚动)→ CUU/CUD 移到首变行 → `\r` → 逐行 `\x1b[2K` + 行内容 → 尾部清理旧多出的行 → `\x1b[?2026l`。只写首变行到末变行,单行变化(spinner)不会闪烁全屏。 -10. 硬保险(`tui-main-screen.ts:651-678`):任何渲染行 `visibleWidth > width` 直接把全部行写进 `tui-crash.log`,清理终端状态后抛错——指向"组件没截断"这个根因。 -11. 更新镜像:`previousLines`、`hardwareCursorRow`、`previousViewportTop`、`previousKittyImageIds`、宽高(`tui-main-screen.ts:737-749`),并按需移动硬件光标(`positionHardwareCursor`,`tui-main-screen.ts:757-788`)。 +`TuiMainScreen.doRender`(`tui-main-screen.ts:323-778`)每帧的完整顺序: + +1. 读取 `terminal.columns/rows`,判定宽/高变化与 overlay 开合(`tui-main-screen.ts:325-337`)。overlay 状态变化意味着上一帧镜像的是合成后的画面,前缀不可跨过这次转换复用(`resetFrom = 0`)。 +2. `Container.render(width)` 得到新行(`tui-main-screen.ts:347`);`renderDirtyStart` 经 `setIncrementalRenderDisabled()` 可强制为 0(`tui.ts:227-233`)。 +3. 有 overlay 时先 `compositeOverlays`(`tui.ts:1213-1272`):按 anchor/margin/百分比解析位置,把各 overlay 的行按列合成进基线,必要时补空行使 overlay 有屏幕相对坐标。 +4. 定位光标标记 `CURSOR_MARKER`(`\x1b_pi:c\x07`,`tui.ts:91`):焦点组件在光标处发出这个 APC 序列,渲染器找到后把它从行里剥掉,再用它定位硬件光标(IME 候选窗定位),`findCursorPosition`(`tui-main-screen.ts:251-261`)。 +5. 行归一化 `applyLineResetsFrom`(`tui-main-screen.ts:225-248`):从 `resetFrom` 起的行做 ANSI 归一化并追加 `SEGMENT_RESET`(`\x1b[0m\x1b]8;;\x07`,`tui.ts:357`),防止样式跨行泄漏;`resetFrom` 之前的行直接引用上一帧。首帧、宽高变化、overlay 切换都会把 `resetFrom` 压到 0。 +6. 全量重绘判定(`fullRender(clear)`,`tui-main-screen.ts:387-430`):首帧不擦屏直接写;宽度变化必擦(换行位置全变);高度变化必擦(Termux 软键盘场景例外,否则每次键盘弹出都重放整段历史,`tui-main-screen.ts:478-482`);内容收缩超过历史最高水位且无 overlay 时擦除空行(`clearOnShrink`,`tui.ts:515-517`、`tui-main-screen.ts:486-490`)。擦屏序列是 `\x1b[2J\x1b[H\x1b[3J`(含 scrollback)。它除了用于上述尺寸或内容收缩场景,也是视口上方变化无法就地修补时的兜底:可见窗口与上一帧对不齐(行整体滑出)、图片跨过视口顶、删除行回退、差分时图片块超出屏幕,都会落回它。 +7. 差分区间扫描(`tui-main-screen.ts:493-518`):从 `resetFrom` 扫到两帧最大行数,记 `firstChanged/lastChanged`;纯追加时区间锁在旧长度处(`appendStart` 快速路径)。区间经过 Kitty 图像块时向外扩展,避免从图像块中间重画(`tui-main-screen.ts:285-306`)。 +8. 视口夹取(`tui-main-screen.ts:586-648`):`firstChanged` 落在上一帧视口之上时,屏幕上的行并没有动,只有 scrollback 副本过期。文档长度没变时只重绘可见部分,保留过期的 scrollback。**长度变了**则窗口平移 `shift` 行,按可见画面的变化分三种处理: + - 可见行内容完全相同(变化全在视口之上):整帧不写屏,只更新簿记,硬件光标的文档行随 `shift` 平移(`tui-main-screen.ts:617-635`),不写 `\x1b[2J`/`\x1b[3J`; + - 可见行有改动、且顶层那一行没变(`inPlaceWindowEdits`,`tui-main-screen.ts:763-782`):用绝对光标定位只重画那几行(`repaintVisibleRows`,`tui-main-screen.ts:436-461`),不滚动也不碰 scrollback。判据是"顶层行未变、且最多 `IN_PLACE_WINDOW_EDIT_LIMIT` 行不同",属于启发式:真实 settle 帧只改底部 1~2 行(状态行、footer),而窗口整体滑动或从顶层插删行时顶层必然变,会落到下一种; + - 其余情况(顶层变了、窗口整体移动、行被增删):退回 `fullRender(true)`,此时会写 `\x1b[2J`/`\x1b[3J`。 + 已知取舍(`README.md` 策略 3 同步说明):视口之上新增或改写的行不会进入终端历史(用户上滚看到的是旧版本,且插入的行缺失);文档收缩后旧行仍留在 scrollback,上滚会看到一段重复;开启 `clearOnShrink`、或"所有变化都在被删除的行"等回退路径仍会清 scrollback。 + +9. 写入(`tui-main-screen.ts:649-709`):`\x1b[?2026h` 开同步输出 → 视口滚动对齐(必要时 `\r\n` 推进滚动)→ CUU/CUD 移到首变行 → `\r` → 逐行 `\x1b[2K` + 行内容 → 尾部清理旧多出的行 → `\x1b[?2026l`。只写首变行到末变行,单行变化(spinner)不会闪烁全屏。 +10. 硬保险(`assertLineFitsWidth`,`tui-main-screen.ts:812-841`):差分写入、窗口重绘和 `fullRender` 都调用它,渲染行 `visibleWidth > width` 时把全部行写进 `tui-crash.log`,清理终端状态后抛错——指向"组件没截断"这个根因。 +11. 更新镜像:`previousLines`、`hardwareCursorRow`、`previousViewportTop`、`previousKittyImageIds`、宽高(`tui-main-screen.ts:740-750`),并按需移动硬件光标(`positionHardwareCursor`,`tui-main-screen.ts:848-879`)。文档长度变化但可见行未变的帧、以及就地修补的帧走同一套镜像更新(`tui-main-screen.ts:617-635`、`tui-main-screen.ts:451-461`),因此下一帧仍然对着正确的行窗口做差分。 输出统一经过 `BoundedTerminalWriter`(`tui-main-screen.ts:24-80`):按 1MiB 分块刷盘,超长串按代理对边界切开,避免整帧拼成一个超过 V8 字符串上限的大串。 -`stop()` 的收尾(`tui-main-screen.ts:194-202`):把光标推到内容末尾再换行,退出后主屏 scrollback 不残留半行。 +`stop()` 的收尾(`tui-main-screen.ts:188-196`):把光标推到内容末尾再换行,退出后主屏 scrollback 不残留半行。 ## 6. 全屏渲染(fullscreen) @@ -213,7 +218,7 @@ alt screen 进入时(`tui-alt-screen.ts:316-318`):`\x1b[?1049h` + 关 auto | 收缩清行 | `terminal.clearOnShrink` 设置项 → `ui.setClearOnShrink()`(`tui.ts:524-527`) | 内容收缩时是否清空多余行(默认 false,开启可能闪烁) | | ESC 超时 | 无开关,按传输自动判定(`terminal.ts:103-116`) | 非 SSH 10ms;检测到 `SSH_CONNECTION`/`SSH_TTY` 时 100ms | -唯一的落盘诊断是崩溃现场:任何渲染行 `visibleWidth > width` 时,全部行写入 `/tui-crash.log`(`tui-main-screen.ts:628-639`)。`logDirectory` 由 `TUI` 构造参数传入,缺省是 `os.tmpdir()` 下按需创建的 `tui-*` 私有临时目录(`tui.ts:479-484`)。 +唯一的落盘诊断是崩溃现场:任何渲染行 `visibleWidth > width` 时,全部行写入 `/tui-crash.log`(`tui-main-screen.ts:671-682`)。`logDirectory` 由 `TUI` 构造参数传入,缺省是 `os.tmpdir()` 下按需创建的 `tui-*` 私有临时目录(`tui.ts:479-484`)。 ## 9. 测试与验证 diff --git a/packages/tui/README.md b/packages/tui/README.md index 7188b8bb..e10fd45d 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -651,11 +651,12 @@ if (matchesKey(data, Key.enter)) { ## Rendering modes -`TuiMainScreen` uses three rendering strategies: +`TuiMainScreen` uses four rendering strategies: 1. **First Render**: Output all lines without clearing scrollback -2. **Width Changed or Change Above Viewport**: Clear screen and fully re-render -3. **Normal Update**: Move the cursor to the first changed line, clear to the end, and render changed lines +2. **Size Changed or Content Shrank**: Clear the screen and scrollback and replay the document, because every row moves +3. **Change Above Viewport**: Leave scrollback alone when a length change moved rows only above the window. Without a document-length change only the affected on-screen rows are repainted. With one, the first visible row still holds its content and at most a few rows below it differ (the settle frame only redraws the dock): nothing is written when they all match, otherwise exactly those rows are repainted in place. A different first row means the window slid and rows left the screen, so that frame replays the document. Two known trade-offs come with keeping scrollback: rows changed above the window never reach terminal history (it shows their old copy, and inserted rows are missing), and a document that shrinks leaves its removed rows in scrollback. +4. **Normal Update**: Move the cursor to the first changed line, clear to the end, and render changed lines `TuiAltScreen` owns a terminal-height viewport. Without an explicit layout root it preserves the legacy single-document scrolling behavior. With `setLayoutRoot()`, `VStack`, `HStack`, and nested `ScrollView` components can reserve fixed regions and independently scroll constrained regions. It updates changed viewport rows in place, follows streaming output while at the bottom, and preserves a manually selected scroll position while content grows. Mouse-wheel and configurable keyboard navigation scroll without modifying terminal scrollback, including jumps between OSC 133 semantic prompt markers. Clicking an OSC 8 hyperlink opens it with the configured URL handler. Dragging with the primary mouse button selects text and, unless `TuiAltScreenOptions.copyOnSelect` is `false`, copies it to the clipboard with OSC 52; holding the drag at a scroll view's top or bottom edge auto-scrolls and extends the selection into off-screen content. Kitty images support vertical viewport cropping; iTerm2 inline images fall back to text because the iTerm2 protocol cannot delete or crop placements during viewport repainting. diff --git a/packages/tui/src/tui-main-screen.ts b/packages/tui/src/tui-main-screen.ts index 61203cc9..ac3781f9 100644 --- a/packages/tui/src/tui-main-screen.ts +++ b/packages/tui/src/tui-main-screen.ts @@ -13,6 +13,13 @@ import { normalizeTerminalOutput, visibleWidth } from "./utils.ts"; const KITTY_SEQUENCE_PREFIX = "\x1b_G"; const MAX_RENDER_WRITE_CHARS = 1024 * 1024; +/** + * How many rows of the visible window a frame may rewrite in place when a length + * change happened entirely above it. Rows that still line up at the same offsets mean + * nothing slid off the screen; a slide misaligns almost every row, so a small budget + * separates an in-place edit (a status line turning into "Done") from a moved window. + */ +const IN_PLACE_WINDOW_EDIT_LIMIT = 3; /** * Streams terminal output in 1 MiB chunks so a full render never forms one string large enough to exceed V8's limit. @@ -400,6 +407,7 @@ export class TuiMainScreen extends TuiBase implements TUI { i += imageReservedRows - 1; continue; } + this.assertLineFitsWidth(line, i, newLines, width); output.append(line); } output.append("\x1b[?2026l"); // End synchronized output @@ -421,6 +429,37 @@ export class TuiMainScreen extends TuiBase implements TUI { this.previousHeight = height; }; + // Repair the rows a frame rewrote inside the visible window. A length change above + // the window moved it, but nothing slid off the screen, so each changed row is + // rewritten where it stands with an absolute cursor move - no scroll, no clearing + // of scrollback - and the reader keeps their place. + const repaintVisibleRows = (nextViewportTop: number, rows: number[]): void => { + for (const row of rows) { + const index = nextViewportTop + row; + this.assertLineFitsWidth(newLines[index] ?? "", index, newLines, width); + } + const output = new BoundedTerminalWriter((data) => this.terminal.write(data)); + output.append("\x1b[?2026h"); // Begin synchronized output + let lastRow = 0; + for (const row of rows) { + output.append(`\x1b[${row + 1};1H`); + output.append(`\x1b[2K${newLines[nextViewportTop + row] ?? ""}`); + lastRow = row; + } + output.append("\x1b[?2026l"); // End synchronized output + output.flush(); + this.cursorRow = Math.max(0, newLines.length - 1); + // The cursor stayed on the screen row of the last rewritten line. + this.hardwareCursorRow = nextViewportTop + lastRow; + this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length); + this.previousViewportTop = nextViewportTop; + this.positionHardwareCursor(cursorPos, newLines.length); + this.previousLines = newLines; + if (kittyScanEnabled) this.previousKittyImageIds = this.collectKittyImageIds(newLines); + this.previousWidth = width; + this.previousHeight = height; + }; + // First render - just output everything without clearing (assumes clean screen) if (this.previousLines.length === 0 && !widthChanged && !heightChanged) { fullRender(false); @@ -542,28 +581,69 @@ export class TuiMainScreen extends TuiBase implements TUI { // destroy and rebuild the user's scrollback several times a second. When the // document kept its length no row moved, so the visible window still maps to the // same terminal rows: keep the stale scrollback and repaint only what is visible. - // Any length change can shift rows under the viewport, so that still needs the - // full redraw. + // A length change moves that window, so the branches below slide it without + // clearing scrollback whenever the screen itself does not have to change. if (firstChanged < prevViewportTop) { - const documentLengthChanged = newLines.length !== this.previousLines.length; // A kitty image block reaching across the clamp point cannot be repainted from // its middle without corrupting it. - const straddlingImage = kittyScanEnabled && this.hasImageLineInRange(firstChanged, prevViewportTop, newLines); - if (documentLengthChanged || straddlingImage) { + if (kittyScanEnabled && this.hasImageLineInRange(firstChanged, prevViewportTop, newLines)) { fullRender(true); return; } - if (lastChanged < prevViewportTop) { - // Nothing on screen differs; only bookkeeping has to catch up. - this.positionHardwareCursor(cursorPos, newLines.length); - this.previousLines = newLines; - if (kittyScanEnabled) this.previousKittyImageIds = this.collectKittyImageIds(newLines); - this.previousWidth = width; - this.previousHeight = height; - this.previousViewportTop = prevViewportTop; + if (newLines.length === this.previousLines.length) { + if (lastChanged < prevViewportTop) { + // Nothing on screen differs; only bookkeeping has to catch up. + this.positionHardwareCursor(cursorPos, newLines.length); + this.previousLines = newLines; + if (kittyScanEnabled) this.previousKittyImageIds = this.collectKittyImageIds(newLines); + this.previousWidth = width; + this.previousHeight = height; + this.previousViewportTop = prevViewportTop; + return; + } + firstChanged = prevViewportTop; + } else { + // The document changed length, so the visible window maps to different rows. + // A change above the window slides it over the document without moving what is + // on screen: the reader keeps their place and scrollback survives. When the slide + // also rewrote a few visible rows, only those rows have to be repaired. + const bufferLength = Math.max(height, newLines.length); + const nextViewportTop = Math.max(0, bufferLength - height); + const shift = nextViewportTop - prevViewportTop; + if (shift !== 0) { + if (this.sameVisibleWindow(prevViewportTop, nextViewportTop, newLines, height)) { + // The visible rows kept their content, so the hardware cursor keeps its row + // on screen while its document line moves by `shift`. + this.cursorRow = Math.max(0, newLines.length - 1); + this.hardwareCursorRow = Math.max(0, hardwareCursorRow + shift); + this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length); + this.previousViewportTop = nextViewportTop; + this.positionHardwareCursor(cursorPos, newLines.length); + this.previousLines = newLines; + if (kittyScanEnabled) this.previousKittyImageIds = this.collectKittyImageIds(newLines); + this.previousWidth = width; + this.previousHeight = height; + return; + } + const editedRows = this.inPlaceWindowEdits(prevViewportTop, nextViewportTop, newLines, height); + const editedImage = + kittyScanEnabled && + editedRows !== null && + editedRows.some( + (row) => + isImageLine(newLines[nextViewportTop + row] ?? "") || + isImageLine(this.previousLines[prevViewportTop + row] ?? ""), + ); + if (editedRows !== null && !editedImage) { + repaintVisibleRows(nextViewportTop, editedRows); + return; + } + } + // Rows left the screen or the window moved as a whole: only a replay can put + // them into scrollback in the right order. + fullRender(true); return; } - firstChanged = prevViewportTop; } // Render from first changed line to end @@ -623,34 +703,7 @@ export class TuiMainScreen extends TuiBase implements TUI { } output.append("\x1b[2K"); // Clear current line - if (!isImage && visibleWidth(line) > width) { - // Log all lines to crash file for debugging - const crashLogPath = path.join(this.logDirectory, "tui-crash.log"); - const crashData = [ - `Crash at ${new Date().toISOString()}`, - `Terminal width: ${width}`, - `Line ${i} visible width: ${visibleWidth(line)}`, - "", - "=== All rendered lines ===", - ...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`), - "", - ].join("\n"); - fs.mkdirSync(path.dirname(crashLogPath), { recursive: true }); - fs.writeFileSync(crashLogPath, crashData); - - // Clean up terminal state before throwing - this.stop(); - - const errorMsg = [ - `Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`, - "", - "This is likely caused by a custom TUI component not truncating its output.", - "Use visibleWidth() to measure and truncateToWidth() to truncate lines.", - "", - `Debug log written to: ${crashLogPath}`, - ].join("\n"); - throw new Error(errorMsg); - } + this.assertLineFitsWidth(line, i, newLines, width); output.append(line); } @@ -695,6 +748,98 @@ export class TuiMainScreen extends TuiBase implements TUI { this.previousHeight = height; } + /** + * Rows of the visible window that a frame changed in place. A length change above the + * window shifts the window over the document but leaves the rows below it aligned: + * only the rows the frame edited differ. A slide misaligns nearly every row, so it + * exceeds the budget and is refused, which sends the caller back to a full replay. + * @param previousTop Window offset the previous frame was written at + * @param nextTop Window offset the new document is viewed at + * @param newLines Lines rendered for this frame + * @param height Terminal rows + * @returns Relative rows that differ, or null when more than + * {@link IN_PLACE_WINDOW_EDIT_LIMIT} rows differ + */ + private inPlaceWindowEdits( + previousTop: number, + nextTop: number, + newLines: string[], + height: number, + ): number[] | null { + const edited: number[] = []; + for (let row = 0; row < height; row++) { + const before = previousTop + row < this.previousLines.length ? this.previousLines[previousTop + row] : ""; + const after = nextTop + row < newLines.length ? newLines[nextTop + row] : ""; + if (before === after) continue; + // A different first row is how a window that slid by a row or two looks, and the row + // that left the screen is already gone: rewriting in place would erase it from the + // terminal as well. Only edits below the top row are safe to repair without a replay. + if (row === 0) return null; + edited.push(row); + if (edited.length > IN_PLACE_WINDOW_EDIT_LIMIT) return null; + } + return edited; + } + + /** + * Whether the window at `nextTop` shows the same rows as the window at + * `previousTop`. A length change above the viewport slides the window over the + * document without altering what is on screen, which is what lets the frame skip + * rewriting it entirely. + * @param previousTop Window offset the previous frame was written at + * @param nextTop Window offset the new document is viewed at + * @param newLines Lines rendered for this frame + * @param height Terminal rows + */ + private sameVisibleWindow(previousTop: number, nextTop: number, newLines: string[], height: number): boolean { + for (let row = 0; row < height; row++) { + const before = previousTop + row < this.previousLines.length ? this.previousLines[previousTop + row] : ""; + const after = nextTop + row < newLines.length ? newLines[nextTop + row] : ""; + if (before !== after) return false; + } + return true; + } + + /** + * Refuse a line the terminal would wrap. A wrap changes the row count under the + * renderer's feet, so it fails loudly with a crash log instead of corrupting the + * frame; the writers call this before emitting the line. + * @param line Rendered line, possibly carrying escape sequences + * @param index Document index of the line, for diagnostics + * @param lines Every rendered line of the frame, dumped to the crash log + * @param width Terminal columns + */ + private assertLineFitsWidth(line: string, index: number, lines: string[], width: number): void { + if (isImageLine(line) || visibleWidth(line) <= width) return; + + // Log all lines to crash file for debugging + const crashLogPath = path.join(this.logDirectory, "tui-crash.log"); + const crashData = [ + `Crash at ${new Date().toISOString()}`, + `Terminal width: ${width}`, + `Line ${index} visible width: ${visibleWidth(line)}`, + "", + "=== All rendered lines ===", + ...lines.map((rendered, renderedIndex) => `[${renderedIndex}] (w=${visibleWidth(rendered)}) ${rendered}`), + "", + ].join("\n"); + fs.mkdirSync(path.dirname(crashLogPath), { recursive: true }); + fs.writeFileSync(crashLogPath, crashData); + + // Clean up terminal state before throwing + this.stop(); + + const errorMsg = [ + `Rendered line ${index} exceeds terminal width (${visibleWidth(line)} > ${width}).`, + "", + "This is likely caused by a custom TUI component not truncating its output.", + "Use visibleWidth() to measure and truncateToWidth() to truncate lines.", + "", + `Debug log written to: ${crashLogPath}`, + ].join("\n"); + throw new Error(errorMsg); + } + /** * Position the hardware cursor for IME candidate window. * @param cursorPos The cursor position extracted from rendered output, or null diff --git a/packages/tui/test/main-screen-offscreen-change.test.ts b/packages/tui/test/main-screen-offscreen-change.test.ts index 04d99bd3..a8ddb704 100644 --- a/packages/tui/test/main-screen-offscreen-change.test.ts +++ b/packages/tui/test/main-screen-offscreen-change.test.ts @@ -122,19 +122,196 @@ describe("main screen changes above the viewport", () => { harness.tui.stop({ preserveScreen: true }); }); - it("falls back to a full redraw when the document length changes above the viewport", async () => { + it("keeps terminal scrollback and the reader's position when the document length changes above the viewport", async () => { const harness = createHarness(ROWS); + // The first frame's writes have to reach the terminal before it can be scrolled, + // or scrollLines() moves a buffer that does not hold the document yet. + await harness.terminal.flush(); const redrawsBefore = harness.tui.fullRedraws; + const bottom = harness.terminal.getViewportTop(); + // The reader scrolled the terminal viewport up before the next frame lands. + harness.terminal.scrollLines(-10); + await harness.terminal.flush(); + const viewportTopBefore = harness.terminal.getViewportTop(); + const viewportBefore = harness.terminal.getViewport(); + assert.strictEqual(viewportTopBefore, bottom - 10, "the reader must actually have scrolled up"); harness.resetWritten(); - // A transcript append shifts every row under it, so the visible window no longer - // maps to the same terminal rows and the clamp would leave the screen stale. - harness.transcript.lines = [...harness.transcript.lines, "transcript appended"]; + // The shape of a settled assistant message reflowing when generation completes: + // an off-screen row is rewritten and the document grows by one line. + harness.transcript.lines = [ + ...harness.transcript.lines.slice(0, 5), + "settled row", + ...harness.transcript.lines.slice(5), + ]; harness.tui.renderNow(); + await harness.terminal.flush(); + + assert.strictEqual(harness.tui.fullRedraws, redrawsBefore, "must not replay the document"); + assert.ok(!harness.written().includes(CLEAR_SCROLLBACK), "must not clear terminal scrollback"); + assert.strictEqual(harness.terminal.getViewportTop(), viewportTopBefore, "viewport must not move"); + assert.deepStrictEqual(harness.terminal.getViewport(), viewportBefore, "visible rows must not change"); + // The frame wrote nothing, so the mirror is the only record of the new document: + // a stale one corrupts the next incremental frame even when the screen looks right. + const mirror = harness.tui as unknown as { previousViewportTop: number; previousLines: string[] }; + const documentLength = + harness.transcript.lines.length + harness.status.lines.length + harness.dialog.lines.length + 1; + assert.strictEqual(mirror.previousLines.length, documentLength, "the mirror must hold the new document"); + assert.ok( + mirror.previousLines.some((line) => line.includes("settled row")), + "the mirror must contain the inserted row", + ); + assert.strictEqual(mirror.previousViewportTop, documentLength - ROWS, "the mirror must track the new window"); + + // The bookkeeping the skipped frame left behind has to describe the new document: + // the next ordinary edit must reach its row without replaying the document. + harness.resetWritten(); + harness.dialog.lines = [...harness.dialog.lines.slice(0, 2), "dialog edited", ...harness.dialog.lines.slice(3)]; + harness.tui.renderNow(); + harness.terminal.scrollLines(200); + const screen = await harness.terminal.flushAndGetViewport(); + assert.strictEqual(harness.tui.fullRedraws, redrawsBefore, "the edit must stay on the differential path"); + assert.ok(!harness.written().includes(CLEAR_SCROLLBACK), "the edit must not clear terminal scrollback"); + assert.ok(screen.includes("dialog edited"), "the edit after the skipped frame must reach its row"); + assert.ok(!screen.includes("dialog 2"), "the replaced row must not survive"); + harness.tui.stop({ preserveScreen: true }); + }); + + it("repairs the rows a settle frame changed instead of replaying the document", async () => { + // The real dock is a handful of rows, so the status line sits inside the viewport. + const harness = createHarness(5); + await harness.terminal.flush(); + const redrawsBefore = harness.tui.fullRedraws; + harness.terminal.scrollLines(-5); + await harness.terminal.flush(); + const viewportTopBefore = harness.terminal.getViewportTop(); + harness.resetWritten(); + + // The settled-message shape: a summary row is inserted above the viewport in the same + // frame that the dock's status line turns into "Done". Only that visible row changed, + // so it is repaired in place and scrollback survives. + harness.transcript.lines = [ + ...harness.transcript.lines.slice(0, 5), + "summary row", + ...harness.transcript.lines.slice(5), + ]; + harness.status.lines = ["Done"]; + harness.tui.renderNow(); + await harness.terminal.flush(); + + assert.strictEqual(harness.tui.fullRedraws, redrawsBefore, "must not replay the document"); + assert.ok(!harness.written().includes(CLEAR_SCROLLBACK), "must not clear terminal scrollback"); + assert.strictEqual(harness.terminal.getViewportTop(), viewportTopBefore, "viewport must not move"); + const screen = await harness.terminal.flushAndGetViewport(); + assert.ok(screen.includes("Done"), "the changed row must be repainted"); + assert.ok(!screen.includes("spinner 0"), "the previous row content must be gone"); + // The repaired frame updated the mirror, so the next ordinary edit still lands on its + // row instead of being papered over by another replay. + const mirror = harness.tui as unknown as { previousViewportTop: number; previousLines: string[] }; + const documentLength = + harness.transcript.lines.length + harness.status.lines.length + harness.dialog.lines.length + 1; + assert.strictEqual(mirror.previousLines.length, documentLength, "the mirror must hold the new document"); + assert.strictEqual(mirror.previousViewportTop, documentLength - ROWS, "the mirror must track the new window"); + harness.resetWritten(); + harness.dialog.lines = [...harness.dialog.lines.slice(0, 3), "dialog later", ...harness.dialog.lines.slice(4)]; + harness.tui.renderNow(); + harness.terminal.scrollLines(200); + const afterEdit = await harness.terminal.flushAndGetViewport(); + assert.strictEqual(harness.tui.fullRedraws, redrawsBefore, "the later edit must stay on the differential path"); + assert.ok(!harness.written().includes(CLEAR_SCROLLBACK), "the later edit must not clear terminal scrollback"); + assert.deepStrictEqual( + afterEdit, + [...harness.transcript.lines, ...harness.status.lines, ...harness.dialog.lines, "footer"].slice(-ROWS), + "the screen must match the document after the later edit", + ); + harness.tui.stop({ preserveScreen: true }); + }); + + it("replays the document when the length change also alters the visible window", async () => { + const harness = createHarness(ROWS); + await harness.terminal.flush(); + const redrawsBefore = harness.tui.fullRedraws; + harness.terminal.scrollLines(-5); + await harness.terminal.flush(); + harness.resetWritten(); + + // The window itself changes, so the frame cannot be caught up by bookkeeping alone: + // it replays the document, and every row still has to be reachable afterwards. + harness.status.lines = ["spinner 1"]; + harness.dialog.lines = [...harness.dialog.lines, "dialog grown a", "dialog grown b", "dialog grown c"]; + harness.tui.renderNow(); + await harness.terminal.flush(); + + assert.ok(harness.tui.fullRedraws > redrawsBefore, "a changed window must replay the document"); + const rowsAfter = harness.terminal.getScrollBuffer().filter((row) => row.length > 0); + for (const row of [...harness.transcript.lines, ...harness.status.lines, ...harness.dialog.lines, "footer"]) { + assert.ok(rowsAfter.includes(row), `document row ${JSON.stringify(row)} must not be dropped`); + } + assert.strictEqual(new Set(rowsAfter).size, rowsAfter.length, "no row may be duplicated"); + harness.tui.stop({ preserveScreen: true }); + }); + + it("does not drop rows when the document grows by a whole screen in one frame", async () => { + const harness = createHarness(ROWS); + await harness.terminal.flush(); + const redrawsBefore = harness.tui.fullRedraws; + harness.resetWritten(); + + // More rows leave the screen than the screen holds, so no scroll can move them into + // scrollback one by one: losing no document row wins over keeping the position. + const grown = Array.from({ length: ROWS + 5 }, (_, index) => `grown ${index}`); + harness.status.lines = ["spinner 1"]; + harness.dialog.lines = [...harness.dialog.lines, ...grown]; + harness.tui.renderNow(); + await harness.terminal.flush(); + + assert.ok(harness.tui.fullRedraws > redrawsBefore, "a whole-screen change must replay the document"); + const rowsAfter = harness.terminal.getScrollBuffer().filter((row) => row.length > 0); + for (const row of [...harness.transcript.lines, ...harness.status.lines, ...harness.dialog.lines, "footer"]) { + assert.ok(rowsAfter.includes(row), `document row ${JSON.stringify(row)} must not be dropped`); + } + harness.tui.stop({ preserveScreen: true }); + }); + + it("clears stale rows when a settled document shrinks below one screen", async () => { + const harness = createHarness(ROWS); + await harness.terminal.flush(); + // Generation settles into a document shorter than the terminal: everything moves + // up, and only the rows still on screen may remain. + harness.transcript.lines = Array.from({ length: 3 }, (_, index) => `transcript ${index}`); + harness.dialog.lines = Array.from({ length: 3 }, (_, index) => `dialog ${index}`); + harness.tui.renderNow(); + await harness.terminal.flush(); + + harness.transcript.lines = ["transcript 0 edited", "transcript 1", "transcript 2"]; + harness.tui.renderNow(); + await harness.terminal.flush(); - assert.strictEqual(harness.tui.fullRedraws, redrawsBefore + 1); const viewport = await harness.terminal.flushAndGetViewport(); - assert.strictEqual(viewport[viewport.length - 1], "footer"); + assert.deepStrictEqual( + viewport.filter((row) => row.length > 0), + [ + "transcript 0 edited", + "transcript 1", + "transcript 2", + "spinner 0", + "dialog 0", + "dialog 1", + "dialog 2", + "footer", + ], + "the screen must match the shrunken document", + ); + harness.tui.stop({ preserveScreen: true }); + }); + + it("refuses an over-wide line instead of letting the repaint wrap it", async () => { + const harness = createHarness(ROWS); + await harness.terminal.flush(); + harness.status.lines = ["spinner 1"]; + harness.dialog.lines = [...harness.dialog.lines, "x".repeat(COLS + 1)]; + + assert.throws(() => harness.tui.renderNow(), /exceeds terminal width/); harness.tui.stop({ preserveScreen: true }); }); }); diff --git a/packages/tui/test/virtual-terminal.ts b/packages/tui/test/virtual-terminal.ts index 4e067f4e..6c7c7dd7 100644 --- a/packages/tui/test/virtual-terminal.ts +++ b/packages/tui/test/virtual-terminal.ts @@ -113,6 +113,19 @@ export class VirtualTerminal implements Terminal { } } + /** + * Simulate scrolling the terminal's own viewport (negative scrolls up into + * scrollback), standing in for a user reading history while output continues. + */ + scrollLines(lines: number): void { + this.xterm.scrollLines(lines); + } + + /** Absolute buffer row currently mapped to the top of the visible viewport. */ + getViewportTop(): number { + return this.xterm.buffer.active.viewportY; + } + /** * Resize the terminal */