24 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 573 | [FEATURE REQUEST] Keybinds for approve/deny/deny with reason |
Inline keybind permission dialog
Release Recommendation
Release: ship independently
Phase 11 Step 4 carries Release: independent in the architecture roadmap.
It is a pure live-authority presentation change on the Authorizer spine — evaluate(), the ruleset, and the gate contract are untouched — so it neither depends on nor blocks the shell-tool-aliases batch (Steps 2–3, already shipped) or any other Phase 11 step.
Problem Statement
The permission prompt shown on an ask decision is a stock two-select modal: the user navigates with arrows / j/k and confirms with enter.
Deny-with-reason costs three keypresses (navigate to the option, enter, then type).
Issue #573 (filed by a third party, Hex4C) asks for single-keypress hotkeys — y approve, n deny, and a one-key path to deny-with-reason — so fine-grained permission control is fast to exercise.
The operator has scheduled this as Phase 11 Step 4 and, in planning, extended the request in two directions beyond the raw issue:
- A double-press-to-confirm affordance modeled on the pi-ask review-shortcuts flow (first press of a hotkey arms the action and shows a "press again to confirm" hint; the same key again commits), gated by a config toggle defaulting on.
- A mandatory inline reason editor for deny-with-reason with back-navigation.
Goals
- Add an inline
ctx.ui.custom<PermissionPromptDecision>permission dialog for TUI sessions with letter hotkeys:yapprove,sapprove-for-this-session,ndeny,rdeny-with-reason, alongside arrow /j/knavigation and enter-confirm. - Require a confirming second press of the same letter hotkey before a decision commits (arm → confirm), governed by a new
doublePressToConfirmconfig toggle that defaults totrue. - Make deny-with-reason an inline editor sub-step:
ropens a reason editor; enter submits; a non-empty reason is mandatory;escnavigates back to the decision list. - Preserve the forwarded-ask grant-scope choice (subagent vs. serving session) as an in-component second step with back-navigation.
- Keep the existing
select()/input()flow unchanged for non-TUI contexts (RPC / frontend — the #519 constraint): the inline component renders only whenctx.mode === "tui". - Keep the
PermissionPromptDecisioncontract,emitUiPromptEventbroadcast, review-log bracketing, and gate behavior byte-for-byte identical — only the human-facing input surface changes.
Non-Goals
- No change to
evaluate(), thePermissionResolver, the ruleset, or any gate outcome — this is presentation-only. - No change to the
ParentAuthorizer(subagent-escalation) orDenyingAuthorizerpaths; onlyLocalUserAuthorizer's live-UI arm gains the inline dialog. - No configurable remapping of the hotkey letters —
y/s/n/rare fixed this round (a per-key config surface, mirroring pi-ask's keybinding schema, is deferred; not filed — revisit only if requested). - No timer-based double-press window — arming is pure state (press the same key again, no elapsed-time constraint), matching pi-ask's
resolveReviewShortcutDoublePress. - No double-press behavior in the non-TUI
select()/input()fallback — a modal select cannot express arm-then-confirm; the toggle affects the inline component only.
Background
Relevant existing modules:
src/authority/authorizer.ts—selectAuthorizer(ctx, deps)performs the once-per-activationhasUI/isSubagent/ deny dispatch, returningLocalUserAuthorizerwhenctx.hasUI. It already receives the fullExtensionContext, soctx.modeandctx.ui.customare reachable here without new plumbing into the caller.src/authority/local-user-authorizer.ts— the singlepermissions:ui_promptemit site; calls the injectedrequestPermissionDecisionFromUi(ui, title, message, options).src/authority/permission-dialog.ts— owns option semantics: thePermissionDecisionUiinterface (select/input),RequestPermissionOptions(includingsessionScopefor forwarded asks),normalizePermissionDenialReason,createDeniedPermissionDecision, and thePermissionPromptDecision/PermissionDecisionStatetypes.src/authority/authorizer-selection.ts—AuthorizerSelectionstoresctxatactivate()and delegates to the selectedAuthorizerviaPermissionPrompter.src/config-modal.ts— the in-package precedent forctx.ui.custom: builds a TUISettingsListfrom@earendil-works/pi-tui, gated onctx.hasUI, resolving viadone().src/config-schema.ts/src/extension-config.ts/src/config-loader.ts— the config source-of-truth chain: Zod schema (.metadescriptions, regeneratedschemas/permissions.schema.json) →PermissionSystemExtensionConfig+DEFAULT_EXTENSION_CONFIG+normalizePermissionSystemConfig→mergeUnifiedConfigsscalar list.
Reference model (external): ~/development/pi/pi-ask/src/ui/review-shortcuts.ts — resolveReviewShortcutDoublePress(digit, pendingActionIndex) is a pure resolver: matching the pending action confirms; any other key re-arms.
The hint text (Press N again to <action>.) is derived from the pending index. pi-ask keeps the double-press logic and the question view-model pure (state.ts, review-shortcuts.ts, question-view-model.ts) and tests the component by invoking the ctx.ui.custom factory with a fake tui + plainTheme() + captured done, then simulating input — the pattern this plan follows.
SDK facts (verified against @earendil-works/pi-coding-agent@0.79.1):
ctx.ui.custom<T>(factory, options?)renders inline by default (overlay ?? false) and returnsPromise<T>; the factory receives(tui, theme, keybindings, done)and returns aComponent(aContainersubclass withhandleInput(data)and optionaldispose()).ctx.modeis"tui" | "rpc" | "json" | "print";ctx.hasUIistruein both"tui"and"rpc". So the currenthasUI-selectedLocalUserAuthorizeralso serves RPC, wherectx.ui.customdoes not render — the inline path must gate onctx.mode === "tui", and RPC must keepselect()/input().@earendil-works/pi-tui@0.79.1(adevDependencyalready present) exports the primitives (Container,Text,Spacer,SelectList,Editor,matchesKey) the component needs;getSelectListTheme/getSettingsListThemecome from@earendil-works/pi-coding-agent.
AGENTS.md / package-skill constraints that apply:
- Config field lifecycle: define in
unifiedConfigSchemawith.meta, regenerate the schema (pnpm run gen:schema; a parity test guards drift), carry throughPermissionSystemExtensionConfig+mergeUnifiedConfigs(the #332/#347 drop class — post-#356 the compiler flags a missed read), and keepconfig.example.json/docs/configuration.md/README.mdaligned. - "Treat any declared config field not read at runtime as a maintenance trap" — the toggle must be consumed in the same phase it is declared (it goes live in TDD step 4).
- Keep Pi SDK / TUI imports out of pure modules — the decision model (step 1) imports neither; only the component (step 3) and the dispatcher wiring (step 4) touch the SDK.
Design Overview
Separation: pure decision model vs. thin TUI component
The interaction logic (which key produces which decision, double-press arming, step transitions, reason validation) lives in a pure module with no SDK/TUI imports; the ctx.ui.custom component is a thin adapter that forwards keystrokes to the model and renders its state.
This is Test-Driven Design: the branch-heavy logic is unit-tested directly, and the component test only has to confirm wiring.
// src/authority/permission-prompt-decision.ts (new, pure)
export type PromptStep = "decision" | "reason" | "scope";
export interface PromptOption {
readonly key: "y" | "s" | "n" | "r";
readonly state: PermissionDecisionState; // approve / approved_for_session / denied / denied_with_reason
readonly label: string;
}
export interface PromptModelConfig {
readonly options: readonly PromptOption[]; // s/scope present only when the ask offers them
readonly doublePressToConfirm: boolean;
readonly sessionScope?: RequestPermissionOptions["sessionScope"];
}
export interface PromptViewState {
readonly step: PromptStep;
readonly highlightedKey: PromptOption["key"];
readonly armedKey?: PromptOption["key"]; // set only while awaiting the confirming second press
readonly hint: string; // "Press y again to approve." etc.
readonly reasonDraft: string;
readonly reasonError?: string; // "A reason is required." when an empty submit is attempted
}
// A press yields either a re-render (new state) or a terminal decision.
export type PromptOutcome =
| { readonly kind: "render"; readonly state: PromptViewState }
| { readonly kind: "decision"; readonly decision: PermissionPromptDecision };
export function initialPromptState(config: PromptModelConfig): PromptViewState;
export function pressKey(
config: PromptModelConfig,
state: PromptViewState,
key: string, // raw key data from the TUI
): PromptOutcome;
export function submitReason(
state: PromptViewState,
): PromptOutcome; // rejects empty (render with reasonError); else decision
Model behavior (the unit-test surface):
- Navigation (
up/down/j/k) moveshighlightedKey, clearsarmedKey(moving off an armed option cancels the arm), and does not commit. - Letter hotkey with
doublePressToConfirm: true: first press setsarmedKey= that key, moves the highlight to it, and setshinttoPress <key> again to <verb>.; pressing the same key again commits its decision; pressing a different letter re-arms the new one. - Letter hotkey with
doublePressToConfirm: false: commits immediately. - enter commits the currently highlighted option in one press (a deliberate navigate-then-confirm is already two keystrokes; enter is the always-single-press confirm path, so the double-press toggle governs only the letter fast-path).
r(deny-with-reason) when armed/confirmed transitions tostep: "reason"rather than returning a decision; the component then shows the editor.submitReason: an empty/whitespace draft returns{ kind: "render" }withreasonErrorset (mandatory reason); a non-empty draft returns{ kind: "decision" }withcreateDeniedPermissionDecision(reason).s(approve-for-session) on a forwarded ask carryingsessionScopetransitions tostep: "scope"(subagent vs. serving-session sub-select) instead of committing; picking a scope commitsapproved_for_sessionorapproved_for_serving_session; a cancelled/escscope step navigates back to the decision list.esc: from"reason"or"scope"→ back to"decision"(clears the draft / armed state); from"decision"(top level) → commitcreateDeniedPermissionDecision()(esc denies, matching the roadmap).
The component and mode dispatch
permission-dialog.ts stays the single option-semantics entry and gains a mode dispatcher.
PermissionDecisionUi widens to expose custom (the select/input members are retained for the fallback), and the entry takes the run mode:
// src/authority/permission-dialog.ts (changed)
export interface PermissionDecisionUi {
select(title: string, options: string[]): Promise<string | undefined>;
input(title: string, placeholder?: string): Promise<string | undefined>;
custom<T>(
factory: (tui: TUI, theme: Theme, keybindings: KeybindingsManager, done: (r: T) => void) => Component,
options?: { overlay?: boolean },
): Promise<T>;
}
export interface PermissionPromptView {
readonly mode: ExtensionMode;
readonly ui: PermissionDecisionUi;
readonly doublePressToConfirm: boolean;
}
// New single entry LocalUserAuthorizer calls; dispatches on mode.
export function requestPermissionDecision(
view: PermissionPromptView,
title: string,
message: string,
options?: RequestPermissionOptions,
): Promise<PermissionPromptDecision> {
if (view.mode === "tui") {
return presentInlinePermissionPrompt(view, title, message, options); // permission-prompt-component.ts
}
return requestPermissionDecisionFromUi(view.ui, title, message, options); // unchanged select/input path
}
requestPermissionDecisionFromUi (the existing select/input implementation) is retained verbatim as the non-TUI fallback, so its current tests keep passing (lift-and-shift — introduce the dispatcher alongside, migrate the caller, keep the old function).
Consumer call site (Tell-Don't-Ask / LoD check)
selectAuthorizer already holds ctx; it reads ctx.mode/ctx.ui once and hands the authorizer a resolved view plus a live preference getter, rather than letting the authorizer reach back through ctx:
// src/authority/authorizer.ts — selectAuthorizer, hasUI arm (sketch)
if (ctx.hasUI) {
return new LocalUserAuthorizer({
ui: ctx.ui,
mode: ctx.mode,
events: deps.events,
getPromptPreferences: deps.getPromptPreferences, // () => ({ doublePressToConfirm })
requestPermissionDecision: deps.requestPermissionDecision,
});
}
// src/authority/local-user-authorizer.ts — authorize (sketch)
authorize(details: PromptPermissionDetails): Promise<PermissionPromptDecision> {
emitUiPromptEvent(this.deps.events, buildUiPrompt(details)); // unchanged single emit site
const { doublePressToConfirm } = this.deps.getPromptPreferences();
return this.deps.requestPermissionDecision(
{ mode: this.deps.mode, ui: this.deps.ui, doublePressToConfirm },
details.forwarding ? "Permission Required (Subagent)" : "Permission Required",
details.message,
buildRequestOptions(details), // unchanged (sessionScope for forwarded asks)
);
}
getPromptPreferences is a getter, not a snapshot, so toggling doublePressToConfirm in the /permission-system settings modal takes effect on the next prompt without re-activation.
It is wired in index.ts from the same config store the settings modal writes.
Config field
doublePressToConfirm is a flat boolean knob alongside yoloMode/debugLog/permissionReviewLog (a UI-behavior toggle, not part of the permission map), defaulting on:
// config.example.json (excerpt)
{
"doublePressToConfirm": true
}
Default-on normalization mirrors permissionReviewLog (present-unless-explicitly-false): raw.doublePressToConfirm !== false.
Edge cases
- RPC session (
hasUItrue,mode: "rpc"): dispatcher takes theselect/inputbranch — behavior identical to today. doublePressToConfirmoff: letter hotkeys commit on first press; enter still commits the highlight; reason/scope sub-steps are unaffected (they are step transitions, not double-press).- Empty reason: rejected with an inline
reasonError; the editor stays open (mandatory reason). - Forwarded ask without a session suggestion: no
sessionScope;scommitsapproved_for_sessiondirectly (no scope step), matching current behavior. soption absent (asks that offer no session grant): the option list omitss; pressing it is a no-op.
Module-Level Changes
New:
src/authority/permission-prompt-decision.ts— the pure model (types +initialPromptState/pressKey/submitReason). No SDK/TUI imports.src/authority/permission-prompt-component.ts—presentInlinePermissionPrompt(view, title, message, options): builds thectx.ui.customfactory, renders decision list + hotkey hints + reason editor + scope sub-select via@earendil-works/pi-tuiprimitives, forwardshandleInputto the model, and resolvesdone(decision).test/authority/permission-prompt-decision.test.ts— pure-model unit tests.test/authority/permission-prompt-component.test.ts— component tests via a faketui/theme/doneharness (the pi-askask-settings-command.test.tspattern).
Changed:
src/authority/permission-dialog.ts— widenPermissionDecisionUiwithcustom; addPermissionPromptView+ therequestPermissionDecisiondispatcher; keeprequestPermissionDecisionFromUias the fallback.src/authority/local-user-authorizer.ts—LocalUserAuthorizerDepsgainsmode,getPromptPreferences, andrequestPermissionDecision(replacing the injectedrequestPermissionDecisionFromUi);uitype widens viaPermissionDecisionUi.src/authority/authorizer.ts—AuthorizerSelectionDepsgainsgetPromptPreferencesandrequestPermissionDecision;selectAuthorizerpassesmode: ctx.modeand the getter intoLocalUserAuthorizer.src/index.ts— constructgetPromptPreferencesfrom the config store and inject it plusrequestPermissionDecisionintoAuthorizerSelection's deps.src/config-schema.ts— adddoublePressToConfirm: z.boolean().optional().meta({ description, markdownDescription }); thenpnpm run gen:schemaregeneratesschemas/permissions.schema.json(do not hand-edit).src/extension-config.ts— adddoublePressToConfirm: booleantoPermissionSystemExtensionConfig,doublePressToConfirm: truetoDEFAULT_EXTENSION_CONFIG, anddoublePressToConfirm: raw.doublePressToConfirm !== falseinnormalizePermissionSystemConfig.src/config-loader.ts— adddoublePressToConfirmto the boolean-scalar merge list inmergeUnifiedConfigs(and the doc comment listing scalar fields).src/config-modal.ts— add the toggle tobuildSettingItems/applySetting/syncSettingValuesand tocloneDefaultConfig(a required boolean must be present in the reset clone).config/config.example.json— add"doublePressToConfirm": true.docs/configuration.md— document the toggle and the inline-dialog hotkeys.README.md— note the inline TUI permission dialog and its hotkeys/toggle under the permission-prompt behavior.docs/architecture/architecture.md— mark Phase 11 Step 4 complete (✅ on the step heading and theS4Mermaid node) and update the health-metric rowInline prompt component files (ui.custom in src/authority/)from0to1, in the implementation doc-update commit.
Symbol-removal / rename grep (performed for this plan): requestPermissionDecisionFromUi is retained (not removed), so no consumer breaks; grep -rn requestPermissionDecisionFromUi src test shows permission-dialog.ts, local-user-authorizer.ts (+ its type import), authorizer.ts, index.ts, and local-user-authorizer.test.ts.
The migration re-points the injected seam from requestPermissionDecisionFromUi to requestPermissionDecision at those call sites; the function itself remains as the fallback implementation.
No docs/ or SKILL.md prose names requestPermissionDecisionFromUi (it is an internal symbol), so no narrative doc update is needed for the rename — only the additive inline-dialog documentation above.
Test Impact Analysis
- New tests the split enables — the double-press arming, reason-required validation, esc back-navigation, and forwarded-scope transitions become directly unit-testable as pure
pressKey/submitReasoncases, with no TUI harness. These are new behaviors with no prior coverage. - Redundant / simplified — none of the existing
permission-dialog.test.tsselect/input cases become redundant: they now pin the RPC fallback branch, which is still reached. No test is deleted. - Must stay as-is —
permission-dialog.test.tsselect/input assertions (they exercise the retained fallback), andpermission-prompter.test.ts/authorizer-selection.test.ts(they exercise bracketing and selection, which are unchanged).
Invariants at risk
Step 4 touches the Authorizer spine landed in Phase 9 (#555–#559).
Invariants to preserve, each with its pinning test:
- Single
permissions:ui_promptemit site (LocalUserAuthorizer, #292/#555) — pinned bylocal-user-authorizer.test.ts("emits a UI prompt event…"). The emit stays inauthorize()ahead of the dispatch; the mode branch is downstream of it, so the broadcast fires identically for TUI and RPC. Update the test's deps shape (addmode/getPromptPreferences/requestPermissionDecision) but keep the emit assertion. - Forwarded-ask provenance rendering (
(Subagent)title + populatedforwardingcontext, #557) — pinned bylocal-user-authorizer.test.ts; the title/buildRequestOptionslogic is unchanged. confirmationUnavailable/DenyingAuthorizerpath (#556) — untouched; no TUI branch exists there.- Review-log bracketing order (
PermissionPrompter, #555) — untouched; the dispatcher sits insideauthorizer.authorize, whichPermissionPrompterstill brackets.
TDD Order
- Pure decision model —
test/authority/permission-prompt-decision.test.tsred →src/authority/permission-prompt-decision.tsgreen. Cover: navigation highlight (no commit, clears arm); double-press arm→confirm per key; different-key re-arm; toggle-off immediate commit; enter commits highlight;r→reason step;submitReasonempty-rejected / non-empty decision;s→scope step with back-nav and least-privilege default; esc back (reason/scope) and esc-denies (top level). Commit:feat(pi-permission-system): add inline permission prompt decision model. - Config field — schema + type + default + normalize + merge + regen schema, with the config-schema parity test and
extension-config/config-loaderunit tests.config.example.json,docs/configuration.md,README.mdupdated here. Commit:feat(pi-permission-system): add doublePressToConfirm config toggle. (Field is declared here and consumed in step 4 — no persistent unread window by the pre-completionfallow dead-codegate.) - Inline component —
test/authority/permission-prompt-component.test.tsred →src/authority/permission-prompt-component.tsgreen, using a faketui/theme/doneharness; assert key sequences resolve the expectedPermissionPromptDecisionand that the reason editor / scope sub-select wire to the model. Commit:feat(pi-permission-system): render inline keybind permission prompt. - Mode dispatch + wiring — widen
PermissionDecisionUi, addPermissionPromptView+requestPermissionDecisioninpermission-dialog.ts; threadmode+getPromptPreferences+ the dispatcher throughauthorizer.ts/local-user-authorizer.ts/index.ts; add the settings-modal toggle inconfig-modal.ts. Updatelocal-user-authorizer.test.tsandpermission-dialog.test.ts(add tui→inline and rpc→fallback dispatch cases; keep the select/input and emit assertions). This removes the old injectedrequestPermissionDecisionFromUiseam fromLocalUserAuthorizerDeps/AuthorizerSelectionDepsand itsindex.tscall site — one commit, since a removed injected field breaks its consumers and their tests at the type level simultaneously. Commit:feat(pi-permission-system): dispatch TUI permission prompts to the inline keybind dialog. - Architecture completion — mark Phase 11 Step 4 ✅ (heading +
S4node) and bump theInline prompt component filesmetric row0 → 1indocs/architecture/architecture.md. Commit:docs(pi-permission-system): mark Phase 11 Step 4 complete.
Risks and Mitigations
ctx.ui.customrenders only in TUI — mitigated by gating onctx.mode === "tui"(nothasUI) and keeping theselect/inputfallback for RPC; a dispatch test pins both branches.- Config field declared but unread (maintenance-trap smell) — mitigated by consuming it in step 4, the same phase; the pre-completion
fallow dead-codegate runs after step 4. - Live-toggle staleness — reading
doublePressToConfirmvia a getter (not an activation snapshot) keeps the settings-modal change effective on the next prompt. - Component hard to unit-test — mitigated by the pure-model split; the component test only confirms wiring against a fake TUI, matching the pi-ask harness pattern.
- Regressing a spine invariant with a green suite — the Invariants-at-risk section names each invariant and its pinning test; the
local-user-authorizer.test.tsemit assertion is preserved through the deps-shape change.
Open Questions
- Per-key hotkey remapping (a config schema mirroring pi-ask's keybindings) is deferred and unfiled; revisit only if a user requests configurable letters.
- Whether the inline dialog should also surface the tool-input preview inline (beyond the current
message) is left to the component's rendering pass; no contract change is implied, so it is an implementation detail, not a planned surface.