Skip to content

Commit b64e31a

Browse files
committed
feat(tui): add Reject and stop to the permission prompt
1 parent cd7eaf2 commit b64e31a

9 files changed

Lines changed: 210 additions & 42 deletions

‎src/tui/allow-once-reprompt.test.ts‎

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,12 @@ const destructiveRequest = (subject: string): PermissionRequest => ({
4444
scopes: [],
4545
});
4646

47-
/** Accept the highlighted choice after moving to `index` (0 Reject, 1 Accept once). */
48-
function acceptChoice(shell: AppShell, index: 0 | 1 | 2): void {
49-
for (let i = 0; i < index; i++) moveOverlaySelection(shell, 1);
47+
/** Move the highlight to the row labelled `label`, then accept it. */
48+
function acceptChoice(shell: AppShell, label: string): void {
49+
const target = shell.overlayItems.indexOf(label);
50+
if (target < 0) throw new Error(`no overlay row labelled ${label}`);
51+
const list = defined(shell.overlayList, "overlayList");
52+
moveOverlaySelection(shell, target - list.activeIndex);
5053
acceptOverlaySelection(shell);
5154
}
5255

@@ -138,7 +141,7 @@ describe("CL-8792 gate level: a second destructive evaluation re-prompts after a
138141
const first = gate.evaluate(shellCall(command));
139142
await flushGateRaise();
140143
expect(shell.overlayKind).toBe("permissions");
141-
acceptChoice(shell, 1);
144+
acceptChoice(shell, "Accept once");
142145
const firstVerdict = await settledWithin(first, 500);
143146
expect(firstVerdict.settled).toBe(true);
144147
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
148151
await flushGateRaise();
149152
// A grant would auto-allow with no overlay; allow-once must re-prompt.
150153
expect(shell.overlayKind).toBe("permissions");
151-
acceptChoice(shell, 1);
154+
acceptChoice(shell, "Accept once");
152155
const verdict = await settledWithin(second, 500);
153156
expect(verdict.settled).toBe(true);
154157
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
173176

174177
// Accept once on the first card: the queued card must take the host
175178
// before the deferred slash, and both evaluations must settle.
176-
acceptChoice(shell, 1);
179+
acceptChoice(shell, "Accept once");
177180
const firstVerdict = await settledWithin(first, 500);
178181
expect(firstVerdict.settled).toBe(true);
179182

180183
expect(shell.overlayKind).toBe("permissions");
181-
acceptChoice(shell, 1);
184+
acceptChoice(shell, "Accept once");
182185
const secondVerdict = await settledWithin(second, 500);
183186
expect(secondVerdict.settled).toBe(true);
184187
if (!secondVerdict.settled) throw new Error("second evaluation hung");
@@ -211,12 +214,12 @@ describe("CL-8792 overlay host: queued cards outrank deferred surfaces", () => {
211214
},
212215
});
213216

214-
acceptChoice(shell, 1);
217+
acceptChoice(shell, "Accept once");
215218
expect(resolvedA).toEqual({ allow: true });
216219
expect(shell.overlayKind).toBe("permissions");
217220
expect(shell.overlayItems).toContain("Accept once");
218221

219-
acceptChoice(shell, 1);
222+
acceptChoice(shell, "Accept once");
220223
expect(resolvedB).toEqual({ allow: true });
221224

222225
// 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", () => {
246249
},
247250
});
248251

249-
acceptChoice(shell, 0);
252+
acceptChoice(shell, "Reject");
250253
expect(resolvedA).toEqual({ allow: false });
251254
expect(shell.overlayKind).toBe("permissions");
252255

253-
acceptChoice(shell, 1);
256+
acceptChoice(shell, "Accept once");
254257
expect(resolvedB).toEqual({ allow: true });
255258
});
256259
});
@@ -286,14 +289,14 @@ describe("CL-8792 overlay host: queued cards outrank deferred surfaces", () => {
286289
},
287290
});
288291

