mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 08:03:39 +00:00
fix(tui): fix viewport tracking and cursor positioning for overlays and content shrink
- Add maxLinesRendered to track terminal's working area (grows with content, only resets on full clear) - Fix viewport calculation: use maxLinesRendered instead of cursorRow for correct viewport bounds - Separate cursorRow (end of content for viewport calc) from hardwareCursorRow (actual cursor position) - Refactor full render into fullRender(clear) helper to reduce duplication - Fix shrink-clearing: properly clear extra lines when content shrinks - Add PI_TUI_DEBUG=1 env var for render debugging (writes to /tmp/tui/) The core issue was that after partial renders (e.g., overlay show/hide), the viewport calculation used cursorRow which reflected where rendering stopped, not where content ended. This caused incorrect viewport bounds, leading to cursor movement into scrollback (unreachable) or rendering at wrong positions. Additionally, when content shrank (e.g., selector dismissed), the terminal still had the old lines as blank space. maxLinesRendered tracks this so viewport calculations account for the terminal's actual working area.
This commit is contained in:
parent
2354bc571e
commit
565488fde6
1 changed files with 68 additions and 38 deletions
|
|
@ -209,6 +209,7 @@ export class TUI extends Container {
|
|||
private inputBuffer = ""; // Buffer for parsing terminal responses
|
||||
private cellSizeQueryPending = false;
|
||||
private showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";
|
||||
private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
|
||||
|
||||
// Overlay stack for modal components rendered on top of base content
|
||||
private overlayStack: {
|
||||
|
|
@ -388,6 +389,7 @@ export class TUI extends Container {
|
|||
this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
|
||||
this.cursorRow = 0;
|
||||
this.hardwareCursorRow = 0;
|
||||
this.maxLinesRendered = 0;
|
||||
}
|
||||
if (this.renderRequested) return;
|
||||
this.renderRequested = true;
|
||||
|
|
@ -796,39 +798,38 @@ export class TUI extends Container {
|
|||
// Width changed - need full re-render
|
||||
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
|
||||
|
||||
// First render - just output everything without clearing (assumes clean screen)
|
||||
if (this.previousLines.length === 0 && !widthChanged) {
|
||||
// Helper to clear scrollback and viewport and render all new lines
|
||||
const fullRender = (clear: boolean): void => {
|
||||
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
||||
if (clear) buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home
|
||||
for (let i = 0; i < newLines.length; i++) {
|
||||
if (i > 0) buffer += "\r\n";
|
||||
buffer += newLines[i];
|
||||
}
|
||||
buffer += "\x1b[?2026l"; // End synchronized output
|
||||
this.terminal.write(buffer);
|
||||
// After rendering N lines, cursor is at end of last line (clamp to 0 for empty)
|
||||
this.cursorRow = Math.max(0, newLines.length - 1);
|
||||
this.hardwareCursorRow = this.cursorRow;
|
||||
// Reset max lines when clearing, otherwise track growth
|
||||
if (clear) {
|
||||
this.maxLinesRendered = newLines.length;
|
||||
} else {
|
||||
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
||||
}
|
||||
this.positionHardwareCursor(cursorPos, newLines.length);
|
||||
this.previousLines = newLines;
|
||||
this.previousWidth = width;
|
||||
};
|
||||
|
||||
// First render - just output everything without clearing (assumes clean screen)
|
||||
if (this.previousLines.length === 0 && !widthChanged) {
|
||||
fullRender(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Width changed - full re-render
|
||||
if (widthChanged) {
|
||||
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
||||
buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home
|
||||
for (let i = 0; i < newLines.length; i++) {
|
||||
if (i > 0) buffer += "\r\n";
|
||||
buffer += newLines[i];
|
||||
}
|
||||
buffer += "\x1b[?2026l"; // End synchronized output
|
||||
this.terminal.write(buffer);
|
||||
this.cursorRow = Math.max(0, newLines.length - 1);
|
||||
this.hardwareCursorRow = this.cursorRow;
|
||||
this.positionHardwareCursor(cursorPos, newLines.length);
|
||||
this.previousLines = newLines;
|
||||
this.previousWidth = width;
|
||||
fullRender(true);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -864,12 +865,22 @@ export class TUI extends Container {
|
|||
if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
|
||||
else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
|
||||
buffer += "\r";
|
||||
// Clear extra lines
|
||||
// Clear extra lines without scrolling
|
||||
const extraLines = this.previousLines.length - newLines.length;
|
||||
for (let i = 0; i < extraLines; i++) {
|
||||
buffer += "\r\n\x1b[2K";
|
||||
if (extraLines > height) {
|
||||
fullRender(true);
|
||||
return;
|
||||
}
|
||||
if (extraLines > 0) {
|
||||
buffer += "\x1b[1B";
|
||||
}
|
||||
for (let i = 0; i < extraLines; i++) {
|
||||
buffer += "\r\x1b[2K";
|
||||
if (i < extraLines - 1) buffer += "\x1b[1B";
|
||||
}
|
||||
if (extraLines > 0) {
|
||||
buffer += `\x1b[${extraLines}A`;
|
||||
}
|
||||
buffer += `\x1b[${extraLines}A`;
|
||||
buffer += "\x1b[?2026l";
|
||||
this.terminal.write(buffer);
|
||||
this.cursorRow = targetRow;
|
||||
|
|
@ -882,25 +893,11 @@ export class TUI extends Container {
|
|||
}
|
||||
|
||||
// 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
|
||||
// If firstChanged < viewportTop, we need full re-render
|
||||
const viewportTop = this.cursorRow - height + 1;
|
||||
// Viewport is based on max lines ever rendered (terminal's working area)
|
||||
const viewportTop = Math.max(0, this.maxLinesRendered - height);
|
||||
if (firstChanged < viewportTop) {
|
||||
// First change is above viewport - need full re-render
|
||||
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
||||
buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home
|
||||
for (let i = 0; i < newLines.length; i++) {
|
||||
if (i > 0) buffer += "\r\n";
|
||||
buffer += newLines[i];
|
||||
}
|
||||
buffer += "\x1b[?2026l"; // End synchronized output
|
||||
this.terminal.write(buffer);
|
||||
this.cursorRow = Math.max(0, newLines.length - 1);
|
||||
this.hardwareCursorRow = this.cursorRow;
|
||||
this.positionHardwareCursor(cursorPos, newLines.length);
|
||||
this.previousLines = newLines;
|
||||
this.previousWidth = width;
|
||||
fullRender(true);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -978,12 +975,45 @@ export class TUI extends Container {
|
|||
|
||||
buffer += "\x1b[?2026l"; // End synchronized output
|
||||
|
||||
if (process.env.PI_TUI_DEBUG === "1") {
|
||||
const debugDir = "/tmp/tui";
|
||||
fs.mkdirSync(debugDir, { recursive: true });
|
||||
const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
|
||||
const debugData = [
|
||||
`firstChanged: ${firstChanged}`,
|
||||
`viewportTop: ${viewportTop}`,
|
||||
`cursorRow: ${this.cursorRow}`,
|
||||
`height: ${height}`,
|
||||
`lineDiff: ${lineDiff}`,
|
||||
`hardwareCursorRow: ${this.hardwareCursorRow}`,
|
||||
`renderEnd: ${renderEnd}`,
|
||||
`finalCursorRow: ${finalCursorRow}`,
|
||||
`cursorPos: ${JSON.stringify(cursorPos)}`,
|
||||
`newLines.length: ${newLines.length}`,
|
||||
`previousLines.length: ${this.previousLines.length}`,
|
||||
"",
|
||||
"=== newLines ===",
|
||||
JSON.stringify(newLines, null, 2),
|
||||
"",
|
||||
"=== previousLines ===",
|
||||
JSON.stringify(this.previousLines, null, 2),
|
||||
"",
|
||||
"=== buffer ===",
|
||||
JSON.stringify(buffer),
|
||||
].join("\n");
|
||||
fs.writeFileSync(debugPath, debugData);
|
||||
}
|
||||
|
||||
// Write entire buffer at once
|
||||
this.terminal.write(buffer);
|
||||
|
||||
// Track cursor position for next render
|
||||
this.cursorRow = finalCursorRow;
|
||||
// cursorRow tracks end of content (for viewport calculation)
|
||||
// hardwareCursorRow tracks actual terminal cursor position (for movement)
|
||||
this.cursorRow = Math.max(0, newLines.length - 1);
|
||||
this.hardwareCursorRow = finalCursorRow;
|
||||
// Track terminal's working area (grows but doesn't shrink unless cleared)
|
||||
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
||||
|
||||
// Position hardware cursor for IME
|
||||
this.positionHardwareCursor(cursorPos, newLines.length);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue