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. setToolsExpandedalready callsthis.ui.requestRender()itself (interactive-mode.ts:3815), so the PR is correct not to add a redundantrequestRender()after the toggle.- Pi's own
ExtensionSelectorComponent.handleInputperforms the samekb.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:
- Interface segregation on the SDK type.
The PR imports
KeybindingsManagerwhole but uses only.matches(). The PR's own test is the tell:PromptFactorytypes the argument as{ matches(data: string, action: string): boolean }— the narrow contract already surfaced under test pressure and should be the production shape. - Constructor width.
PermissionPromptComponentgoes 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— sopresentInlinePermissionPromptowns both the keybinding lookup and theuireach-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. - Key precedence during the
reasonstep. The PR checks the app action at the very top ofhandleInput, ahead of thereasonbranch. Harmless for the default Ctrl+O (\u000fis non-printable andisPrintablewould drop it anyway), butapp.tools.expandis user-rebindable: a printable rebinding becomes untypeable inside a deny reason and shadows they/s/n/rdecision hotkeys. - 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.expandwhile the inline permission prompt is focused, without touching the pending decision. - Collapse the two new constructor parameters into one narrow app-action seam; keep
KeybindingsManagerout ofPermissionPromptComponent. - Precedence: check the app action before local handling, but only in the
decisionandscopesteps — thereasonstep'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.mdand the prompt description inREADME.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
PermissionPromptUiwidening stands as the PR has it. It also typesLocalUserAuthorizerDeps.ui, so the non-TUIrequestPermissionDecisionFromUipath nominally gains two methods it never calls — accepted, since every mode supplies them and the alternative (a separate field onPermissionPromptView) buys little. This is why the diff toucheslocal-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 #642 —
issue_closeascompleted, 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 bundleddist. Two facts changed the design rather than merely confirming it.setToolsExpandedends withthis.ui.requestRender()(interactive-mode.ts:3815), so the component must not callrequestRender()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-optionalKeybindingsManagerinvoked asfactory(this.ui, theme, this.keybindings, close)(interactive-mode.ts:2490), so no undefined guard is needed despite the existing test passingundefinedbehind a cast. - Measured the ISP narrowing instead of asserting it.
Compiled a throwaway probe (
Pick<KeybindingsManager, "matches">satisfied by a bare object literal) undertscbefore planning around it. This is the skill's "confirm what a module exports withtsc, 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.themewiththis.deps.themeacross 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 thetidy-first-assessorat/tdd-planstart rather than pre-empted. - The precedence choice is what protects a #573 invariant.
Intercepting keystrokes during the
reasonstep could make a required denial reason untypeable. The plan pins this with a test that bindsapp.tools.expandto the printable keye— asserting on the default Ctrl+O would false-green, sinceisPrintabledrops 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 ownExtensionSelectorComponent, which checks the action first. Host-convention consistency won; the residual risk is bounded because arrow/j/k+enterstill 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(twouiliterals) breaks attsc, because it is typed throughLocalUserAuthorizerDeps.authorizer.test.tsandauthorizer-selection.test.tsbuild theirs behindas unknown as ExtensionContextcasts and never reachcustom, 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 thefix:commit (2 commits, not 3). Rationale: the widenedPermissionPromptUibreakslocal-user-authorizer.test.tsat the type level in the same commit regardless, so a standalone red commit would have left the tree failingtscand the suite. The Red→Green cycle still ran — the red was measured (2 failed / 17 passed) before anysrc/edit. The reviewer independently endorsed the call on two grounds: thetestingskill's interface-change rule mandates bundling, and this repo's history showsfix:commits routinely carry their own tests rather than landing a separate red commit. - The
tidy-first-assessorreturned "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 failstscrather than silently misbehaving — which is what makes 7 positional params acceptable here. It also correctly declined to split themakePromptUi()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
reasonbranch makes"e"never reachreasonBuffer, soENTERsubmits 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+Owould have false-greened, becauseisPrintabledrops\u000fin 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 throwawaytscprobe during planning rather than assumed. Same for the no-requestRender()decision, which came from readinginteractive-mode.ts:3815in the sibling../picheckout rather than the bundleddist. - Blast radius matched the plan exactly: only
local-user-authorizer.test.tsbroke attsc(cascading to 10 call sites throughmakeDeps), while the cast-masked ctx literals inauthorizer.test.ts/authorizer-selection.test.tswere 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 throwawaysrc/__kbprobe.ts, ranpnpm run check, and deleted it. Thetestingskill says to confirm export claims withtscrather 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+Owould false-green, becauseisPrintabledrops\u000fin the reason editor whether or not the seam intercepts it, and specified binding the fake action to the printable keyeinstead. The pre-completion reviewer independently confirmed the test is load-bearing by tracing the failure mode: hoisting the check above thereasonbranch makesENTERsubmit an empty reason, which the model rejects, so the promise never resolves and the test hangs to timeout. - The plan predicted the
tscblast radius exactly. It namedlocal-user-authorizer.test.tsas the only type-level break and correctly predicted that the cast-masked ctx literals inauthorizer.test.ts/authorizer-selection.test.tswould need nothing — an application of the package skill's cast-masking warning that held on contact. - The
tidy-first-assessorearned 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 failstscrather 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:88does sayNNNNmatches 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+ anEdit+ acommit --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../picheckout hunting whereui.custompasses its keybindings argument to the factory:grep "async custom"→grep "custom:"→ anawkline-range →grep keybindingsManager→grep showCustomComponent→ a tab-literal grep that finally hit line 2161 →showExtensionCustom→ thefactory(...)call. Impact: no rework and the answer was correct and load-bearing, but the whole multi-hop trace burned planning-session context that anExploresubagent dispatch would have kept off it.other(tool-selection slip) — the retro stage's skill load pulledgithub-voiceinstead of theask-userskill 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 standalonetest:red commit, while/tdd-planstates 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 643without 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-issuewas 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 onanthropic/claude-sonnet-5; the retro returned to opus-5. Both subagents (tidy-first-assessor,pre-completion-reviewer) are pinned toanthropic/claude-sonnet-5in their frontmatter. No mismatch found in either direction. The sonnet ship stage handled the one judgment call it met correctly — distinguishing a genuinelyIN_PROGRESScheck from the empty-rollupGITHUB_TOKENcase on the release PR, and waiting rather than falling back togh pr merge— which is the/ship-issuerunbook doing its job on a cheaper model. - Escalation-delay tracking — the
../pispelunk above ran ~8 consecutive tool calls on one question, past the 5-call threshold. It should have been anExploresubagent dispatch. - Unused-tool detection —
Explorewas never dispatched despite two read-only, multi-hop exploration tasks (the../pitrace; the initial survey ofpermission-prompt-component.tsand its test-fixture blast radius).colgrepwas also never used; every search was exact-symbolgrep, which was defensible here since the targets were known identifiers. - Feedback-loop gap analysis — no gap.
Verification ran incrementally:
viteston the single file at red and again at green,pnpm run checkimmediately after the shared-interface change and before the commit, theauthority/directory suite next, then the full suite pluslintandfallow dead-codebefore the docs commit.
Changes made
.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 forRefs #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 newpr:field carries the PR), and the direction-1 handoff line, which now names/plan-issue #<issue>rather than#$1.AGENTS.md— extended the../pisibling-checkout rule to name the dispatch mechanism: anExploresubagent withmodel: "sonnet-5"for a multi-hop trace, inline reads for a known file. The explicit model pin is deliberate —Exploredefaults toclaude-haiku-4-5, which is the reasoning-weak-model-on-judgment-work mismatch this retro's own model lens is meant to catch.- Declined a third proposal (noting in
/plan-issuethat 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.