Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/tui-rendering-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@
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 副本过期,屏幕上行没动——只重绘可见部分;任何长度变化都可能让行在视口下位移,仍走全量重绘。
8. 视口夹取(`tui-main-screen.ts:568-589`):`firstChanged` 落在上一帧视口之上时,只有 scrollback 副本过期,把起点夹到视口顶只重绘可见部分。文档长度不变时屏幕上行没动;文档变长时重写可见窗口产生的 `\r\n` 数等于追加行数,终端滚动的距离与普通追加相同,视口依然对齐——流式输出时视口上方的 spinner 行每帧都在变,若在这里升级为全量重绘,就会每帧清屏并清空 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`)。
Expand Down
14 changes: 10 additions & 4 deletions packages/tui/src/tui-main-screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,14 +542,20 @@ 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.
//
// Growth keeps that mapping too. The tail moved down by the appended line count, and
// rewriting the visible window feeds exactly that many line feeds through the bottom
// row, so the terminal scrolls by the same amount a plain append would. Escalating a
// growing document to a full redraw here would clear and rewrite the scrollback on
// every streamed frame - the case that made history vanish in fullscreen terminals.
// A shrink is different: rows left the document and the terminal cannot un-scroll,
// so the rows above the clamp point would stay stale; that still needs the redraw.
if (firstChanged < prevViewportTop) {
const documentLengthChanged = newLines.length !== this.previousLines.length;
const documentShrank = 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 (documentShrank || straddlingImage) {
fullRender(true);
return;
}
Expand Down
49 changes: 46 additions & 3 deletions packages/tui/test/main-screen-offscreen-change.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,16 +122,59 @@ 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("repaints the visible rows when the document grows above the viewport", async () => {
const harness = createHarness(ROWS);
const redrawsBefore = harness.tui.fullRedraws;
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.
// A transcript append shifts every row under it. The terminal scrolls by the
// appended line count while the visible window is rewritten, so the screen stays
// aligned without a full redraw.
harness.transcript.lines = [...harness.transcript.lines, "transcript appended"];
harness.tui.renderNow();

assert.strictEqual(harness.tui.fullRedraws, redrawsBefore);
assert.ok(!harness.written().includes(CLEAR_SCROLLBACK));
const viewport = await harness.terminal.flushAndGetViewport();
assert.strictEqual(viewport[viewport.length - 1], "footer");
harness.tui.stop({ preserveScreen: true });
});

it("keeps streaming growth above the viewport out of the full-redraw path", async () => {
// The real streaming shape: a running tool row above the viewport ticks at 5 Hz
// while the assistant message appends tokens every frame.
const harness = createHarness(ROWS);
const redrawsBefore = harness.tui.fullRedraws;
harness.resetWritten();

for (let tick = 0; tick < 40; tick++) {
harness.status.lines = [`spinner ${tick}`];
harness.transcript.lines = [...harness.transcript.lines, `token ${tick}`];
harness.tui.renderNow();
}

assert.strictEqual(harness.tui.fullRedraws, redrawsBefore);
assert.ok(!harness.written().includes(CLEAR_SCROLLBACK));
const viewport = await harness.terminal.flushAndGetViewport();
assert.strictEqual(viewport[viewport.length - 1], "footer");
const scrollback = harness.terminal.getScrollBuffer();
assert.ok(
scrollback.some((line) => line.trimEnd() === "transcript 0"),
"history stays in scrollback",
);
harness.tui.stop({ preserveScreen: true });
});

it("falls back to a full redraw when the document shrinks above the viewport", async () => {
const harness = createHarness(ROWS);
const redrawsBefore = harness.tui.fullRedraws;
harness.resetWritten();

// Rows left the document and the terminal cannot un-scroll, so the clamp point
// would leave the rows above it stale.
harness.transcript.lines = harness.transcript.lines.slice(0, -1);
harness.tui.renderNow();

assert.strictEqual(harness.tui.fullRedraws, redrawsBefore + 1);
const viewport = await harness.terminal.flushAndGetViewport();
assert.strictEqual(viewport[viewport.length - 1], "footer");
Expand Down
Loading