Skip to content

Commit 6208ba9

Browse files
committed
feat(tui): add Reject and stop to the permission prompt
1 parent 7d6a721 commit 6208ba9

9 files changed

Lines changed: 197 additions & 43 deletions

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

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

59-
/** Accept the highlighted choice after moving to `index` (0 Reject, 1 Accept once). */
60-
function acceptChoice(shell: AppShell, index: 0 | 1 | 2): void {
61-
for (let i = 0; i < index; i++) moveOverlaySelection(shell, 1);
59+
/** Move the highlight to the row labelled `label`, then accept it. */
60+
function acceptChoice(shell: AppShell, label: string): void {
61+
const target = shell.overlayItems.indexOf(label);
62+
if (target < 0) throw new Error(`no overlay row labelled ${label}`);
63+
const list = defined(shell.overlayList, "overlayList");
64+
moveOverlaySelection(shell, target - list.activeIndex);
6265
acceptOverlaySelection(shell);
6366
}
6467

@@ -151,7 +154,7 @@ describe("CL-8792 gate level: a second destructive evaluation re-prompts after a
151154
const first = gate.evaluate(shellCall(command));
152155
await flushGateRaise();
153156
expect(shell.overlayKind).toBe("permissions");
154-
acceptChoice(shell, 1);
157+
acceptChoice(shell, "Accept once");
155158
const firstVerdict = await settledWithin(first, 500);
156159
expect(firstVerdict.settled).toBe(true);
157160
if (!firstVerdict.settled) throw new Error("first evaluation hung");
@@ -161,7 +164,7 @@ describe("CL-8792 gate level: a second destructive evaluation re-prompts after a
161164
await flushGateRaise();
162165
// A grant would auto-allow with no overlay; allow-once must re-prompt.
163166
expect(shell.overlayKind).toBe("permissions");
164-
acceptChoice(shell, 1);
167+
acceptChoice(shell, "Accept once");
165168
const verdict = await settledWithin(second, 500);
166169
expect(verdict.settled).toBe(true);
167170
if (!verdict.settled) throw new Error("second evaluation hung");
@@ -186,12 +189,12 @@ describe("CL-8792 gate level: a second destructive evaluation re-prompts after a
186189

187190
// Accept once on the first card: the queued card must take the host
188191
// before the deferred slash, and both evaluations must settle.
189-
acceptChoice(shell, 1);
192+
acceptChoice(shell, "Accept once");
190193
const firstVerdict = await settledWithin(first, 500);
191194
expect(firstVerdict.settled).toBe(true);
192195

193196
expect(shell.overlayKind).toBe("permissions");
194-
acceptChoice(shell, 1);
197+
acceptChoice(shell, "Accept once");
195198
const secondVerdict = await settledWithin(second, 500);
196199
expect(secondVerdict.settled).toBe(true);
197200
if (!secondVerdict.settled) throw new Error("second evaluation hung");
@@ -224,12 +227,12 @@ describe("CL-8792 overlay host: queued cards outrank deferred surfaces", () => {
224227
},
225228
});
226229

227-
acceptChoice(shell, 1);
230+
acceptChoice(shell, "Accept once");
228231
expect(resolvedA).toEqual({ allow: true });
229232
expect(shell.overlayKind).toBe("permissions");
230233
expect(shell.overlayItems).toContain("Accept once");
231234

232-
acceptChoice(shell, 1);
235+
acceptChoice(shell, "Accept once");
233236
expect(resolvedB).toEqual({ allow: true });
234237

235238
// With every gate settled, the deferred slash finally takes the host.
@@ -259,11 +262,11 @@ describe("CL-8792 overlay host: queued cards outrank deferred surfaces", () => {
259262
},
260263
});
261264

262-
acceptChoice(shell, 0);
265+
acceptChoice(shell, "Reject");
263266
expect(resolvedA).toEqual({ allow: false });
264267
expect(shell.overlayKind).toBe("permissions");
265268

266-
acceptChoice(shell, 1);
269+
acceptChoice(shell, "Accept once");
267270
expect(resolvedB).toEqual({ allow: true });
268271
});
269272
});
@@ -299,14 +302,14 @@ describe("CL-8792 overlay host: queued cards outrank deferred surfaces", () => {
299302
},
300303
});
301304

302-
acceptChoice(shell, 2);
305+
acceptChoice(shell, "Allow rm *");
303306
expect(resolvedA).toMatchObject({
304307
allow: true,
305308
persist: { id: "scope-a" },
306309
});
307310
expect(shell.overlayKind).toBe("permissions");
308311

309-
acceptChoice(shell, 1);
312+
acceptChoice(shell, "Accept once");
310313
expect(resolvedB).toEqual({ allow: true });
311314
});
312315
});
@@ -326,7 +329,7 @@ describe("CL-8792 overlay host: queued cards outrank deferred surfaces", () => {
326329
});
327330
expect(shell.overlayKind).toBe("permissions");
328331

