fix(pi-tui): use contentCursorRow for render movement baseline instead of cursorRow

PR #3744 fixed autocomplete ghost lines by introducing a local
contentCursorRow initialized from this.cursorRow, but this.cursorRow
tracks the content end (last line), not where the cursor actually
ended up after rendering. This caused computeLineDiff to compute
wrong movement deltas, making content clear and jump on every keystroke.

Fix: add an instance field contentCursorRow that stores finalCursorRow
after content rendering but before positionHardwareCursor moves the
cursor for IME. This correctly separates three cursor concepts:
- cursorRow: logical content end (viewport calculation)
- contentCursorRow: post-render cursor position (movement baseline)
- hardwareCursorRow: actual terminal cursor (may differ due to IME)

Closes #3764
This commit is contained in:
Jeremy 2026-04-07 22:34:17 -05:00
parent 0d69b9c57d
commit 561d73d3aa

View file

@ -239,6 +239,7 @@ export class TUI extends Container {
public onDebug?: () => void;
private renderRequested = false;
private cursorRow = 0; // Logical cursor row (end of rendered content)
private contentCursorRow = 0; // Cursor row after content rendering, before IME repositioning
private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
private inputBuffer = ""; // Buffer for parsing terminal responses
private cellSizeQueryPending = false;
@ -498,6 +499,7 @@ export class TUI extends Container {
this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
this.cursorRow = 0;
this.contentCursorRow = 0;
this.hardwareCursorRow = 0;
this.maxLinesRendered = 0;
this.previousViewportTop = 0;
@ -616,10 +618,9 @@ export class TUI extends Container {
const height = this.terminal.rows;
let viewportTop = Math.max(0, this.maxLinesRendered - height);
let prevViewportTop = this.previousViewportTop;
let contentCursorRow = this.cursorRow;
let hardwareCursorRow = this.hardwareCursorRow;
let hardwareCursorRow = this.contentCursorRow;
const computeLineDiff = (targetRow: number): number => {
const currentScreenRow = contentCursorRow - prevViewportTop;
const currentScreenRow = hardwareCursorRow - prevViewportTop;
const targetScreenRow = targetRow - viewportTop;
return targetScreenRow - currentScreenRow;
};
@ -664,6 +665,7 @@ export class TUI extends Container {
buffer += "\x1b[?2026l"; // End synchronized output
this.terminal.write(buffer);
this.cursorRow = Math.max(0, newLines.length - 1);
this.contentCursorRow = this.cursorRow;
this.hardwareCursorRow = this.cursorRow;
// Reset max lines when clearing, otherwise track growth
if (clear) {
@ -771,6 +773,7 @@ export class TUI extends Container {
buffer += "\x1b[?2026l";
this.terminal.write(buffer);
this.cursorRow = targetRow;
this.contentCursorRow = targetRow;
this.hardwareCursorRow = targetRow;
}
this.positionHardwareCursor(cursorPos, newLines.length);
@ -806,7 +809,6 @@ export class TUI extends Container {
buffer += "\r\n".repeat(scroll);
prevViewportTop += scroll;
viewportTop += scroll;
contentCursorRow = moveTargetRow;
hardwareCursorRow = moveTargetRow;
}
@ -889,8 +891,10 @@ export class TUI extends Container {
// Track cursor position for next render
// cursorRow tracks end of content (for viewport calculation)
// hardwareCursorRow tracks actual terminal cursor position (for movement)
// contentCursorRow tracks cursor after content rendering (before IME repositioning)
// hardwareCursorRow tracks actual terminal cursor position (may differ due to IME)
this.cursorRow = Math.max(0, newLines.length - 1);
this.contentCursorRow = finalCursorRow;
this.hardwareCursorRow = finalCursorRow;
// Track terminal's working area (grows but doesn't shrink unless cleared)
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);