Plan: plan review as one composed component (#43)

On this page

Closes: #43. Branch: feature/review-pane (shipped), then fix/pane-known-zero. Status: Done (2026-09-07) — accepted live by the operator.

TL;DR

exit_plan_mode used to show the plan on a right-half overlay and ask with a separate ui.select. pi-tui gives input to exactly one component, so the overlay was display-only and unfocused (#40), which meant the plan could not scroll and ctrl+o clobbered the split.

The fix is one ui.custom component that owns the whole review: a scrollable document viewport that fits whatever rows it is given; one line stating the last cold-review outcome for this plan text; the three choices; a key legend — under one handleInput. The cold-review outcome comes from pi-workflow over the bridge in the reverse direction (planReviewed). Consent stays fail-closed: only enter on an offered choice resolves one.

Steps 1–5 and the first release shipped on 2026-09-03 (0.8.0). The operator’s terminal found the pane overflowing into scrollback; 0.8.1 removed the pane’s minimum size and mounted it as a focused overlay. The plan review of this document then found one more defect in that fix (a known budget of zero read as unknown), which is the remaining code step.

Decisions

Stacked, not side-by-side (operator’s ruling, 2026-09-01). The document is the thing being read; the choices are three lines. Side-by-side halves the document, breaks code blocks, and needs a second layout below ~100 columns. Stacked: document viewport on top; then ONE bordered block holding the lines a–b of N indicator on its seam, the cold-review line, the three choices and the key legend.

The pane fits whatever it is given; there is no minimum (amended 2026-09-03, shipped in 0.8.1). The first cut bounded the viewport as clamp(rows − 6, 8, 40). Measured with PI_TUI_WRITE_LOG in a pty of known size, pi’s own chrome is six rows at rest, so the pane left two rows for it and overflowed on every frame; pi’s TUI then pushes rows into scrollback ("self-reinforcing inflation", in its own words), which the operator saw as the scrollbar crawling to the top of the session and snapping back. The floor was the real defect: it made the pane unable to shrink below 15 rows, so no reserve constant could be right for every terminal. Now paneLayout in packages/pi-modes/review-pane.ts spends a row budget in priority order — the three choices first and never at risk, then the review line, the seam, the legend, the bottom rule, and whatever survives goes to the document, capped at 40 so a tall terminal gives more transcript rather than more pane. As the budget shrinks the document yields first, then the bottom rule, the legend, the seam, and the review line last (a verdict bears on the decision; the legend is discoverable). At zero the seam reads plan above, in the transcript, which is where it already is. Below three rows the choices cannot be shown and the tool refuses with a notice — in 0.8.1 for 2- and 3-row terminals; the 1-row case slips through the known-zero defect step 9 fixes, and the claim is complete only after it.

A focused overlay, not a component in the flow (amended 2026-09-03, shipped in 0.8.1). The first cut said "no overlay" because #40’s hazard was a focused overlay stealing keys from a SEPARATE select. There is no second component now, so focus is where it belongs. The overlay buys the height contract: the TUI clamps it (maxHeight) and composites it at a screen position, which is pi-tui’s documented overlay behaviour rather than anything this plan pins — the pane’s own side of the contract (it never renders more rows than its budget) is what the 3–200 sweep pins, and whether the two together keep the transcript still is what the operator’s short-window check in step 6 observes. One hazard comes with it: the TUI enforces maxHeight by slicing from the BOTTOM, where the consent is, so the overlay’s maxHeight and the pane’s budget are both derived from PANE_MAX_FRACTION (via paneRowBudget) so that they do not drift — a design intent whose two-sided pin arrives with step 9; until then the only pin compares the pane’s number with its own definition.

One input owner, a fixed key split. Viewport: pageUp, pageDown, j, k, home, end, ctrl+d, ctrl+u. Choices: up, down, enter, escape. Arrows go to the choices because pi’s own dialogs trained the hand that way; anything else is ignored. No tab, no focus toggling.

The cold-review line is state, not a second review. pi-workflow reviews the draft before the tool executes and returns the review to the author first; the human is asked on the retry, when the ledger already holds that plan text (the cadence #44 established and pinned in its own suite). The line reports that review: Cold review: CLEAN — claude-fable-5-1, 41 s, or FLAGS with the findings appended under a heading at the end of the document, or UNREVIEWED — <reason> (attempt N of 2), or none recorded. The record carries the exact text reviewed and pi-modes compares it with the text it presents, both sides trimmed, because the two packages read the draft file differently (one trims, one does not) and a strict === would miss on any file ending in a newline. No key crosses the bridge: ADR-004 forbids the import, and a private copy of the key would be the drift that makes the line lie.

The bridge gains one optional member in the reverse direction. planReviewed(record) is pi-workflow informing pi-modes; optional because the bridge is. pi-modes keeps the latest record in memory for the session; nothing is persisted.

The ui.select path went; the harnesses fake ui.custom. Pre-1.0, no compatibility layer. Both wiring harnesses instantiate the REAL component and drive it with a key script, and settle ONCE as pi does (an earlier fake let a later done() overwrite the first, which turned a refusal into an approval).

Transcript render stays. The plan is rendered into the transcript before the dialog opens; the pane is the review surface, and when the budget leaves no room for the document, the transcript is where the seam points.

Steps

Done steps are records: what shipped and the merge that carries it (each merge’s commits name their tests). Only the live step names files.

  1. Plan to Antora. This document at docs/modules/ROOT/pages/plans/review-pane.adoc, nav under Active. (Status: Done (2026-09-03) — 74beffe)

  2. Bridge member + guard call. PlanReviewRecord and optional planReviewed on the bridge; reviewPlanExit reports every outcome (CLEAN/FLAGS by the review module’s clean predicate on the reviewer’s words, UNREVIEWED with attempt N of 2) carrying the draft’s bytes exactly as read; ReviewOutcome gains body. Contract tests for each outcome, the trailing newline, an edited draft, and a bridge without the member. (Status: Done (2026-09-03) — 1b5117b)

  3. The component. packages/pi-modes/review-pane.ts: buildReviewPane, paneLayout, paneFits, paneRowBudget, PANE_MAX_FRACTION, VIEWPORT_MAX_ROWS, UNKNOWN_ROWS_BUDGET. Unit tests in packages/pi-modes/test/review-pane.test.ts: the 3–200 sweep (red on the first cut: budget 3: expected 15 to be less than or equal to 3), the yield order, the zero-document seam, paneFits at the boundary, the tall-terminal cap, the unknown-size default, every line ≤ width with styled/CJK/emoji content, each pager key, scroll bounds at the last rendered width, region isolation, unrecognised keys, the four review-line variants, the review body under its heading. (Status: Done (2026-09-03) — 70083bc; budget ladder c31912f)

  4. Wire it. The exit_plan_mode tool (packages/pi-modes/index.ts) mounts the pane as a focused overlay (width: "100%", maxHeight from PANE_MAX_FRACTION, bottom-center, focused on handle) with the plan text, the matching review record, exitPlanChoices() and availableRows from paneRowBudget; when paneFits is false it resolves undefined, returns an inert component and notifies what is needed; the try/catch around the dialog survived and now notifies why. The old overlay block, ui.select call and buildPlanPanel are gone. Both wiring harnesses fake ui.custom with the real component. Wiring tests in packages/pi-modes/test/wiring-lifecycle.test.ts ("the approval pane, through the real wiring (#43)"): down-enter approves into manual; the pane shows the review the bridge was told about through the draft-FILE path and none after an edit; a 3-row terminal refuses; a throwing pane keeps planning. (Status: Done (2026-09-03) — a5b8122; fit refusal c31912f)

  5. Docs and changelog. docs/modules/ROOT/pages/modes.adoc "Talking to the human" describes the pane, its key split, the review line and its source, the size contract and the fail-closed resolution (with the contradiction step 9 repairs); the packages page names the reverse bridge member; the changelog carries 0.8.0 and 0.8.1. (Status: Done (2026-09-03) — 74068a8, c31912f; the contradiction between them repaired with step 9 in v0.8.2)

  6. Acceptance at the terminal (operator). Enter plan, author a draft longer than the screen, exit_plan_mode; PgDn/j scroll the document, arrows move the choice, ctrl+o does not disturb the pane, the review line names the reviewer that just ran, escape returns to plan mode, the pane stays inside the screen on a short window. (Status: Done (2026-09-07) — on 0.8.0 the operator confirmed "it works in a general sense" and found the scrollback overflow, fixed in 0.8.1; on 0.8.1 the decline-with-feedback path was exercised live twice ("address the P fails", "still failed …") and the pane presented and resolved correctly each time; on 2026-09-07, with the 0.17.0 marker, the operator ran the full checklist on a live pane presenting the acceptance plan and reported "all rows behaved, including the short window")

  7. Release. MINOR for the surface change: v0.8.0 (pi-modes 0.6.0, pi-workflow 0.6.0, meta 0.8.0), pipeline 2818349033; then v0.8.1 (pi-modes 0.6.1, meta 0.8.1), pipeline 2818480630; consumer-sim OK against both. Cut before acceptance because pi refuses to load a second copy of an already-loaded extension, so the pane could not be driven from a checkout beside the installed package (operator, 2026-09-03). (Status: Done (2026-09-03))

  8. Sync this plan to the committed page — first thing after approval, before any code: this document replaces docs/modules/ROOT/pages/plans/review-pane.adoc, whose committed text was the pre-0.8.1 plan (no overlay, clamp(rows−6, 8, 40), 0.8.0 only). (Status: Done (2026-09-03))

  9. Known-zero is not unknown (plan-review catch, 2026-09-03). In 0.8.1 paneRowBudget(rows) returns floor(rows × 0.8), which is 0 for a 1-row terminal (2 and 3 rows give 1 and 2, which paneFits already refuses), and budgetOf treats 0 like undefined: assume 15. So the one terminal that most cannot show the choices is the one where paneFits says it can, and the overlay clamp slices the consent off — the hazard this plan claims closed. One row is an edge nobody will meet; the rule it breaks ("a known budget is never treated as unknown") is not. Fix in packages/pi-modes/review-pane.ts, precisely: paneRowBudget KEEPS its !(terminalRows > 0) → undefined branch — absent or zero terminal rows is "no terminal", genuinely unknown, and the existing pin paneRowBudget(0) is undefined stays; budgetOf changes from !(available > 0) to available === undefined so a known budget of 0 (a 1-row terminal) is 0; the availableRows docblock changes from "undefined or non-positive means unknown" to "undefined means unknown; 0 means none"; paneFits(0, 3) becomes false and the tool refuses. Tests by category: REGRESSION — a wiring test in packages/pi-modes/test/wiring-lifecycle.test.ts at paneTerminalRows.value = 1 (the one height that hands the pane a known 0) asserting the tool refuses with the too-short notice and renders no pane, red on 0.8.1 because today it renders 15 rows; UNIT — in packages/pi-modes/test/review-pane.test.ts, paneFits false at budgets 0, 1, 2, and the existing paneFits(0, CHOICES.length) === true pin, which is WRONG, flips to false; CONTRACT — the two sides of the height contract against each other, not either against itself: the harness records the overlayOptions.maxHeight string the tool actually passed, the test parses its percentage, and for several paneTerminalRows (5, 24, 40, 60, 200) asserts paneRowBudget(rows) ≤ floor(rows × pct / 100) — the TUI’s slice can then never fall inside the pane’s budget. Added beside the existing paneRowBudget pin, not in its place (the plan review’s shape). This needs the harness custom fake in wiring-lifecycle.test.ts to accept and record its second argument (options; today it takes factory only). Docs, docs/modules/ROOT/pages/modes.adoc "Talking to the human": the pane paragraph contradicts itself today — it says both "There is no overlay and no focus to juggle" (from step 5) and "The pane is a focused overlay" (from 0.8.1), and its pin list still cites a "height clamp and floor", the defect 0.8.1 removed (quoted strings, not line numbers: the lines move, the sentences are the anchor); this step rewrites the paragraph to one account (focused overlay, no minimum, the yield order, known-zero refuses) and the pin list to exactly the pins this document’s Risks section names (all in packages/pi-modes/test/review-pane.test.ts and packages/pi-modes/test/wiring-lifecycle.test.ts). The project changelog gains its entry under Unreleased, then the cut: pi-modes 0.6.2 / meta 0.8.2 with tag, pipeline and consumer-sim as before. (Status: Done (2026-09-03) — cut 312145f, tag v0.8.2, CHANGELOG.adoc == 0.8.2 - 2026-09-03)

  10. Close. Closing comment on #43 with the merge SHAs, the releases, the tests named above, and the operator’s acceptance; this plan’s Status set to Done and the page moved to the Archive nav. (Status: Done (2026-09-07))

Out of scope

Mouse wheel scrolling (pi-tui does not deliver mouse events to components here); a text search inside the pane; persisting review records across sessions; re-running the cold review from the pane. Each is a new issue if wanted.

Risks

Each names its failure direction and its pin. Pins for the shipped steps exist and are named by file and title; the pins step 9 names do not exist until it lands, and two claims below hold only after it — each says so.

The pane is taller than the screen. Silent — the failure the operator found. Mitigated structurally (a clamped overlay) and by construction (no minimum; the layout never spends more than its budget), and closed only after step 9: on a 1-row terminal the pane still assumes 15 rows, which is the same defect seen from this side. Pinned: packages/pi-modes/test/review-pane.test.ts "from 3 rows to 200: the render never exceeds the budget and every choice is present" and "regions yield in a fixed order and the choices never do".

The consent is sliced off. Silent and dangerous: the TUI cuts an over-tall overlay from the bottom, where the choices are. Mitigated by deriving both bounds from one constant; closed only after step 9, which stops the pane assuming rows a known terminal does not have and pins the two sides of the maxHeight contract against each other.

A key the pane does not recognise. Ignored — fail-closed. Pinned in packages/pi-modes/test/review-pane.test.ts "an unrecognised key changes neither region and resolves nothing".

Rows unknown. Fail-OPEN by a bounded amount, deliberately and only for undefined: fakes and odd terminals report no rows, the pane assumes a small screen (UNKNOWN_ROWS_BUDGET, 15) and can only be as right as that assumption — stated in the code. A KNOWN budget, including 0, is never treated as unknown — true only after step 9. Pinned: "an unknown size assumes a small screen, and the budget is a share of the terminal", and after step 9, paneFits false at budgets 0–2.

Too short to ask. Under three rows the tool refuses rather than drawing choices the human cannot read: fail-closed and NOT silent. Pinned: packages/pi-modes/test/wiring-lifecycle.test.ts "a terminal too short for the choices refuses instead of asking (#43)".

Wide characters and ANSI. Fail-loud: any rendered line wider than width is a test failure. Pinned: "every rendered line is at most width cells, with styled title, CJK and emoji content".

The review line lies. Silent: a record that never matches reads none recorded forever. There is no key on the pi-modes side to drift (the match is the text itself, in matchingPlanReview); whitespace drift is closed by trimming both sides and pinned through the draft-FILE path with its trailing newline. Pinned: packages/pi-modes/test/wiring-lifecycle.test.ts "the pane shows the review the bridge was told about, and none for other text".

Consent. Any path that does not end in enter on a choice resolves undefined → keep planning; a dialog that throws is caught and the reason notified. Pinned: packages/pi-modes/test/review-pane.test.ts "escape resolves undefined"; packages/pi-modes/test/wiring-lifecycle.test.ts "a pane that throws keeps planning: an error in the dialog is not consent" and "a terminal too short for the choices refuses instead of asking (#43)" (both resolve undefined through the real tool).

Edit this page · latest