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
110 changes: 110 additions & 0 deletions src/tui/approval-prompt-visibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
* box's growth and over the overlay's own context text.
*/
import { describe, expect, test } from "bun:test";
import { EventEmitter } from "node:events";
import { defined } from "../../testkit/defined.js";
import { PRIMARY_ASK_OPERATOR_SOURCE } from "./gate-events.js";
import { wireGates } from "./gate-wire.js";
import { makePermissionItems, withTestRenderer } from "./harness.js";
import { appendStreamRow } from "./shell/chrome.js";
import { createAppShell } from "./shell/index.js";
Expand Down Expand Up @@ -178,3 +181,110 @@ describe("approval overlay keeps the prompt box on screen (CL-5750)", () => {
);
});
});

// A long multi-line command makes the permission body taller than the host.
// Only the first choice was guaranteed rows, so the prompt showed Reject alone
// and Accept once sat scrolled out of view (CL-11362).
const LONG_COMMAND = [
"set +e",
"TARGET=/Users/operator/code/worktrees/corbits-code/stack-1559-1561-1562-1563-cl-10768",
'FIXTURE="$(mktemp -d /tmp/corbits-linear-release.XXXXXX)"',
...Array.from(
{ length: 28 },
(_, i) =>
`echo "probe step ${i}: checking the release issue list" >> "$FIXTURE/log"`,
),
].join("\n");

const FIXED_CHOICES = ["Reject", "Reject and stop", "Accept once"] as const;

const TALL_QUESTION = [
"Which release branch should the probe target?",
...Array.from(
{ length: 30 },
(_, i) => `context line ${i}: the probe found a candidate branch`,
),
].join("\n");

const OPERATOR_CHOICES = [
"Use the current branch",
"Use the release branch",
"Skip the probe",
"Stop and ask later",
] as const;

describe("a tall approval body never hides a choice (CL-11362)", () => {
for (const [columns, rows] of [
[80, 24],
[100, 30],
] as const) {
test(`every choice is painted for a long command at ${columns}x${rows}`, async () => {
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns, rows },
run: "idle",
});
const emitter = new EventEmitter();
const dispose = wireGates(emitter, shell);
try {
primeSession(shell);
emitter.emit("permission.gate", {
id: "gate-1",
request: {
tool: "run_shell",
action: "Run shell command",
subject: LONG_COMMAND,
scopes: [],
},
resolve: () => undefined,
});
await h.renderOnce();
await h.renderOnce();
const frame = h.captureCharFrame();
for (const choice of FIXED_CHOICES) expect(frame).toContain(choice);
} finally {
dispose();
shell.dispose();
}
},
{ width: columns, height: rows },
);
});

// The operator ask is the other decision overlay: its choices are
// caller-supplied, so a tall question must not push any of them out.
test(`every operator choice is painted for a tall question at ${columns}x${rows}`, async () => {
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns, rows },
run: "idle",
});
const emitter = new EventEmitter();
const dispose = wireGates(emitter, shell);
try {
primeSession(shell);
emitter.emit("operator.gate", {
id: "ask-1",
question: TALL_QUESTION,
options: [...OPERATOR_CHOICES],
resolve: () => undefined,
source: PRIMARY_ASK_OPERATOR_SOURCE,
});
await h.renderOnce();
await h.renderOnce();
const frame = h.captureCharFrame();
for (const choice of OPERATOR_CHOICES) {
expect(frame).toContain(choice);
}
} finally {
dispose();
shell.dispose();
}
},
{ width: columns, height: rows },
);
});
}
});
13 changes: 10 additions & 3 deletions src/tui/shell/chrome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -830,8 +830,9 @@ export function paintPromptBorder(shell: AppShell): void {
shell.promptBottomRule.content = new StyledText(ruleChunks(shell, bottom));
}

/** Shrink overlay body and list to fit the host; on a short terminal,
* drop context first so one choice stays answerable. */
/** Shrink overlay body and list to fit the host, dropping context before
* choices: a decision overlay keeps every choice on screen, any other overlay
* keeps one, and a host too short for every choice still keeps one. */
function fitOverlayListToHost(shell: AppShell, hostH: number): void {
const list = shell.overlayList;
if (!list || hostH <= 0) return;
Expand All @@ -840,7 +841,13 @@ function fitOverlayListToHost(shell: AppShell, hostH: number): void {
const hasAnswer = overlayAnswerState(shell) !== null;
const perItem = overlayRowsPerItem(shell.overlayKind);
const hasItems = shell.overlayItems.length > 0;
const choiceWant = hasItems ? perItem : 0;
// A decision's first choice is Reject, so reserving one choice can leave
// the only approving choice scrolled out of view under a tall body.
const choiceWant = !hasItems
? 0
: isDecisionOverlay(shell.overlayKind)
? shell.overlayItems.length * perItem
: perItem;
const choiceMin = hasItems ? 1 : 0;
let bodyCount = shell.overlayBodyLines.length;
const chromeOf = (n: number): number =>
Expand Down
Loading