pmacs/docs/discovery-stage2-framing.md

16 KiB
Raw Blame History

Discovery Stage 2 — M-x rows stop being bare names

Status: revision 3, APPROVED 2026-08-09. Implementation may proceed.

Revision 3 fixes three things revision 2 asserted without checking the mechanism it was reasoning about: a "frozen-shape" test that freezes nothing, a cache hazard that this architecture makes impossible, and a clipping rule that is unachievable at narrow enough widths. All three verified in the tree.

Revision 2 fixes two claims revision 1 made about compatibility and about the TUI, both wrong, both checkable. An in-place field change cannot preserve v22 — postcard is not self-describing — and "both frontends render it" was false, because the grid TUI never reads that message at all. Verified in the tree, not reasoned about.


1. The gap, stated exactly

COHERENCE.md §5 grades unified discoverability Partial after Stage 1 (#207), and names three things left. This lane takes one:

Command still has no title/category/flags, M-x rows are still bare names, and the Rust help layer is still orphaned.

The descriptions already exist. Command.description is a required field (src/command.rs:69), and help.list-commands already renders "every registered command with its description" (builtin/runtime/help.lua:339). A user who runs M-x help can read what everything does.

What they cannot do is see it at the moment of choosing. M-x shows names alone — so the information exists, is already surfaced elsewhere, and is missing from the one place it would change a decision. That is §1.1's substrate without surface in its purest form, and it is felt every time the editor is used.

2. Ground truth

Scouted:

  • The wire asymmetry is a single field. InstanceMessage::MinibufferPrompt carries candidates: Vec<String> (pmacs-protocol/src/message.rs:1113).

  • The rich pattern is already proven in a sibling variant. CompletionPopup carries rows: Vec<CompletionPopupRow>label, kind: u8, detail: Option<String> (:1387) — and both frontends already render it.

    (Revision note: an earlier read of mine reported two bare-string sites. There is one. The second grep hit was CompletionPopup's doc comment, which says "candidates" while the field is rows.)

  • Command needs no change for this lane. description is already there and already required. Title/category/aliases — the lane's other Stage-2 candidate — would enrich these rows further and are deliberately not in scope: they are a ~175-site change and this lane can deliver the felt improvement without them.

  • ADVERTISED_PROTOCOL_VERSION is pinned at 20 (pmacs-protocol/src/message.rs:1767) and must not be edited, per handoff §3/§5.

  • The transport is postcard (pmacs-protocol/src/transport.rs:1), which is not self-describing: enum variants encode by index and fields by position. Changing a field's type in place is a wire break, not a compatible evolution — a v22 peer would mis-decode the bytes rather than ignore them.

  • MinibufferPrompt is sent to every peer negotiated >= 12 (src/daemon.rs:1472, "Q#MB1 — MinibufferPrompt gated at v12"). So the population that would break is every frontend from v12 to v22.

  • The grid TUI never reads MinibufferPrompt. src/editor.rs contains zero references to it; paint_minibuffer reads core.minibuffer directly and renders the selected candidate as an inline suffix, format!(" [{cand}]") (src/editor.rs:5484), with its own ui.minibuffer.candidate face. The rich wire reaches pmacs-gpu only.

3. The change

This is a protocol change: v22 → v23, and it is additive, not an edit.

3.1 A new variant, because an in-place change cannot be compatible

Revision 1 proposed changing candidates in place. That breaks every frontend from v12 to v22: postcard encodes fields positionally, so a v22 peer decoding a Vec<MinibufferRow> where it expects Vec<String> mis-reads the bytes — it does not skip them.

And gating the changed variant at >= 23 does not rescue it: the peer would then receive no minibuffer message at all, because there is only one variant to send. Compatibility means sending the old shape, which requires the old shape to still exist.

So:

  • MinibufferPrompt is retained, unchanged, for v12v22. Its encoding is frozen.
  • MinibufferPromptRows is a NEW variant appended to the enum, carrying rows: Vec<MinibufferRow> and otherwise mirroring MinibufferPrompt's fields.
  • Appended, not inserted. Variant indices are positional in postcard; inserting anywhere but the end renumbers every later variant and breaks everything at once.

3.2 Per-session selection, and the ordering that matters

  • Selection is per peer, decided from its negotiated version: >= 23 receives MinibufferPromptRows; 12..=22 receives MinibufferPrompt. This mirrors the existing gates in src/daemon.rs:1472, which already suppress MenuPrompt, MinibufferPrompt and LineNumbers per peer.

  • Exactly one of the two is sent to any given peer, ever. Sending both to a v23 peer would double-render; sending neither is the bug gating alone would have caused.

  • The selection is a producer gate, named peer_knows_minibuffer_rows, alongside the existing peer_knows_minibuffer_prompt / peer_knows_menu_prompt / peer_knows_completion_popup (src/daemon.rs:1410-1435). One new gate in an established pattern, not a new mechanism.

  • ONE per-peer minibuffer cache, not a per-variant key.

    Revision 2's rationale for a per-variant key was false, and the architecture is why: SemanticRenderState::for_peer(frontend_id, negotiated_protocol_version) is created per peer, with its version baked in, on attach (src/daemon.rs:2080) and removed on detach (:1591). A cache therefore never spans two negotiated versions — the v23→v22 reconnect suppression I described cannot occur, because reconnecting creates a fresh state. The corresponding test is removed rather than written; a test for an impossible condition passes forever and teaches the next reader that the hazard is real.

  • The close message must still use the same variant family as the open — a MinibufferPromptRows session closed by a legacy clear is the mismatch that leaves a popup on screen forever. That one is independent of caching and stands.

3.3 What each frontend does

  • pmacs-gpu renders label + detail from the new variant.
  • The grid TUI does not consume this message at all and is addressed separately in §3.4.

3.4 The TUI presentation contract

Revision 1 said "both frontends render label + detail". The grid TUI does not read MinibufferPrompt — it paints from core.minibuffer and renders the selected candidate as format!(" [{cand}]") (src/editor.rs:5484). The wire change reaches it not at all.

My vote: an inline selected form, matching what is already there:

M-x buffer.sa    [buffer.save — Write the buffer to its file]
  • Source: local. The TUI is in-process with the core, so it reads Command.description from the registry directly. No wire involvement, which is why this half of the lane is independent of the bump.

  • Only the selected candidate, as today. This is a formatting change to an existing suffix, not a new surface.

  • Clipping, in three ordered steps. The suffix is already written against max = term_size.cols with a running written count, and the prompt plus typed input consume that budget first — so the remaining width can be too small even for the bare name. Revision 2 said "the name must survive", which is not achievable at arbitrary widths and would have forced a partial name. The rule:

    1. If the remaining suffix width cannot fit the WHOLE name, omit the suffix entirely. Never emit a partial name — [buffer.sa…] is worse than nothing, because it reads as a different command.
    2. Only once the whole name fits is a description attempted.
    3. If the description does not fit whole, drop the description, leaving today's [name]. No ellipsis stub.

    So the guarantee is "never a partial name", which is achievable, rather than "the name always survives", which is not.

  • The ui.minibuffer.candidate face already exists and continues to cover the suffix.

A multi-row TUI chooser is explicitly NOT this lane. It would be a new interaction surface, a §6 island risk, and materially larger than the wire work — it is named here so that "make the TUI match the GPU" does not quietly become that.

3.5 Scheduling consequence, which is not incidental

PROTOCOL_VERSION is a strict serialization point — two lanes bumping it collide, and this session recorded eight broken version assertions from a single bump. So:

  • This lane holds the bump slot. Git Stage 1 is deliberately no-wire and runs beside it without contention.
  • Git Stage 2 (gutter markers) also needs a bump and must therefore wait for this to land. That ordering should be explicit in the ledger rather than discovered when the two collide.

4. Coherence impact (§20)

  • §5 unified discoverability — the direct target, and the specific clause "M-x rows are still bare names".
  • Journey step 4 ("understand the interface"): COHERENCE.md P4 says most of it "rides on" discovery. This improves the step without adding one.
  • §16 semantic frontend: a clean instance of the architecture — the instance states what a candidate is, each frontend decides how to draw it. Degradation is the established practice (Q#D2-4).
  • Interaction islands (§6): none added. No new key interception; this changes what an existing prompt carries.
  • Config registry: no new setting. Whether detail rendering is optional is Q#D2-3, and my vote is no setting at all.
  • Background-work attribution (§9): untouched. No new background work.

5. Open questions

Q#D2-1 — reuse CompletionPopupRow, or a new type?

Reuse is tempting and I think wrong. CompletionPopupRow.kind is an LSP CompletionItemKind code (1..=25) with a documented contract; an M-x command is not an LSP completion item and has no honest value for that field. Reusing it would mean either inventing a fake kind or declaring 0/unknown everywhere — a type whose invariant is "meaningless in half its uses".

My vote: a new MinibufferRow { label, detail: Option<String> } — no kind. If a category field is wanted later it arrives with Command.category (the other Stage-2 candidate), typed as what it actually is rather than borrowed from LSP.

Q#D2-2 — which prompts get rows?

pmacs.minibuffer.read serves many sources, not just M-x: file paths, buffer names, apropos substrings, settings. Only some have a natural detail.

My vote: the field is Option<String> per row and the daemon fills it where it has one. Commands get their description; a file-path prompt leaves it None and renders exactly as today. No source is obliged to invent a detail, and none is prevented from gaining one later.

Q#D2-3 — is detail rendering configurable?

My vote: no setting. §11 grades the registry "partial (foundation only)"; adding a speculative toggle for a feature nobody has yet asked to disable is how a registry becomes noise. If somebody wants it off, that is use evidence and a later one-line addition.

Q#D2-4 — older frontends — RESOLVED, in §3.13.2

No longer open, and the revision-1 answer was wrong. "Gate the richer form at >= 23" would have removed the minibuffer entirely from every v12v22 peer, because there would have been only one variant to gate. Compatibility requires the legacy shape to still exist and still be sent — hence the additive MinibufferPromptRows variant, a per-peer peer_knows_minibuffer_rows producer gate, one per-peer minibuffer cache, and matched open/close families.

(Revision 2 said "per-variant cache keys" here. §3.2 corrected that in revision 3 — the render state is per peer with its version baked in, so a cache cannot span two versions — and this sentence was left stale.)

The CompletionPopup gate I proposed copying (daemon-gated >= 15) is the right precedent for how to select per peer; it is not a precedent for changing a live variant's shape, because that variant was new when it was gated.

Q#D2-5 — does this tempt closed-set acceptance? (a trap)

The discovery lane's own handoff note warns: completion is assistance, not validationresolve_accepted_value returns the literal typed text when no candidate is selected, so closed-set acceptance is unbuilt Rust work.

Richer rows make M-x look like a closed set, which invites someone to make acceptance reject unmatched input. That is out of scope and would be a behaviour change, not a rendering one. Stated here because the temptation arrives with the feature.

6. Verification

  • A command's description reaches the GPU row, asserted through the real prompt path rather than by constructing a message.

  • A v22 peer still receives MinibufferPrompt, with its old encoding — the case revision 1 would have broken. Asserted by negotiating v22 and observing the legacy variant arrive, not by observing "no error".

  • A v23 peer receives MinibufferPromptRows and NOT the legacy variant — the double-render guard.

  • LITERAL POSTCARD BYTE FIXTURES for the legacy variant, open and clear: assert_eq!(encoded, LEGACY_BYTES) against a constant.

    Revision 2 proposed a round-trip and that freezes nothing. A round-trip encodes and decodes with the same types, so adding a field to MinibufferPrompt leaves it passing — both sides simply learn the new shape, while every v12v22 peer in the field breaks. The existing minibuffer_prompt_round_trips_through_postcard (src/protocol.rs:2363) is exactly that kind of test, and there are no literal byte fixtures anywhere in the protocol tests today — checked, not assumed.

    Only comparing against bytes captured now can fail when the encoding changes. Two fixtures: an open prompt with candidates and a selection, and a cleared band — the two shapes the existing semantic test already covers, so the corpus is not a new judgement call.

  • No cross-version cache test. Revision 2 required one; it asserts a condition this architecture makes impossible (§3.2), and a test that cannot fail passes forever while teaching the next reader that the hazard is real. What is asserted is the producer gate: a v22 peer and a v23 peer attached simultaneously each receive their own variant and only their own.

  • Close matches open: a MinibufferPromptRows session is closed by its own family, witnessed by the popup actually clearing.

  • The TUI renders name — description for the selected candidate (§3.4), from the local registry, with no wire involvement.

  • TUI clipping is witnessed at THREE widths (§3.4): wide enough for name + description; wide enough for the name only (description dropped, [name] as today); and too narrow for even the whole name — the suffix vanishes entirely. The last is the case revision 2's rule could not express, and the assertion is that no prefix of a name is ever emitted.

  • A source with no detail renders exactly as before — the file-path prompt is the witness (Q#D2-2).

  • Typed-but-unmatched input is still accepted (Q#D2-5) — the guard against this lane quietly becoming a validation change.

  • The version-bump discipline: ADVERTISED_PROTOCOL_VERSION unchanged at 20, and the tripwire assertions updated knowingly. Handoff §3 requires the strengthened two-configuration sweep for a PROTOCOL_VERSION change — scripts/gate --protocol, which exists precisely for this.

What this will not prove: that Command carries title or category (not in scope), or that predicates are evaluated (Stage 3+).

7. Not in scope

Command gaining title/category/aliases/flags/arg-schema — the ~175-site change, and the lane's next candidate. A multi-row TUI chooser (§3.4) — a new interaction surface and materially larger than this lane. Changing MinibufferPrompt's existing shape — it is frozen for v12v22. Predicate evaluation, which makes commands stop being invocable and needs its own decision at each call site. Help-layer unification (src/help.rs is still orphaned). The help prefix key — C-h is not free, since non-kitty terminals cannot disambiguate Ctrl+Backspace from Ctrl+H (both are byte 0x08). Closed-set acceptance (Q#D2-5).