diff --git a/src/tui/approval-prompt-visibility.test.ts b/src/tui/approval-prompt-visibility.test.ts index fc8e8ef10..25ed00a93 100644 --- a/src/tui/approval-prompt-visibility.test.ts +++ b/src/tui/approval-prompt-visibility.test.ts @@ -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"; @@ -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 }, + ); + }); + } +}); diff --git a/src/tui/shell/chrome.ts b/src/tui/shell/chrome.ts index 6a4542c7f..6caadfbc9 100644 --- a/src/tui/shell/chrome.ts +++ b/src/tui/shell/chrome.ts @@ -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; @@ -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 =>