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:
metaphorics
2026-06-14 06:59:58 +09:00
parent 11622e6190
commit b28ad9a1dc
6 changed files with 79 additions and 9 deletions
+4
View File
@@ -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);
}
});
});
+4
View File
@@ -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
+2 -5
View File
@@ -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;
+43
View File
@@ -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");