Files
my-pi/pi-permission-system/docs/retro/0642-preserve-tool-expansion-in-prompts.md
T

22 KiB

issue, issue_title, pr
issue issue_title pr
642 pi-permission-system: preserve Ctrl+O tool expansion in inline permission prompts 643

Retro: #642 — preserve Ctrl+O tool expansion in inline permission prompts

Tracking issue #642 (the bug report); PR #643 is @0xbentang's implementation against it, evaluated below as reference material. Both were filed by @0xbentang.

Stage: PR Review (2026-07-26T01:17:17Z)

Session summary

Issue #642 reports that Pi's app.tools.expand action (Ctrl+O) has no effect while an inline permission prompt is focused; PR #643 from @0xbentang (third party) implements a fix, so a user can expand a truncated tool preview before approving it. The underlying gap is real: PermissionPromptComponent.handleInput consumes every keystroke and the ctx.ui.custom factory discards its injected keybindings manager (_keybindings), so the global expand action is dead for the whole duration of an ask. The operator chose direction 1 — adopt the capability with our own simplified design, using the PR as reference rather than the merge target.

Evaluation

Problem — real, and it works against a stated package priority. packages/pi-permission-system/src/authority/permission-prompt-component.ts renders the ask inline (view.ui.custom(..., { overlay: false })) and takes focus. Its handleInput dispatches only to handleReasonInput and toEvent, and presentInlinePermissionPrompt names the third factory argument _keybindings, so no application action survives the prompt. The package's "keep block/ask/allow decisions reviewable" priority argues directly for the fix: the moment a user most needs the full pending tool invocation is the moment they are deciding on it.

The SDK surface exists and the sibling pattern is established. Verified against the sibling Pi checkout, not the bundled dist:

  • ExtensionUIContext.getToolsExpanded() / setToolsExpanded() are declared at ../pi/packages/coding-agent/src/core/extensions/types.ts:277.
  • All three modes supply them — interactive (interactive-mode.ts:2189), RPC (rpc-mode.ts:302, no-op), and the headless runner stub (runner.ts:262) — so widening the prompt's UI surface cannot fail at runtime.
  • setToolsExpanded already calls this.ui.requestRender() itself (interactive-mode.ts:3815), so the PR is correct not to add a redundant requestRender() after the toggle.
  • Pi's own ExtensionSelectorComponent.handleInput performs the same kb.matches(keyData, "app.tools.expand") check first (components/extension-selector.ts:93), so this is convention-fit, not an invented shape.

There is no speculative generality here — nothing declared-but-unread, no over-wide threading of a value through layers that ignore it. CI on the PR head is green (check passes).

What is valuable: the capability itself, the decision to route through the injected KeybindingsManager rather than hard-coding \u000f, the delegation of the get/set reach-through into a closure owned by presentInlinePermissionPrompt, and the regression test's core assertion — that toggling never settles the decision promise.

What I would change:

  1. Interface segregation on the SDK type. The PR imports KeybindingsManager whole but uses only .matches(). The PR's own test is the tell: PromptFactory types the argument as { matches(data: string, action: string): boolean } — the narrow contract already surfaced under test pressure and should be the production shape.
  2. Constructor width. PermissionPromptComponent goes from six to eight positional constructor parameters, and two of the additions (keybindings + toggleToolsExpanded) are one collaborator's worth of behavior split across two slots. Collapse them into a single injected seam — an "app action consumed this keystroke" predicate of shape (data: string) => boolean — so presentInlinePermissionPrompt owns both the keybinding lookup and the ui reach-through, the component holds no Pi SDK type, and the test needs no fake keybindings manager. This is "thread decisions, not discriminators": the component should not re-interpret a raw keybindings manager.
  3. Key precedence during the reason step. The PR checks the app action at the very top of handleInput, ahead of the reason branch. Harmless for the default Ctrl+O (\u000f is non-printable and isPrintable would drop it anyway), but app.tools.expand is user-rebindable: a printable rebinding becomes untypeable inside a deny reason and shadows the y/s/n/r decision hotkeys.
  4. Docs. The inline-dialog key table in docs/configuration.md (the block at lines 119-130) says nothing about tool expansion, so the behavior is undocumented.

Behavior / breaking: not breaking. Purely additive keystroke handling — no output shape, no default, no config field, no change to any existing key's meaning. fix(pi-permission-system): is the correct type.

Security surface: least-privilege and aligned with the package's priorities. The toggle mutates display expansion only; it cannot resolve, arm, or alter a pending decision, and the new test asserts non-resolution across two toggles before the decision is committed. It strictly increases the information available to the human before an approval.

Decision and attribution

