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.
-
Plan to Antora. This document at
docs/modules/ROOT/pages/plans/review-pane.adoc, nav under Active. (Status: Done (2026-09-03) — 74beffe) -
Bridge member + guard call.
PlanReviewRecordand optionalplanReviewedon the bridge;reviewPlanExitreports 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;ReviewOutcomegainsbody. Contract tests for each outcome, the trailing newline, an edited draft, and a bridge without the member. (Status: Done (2026-09-03) — 1b5117b) -
The component.
packages/pi-modes/review-pane.ts:buildReviewPane,paneLayout,paneFits,paneRowBudget,PANE_MAX_FRACTION,VIEWPORT_MAX_ROWS,UNKNOWN_ROWS_BUDGET. Unit tests inpackages/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,paneFitsat 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) -
Wire it. The
exit_plan_modetool (packages/pi-modes/index.ts) mounts the pane as a focused overlay (width: "100%",maxHeightfromPANE_MAX_FRACTION, bottom-center, focused on handle) with the plan text, the matching review record,exitPlanChoices()andavailableRowsfrompaneRowBudget; whenpaneFitsis false it resolvesundefined, 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.selectcall andbuildPlanPanelare gone. Both wiring harnesses fakeui.customwith the real component. Wiring tests inpackages/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) -
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) -
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") -
Release. MINOR for the surface change: v0.8.0 (
pi-modes0.6.0,pi-workflow0.6.0, meta 0.8.0), pipeline 2818349033; then v0.8.1 (pi-modes0.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)) -
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)) -
Known-zero is not unknown (plan-review catch, 2026-09-03). In 0.8.1
paneRowBudget(rows)returnsfloor(rows × 0.8), which is0for a 1-row terminal (2 and 3 rows give 1 and 2, whichpaneFitsalready refuses), andbudgetOftreats0likeundefined: assume 15. So the one terminal that most cannot show the choices is the one wherepaneFitssays 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 inpackages/pi-modes/review-pane.ts, precisely:paneRowBudgetKEEPS its!(terminalRows > 0) → undefinedbranch — absent or zero terminal rows is "no terminal", genuinely unknown, and the existing pinpaneRowBudget(0)isundefinedstays;budgetOfchanges from!(available > 0)toavailable === undefinedso a known budget of0(a 1-row terminal) is0; theavailableRowsdocblock changes from "undefined or non-positive means unknown" to "undefined means unknown;0means none";paneFits(0, 3)becomes false and the tool refuses. Tests by category: REGRESSION — a wiring test inpackages/pi-modes/test/wiring-lifecycle.test.tsatpaneTerminalRows.value = 1(the one height that hands the pane a known0) 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 — inpackages/pi-modes/test/review-pane.test.ts,paneFitsfalse at budgets 0, 1, 2, and the existingpaneFits(0, CHOICES.length) === truepin, which is WRONG, flips to false; CONTRACT — the two sides of the height contract against each other, not either against itself: the harness records theoverlayOptions.maxHeightstring the tool actually passed, the test parses its percentage, and for severalpaneTerminalRows(5, 24, 40, 60, 200) assertspaneRowBudget(rows) ≤ floor(rows × pct / 100)— the TUI’s slice can then never fall inside the pane’s budget. Added beside the existingpaneRowBudgetpin, not in its place (the plan review’s shape). This needs the harnesscustomfake inwiring-lifecycle.test.tsto accept and record its second argument (options; today it takesfactoryonly). 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 inpackages/pi-modes/test/review-pane.test.tsandpackages/pi-modes/test/wiring-lifecycle.test.ts). The project changelog gains its entry under Unreleased, then the cut:pi-modes0.6.2 / meta 0.8.2 with tag, pipeline and consumer-sim as before. (Status: Done (2026-09-03) — cut 312145f, tagv0.8.2,CHANGELOG.adoc== 0.8.2 - 2026-09-03) -
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).