22 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 642 | pi-permission-system: preserve Ctrl+O tool expansion in inline permission prompts |
Preserve tool expansion in inline permission prompts
Release Recommendation
Release: ship independently
Issue #642 is a standalone bug fix, not a numbered step in docs/architecture/architecture.md's improvement roadmap — a grep for #642 and #643 there returns nothing, so there is no Release: batch tag to honor.
The change lands a fix: commit, which is an unhidden changelog type and cuts a release on its own.
Problem Statement
While an inline permission prompt holds keyboard focus, Pi's app.tools.expand action (Ctrl+O by default) does nothing.
The user cannot expand a truncated tool preview at exactly the moment they are being asked to approve that tool call.
The cause is in src/authority/permission-prompt-component.ts.
presentInlinePermissionPrompt builds the ctx.ui.custom factory but discards its third argument as _keybindings, and PermissionPromptComponent.handleInput routes every keystroke to either handleReasonInput or toEvent — neither of which knows about application-level actions.
A focused custom component must explicitly preserve the app actions it does not own; this one preserves none.
This works directly against a stated package priority: keep block/ask/allow decisions reviewable. The moment a user most needs the full pending tool invocation is the moment they are deciding on it.
PR #643 from @0xbentang implements a fix.
The PR-review stage (see docs/retro/0642-preserve-tool-expansion-in-prompts.md) settled the direction: adopt the capability with our own simplified design, using the PR as reference rather than the merge target.
That retro entry satisfies this plan's Decide gate; the plan below implements the recorded decision rather than re-opening it.
Goals
- While the inline permission prompt is focused,
app.tools.expandtoggles Pi's tool-output expansion. - The toggle never resolves, commits, arms, or otherwise alters the pending permission decision.
- The action is consulted only in the
decisionandscopesteps; thereasonstep's text entry is never intercepted. - The component holds no Pi SDK keybindings type — the keybinding lookup and the
uireach-through stay in the module's factory function. docs/configuration.mdandREADME.mddocument the behavior.
This change is not breaking. It is additive keystroke handling: no output shape, no default, no config field, and no existing key's meaning changes.
Non-Goals
- No expand hint in the prompt's hint line. Operator decision: expansion is a global app binding most users already know, the decision-step hint line is already dense, and a permission dialog is the wrong place to teach an unrelated global key.
- No new config field.
The binding is Pi's own
app.tools.expand; this package reads it, it does not redefine or re-bind it. - No change to
PermissionDecisionUi. The narrowselect/inputfallback surface stays exactly as #573 left it. - No change to the pure decision model (
src/authority/permission-prompt-decision.ts). Tool expansion is a display concern with no bearing on the decision, so it must not become aPromptEvent. - No other app actions.
Only
app.tools.expandis forwarded; a general app-action passthrough is not in scope and is not speculatively built.
Background
Relevant modules:
src/authority/permission-prompt-component.ts—presentInlinePermissionPromptbuilds thectx.ui.customfactory; the privatePermissionPromptComponentclass renders state and maps keystrokes toPromptEvents. DeclaresPermissionPromptUi = Pick<ExtensionUIContext, "select" | "input" | "custom">andPermissionPromptView.src/authority/permission-prompt-decision.ts— the purereducePromptmodel. OwnsPromptStep = "decision" | "reason" | "scope". No SDK or TUI imports; it must stay that way.src/authority/local-user-authorizer.ts—LocalUserAuthorizerDeps.uiis typedPermissionPromptUi, so widening that type reaches this file's tests.src/authority/authorizer.ts—selectAuthorizerpasses the realctx.ui(a fullExtensionUIContext), so widening is safe in production.
Facts verified against the sibling Pi checkout at ../pi, not the bundled dist:
| Fact | Location |
|---|---|
getToolsExpanded() / setToolsExpanded() are declared on ExtensionUIContext |
core/extensions/types.ts:277 |
custom's third factory argument is a non-optional KeybindingsManager |
core/extensions/types.ts:195 |
The factory is invoked as factory(this.ui, theme, this.keybindings, close) — always a real manager |
modes/interactive/interactive-mode.ts:2490 |
setToolsExpanded ends with this.ui.requestRender() |
modes/interactive/interactive-mode.ts:3815 |
| All three modes supply both methods (interactive; RPC no-op; headless runner stub) | interactive-mode.ts:2189, rpc-mode.ts:302, extensions/runner.ts:262 |
| Pi's own focused component checks this action first | modes/interactive/components/extension-selector.ts:93 |
matches(data: string, keybinding: Keybinding) where Keybinding = keyof Keybindings includes "app.tools.expand" |
tui/src/keybindings.ts:194, core/keybindings.ts:22 |
Two consequences follow from that table and shape the design.
Because setToolsExpanded already re-renders the host, the component must not call requestRender() after toggling — the prompt's own rendered lines are unchanged by expansion.
Because the keybindings argument is non-optional and always a real instance, no undefined guard is needed.
An AGENTS.md constraint applies: keep Pi SDK imports out of business-logic modules and accept capabilities as parameters.
permission-prompt-component.ts is a legitimate SDK consumer (it already imports ExtensionUIContext), but the private component class inside it should not gain an SDK type — the module's factory function is the right home for the lookup.
Design Overview
The seam
The component gets exactly one new collaborator: a predicate that answers "was this keystroke an application action, and did I handle it?"
/** The keybindings surface the dialog consults; only `matches` is read (ISP). */
type PromptKeybindings = Pick<KeybindingsManager, "matches">;
The Pick was verified to compile and to be satisfiable by a bare object literal ({ matches: (_d, _k) => false }) under tsc at planning time, so the narrowing is a measurement rather than an assumption.
presentInlinePermissionPrompt owns both the keybinding lookup and the ui reach-through:
return view.ui.custom<PermissionPromptDecision>(
(tui, theme, keybindings, done) =>
new PermissionPromptComponent(
theme,
config,
title,
message,
(data) => handleToolsExpandAction(data, keybindings, view.ui),
() => {
tui.requestRender();
},
done,
),
{ overlay: false },
);
with the named helper below it (stepdown rule):
/**
* Forward Pi's tool-expansion action while the dialog holds focus.
*
* Returns `true` when the keystroke was the action (and was handled), so the
* component stops before mapping it to a `PromptEvent`. Deliberately does not
* request a render: `setToolsExpanded` re-renders the host itself, and the
* dialog's own lines are unaffected by tool expansion.
*/
function handleToolsExpandAction(
data: string,
keybindings: PromptKeybindings,
ui: PermissionPromptUi,
): boolean {
if (!keybindings.matches(data, "app.tools.expand")) {
return false;
}
ui.setToolsExpanded(!ui.getToolsExpanded());
return true;
}
This keeps KeybindingsManager out of the component entirely, so the component's tests need no keybindings fake to construct it — the seam is a plain (data: string) => boolean.
Precedence
The check sits immediately after the existing reason early-return and before local key mapping:
handleInput(data: string): void {
if (this.state.step === "reason") {
this.handleReasonInput(data);
return;
}
if (this.handleAppAction(data)) {
return;
}
const event = this.toEvent(data);
if (event) {
this.apply(event);
}
}
Because the reason branch already returns first, this single-line insertion is "before local handling, but only in the decision and scope steps" — no new branching structure is required, and PromptStep is not re-interrogated.
The alternative — consulting the app action only after toEvent declines — was considered and rejected.
It would be marginally safer against a pathological rebinding, but it diverges from Pi's own ExtensionSelectorComponent, which checks app.tools.expand first.
Consistency with the host's focused-component convention wins; the residual rebinding risk is covered under Risks.
Constructor shape
The PR under review added two positional constructor parameters (a KeybindingsManager plus a toggle callback), taking the class to eight.
This design adds one, taking it to seven, and places it before requestRender so the two callback arguments differ visibly at the call site (one takes data and delegates to a named helper; the other takes nothing).
Converting the constructor to a params object was considered and deliberately not planned.
PermissionPromptComponent is private to its module with a single call site, and the conversion would replace this.theme / this.config with this.deps.theme / this.deps.config across all three render methods — churn that trades one readability problem for another in a bug-fix commit.
The tidy-first-assessor dispatched at the start of /tdd-plan reads this file and may revisit the call; this plan does not pre-empt it.
UI surface widening
PermissionPromptUi gains the two accessors:
export type PermissionPromptUi = Pick<
ExtensionUIContext,
"select" | "input" | "custom" | "getToolsExpanded" | "setToolsExpanded"
>;
PermissionDecisionUi (the narrow select/input fallback surface) is untouched, preserving #573's ISP split.
Production passes ctx.ui, so nothing changes at the composition root.
Module-Level Changes
src/authority/permission-prompt-component.ts:
- Add a type-only
KeybindingsManagerimport from@earendil-works/pi-coding-agent. - Add the module-private
PromptKeybindings = Pick<KeybindingsManager, "matches">alias. - Widen
PermissionPromptUiwithgetToolsExpandedandsetToolsExpanded. - Name the factory's third argument
keybindings(was_keybindings) and pass the new closure. - Add
handleToolsExpandActionbelowpresentInlinePermissionPrompt. - Add
private readonly handleAppAction: (data: string) => booleanto the constructor, betweenmessageandrequestRender. - Insert the app-action check in
handleInputafter thereasonearly-return.
test/authority/permission-prompt-component.test.ts:
- Retype
PromptFactory'skeybindingsparameter fromundefinedto{ matches(data: string, action: string): boolean }. - Extend
makeFakeViewwith a configurable expand key (default Ctrl+O), a fakematches, statefulgetToolsExpanded/setToolsExpandedstubs on the fakeui, and both stubs on the returned object. - Add a
describe("tool expansion")block with three tests (see TDD Order).
test/authority/local-user-authorizer.test.ts:
- Both
uiobject literals (lines 31 and 138) are typed throughLocalUserAuthorizerDeps.ui, so the widening breaks them attsc. - Extract a
makePromptUi()helper and use it at both sites.
Docs:
docs/configuration.md— extend the### Inline permission dialog (TUI)section (line 115) with a sentence on tool expansion after the existing navigation paragraph. Not a new table row: the key is Pi's user-rebindableapp.tools.expand, not a prompt-owned hotkey, so it does not belong in a table ofy/s/n/r.README.md— extend the inline-dialog sentence at line 68.docs/architecture/architecture.md— update thepermission-prompt-component.tsmodule-tree entry (line 794) to state that it forwardsapp.tools.expandin the decision/scope steps. Per AGENTS.md, the entry describes current behavior; no issue ref is added, since this encodes no lint-guarded or ADR boundary.
Verified as not requiring updates:
test/authority/authorizer.test.ts:18andtest/authority/authorizer-selection.test.ts:34builduiliterals behindas unknown as ExtensionContextcasts and never reachcustom, so they neither break attscnor at runtime..pi/skills/package-pi-permission-system/SKILL.md— greps forpermission-prompt-component,inline keybind, andhotkeyreturn no match.docs/architecture/history/phase-11-*.md— history, never edited retroactively.
Test Impact Analysis
This is a behavior addition, not an extraction, so the analysis is short.
- Newly enabled tests.
The
(data: string) => booleanseam means the component's app-action behavior is testable through the existing fake-view harness with no keybindings-manager fake — the harness supplies a two-linematches. Making the expand key configurable in the harness newly enables the precedence test: binding the action to a printable key is the only way to prove thereasonstep is not intercepted, since the default Ctrl+O would be dropped byisPrintableanyway and would false-green. - Newly redundant tests. None. No existing test covers app-action handling.
- Tests that must stay as-is.
Every existing test in
permission-prompt-component.test.tsnow doubles as a guard that the seam does not swallow ordinary keystrokes — the hotkey, navigation, escape, reason-editing, and scope tests all pass keys throughhandleInputahead oftoEvent. They must keep passing unchanged; a regression in the seam breaks them.
Invariants at risk
This surface was built by #573 (phase 11). Its documented outcomes and the tests that pin them:
| Invariant from #573 | Pinned by |
|---|---|
PermissionDecisionUi stays narrow (select/input); the wider custom-capable surface is a separate PermissionPromptUi (ISP) |
test/authority/permission-dialog.test.ts — passes ui literals with only select/input to requestPermissionDecisionFromUi |
| The double-press affordance is a config toggle read live at prompt time | the double-press to confirm (enabled) / (disabled) describe blocks |
| Deny-with-reason requires a non-empty reason | rejects an empty reason and shows an error, then accepts a real one |
The mode dispatch renders inline only in "tui" |
falls back to the select flow outside TUI mode |
The widening touches the first invariant's boundary but preserves it: PermissionPromptUi grows, PermissionDecisionUi does not, and the existing permission-dialog.test.ts literals keep compiling untouched — which is itself the measurement that the split held.
The third invariant is the one this change could most plausibly regress, and the chosen precedence is what protects it: intercepting keystrokes during the reason step could make a required reason untypeable.
The new precedence test pins it directly rather than leaving it to prose.
No quantitative invariants (byte-identical prefixes, token budgets, latency) are in play.
TDD Order
-
Red — cover tool expansion during the prompt. Surface:
test/authority/permission-prompt-component.test.ts. Retype thePromptFactorykeybindings parameter, extendmakeFakeViewwith a configurable expand key and the expansion stubs, and adddescribe("tool expansion")with three tests:- toggles expansion on each press (
truethenfalse) and leaves the decision promise unsettled, then still resolvesapprovedony,y; - during the
scopestep (forwarded ask), toggles without committing, thenenterresolvesapproved_for_session; - with the action bound to the printable key
e, typingeinside thereasonstep yieldsdenialReason: "e"andsetToolsExpandedis never called.
The harness casts to
PermissionPromptView, so this file compiles andpnpm run checkstays green; the red is behavioral (setToolsExpandeduncalled), not a type error. Commit:test(pi-permission-system): cover tool expansion during permission prompts. - toggles expansion on each press (
-
Green — forward the tools-expand action. Surface:
src/authority/permission-prompt-component.tsplustest/authority/local-user-authorizer.test.ts. Apply everypermission-prompt-component.tschange from Module-Level Changes, and update bothlocal-user-authorizer.test.tsuiliterals via amakePromptUi()helper in this same commit — wideningPermissionPromptUibreaks them at the type level, so the interface change and its typed call sites cannot land separately. Runpnpm run checkimmediately after this commit (shared-interface change) and the full package suite, not just the two edited files. Commit:fix(pi-permission-system): preserve tool expansion in inline permission prompts. -
Docs — document the behavior. Surface:
docs/configuration.md,README.md,docs/architecture/architecture.md. Commit:docs(pi-permission-system): document tool expansion during permission prompts.
Every commit carries the contributor trailer as the last line of the body, after a blank line:
Co-authored-by: Ben Tang <bentang@fastmail.com>
Reference the sources as Refs #642, #643 in the body — never a Closes keyword, which would pre-empt the curated close comments.
Risks and Mitigations
| Risk | Mitigation |
|---|---|
A user rebinds app.tools.expand to a decision hotkey (y/s/n/r), shadowing it in the decision step |
Accepted consequence of the chosen precedence, and self-inflicted. No option becomes unreachable: arrow/j/k navigation plus enter still commits every option, and esc still denies. The reason step is structurally immune. |
A future Pi version stops re-rendering inside setToolsExpanded, making the toggle appear inert |
The omission of a requestRender() call is deliberate and load-bearing on interactive-mode.ts:3815; the design comment on handleToolsExpandAction records why, so a future reader sees the dependency rather than re-deriving it. |
The widened PermissionPromptUi reaches a non-TUI caller lacking the accessors |
Cannot happen: requestPermissionDecision dispatches to custom only when mode === "tui", production passes the full ctx.ui, and all three modes implement both methods anyway (RPC and headless as no-ops). |
| The precedence test false-greens because the default Ctrl+O is non-printable and would be dropped regardless | The test binds the action to the printable key e specifically so the assertion discriminates; asserting on Ctrl+O would prove nothing. |
| The seam silently swallows ordinary keystrokes | The full existing test file exercises hotkeys, navigation, escape, reason editing, and scope through handleInput; a swallowing regression fails those, not just the new block. |
Open Questions
- Whether the expand key should eventually appear in the prompt's hint line for discoverability. Explicitly declined by the operator for this change (see Non-Goals). Revisit only if users report that the capability is undiscoverable; no follow-up issue filed, since filing one now would be speculative.