329-
acceptChoice(shell, 1);
332+
acceptChoice(shell, "Accept once");
330333
expect(resolved).toEqual({ allow: true });
331334
expect(shell.overlayKind).toBe("help");
332335
expect(shell.overlayItems).toEqual(["Help"]);
@@ -363,7 +366,7 @@ describe("CL-8792 overlay host: queued cards outrank deferred surfaces", () => {
363366
await new Promise((resolve) => setTimeout(resolve, 80));
364367
expect(resolvedB).toBeUndefined();
365368

366-
acceptChoice(shell, 1);
369+
acceptChoice(shell, "Accept once");
367370
expect(resolvedA).toEqual({ allow: true });
368371
expect(shell.overlayKind).toBe("permissions");
369372

@@ -441,7 +444,7 @@ describe("CL-8792 elapsed: pending tool row freezes while a gate is outstanding
441444
await h.renderOnce();
442445
expect(stat()).toBe("0:10");
443446

444-
acceptChoice(shell, 1);
447+
acceptChoice(shell, "Accept once");
445448
expect(resolved).toEqual({ allow: true });
446449
bridge.gateClosed();
447450
} finally {
@@ -479,7 +482,7 @@ describe("CL-8792 overlay host: suspend preserves the surface instead of dismiss
479482
expect(shell.overlayKind).toBe("permissions");
480483
expect(events).toEqual([]);
481484

482-
acceptChoice(shell, 1);
485+
acceptChoice(shell, "Accept once");
483486
expect(resolved).toEqual({ allow: true });
484487
expect(shell.overlayKind).toBe("help");
485488
expect(events).toEqual([]);
@@ -506,7 +509,7 @@ describe("CL-8792 overlay host: suspend preserves the surface instead of dismiss
506509
});
507510
expect(shell.overlayKind).toBe("permissions");
508511

509-
acceptChoice(shell, 1);
512+
acceptChoice(shell, "Accept once");
510513
expect(resolved).toEqual({ allow: true });
511514
expect(shell.overlayKind).toBe("help");
512515
expect(shell.overlayList).toBe(list);
@@ -551,7 +554,7 @@ describe("CL-8792 overlay host: suspend preserves the surface instead of dismiss
551554
expect(shell.overlayKind).toBe("permissions");
552555
expect(shell.overlayItems).toContain("Accept once");
553556

554-
acceptChoice(shell, 1);
557+
acceptChoice(shell, "Accept once");
555558
expect(resolved).toEqual({ allow: true });
556559
expect(cancelOpens).toBe(0);
557560
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
]);
@@ -1186,8 +1209,8 @@ describe("permission overlay height", () => {
11861209
};
11871210

11881211
test("tracks item count, not terminal height", async () => {
1189-
const short = await hostRowsFor(30, 1);
1190-
const tall = await hostRowsFor(60, 1);
1212+
const short = await hostRowsFor(60, 1);
1213+
const tall = await hostRowsFor(80, 1);
11911214
expect(short).toBe(tall);
11921215

11931216
// Two extra choices cost exactly four extra rows: each choice occupies

‎src/tui/gate-wire.ts‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
onOverlayClosed,
2222
resumeSuspendedCommandSurface,
2323
setOverlayBody,
24+
stopDecisionGate,
2425
suspendReplaceableOverlay,
2526
} from "./shell/overlay-host.js";
2627
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
/**
@@ -62,10 +64,10 @@ export interface GateSelection {
6264

6365
/**
6466
* Build permission overlay rows from a live PermissionRequest.
65-
* Order: Reject → Accept once → request.scopes. Labels are bare so the choice
66-
* list stays short action names; each scope's hint paints in the body above
67-
* the list (see permissionBodyFromRequest) instead of being truncated inside
68-
* a choice row.
67+
* Order: Reject → Reject and stop → Accept once → request.scopes. Labels are
68+
* bare so the choice list stays short action names; each scope's hint paints
69+
* in the body above the list (see permissionBodyFromRequest) instead of being
70+
* truncated inside a choice row.
6971
*/
7072
export function permissionChoicesFromRequest(
7173
request: PermissionRequest,
@@ -80,6 +82,10 @@ export function permissionChoicesFromRequest(
8082
itemIds.push(rowId(PERMISSION_DENY_ID));
8183
outcomes.push({ allow: false });
8284

85+
items.push("Reject and stop");
86+
itemIds.push(rowId(PERMISSION_STOP_ID));
87+
outcomes.push({ allow: false });
88+
8389
items.push("Accept once");
8490
itemIds.push(rowId(PERMISSION_ONCE_ID));
8591
outcomes.push({ allow: true });
@@ -431,6 +437,16 @@ export function wireGates(
431437
echoChoice: false,
432438
...(collapsedAnything ? { onToggleExpand } : {}),
433439
onAccept: (sel: OverlaySelection) => {
440+
if (sel.id === `${ev.id}:${PERMISSION_STOP_ID}`) {
441+
settle(
442+
approvalOutcomeFromSelection(choices, {
443+
index: sel.index,
444+
id: sel.id,
445+
}),
446+
);
447+
stopDecisionGate(shell);
448+
return;
449+
}
434450
const gateSelection = {
435451
index: sel.index,
436452
...(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)