Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 22 additions & 19 deletions src/tui/allow-once-reprompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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");
Expand All @@ -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");
Expand All @@ -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");
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 });
});
});
Expand Down Expand Up @@ -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 });
});
});
Expand All @@ -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"]);
Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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([]);
Expand All @@ -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);
Expand Down Expand Up @@ -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");
Expand Down
1 change: 1 addition & 0 deletions src/tui/decision-truncation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]);
Expand Down
41 changes: 32 additions & 9 deletions src/tui/gate-wire.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
operatorChoicesFromOptions,
operatorResultFromSelection,
PERMISSION_DENY_ID,
PERMISSION_STOP_ID,
PERMISSION_ONCE_ID,
permissionBodyFromRequest,
permissionChoicesFromRequest,
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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 });
});
});

Expand All @@ -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,
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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 });
Expand All @@ -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",
]);
Expand Down Expand Up @@ -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
Expand Down
24 changes: 21 additions & 3 deletions src/tui/gate-wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
onOverlayClosed,
resumeSuspendedCommandSurface,
setOverlayBody,
stopDecisionGate,
suspendReplaceableOverlay,
} from "./shell/overlay-host.js";
import { EXPAND_KEY } from "./stream.js";
Expand All @@ -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;

/**
Expand All @@ -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,
Expand All @@ -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 });
Expand Down Expand Up @@ -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 } : {}),
Expand Down
6 changes: 3 additions & 3 deletions src/tui/overlay-overflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
Loading
Loading