Skip to content

Commit 3734a57

Browse files
committed
Size the landing-floated overlay to its full computed height
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.
1 parent d864400 commit 3734a57

2 files changed

Lines changed: 55 additions & 16 deletions

File tree

src/tui-opentui/landing.test.ts

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
isLanding,
1717
paintLanding,
1818
} from "./shell"
19-
import { openOperatorOverlay } from "./overlays"
19+
import { makeOperatorQuestion, openOperatorOverlay } from "./overlays"
2020
import {
2121
LANDING_HINTS,
2222
LANDING_SUGGESTIONS,
@@ -328,7 +328,7 @@ describe("landing screen", () => {
328328
)
329329
})
330330

331-
test("an overlay covers the landing instead of moving it", async () => {
331+
test("an overlay covers the landing, sliding it only as far as its content needs", async () => {
332332
await withTestRenderer(
333333
async (h) => {
334334
const shell = createAppShell(h.renderer, {
@@ -345,15 +345,25 @@ describe("landing screen", () => {
345345
before.findIndex((row) => row.includes(text)),
346346
)
347347
expect(was.every((index) => index > 0)).toBe(true)
348+
// The anchors are listed top to bottom, so their positions climb
349+
// together before the overlay opens.
350+
expect(was).toEqual([...was].sort((a, b) => a - b))
348351

349352
openOperatorOverlay(shell)
350353
await settle(h)
351354
const after = rows(h)
352-
// Every landing anchor is on the row it was on: the overlay covers
353-
// the composition, it does not push it around.
354-
expect(
355-
anchors.map((text) => after.findIndex((row) => row.includes(text))),
356-
).toEqual(was)
355+
// Every landing anchor is still on screen and in the same relative
356+
// order: the overlay is not letting the composition it covers spill
357+
// off the viewport, overlap itself, or reshuffle. It may still
358+
// slide the composition (up or down a little, as the mark re-grids
359+
// for its new tier) when its own content needs more room than the
360+
// even top/bottom split would otherwise leave it.
361+
const nowAt = anchors.map((text) =>
362+
after.findIndex((row) => row.includes(text)),
363+
)
364+
expect(nowAt.every((index) => index > 0)).toBe(true)
365+
expect(nowAt).toEqual([...nowAt].sort((a, b) => a - b))
366+
expect(new Set(nowAt).size).toBe(nowAt.length)
357367
expect(h.captureCharFrame()).toContain("operator")
358368
} finally {
359369
shell.dispose()
@@ -363,6 +373,35 @@ describe("landing screen", () => {
363373
)
364374
})
365375

376+
// A question with more choices than the even top/bottom split would leave
377+
// room for used to get its list starved down to whatever that split
378+
// happened to allow — as little as one or two choices — because the float
379+
// only asked the split for one choice row of headroom. It now asks for the
380+
// overlay's real, already fraction-capped content height, so a terminal
381+
// tall enough for that content shows every choice without scrolling.
382+
test("a landing overlay with many choices shows them all when there is room", async () => {
383+
await withTestRenderer(
384+
async (h) => {
385+
const shell = createAppShell(h.renderer, {
386+
terminal: { columns: 100, rows: 36 },
387+
wireKeys: false,
388+
run: "idle",
389+
})
390+
try {
391+
openOperatorOverlay(shell)
392+
await settle(h)
393+
const frame = h.captureCharFrame()
394+
for (const choice of makeOperatorQuestion().choices) {
395+
expect(frame).toContain(choice)
396+
}
397+
} finally {
398+
shell.dispose()
399+
}
400+
},
401+
{ width: 100, height: 36 },
402+
)
403+
})
404+
366405
test("a short or narrow terminal shrinks the mark, never the prompt box", async () => {
367406
for (const size of [
368407
{ width: 100, height: 30 },

src/tui-opentui/shell.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1515,8 +1515,9 @@ function meterEquals(a: CostContextMeter | null, b: CostContextMeter | null): bo
15151515
* A floated overlay is clipped to the rows above the box so it never covers the
15161516
* thing the operator types into. Losing the tail of a long body to that clip is
15171517
* survivable; losing every choice is not, because then the surface cannot be
1518-
* answered. So the box slides down just far enough to keep the overlay's chrome
1519-
* and one choice on screen, and the starters below it pay for the move.
1518+
* answered. So the box slides down just far enough to keep the overlay's full,
1519+
* already fraction-capped height on screen, and the starters below it pay for
1520+
* the move.
15201521
*/
15211522
function landingSplitFor(
15221523
landingRows: number,
@@ -1572,14 +1573,13 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void {
15721573
const bag = internals.get(shell)
15731574
const landing = bag?.landing ?? null
15741575
const landingRows = transcriptH - padH - bottomPadH + (landing === null ? 0 : overlayH)
1576+
// The resolver already sized overlayH to the overlay's real content (list
1577+
// included) and capped it against the fraction/floor limits, so it is the
1578+
// correct minimum to ask the landing split to make room for — asking for
1579+
// less (e.g. just enough for one choice row) starves the list underneath
1580+
// the title down to nearly nothing once floatOverlayHost pins the host to it.
15751581
const split =
1576-
landing === null
1577-
? null
1578-
: landingSplitFor(
1579-
landingRows,
1580-
overlayH > 0 ? overlayHostRows(shell, shell.overlayBodyLines.length, 1) : 0,
1581-
padH,
1582-
)
1582+
landing === null ? null : landingSplitFor(landingRows, overlayH, padH)
15831583
if (bag !== undefined && landing !== null && split !== null) {
15841584
landing.above.box.height = Math.max(1, split.above)
15851585
// A new zone can seat a different tier, and a tier is a different grid, so

0 commit comments

Comments
 (0)