diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index ba91b44d..711686e0 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -154,6 +154,27 @@ const result = await ctx.ui.custom( ); ``` +### Overlay Lifecycle + +Overlay components are disposed when closed. Don't reuse references - create fresh instances: + +```typescript +// Wrong - stale reference +let menu: MenuComponent; +await ctx.ui.custom((_, __, ___, done) => { + menu = new MenuComponent(done); + return menu; +}, { overlay: true }); +setActiveComponent(menu); // Disposed + +// Correct - re-call to re-show +const showMenu = () => ctx.ui.custom((_, __, ___, done) => + new MenuComponent(done), { overlay: true }); + +await showMenu(); // First show +await showMenu(); // "Back" = just call again +``` + See [overlay-qa-tests.ts](../examples/extensions/overlay-qa-tests.ts) for comprehensive examples covering anchors, margins, stacking, responsive visibility, and animation. ## Built-in Components diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index ff17086d..5a456e2f 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Slash command menu now only triggers when the editor input is otherwise empty ([#904](https://github.com/badlogic/pi-mono/issues/904)) +- Center-anchored overlays now stay vertically centered when resizing the terminal taller after a shrink ## [0.49.3] - 2026-01-22 diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 685bc53b..b4823e11 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -210,6 +210,7 @@ export class TUI extends Container { private cellSizeQueryPending = false; private showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1"; private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered) + private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves // Overlay stack for modal components rendered on top of base content private overlayStack: { @@ -390,6 +391,7 @@ export class TUI extends Container { this.cursorRow = 0; this.hardwareCursorRow = 0; this.maxLinesRendered = 0; + this.previousViewportTop = 0; } if (this.renderRequested) return; this.renderRequested = true; @@ -658,12 +660,16 @@ export class TUI extends Container { minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length); } - // Extend result with empty lines if content is too short for overlay placement - while (result.length < minLinesNeeded) { + // Ensure result covers the terminal working area to keep overlay positioning stable across resizes. + // maxLinesRendered can exceed current content length after a shrink; pad to keep viewportStart consistent. + const workingHeight = Math.max(this.maxLinesRendered, minLinesNeeded); + + // Extend result with empty lines if content is too short for overlay placement or working area + while (result.length < workingHeight) { result.push(""); } - const viewportStart = Math.max(0, result.length - termHeight); + const viewportStart = Math.max(0, workingHeight - termHeight); // Track which lines were modified for final verification const modifiedLines = new Set(); @@ -781,6 +787,13 @@ export class TUI extends Container { private doRender(): void { const width = this.terminal.columns; const height = this.terminal.rows; + const viewportTop = Math.max(0, this.maxLinesRendered - height); + const prevViewportTop = this.previousViewportTop; + const computeLineDiff = (targetRow: number): number => { + const currentScreenRow = this.hardwareCursorRow - prevViewportTop; + const targetScreenRow = targetRow - viewportTop; + return targetScreenRow - currentScreenRow; + }; // Render all components to get new lines let newLines = this.render(width); @@ -816,6 +829,7 @@ export class TUI extends Container { } else { this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length); } + this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); this.positionHardwareCursor(cursorPos, newLines.length); this.previousLines = newLines; this.previousWidth = width; @@ -848,10 +862,18 @@ export class TUI extends Container { lastChanged = i; } } + const appendedLines = newLines.length > this.previousLines.length; + if (appendedLines) { + if (firstChanged === -1) { + firstChanged = this.previousLines.length; + } + lastChanged = newLines.length - 1; + } // No changes - but still need to update hardware cursor position if it moved if (firstChanged === -1) { this.positionHardwareCursor(cursorPos, newLines.length); + this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); return; } @@ -861,7 +883,7 @@ export class TUI extends Container { let buffer = "\x1b[?2026h"; // Move to end of new content (clamp to 0 for empty content) const targetRow = Math.max(0, newLines.length - 1); - const lineDiff = targetRow - this.hardwareCursorRow; + const lineDiff = computeLineDiff(targetRow); if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`; else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`; buffer += "\r"; @@ -889,12 +911,12 @@ export class TUI extends Container { this.positionHardwareCursor(cursorPos, newLines.length); this.previousLines = newLines; this.previousWidth = width; + this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); return; } // Check if firstChanged is outside the viewport // 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 fullRender(true); @@ -906,7 +928,7 @@ export class TUI extends Container { let buffer = "\x1b[?2026h"; // Begin synchronized output // Move cursor to first changed line (use hardwareCursorRow for actual position) - const lineDiff = firstChanged - this.hardwareCursorRow; + const lineDiff = computeLineDiff(firstChanged); if (lineDiff > 0) { buffer += `\x1b[${lineDiff}B`; // Move down } else if (lineDiff < 0) { @@ -1014,6 +1036,7 @@ export class TUI extends Container { this.hardwareCursorRow = finalCursorRow; // Track terminal's working area (grows but doesn't shrink unless cleared) this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length); + this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); // Position hardware cursor for IME this.positionHardwareCursor(cursorPos, newLines.length);