fix(tui): clamp editor height and overlays on small terminals
Two bounded TUI overlap fixes: - Editor max-height (coding-agent): the [6,18] clamp's floor of 6 exceeded available space on terminals <=18 rows, letting the editor crowd the transcript/status. Extracted a pure computeEditorMaxHeight(rows) with an EDITOR_MIN_CHROME_ROWS=4 upper bound; identical for rows >=18. - Overlay overflow (tui): #resolveOverlayLayout left maxHeight undefined when the option was unset, so a tall overlay's bottom rows were dropped off-screen. maxHeight now defaults to availHeight so every overlay is sliced to fit and re-clamps on resize.
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the editor input box claiming a disproportionate share of small terminals (<=18 rows): the editor max-height floor (6 rows) ignored the available space. The height now yields to terminal size (`EDITOR_MIN_CHROME_ROWS`), always reserving rows for the transcript and status line.
|
||||
|
||||
## [15.12.5] - 2026-06-13
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -210,6 +210,14 @@ const EDITOR_MAX_HEIGHT_MIN = 6;
|
||||
const EDITOR_MAX_HEIGHT_MAX = 18;
|
||||
const EDITOR_RESERVED_ROWS = 12;
|
||||
const EDITOR_FALLBACK_ROWS = 24;
|
||||
const EDITOR_MIN_CHROME_ROWS = 4; // rows reserved for transcript + status + chrome on tiny terms
|
||||
|
||||
export function computeEditorMaxHeight(terminalRows: number): number {
|
||||
const rows = Number.isFinite(terminalRows) && terminalRows > 0 ? terminalRows : EDITOR_FALLBACK_ROWS;
|
||||
const desired = Math.max(EDITOR_MAX_HEIGHT_MIN, Math.min(EDITOR_MAX_HEIGHT_MAX, rows - EDITOR_RESERVED_ROWS));
|
||||
// Never let the editor crowd out the rest of the UI on small terminals.
|
||||
return Math.max(1, Math.min(desired, rows - EDITOR_MIN_CHROME_ROWS));
|
||||
}
|
||||
|
||||
const HUD_NOTE_SUP_DIGITS: Record<string, string> = {
|
||||
"0": "\u2070",
|
||||
@@ -1156,10 +1164,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
}
|
||||
|
||||
#computeEditorMaxHeight(): number {
|
||||
const rows = this.ui.terminal.rows;
|
||||
const terminalRows = Number.isFinite(rows) && rows > 0 ? rows : EDITOR_FALLBACK_ROWS;
|
||||
const maxHeight = terminalRows - EDITOR_RESERVED_ROWS;
|
||||
return Math.max(EDITOR_MAX_HEIGHT_MIN, Math.min(EDITOR_MAX_HEIGHT_MAX, maxHeight));
|
||||
return computeEditorMaxHeight(this.ui.terminal.rows);
|
||||
}
|
||||
|
||||
#syncEditorMaxHeight(): void {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { computeEditorMaxHeight } from "@oh-my-pi/pi-coding-agent/modes/interactive-mode";
|
||||
|
||||
describe("computeEditorMaxHeight", () => {
|
||||
it("caps the editor while preserving chrome rows on small terminals", () => {
|
||||
expect(computeEditorMaxHeight(30)).toBe(18);
|
||||
expect(computeEditorMaxHeight(18)).toBe(6);
|
||||
expect(computeEditorMaxHeight(8)).toBe(4);
|
||||
expect(computeEditorMaxHeight(5)).toBe(1);
|
||||
expect(computeEditorMaxHeight(Number.NaN)).toBe(12);
|
||||
expect(computeEditorMaxHeight(0)).toBe(12);
|
||||
|
||||
for (let rows = 5; rows <= 18; rows += 1) {
|
||||
expect(rows - computeEditorMaxHeight(rows)).toBeGreaterThanOrEqual(4);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed overlays without an explicit `maxHeight` dropping their bottom rows off-screen when taller than the terminal: `#resolveOverlayLayout` now defaults the height cap to the available rows, so a tall overlay is sliced to fit (and re-clamps on resize) instead of overflowing the visible region.
|
||||
|
||||
## [15.12.5] - 2026-06-13
|
||||
### Added
|
||||
|
||||
|
||||
@@ -1902,11 +1902,8 @@ export class TUI extends Container {
|
||||
width = Math.max(1, Math.min(width, availWidth));
|
||||
|
||||
// === Resolve maxHeight ===
|
||||
let maxHeight = parseSizeValue(opt.maxHeight, termHeight);
|
||||
// Clamp to available space
|
||||
if (maxHeight !== undefined) {
|
||||
maxHeight = Math.max(1, Math.min(maxHeight, availHeight));
|
||||
}
|
||||
let maxHeight = parseSizeValue(opt.maxHeight, termHeight) ?? availHeight;
|
||||
maxHeight = Math.max(1, Math.min(maxHeight, availHeight));
|
||||
|
||||
// Effective overlay height (may be clamped by maxHeight)
|
||||
const effectiveHeight = maxHeight !== undefined ? Math.min(overlayHeight, maxHeight) : overlayHeight;
|
||||
|
||||
@@ -140,6 +140,49 @@ describe("TUI overlays", () => {
|
||||
expect(term.getScrollBuffer().length).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it("clamps tall overlays without an explicit maxHeight to the available rows", async () => {
|
||||
const term = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(term);
|
||||
|
||||
tui.addChild(new LineComponent("base-", 3));
|
||||
|
||||
tui.start();
|
||||
await flushRender(term);
|
||||
|
||||
// A bottom margin reserves rows the overlay must NOT paint into. The overlay
|
||||
// has no explicit maxHeight, so before the fix it rendered all 40 lines and
|
||||
// the compositor only skipped rows past the terminal edge — ov-0..ov-(rows-1)
|
||||
// were painted, including the reserved bottom band. The maxHeight=availHeight
|
||||
// default slices the overlay to availHeight = rows - marginBottom.
|
||||
const marginBottom = 6;
|
||||
tui.showOverlay(new LineComponent("ov-", 40), { anchor: "top-center", margin: { bottom: marginBottom } });
|
||||
await flushRender(term);
|
||||
|
||||
const maxVisibleOverlayIndex = (): number => {
|
||||
let max = -1;
|
||||
for (const line of term.getViewport()) {
|
||||
const match = line.trim().match(/^ov-(\d+)$/);
|
||||
if (!match) continue;
|
||||
max = Math.max(max, Number.parseInt(match[1], 10));
|
||||
}
|
||||
return max;
|
||||
};
|
||||
|
||||
// availHeight = 24 - 6 = 18 → overlay sliced to ov-0..ov-17, nothing in the
|
||||
// reserved bottom 6 rows. The old unclamped behavior surfaced ov-18..ov-23.
|
||||
expect(maxVisibleOverlayIndex()).toBeGreaterThanOrEqual(0);
|
||||
expect(maxVisibleOverlayIndex()).toBeLessThan(24 - marginBottom);
|
||||
|
||||
term.resize(80, 10);
|
||||
await settleResize(term);
|
||||
|
||||
// availHeight = 10 - 6 = 4 → overlay re-clamped to ov-0..ov-3.
|
||||
expect(maxVisibleOverlayIndex()).toBeGreaterThanOrEqual(0);
|
||||
expect(maxVisibleOverlayIndex()).toBeLessThan(10 - marginBottom);
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("clears stale viewport content on launch", async () => {
|
||||
const term = new VirtualTerminal(40, 4);
|
||||
term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\nshell-4\r\n");
|
||||
|
||||
Reference in New Issue
Block a user