diff --git a/CHANGELOG.md b/CHANGELOG.md index 44f306fa..6d431429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,18 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename as an unhandled rejection. A failed close now short-circuits the rebuild with a clear, catchable error instead of retrying a doomed second acquisition. +### TUI + +- **The approval overlay no longer pushes the prompt box off screen or clips + itself at the bottom.** On a short terminal the fixed context budget behind + a permission/operator approval could ask for more rows than its frame had, + so the resolver fell back to sizing it below its own render minimum — the + overlay's border and choices painted past the frame, or vanished entirely, + while the prompt box was still on screen but the thing the operator needed + to answer was not. The overlay's context text now shrinks (down to dropping + it entirely on the shortest terminals) so the header, every choice's row + budget, and the prompt box at its floor always fit together; choices win the + row budget over context detail when a terminal is too short for both. ## [0.2.107] - 2026-08-24 diff --git a/src/tui/approval-prompt-visibility.test.ts b/src/tui/approval-prompt-visibility.test.ts new file mode 100644 index 00000000..9c25c652 --- /dev/null +++ b/src/tui/approval-prompt-visibility.test.ts @@ -0,0 +1,165 @@ +/** + * CL-5750: the approval surface must never push the prompt box off screen, + * and its choices must always be visible/reachable — an unanswerable + * approval deadlocks the session, so the choices win the row budget over + * the prompt box's growth and over the overlay's own context text. + */ +import { describe, expect, test } from "bun:test"; +import { withTestRenderer } from "./harness.js"; +import { createAppShell, appendStreamRow, type AppShell } from "./shell.js"; +import { openPermissionsOverlay, makePermissionItems } from "./overlays.js"; + +const WIDTH = 80; +// Deliberately spans from far below the documented 24-row baseline down to +// 10 rows, the shortest terminal this fix guarantees, plus a comfortable one. +const HEIGHTS = [10, 12, 15, 24, 40] as const; + +const APPROVAL_BODY = [ + "run_shell", + "Run shell command", + "This is context describing what the tool is about to do to the workspace.", +].join("\n"); + +function primeSession(shell: AppShell): void { + // In-flow layout: the overlay host competes with the prompt for rows the + // same way a live approval does mid-session (not the landing screen). + appendStreamRow(shell, { role: "assistant", text: "session underway" }); +} + +interface ApprovalSnapshot { + readonly frame: string; + readonly promptHeight: number; + readonly promptVisible: boolean; + readonly listHeight: number | null; + readonly hasOverlayList: boolean; +} + +async function paintApproval(height: number, itemCount = 6): Promise { + return withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: WIDTH, rows: height }, + run: "idle", + }); + try { + primeSession(shell); + openPermissionsOverlay(shell, { + items: makePermissionItems(itemCount), + body: APPROVAL_BODY, + }); + await h.renderOnce(); + await h.renderOnce(); + const frame = h.captureCharFrame().replace(/\n$/, ""); + return { + frame, + promptHeight: shell.layout.heights.prompt, + promptVisible: shell.promptBox.visible, + listHeight: shell.overlayList?.height ?? null, + hasOverlayList: shell.overlayList !== null, + }; + } finally { + shell.dispose(); + } + }, + { width: WIDTH, height }, + ); +} + +describe("approval overlay keeps the prompt box on screen (CL-5750)", () => { + for (const height of HEIGHTS) { + test(`prompt box stays visible and unclipped at ${height} rows`, async () => { + const snap = await paintApproval(height); + + // The prompt box is always assigned rows, never displaced entirely. + expect(snap.promptHeight).toBeGreaterThan(0); + expect(snap.promptVisible).toBe(true); + + // The painted frame actually shows the prompt box's border, not just + // internal state — the bug was a visual displacement, not a state one. + const lines = snap.frame.split("\n"); + expect(lines.length).toBeLessThanOrEqual(height); + const promptTopBorder = lines.findIndex((l) => l.includes("╭")); + const promptBottomBorder = lines.findIndex((l) => l.includes("╰")); + expect(promptTopBorder).toBeGreaterThanOrEqual(0); + expect(promptBottomBorder).toBeGreaterThan(promptTopBorder); + // The prompt box's own bottom rule (with the cwd/branding) must be + // fully painted within the frame, not cut off past the last row. + expect(promptBottomBorder).toBeLessThan(lines.length); + }); + + test(`at least one approval choice is painted and reachable at ${height} rows`, async () => { + const snap = await paintApproval(height); + + expect(snap.hasOverlayList).toBe(true); + // The active choice is always inside the viewport window state... + expect(snap.listHeight).toBeGreaterThanOrEqual(1); + + // ...and it is actually painted on screen, not just tracked in state: + // the marked active choice's label must appear in the frame. + expect(snap.frame).toContain("Allow once"); + }); + } + + test("overlay host never displaces the prompt box out of the frame even with a tall body", async () => { + const tallBody = Array.from({ length: 20 }, (_, i) => `context line ${i}`).join("\n"); + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: WIDTH, rows: 12 }, + run: "idle", + }); + try { + primeSession(shell); + openPermissionsOverlay(shell, { items: makePermissionItems(10), body: tallBody }); + await h.renderOnce(); + await h.renderOnce(); + const frame = h.captureCharFrame().replace(/\n$/, ""); + const lines = frame.split("\n"); + expect(lines.length).toBeLessThanOrEqual(12); + expect(shell.layout.heights.prompt).toBeGreaterThan(0); + expect(lines.some((l) => l.includes("╭"))).toBe(true); + expect(lines.some((l) => l.includes("╰"))).toBe(true); + expect(shell.overlayList).not.toBeNull(); + expect(shell.overlayList!.height).toBeGreaterThanOrEqual(1); + } finally { + shell.dispose(); + } + }, + { width: WIDTH, height: 12 }, + ); + }); + + test("no dead space between the transcript and the overlay: overlay sits directly above the prompt", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: WIDTH, rows: 24 }, + run: "idle", + }); + try { + primeSession(shell); + openPermissionsOverlay(shell, { items: makePermissionItems(6), body: APPROVAL_BODY }); + await h.renderOnce(); + await h.renderOnce(); + const heights = shell.layout.heights; + // Paint order stacks transcript, then overlay_host, then the other + // zones, then prompt — with nothing charged a height between the + // overlay and the prompt box, the overlay's bottom edge abuts the + // zones immediately above the prompt rather than leaving a gap. + // These zones are all off in this idle, no-task/no-agents scenario, + // so nothing should separate the overlay from the prompt. + const between = + heights.agents + + heights.task + + heights.plugin_banner + + heights.command_banner + + heights.settings_notice; + expect(between).toBe(0); + } finally { + shell.dispose(); + } + }, + { width: WIDTH, height: 24 }, + ); + }); +}); diff --git a/src/tui/overlay-body-cache-staleness.test.ts b/src/tui/overlay-body-cache-staleness.test.ts new file mode 100644 index 00000000..5a595368 --- /dev/null +++ b/src/tui/overlay-body-cache-staleness.test.ts @@ -0,0 +1,68 @@ +/** + * CL-5750 follow-up: a palette stacked over an open approval must not + * clobber the approval's cached raw body text, which a resize re-shapes + * against the new height's context budget (see `decisionContextBudget` / + * `applyOverlayBodyText` in shell.ts). + */ +import { describe, expect, test } from "bun:test"; +import { withTestRenderer } from "./harness.js"; +import { + createAppShell, + appendStreamRow, + closeInsetOverlay, + openPalette, + type AppShell, +} from "./shell.js"; +import { openPermissionsOverlay, makePermissionItems } from "./overlays.js"; + +function primeSession(shell: AppShell): void { + appendStreamRow(shell, { role: "assistant", text: "session underway" }); +} + +describe("decision overlay body cache survives a stacked palette", () => { + test("resize after popping a stacked palette re-shapes the approval's own body, not a blanked one", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }); + try { + primeSession(shell); + openPermissionsOverlay(shell, { + items: makePermissionItems(3), + body: "run_shell\nRun shell command\nSome context about the risky command.", + }); + await h.renderOnce(); + expect(shell.overlayBodyLines.length).toBeGreaterThan(0); + + // Stack a palette over the open permissions overlay — its own + // (empty) body must not overwrite the approval's cached raw text. + openPalette(shell, { + catalog: [{ id: "foo", label: "foo" }], + title: "commands", + }); + await h.renderOnce(); + expect(shell.overlayKind).toBe("palette"); + + // Pop the palette back to the permissions overlay underneath. + closeInsetOverlay(shell); + await h.renderOnce(); + expect(shell.overlayKind).toBe("permissions"); + expect(shell.overlayBodyLines.length).toBeGreaterThan(0); + + // Resize: the body must still show the permission context, not be + // blanked by re-shaping from the palette's stale empty cache. + h.resize(80, 20); + await h.renderOnce(); + await h.renderOnce(); + expect(shell.overlayBodyLines.length).toBeGreaterThan(0); + expect(shell.overlayBodyLines.join("\n")).toContain("run_shell"); + } finally { + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); +}); diff --git a/src/tui/overlay-body.ts b/src/tui/overlay-body.ts index eacfa62f..c01bad2c 100644 --- a/src/tui/overlay-body.ts +++ b/src/tui/overlay-body.ts @@ -152,7 +152,11 @@ export function composeDecisionBody( width: number, contextLines: number, ): OverlayBodyRow[] { - const budget = Math.max(1, Math.floor(contextLines)); + // Zero is a valid budget: on a terminal too short to spare a row of air + // plus a line of context on top of the header, the context section is + // dropped entirely rather than forced to cost at least one row it cannot + // afford — the choices below it must win that row instead. + const budget = Math.max(0, Math.floor(contextLines)); const lines = text.split("\n"); const headIndex = lines.findIndex((l) => l.trim().length > 0); if (headIndex < 0) return []; @@ -168,7 +172,7 @@ export function composeDecisionBody( }); }); - const rest = lines.slice(headIndex + 1).filter((l) => l.trim().length > 0); + const rest = budget > 0 ? lines.slice(headIndex + 1).filter((l) => l.trim().length > 0) : []; if (rest.length > 0) { rows.push({ text: "", fg: UI.textDim }); // Continuation rows are indented so a wrapped chain segment can never be diff --git a/src/tui/overlay-overflow.test.ts b/src/tui/overlay-overflow.test.ts index 73bef2c6..0e3f7be3 100644 --- a/src/tui/overlay-overflow.test.ts +++ b/src/tui/overlay-overflow.test.ts @@ -351,7 +351,10 @@ describe("gate-wire approval overflow on short terminal", () => { emitter.emit("permission.gate", { request, resolve: () => {} }); const body = permissionBodyFromRequest(request, { hint: true }); - expect(shell.overlayBodyLines.join("\n")).toContain(" r.text); shell.overlayBodyFgs = rows.map((r) => r.fg); return; @@ -5896,6 +5976,13 @@ export function createAppShell(renderer: ShellRenderer, options?: AppShellOption const onResize = (width: number, height: number): void => { if (disposed) return; const bag = internals.get(shell); + // A decision overlay's body was shaped against the old height's context + // budget; a shorter terminal can no longer afford as much of it without + // crowding out the choices, so it is re-shaped before asking for rows. + if (shell.overlayList && isDecisionOverlay(shell.overlayKind) && bag) { + applyOverlayBodyText(shell, bag.overlayRawBodyText, 0, height); + relayoutOverlayHost(shell, shell.overlayItems.length); + } relayout(shell, { columns: width, rows: height, @@ -6015,6 +6102,7 @@ export function createAppShell(renderer: ShellRenderer, options?: AppShellOption overlayMode: "closed", overlayBodyRows: undefined, overlayMinBodyRows: undefined, + overlayRawBodyText: "", priorOverlay: null, overlayItemIds: [], overlayItemValues: [],