Skip to content

Commit 409a85d

Browse files
committed
feat(tui): add Reject and stop to the permission prompt
1 parent af5f8bb commit 409a85d

9 files changed

Lines changed: 198 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
@@ -33,6 +33,7 @@ import {
3333
operatorChoicesFromOptions,
3434
operatorResultFromSelection,
3535
PERMISSION_DENY_ID,
36+
PERMISSION_STOP_ID,
3637
PERMISSION_ONCE_ID,
3738
permissionBodyFromRequest,
3839
permissionChoicesFromRequest,
@@ -129,14 +130,19 @@ function settleCapture(): {
129130
}
130131

131132
describe("permissionChoicesFromRequest", () => {
132-
test("always includes reject + accept once", () => {
133+
test("always includes reject, reject and stop, and accept once", () => {
133134
const choices = permissionChoicesFromRequest(baseRequest(), "req-1");
134-
expect(choices.items).toEqual(["Reject", "Accept once"]);
135+
expect(choices.items).toEqual(["Reject", "Reject and stop", "Accept once"]);
135136
expect(choices.itemIds).toEqual([
136137
`req-1:${PERMISSION_DENY_ID}`,
138+
`req-1:${PERMISSION_STOP_ID}`,
137139
`req-1:${PERMISSION_ONCE_ID}`,
138140
]);
139-
expect(choices.outcomes).toEqual([{ allow: false }, { allow: true }]);
141+
expect(choices.outcomes).toEqual([
142+
{ allow: false },
143+
{ allow: false },
144+
{ allow: true },
145+
]);
140146
});
141147

142148
test("appends scopes as bare labels; persist only when pattern set", () => {
@@ -160,21 +166,26 @@ describe("permissionChoicesFromRequest", () => {
160166
);
161167
expect(choices.items).toEqual([
162168
"Reject",
169+
"Reject and stop",
163170
"Accept once",
164171
"Allow git *",
165172
"Allow this path",
166173
]);
167174
expect(choices.itemIds).toEqual([
168175
`req-1:${PERMISSION_DENY_ID}`,
176+
`req-1:${PERMISSION_STOP_ID}`,
169177
`req-1:${PERMISSION_ONCE_ID}`,
170178
"req-1:session-git",
171179
"req-1:once-extra",
172180
]);
173-
expect(choices.outcomes[2]).toEqual({
181+
// Reject and stop never carries a grant, even when a persistable scope is
182+
// on offer.
183+
expect(choices.outcomes[1]).toStrictEqual({ allow: false });
184+
expect(choices.outcomes[3]).toEqual({
174185
allow: true,
175186
persist: scopeWithPattern,
176187
});
177-
expect(choices.outcomes[3]).toEqual({ allow: true });
188+
expect(choices.outcomes[4]).toEqual({ allow: true });
178189
});
179190
});
180191

@@ -201,6 +212,12 @@ describe("approvalOutcomeFromSelection", () => {
201212
).toEqual({
202213
allow: false,
203214
});
215+
expect(
216+
approvalOutcomeFromSelection(choices, {
217+
index: 0,
218+
id: `req-1:${PERMISSION_STOP_ID}`,
219+
}),
220+
).toEqual({ allow: false });
204221
expect(
205222
approvalOutcomeFromSelection(choices, {
206223
index: 0,
@@ -404,7 +421,11 @@ describe("wireGates", () => {
404421
const settled = settleCapture();
405422
emitPermission(emitter, { resolve: settled.resolve });
406423
expect(shell.overlayKind).toBe("permissions");
407-
expect(shell.overlayItems).toEqual(["Reject", "Accept once"]);
424+
expect(shell.overlayItems).toEqual([
425+
"Reject",
426+
"Reject and stop",
427+
"Accept once",
428+
]);
408429

409430
acceptOverlaySelection(shell);
410431
expect(settled.get()).toEqual({ allow: false });
@@ -521,7 +542,7 @@ describe("wireGates", () => {
521542
expect(shell.overlayKind).toBe("permissions");
522543
expect(
523544
shell.overlayList?.select.options.map((option) => option.name),
524-
).toEqual(["Reject", "Accept once", "Allow git A"]);
545+
).toEqual(["Reject", "Reject and stop", "Accept once", "Allow git A"]);
525546

526547
closeInsetOverlay(shell);
527548
expect(settledA.get()).toEqual({ allow: false });
@@ -539,11 +560,13 @@ describe("wireGates", () => {
539560
const painted = shell.overlayList?.select.options ?? [];
540561
expect(painted.map((option) => option.name)).toEqual([
541562
"Reject",
563+
"Reject and stop",
542564
"Accept once",
543565
"Allow git B",
544566
]);
545567
expect(painted.map((option) => option.value)).toEqual([
546568
`req-b:${PERMISSION_DENY_ID}`,
569+
`req-b:${PERMISSION_STOP_ID}`,
547570
`req-b:${PERMISSION_ONCE_ID}`,
548571
"req-b:scope-b",
549572
]);
@@ -1180,8 +1203,8 @@ describe("permission overlay height", () => {
11801203
};
11811204

11821205
test("tracks item count, not terminal height", async () => {
1183-
const short = await hostRowsFor(30, 1);
1184-
const tall = await hostRowsFor(60, 1);
1206+
const short = await hostRowsFor(60, 1);
1207+
const tall = await hostRowsFor(80, 1);
11851208
expect(short).toBe(tall);
11861209

11871210
// 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";
@@ -35,6 +36,7 @@ import {
3536

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

4042
/**
@@ -58,9 +60,11 @@ export interface GateSelection {
5860
}
5961

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

82+
items.push("Reject and stop");
83+
itemIds.push(rowId(PERMISSION_STOP_ID));
84+
outcomes.push({ allow: false });
85+
7886
items.push("Accept once");
7987
itemIds.push(rowId(PERMISSION_ONCE_ID));
8088
outcomes.push({ allow: true });
@@ -388,6 +396,16 @@ export function wireGates(
388396
echoChoice: false,
389397
...(collapsedAnything ? { onToggleExpand } : {}),
390398
onAccept: (sel: OverlaySelection) => {
399+
if (sel.id === `${ev.id}:${PERMISSION_STOP_ID}`) {
400+
settle(
401+
approvalOutcomeFromSelection(choices, {
402+
index: sel.index,
403+
id: sel.id,
404+
}),
405+
);
406+
stopDecisionGate(shell);
407+
return;
408+
}
391409
const gateSelection = {
392410
index: sel.index,
393411
...(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
});

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,45 @@ describe("overlay view", () => {
238238
});
239239
});
240240

241+
test("permission title hints keep Ctrl+C stop in every width fallback", async () => {
242+
await withTestRenderer(async (h) => {
243+
const view = createOverlayView(h.renderer);
244+
h.renderer.root.add(view.host);
245+
const title: OverlayTitlePresentation = {
246+
title: "Permissions",
247+
kind: "permissions",
248+
hasChoices: true,
249+
answer: null,
250+
mcpManageHint: false,
251+
mcpAddHint: false,
252+
};
253+
const titleText = () =>
254+
view.title.content.chunks.map((chunk) => chunk.text).join("");
255+
256+
// Each width is the narrowest that still selects its tier; one column
257+
// less falls through to the next tier.
258+
const tiers: readonly [number, string][] = [
259+
[
260+
78,
261+
" Permissions · Esc cancel · Enter choose · Ctrl+C stop · /yolo skip prompts",
262+
],
263+
[50, " Permissions · Esc · Enter · Ctrl+C stop · /yolo"],
264+
[42, " Permissions · Esc · Enter · Ctrl+C stop"],
265+
[28, " Permissions · Ctrl+C stop"],
266+
];
267+
for (const [width, line] of tiers) {
268+
view.paintTitle(title, width);
269+
expect(titleText()).toBe(line);
270+
view.paintTitle(title, width + 1);
271+
expect(titleText()).toContain("Ctrl+C stop");
272+
}
273+
for (const [width] of tiers.slice(0, -1)) {
274+
view.paintTitle(title, width - 1);
275+
expect(titleText()).toContain("Ctrl+C stop");
276+
}
277+
});
278+
});
279+
241280
test("intrinsic chrome charges answer and description once and palette omits title", () => {
242281
const full = overlayChromeRows("model_picker", 2, true, true);
243282
const bare = overlayChromeRows("model_picker", 2, false, false);

0 commit comments

Comments
 (0)