Skip to content

Commit ea0b3ce

Browse files
committed
fix(tui): keep every approval choice on screen under a tall body
1 parent 4ed79b6 commit ea0b3ce

2 files changed

Lines changed: 66 additions & 1 deletion

File tree

‎src/tui/approval-prompt-visibility.test.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
* box's growth and over the overlay's own context text.
66
*/
77
import { describe, expect, test } from "bun:test";
8+
import { EventEmitter } from "node:events";
89
import { defined } from "../../testkit/defined.js";
10+
import { wireGates } from "./gate-wire.js";
911
import { makePermissionItems, withTestRenderer } from "./harness.js";
1012
import { appendStreamRow } from "./shell/chrome.js";
1113
import { createAppShell } from "./shell/index.js";
@@ -178,3 +180,60 @@ describe("approval overlay keeps the prompt box on screen (CL-5750)", () => {
178180
);
179181
});
180182
});
183+
184+
// A long multi-line command makes the permission body taller than the host.
185+
// Only the first choice was guaranteed rows, so the prompt showed Reject alone
186+
// and Accept once sat scrolled out of view (CL-11362).
187+
const LONG_COMMAND = [
188+
"set +e",
189+
"TARGET=/Users/operator/code/worktrees/corbits-code/stack-1559-1561-1562-1563-cl-10768",
190+
'FIXTURE="$(mktemp -d /tmp/corbits-linear-release.XXXXXX)"',
191+
...Array.from(
192+
{ length: 28 },
193+
(_, i) =>
194+
`echo "probe step ${i}: checking the release issue list" >> "$FIXTURE/log"`,
195+
),
196+
].join("\n");
197+
198+
const FIXED_CHOICES = ["Reject", "Reject and stop", "Accept once"] as const;
199+
200+
describe("a tall approval body never hides a choice (CL-11362)", () => {
201+
for (const [columns, rows] of [
202+
[80, 24],
203+
[100, 30],
204+
] as const) {
205+
test(`every choice is painted for a long command at ${columns}x${rows}`, async () => {
206+
await withTestRenderer(
207+
async (h) => {
208+
const shell = createAppShell(h.renderer, {
209+
terminal: { columns, rows },
210+
run: "idle",
211+
});
212+
const emitter = new EventEmitter();
213+
const dispose = wireGates(emitter, shell);
214+
try {
215+
primeSession(shell);
216+
emitter.emit("permission.gate", {
217+
id: "gate-1",
218+
request: {
219+
tool: "run_shell",
220+
action: "Run shell command",
221+
subject: LONG_COMMAND,
222+
scopes: [],
223+
},
224+
resolve: () => undefined,
225+
});
226+
await h.renderOnce();
227+
await h.renderOnce();
228+
const frame = h.captureCharFrame();
229+
for (const choice of FIXED_CHOICES) expect(frame).toContain(choice);
230+
} finally {
231+
dispose();
232+
shell.dispose();
233+
}
234+
},
235+
{ width: columns, height: rows },
236+
);
237+
});
238+
}
239+
});

‎src/tui/shell/chrome.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -840,7 +840,13 @@ function fitOverlayListToHost(shell: AppShell, hostH: number): void {
840840
const hasAnswer = overlayAnswerState(shell) !== null;
841841
const perItem = overlayRowsPerItem(shell.overlayKind);
842842
const hasItems = shell.overlayItems.length > 0;
843-
const choiceWant = hasItems ? perItem : 0;
843+
// A decision's first choice is Reject, so reserving one choice can leave
844+
// the only approving choice scrolled out of view under a tall body.
845+
const choiceWant = !hasItems
846+
? 0
847+
: isDecisionOverlay(shell.overlayKind)
848+
? shell.overlayItems.length * perItem
849+
: perItem;
844850
const choiceMin = hasItems ? 1 : 0;
845851
let bodyCount = shell.overlayBodyLines.length;
846852
const chromeOf = (n: number): number =>

0 commit comments

Comments
 (0)