From cf895c5952ca26946eb9b8fcb1a34fef14720bd0 Mon Sep 17 00:00:00 2001 From: Ben Wachman Date: Sat, 10 Oct 2026 00:00:07 -0400 Subject: [PATCH 1/2] refactor(tui): share the decision-gate stop path --- src/tui/shell/keys.ts | 5 ++--- src/tui/shell/overlay-host.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/tui/shell/keys.ts b/src/tui/shell/keys.ts index 1897ef5ba..0cf156169 100644 --- a/src/tui/shell/keys.ts +++ b/src/tui/shell/keys.ts @@ -30,6 +30,7 @@ import { exitOverlayAnswerMode, handleOverlayAnswerKey, notifyOverlayClosed, + stopDecisionGate, } from "./overlay-host.js"; import { applyFocus, @@ -413,9 +414,7 @@ export function createShellKeyHandlers( // way Esc does, then interrupts the turn, which drops queued gates too. if (key.ctrl && key.name === "c" && isDecisionGate(shell)) { key.preventDefault(); - abortOverlayHostReservations(shell); - closeInsetOverlay(shell); - interruptShell(shell); + stopDecisionGate(shell); return; } // Checked before the filter handlers: an opener pressed again closes, diff --git a/src/tui/shell/overlay-host.ts b/src/tui/shell/overlay-host.ts index fd022334b..78e69f70e 100644 --- a/src/tui/shell/overlay-host.ts +++ b/src/tui/shell/overlay-host.ts @@ -51,6 +51,7 @@ import { relayout, setStatusFlash, } from "./chrome.js"; +import { interruptShell } from "./prompt.js"; function refreshOverlayTitle(shell: AppShell): void { const bag = shellInternals(shell); @@ -639,6 +640,13 @@ export function abortOverlayHostReservations(shell: AppShell): void { scheduleDeferredCommandFlush(shell); } +/** Reject a decision gate and stop the turn, matching its Ctrl+C behavior. */ +export function stopDecisionGate(shell: AppShell): void { + abortOverlayHostReservations(shell); + closeInsetOverlay(shell); + interruptShell(shell); +} + /** One deferred command-surface slot while the host is busy. */ function deferBusyCommandOpen( shell: AppShell, From 34e517c244cd2990a07c4322934d6ace7109617f Mon Sep 17 00:00:00 2001 From: Ben Wachman Date: Sat, 10 Oct 2026 00:00:14 -0400 Subject: [PATCH 2/2] feat(tui): add Reject and stop to the permission prompt --- src/tui/allow-once-reprompt.test.ts | 41 ++++++++++--------- src/tui/decision-truncation.test.ts | 1 + src/tui/gate-wire.test.ts | 41 ++++++++++++++----- src/tui/gate-wire.ts | 24 ++++++++++-- src/tui/overlay-overflow.test.ts | 6 +-- src/tui/overlay-view.test.ts | 47 ++++++++++++++++++++++ src/tui/overlay-view.ts | 14 +++++-- src/tui/overlays.test.ts | 17 +++++++- src/tui/runtime-bridge.test.ts | 61 ++++++++++++++++++++++++++++- 9 files changed, 210 insertions(+), 42 deletions(-) diff --git a/src/tui/allow-once-reprompt.test.ts b/src/tui/allow-once-reprompt.test.ts index cde9f8e93..88115b6e7 100644 --- a/src/tui/allow-once-reprompt.test.ts +++ b/src/tui/allow-once-reprompt.test.ts @@ -44,9 +44,12 @@ const destructiveRequest = (subject: string): PermissionRequest => ({ scopes: [], }); -/** Accept the highlighted choice after moving to `index` (0 Reject, 1 Accept once). */ -function acceptChoice(shell: AppShell, index: 0 | 1 | 2): void { - for (let i = 0; i < index; i++) moveOverlaySelection(shell, 1); +/** Move the highlight to the row labelled `label`, then accept it. */ +function acceptChoice(shell: AppShell, label: string): void { + const target = shell.overlayItems.indexOf(label); + if (target < 0) throw new Error(`no overlay row labelled ${label}`); + const list = defined(shell.overlayList, "overlayList"); + moveOverlaySelection(shell, target - list.activeIndex); acceptOverlaySelection(shell); } @@ -138,7 +141,7 @@ describe("CL-8792 gate level: a second destructive evaluation re-prompts after a const first = gate.evaluate(shellCall(command)); await flushGateRaise(); expect(shell.overlayKind).toBe("permissions"); - acceptChoice(shell, 1); + acceptChoice(shell, "Accept once"); const firstVerdict = await settledWithin(first, 500); expect(firstVerdict.settled).toBe(true); if (!firstVerdict.settled) throw new Error("first evaluation hung"); @@ -148,7 +151,7 @@ describe("CL-8792 gate level: a second destructive evaluation re-prompts after a await flushGateRaise(); // A grant would auto-allow with no overlay; allow-once must re-prompt. expect(shell.overlayKind).toBe("permissions"); - acceptChoice(shell, 1); + acceptChoice(shell, "Accept once"); const verdict = await settledWithin(second, 500); expect(verdict.settled).toBe(true); if (!verdict.settled) throw new Error("second evaluation hung"); @@ -173,12 +176,12 @@ describe("CL-8792 gate level: a second destructive evaluation re-prompts after a // Accept once on the first card: the queued card must take the host // before the deferred slash, and both evaluations must settle. - acceptChoice(shell, 1); + acceptChoice(shell, "Accept once"); const firstVerdict = await settledWithin(first, 500); expect(firstVerdict.settled).toBe(true); expect(shell.overlayKind).toBe("permissions"); - acceptChoice(shell, 1); + acceptChoice(shell, "Accept once"); const secondVerdict = await settledWithin(second, 500); expect(secondVerdict.settled).toBe(true); if (!secondVerdict.settled) throw new Error("second evaluation hung"); @@ -211,12 +214,12 @@ describe("CL-8792 overlay host: queued cards outrank deferred surfaces", () => { }, }); - acceptChoice(shell, 1); + acceptChoice(shell, "Accept once"); expect(resolvedA).toEqual({ allow: true }); expect(shell.overlayKind).toBe("permissions"); expect(shell.overlayItems).toContain("Accept once"); - acceptChoice(shell, 1); + acceptChoice(shell, "Accept once"); expect(resolvedB).toEqual({ allow: true }); // With every gate settled, the deferred slash finally takes the host. @@ -246,11 +249,11 @@ describe("CL-8792 overlay host: queued cards outrank deferred surfaces", () => { }, }); - acceptChoice(shell, 0); + acceptChoice(shell, "Reject"); expect(resolvedA).toEqual({ allow: false }); expect(shell.overlayKind).toBe("permissions"); - acceptChoice(shell, 1); + acceptChoice(shell, "Accept once"); expect(resolvedB).toEqual({ allow: true }); }); }); @@ -286,14 +289,14 @@ describe("CL-8792 overlay host: queued cards outrank deferred surfaces", () => { }, }); - acceptChoice(shell, 2); + acceptChoice(shell, "Allow rm *"); expect(resolvedA).toMatchObject({ allow: true, persist: { id: "scope-a" }, }); expect(shell.overlayKind).toBe("permissions"); - acceptChoice(shell, 1); + acceptChoice(shell, "Accept once"); expect(resolvedB).toEqual({ allow: true }); }); }); @@ -313,7 +316,7 @@ describe("CL-8792 overlay host: queued cards outrank deferred surfaces", () => { }); expect(shell.overlayKind).toBe("permissions"); - acceptChoice(shell, 1); + acceptChoice(shell, "Accept once"); expect(resolved).toEqual({ allow: true }); expect(shell.overlayKind).toBe("help"); expect(shell.overlayItems).toEqual(["Help"]); @@ -350,7 +353,7 @@ describe("CL-8792 overlay host: queued cards outrank deferred surfaces", () => { await new Promise((resolve) => setTimeout(resolve, 80)); expect(resolvedB).toBeUndefined(); - acceptChoice(shell, 1); + acceptChoice(shell, "Accept once"); expect(resolvedA).toEqual({ allow: true }); expect(shell.overlayKind).toBe("permissions"); @@ -428,7 +431,7 @@ describe("CL-8792 elapsed: pending tool row freezes while a gate is outstanding await h.renderOnce(); expect(stat()).toBe("0:10"); - acceptChoice(shell, 1); + acceptChoice(shell, "Accept once"); expect(resolved).toEqual({ allow: true }); bridge.gateClosed(); } finally { @@ -466,7 +469,7 @@ describe("CL-8792 overlay host: suspend preserves the surface instead of dismiss expect(shell.overlayKind).toBe("permissions"); expect(events).toEqual([]); - acceptChoice(shell, 1); + acceptChoice(shell, "Accept once"); expect(resolved).toEqual({ allow: true }); expect(shell.overlayKind).toBe("help"); expect(events).toEqual([]); @@ -493,7 +496,7 @@ describe("CL-8792 overlay host: suspend preserves the surface instead of dismiss }); expect(shell.overlayKind).toBe("permissions"); - acceptChoice(shell, 1); + acceptChoice(shell, "Accept once"); expect(resolved).toEqual({ allow: true }); expect(shell.overlayKind).toBe("help"); expect(shell.overlayList).toBe(list); @@ -538,7 +541,7 @@ describe("CL-8792 overlay host: suspend preserves the surface instead of dismiss expect(shell.overlayKind).toBe("permissions"); expect(shell.overlayItems).toContain("Accept once"); - acceptChoice(shell, 1); + acceptChoice(shell, "Accept once"); expect(resolved).toEqual({ allow: true }); expect(cancelOpens).toBe(0); expect(shell.overlayKind).toBe("mcp"); diff --git a/src/tui/decision-truncation.test.ts b/src/tui/decision-truncation.test.ts index fd210987e..d92374c60 100644 --- a/src/tui/decision-truncation.test.ts +++ b/src/tui/decision-truncation.test.ts @@ -110,6 +110,7 @@ describe("decision choice rendering", () => { // Choices are bare action names. expect(shell.overlayItems).toEqual([ "Reject", + "Reject and stop", "Accept once", "Allow always", ]); diff --git a/src/tui/gate-wire.test.ts b/src/tui/gate-wire.test.ts index f5fb1b6e5..c5cea0e94 100644 --- a/src/tui/gate-wire.test.ts +++ b/src/tui/gate-wire.test.ts @@ -34,6 +34,7 @@ import { operatorChoicesFromOptions, operatorResultFromSelection, PERMISSION_DENY_ID, + PERMISSION_STOP_ID, PERMISSION_ONCE_ID, permissionBodyFromRequest, permissionChoicesFromRequest, @@ -136,14 +137,19 @@ function settleCapture(): { } describe("permissionChoicesFromRequest", () => { - test("always includes reject + accept once", () => { + test("always includes reject, reject and stop, and accept once", () => { const choices = permissionChoicesFromRequest(baseRequest(), "req-1"); - expect(choices.items).toEqual(["Reject", "Accept once"]); + expect(choices.items).toEqual(["Reject", "Reject and stop", "Accept once"]); expect(choices.itemIds).toEqual([ `req-1:${PERMISSION_DENY_ID}`, + `req-1:${PERMISSION_STOP_ID}`, `req-1:${PERMISSION_ONCE_ID}`, ]); - expect(choices.outcomes).toEqual([{ allow: false }, { allow: true }]); + expect(choices.outcomes).toEqual([ + { allow: false }, + { allow: false }, + { allow: true }, + ]); }); test("appends scopes as bare labels; persist only when pattern set", () => { @@ -167,21 +173,26 @@ describe("permissionChoicesFromRequest", () => { ); expect(choices.items).toEqual([ "Reject", + "Reject and stop", "Accept once", "Allow git *", "Allow this path", ]); expect(choices.itemIds).toEqual([ `req-1:${PERMISSION_DENY_ID}`, + `req-1:${PERMISSION_STOP_ID}`, `req-1:${PERMISSION_ONCE_ID}`, "req-1:session-git", "req-1:once-extra", ]); - expect(choices.outcomes[2]).toEqual({ + // Reject and stop never carries a grant, even when a persistable scope is + // on offer. + expect(choices.outcomes[1]).toStrictEqual({ allow: false }); + expect(choices.outcomes[3]).toEqual({ allow: true, persist: scopeWithPattern, }); - expect(choices.outcomes[3]).toEqual({ allow: true }); + expect(choices.outcomes[4]).toEqual({ allow: true }); }); }); @@ -208,6 +219,12 @@ describe("approvalOutcomeFromSelection", () => { ).toEqual({ allow: false, }); + expect( + approvalOutcomeFromSelection(choices, { + index: 0, + id: `req-1:${PERMISSION_STOP_ID}`, + }), + ).toEqual({ allow: false }); expect( approvalOutcomeFromSelection(choices, { index: 0, @@ -411,7 +428,11 @@ describe("wireGates", () => { const settled = settleCapture(); emitPermission(emitter, { resolve: settled.resolve }); expect(shell.overlayKind).toBe("permissions"); - expect(shell.overlayItems).toEqual(["Reject", "Accept once"]); + expect(shell.overlayItems).toEqual([ + "Reject", + "Reject and stop", + "Accept once", + ]); acceptOverlaySelection(shell); expect(settled.get()).toEqual({ allow: false }); @@ -880,7 +901,7 @@ describe("wireGates", () => { expect(shell.overlayKind).toBe("permissions"); expect( shell.overlayList?.select.options.map((option) => option.name), - ).toEqual(["Reject", "Accept once", "Allow git A"]); + ).toEqual(["Reject", "Reject and stop", "Accept once", "Allow git A"]); closeInsetOverlay(shell); expect(settledA.get()).toEqual({ allow: false }); @@ -898,11 +919,13 @@ describe("wireGates", () => { const painted = shell.overlayList?.select.options ?? []; expect(painted.map((option) => option.name)).toEqual([ "Reject", + "Reject and stop", "Accept once", "Allow git B", ]); expect(painted.map((option) => option.value)).toEqual([ `req-b:${PERMISSION_DENY_ID}`, + `req-b:${PERMISSION_STOP_ID}`, `req-b:${PERMISSION_ONCE_ID}`, "req-b:scope-b", ]); @@ -1539,8 +1562,8 @@ describe("permission overlay height", () => { }; test("tracks item count, not terminal height", async () => { - const short = await hostRowsFor(30, 1); - const tall = await hostRowsFor(60, 1); + const short = await hostRowsFor(60, 1); + const tall = await hostRowsFor(80, 1); expect(short).toBe(tall); // Two extra choices cost exactly four extra rows (wrap padding), so the diff --git a/src/tui/gate-wire.ts b/src/tui/gate-wire.ts index 6f858df25..04f527bc8 100644 --- a/src/tui/gate-wire.ts +++ b/src/tui/gate-wire.ts @@ -20,6 +20,7 @@ import { onOverlayClosed, resumeSuspendedCommandSurface, setOverlayBody, + stopDecisionGate, suspendReplaceableOverlay, } from "./shell/overlay-host.js"; import { EXPAND_KEY } from "./stream.js"; @@ -36,6 +37,7 @@ import { /** Stable sentinel ids for the always-present deny / once rows. */ export const PERMISSION_DENY_ID = "__deny__" as const; +export const PERMISSION_STOP_ID = "__stop__" as const; export const PERMISSION_ONCE_ID = "__once__" as const; /** @@ -59,9 +61,11 @@ export interface GateSelection { } /** - * Rows from a live PermissionRequest. Labels stay bare action names; scope - * hints paint in the body (permissionBodyFromRequest), not inside a choice - * row. + * Build permission overlay rows from a live PermissionRequest. + * Order: Reject → Reject and stop → Accept once → request.scopes. Labels are + * bare so the choice list stays short action names; each scope's hint paints + * in the body above the list (see permissionBodyFromRequest) instead of being + * truncated inside a choice row. */ export function permissionChoicesFromRequest( request: PermissionRequest, @@ -76,6 +80,10 @@ export function permissionChoicesFromRequest( itemIds.push(rowId(PERMISSION_DENY_ID)); outcomes.push({ allow: false }); + items.push("Reject and stop"); + itemIds.push(rowId(PERMISSION_STOP_ID)); + outcomes.push({ allow: false }); + items.push("Accept once"); itemIds.push(rowId(PERMISSION_ONCE_ID)); outcomes.push({ allow: true }); @@ -393,6 +401,16 @@ export function wireGates( echoChoice: false, ...(collapsedAnything ? { onToggleExpand } : {}), onAccept: (sel: OverlaySelection) => { + if (sel.id === `${ev.id}:${PERMISSION_STOP_ID}`) { + settle( + approvalOutcomeFromSelection(choices, { + index: sel.index, + id: sel.id, + }), + ); + stopDecisionGate(shell); + return; + } const gateSelection = { index: sel.index, ...(sel.id !== undefined ? { id: sel.id } : {}), diff --git a/src/tui/overlay-overflow.test.ts b/src/tui/overlay-overflow.test.ts index 25d483379..bf212d9dc 100644 --- a/src/tui/overlay-overflow.test.ts +++ b/src/tui/overlay-overflow.test.ts @@ -299,9 +299,9 @@ describe("gate-wire approval overflow on short terminal", () => { expect(shell.layout.heights.overlay_host).toBeLessThanOrEqual( Math.floor(SHORT.height * OVERLAY_MAX_FRACTION), ); - // Two base choices + one scope still navigable. - expect(shell.overlayItems.length).toBe(3); - moveOverlaySelection(shell, 2); + // Three base choices + one scope still navigable. + expect(shell.overlayItems.length).toBe(4); + moveOverlaySelection(shell, 3); activeVisible(shell); dispose(); }); diff --git a/src/tui/overlay-view.test.ts b/src/tui/overlay-view.test.ts index 11526c47a..9e0851fc5 100644 --- a/src/tui/overlay-view.test.ts +++ b/src/tui/overlay-view.test.ts @@ -238,6 +238,53 @@ describe("overlay view", () => { }); }); + test("permission title hints keep Ctrl+C stop down to contentWidth 28", async () => { + await withTestRenderer(async (h) => { + const view = createOverlayView(h.renderer); + h.renderer.root.add(view.host); + const title: OverlayTitlePresentation = { + title: "Permissions", + kind: "permissions", + hasChoices: true, + answer: null, + mcpManageHint: false, + mcpAddHint: false, + }; + const titleText = () => + view.title.content.chunks.map((chunk) => chunk.text).join(""); + + // Interior is contentWidth - 2, so each tier's narrowest width is its + // line length + 2. Both sides of every transition are pinned, plus the + // width where "Ctrl+C stop" drops and the title falls back to its name. + const tier1 = + " Permissions · Esc cancel · Enter choose · Ctrl+C stop · /yolo skip prompts"; + const tier2 = " Permissions · Esc · Enter · Ctrl+C stop · /yolo"; + const tier3 = " Permissions · Esc · Enter · Ctrl+C stop"; + const tier4 = " Permissions · Ctrl+C stop"; + const bare = " Permissions"; + const expected: readonly [number, string][] = [ + [120, tier1], + [78, tier1], + [77, tier1], + [76, tier2], + [51, tier2], + [50, tier2], + [49, tier3], + [43, tier3], + [42, tier3], + [41, tier4], + [29, tier4], + [28, tier4], + [27, bare], + [20, bare], + ]; + for (const [width, line] of expected) { + view.paintTitle(title, width); + expect(titleText()).toBe(line); + } + }); + }); + test("intrinsic chrome charges answer and description once and palette omits title", () => { const full = overlayChromeRows("model_picker", 2, true, true); const bare = overlayChromeRows("model_picker", 2, false, false); diff --git a/src/tui/overlay-view.ts b/src/tui/overlay-view.ts index 0310034ab..471fa34f1 100644 --- a/src/tui/overlay-view.ts +++ b/src/tui/overlay-view.ts @@ -201,11 +201,17 @@ const MODEL_PICKER_HINTS_REMOVE_ONLY = [ "Esc · Enter", ] as const; -/** Permissions only: name `/yolo` so skip-prompts is discoverable at the ask. */ +/** + * Permissions only: name `/yolo` so skip-prompts is discoverable at the ask. + * Every tier keeps "Ctrl+C stop" (reject and stop the turn), so the stop key + * stays discoverable down to contentWidth 28 (interior 26). Below that even + * the last tier does not fit and the title falls back to " Permissions". + */ const PERMISSIONS_HINTS = [ - "Esc cancel · Enter choose · /yolo skip prompts", - "Esc · Enter · /yolo", - "Esc · Enter", + "Esc cancel · Enter choose · Ctrl+C stop · /yolo skip prompts", + "Esc · Enter · Ctrl+C stop · /yolo", + "Esc · Enter · Ctrl+C stop", + "Ctrl+C stop", ] as const; /** /plugins: longest-first how-to, same fallback shape as the model picker. */ diff --git a/src/tui/overlays.test.ts b/src/tui/overlays.test.ts index 52e732386..a30549f85 100644 --- a/src/tui/overlays.test.ts +++ b/src/tui/overlays.test.ts @@ -116,19 +116,32 @@ describe("permissions overlay", () => { ); }); - test("key hints drop to Esc · Enter on a narrow interior", async () => { + test("key hints narrow to Ctrl+C stop on a narrow interior", async () => { await withAppShell( async (shell, h) => { openPermissionsOverlay(shell, { items: makePermissionItems(4) }); await h.renderOnce(); const frame = h.captureCharFrame(); - expect(frame).toContain("Esc · Enter"); + expect(frame).toContain("permissions · Ctrl+C stop"); expect(frame).not.toContain("/yolo"); }, { width: 32 }, ); }); + test("key hints keep Esc · Enter · Ctrl+C stop at a medium width", async () => { + await withAppShell( + async (shell, h) => { + openPermissionsOverlay(shell, { items: makePermissionItems(4) }); + await h.renderOnce(); + const frame = h.captureCharFrame(); + expect(frame).toContain("Esc · Enter · Ctrl+C stop"); + expect(frame).not.toContain("/yolo"); + }, + { width: 44 }, + ); + }); + test("Esc key closes permissions overlay via wireKeys", async () => { await withAppShell( async (shell, h) => { diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index 279d8a0d9..e31b8f805 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -24,7 +24,12 @@ import { streamRowCount } from "./shell/transcript"; import { STEER_WAIT_NOTICE_MS } from "./notice-line"; import type { Harness } from "./harness"; import { withAppShell } from "./test-helpers"; -import { operatorCancelResult, wireGates } from "./gate-wire.js"; +import { + operatorCancelResult, + PERMISSION_DENY_ID, + PERMISSION_STOP_ID, + wireGates, +} from "./gate-wire.js"; import { acceptOverlaySelection } from "./shell/overlay-host.js"; import { moveOverlaySelection } from "./shell/overlay-list.js"; import { @@ -1145,6 +1150,55 @@ describe("Ctrl+C on a decision gate", () => { ); }); + test("Reject and stop declines the open gate and drops queued gates", async () => { + await withBridge( + { run: "busy", wireKeys: true, gates: true }, + async ({ h, shell, port, emitter }) => { + const identity = new AbortController(); + const first = emitPermissionGate(emitter, "req-1", identity.signal); + const queued = emitPermissionGate(emitter, "req-2", identity.signal); + port.clear(); + + moveOverlaySelection(shell, 1); + expect(shell.overlayList?.select.getSelectedOption()?.value).toBe( + `req-1:${PERMISSION_STOP_ID}`, + ); + acceptOverlaySelection(shell); + await h.renderOnce(); + + expect(first()).toStrictEqual({ allow: false }); + expect(port.calls.some((c) => c.op === "interrupt")).toBe(true); + + await h.renderOnce(); + expect(queued()).toStrictEqual({ allow: false }); + expect(shell.overlayList).toBeNull(); + }, + ); + }); + + test("plain Reject declines only the open gate and the turn continues", async () => { + await withBridge( + { run: "busy", wireKeys: true, gates: true }, + async ({ h, shell, port, emitter }) => { + const identity = new AbortController(); + const first = emitPermissionGate(emitter, "req-1", identity.signal); + const queued = emitPermissionGate(emitter, "req-2", identity.signal); + port.clear(); + + acceptOverlaySelection(shell); + await h.renderOnce(); + + expect(first()).toEqual({ allow: false }); + expect(port.calls.some((c) => c.op === "interrupt")).toBe(false); + expect(queued()).toBeUndefined(); + expect(shell.overlayKind).toBe("permissions"); + expect( + shell.overlayList?.select.options.map((option) => option.value), + ).toContain(`req-2:${PERMISSION_DENY_ID}`); + }, + ); + }); + test("declines an open operator question and interrupts the turn", async () => { await withBridge( { run: "busy", wireKeys: true, gates: true }, @@ -2072,7 +2126,10 @@ describe("in-flight tool row elapsed time", () => { describe("CL-7802 gated tool elapsed starts at grant", () => { function acceptOnce(shell: AppShell): void { - moveOverlaySelection(shell, 1); + const target = shell.overlayItems.indexOf("Accept once"); + if (target < 0) throw new Error("no overlay row labelled Accept once"); + const list = defined(shell.overlayList, "overlayList"); + moveOverlaySelection(shell, target - list.activeIndex); acceptOverlaySelection(shell); }