289-
acceptChoice(shell, 2);
292+
acceptChoice(shell, "Allow rm *");
290293
expect(resolvedA).toMatchObject({
291294
allow: true,
292295
persist: { id: "scope-a" },
293296
});
294297
expect(shell.overlayKind).toBe("permissions");
295298

296-
acceptChoice(shell, 1);
299+
acceptChoice(shell, "Accept once");
297300
expect(resolvedB).toEqual({ allow: true });
298301
});
299302
});
@@ -313,7 +316,7 @@ describe("CL-8792 overlay host: queued cards outrank deferred surfaces", () => {
313316
});
314317
expect(shell.overlayKind).toBe("permissions");
315318

316-
acceptChoice(shell, 1);
319+
acceptChoice(shell, "Accept once");
317320
expect(resolved).toEqual({ allow: true });
318321
expect(shell.overlayKind).toBe("help");
319322
expect(shell.overlayItems).toEqual(["Help"]);
@@ -350,7 +353,7 @@ describe("CL-8792 overlay host: queued cards outrank deferred surfaces", () => {
350353
await new Promise((resolve) => setTimeout(resolve, 80));
351354
expect(resolvedB).toBeUndefined();
352355

353-
acceptChoice(shell, 1);
356+
acceptChoice(shell, "Accept once");
354357
expect(resolvedA).toEqual({ allow: true });
355358
expect(shell.overlayKind).toBe("permissions");
356359

@@ -428,7 +431,7 @@ describe("CL-8792 elapsed: pending tool row freezes while a gate is outstanding
428431
await h.renderOnce();
429432
expect(stat()).toBe("0:10");
430433

431-
acceptChoice(shell, 1);
434+
acceptChoice(shell, "Accept once");
432435
expect(resolved).toEqual({ allow: true });
433436
bridge.gateClosed();
434437
} finally {
@@ -466,7 +469,7 @@ describe("CL-8792 overlay host: suspend preserves the surface instead of dismiss
466469
expect(shell.overlayKind).toBe("permissions");
467470
expect(events).toEqual([]);
468471

469-
acceptChoice(shell, 1);
472+
acceptChoice(shell, "Accept once");
470473
expect(resolved).toEqual({ allow: true });
471474
expect(shell.overlayKind).toBe("help");
472475
expect(events).toEqual([]);
@@ -493,7 +496,7 @@ describe("CL-8792 overlay host: suspend preserves the surface instead of dismiss
493496
});
494497
expect(shell.overlayKind).toBe("permissions");
495498

496-
acceptChoice(shell, 1);
499+
acceptChoice(shell, "Accept once");
497500
expect(resolved).toEqual({ allow: true });
498501
expect(shell.overlayKind).toBe("help");
499502
expect(shell.overlayList).toBe(list);
@@ -538,7 +541,7 @@ describe("CL-8792 overlay host: suspend preserves the surface instead of dismiss
538541
expect(shell.overlayKind).toBe("permissions");
539542
expect(shell.overlayItems).toContain("Accept once");
540543

541-
acceptChoice(shell, 1);
544+
acceptChoice(shell, "Accept once");
542545
expect(resolved).toEqual({ allow: true });
543546
expect(cancelOpens).toBe(0);
544547
expect(shell.overlayKind).toBe("mcp");

‎src/tui/decision-truncation.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ describe("decision choice rendering", () => {
110110
// Choices are bare action names.
111111
expect(shell.overlayItems).toEqual([
112112
"Reject",
113+
"Reject and stop",
113114
"Accept once",
114115
"Allow always",
115116
]);

‎src/tui/gate-wire.test.ts‎

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
operatorChoicesFromOptions,
3535
operatorResultFromSelection,
3636
PERMISSION_DENY_ID,
37+
PERMISSION_STOP_ID,
3738
PERMISSION_ONCE_ID,
3839
permissionBodyFromRequest,
3940
permissionChoicesFromRequest,
@@ -136,14 +137,19 @@ function settleCapture(): {
136137
}
137138

138139
describe("permissionChoicesFromRequest", () => {
139-
test("always includes reject + accept once", () => {
140+
test("always includes reject, reject and stop, and accept once", () => {
140141
const choices = permissionChoicesFromRequest(baseRequest(), "req-1");
141-
expect(choices.items).toEqual(["Reject", "Accept once"]);
142+
expect(choices.items).toEqual(["Reject", "Reject and stop", "Accept once"]);
142143
expect(choices.itemIds).toEqual([
143144
`req-1:${PERMISSION_DENY_ID}`,
145+
`req-1:${PERMISSION_STOP_ID}`,
144146
`req-1:${PERMISSION_ONCE_ID}`,
145147
]);
146-
expect(choices.outcomes).toEqual([{ allow: false }, { allow: true }]);
148+
expect(choices.outcomes).toEqual([
149+
{ allow: false },
150+
{ allow: false },
151+
{ allow: true },
152+
]);
147153
});
148154

149155
test("appends scopes as bare labels; persist only when pattern set", () => {
@@ -167,21 +173,26 @@ describe("permissionChoicesFromRequest", () => {
167173
);
168174
expect(choices.items).toEqual([
169175
"Reject",
176+
"Reject and stop",
170177
"Accept once",
171178
"Allow git *",
172179
"Allow this path",
173180
]);
174181
expect(choices.itemIds).toEqual([
175182
`req-1:${PERMISSION_DENY_ID}`,
183+
`req-1:${PERMISSION_STOP_ID}`,
176184
`req-1:${PERMISSION_ONCE_ID}`,
177185
"req-1:session-git",
178186
"req-1:once-extra",
179187
]);
180-
expect(choices.outcomes[2]).toEqual({
188+
// Reject and stop never carries a grant, even when a persistable scope is
189+
// on offer.
190+
expect(choices.outcomes[1]).toStrictEqual({ allow: false });
191+
expect(choices.outcomes[3]).toEqual({
181192
allow: true,
182193
persist: scopeWithPattern,
183194
});
184-
expect(choices.outcomes[3]).toEqual({ allow: true });
195+
expect(choices.outcomes[4]).toEqual({ allow: true });
185196
});
186197
});
187198

@@ -208,6 +219,12 @@ describe("approvalOutcomeFromSelection", () => {
208219
).toEqual({
209220
allow: false,
210221
});
222+
expect(
223+
approvalOutcomeFromSelection(choices, {
224+
index: 0,
225+
id: `req-1:${PERMISSION_STOP_ID}`,
226+
}),
227+
).toEqual({ allow: false });
211228
expect(
212229
approvalOutcomeFromSelection(choices, {
213230
index: 0,
@@ -411,7 +428,11 @@ describe("wireGates", () => {
411428
const settled = settleCapture();
412429
emitPermission(emitter, { resolve: settled.resolve });
413430
expect(shell.overlayKind).toBe("permissions");
414-
expect(shell.overlayItems).toEqual(["Reject", "Accept once"]);
431+
expect(shell.overlayItems).toEqual([
432+
"Reject",
433+
"Reject and stop",
434+
"Accept once",
435+
]);
415436

416437
acceptOverlaySelection(shell);
417438
expect(settled.get()).toEqual({ allow: false });
@@ -880,7 +901,7 @@ describe("wireGates", () => {
880901
expect(shell.overlayKind).toBe("permissions");
881902
expect(
882903
shell.overlayList?.select.options.map((option) => option.name),
883-
).toEqual(["Reject", "Accept once", "Allow git A"]);
904+
).toEqual(["Reject", "Reject and stop", "Accept once", "Allow git A"]);
884905

885906
closeInsetOverlay(shell);
886907
expect(settledA.get()).toEqual({ allow: false });
@@ -898,11 +919,13 @@ describe("wireGates", () => {
898919
const painted = shell.overlayList?.select.options ?? [];
899920
expect(painted.map((option) => option.name)).toEqual([
900921
"Reject",
922+
"Reject and stop",
901923
"Accept once",
902924
"Allow git B",
903925
]);
904926
expect(painted.map((option) => option.value)).toEqual([
905927
`req-b:${PERMISSION_DENY_ID}`,
928+
`req-b:${PERMISSION_STOP_ID}`,
906929
`req-b:${PERMISSION_ONCE_ID}`,
907930
"req-b:scope-b",
908931
]);
@@ -1539,8 +1562,8 @@ describe("permission overlay height", () => {
15391562
};
15401563

15411564
test("tracks item count, not terminal height", async () => {
1542-
const short = await hostRowsFor(30, 1);
1543-
const tall = await hostRowsFor(60, 1);
1565+
const short = await hostRowsFor(60, 1);
1566+
const tall = await hostRowsFor(80, 1);
15441567
expect(short).toBe(tall);
15451568

15461569
// Two extra choices cost exactly four extra rows (wrap padding), so the

‎src/tui/gate-wire.ts‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
onOverlayClosed,
2121
resumeSuspendedCommandSurface,
2222
setOverlayBody,
23+
stopDecisionGate,
2324
suspendReplaceableOverlay,
2425
} from "./shell/overlay-host.js";
2526
import { EXPAND_KEY } from "./stream.js";
@@ -36,6 +37,7 @@ import {
3637

3738
/** Stable sentinel ids for the always-present deny / once rows. */
3839
export const PERMISSION_DENY_ID = "__deny__" as const;
40+
export const PERMISSION_STOP_ID = "__stop__" as const;
3941
export const PERMISSION_ONCE_ID = "__once__" as const;
4042

4143
/**
@@ -59,9 +61,11 @@ export interface GateSelection {
5961
}
6062

6163
/**
62-
* Rows from a live PermissionRequest. Labels stay bare action names; scope
63-
* hints paint in the body (permissionBodyFromRequest), not inside a choice
64-
* row.
64+
* Build permission overlay rows from a live PermissionRequest.
65+
* Order: Reject → Reject and stop → Accept once → request.scopes. Labels are
66+
* bare so the choice list stays short action names; each scope's hint paints
67+
* in the body above the list (see permissionBodyFromRequest) instead of being
68+
* truncated inside a choice row.
6569
*/
6670
export function permissionChoicesFromRequest(
6771
request: PermissionRequest,
@@ -76,6 +80,10 @@ export function permissionChoicesFromRequest(
7680
itemIds.push(rowId(PERMISSION_DENY_ID));
7781
outcomes.push({ allow: false });
7882

83+
items.push("Reject and stop");
84+
itemIds.push(rowId(PERMISSION_STOP_ID));
85+
outcomes.push({ allow: false });
86+
7987
items.push("Accept once");
8088
itemIds.push(rowId(PERMISSION_ONCE_ID));
8189
outcomes.push({ allow: true });
@@ -393,6 +401,16 @@ export function wireGates(
393401
echoChoice: false,
394402
...(collapsedAnything ? { onToggleExpand } : {}),
395403
onAccept: (sel: OverlaySelection) => {
404+
if (sel.id === `${ev.id}:${PERMISSION_STOP_ID}`) {
405+
settle(
406+
approvalOutcomeFromSelection(choices, {
407+
index: sel.index,
408+
id: sel.id,
409+
}),
410+
);
411+
stopDecisionGate(shell);
412+
return;
413+
}
396414
const gateSelection = {
397415
index: sel.index,
398416
...(sel.id !== undefined ? { id: sel.id } : {}),

‎src/tui/overlay-overflow.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -299,9 +299,9 @@ describe("gate-wire approval overflow on short terminal", () => {
299299
expect(shell.layout.heights.overlay_host).toBeLessThanOrEqual(
300300
Math.floor(SHORT.height * OVERLAY_MAX_FRACTION),
301301
);
302-
// Two base choices + one scope still navigable.
303-
expect(shell.overlayItems.length).toBe(3);
304-
moveOverlaySelection(shell, 2);
302+
// Three base choices + one scope still navigable.
303+
expect(shell.overlayItems.length).toBe(4);
304+
moveOverlaySelection(shell, 3);
305305
activeVisible(shell);
306306
dispose();
307307
});

0 commit comments

Comments
 (0)