diff --git a/site/docs.html b/site/docs.html index 98360a772..eeff6abe3 100644 --- a/site/docs.html +++ b/site/docs.html @@ -673,6 +673,18 @@

Worktrees and pull requests

Codex command cards are read-only; see Codex sessions for their approval controls.

+

+ Answer permission and plan cards from the keyboard, like the CLIs: + y accepts (or approves the plan), n denies (or + keeps planning), and a 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 + npm test:*, not all of Bash; edit it, or allow the whole + tool, under Always-allow options. +

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 diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx index d6ce6d996..3251c2031 100644 --- a/src/components/Composer.tsx +++ b/src/components/Composer.tsx @@ -2074,6 +2074,7 @@ export const Composer = memo(function Composer({ disabled={disabled || sending} readOnly={dictating} data-vim-mode={vimEnabled ? vimMode : undefined} + data-composer-input="" />

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 ( + + ); +} + /** * Plan approval (ExitPlanMode): the plan rendered as markdown in the prompt * panel, approve or send the agent back to planning (with optional notes), @@ -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); @@ -128,17 +181,19 @@ export function PlanPrompt({ className={styles.permissionAllow} disabled={sent} onClick={() => answer("allow")} + aria-keyshortcuts="y" > - Approve plan + Approve plan {onImplement ? ( {scoped ? ( ) : acceptAlways ? ( ) : null} {pending.folderGrant ? ( @@ -395,8 +466,9 @@ export function PermissionPrompt({ className={styles.permissionDeny} disabled={sent} onClick={() => answer("deny")} + aria-keyshortcuts="n" > - Deny + Deny
{scoped ? ( diff --git a/test/permissionPrompt.test.tsx b/test/permissionPrompt.test.tsx index 8278ea94c..095413c91 100644 --- a/test/permissionPrompt.test.tsx +++ b/test/permissionPrompt.test.tsx @@ -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 { + 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"]); + }); +}); diff --git a/test/planPrompt.test.tsx b/test/planPrompt.test.tsx index 78ca32034..d9efac8ca 100644 --- a/test/planPrompt.test.tsx +++ b/test/planPrompt.test.tsx @@ -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;