From 3734a57f03c040ac9ac931f87bbe5f810dd4e1cb Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 6 Aug 2026 21:15:16 -0700 Subject: [PATCH] Size the landing-floated overlay to its full computed height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate can fire before any transcript row exists, while the landing composition still owns the screen. The float only asked the landing split for enough headroom to fit one choice row, so a request with more choices than that got its list cut down to whatever the even top/bottom split happened to leave above the prompt box — the title and body painted, most or all of the choices did not, leaving the operator with no visible way to answer. --- src/tui-opentui/landing.test.ts | 53 ++++++++++++++++++++++++++++----- src/tui-opentui/shell.ts | 18 +++++------ 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/src/tui-opentui/landing.test.ts b/src/tui-opentui/landing.test.ts index 071987a1f..1938da0b5 100644 --- a/src/tui-opentui/landing.test.ts +++ b/src/tui-opentui/landing.test.ts @@ -16,7 +16,7 @@ import { isLanding, paintLanding, } from "./shell" -import { openOperatorOverlay } from "./overlays" +import { makeOperatorQuestion, openOperatorOverlay } from "./overlays" import { LANDING_HINTS, LANDING_SUGGESTIONS, @@ -328,7 +328,7 @@ describe("landing screen", () => { ) }) - test("an overlay covers the landing instead of moving it", async () => { + test("an overlay covers the landing, sliding it only as far as its content needs", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -345,15 +345,25 @@ describe("landing screen", () => { before.findIndex((row) => row.includes(text)), ) expect(was.every((index) => index > 0)).toBe(true) + // The anchors are listed top to bottom, so their positions climb + // together before the overlay opens. + expect(was).toEqual([...was].sort((a, b) => a - b)) openOperatorOverlay(shell) await settle(h) const after = rows(h) - // Every landing anchor is on the row it was on: the overlay covers - // the composition, it does not push it around. - expect( - anchors.map((text) => after.findIndex((row) => row.includes(text))), - ).toEqual(was) + // Every landing anchor is still on screen and in the same relative + // order: the overlay is not letting the composition it covers spill + // off the viewport, overlap itself, or reshuffle. It may still + // slide the composition (up or down a little, as the mark re-grids + // for its new tier) when its own content needs more room than the + // even top/bottom split would otherwise leave it. + const nowAt = anchors.map((text) => + after.findIndex((row) => row.includes(text)), + ) + expect(nowAt.every((index) => index > 0)).toBe(true) + expect(nowAt).toEqual([...nowAt].sort((a, b) => a - b)) + expect(new Set(nowAt).size).toBe(nowAt.length) expect(h.captureCharFrame()).toContain("operator") } finally { shell.dispose() @@ -363,6 +373,35 @@ describe("landing screen", () => { ) }) + // A question with more choices than the even top/bottom split would leave + // room for used to get its list starved down to whatever that split + // happened to allow — as little as one or two choices — because the float + // only asked the split for one choice row of headroom. It now asks for the + // overlay's real, already fraction-capped content height, so a terminal + // tall enough for that content shows every choice without scrolling. + test("a landing overlay with many choices shows them all when there is room", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 100, rows: 36 }, + wireKeys: false, + run: "idle", + }) + try { + openOperatorOverlay(shell) + await settle(h) + const frame = h.captureCharFrame() + for (const choice of makeOperatorQuestion().choices) { + expect(frame).toContain(choice) + } + } finally { + shell.dispose() + } + }, + { width: 100, height: 36 }, + ) + }) + test("a short or narrow terminal shrinks the mark, never the prompt box", async () => { for (const size of [ { width: 100, height: 30 }, diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index 8ff638b43..432d9f872 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -1515,8 +1515,9 @@ function meterEquals(a: CostContextMeter | null, b: CostContextMeter | null): bo * A floated overlay is clipped to the rows above the box so it never covers the * thing the operator types into. Losing the tail of a long body to that clip is * survivable; losing every choice is not, because then the surface cannot be - * answered. So the box slides down just far enough to keep the overlay's chrome - * and one choice on screen, and the starters below it pay for the move. + * answered. So the box slides down just far enough to keep the overlay's full, + * already fraction-capped height on screen, and the starters below it pay for + * the move. */ function landingSplitFor( landingRows: number, @@ -1572,14 +1573,13 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void { const bag = internals.get(shell) const landing = bag?.landing ?? null const landingRows = transcriptH - padH - bottomPadH + (landing === null ? 0 : overlayH) + // The resolver already sized overlayH to the overlay's real content (list + // included) and capped it against the fraction/floor limits, so it is the + // correct minimum to ask the landing split to make room for — asking for + // less (e.g. just enough for one choice row) starves the list underneath + // the title down to nearly nothing once floatOverlayHost pins the host to it. const split = - landing === null - ? null - : landingSplitFor( - landingRows, - overlayH > 0 ? overlayHostRows(shell, shell.overlayBodyLines.length, 1) : 0, - padH, - ) + landing === null ? null : landingSplitFor(landingRows, overlayH, padH) if (bag !== undefined && landing !== null && split !== null) { landing.above.box.height = Math.max(1, split.above) // A new zone can seat a different tier, and a tier is a different grid, so