diff --git a/.changeset/cli-blank-screen-after-shrink.md b/.changeset/cli-blank-screen-after-shrink.md new file mode 100644 index 000000000..e8d4816c7 --- /dev/null +++ b/.changeset/cli-blank-screen-after-shrink.md @@ -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. diff --git a/.changeset/cli-session-reset-full-clear.md b/.changeset/cli-session-reset-full-clear.md new file mode 100644 index 000000000..046e08a71 --- /dev/null +++ b/.changeset/cli-session-reset-full-clear.md @@ -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. diff --git a/.changeset/pi-tui-collapse-repaint.md b/.changeset/pi-tui-collapse-repaint.md new file mode 100644 index 000000000..681b90a84 --- /dev/null +++ b/.changeset/pi-tui-collapse-repaint.md @@ -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. diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 2d92ec2e4..2061c805c 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -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 { diff --git a/packages/pi-tui/src/tui.ts b/packages/pi-tui/src/tui.ts index c8924d47c..c9e7a7b6d 100644 --- a/packages/pi-tui/src/tui.ts +++ b/packages/pi-tui/src/tui.ts @@ -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 diff --git a/packages/pi-tui/test/tui-render.test.ts b/packages/pi-tui/test/tui-render.test.ts index 77d33416b..4d67dca8f 100644 --- a/packages/pi-tui/test/tui-render.test.ts +++ b/packages/pi-tui/test/tui-render.test.ts @@ -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(); diff --git a/packages/pi-tui/test/tui-shrink.test.ts b/packages/pi-tui/test/tui-shrink.test.ts index 13fa5c4bb..16974f046 100644 --- a/packages/pi-tui/test/tui-shrink.test.ts +++ b/packages/pi-tui/test/tui-shrink.test.ts @@ -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(); + }); }); diff --git a/packages/pi-tui/test/virtual-terminal.ts b/packages/pi-tui/test/virtual-terminal.ts index 4e067f4e5..067c8b7b4 100644 --- a/packages/pi-tui/test/virtual-terminal.ts +++ b/packages/pi-tui/test/virtual-terminal.ts @@ -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 */