fix(pi-tui): repaint viewport in place when content collapses above it (#1315)

* fix(pi-tui): repaint viewport in place when content collapses above it

When content shrinks past the viewport top while a line above the
viewport also changes, the clamped differential path left the render
loop empty, cleared deleted lines past the screen bottom, and desynced
the cursor anchor — leaving the viewport blank with the input box gone
until a full redraw.

Repaint the visible viewport in place for that case (no ESC[3J, so the
scrollback and the user's scroll position are preserved), and clamp
deleted-line clearing to the screen bottom so it can never scroll
untracked.

* fix(kimi-code): clear the screen fully on session reset

The collapse repaint in pi-tui intentionally preserves scrollback, so
/new, /clear, and session switches no longer got a clean screen as a
side effect of the destructive full redraw — the previous session's
text stayed above the welcome banner.

Session resets want a pristine screen, so force a destructive full
render explicitly instead of relying on the renderer's shrink
behavior.

* fix(pi-tui): delete kitty images straddling the viewport top on collapse repaint

A multi-row kitty image can start above prevViewportTop while its
reserved rows are still visible. The collapse repaint's image-delete
range started at prevViewportTop and missed the image line carrying the
id, leaving a stale overlay that also dropped out of
previousKittyImageIds tracking. Widen the range to include such a
straddling block.

* chore: shorten session reset changeset wording

* fix(pi-tui): re-anchor the viewport whenever content shrinks below the screen bottom

previousViewportTop only ever grows during normal rendering, so after a
shrink the content bottom could hover above the screen bottom, leaving
dead rows that nothing repaints. Upstream masked this by frequently
doing destructive full redraws, which re-anchored as a side effect; the
fork removed those redraws without replacing the re-anchoring.

Generalize the collapse repaint into a re-anchor check at the top of
the differential path: whenever prevViewportTop exceeds
max(0, newLines - height), repaint the visible viewport in place with
the tail of the new content. The input area snaps back to the screen
bottom, scrollback and the user's scroll position stay intact (no
ESC[3J), and the previous collapse branch becomes a defensive
destructive fallback.

Update the three renderer tests that encoded the old behavior
(destructive redraw on shrink, viewport hover after clamped shrink) to
assert the re-anchored behavior instead.

* fix(kimi-code): full repaint on ctrl+o expansion toggle

Expanding tool output shifts content above the viewport; the clamped
differential render paints the shifted content through the screen,
stacking a duplicate copy below the stale one in scrollback on every
toggle. The toggle is a deliberate user action (like /clear), so do a
destructive full render instead: scrollback holds exactly one copy and
the expanded output stays readable by scrolling up.

* chore: simplify user-facing changeset wording
This commit is contained in:
liruifengv 2026-07-02 20:52:37 +08:00 committed by GitHub
parent e8ab7ca786
commit b40bb71399
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 362 additions and 18 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix TUI rendering bugs that caused the screen to go blank and the input box to disappear.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Clear the screen fully when starting a new session via /new, /clear, or a session switch.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/pi-tui": patch
---
Re-anchor the viewport with an in-place repaint whenever content shrinks below the screen bottom, and clamp deleted-line clearing to the screen bottom, so large shrinks no longer blank the screen, desync the cursor, or leave the UI hovering above dead rows.

View file

@ -1905,6 +1905,11 @@ export class KimiTUI {
this.state.todoPanelContainer.clear();
this.imageStore.clear();
this.renderWelcome();
// Session resets (/new, /clear, session switch) want a pristine screen.
// Force a destructive full render: the renderer's collapse repaint
// intentionally preserves scrollback, which would leave the previous
// session's text above the welcome banner.
this.state.ui.requestRender(true);
}
private isTurnBoundaryComponent(child: Component): boolean {
@ -2350,7 +2355,12 @@ export class KimiTUI {
if (!isExpandable(child)) continue;
child.setExpanded(this.state.toolOutputExpanded && i >= expandCutoff);
}
this.state.ui.requestRender();
// Expanding/collapsing shifts content above the viewport; the clamped
// differential render would paint a second copy below the stale one in
// scrollback. This is a deliberate user action (like /clear), so do a
// destructive full render: scrollback holds exactly one copy and the
// expanded output can be read by scrolling up.
this.state.ui.requestRender(true);
}
toggleTodoPanelExpansion(): void {

View file

@ -1425,6 +1425,80 @@ export class TUI extends Container {
return;
}
// Re-anchor the viewport when content shrinks. previousViewportTop
// only ever grows during normal rendering, so after a shrink the
// content bottom can sit above the screen bottom, leaving dead rows
// that nothing repaints (upstream masked this by frequently doing
// destructive full redraws, which re-anchored as a side effect).
// Repaint the visible viewport in place with the tail of the new
// content: the input area snaps back to the screen bottom (or the
// content top-anchors when shorter than the screen), while scrollback
// and the user's scroll position stay intact (no ESC[3J).
const desiredViewportTop = Math.max(0, newLines.length - height);
if (prevViewportTop > desiredViewportTop) {
// Kitty image lines need multi-row placement; fall back to a
// destructive full render for that rare combination.
for (let r = 0; r < height; r++) {
const idx = desiredViewportTop + r;
if (idx < newLines.length && isImageLine(newLines[idx]!)) {
logRedraw(`viewport repaint hit kitty image at line ${idx}`);
fullRender(true);
return;
}
}
logRedraw(
`viewport repaint: re-anchor after shrink (viewportTop ${prevViewportTop} -> ${desiredViewportTop}, newLines=${newLines.length})`,
);
let repaint = "\x1b[?2026h"; // Begin synchronized output
// Delete kitty images placed in the old visible viewport; images
// above it live in scrollback and are left alone. A multi-row
// image can straddle the viewport top: its id lives on the image
// line above prevViewportTop while its reserved rows are still
// visible, so widen the range to include such blocks — otherwise
// the stale overlay would survive the repaint and its id would
// drop out of previousKittyImageIds, never to be cleaned up.
let deleteFrom = prevViewportTop;
for (let k = 0; k < prevViewportTop; k++) {
const prevLine = this.previousLines[k] ?? "";
if (!isImageLine(prevLine)) continue;
const blockEnd = k + this.getKittyImageReservedRows(this.previousLines, k) - 1;
if (blockEnd >= prevViewportTop) {
deleteFrom = k;
break;
}
}
repaint += this.deleteChangedKittyImages(deleteFrom, this.previousLines.length - 1);
// Move the cursor to the top of the screen (old anchor still valid).
const cursorScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
if (cursorScreenRow > 0) {
repaint += `\x1b[${cursorScreenRow}A`;
}
repaint += "\r";
// Rewrite every screen row; the last row gets no trailing \r\n,
// so this never scrolls the terminal.
for (let r = 0; r < height; r++) {
if (r > 0) repaint += "\r\n";
repaint += "\x1b[2K";
const idx = desiredViewportTop + r;
if (idx < newLines.length) {
repaint += newLines[idx]!;
}
}
repaint += "\x1b[?2026l"; // End synchronized output
this.terminal.write(repaint);
// Re-anchor: screen row r now shows newLines[desiredViewportTop + r].
this.cursorRow = Math.max(0, newLines.length - 1);
this.hardwareCursorRow = desiredViewportTop + height - 1;
this.previousViewportTop = desiredViewportTop;
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
this.previousWidth = width;
this.previousHeight = height;
return;
}
// All changes are in deleted lines (nothing to render, just clear)
if (firstChanged >= newLines.length) {
if (this.previousLines.length > newLines.length) {
@ -1501,6 +1575,19 @@ export class TUI extends Container {
this.previousViewportTop = prevViewportTop;
return;
}
// The remaining visible changes would be entirely in the deleted
// tail region. The shrink re-anchor repaint above handles every
// case where the content bottom moves off the screen bottom, so
// this should be unreachable; keep a destructive fallback rather
// than let the differential path below desync on an empty render
// range (empty loop + untracked tail-clear scrolling).
if (visibleFirstChanged >= newLines.length) {
logRedraw(
`unexpected deleted-tail clamp (visibleFirstChanged=${visibleFirstChanged} >= ${newLines.length})`,
);
fullRender(true);
return;
}
logRedraw(`clamped firstChanged ${firstChanged} -> ${visibleFirstChanged} (viewportTop=${prevViewportTop})`);
firstChanged = visibleFirstChanged;
}
@ -1578,12 +1665,20 @@ export class TUI extends Container {
buffer += `\x1b[${moveDown}B`;
finalCursorRow = newLines.length - 1;
}
// Only clear what is actually on screen: writing \r\n past the
// bottom row would scroll the terminal without updating
// viewportTop/hardwareCursorRow, desyncing every later render.
// Rows below the screen bottom are invisible and need no clearing.
const extraLines = this.previousLines.length - newLines.length;
for (let i = newLines.length; i < this.previousLines.length; i++) {
const rowsBelowCursor = height - 1 - (finalCursorRow - viewportTop);
const clearLines = Math.max(0, Math.min(extraLines, rowsBelowCursor));
for (let i = 0; i < clearLines; i++) {
buffer += "\r\n\x1b[2K";
}
// Move cursor back to end of new content
buffer += `\x1b[${extraLines}A`;
if (clearLines > 0) {
buffer += `\x1b[${clearLines}A`;
}
}
buffer += "\x1b[?2026l"; // End synchronized output

View file

@ -658,7 +658,7 @@ describe("TUI differential rendering", () => {
tui.stop();
});
it("full re-renders when deleted lines move the viewport upward", async () => {
it("re-anchors without a full redraw when deleted lines move the viewport upward", async () => {
const terminal = new VirtualTerminal(20, 5);
const tui = new TUI(terminal);
const component = new TestComponent();
@ -674,7 +674,7 @@ describe("TUI differential rendering", () => {
tui.requestRender();
await terminal.waitForRender();
assert.ok(tui.fullRedraws > initialRedraws, "Shrink should trigger a full redraw");
assert.strictEqual(tui.fullRedraws, initialRedraws, "Shrink should re-anchor in place, not full-redraw");
assert.deepStrictEqual(terminal.getViewport(), ["Line 2", "Line 3", "Line 4", "Line 5", "Line 6"]);
tui.stop();
@ -696,7 +696,8 @@ describe("TUI differential rendering", () => {
tui.requestRender();
await terminal.waitForRender();
assert.ok(tui.fullRedraws > initialRedraws, "Shrink should reset the viewport with a full redraw");
assert.strictEqual(tui.fullRedraws, initialRedraws, "Shrink should re-anchor in place, not full-redraw");
assert.deepStrictEqual(terminal.getViewport(), ["Line 0", "Line 1", "", "", ""]);
const redrawsAfterShrink = tui.fullRedraws;
component.lines = ["Line 0", "Line 1", "Line 2"];
@ -743,7 +744,7 @@ describe("TUI differential rendering", () => {
assert.strictEqual(
tui.fullRedraws,
redrawsBeforeSwitch,
"Branch switch should not trigger a full redraw (clamped to viewport)",
"Branch switch should not trigger a full redraw (re-anchored in place)",
);
const viewport = terminal.getViewport();
@ -754,20 +755,21 @@ describe("TUI differential rendering", () => {
assert.ok(!line.includes("Chat 14"), `Stale "Chat 14" at viewport row ${i}`);
}
// After clamping, the viewport keeps its previous scroll position
// (prevViewportTop=13) rather than resetting to the new content bottom.
// The stale "Chat 12/13/14" rows remain in scrollback but are not visible.
// Each shrink re-anchors the viewport so the content bottom stays on
// the bottom screen row; the tail of the chat plus the editor fill
// the screen with no dead rows. Stale "Chat 12/13/14" rows remain in
// scrollback but are not visible.
assert.deepStrictEqual(viewport, [
"Chat 5",
"Chat 6",
"Chat 7",
"Chat 8",
"Chat 9",
"Chat 10",
"Chat 11",
"Editor 0",
"Editor 1",
"Editor 2",
"",
"",
"",
"",
"",
"",
"",
"",
]);
tui.stop();

View file

@ -10,6 +10,10 @@ class Lines implements Component {
this.lines = lines;
}
setLines(lines: string[]): void {
this.lines = lines;
}
render(): string[] {
return this.lines;
}
@ -17,6 +21,15 @@ class Lines implements Component {
invalidate(): void {}
}
class WriteCapturingTerminal extends VirtualTerminal {
writes: string[] = [];
override write(data: string): void {
this.writes.push(data);
super.write(data);
}
}
describe("TUI shrinking content", () => {
it("clears all rendered lines when content shrinks to zero", async () => {
const terminal = new VirtualTerminal(40, 10);
@ -41,4 +54,192 @@ describe("TUI shrinking content", () => {
tui.stop();
});
it("repaints the viewport when content collapses above it with an above-viewport change", async () => {
// Regression: compaction/collapse shrinks 30 lines to 8 (below the
// viewport top at 20) while a line above the viewport also changes.
// The clamped differential path used to desync the cursor and leave
// the viewport blank with the input box gone.
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const content = new Lines([]);
tui.addChild(content);
content.setLines([...Array.from({ length: 29 }, (_, i) => `L${i}`), "[INPUT-BOX]"]);
tui.start();
await terminal.waitForRender();
assert.ok(terminal.getViewport().some((line) => line.includes("[INPUT-BOX]")));
content.setLines(["L0", "L1", "L2", "L3", "L4", "L5-CHANGED", "L6", "[INPUT-BOX]"]);
tui.requestRender();
await terminal.waitForRender();
const viewport = terminal.getViewport();
assert.ok(
viewport.some((line) => line.includes("[INPUT-BOX]")),
`input box should stay visible, got: ${JSON.stringify(viewport)}`,
);
assert.ok(viewport.some((line) => line.includes("L5-CHANGED")));
// Scrollback must be preserved (no ESC[3J): old history stays above.
assert.ok(
terminal.getScrollBuffer().some((line) => line.includes("L15")),
"scrollback should keep the old history",
);
// Subsequent renders must land in the right place (self-healing).
content.setLines(["L0", "L1", "L2", "L3", "L4", "L5-CHANGED", "L6", "[INPUT-BOX]x"]);
tui.requestRender();
await terminal.waitForRender();
assert.ok(
terminal.getViewport().some((line) => line.includes("[INPUT-BOX]x")),
"render after the collapse should update the input box in place",
);
tui.stop();
});
it("preserves the user's scroll position when content collapses while scrolled up", async () => {
// While the user is reading scrollback, the collapse repaint must only
// touch the live screen area at the bottom of the buffer: no ESC[3J,
// no viewport yank. Scrolling back down shows the fresh content.
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const content = new Lines([]);
tui.addChild(content);
content.setLines([...Array.from({ length: 29 }, (_, i) => `L${i}`), "[INPUT-BOX]"]);
tui.start();
await terminal.waitForRender();
// User scrolls up into scrollback.
terminal.scrollViewport(-10);
const scrolledPosition = terminal.getScrollPosition();
assert.ok(scrolledPosition < 20, "user should be scrolled into scrollback");
assert.ok(terminal.getViewport().some((line) => line.includes("L10")));
content.setLines(["L0", "L1", "L2", "L3", "L4", "L5-CHANGED", "L6", "[INPUT-BOX]"]);
tui.requestRender();
await terminal.waitForRender();
// The user's scrolled view must not move, and still shows the history.
assert.strictEqual(terminal.getScrollPosition(), scrolledPosition, "scroll position should be preserved");
assert.ok(terminal.getViewport().some((line) => line.includes("L10")));
// Scrolling back down reveals the repainted content with the input box.
terminal.scrollToBottom();
const viewport = terminal.getViewport();
assert.ok(
viewport.some((line) => line.includes("[INPUT-BOX]")),
`input box should be visible at the bottom, got: ${JSON.stringify(viewport)}`,
);
assert.ok(viewport.some((line) => line.includes("L5-CHANGED")));
tui.stop();
});
it("deletes a kitty image straddling the viewport top when content collapses", async () => {
// A multi-row image can start above the viewport top while its
// reserved rows are still visible. The collapse repaint must widen
// its image-delete range to that block, or the stale overlay
// survives and its id drops out of tracking.
const terminal = new WriteCapturingTerminal(40, 10);
const tui = new TUI(terminal);
const content = new Lines([]);
tui.addChild(content);
// 30 lines, image line at index 18 with 4 reserved rows (18..21),
// straddling the viewport top at 20.
const imageLine = "\x1b_Ga=T,i=42,r=4;AAAA\x1b\\";
const first = [
...Array.from({ length: 18 }, (_, i) => `L${i}`),
imageLine,
"",
"",
"",
...Array.from({ length: 7 }, (_, i) => `L${22 + i}`),
"[INPUT-BOX]",
];
content.setLines(first);
tui.start();
await terminal.waitForRender();
terminal.writes = [];
content.setLines(["L0", "L1", "L2", "L3", "L4", "L5-CHANGED", "L6", "[INPUT-BOX]"]);
tui.requestRender();
await terminal.waitForRender();
const written = terminal.writes.join("");
assert.ok(
written.includes("\x1b_Ga=d,d=I,i=42,q=2\x1b\\"),
"the straddling image should be deleted during the collapse repaint",
);
assert.ok(terminal.getViewport().some((line) => line.includes("[INPUT-BOX]")));
tui.stop();
});
it("re-anchors the input box to the screen bottom when content shrinks", async () => {
// Regression: previousViewportTop only ever grows, so after a shrink
// (e.g. collapsing expanded tool output) the content bottom hovered
// mid-screen with dead rows below, and nothing ever re-anchored it.
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const content = new Lines([]);
tui.addChild(content);
content.setLines([...Array.from({ length: 59 }, (_, i) => `old-${i}`), "[INPUT-BOX]"]);
tui.start();
await terminal.waitForRender();
assert.ok(terminal.getViewport()[9]!.includes("[INPUT-BOX]"));
// Shrink 60 -> 30 lines; content is still taller than the screen, so
// the tail must stay glued to the screen bottom.
content.setLines([...Array.from({ length: 29 }, (_, i) => `new-${i}`), "[INPUT-BOX]"]);
tui.requestRender();
await terminal.waitForRender();
const viewport = terminal.getViewport();
assert.ok(
viewport[9]!.includes("[INPUT-BOX]"),
`input box should sit on the bottom screen row, got: ${JSON.stringify(viewport)}`,
);
assert.ok(viewport[0]!.includes("new-20"), "viewport should show the tail of the new content");
// Subsequent renders must land in the right place (self-healing).
content.setLines([...Array.from({ length: 29 }, (_, i) => `new-${i}`), "[INPUT-BOX]x"]);
tui.requestRender();
await terminal.waitForRender();
assert.ok(terminal.getViewport()[9]!.includes("[INPUT-BOX]x"));
tui.stop();
});
it("shows the tail of collapsed content when it is still taller than the screen", async () => {
// 100 lines -> 30 lines (still > height 10) with a change above the
// viewport: the viewport should show the tail of the new content.
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const content = new Lines([]);
tui.addChild(content);
content.setLines(Array.from({ length: 100 }, (_, i) => `old-${i}`));
tui.start();
await terminal.waitForRender();
const newLines = Array.from({ length: 30 }, (_, i) => `new-${i}`);
content.setLines(newLines);
tui.requestRender();
await terminal.waitForRender();
const viewport = terminal.getViewport();
for (let i = 20; i < 30; i++) {
assert.ok(
viewport.some((line) => line.includes(`new-${i}`)),
`tail line new-${i} should be visible, got: ${JSON.stringify(viewport)}`,
);
}
tui.stop();
});
});

View file

@ -135,6 +135,27 @@ export class VirtualTerminal implements Terminal {
});
}
/**
* Simulate the user scrolling the terminal window (negative = up into scrollback).
*/
scrollViewport(lines: number): void {
this.xterm.scrollLines(lines);
}
/**
* Current scroll position of the user-visible window (buffer.viewportY).
*/
getScrollPosition(): number {
return this.xterm.buffer.active.viewportY;
}
/**
* Scroll the user-visible window back to the bottom of the buffer.
*/
scrollToBottom(): void {
this.xterm.scrollToBottom();
}
/**
* Flush and get viewport - convenience method for tests
*/