From 3bb115a39c7c2c29266fc38e79ded2a186d9f7f4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 11 Jan 2026 03:00:23 +0100 Subject: [PATCH] fix(tui): cursor position tracking when content shrinks with unchanged lines --- packages/tui/CHANGELOG.md | 1 + packages/tui/src/tui.ts | 25 ++++++++++++++++ packages/tui/test/tui-render.test.ts | 43 ++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 packages/tui/test/tui-render.test.ts diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 1ce7dc71..a0860a0c 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Cursor position tracking when content shrinks with unchanged remaining lines - TUI renders with wrong dimensions after suspend/resume if terminal was resized while suspended ([#599](https://github.com/badlogic/pi-mono/issues/599)) ## [0.42.4] - 2026-01-10 diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index ca5c8efe..e36ef822 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -385,6 +385,31 @@ export class TUI extends Container { return; } + // All changes are in deleted lines (nothing to render, just clear) + if (firstChanged >= newLines.length) { + if (this.previousLines.length > newLines.length) { + let buffer = "\x1b[?2026h"; + // Move to end of new content + const targetRow = newLines.length - 1; + const lineDiff = targetRow - this.cursorRow; + if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`; + else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`; + buffer += "\r"; + // Clear extra lines + const extraLines = this.previousLines.length - newLines.length; + for (let i = 0; i < extraLines; i++) { + buffer += "\r\n\x1b[2K"; + } + buffer += `\x1b[${extraLines}A`; + buffer += "\x1b[?2026l"; + this.terminal.write(buffer); + this.cursorRow = newLines.length - 1; + } + this.previousLines = newLines; + this.previousWidth = width; + return; + } + // Check if firstChanged is outside the viewport // cursorRow is the line where cursor is (0-indexed) // Viewport shows lines from (cursorRow - height + 1) to cursorRow diff --git a/packages/tui/test/tui-render.test.ts b/packages/tui/test/tui-render.test.ts new file mode 100644 index 00000000..61c6d155 --- /dev/null +++ b/packages/tui/test/tui-render.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { type Component, TUI } from "../src/tui.js"; +import { VirtualTerminal } from "./virtual-terminal.js"; + +class TestComponent implements Component { + lines: string[] = []; + render(_width: number): string[] { + return this.lines; + } + invalidate(): void {} +} + +describe("TUI differential rendering", () => { + it("tracks cursor correctly when content shrinks with unchanged remaining lines", async () => { + const terminal = new VirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + // Initial render: 5 identical lines + component.lines = ["Line 0", "Line 1", "Line 2", "Line 3", "Line 4"]; + tui.start(); + await terminal.flush(); + + // Shrink to 3 lines, all identical to before (no content changes in remaining lines) + component.lines = ["Line 0", "Line 1", "Line 2"]; + tui.requestRender(); + await terminal.flush(); + + // cursorRow should be 2 (last line of new content) + // Verify by doing another render with a change on line 1 + component.lines = ["Line 0", "CHANGED", "Line 2"]; + tui.requestRender(); + await terminal.flush(); + + const viewport = terminal.getViewport(); + // Line 1 should show "CHANGED", proving cursor tracking was correct + assert.ok(viewport[1]?.includes("CHANGED"), `Expected "CHANGED" on line 1, got: ${viewport[1]}`); + + tui.stop(); + }); +});