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