Direction: adopt the capability, plan a simplified design (/plan-issue #642). The work is tracked on issue #642; PR #643 is reference material, and the implementation is ours.

Agreed scope:

  • Toggle app.tools.expand while the inline permission prompt is focused, without touching the pending decision.
  • Collapse the two new constructor parameters into one narrow app-action seam; keep KeybindingsManager out of PermissionPromptComponent.
  • Precedence: check the app action before local handling, but only in the decision and scope steps — the reason step's text entry is never intercepted. Operator's call, and I agree: it preserves Pi-like precedence while choosing, and removes the rebinding collision entirely for text input.
  • Update the inline-dialog key table in docs/configuration.md and the prompt description in README.md.

Non-goals (operator decisions, both sound):

  • No expand hint in the prompt's hint line. 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.
  • The PermissionPromptUi widening stands as the PR has it. It also types LocalUserAuthorizerDeps.ui, so the non-TUI requestPermissionDecisionFromUi path nominally gains two methods it never calls — accepted, since every mode supplies them and the alternative (a separate field on PermissionPromptView) buys little. This is why the diff touches local-user-authorizer.test.ts; expect the same test churn in our implementation.

Attribution — required on every implementation and docs commit for this work, as the last line of the body after a blank line:

Co-authored-by: Ben Tang <bentang@fastmail.com>

Reference both as Refs #642, #643 / (#642) — never Closes #642 or Closes #643, which would pre-empt the curated close comments.

Close-out at ship time closes both:

  • Issue #642issue_close as completed, with the behavior summary and the implementing SHA(s).
  • PR #643 — closed as superseded, with a comment thanking @0xbentang by name, explaining that we adopted the capability with a simplified design, and linking the implementing SHA(s).

Stage: Planning (2026-07-26T01:26:47Z)

Session summary

Wrote docs/plans/0642-preserve-tool-expansion-in-prompts.md implementing the PR-review stage's recorded decision — adopt the capability, simplify the seam — in three TDD cycles (red test, fix: green, docs:). The Decide gate was already satisfied by the PR Review stage above, so this session planned around the recorded direction rather than re-litigating it. Release is ship independently: a grep of docs/architecture/architecture.md for #642/#643 returns nothing, so the issue is in no roadmap batch and its fix: commit cuts a release on its own.

Observations

  • Verified the SDK against ../pi, not the bundled dist. Two facts changed the design rather than merely confirming it. setToolsExpanded ends with this.ui.requestRender() (interactive-mode.ts:3815), so the component must not call requestRender() after toggling — the omission is load-bearing, and the plan records why so a future reader does not "fix" it. custom's third factory argument is a non-optional KeybindingsManager invoked as factory(this.ui, theme, this.keybindings, close) (interactive-mode.ts:2490), so no undefined guard is needed despite the existing test passing undefined behind a cast.
  • Measured the ISP narrowing instead of asserting it. Compiled a throwaway probe (Pick<KeybindingsManager, "matches"> satisfied by a bare object literal) under tsc before planning around it. This is the skill's "confirm what a module exports with tsc, not a runtime symptom" rule applied at planning time.
  • Rejected the params-object constructor refactor. The PR review flagged constructor width, and collapsing the PR's two new parameters into one seam addresses the agreed scope (8 → 7 params). A full params-object conversion would replace this.theme with this.deps.theme across all three render methods of a private, single-call-site class — trading one readability problem for another inside a bug-fix commit. Left explicitly to the tidy-first-assessor at /tdd-plan start rather than pre-empted.
  • The precedence choice is what protects a #573 invariant. Intercepting keystrokes during the reason step could make a required denial reason untypeable. The plan pins this with a test that binds app.tools.expand to the printable key e — asserting on the default Ctrl+O would false-green, since isPrintable drops it regardless of the seam. This was the sharpest planning insight: the obvious test proves nothing.
  • Alternative precedence considered and rejected. Consulting the app action only after local mapping declines is marginally safer against a pathological rebinding (e.g. binding expand to y), but diverges from Pi's own ExtensionSelectorComponent, which checks the action first. Host-convention consistency won; the residual risk is bounded because arrow/j/k + enter still commits every option, so no decision becomes unreachable.
  • Enumerated the widening's blast radius by type, not by grep alone. Only local-user-authorizer.test.ts (two ui literals) breaks at tsc, because it is typed through LocalUserAuthorizerDeps. authorizer.test.ts and authorizer-selection.test.ts build theirs behind as unknown as ExtensionContext casts and never reach custom, so they break neither at compile time nor at runtime — the package skill's warning about cast-masked ctx literals applied, and the answer here was "no update needed".
  • No follow-up issues filed. The one deferred item (an expand hint in the prompt's hint line) is an operator-declined non-goal, not concretely named future work; filing it would be speculative.

Stage: Implementation — TDD (2026-07-26T13:50:20Z)

Session summary

Implemented the plan in two commits (6a0d2412 fix:, f4098d33 docs:), forwarding Pi's app.tools.expand action through a narrow (data: string) => boolean seam consulted only in the decision and scope steps. Test count for pi-permission-system went 2665 → 2668 (+3, all in test/authority/permission-prompt-component.test.ts). Pre-completion reviewer: PASS — ready for /ship-issue.

Observations

  • Deviation: folded the plan's test: red step into the fix: commit (2 commits, not 3). Rationale: the widened PermissionPromptUi breaks local-user-authorizer.test.ts at the type level in the same commit regardless, so a standalone red commit would have left the tree failing tsc and the suite. The Red→Green cycle still ran — the red was measured (2 failed / 17 passed) before any src/ edit. The reviewer independently endorsed the call on two grounds: the testing skill's interface-change rule mandates bundling, and this repo's history shows fix: commits routinely carry their own tests rather than landing a separate red commit.
  • The tidy-first-assessor returned "no preparatory tidying warranted" and independently confirmed the plan's decision to keep the positional constructor. It added an argument the plan had not made: the three callback parameters have mutually incompatible signatures ((data: string) => boolean, () => void, (decision) => void), so a transposition at the single call site fails tsc rather than silently misbehaving — which is what makes 7 positional params acceptable here. It also correctly declined to split the makePromptUi() extraction into a separate prep commit, noting it is not separable from the widening.
  • The precedence guard test is load-bearing, and the reviewer proved it more sharply than planning did. Planning argued it "discriminates"; the reviewer traced the actual failure mode: hoisting the check above the reason branch makes "e" never reach reasonBuffer, so ENTER submits an empty reason, the decision model rejects it, the promise never resolves, and the test hangs to timeout. Worth remembering as a pattern — an "absence of interception" assertion can look weak while actually pinning ordering that no lint rule or type constrains.
  • Binding the fake action to a printable key was the whole trick. A test asserting on the default Ctrl+O would have false-greened, because isPrintable drops \u000f in the reason editor whether or not the seam intercepts it. The plan called this out in advance and it held up exactly as predicted.
  • The Pick<KeybindingsManager, "matches"> narrowing needed no rework, because it was compiled as a throwaway tsc probe during planning rather than assumed. Same for the no-requestRender() decision, which came from reading interactive-mode.ts:3815 in the sibling ../pi checkout rather than the bundled dist.
  • Blast radius matched the plan exactly: only local-user-authorizer.test.ts broke at tsc (cascading to 10 call sites through makeDeps), while the cast-masked ctx literals in authorizer.test.ts / authorizer-selection.test.ts were correctly predicted to need nothing. No unplanned file was touched.

Stage: Final Retrospective (2026-07-26T15:22:34Z)

Session summary

All five stages — PR review, planning, TDD, ship, retro — ran in a single session, taking third-party PR #643 from triage through pi-permission-system-v23.0.3. The capability (Pi's app.tools.expand staying live during an inline permission prompt) was adopted with a narrower design than the PR proposed, with @0xbentang credited via Co-authored-by: trailers and a close comment on both #642 and #643. One user correction (retro keyed to the PR number instead of the issue) and one multi-hop SDK spelunk were the only real friction.

Observations

What went well

  • Measuring a type-level assumption at planning time. Before designing around Pick<KeybindingsManager, "matches">, planning wrote a throwaway src/__kbprobe.ts, ran pnpm run check, and deleted it. The testing skill says to confirm export claims with tsc rather than a runtime symptom, and the plan template says to measure quantitative invariants — this generalized both to a type-level design assumption, and the narrowing needed no rework at implementation.
  • The plan's skepticism about its own test design was the highest-value planning output. Planning noticed that a precedence test using the default Ctrl+O would false-green, because isPrintable drops \u000f in the reason editor whether or not the seam intercepts it, and specified binding the fake action to the printable key e instead. The pre-completion reviewer independently confirmed the test is load-bearing by tracing the failure mode: hoisting the check above the reason branch makes ENTER submit an empty reason, which the model rejects, so the promise never resolves and the test hangs to timeout.
  • The plan predicted the tsc blast radius exactly. It named local-user-authorizer.test.ts as the only type-level break and correctly predicted that the cast-masked ctx literals in authorizer.test.ts / authorizer-selection.test.ts would need nothing — an application of the package skill's cast-masking warning that held on contact.
  • The tidy-first-assessor earned its dispatch by sharpening an argument rather than adding work. It returned "no preparatory tidying warranted" and confirmed the plan's decision to keep the positional constructor, adding a point planning had not made: the three callback parameters have mutually incompatible signatures, so a transposition fails tsc rather than silently misbehaving.

What caused friction (agent side)

  • missing-context — keyed the PR-review triage note to the PR number (0643-…) instead of the issue it addresses (0642-…), and told the operator to run /plan-issue #643. The prompt at .pi/prompts/pr-review.md:88 does say NNNN matches the PR number, but issue #642 had been read at the start of the session and a directory listing two turns earlier showed the issue-keyed convention (0645-, 0646-, 0647-, 0653-) — neither signal was reconciled against the prompt's rule. User-caught ("Note, this is also about Issue #642"). Impact: git mv + an Edit + a commit --amend (4 tool calls of rework) and a wrong handoff already printed. The mis-keyed file would also not have been found by /plan-issue, which looks the retro up by issue number.
  • rabbit-hole — spent roughly 8 consecutive tool calls in the sibling ../pi checkout hunting where ui.custom passes its keybindings argument to the factory: grep "async custom"grep "custom:" → an awk line-range → grep keybindingsManagergrep showCustomComponent → a tab-literal grep that finally hit line 2161 → showExtensionCustom → the factory(...) call. Impact: no rework and the answer was correct and load-bearing, but the whole multi-hop trace burned planning-session context that an Explore subagent dispatch would have kept off it.
  • other (tool-selection slip) — the retro stage's skill load pulled github-voice instead of the ask-user skill the prompt names. Self-identified and corrected on the next turn. Impact: one wasted file read, no rework.
  • other (plan/template mismatch) — the plan specified a standalone test: red commit, while /tdd-plan states test-only commits are "rare; usually folded into the feat." Implementation folded them, which the pre-completion reviewer endorsed on two independent grounds. Impact: no rework; a deviation that had to be justified in a commit body and to the reviewer.

What caused friction (user side)

  • Nothing that cost rework. The single correction ("Note, this is also about Issue #642") was five words delivered at the earliest possible moment — immediately after the mis-keyed handoff was printed, before any downstream stage consumed it. That is the ideal shape for this intervention, and the fix belongs in the prompt rather than in operator vigilance.
  • Invoking /pr-review 643 without the issue number was reasonable; the prompt should derive the issue from the PR body, and the proposal below makes it do so.
  • "Everything ready to ship?" before /ship-issue was a useful checkpoint: it found nothing wrong but forced an explicit state verification (clean tree, 6 unpushed commits, nothing through CI yet) before an irreversible action.

Diagnostic details

  • Model-performance correlation — PR review, planning, and TDD ran on anthropic/claude-opus-5; the ship stage ran on anthropic/claude-sonnet-5; the retro returned to opus-5. Both subagents (tidy-first-assessor, pre-completion-reviewer) are pinned to anthropic/claude-sonnet-5 in their frontmatter. No mismatch found in either direction. The sonnet ship stage handled the one judgment call it met correctly — distinguishing a genuinely IN_PROGRESS check from the empty-rollup GITHUB_TOKEN case on the release PR, and waiting rather than falling back to gh pr merge — which is the /ship-issue runbook doing its job on a cheaper model.
  • Escalation-delay tracking — the ../pi spelunk above ran ~8 consecutive tool calls on one question, past the 5-call threshold. It should have been an Explore subagent dispatch.
  • Unused-tool detectionExplore was never dispatched despite two read-only, multi-hop exploration tasks (the ../pi trace; the initial survey of permission-prompt-component.ts and its test-fixture blast radius). colgrep was also never used; every search was exact-symbol grep, which was defensible here since the targets were known identifiers.
  • Feedback-loop gap analysis — no gap. Verification ran incrementally: vitest on the single file at red and again at green, pnpm run check immediately after the shared-interface change and before the commit, the authority/ directory suite next, then the full suite plus lint and fallow dead-code before the docs commit.

Changes made

  1. .pi/prompts/pr-review.md — the triage note is now keyed to the issue the PR addresses, not the PR number. Three spots: the path rule (read the PR body for Refs #N / Closes #N, fall back to the PR number only when there is no issue), the frontmatter block (issue: takes the issue number and a new pr: field carries the PR), and the direction-1 handoff line, which now names /plan-issue #<issue> rather than #$1.
  2. AGENTS.md — extended the ../pi sibling-checkout rule to name the dispatch mechanism: an Explore subagent with model: "sonnet-5" for a multi-hop trace, inline reads for a known file. The explicit model pin is deliberate — Explore defaults to claude-haiku-4-5, which is the reasoning-weak-model-on-judgment-work mismatch this retro's own model lens is meant to catch.
  3. Declined a third proposal (noting in /plan-issue that a red test and its green land in one commit). The deviation it targets cost no rework, and the plan-vs-template inconsistency is documented here instead.

Not done, available as a follow-up: pinning Explore to sonnet-5 globally via a .pi/agents/Explore.md override. The change above scopes the pin to ../pi SDK tracing only; a global override would change every Explore dispatch in the repo and is a larger call than a retro should make unasked.