--- issue: 642 issue_title: "pi-permission-system: preserve Ctrl+O tool expansion in inline permission prompts" pr: 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: ```text Co-authored-by: Ben Tang ``` 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_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` 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` 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`, 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 keybindingsManager` → `grep 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 detection** — `Explore` 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 #` 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.