Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
165 changes: 165 additions & 0 deletions src/tui/approval-prompt-visibility.test.ts
Original file line number Diff line number Diff line change
@@ -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<ApprovalSnapshot> {
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 },
);
});
});
68 changes: 68 additions & 0 deletions src/tui/overlay-body-cache-staleness.test.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
});
});
8 changes: 6 additions & 2 deletions src/tui/overlay-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/tui/overlay-overflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<message,");
// The raw body still carries the collapsed-command hint — only what
// gets painted is squeezed. On this short a terminal (CL-5750) the
// choices win the row budget over the hint text, so the rendered
// lines are not required to contain it.
expect(body).toContain("e expand");
expect(shell.layout.heights.overlay_host).toBeLessThanOrEqual(
Math.floor(SHORT.height * OVERLAY_MAX_FRACTION),
Expand Down
Loading
Loading