fix(tui): keep overlays centered across resizes (#950)

This commit is contained in:
Nico Bailon 2026-01-26 00:43:16 -08:00 committed by GitHub
parent b1b0fd82b6
commit c565fa9af8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 51 additions and 6 deletions

View file

@ -154,6 +154,27 @@ const result = await ctx.ui.custom<string | null>(
);
```
### 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

View file

@ -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

View file

@ -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<number>();
@ -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);