mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-20 05:04:44 +00:00
fix(tui): keep overlays centered across resizes (#950)
This commit is contained in:
parent
b1b0fd82b6
commit
c565fa9af8
3 changed files with 51 additions and 6 deletions
|
|
@ -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.
|
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
|
## Built-in Components
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Slash command menu now only triggers when the editor input is otherwise empty ([#904](https://github.com/badlogic/pi-mono/issues/904))
|
- 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
|
## [0.49.3] - 2026-01-22
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,7 @@ export class TUI extends Container {
|
||||||
private cellSizeQueryPending = false;
|
private cellSizeQueryPending = false;
|
||||||
private showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";
|
private showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";
|
||||||
private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
|
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
|
// Overlay stack for modal components rendered on top of base content
|
||||||
private overlayStack: {
|
private overlayStack: {
|
||||||
|
|
@ -390,6 +391,7 @@ export class TUI extends Container {
|
||||||
this.cursorRow = 0;
|
this.cursorRow = 0;
|
||||||
this.hardwareCursorRow = 0;
|
this.hardwareCursorRow = 0;
|
||||||
this.maxLinesRendered = 0;
|
this.maxLinesRendered = 0;
|
||||||
|
this.previousViewportTop = 0;
|
||||||
}
|
}
|
||||||
if (this.renderRequested) return;
|
if (this.renderRequested) return;
|
||||||
this.renderRequested = true;
|
this.renderRequested = true;
|
||||||
|
|
@ -658,12 +660,16 @@ export class TUI extends Container {
|
||||||
minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
|
minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extend result with empty lines if content is too short for overlay placement
|
// Ensure result covers the terminal working area to keep overlay positioning stable across resizes.
|
||||||
while (result.length < minLinesNeeded) {
|
// 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("");
|
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
|
// Track which lines were modified for final verification
|
||||||
const modifiedLines = new Set<number>();
|
const modifiedLines = new Set<number>();
|
||||||
|
|
@ -781,6 +787,13 @@ export class TUI extends Container {
|
||||||
private doRender(): void {
|
private doRender(): void {
|
||||||
const width = this.terminal.columns;
|
const width = this.terminal.columns;
|
||||||
const height = this.terminal.rows;
|
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
|
// Render all components to get new lines
|
||||||
let newLines = this.render(width);
|
let newLines = this.render(width);
|
||||||
|
|
@ -816,6 +829,7 @@ export class TUI extends Container {
|
||||||
} else {
|
} else {
|
||||||
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
||||||
}
|
}
|
||||||
|
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
|
||||||
this.positionHardwareCursor(cursorPos, newLines.length);
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
||||||
this.previousLines = newLines;
|
this.previousLines = newLines;
|
||||||
this.previousWidth = width;
|
this.previousWidth = width;
|
||||||
|
|
@ -848,10 +862,18 @@ export class TUI extends Container {
|
||||||
lastChanged = i;
|
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
|
// No changes - but still need to update hardware cursor position if it moved
|
||||||
if (firstChanged === -1) {
|
if (firstChanged === -1) {
|
||||||
this.positionHardwareCursor(cursorPos, newLines.length);
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
||||||
|
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -861,7 +883,7 @@ export class TUI extends Container {
|
||||||
let buffer = "\x1b[?2026h";
|
let buffer = "\x1b[?2026h";
|
||||||
// Move to end of new content (clamp to 0 for empty content)
|
// Move to end of new content (clamp to 0 for empty content)
|
||||||
const targetRow = Math.max(0, newLines.length - 1);
|
const targetRow = Math.max(0, newLines.length - 1);
|
||||||
const lineDiff = targetRow - this.hardwareCursorRow;
|
const lineDiff = computeLineDiff(targetRow);
|
||||||
if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
|
if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
|
||||||
else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
|
else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
|
||||||
buffer += "\r";
|
buffer += "\r";
|
||||||
|
|
@ -889,12 +911,12 @@ export class TUI extends Container {
|
||||||
this.positionHardwareCursor(cursorPos, newLines.length);
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
||||||
this.previousLines = newLines;
|
this.previousLines = newLines;
|
||||||
this.previousWidth = width;
|
this.previousWidth = width;
|
||||||
|
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if firstChanged is outside the viewport
|
// Check if firstChanged is outside the viewport
|
||||||
// Viewport is based on max lines ever rendered (terminal's working area)
|
// Viewport is based on max lines ever rendered (terminal's working area)
|
||||||
const viewportTop = Math.max(0, this.maxLinesRendered - height);
|
|
||||||
if (firstChanged < viewportTop) {
|
if (firstChanged < viewportTop) {
|
||||||
// First change is above viewport - need full re-render
|
// First change is above viewport - need full re-render
|
||||||
fullRender(true);
|
fullRender(true);
|
||||||
|
|
@ -906,7 +928,7 @@ export class TUI extends Container {
|
||||||
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
||||||
|
|
||||||
// Move cursor to first changed line (use hardwareCursorRow for actual position)
|
// Move cursor to first changed line (use hardwareCursorRow for actual position)
|
||||||
const lineDiff = firstChanged - this.hardwareCursorRow;
|
const lineDiff = computeLineDiff(firstChanged);
|
||||||
if (lineDiff > 0) {
|
if (lineDiff > 0) {
|
||||||
buffer += `\x1b[${lineDiff}B`; // Move down
|
buffer += `\x1b[${lineDiff}B`; // Move down
|
||||||
} else if (lineDiff < 0) {
|
} else if (lineDiff < 0) {
|
||||||
|
|
@ -1014,6 +1036,7 @@ export class TUI extends Container {
|
||||||
this.hardwareCursorRow = finalCursorRow;
|
this.hardwareCursorRow = finalCursorRow;
|
||||||
// Track terminal's working area (grows but doesn't shrink unless cleared)
|
// Track terminal's working area (grows but doesn't shrink unless cleared)
|
||||||
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
||||||
|
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
|
||||||
|
|
||||||
// Position hardware cursor for IME
|
// Position hardware cursor for IME
|
||||||
this.positionHardwareCursor(cursorPos, newLines.length);
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue