16 KiB
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:
Commandstill 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::MinibufferPromptcarriescandidates: Vec<String>(pmacs-protocol/src/message.rs:1113). -
The rich pattern is already proven in a sibling variant.
CompletionPopupcarriesrows: 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 isrows.) -
Commandneeds no change for this lane.descriptionis 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_VERSIONis 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. -
MinibufferPromptis 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.rscontains zero references to it;paint_minibufferreadscore.minibufferdirectly and renders the selected candidate as an inline suffix,format!(" [{cand}]")(src/editor.rs:5484), with its ownui.minibuffer.candidateface. The rich wire reachespmacs-gpuonly.
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:
MinibufferPromptis retained, unchanged, for v12–v22. Its encoding is frozen.MinibufferPromptRowsis a NEW variant appended to the enum, carryingrows: Vec<MinibufferRow>and otherwise mirroringMinibufferPrompt'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:
>= 23receivesMinibufferPromptRows;12..=22receivesMinibufferPrompt. This mirrors the existing gates insrc/daemon.rs:1472, which already suppressMenuPrompt,MinibufferPromptandLineNumbersper 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 existingpeer_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
MinibufferPromptRowssession 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-gpurenders 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.descriptionfrom 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.colswith a runningwrittencount, 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:- 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. - Only once the whole name fits is a description attempted.
- 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.
- If the remaining suffix width cannot fit the WHOLE name, omit
the suffix entirely. Never emit a partial name —
-
The
ui.minibuffer.candidateface 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.mdP4 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.1–3.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 v12–v22 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 validation — resolve_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
MinibufferPromptRowsand 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
MinibufferPromptleaves it passing — both sides simply learn the new shape, while every v12–v22 peer in the field breaks. The existingminibuffer_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
MinibufferPromptRowssession is closed by its own family, witnessed by the popup actually clearing. -
The TUI renders
name — descriptionfor 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_VERSIONunchanged at 20, and the tripwire assertions updated knowingly. Handoff §3 requires the strengthened two-configuration sweep for aPROTOCOL_VERSIONchange —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 v12–v22. 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).