Skip to content

Commit a3361c5

Browse files
committed
Give the landing-floated permission and operator overlays their 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 cf3bb84 commit a3361c5

2 files changed

Lines changed: 49 additions & 16 deletions

File tree

src/tui-opentui/landing.test.ts

Lines changed: 40 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, {
@@ -349,11 +349,15 @@ describe("landing screen", () => {
349349
openOperatorOverlay(shell)
350350
await settle(h)
351351
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)
352+
// Every landing anchor is still on screen: the overlay is not
353+
// letting the composition it covers spill off the viewport. It may
354+
// still slide the composition (up or down a little, as the mark
355+
// re-grids for its new tier) when its own content needs more room
356+
// than the even top/bottom split would otherwise leave it.
357+
const nowAt = anchors.map((text) =>
358+
after.findIndex((row) => row.includes(text)),
359+
)
360+
expect(nowAt.every((index) => index > 0)).toBe(true)
357361
expect(h.captureCharFrame()).toContain("operator")
358362
} finally {
359363
shell.dispose()
@@ -363,6 +367,35 @@ describe("landing screen", () => {
363367
)
364368
})
365369

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

src/tui-opentui/shell.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1499,8 +1499,9 @@ function meterEquals(a: CostContextMeter | null, b: CostContextMeter | null): bo
14991499
* A floated overlay is clipped to the rows above the box so it never covers the
15001500
* thing the operator types into. Losing the tail of a long body to that clip is
15011501
* survivable; losing every choice is not, because then the surface cannot be
1502-
* answered. So the box slides down just far enough to keep the overlay's chrome
1503-
* and one choice on screen, and the starters below it pay for the move.
1502+
* answered. So the box slides down just far enough to keep the overlay's full,
1503+
* already fraction-capped height on screen, and the starters below it pay for
1504+
* the move.
15041505
*/
15051506
function landingSplitFor(
15061507
landingRows: number,
@@ -1556,14 +1557,13 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void {
15561557
const bag = internals.get(shell)
15571558
const landing = bag?.landing ?? null
15581559
const landingRows = transcriptH - padH - bottomPadH + (landing === null ? 0 : overlayH)
1560+
// The resolver already sized overlayH to the overlay's real content (list
1561+
// included) and capped it against the fraction/floor limits, so it is the
1562+
// correct minimum to ask the landing split to make room for — asking for
1563+
// less (e.g. just enough for one choice row) starves the list underneath
1564+
// the title down to nearly nothing once floatOverlayHost pins the host to it.
15591565
const split =
1560-
landing === null
1561-
? null
1562-
: landingSplitFor(
1563-
landingRows,
1564-
overlayH > 0 ? overlayHostRows(shell, shell.overlayBodyLines.length, 1) : 0,
1565-
padH,
1566-
)
1566+
landing === null ? null : landingSplitFor(landingRows, overlayH, padH)
15671567
if (bag !== undefined && landing !== null && split !== null) {
15681568
landing.above.box.height = Math.max(1, split.above)
15691569
// A new zone can seat a different tier, and a tier is a different grid, so

0 commit comments

Comments
 (0)