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
12 changes: 12 additions & 0 deletions site/docs.html
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,18 @@ <h2 id="worktrees">Worktrees and pull requests</h2>
Codex command cards are read-only; see <a href="#codex">Codex
sessions</a> for their approval controls.
</p>
<p>
Answer permission and plan cards from the keyboard, like the CLIs:
<kbd>y</kbd> accepts (or approves the plan), <kbd>n</kbd> denies (or
keeps planning), and <kbd>a</kbd> always allows for the rest of the
session. A pending permission outranks typing: the keys work even
while the composer has focus, and are not typed into it. The card's
own fields (the command, the rule, plan notes) keep their keys, and a
card the guardrail flagged as risky never takes keys from the
composer. Always-allow on a shell command is a prefix rule such as
<code>npm test:*</code>, not all of Bash; edit it, or allow the whole
tool, under <strong>Always-allow options</strong>.
</p>
<p>
PR state is live: checks show as badges on the thread card, and merging
is a button, not a terminal command. Worktree merge and delete are
Expand Down
1 change: 1 addition & 0 deletions src/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2074,6 +2074,7 @@ export const Composer = memo(function Composer({
disabled={disabled || sending}
readOnly={dictating}
data-vim-mode={vimEnabled ? vimMode : undefined}
data-composer-input=""
/>
<div
ref={overflowRef}
Expand Down
15 changes: 15 additions & 0 deletions src/components/ThreadView.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -3201,6 +3201,21 @@ button.diffLineGutter:focus-visible {
text-decoration: underline;
}

/* y / n / a key hint on a permission button (#477). */
.permissionKey {
display: inline-block;
min-width: 1.4em;
margin-right: 2px;
padding: 0 4px;
border: 1px solid currentColor;
border-radius: 3px;
font: inherit;
font-size: 10.5px;
line-height: 1.4;
text-align: center;
opacity: 0.7;
}

/* Always-allow overflow (#479): edit the session rule, or whole tool. */
.permissionMore {
font-size: 11px;
Expand Down
88 changes: 80 additions & 8 deletions src/components/thread/cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,56 @@ import { commandPrefix, WHOLE_TOOL_RULE } from "../../permissionRule";
import styles from "../ThreadView.module.css";
import diffStyles from "../TurnDiffPanel.module.css";

type PermissionKey = "y" | "n" | "a";

/**
* y / n / a answer a pending permission or plan card, like the CLIs (#477).
* Pending permission outranks typing: the keys are taken from a focused
* composer too, in the capture phase so the composer never sees them.
* Every other text field (the card's own command / rule / notes, search
* boxes) keeps its keys. A key with no handler (answered, empty command)
* passes through untouched. `fromComposer: false` (guardrail-flagged calls,
* CI sign-off) leaves the composer alone too: a stray `y` mid-sentence must
* not approve something the guardrail called risky.
*/
function usePermissionKeys(
keys: Partial<Record<PermissionKey, () => void>>,
fromComposer = true,
) {
useEffect(() => {
const onKey = (ev: KeyboardEvent) => {
if (ev.metaKey || ev.ctrlKey || ev.altKey || ev.isComposing) return;
const run = keys[ev.key as PermissionKey];
if (!run) return;
const t = ev.target as HTMLElement | null;
if (
t &&
!(fromComposer && t.hasAttribute?.("data-composer-input")) &&
(t.tagName === "INPUT" ||
t.tagName === "TEXTAREA" ||
t.tagName === "SELECT" ||
t.isContentEditable)
) {
return;
}
ev.preventDefault();
ev.stopPropagation();
run();
};
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
});
}

/** Key hint on a card button: `y` Accept. */
function KeyHint({ k }: { k: PermissionKey }) {
return (
<kbd className={styles.permissionKey} aria-hidden="true">
{k}
</kbd>
);
}

/**
* Plan approval (ExitPlanMode): the plan rendered as markdown in the prompt
* panel, approve or send the agent back to planning (with optional notes),
Expand Down Expand Up @@ -81,6 +131,9 @@ export function PlanPrompt({
notes || undefined,
);
};
usePermissionKeys(
sent ? {} : { y: () => answer("allow"), n: () => answer("deny") },
);
const save = async () => {
if (!onSave || saving) return;
setSaving(true);
Expand Down Expand Up @@ -128,17 +181,19 @@ export function PlanPrompt({
className={styles.permissionAllow}
disabled={sent}
onClick={() => answer("allow")}
aria-keyshortcuts="y"
>
Approve plan
<KeyHint k="y" /> Approve plan
</button>
<button
type="button"
className={styles.permissionDeny}
disabled={sent}
onClick={() => answer("deny")}
aria-keyshortcuts="n"
data-keep-planning=""
>
Keep planning
<KeyHint k="n" /> Keep planning
</button>
{onImplement ? (
<button
Expand Down Expand Up @@ -266,6 +321,19 @@ export function PermissionPrompt({
alwaysRule,
);
};
const always = scoped
? () => answer("allowAlways", rule.trim())
: () => answer("allowAlways");
usePermissionKeys(
sent
? {}
: {
n: () => answer("deny"),
...(empty ? {} : { y: () => answer("allow") }),
...(acceptAlways && !empty && !ruleEmpty ? { a: always } : {}),
},
!guardrail,
);

return (
<div
Expand Down Expand Up @@ -355,27 +423,30 @@ export function PermissionPrompt({
className={styles.permissionAllow}
disabled={sent || empty}
onClick={() => answer("allow")}
aria-keyshortcuts="y"
>
Accept
<KeyHint k="y" /> Accept
</button>
{scoped ? (
<button
type="button"
className={styles.permissionAllow}
disabled={sent || empty || ruleEmpty}
data-permission-always=""
onClick={() => answer("allowAlways", rule.trim())}
onClick={always}
aria-keyshortcuts="a"
>
Always allow <code>{rule}</code>
<KeyHint k="a" /> Always allow <code>{rule}</code>
</button>
) : acceptAlways ? (
<button
type="button"
className={styles.permissionAllow}
disabled={sent || empty}
onClick={() => answer("allowAlways")}
onClick={always}
aria-keyshortcuts="a"
>
Accept all
<KeyHint k="a" /> Accept all
</button>
) : null}
{pending.folderGrant ? (
Expand All @@ -395,8 +466,9 @@ export function PermissionPrompt({
className={styles.permissionDeny}
disabled={sent}
onClick={() => answer("deny")}
aria-keyshortcuts="n"
>
Deny
<KeyHint k="n" /> Deny
</button>
</div>
{scoped ? (
Expand Down
89 changes: 89 additions & 0 deletions test/permissionPrompt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -403,3 +403,92 @@ describe("PermissionPrompt folder grant (#514)", () => {
assert.equal(view.query("[data-permission-allow-folder]"), null);
});
});

/** Press a key; true when the card took it (the field never saw the keydown). */
async function key(view: Mounted, el: Element, k: string): Promise<boolean> {
let reached = false;
const seen = () => {
reached = true;
};
el.addEventListener("keydown", seen);
await view.press(el, k);
el.removeEventListener("keydown", seen);
return !reached;
}

describe("PermissionPrompt keyboard (#477)", () => {
const npmPending: PendingPermissionInfo = {
...bashPending,
summary: "Bash: npm test -- foo",
command: "npm test -- foo",
};

for (const [k, decision] of [
["y", "allow"],
["n", "deny"],
["a", "allowAlways"],
] as const) {
it(`${k} answers ${decision} from a focused composer and is not typed`, async () => {
const { m, spy } = mountView(npmPending);
const view = await m;
const composer = view.query(
"[data-composer-input]",
) as HTMLTextAreaElement;
assert.ok(composer, "composer textarea");
composer.focus();
assert.equal(await key(view, composer, k), true);
assert.equal(composer.value, "");
assert.equal(spy.calls.length, 1);
assert.equal(spy.calls[0].decision, decision);
if (k === "a") assert.equal(spy.calls[0].alwaysRule, "npm test:*");
});
}

it("a sends the edited rule, like the button", async () => {
const { m, spy } = mountView(npmPending);
const view = await m;
await view.type(view.query("[data-permission-rule]"), "npm *");
await key(view, view.query("[data-composer-input]")!, "a");
assert.equal(spy.calls[0].alwaysRule, "npm *");
});

it("the card's own fields keep their keys; answered cards release them", async () => {
const { m, spy } = mountView(npmPending);
const view = await m;
const ta = view.query("[data-permission-command]")!;
const rule = view.query("[data-permission-rule]")!;
assert.equal(await key(view, ta, "y"), false);
assert.equal(await key(view, rule, "a"), false);
assert.equal(spy.calls.length, 0);
const composer = view.query("[data-composer-input]")!;
assert.equal(await key(view, composer, "n"), true);
assert.equal(await key(view, composer, "y"), false);
assert.equal(spy.calls.length, 1);
});

it("a guardrail-flagged call never takes keys from the composer", async () => {
const { m, spy } = mountView({
...npmPending,
guardrail: { rule: "RISKY", reason: "touches prod" },
});
const view = await m;
assert.equal(await key(view, view.query("[data-composer-input]")!, "y"), false);
assert.equal(spy.calls.length, 0);
// Outside any text field the keys still answer.
assert.equal(await key(view, view.query("[data-permission-card]")!, "y"), true);
assert.equal(spy.calls[0].decision, "allow");
});

it("modifiers pass through (Cmd+A still selects all)", async () => {
const { m, spy } = mountView(npmPending);
const view = await m;
await view.press(view.query("[data-composer-input]"), "a", { metaKey: true });
assert.equal(spy.calls.length, 0);
});

it("buttons show their keys", async () => {
const view = await mountView(npmPending).m;
const keys = view.queryAll("[data-permission-card] kbd").map((k) => k.textContent);
assert.deepEqual(keys, ["y", "a", "n"]);
});
});
16 changes: 16 additions & 0 deletions test/planPrompt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,22 @@ describe("PlanPrompt", () => {
]);
});

it("y approves and n keeps planning, even from the composer (#477)", async () => {
for (const [k, decision] of [["y", "allow"], ["n", "deny"]] as const) {
const { m, spy } = mountView();
const view = await m;
const composer = view.query("[data-composer-input]")!;
await view.press(composer, k);
assert.deepEqual(spy.calls, [{ requestId: "req-plan-1", decision }]);
view.unmount();
}
// Typing notes keeps its keys.
const { m, spy } = mountView();
const view = await m;
await view.press(view.query("textarea[data-plan-feedback]"), "n");
assert.deepEqual(spy.calls, []);
});

it("sends Keep planning notes, and Approve ignores them (#1501)", async () => {
const { m, spy } = mountView();
const view = await m;
Expand Down
Loading