Merge pull request #228 from levineuwirth/discovery-stage2
feat(discovery): M-x rows carry descriptions — protocol v22 → v23
This commit is contained in:
commit
0857bf4349
31
COHERENCE.md
31
COHERENCE.md
|
|
@ -570,9 +570,11 @@ descriptions, indexed by `M-x help`. It needed **no Rust** — the data
|
||||||
was all reachable from Lua, and even the settings completion source is a
|
was all reachable from Lua, and even the settings completion source is a
|
||||||
Lua function through `CompletionSource::Custom`.
|
Lua function through `CompletionSource::Custom`.
|
||||||
|
|
||||||
**What is still missing** is itemized below and unchanged by that stage:
|
**What is still missing** is itemized below. Discovery Stage 2 took one
|
||||||
`Command` has no title/category/aliases/flags/arg-schema; the predicate
|
item — **M-x rows now carry each command's description** — and the rest
|
||||||
is still never evaluated; M-x rows are still bare name strings; the Rust
|
is unchanged by both stages: `Command` has no
|
||||||
|
title/category/aliases/flags/arg-schema; the predicate
|
||||||
|
is still never evaluated; the Rust
|
||||||
help layer is still orphaned (Stage 1 funnels every command through one
|
help layer is still orphaned (Stage 1 funnels every command through one
|
||||||
Lua seam so the eventual migration is enumerated per subject rather than
|
Lua seam so the eventual migration is enumerated per subject rather than
|
||||||
per call site); **packages** have no discovery surface, and workers,
|
per call site); **packages** have no discovery surface, and workers,
|
||||||
|
|
@ -621,12 +623,21 @@ the sharpest instance of §1.1.**
|
||||||
M-x filtering, or the menu. The doc comment's claim that "the command
|
M-x filtering, or the menu. The doc comment's claim that "the command
|
||||||
palette (T M2.7) uses it to gray out unavailable entries" describes
|
palette (T M2.7) uses it to gray out unavailable entries" describes
|
||||||
something that never shipped.
|
something that never shipped.
|
||||||
- **M-x shows bare name strings.** `CompletionSource::Commands` returns
|
- **M-x rows carry a description — Discovery Stage 2 (protocol v23).**
|
||||||
`Vec<String>` of names; the wire type `MinibufferPrompt.candidates`
|
`CompletionSource::Commands` still returns `Vec<String>` of names, but
|
||||||
is `Vec<String>` (`pmacs-protocol/src/message.rs:994-1006`). No
|
the row the user reads is no longer one. The GPU receives
|
||||||
description, no keybinding, no category alongside candidates — while
|
`InstanceMessage::MinibufferPromptRows` — `MinibufferRow { label,
|
||||||
`CompletionPopupRow` (`:1231`) already carries `kind` and `detail`,
|
detail }`, a new type rather than a borrowed `CompletionPopupRow`,
|
||||||
proving richer rows are a solved wire problem in this codebase.
|
whose `kind` is an LSP code with no honest value for a command — and
|
||||||
|
the grid TUI renders `[name — description]` inline from the registry
|
||||||
|
**in-process**, since `src/editor.rs` never consumed the wire variant
|
||||||
|
at all. The bump is additive: `MinibufferPrompt` is FROZEN and still
|
||||||
|
sent to every `12..=22` peer (postcard is positional, so widening it
|
||||||
|
would mis-decode there rather than be ignored), and exactly one of the
|
||||||
|
two variants reaches any peer.
|
||||||
|
**What is still missing here:** no keybinding and no category
|
||||||
|
alongside the candidate — those wait on `Command` gaining the fields
|
||||||
|
at all.
|
||||||
- **The entire Rust help layer is orphaned** (§1.1). Consequence: two
|
- **The entire Rust help layer is orphaned** (§1.1). Consequence: two
|
||||||
parallel `*help*` implementations exist — `help.rs`'s
|
parallel `*help*` implementations exist — `help.rs`'s
|
||||||
cross-referenced renderer and the Lua `show_help_text` in
|
cross-referenced renderer and the Lua `show_help_text` in
|
||||||
|
|
@ -684,6 +695,8 @@ the sharpest instance of §1.1.**
|
||||||
provenance in the config registry, (c) a dozen interactive commands and
|
provenance in the config registry, (c) a dozen interactive commands and
|
||||||
richer M-x candidate rows over introspection that **already exists**.
|
richer M-x candidate rows over introspection that **already exists**.
|
||||||
This is the highest payoff-per-effort concern in the document.
|
This is the highest payoff-per-effort concern in the document.
|
||||||
|
*(c) is done: Stage 1 shipped the command family, Stage 2 the richer
|
||||||
|
rows. (a) and (b) remain.*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -265,6 +265,128 @@ also removed: this branch's "R8 NEEDS A LANE" investigation block, and
|
||||||
durable facts are in the retired registry row and the handoff §6
|
durable facts are in the retired registry row and the handoff §6
|
||||||
census.
|
census.
|
||||||
|
|
||||||
|
## Discovery Stage 2 — PR #228 OPEN, **MERGE-BLOCKED**
|
||||||
|
|
||||||
|
**PR #228** — https://github.com/levineuwirth/pmacs/pull/228. Opened
|
||||||
|
2026-08-09 at `2d298dd`. **Open for review, not for merge.**
|
||||||
|
|
||||||
|
**The block is a gate-integrity problem, not backlog hygiene.** This
|
||||||
|
lane's gate is `scripts/gate --protocol`, which promises the CRDT
|
||||||
|
workspace sweep. That sweep's documented precondition is
|
||||||
|
`cargo build --workspace --no-default-features --features luajit,crdt`
|
||||||
|
(handoff §5), and **the script does not run it** — confirmed by reading
|
||||||
|
its plan emitter. On a fresh per-worktree target directory the sweep
|
||||||
|
fails on twelve `gpu_invocation_acceptance` tests missing the
|
||||||
|
`pmacs-gpu` binary, so a `--protocol` result can be decided by the
|
||||||
|
state of the build directory rather than by the diff.
|
||||||
|
|
||||||
|
Latent until #225 gave each worktree its own target dir — a shared one
|
||||||
|
usually already had `pmacs-gpu` built, satisfying the precondition by
|
||||||
|
accident. It surfaced on this branch's first gate run.
|
||||||
|
|
||||||
|
**Unblocking requires both:** the `scripts/gate` repair, in its own
|
||||||
|
narrow framing and its own PR (explicitly **not** folded into this
|
||||||
|
feature branch), and then a **fresh-target rerun of this branch's
|
||||||
|
protocol gate** under the repaired script.
|
||||||
|
|
||||||
|
**Written with the lane's first commit**, per the standing correction
|
||||||
|
from #171 and #215.
|
||||||
|
|
||||||
|
**Branch `discovery-stage2`**, base `githubsucks/main` @ `4bc55e8`
|
||||||
|
(the #225 merge). **`githubsucks/discovery-stage2` is the authoritative
|
||||||
|
tip** — the ref, not a SHA. Recover with
|
||||||
|
`git fetch githubsucks && git checkout discovery-stage2`.
|
||||||
|
|
||||||
|
- **Framing `docs/discovery-stage2-framing.md`, revision 3, APPROVED
|
||||||
|
2026-08-09** after three review rounds. Each round found the previous
|
||||||
|
one reasoning about a mechanism instead of reading it — an in-place
|
||||||
|
field change that postcard cannot make compatible, a TUI that never
|
||||||
|
reads the message at all, a round-trip test that freezes nothing, a
|
||||||
|
cache hazard the per-peer render state makes impossible, and a
|
||||||
|
clipping rule unachievable at narrow widths.
|
||||||
|
Scope: `COHERENCE.md` §5's "M-x rows are still bare names".
|
||||||
|
Descriptions already exist on `Command` and are already rendered by
|
||||||
|
`help.list-commands`; they are missing at the one moment they would
|
||||||
|
change a decision.
|
||||||
|
- **PROTOCOL BUMP v22 → v23, and this lane HOLDS THE BUMP SLOT.**
|
||||||
|
Additive: a new `MinibufferPromptRows` variant **appended** to the
|
||||||
|
enum, with `MinibufferPrompt` **frozen** for v12–v22. An in-place
|
||||||
|
field change is a wire break — postcard encodes positionally, and
|
||||||
|
that variant is sent to every peer `>= 12` (`src/daemon.rs:1472`).
|
||||||
|
- **Git Stage 2 (gutter markers) also needs a bump and must wait for
|
||||||
|
this to land.** Git Stage 1 is no-wire and runs beside it.
|
||||||
|
- **Two halves, only one of which is wire work.** `pmacs-gpu` renders
|
||||||
|
the new variant. **The grid TUI never reads `MinibufferPrompt` at
|
||||||
|
all** — it paints from `core.minibuffer` and renders
|
||||||
|
`format!(" [{cand}]")` (`src/editor.rs:5484`), so its half is a
|
||||||
|
local formatting change reading the registry directly. A multi-row
|
||||||
|
TUI chooser is explicitly NOT this lane.
|
||||||
|
- **Gates:** `scripts/gate --protocol --acceptance
|
||||||
|
discovery_stage2_acceptance --acceptance m9_6_acceptance --acceptance
|
||||||
|
m9_7_acceptance --acceptance m9_8_acceptance` — the strengthened
|
||||||
|
two-configuration sweep, which is what `--protocol` exists for. The
|
||||||
|
three m9 suites are named because the PR #228 review round measured
|
||||||
|
them as this change's blast radius (see the description-clip bullet);
|
||||||
|
their continued passing is on the record rather than assumed.
|
||||||
|
**`--protocol` does NOT run its own documented precondition**
|
||||||
|
(`cargo build --workspace --no-default-features --features
|
||||||
|
luajit,crdt`, handoff §5) — run it by hand first or twelve
|
||||||
|
`gpu_invocation_acceptance` tests fail on a missing `pmacs-gpu`
|
||||||
|
binary. That omission is the `gate-protocol-build` lane's, not this
|
||||||
|
one's.
|
||||||
|
- **IMPLEMENTED.** `PROTOCOL_VERSION` is 23,
|
||||||
|
`ADVERTISED_PROTOCOL_VERSION` is untouched at 20. New suite
|
||||||
|
`tests/discovery_stage2_acceptance.rs`; the daemon half is
|
||||||
|
`crdt`-gated (a semantic session is necessarily a text replica) and
|
||||||
|
runs one daemon serving a v22 and a v23 session simultaneously.
|
||||||
|
- **Multi-line descriptions are clipped AT THE SURFACE, and
|
||||||
|
registration-level rejection was investigated and REJECTED ON
|
||||||
|
EVIDENCE — do not re-propose it.** PR #228 review found the real
|
||||||
|
hazard: the GPU dropdown derives its height, visible window and
|
||||||
|
highlight offset from `rows.len()` (one logical row per candidate),
|
||||||
|
so a detail carrying a line break misaligns every row below it; the
|
||||||
|
TUI writes into a single-row band. The obvious fix — reject CR/LF in
|
||||||
|
`CommandRegistry::define` — was implemented and measured, and it
|
||||||
|
**fails 36 tests across `m9_6`/`m9_7`/`m9_8`**, because MCP tool
|
||||||
|
registration renders a whole schema block into `description`
|
||||||
|
(`tests/fixtures/pmacs-mcp-tools/init.lua:272`,
|
||||||
|
`table.concat(lines, "\n")`, used at `:496`) and
|
||||||
|
**`tests/m9_6_acceptance.rs:583-598` asserts four separate lines of
|
||||||
|
it** — tool text, `Arguments:`, and two per-argument lines. No
|
||||||
|
single-line rendering satisfies those assertions, so a registry guard
|
||||||
|
could only go green by deleting a shipped acceptance criterion.
|
||||||
|
The one-line constraint belongs to the surfaces that have it:
|
||||||
|
`Command::description_first_line` clips, both single-row consumers
|
||||||
|
call it, and the full text still reaches `describe-command` /
|
||||||
|
`help.list-commands` untouched. Precedent already in-tree — the same
|
||||||
|
MCP fixture clips a tool RESULT to its first line because *"a
|
||||||
|
multi-line set_status would corrupt the row layout"* (`:277-285`).
|
||||||
|
**A startup census is not a corpus census**: booting an
|
||||||
|
`EditorState` and scanning all 180 registered descriptions found zero
|
||||||
|
offenders, because MCP registers at RUNTIME and builds the string by
|
||||||
|
concatenation — invisible to both that census and a grep for literals.
|
||||||
|
The workspace sweep is what caught it.
|
||||||
|
- **The freeze is enforced by LITERAL byte fixtures**, not a round-trip
|
||||||
|
— `minibuffer_prompt_v12_wire_bytes_are_frozen` in `src/protocol.rs`,
|
||||||
|
the first such fixture in this repo. Bite-verified: reordering two
|
||||||
|
fields of `MinibufferPrompt` leaves
|
||||||
|
`minibuffer_prompt_round_trips_through_postcard` **passing** and fails
|
||||||
|
the fixture, which is exactly the hazard a round-trip cannot see.
|
||||||
|
- **Version assertions updated (five, each read before editing):**
|
||||||
|
`src/protocol.rs` — the `PROTOCOL_VERSION == 22` tripwire (renamed
|
||||||
|
`protocol_version_is_twenty_three_for_minibuffer_prompt_rows`) and
|
||||||
|
`supported_protocol_versions_resume_ladder_on_v6_floor`'s
|
||||||
|
accepted/rejected ranges; `tests/statusline_segments_acceptance.rs`
|
||||||
|
(version + supported range + the `!supported` ceiling);
|
||||||
|
`tests/bottom_panel_stage2b_gpu_acceptance.rs`;
|
||||||
|
`tests/vterm_stage3_acceptance.rs`. **No `ADVERTISED_PROTOCOL_VERSION`
|
||||||
|
assertion fired**, which is the pin doing its job.
|
||||||
|
- **No cross-version cache test, deliberately** (framing §3.2/§6):
|
||||||
|
`SemanticRenderState::for_peer` bakes the negotiated version in at
|
||||||
|
attach and is dropped at detach, so a cache cannot span two versions.
|
||||||
|
A test for an impossible condition passes forever while teaching the
|
||||||
|
next reader that the hazard is real.
|
||||||
|
|
||||||
## LSP LaTeX coverage — IMPLEMENTED, gates green, no PR yet
|
## LSP LaTeX coverage — IMPLEMENTED, gates green, no PR yet
|
||||||
|
|
||||||
**Written with the lane's first commit**, per the standing correction
|
**Written with the lane's first commit**, per the standing correction
|
||||||
|
|
@ -603,6 +725,7 @@ authoritative tip** — the ref, not a SHA. Recover with
|
||||||
— added in the second round — a **rename of either** the build or the
|
— added in the second round — a **rename of either** the build or the
|
||||||
sweep step each fail the suite.
|
sweep step each fail the suite.
|
||||||
||||||| parent of 72bbb96 (docs: LSP LaTeX coverage framing revision 2, on a branch at last)
|
||||||| parent of 72bbb96 (docs: LSP LaTeX coverage framing revision 2, on a branch at last)
|
||||||
|
||||||| parent of 312ec7a (docs: frame Discovery Stage 2 (revision 2) — M-x rows)
|
||||||
|
|
||||||
## QoL arc retirement — PR #224 OPEN (docs only)
|
## QoL arc retirement — PR #224 OPEN (docs only)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2598,11 +2598,30 @@ cannot advertise 21 without stranding existing v20 clients before
|
||||||
`AttachRequest`. v15 = `CompletionPopup` + `StatusFacts.message`; v16 =
|
`AttachRequest`. v15 = `CompletionPopup` + `StatusFacts.message`; v16 =
|
||||||
`ThemeFacts`; v17 = `FontFacts`; v18 = `StatuslineSegments`; v19 = the vterm
|
`ThemeFacts`; v17 = `FontFacts`; v18 = `StatuslineSegments`; v19 = the vterm
|
||||||
terminal family; v20 = semantic `SessionBootstrapRequest` plus appended
|
terminal family; v20 = semantic `SessionBootstrapRequest` plus appended
|
||||||
`InitialTargetResult`; v21 reserves the panel frame/event family. New wire
|
`InitialTargetResult`; v21 reserves the panel frame/event family;
|
||||||
|
v22 = `LineWrapFacts`; v23 = `MinibufferPromptRows`. New wire
|
||||||
surface ⇒ bump + both-frontends support + acceptance. An APPENDED variant
|
surface ⇒ bump + both-frontends support + acceptance. An APPENDED variant
|
||||||
must be guarded by a byte pin on the PREVIOUS final variant — its own
|
must be guarded by a byte pin on the PREVIOUS final variant — its own
|
||||||
round-trip cannot detect a discriminant shift.
|
round-trip cannot detect a discriminant shift.
|
||||||
|
|
||||||
|
**A SUPERSEDED variant can be frozen rather than widened, and v23 is the
|
||||||
|
first case.** Discovery Stage 2 needed richer minibuffer rows.
|
||||||
|
Widening `MinibufferPrompt` in place was not an option — postcard
|
||||||
|
encodes fields positionally, so every v12–v22 peer would **mis-decode**
|
||||||
|
the bytes rather than ignore them — and gating the widened form at
|
||||||
|
`>= 23` would have left those peers with **no minibuffer message at
|
||||||
|
all**, because there would have been only one variant to gate.
|
||||||
|
Compatibility requires the old shape to still exist *and still be sent*.
|
||||||
|
So `MinibufferPrompt` is retained unchanged for `12..=22`,
|
||||||
|
`MinibufferPromptRows` is appended for `>= 23`, and the daemon gate is a
|
||||||
|
**range on both sides** so exactly one variant reaches any peer.
|
||||||
|
Two consequences worth carrying forward: a frozen variant needs a
|
||||||
|
**literal byte fixture** (`assert_eq!(encoded, LEGACY_BYTES)`), because a
|
||||||
|
round-trip encodes and decodes with the same types and so freezes
|
||||||
|
nothing; and the CLOSE message must use the same variant family as the
|
||||||
|
OPEN, or a session closed by the other family's clear leaves its surface
|
||||||
|
on screen forever.
|
||||||
|
|
||||||
**Fake LSP** (`src/bin/pmacs_fake_lsp.rs`) modes: `fullonly`,
|
**Fake LSP** (`src/bin/pmacs_fake_lsp.rs`) modes: `fullonly`,
|
||||||
`rangeonly`, `rangeonly16` (UTF-16 + fail-closed bounds validation),
|
`rangeonly`, `rangeonly16` (UTF-16 + fail-closed bounds validation),
|
||||||
`sighelp`. Use these for capability-matrix tests, not real servers.
|
`sighelp`. Use these for capability-matrix tests, not real servers.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,348 @@
|
||||||
|
# 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 v12–v22.** 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.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 `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 v12–v22 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 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).
|
||||||
|
|
@ -44,9 +44,10 @@ use pmacs_protocol::{
|
||||||
CompletionPopupRow, CrdtOp, Decoration, DecorationKind, DecorationSegment, FrontendId,
|
CompletionPopupRow, CrdtOp, Decoration, DecorationKind, DecorationSegment, FrontendId,
|
||||||
InlineAdornment, InstanceMessage, InstanceSignal, Key as ProtocolKey, LineNumberMode,
|
InlineAdornment, InstanceMessage, InstanceSignal, Key as ProtocolKey, LineNumberMode,
|
||||||
MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES,
|
MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES,
|
||||||
MAX_STATUSLINE_TOTAL_TEXT_BYTES, MenuPromptRow, Modifiers, MouseButton as ProtocolMouseButton,
|
MAX_STATUSLINE_TOTAL_TEXT_BYTES, MenuPromptRow, MinibufferRow, Modifiers,
|
||||||
MouseKind as ProtocolMouseKind, PointerKind, SelectionSnapshot, StatuslineSegment,
|
MouseButton as ProtocolMouseButton, MouseKind as ProtocolMouseKind, PointerKind,
|
||||||
StyleSegment, StyleSpan, TAB_STOP_COLUMNS, TerminalFrame, UnderlineStyle,
|
SelectionSnapshot, StatuslineSegment, StyleSegment, StyleSpan, TAB_STOP_COLUMNS, TerminalFrame,
|
||||||
|
UnderlineStyle,
|
||||||
cell::{Color as CellColor, Style as CellStyle},
|
cell::{Color as CellColor, Style as CellStyle},
|
||||||
is_builtin_pair_char, is_modeline_face_name,
|
is_builtin_pair_char, is_modeline_face_name,
|
||||||
panel::{PANEL_MIN_VERSION, PanelFrame, PanelFramePayload},
|
panel::{PANEL_MIN_VERSION, PanelFrame, PanelFramePayload},
|
||||||
|
|
@ -2259,16 +2260,25 @@ struct SearchPromptLocal {
|
||||||
invalid: bool,
|
invalid: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The live minibuffer (Q#MB1, protocol v12), mirrored from a
|
/// The live minibuffer (Q#MB1, protocol v12), mirrored from whichever
|
||||||
/// `MinibufferPrompt` whose `prompt` was `Some`. The prompt+input draw
|
/// minibuffer variant this session's negotiated version carries, when
|
||||||
/// in the bottom band with a caret; `candidates` (a windowed slice) feed
|
/// its `prompt` was `Some`. The prompt+input draw in the bottom band
|
||||||
/// the dropdown.
|
/// with a caret; `rows` (a windowed slice) feed the dropdown.
|
||||||
|
///
|
||||||
|
/// **One local shape for two wire variants.** A `>= 23` daemon sends
|
||||||
|
/// `MinibufferPromptRows` with per-row details; a `12..=22` daemon sends
|
||||||
|
/// the frozen `MinibufferPrompt` with bare strings, which land here as
|
||||||
|
/// rows whose `detail` is `None`. Both are live: this binary offers its
|
||||||
|
/// own `PROTOCOL_VERSION` only when the daemon advertises the current
|
||||||
|
/// baseline, and echoes an older baseline verbatim — so an older daemon
|
||||||
|
/// still negotiates an older session, and the legacy arm is reachable
|
||||||
|
/// rather than dead code.
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
struct MinibufferLocal {
|
struct MinibufferLocal {
|
||||||
prompt: String,
|
prompt: String,
|
||||||
input: String,
|
input: String,
|
||||||
cursor: u32,
|
cursor: u32,
|
||||||
candidates: Vec<String>,
|
rows: Vec<MinibufferRow>,
|
||||||
selected: Option<u32>,
|
selected: Option<u32>,
|
||||||
total: u32,
|
total: u32,
|
||||||
}
|
}
|
||||||
|
|
@ -5085,7 +5095,10 @@ impl State {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
// Q#MB1 — the minibuffer prompt/input/candidates. `prompt:
|
// Q#MB1 — the minibuffer prompt/input/candidates. `prompt:
|
||||||
// None` closes it.
|
// None` closes it. This is the FROZEN legacy variant, which
|
||||||
|
// only a `12..=22` daemon sends; its candidates carry no
|
||||||
|
// detail, so they become rows with `detail: None` and render
|
||||||
|
// exactly as they did before v23.
|
||||||
InstanceMessage::MinibufferPrompt {
|
InstanceMessage::MinibufferPrompt {
|
||||||
prompt,
|
prompt,
|
||||||
input,
|
input,
|
||||||
|
|
@ -5098,7 +5111,38 @@ impl State {
|
||||||
prompt,
|
prompt,
|
||||||
input,
|
input,
|
||||||
cursor,
|
cursor,
|
||||||
candidates,
|
rows: candidates
|
||||||
|
.into_iter()
|
||||||
|
.map(|label| MinibufferRow {
|
||||||
|
label,
|
||||||
|
detail: None,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
selected,
|
||||||
|
total,
|
||||||
|
});
|
||||||
|
self.request_redraw();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
// Discovery Stage 2 — the v23 rows form of the same surface,
|
||||||
|
// carrying an optional per-row detail (a command's
|
||||||
|
// description). `prompt: None` closes it, and the close
|
||||||
|
// arrives in THIS family because the daemon picks the family
|
||||||
|
// per peer: a rows session closed by a legacy clear would
|
||||||
|
// leave the dropdown on screen forever.
|
||||||
|
InstanceMessage::MinibufferPromptRows {
|
||||||
|
prompt,
|
||||||
|
input,
|
||||||
|
cursor,
|
||||||
|
rows,
|
||||||
|
selected,
|
||||||
|
total,
|
||||||
|
} => {
|
||||||
|
self.minibuffer = prompt.map(|prompt| MinibufferLocal {
|
||||||
|
prompt,
|
||||||
|
input,
|
||||||
|
cursor,
|
||||||
|
rows,
|
||||||
selected,
|
selected,
|
||||||
total,
|
total,
|
||||||
});
|
});
|
||||||
|
|
@ -7619,11 +7663,22 @@ impl State {
|
||||||
|
|
||||||
/// Re-shape the minibuffer dropdown candidates (Q#MB1), one line per
|
/// Re-shape the minibuffer dropdown candidates (Q#MB1), one line per
|
||||||
/// candidate, best match first. Empty when there are no candidates.
|
/// candidate, best match first. Empty when there are no candidates.
|
||||||
|
///
|
||||||
|
/// Discovery Stage 2: a row with a `detail` renders `label detail`,
|
||||||
|
/// the same two-space form the completion dropdown already uses. A
|
||||||
|
/// row without one renders the bare label, so a file-path or
|
||||||
|
/// buffer-name prompt looks exactly as it did before v23.
|
||||||
fn refresh_mb_buffer(&mut self) {
|
fn refresh_mb_buffer(&mut self) {
|
||||||
let text = self
|
let text = self.minibuffer.as_ref().map_or_else(String::new, |mb| {
|
||||||
.minibuffer
|
mb.rows
|
||||||
.as_ref()
|
.iter()
|
||||||
.map_or_else(String::new, |mb| mb.candidates.join("\n"));
|
.map(|row| match row.detail.as_deref() {
|
||||||
|
Some(detail) => format!("{} {detail}", row.label),
|
||||||
|
None => row.label.clone(),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
});
|
||||||
let family = self.resolved_family.clone();
|
let family = self.resolved_family.clone();
|
||||||
self.mb_buffer.set_text(
|
self.mb_buffer.set_text(
|
||||||
&mut self.font_system,
|
&mut self.font_system,
|
||||||
|
|
@ -7644,7 +7699,7 @@ impl State {
|
||||||
let mb = self.minibuffer.as_ref()?;
|
let mb = self.minibuffer.as_ref()?;
|
||||||
let band_top = status_band_top(self.config.height, self.fm);
|
let band_top = status_band_top(self.config.height, self.fm);
|
||||||
mb_dropdown_window(
|
mb_dropdown_window(
|
||||||
mb.candidates.len(),
|
mb.rows.len(),
|
||||||
mb.selected.map_or(0, |s| s as usize),
|
mb.selected.map_or(0, |s| s as usize),
|
||||||
band_top,
|
band_top,
|
||||||
self.fm,
|
self.fm,
|
||||||
|
|
@ -10868,6 +10923,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str {
|
||||||
InstanceMessage::SearchPrompt { .. } => "SearchPrompt",
|
InstanceMessage::SearchPrompt { .. } => "SearchPrompt",
|
||||||
InstanceMessage::MenuPrompt { .. } => "MenuPrompt",
|
InstanceMessage::MenuPrompt { .. } => "MenuPrompt",
|
||||||
InstanceMessage::MinibufferPrompt { .. } => "MinibufferPrompt",
|
InstanceMessage::MinibufferPrompt { .. } => "MinibufferPrompt",
|
||||||
|
InstanceMessage::MinibufferPromptRows { .. } => "MinibufferPromptRows",
|
||||||
InstanceMessage::BlockAdornments { .. } => "BlockAdornments",
|
InstanceMessage::BlockAdornments { .. } => "BlockAdornments",
|
||||||
InstanceMessage::FoldState { .. } => "FoldState",
|
InstanceMessage::FoldState { .. } => "FoldState",
|
||||||
InstanceMessage::ResourceOffer { .. } => "ResourceOffer",
|
InstanceMessage::ResourceOffer { .. } => "ResourceOffer",
|
||||||
|
|
@ -13766,6 +13822,22 @@ mod tests {
|
||||||
// They skip (not fail) when no wgpu adapter is available — a dev box
|
// They skip (not fail) when no wgpu adapter is available — a dev box
|
||||||
// without working Vulkan, or CI without lavapipe.
|
// without working Vulkan, or CI without lavapipe.
|
||||||
|
|
||||||
|
/// Detail-free minibuffer rows from bare labels — what a `12..=22`
|
||||||
|
/// daemon's frozen `MinibufferPrompt` lands as.
|
||||||
|
fn detailless_rows<I, S>(labels: I) -> Vec<MinibufferRow>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = S>,
|
||||||
|
S: Into<String>,
|
||||||
|
{
|
||||||
|
labels
|
||||||
|
.into_iter()
|
||||||
|
.map(|label| MinibufferRow {
|
||||||
|
label: label.into(),
|
||||||
|
detail: None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Build a headless `State`, or return `None` and log when there's no
|
/// Build a headless `State`, or return `None` and log when there's no
|
||||||
/// adapter so the caller can skip. When `PMACS_REQUIRE_GPU` is set
|
/// adapter so the caller can skip. When `PMACS_REQUIRE_GPU` is set
|
||||||
/// (CI, where lavapipe is installed) a missing adapter is a hard
|
/// (CI, where lavapipe is installed) a missing adapter is a hard
|
||||||
|
|
@ -14888,7 +14960,7 @@ mod tests {
|
||||||
prompt: "M-x ".to_owned(),
|
prompt: "M-x ".to_owned(),
|
||||||
input: "find".to_owned(),
|
input: "find".to_owned(),
|
||||||
cursor: 4,
|
cursor: 4,
|
||||||
candidates: Vec::new(),
|
rows: Vec::new(),
|
||||||
selected: None,
|
selected: None,
|
||||||
total: 0,
|
total: 0,
|
||||||
});
|
});
|
||||||
|
|
@ -15291,7 +15363,7 @@ mod tests {
|
||||||
prompt: "M-x ".into(),
|
prompt: "M-x ".into(),
|
||||||
input: "theme".into(),
|
input: "theme".into(),
|
||||||
cursor: 5,
|
cursor: 5,
|
||||||
candidates: Vec::new(),
|
rows: Vec::new(),
|
||||||
selected: None,
|
selected: None,
|
||||||
total: 0,
|
total: 0,
|
||||||
});
|
});
|
||||||
|
|
@ -15335,7 +15407,7 @@ mod tests {
|
||||||
prompt: "M-x ".into(),
|
prompt: "M-x ".into(),
|
||||||
input: "the".into(),
|
input: "the".into(),
|
||||||
cursor: 3,
|
cursor: 3,
|
||||||
candidates: vec!["theme-set".into(), "theme-clear".into()],
|
rows: detailless_rows(["theme-set", "theme-clear"]),
|
||||||
selected: Some(0),
|
selected: Some(0),
|
||||||
total: 2,
|
total: 2,
|
||||||
});
|
});
|
||||||
|
|
@ -15357,6 +15429,112 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Discovery Stage 2: a row's `detail` reaches the shaped dropdown
|
||||||
|
/// line, and BOTH wire families land in the same local shape.
|
||||||
|
///
|
||||||
|
/// Driven through `apply_attach_message` rather than by assigning
|
||||||
|
/// `state.minibuffer` — the mapping from wire variant to local row
|
||||||
|
/// is exactly what this asserts, so constructing the local value
|
||||||
|
/// would skip the thing under test. The shaped `layout_runs()` text
|
||||||
|
/// is what glyphon rasterizes, so a description present there is a
|
||||||
|
/// description on screen.
|
||||||
|
#[test]
|
||||||
|
fn a_minibuffer_row_detail_reaches_the_shaped_dropdown_line() {
|
||||||
|
let Some(mut state) = headless_or_skip(600, 400, "hello") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The v23 rows form: a row with a detail, and a row without.
|
||||||
|
let _ = state.apply_attach_message(InstanceMessage::MinibufferPromptRows {
|
||||||
|
prompt: Some("M-x ".into()),
|
||||||
|
input: "buf".into(),
|
||||||
|
cursor: 3,
|
||||||
|
rows: vec![
|
||||||
|
MinibufferRow {
|
||||||
|
label: "buffer.save".into(),
|
||||||
|
detail: Some("Write the buffer to its file".into()),
|
||||||
|
},
|
||||||
|
MinibufferRow {
|
||||||
|
label: "buffer.kill".into(),
|
||||||
|
detail: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selected: Some(0),
|
||||||
|
total: 2,
|
||||||
|
});
|
||||||
|
state.refresh_mb_buffer();
|
||||||
|
let lines: Vec<String> = state
|
||||||
|
.mb_buffer
|
||||||
|
.layout_runs()
|
||||||
|
.map(|run| run.text.to_owned())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
lines
|
||||||
|
.iter()
|
||||||
|
.any(|l| l.contains("buffer.save") && l.contains("Write the buffer to its file")),
|
||||||
|
"the detail must be shaped into the row: {lines:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lines
|
||||||
|
.iter()
|
||||||
|
.find(|l| l.contains("buffer.kill"))
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("buffer.kill"),
|
||||||
|
"a row with no detail renders the bare label, exactly as before v23: {lines:?}"
|
||||||
|
);
|
||||||
|
// The geometry invariant the dropdown depends on: it derives
|
||||||
|
// its height, its visible window and its selection-highlight
|
||||||
|
// offset from `rows.len()`, so ONE physical line per logical
|
||||||
|
// row is what keeps those aligned. The daemon clips a detail to
|
||||||
|
// its first line (`Command::description_first_line`) precisely
|
||||||
|
// so this holds for an MCP schema block.
|
||||||
|
assert_eq!(
|
||||||
|
lines.len(),
|
||||||
|
state.minibuffer.as_ref().map_or(0, |mb| mb.rows.len()),
|
||||||
|
"one physical line per candidate row: {lines:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The frozen `12..=22` form, which an older daemon still sends:
|
||||||
|
// bare strings become detail-free rows.
|
||||||
|
let _ = state.apply_attach_message(InstanceMessage::MinibufferPrompt {
|
||||||
|
prompt: Some("M-x ".into()),
|
||||||
|
input: "buf".into(),
|
||||||
|
cursor: 3,
|
||||||
|
candidates: vec!["buffer.save".into()],
|
||||||
|
selected: Some(0),
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
state.minibuffer.as_ref().map(|mb| mb.rows.clone()),
|
||||||
|
Some(vec![MinibufferRow {
|
||||||
|
label: "buffer.save".into(),
|
||||||
|
detail: None,
|
||||||
|
}]),
|
||||||
|
"the legacy variant lands as a detail-free row"
|
||||||
|
);
|
||||||
|
state.refresh_mb_buffer();
|
||||||
|
let legacy: Vec<String> = state
|
||||||
|
.mb_buffer
|
||||||
|
.layout_runs()
|
||||||
|
.map(|run| run.text.to_owned())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(legacy, vec!["buffer.save".to_owned()]);
|
||||||
|
|
||||||
|
// Either family closes the surface with `prompt: None`.
|
||||||
|
let _ = state.apply_attach_message(InstanceMessage::MinibufferPromptRows {
|
||||||
|
prompt: None,
|
||||||
|
input: String::new(),
|
||||||
|
cursor: 0,
|
||||||
|
rows: Vec::new(),
|
||||||
|
selected: None,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
state.minibuffer.is_none(),
|
||||||
|
"a rows clear closes the surface"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn headless_diag_face_recolors_band_counter_despite_unchanged_text() {
|
fn headless_diag_face_recolors_band_counter_despite_unchanged_text() {
|
||||||
// Acceptance 22 — the round-1 finding-3 bite. The E: counter
|
// Acceptance 22 — the round-1 finding-3 bite. The E: counter
|
||||||
|
|
@ -15954,7 +16132,7 @@ mod tests {
|
||||||
prompt: "P: ".into(),
|
prompt: "P: ".into(),
|
||||||
input: String::new(),
|
input: String::new(),
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
candidates: vec![long.clone(), long.clone()],
|
rows: detailless_rows([long.clone(), long.clone()]),
|
||||||
selected: Some(1),
|
selected: Some(1),
|
||||||
total: 2,
|
total: 2,
|
||||||
});
|
});
|
||||||
|
|
@ -16172,7 +16350,7 @@ mod tests {
|
||||||
prompt: "M-x ".into(),
|
prompt: "M-x ".into(),
|
||||||
input: String::new(),
|
input: String::new(),
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
candidates: (0..30).map(|i| format!("candidate-{i}")).collect(),
|
rows: detailless_rows((0..30).map(|i| format!("candidate-{i}"))),
|
||||||
selected: Some(1),
|
selected: Some(1),
|
||||||
total: 30,
|
total: 30,
|
||||||
});
|
});
|
||||||
|
|
@ -17095,7 +17273,7 @@ mod tests {
|
||||||
prompt: ":".into(),
|
prompt: ":".into(),
|
||||||
input: String::new(),
|
input: String::new(),
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
candidates: Vec::new(),
|
rows: Vec::new(),
|
||||||
selected: None,
|
selected: None,
|
||||||
total: 0,
|
total: 0,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -65,11 +65,12 @@ pub use message::{
|
||||||
InstanceMessage, InstanceSignal, Key, KeyEvent, LineNumberMode, MAX_INITIAL_TARGET_ERROR_BYTES,
|
InstanceMessage, InstanceSignal, Key, KeyEvent, LineNumberMode, MAX_INITIAL_TARGET_ERROR_BYTES,
|
||||||
MAX_INITIAL_TARGET_PATH_BYTES, MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES,
|
MAX_INITIAL_TARGET_PATH_BYTES, MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES,
|
||||||
MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES,
|
MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES,
|
||||||
MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities,
|
MenuPromptRow, MinibufferRow, Modifiers, MouseButton, MouseEvent, MouseKind,
|
||||||
PROTOCOL_VERSION, PointerKind, ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot,
|
NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, ResourceBody,
|
||||||
SessionBootstrapRequest, StatuslineSegment, StyleSegment, StyleSpan, ThemeFace,
|
SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, SessionBootstrapRequest, StatuslineSegment,
|
||||||
is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name,
|
StyleSegment, StyleSpan, ThemeFace, is_builtin_pair_char, is_modeline_face_name,
|
||||||
negotiate_capabilities, negotiated_session_version, requested_protocol_version,
|
is_supported_protocol_version, is_ui_face_name, negotiate_capabilities,
|
||||||
|
negotiated_session_version, requested_protocol_version,
|
||||||
};
|
};
|
||||||
pub use panel::{
|
pub use panel::{
|
||||||
MAX_PANEL_VISIBLE_CELLS, PANEL_MIN_VERSION, PanelFrame, PanelFrameError, PanelFramePayload,
|
MAX_PANEL_VISIBLE_CELLS, PANEL_MIN_VERSION, PanelFrame, PanelFrameError, PanelFramePayload,
|
||||||
|
|
|
||||||
|
|
@ -1098,7 +1098,24 @@ pub enum InstanceMessage {
|
||||||
/// v12). The minibuffer is a single *global* core instance, so this
|
/// v12). The minibuffer is a single *global* core instance, so this
|
||||||
/// is bufferless; the producer still emits it from the active-buffer
|
/// is bufferless; the producer still emits it from the active-buffer
|
||||||
/// viewport. `prompt: None` clears the GUI. Cached-compare
|
/// viewport. `prompt: None` clears the GUI. Cached-compare
|
||||||
/// suppressed like `SearchPrompt`; daemon-gated `>= 12`.
|
/// suppressed like `SearchPrompt`; daemon-gated `12..=22`.
|
||||||
|
///
|
||||||
|
/// # FROZEN — this variant's encoding must not move
|
||||||
|
///
|
||||||
|
/// Discovery Stage 2 (v23) needed richer rows, and postcard is not
|
||||||
|
/// self-describing: enum variants encode by index and fields by
|
||||||
|
/// position, so widening `candidates` in place would make every
|
||||||
|
/// v12–v22 peer **mis-decode** these bytes rather than ignore them.
|
||||||
|
/// Gating the widened shape at `>= 23` would not rescue them either
|
||||||
|
/// — with only one variant to send, they would receive no minibuffer
|
||||||
|
/// message at all. So the rich form went into a new appended
|
||||||
|
/// variant, [`Self::MinibufferPromptRows`], and this one is retained
|
||||||
|
/// unchanged as what a `12..=22` peer receives.
|
||||||
|
///
|
||||||
|
/// Its bytes are pinned literally by
|
||||||
|
/// `minibuffer_prompt_v12_wire_bytes_are_frozen` in
|
||||||
|
/// `src/protocol.rs` — a round-trip cannot detect a field addition,
|
||||||
|
/// because both sides simply learn the new shape.
|
||||||
MinibufferPrompt {
|
MinibufferPrompt {
|
||||||
/// The prompt string (e.g. `"M-x "`), or `None` when no
|
/// The prompt string (e.g. `"M-x "`), or `None` when no
|
||||||
/// minibuffer is open.
|
/// minibuffer is open.
|
||||||
|
|
@ -1299,6 +1316,59 @@ pub enum InstanceMessage {
|
||||||
/// Whether that buffer's long lines wrap.
|
/// Whether that buffer's long lines wrap.
|
||||||
wrap: bool,
|
wrap: bool,
|
||||||
},
|
},
|
||||||
|
/// Discovery Stage 2 (protocol v23): the minibuffer prompt with
|
||||||
|
/// **structured rows** — a label and an optional one-line detail —
|
||||||
|
/// instead of bare candidate strings.
|
||||||
|
///
|
||||||
|
/// # Why a second variant rather than a wider `MinibufferPrompt`
|
||||||
|
///
|
||||||
|
/// `Command.description` already exists and is already rendered by
|
||||||
|
/// `help.list-commands`; it is missing at the one moment it would
|
||||||
|
/// change a decision, which is the `M-x` row. Carrying it means
|
||||||
|
/// widening the minibuffer's candidate shape — and postcard encodes
|
||||||
|
/// fields **positionally**, so changing `candidates: Vec<String>` in
|
||||||
|
/// place is a wire break, not an evolution: a v22 peer mis-decodes
|
||||||
|
/// the bytes rather than skipping them. Gating the changed variant
|
||||||
|
/// at `>= 23` does not rescue it either, because a `12..=22` peer
|
||||||
|
/// would then receive no minibuffer message at all. Compatibility
|
||||||
|
/// requires the old shape to still exist *and still be sent*, so
|
||||||
|
/// [`Self::MinibufferPrompt`] is frozen and this is appended beside
|
||||||
|
/// it.
|
||||||
|
///
|
||||||
|
/// # Exactly one of the two reaches any peer
|
||||||
|
///
|
||||||
|
/// The producer selects on the session's negotiated version and the
|
||||||
|
/// daemon's write loop gates both directions: `>= 23` receives this
|
||||||
|
/// and never the legacy variant; `12..=22` receives the legacy
|
||||||
|
/// variant and never this. Sending both would double-render; sending
|
||||||
|
/// neither is the bug gating alone would have caused. The close
|
||||||
|
/// message must use the same family as the open — a rows session
|
||||||
|
/// closed by a legacy clear leaves a popup on screen forever.
|
||||||
|
///
|
||||||
|
/// Otherwise this mirrors [`Self::MinibufferPrompt`] exactly:
|
||||||
|
/// bufferless (one global core minibuffer), `prompt: None` clears
|
||||||
|
/// the GUI, cached-compare suppressed, emitted from the
|
||||||
|
/// active-buffer viewport.
|
||||||
|
///
|
||||||
|
/// Appended after [`Self::LineWrapFacts`], the final v22 variant, so
|
||||||
|
/// no existing postcard discriminant moves.
|
||||||
|
MinibufferPromptRows {
|
||||||
|
/// The prompt string (e.g. `"M-x "`), or `None` when no
|
||||||
|
/// minibuffer is open.
|
||||||
|
prompt: Option<String>,
|
||||||
|
/// The text typed so far.
|
||||||
|
input: String,
|
||||||
|
/// Codepoints before the cursor within `input` (the caret
|
||||||
|
/// position).
|
||||||
|
cursor: u32,
|
||||||
|
/// A windowed slice of the completion candidates (best-first,
|
||||||
|
/// already filtered/sorted by the core), `<= MB_VISIBLE`.
|
||||||
|
rows: Vec<MinibufferRow>,
|
||||||
|
/// Highlighted row *within* `rows`, or `None`.
|
||||||
|
selected: Option<u32>,
|
||||||
|
/// Total candidate count (the window is a slice of this).
|
||||||
|
total: u32,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One resolved UI face for [`InstanceMessage::ThemeFacts`]: a full
|
/// One resolved UI face for [`InstanceMessage::ThemeFacts`]: a full
|
||||||
|
|
@ -1394,6 +1464,35 @@ pub struct CompletionPopupRow {
|
||||||
pub detail: Option<String>,
|
pub detail: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One row of the minibuffer's candidate list on the wire
|
||||||
|
/// ([`InstanceMessage::MinibufferPromptRows`], Discovery Stage 2,
|
||||||
|
/// protocol v23).
|
||||||
|
///
|
||||||
|
/// # Why this is not `CompletionPopupRow`
|
||||||
|
///
|
||||||
|
/// Reuse was tempting and is wrong. [`CompletionPopupRow::kind`] is an
|
||||||
|
/// LSP `CompletionItemKind` code with a documented contract, and an
|
||||||
|
/// `M-x` command is not an LSP completion item — it has no honest value
|
||||||
|
/// for that field. Reusing it would mean inventing a fake kind or
|
||||||
|
/// declaring unknown everywhere: a type whose invariant is "meaningless
|
||||||
|
/// in half its uses". If a category is wanted later it arrives with
|
||||||
|
/// `Command.category`, typed as what it actually is rather than
|
||||||
|
/// borrowed from LSP.
|
||||||
|
///
|
||||||
|
/// `detail` is optional **per row** because `pmacs.minibuffer.read`
|
||||||
|
/// serves many sources — file paths, buffer names, settings — and only
|
||||||
|
/// some have a natural detail. A source with none leaves it `None` and
|
||||||
|
/// renders exactly as it did before v23.
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct MinibufferRow {
|
||||||
|
/// Display label — the candidate itself, and the value acceptance
|
||||||
|
/// resolves to.
|
||||||
|
pub label: String,
|
||||||
|
/// Optional one-line detail rendered after the label (a command's
|
||||||
|
/// description, for `M-x`).
|
||||||
|
pub detail: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Flat selection state for the wire.
|
/// Flat selection state for the wire.
|
||||||
///
|
///
|
||||||
/// Mirrors [`crate::window::Selection`] but as a self-contained pair
|
/// Mirrors [`crate::window::Selection`] but as a self-contained pair
|
||||||
|
|
@ -1731,7 +1830,17 @@ pub enum ResourceBody {
|
||||||
/// directions: a v20 peer neither receives `PanelFrame` nor is placed in
|
/// directions: a v20 peer neither receives `PanelFrame` nor is placed in
|
||||||
/// a side window, because denying only the events would leave its
|
/// a side window, because denying only the events would leave its
|
||||||
/// window invisible.
|
/// window invisible.
|
||||||
pub const PROTOCOL_VERSION: u32 = 22;
|
///
|
||||||
|
/// Discovery Stage 2: bumped 22 → 23 for
|
||||||
|
/// [`InstanceMessage::MinibufferPromptRows`] — the minibuffer's
|
||||||
|
/// candidate rows gaining an optional per-row detail. Appended after
|
||||||
|
/// `LineWrapFacts`, the final v22 variant, so no existing discriminant
|
||||||
|
/// moves; [`InstanceMessage::MinibufferPrompt`] is retained **frozen**
|
||||||
|
/// and still sent to `12..=22` peers, because postcard's positional
|
||||||
|
/// encoding makes an in-place widening a wire break rather than an
|
||||||
|
/// evolution, and gating the widened form would have left those peers
|
||||||
|
/// with no minibuffer message at all.
|
||||||
|
pub const PROTOCOL_VERSION: u32 = 23;
|
||||||
|
|
||||||
/// Protocol version placed in the daemon's server-first [`Hello`].
|
/// Protocol version placed in the daemon's server-first [`Hello`].
|
||||||
///
|
///
|
||||||
|
|
@ -1905,8 +2014,15 @@ pub fn negotiated_session_version(frontend_offer: u32) -> u32 {
|
||||||
/// [`ADVERTISED_PROTOCOL_VERSION`] does not move — a v21 frontend
|
/// [`ADVERTISED_PROTOCOL_VERSION`] does not move — a v21 frontend
|
||||||
/// negotiates v21, never receives the variant, and keeps its own
|
/// negotiates v21, never receives the variant, and keeps its own
|
||||||
/// behavior.
|
/// behavior.
|
||||||
|
///
|
||||||
|
/// Discovery Stage 2: extended to `[6, ..., 23]` for
|
||||||
|
/// [`InstanceMessage::MinibufferPromptRows`]. Additive and daemon-gated,
|
||||||
|
/// and unusually the gate is a **range on both sides**: a `12..=22` peer
|
||||||
|
/// keeps receiving the frozen [`InstanceMessage::MinibufferPrompt`], a
|
||||||
|
/// `>= 23` peer receives only the rows form, and no peer ever receives
|
||||||
|
/// both. [`ADVERTISED_PROTOCOL_VERSION`] does not move.
|
||||||
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[
|
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[
|
||||||
6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22,
|
6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// T M10.5: predicate for the handshake check. Returns `true` if
|
/// T M10.5: predicate for the handshake check. Returns `true` if
|
||||||
|
|
|
||||||
147
src/command.rs
147
src/command.rs
|
|
@ -66,7 +66,28 @@ impl SourceLocation {
|
||||||
pub struct Command {
|
pub struct Command {
|
||||||
/// Unique name (e.g. `buffer.save`).
|
/// Unique name (e.g. `buffer.save`).
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// One-line human-readable description (R42, required).
|
/// Human-readable description. Required and non-empty after trim
|
||||||
|
/// (R42), but otherwise **free-form, and legitimately multi-line**.
|
||||||
|
///
|
||||||
|
/// # Do not add a registration-time one-line guard
|
||||||
|
///
|
||||||
|
/// This doc used to read "one-line human-readable description",
|
||||||
|
/// which was an aspiration rather than the contract: MCP tool
|
||||||
|
/// registration renders a whole schema block in here — the tool's
|
||||||
|
/// text, a blank line, `Arguments:`, then one line per argument
|
||||||
|
/// (`tests/fixtures/pmacs-mcp-tools/init.lua:272`, a
|
||||||
|
/// `table.concat(lines, "\n")`) — and `m9_6_acceptance.rs:583-598`
|
||||||
|
/// asserts all four of those lines. Rejecting CR/LF in
|
||||||
|
/// [`CommandRegistry::define`] was tried, measured, and abandoned:
|
||||||
|
/// it fails 36 tests across `m9_6`/`m9_7`/`m9_8` and could only be
|
||||||
|
/// made green by deleting a shipped acceptance criterion.
|
||||||
|
///
|
||||||
|
/// The one-line constraint belongs to the **surfaces that have
|
||||||
|
/// it**, so a consumer rendering into a single row clips with
|
||||||
|
/// [`Self::description_first_line`] — the minibuffer band and the
|
||||||
|
/// completion dropdown both do. The full text stays intact for
|
||||||
|
/// `describe-command` and `help.list-commands`, which is what keeps
|
||||||
|
/// this a rendering decision rather than data loss.
|
||||||
pub description: String,
|
pub description: String,
|
||||||
/// Where the command was defined.
|
/// Where the command was defined.
|
||||||
pub source: SourceLocation,
|
pub source: SourceLocation,
|
||||||
|
|
@ -79,6 +100,53 @@ pub struct Command {
|
||||||
pub predicate: Option<Function>,
|
pub predicate: Option<Function>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Command {
|
||||||
|
/// [`Self::description`] clipped to its first line, for a consumer
|
||||||
|
/// rendering into a surface that has exactly one row.
|
||||||
|
///
|
||||||
|
/// The description is free-form and may carry a whole schema block
|
||||||
|
/// (see that field). Two surfaces cannot show one: the grid TUI
|
||||||
|
/// writes the selected candidate into a single-row suffix on the
|
||||||
|
/// minibuffer band, and the GPU dropdown derives its height, its
|
||||||
|
/// visible window and its selection-highlight offset from
|
||||||
|
/// `rows.len()` — **one logical row per candidate** — so a detail
|
||||||
|
/// that shapes into more physical lines than that misaligns every
|
||||||
|
/// row below it and the highlight with it.
|
||||||
|
///
|
||||||
|
/// Clipping here rather than refusing at registration follows the
|
||||||
|
/// precedent already in this tree: the MCP fixture's result
|
||||||
|
/// delivery keeps only the first line of a tool result because
|
||||||
|
/// *"a multi-line `set_status` would corrupt the row layout"*
|
||||||
|
/// (`tests/fixtures/pmacs-mcp-tools/init.lua:277-285`), leaving
|
||||||
|
/// width clipping to the frontend. Same hazard class, same
|
||||||
|
/// resolution.
|
||||||
|
///
|
||||||
|
/// **No ellipsis or truncation marker**, matching that precedent
|
||||||
|
/// and the minibuffer's own width rule, which rejects stub markers
|
||||||
|
/// for the same reason: the full text is one `describe-command`
|
||||||
|
/// away, and a marker in a candidate row reads as part of the
|
||||||
|
/// candidate.
|
||||||
|
#[must_use]
|
||||||
|
pub fn description_first_line(&self) -> &str {
|
||||||
|
first_line(&self.description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The prefix of `text` before its first line break.
|
||||||
|
///
|
||||||
|
/// Breaks on CR **or** LF, not LF alone: a lone CR ends a line on
|
||||||
|
/// classic-Mac-era input and is the leading half of a CRLF, so an
|
||||||
|
/// LF-only clip would pass a bare `\r` straight through to a
|
||||||
|
/// single-row surface — and a CR-only clip would do the same for `\n`.
|
||||||
|
/// Splitting on the first of either handles all three forms with one
|
||||||
|
/// scan, since CRLF's `\r` comes first.
|
||||||
|
fn first_line(text: &str) -> &str {
|
||||||
|
match text.find(['\n', '\r']) {
|
||||||
|
Some(break_at) => &text[..break_at],
|
||||||
|
None => text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Errors raised by the command registry.
|
/// Errors raised by the command registry.
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum CommandError {
|
pub enum CommandError {
|
||||||
|
|
@ -271,6 +339,83 @@ mod tests {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_multi_line_description_registers_and_clips_to_its_first_line() {
|
||||||
|
// Registration accepts it — MCP tool registration renders a
|
||||||
|
// whole schema block into `description` and
|
||||||
|
// `m9_6_acceptance.rs:583-598` asserts four of its lines, so a
|
||||||
|
// one-line guard here would delete a shipped contract. The
|
||||||
|
// one-line constraint lives at the single-row surfaces, which
|
||||||
|
// read `description_first_line`.
|
||||||
|
//
|
||||||
|
// All three break forms: a clip that split on `\n` alone would
|
||||||
|
// pass a bare `\r` through, and one that split on `\r` alone
|
||||||
|
// would pass `\n` through.
|
||||||
|
let lua = Lua::new();
|
||||||
|
for (label, description) in [
|
||||||
|
(
|
||||||
|
"LF",
|
||||||
|
"Greet someone.\n\nArguments:\n name (string, required)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"CR",
|
||||||
|
"Greet someone.\r\rArguments:\r name (string, required)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"CRLF",
|
||||||
|
"Greet someone.\r\n\r\nArguments:\r\n name (string, required)",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let mut r = CommandRegistry::new();
|
||||||
|
r.define(make_command(&lua, "mcp.greet", description))
|
||||||
|
.unwrap_or_else(|e| panic!("{label}: a schema block must still register: {e}"));
|
||||||
|
let cmd = r.get("mcp.greet").expect("registered");
|
||||||
|
assert_eq!(
|
||||||
|
cmd.description, description,
|
||||||
|
"{label}: the registry stores the description verbatim — the clip is a \
|
||||||
|
rendering decision, so `describe-command` must still see every line"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cmd.description_first_line(),
|
||||||
|
"Greet someone.",
|
||||||
|
"{label}: a single-row surface gets the first line only"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!cmd.description_first_line().contains(['\n', '\r']),
|
||||||
|
"{label}: the clipped form must carry no break at all"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_single_line_description_is_byte_identical_after_the_clip() {
|
||||||
|
// The other half: the clip must not tighten past its purpose.
|
||||||
|
// Interior whitespace, punctuation and non-ASCII all survive,
|
||||||
|
// and there is no ellipsis or truncation marker.
|
||||||
|
let lua = Lua::new();
|
||||||
|
let mut r = CommandRegistry::new();
|
||||||
|
let description = "Write the buffer to its file — with a dash, and \ttabs.";
|
||||||
|
r.define(make_command(&lua, "buffer.save", description))
|
||||||
|
.expect("registers");
|
||||||
|
assert_eq!(
|
||||||
|
r.get("buffer.save").unwrap().description_first_line(),
|
||||||
|
description,
|
||||||
|
"a description with no break is returned unchanged"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_description_whose_first_line_is_empty_clips_to_empty() {
|
||||||
|
// The case the producer turns into `None` rather than
|
||||||
|
// `Some("")`: a leading break leaves nothing to render, and a
|
||||||
|
// `Some("")` detail would draw trailing padding after the label.
|
||||||
|
let lua = Lua::new();
|
||||||
|
let mut r = CommandRegistry::new();
|
||||||
|
r.define(make_command(&lua, "x", "\nArguments:\n a (string)"))
|
||||||
|
.expect("registers");
|
||||||
|
assert_eq!(r.get("x").unwrap().description_first_line(), "");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_name_is_rejected() {
|
fn empty_name_is_rejected() {
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
|
|
|
||||||
|
|
@ -1420,9 +1420,23 @@ fn dispatcher_loop(
|
||||||
let peer_knows_menu_prompt = session_registry
|
let peer_knows_menu_prompt = session_registry
|
||||||
.session_state(*fid)
|
.session_state(*fid)
|
||||||
.is_some_and(|s| s.negotiated_protocol_version >= 11);
|
.is_some_and(|s| s.negotiated_protocol_version >= 11);
|
||||||
let peer_knows_minibuffer_prompt = session_registry
|
// Q#MB1 / Discovery Stage 2 — the minibuffer is the one
|
||||||
.session_state(*fid)
|
// surface with TWO live variants, and the gate is a
|
||||||
.is_some_and(|s| s.negotiated_protocol_version >= 12);
|
// RANGE on both sides rather than a floor. The legacy
|
||||||
|
// `MinibufferPrompt` is frozen and belongs to `12..=22`;
|
||||||
|
// `MinibufferPromptRows` belongs to `>= 23`. Writing the
|
||||||
|
// legacy gate as a bare `>= 12` would let a v23 peer
|
||||||
|
// receive both and double-render its dropdown.
|
||||||
|
let peer_knows_minibuffer_prompt =
|
||||||
|
session_registry.session_state(*fid).is_some_and(|s| {
|
||||||
|
(12..crate::semantic_render::MINIBUFFER_ROWS_MIN_VERSION)
|
||||||
|
.contains(&s.negotiated_protocol_version)
|
||||||
|
});
|
||||||
|
let peer_knows_minibuffer_rows =
|
||||||
|
session_registry.session_state(*fid).is_some_and(|s| {
|
||||||
|
s.negotiated_protocol_version
|
||||||
|
>= crate::semantic_render::MINIBUFFER_ROWS_MIN_VERSION
|
||||||
|
});
|
||||||
// UX gutter — `LineNumbers` carries a `LineNumberMode` since
|
// UX gutter — `LineNumbers` carries a `LineNumberMode` since
|
||||||
// v14 (was `enabled: bool` in v13); a peer below 14 keeps
|
// v14 (was `enabled: bool` in v13); a peer below 14 keeps
|
||||||
// its gutter off rather than mis-decoding the wider shape.
|
// its gutter off rather than mis-decoding the wider shape.
|
||||||
|
|
@ -1470,12 +1484,24 @@ fn dispatcher_loop(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Q#MB1 — MinibufferPrompt gated at v12; a v11 peer
|
// Q#MB1 — MinibufferPrompt gated at v12; a v11 peer
|
||||||
// simply can't render the GUI minibuffer.
|
// simply can't render the GUI minibuffer. Discovery
|
||||||
|
// Stage 2 closed the range at the top: a v23 peer
|
||||||
|
// gets the rows form instead, never both.
|
||||||
if !peer_knows_minibuffer_prompt
|
if !peer_knows_minibuffer_prompt
|
||||||
&& matches!(msg, InstanceMessage::MinibufferPrompt { .. })
|
&& matches!(msg, InstanceMessage::MinibufferPrompt { .. })
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Discovery Stage 2 — MinibufferPromptRows gated at
|
||||||
|
// v23. A `12..=22` peer keeps the frozen legacy
|
||||||
|
// variant above, which is why gating alone was never
|
||||||
|
// enough: with one variant it would have lost the
|
||||||
|
// minibuffer entirely.
|
||||||
|
if !peer_knows_minibuffer_rows
|
||||||
|
&& matches!(msg, InstanceMessage::MinibufferPromptRows { .. })
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if !peer_knows_line_numbers
|
if !peer_knows_line_numbers
|
||||||
&& matches!(msg, InstanceMessage::LineNumbers { .. })
|
&& matches!(msg, InstanceMessage::LineNumbers { .. })
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -4732,7 +4732,10 @@ pub fn paint_frame(
|
||||||
paint_search_prompt(grid, core, term_size, &theme);
|
paint_search_prompt(grid, core, term_size, &theme);
|
||||||
None
|
None
|
||||||
} else if core.minibuffer.is_active() {
|
} else if core.minibuffer.is_active() {
|
||||||
Some(paint_minibuffer(grid, core, term_size, &theme))
|
// The command registry is a separate `RefCell` from the core, so
|
||||||
|
// this borrow does not contend with the one held above.
|
||||||
|
let commands = state.lua_host.commands().borrow();
|
||||||
|
Some(paint_minibuffer(grid, core, &commands, term_size, &theme))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
@ -5464,9 +5467,42 @@ fn minibuffer_style(theme: &crate::highlight::Theme) -> crate::cell::Style {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The inline candidate suffix for the minibuffer's bottom row, given
|
||||||
|
/// the columns still free after the prompt and the typed input.
|
||||||
|
///
|
||||||
|
/// Discovery Stage 2 §3.4 — three ORDERED steps, and the guarantee is
|
||||||
|
/// **"never a partial name"**, not "the name always survives". The
|
||||||
|
/// latter is unachievable: the prompt and the typed input consume the
|
||||||
|
/// budget first, so the remainder can be too small even for the bare
|
||||||
|
/// name.
|
||||||
|
///
|
||||||
|
/// 1. If the whole name does not fit, emit **nothing**. A truncated
|
||||||
|
/// `[buffer.sa…]` is worse than no suffix, 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 it — leaving exactly
|
||||||
|
/// today's `[name]`. No ellipsis stub.
|
||||||
|
///
|
||||||
|
/// Measured in `char`s, matching the painter below: it writes one cell
|
||||||
|
/// per `char`.
|
||||||
|
fn minibuffer_candidate_suffix(name: &str, detail: Option<&str>, remaining: u32) -> String {
|
||||||
|
let bare = format!(" [{name}]");
|
||||||
|
if bare.chars().count() as u32 > remaining {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
if let Some(detail) = detail.map(str::trim).filter(|d| !d.is_empty()) {
|
||||||
|
let full = format!(" [{name} — {detail}]");
|
||||||
|
if full.chars().count() as u32 <= remaining {
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bare
|
||||||
|
}
|
||||||
|
|
||||||
fn paint_minibuffer(
|
fn paint_minibuffer(
|
||||||
grid: &mut crate::cell::CellGrid<'_>,
|
grid: &mut crate::cell::CellGrid<'_>,
|
||||||
core: &EditorCore,
|
core: &EditorCore,
|
||||||
|
commands: &crate::command::CommandRegistry,
|
||||||
term_size: crate::cell::CellSize,
|
term_size: crate::cell::CellSize,
|
||||||
theme: &crate::highlight::Theme,
|
theme: &crate::highlight::Theme,
|
||||||
) -> u32 {
|
) -> u32 {
|
||||||
|
|
@ -5477,12 +5513,6 @@ fn paint_minibuffer(
|
||||||
.expect("called only when active");
|
.expect("called only when active");
|
||||||
let prompt = &session.prompt;
|
let prompt = &session.prompt;
|
||||||
let contents = core.minibuffer.contents();
|
let contents = core.minibuffer.contents();
|
||||||
let mut suffix = String::new();
|
|
||||||
if let Some(idx) = session.selected
|
|
||||||
&& let Some(cand) = session.candidates.get(idx)
|
|
||||||
{
|
|
||||||
suffix = format!(" [{cand}]");
|
|
||||||
}
|
|
||||||
let row = term_size.rows - 1;
|
let row = term_size.rows - 1;
|
||||||
let mut col: u32 = 0;
|
let mut col: u32 = 0;
|
||||||
let mut written: u32 = 0;
|
let mut written: u32 = 0;
|
||||||
|
|
@ -5537,6 +5567,35 @@ fn paint_minibuffer(
|
||||||
cursor_col = prompt_end;
|
cursor_col = prompt_end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Discovery Stage 2 (§3.4): the selected candidate's suffix now
|
||||||
|
// carries the command's DESCRIPTION, read from the registry
|
||||||
|
// in-process. The grid TUI never consumes `MinibufferPrompt` — it
|
||||||
|
// paints from `core.minibuffer` — so this half of the lane involves
|
||||||
|
// no wire at all and is independent of the v23 bump.
|
||||||
|
//
|
||||||
|
// Q#D2-2: only the command source has a detail. A file-path or
|
||||||
|
// buffer-name prompt renders exactly as it did before.
|
||||||
|
//
|
||||||
|
// FIRST LINE ONLY: this band is a single row, and
|
||||||
|
// `Command.description` is free-form — MCP registration renders a
|
||||||
|
// whole schema block into it. The full text stays reachable through
|
||||||
|
// `describe-command`.
|
||||||
|
let suffix = match session.selected.and_then(|idx| session.candidates.get(idx)) {
|
||||||
|
Some(cand) => {
|
||||||
|
let detail = matches!(
|
||||||
|
session.source,
|
||||||
|
crate::minibuffer::CompletionSource::Commands
|
||||||
|
)
|
||||||
|
.then(|| {
|
||||||
|
commands
|
||||||
|
.get(cand)
|
||||||
|
.map(crate::command::Command::description_first_line)
|
||||||
|
})
|
||||||
|
.flatten();
|
||||||
|
minibuffer_candidate_suffix(cand, detail, max.saturating_sub(col))
|
||||||
|
}
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
for ch in suffix.chars() {
|
for ch in suffix.chars() {
|
||||||
if col >= max {
|
if col >= max {
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -406,6 +406,10 @@ impl Frontend {
|
||||||
// surface; the TUI paints the minibuffer via its own bottom
|
// surface; the TUI paints the minibuffer via its own bottom
|
||||||
// row, so it drops this silently too.
|
// row, so it drops this silently too.
|
||||||
| InstanceMessage::MinibufferPrompt { .. }
|
| InstanceMessage::MinibufferPrompt { .. }
|
||||||
|
// Discovery Stage 2 — the v23 rows form of the same surface.
|
||||||
|
// The TUI reads `Command.description` from the registry
|
||||||
|
// in-process instead, so this reaches it not at all.
|
||||||
|
| InstanceMessage::MinibufferPromptRows { .. }
|
||||||
// UX gutter — LineNumbers is the semantic-frontend gutter
|
// UX gutter — LineNumbers is the semantic-frontend gutter
|
||||||
// toggle; the cell-grid TUI reads its window's mode directly,
|
// toggle; the cell-grid TUI reads its window's mode directly,
|
||||||
// so it drops this silently like the other semantic families.
|
// so it drops this silently like the other semantic families.
|
||||||
|
|
|
||||||
145
src/protocol.rs
145
src/protocol.rs
|
|
@ -1683,7 +1683,7 @@ mod tests {
|
||||||
// --- M5.5a handshake & postcard round-trips ---
|
// --- M5.5a handshake & postcard round-trips ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn protocol_version_is_twenty_two_for_line_wrap_facts() {
|
fn protocol_version_is_twenty_three_for_minibuffer_prompt_rows() {
|
||||||
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
|
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
|
||||||
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
|
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
|
||||||
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
|
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
|
||||||
|
|
@ -1732,7 +1732,15 @@ mod tests {
|
||||||
// daemon-gated, appended after the final v21 variant). The
|
// daemon-gated, appended after the final v21 variant). The
|
||||||
// GPU lays out locally and would otherwise never hear the wrap
|
// GPU lays out locally and would otherwise never hear the wrap
|
||||||
// setting; the advertised baseline is deliberately unmoved.
|
// setting; the advertised baseline is deliberately unmoved.
|
||||||
assert_eq!(PROTOCOL_VERSION, 22);
|
// Discovery Stage 2 bumps 22→23 (`InstanceMessage::
|
||||||
|
// MinibufferPromptRows`, daemon-gated, appended after the final
|
||||||
|
// v22 variant). The first bump to leave the SUPERSEDED variant
|
||||||
|
// live rather than widening it: postcard is positional, so
|
||||||
|
// widening `MinibufferPrompt` would break every v12–v22 peer,
|
||||||
|
// and gating the wider form would have left them with no
|
||||||
|
// minibuffer at all. `MinibufferPrompt` is therefore frozen and
|
||||||
|
// pinned by literal bytes below.
|
||||||
|
assert_eq!(PROTOCOL_VERSION, 23);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1809,17 +1817,18 @@ mod tests {
|
||||||
// (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`),
|
// (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`),
|
||||||
// v18 (`StatuslineSegments`), v19 (the vterm terminal family),
|
// v18 (`StatuslineSegments`), v19 (the vterm terminal family),
|
||||||
// v20 (semantic initial-target bootstrap), v21 (the bottom
|
// v20 (semantic initial-target bootstrap), v21 (the bottom
|
||||||
// panel band), and v22 (`LineWrapFacts`) all interoperate.
|
// panel band), v22 (`LineWrapFacts`), and v23
|
||||||
for accepted in 6..=22 {
|
// (`MinibufferPromptRows`) all interoperate.
|
||||||
|
for accepted in 6..=23 {
|
||||||
assert!(
|
assert!(
|
||||||
is_supported_protocol_version(accepted),
|
is_supported_protocol_version(accepted),
|
||||||
"v{accepted} must be accepted"
|
"v{accepted} must be accepted"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for rejected in [0, 1, 2, 3, 4, 5, 23, u32::MAX] {
|
for rejected in [0, 1, 2, 3, 4, 5, 24, u32::MAX] {
|
||||||
assert!(
|
assert!(
|
||||||
!is_supported_protocol_version(rejected),
|
!is_supported_protocol_version(rejected),
|
||||||
"v{rejected} must be rejected by a v22 binary"
|
"v{rejected} must be rejected by a v23 binary"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2406,6 +2415,130 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn minibuffer_prompt_v12_wire_bytes_are_frozen() {
|
||||||
|
// Discovery Stage 2 (v23) froze `MinibufferPrompt` and put the
|
||||||
|
// richer shape in an appended `MinibufferPromptRows`. THIS is
|
||||||
|
// what makes the freeze real, and the round-trip above is not:
|
||||||
|
// a round-trip encodes and decodes with the SAME types, so
|
||||||
|
// adding a field to `MinibufferPrompt` leaves it passing while
|
||||||
|
// every v12–v22 peer in the field mis-decodes the bytes. Only a
|
||||||
|
// comparison against bytes captured now can fail when the
|
||||||
|
// encoding changes.
|
||||||
|
//
|
||||||
|
// Two fixtures, the two shapes the producer emits: an open
|
||||||
|
// prompt with a windowed candidate list and a selection, and a
|
||||||
|
// cleared band. Discriminant 20, then the fields positionally
|
||||||
|
// (postcard is not self-describing).
|
||||||
|
let open = InstanceMessage::MinibufferPrompt {
|
||||||
|
prompt: Some("M-x ".to_owned()),
|
||||||
|
input: "ed".to_owned(),
|
||||||
|
cursor: 2,
|
||||||
|
candidates: vec!["edit.copy".to_owned(), "edit.cut".to_owned()],
|
||||||
|
selected: Some(1),
|
||||||
|
total: 7,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
postcard::to_allocvec(&open).expect("encode open"),
|
||||||
|
[
|
||||||
|
20, // InstanceMessage::MinibufferPrompt
|
||||||
|
1, 4, b'M', b'-', b'x', b' ', // prompt: Some("M-x ")
|
||||||
|
2, b'e', b'd', // input: "ed"
|
||||||
|
2, // cursor
|
||||||
|
2, 9, b'e', b'd', b'i', b't', b'.', b'c', b'o', b'p', b'y', 8, b'e', b'd', b'i',
|
||||||
|
b't', b'.', b'c', b'u', b't', // candidates
|
||||||
|
1, 1, // selected: Some(1)
|
||||||
|
7, // total
|
||||||
|
],
|
||||||
|
"MinibufferPrompt's v12 wire bytes changed. It is FROZEN for \
|
||||||
|
v12..=22 — a widening here mis-decodes on every already-shipped \
|
||||||
|
frontend rather than being ignored. Richer minibuffer rows \
|
||||||
|
belong in MinibufferPromptRows."
|
||||||
|
);
|
||||||
|
|
||||||
|
let clear = InstanceMessage::MinibufferPrompt {
|
||||||
|
prompt: None,
|
||||||
|
input: String::new(),
|
||||||
|
cursor: 0,
|
||||||
|
candidates: Vec::new(),
|
||||||
|
selected: None,
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
postcard::to_allocvec(&clear).expect("encode clear"),
|
||||||
|
[20, 0, 0, 0, 0, 0, 0],
|
||||||
|
"MinibufferPrompt's cleared-band v12 wire bytes changed — see the \
|
||||||
|
open-prompt fixture above"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_wrap_facts_encoding_is_unchanged_by_the_v23_build() {
|
||||||
|
// Discovery Stage 2 placement pin: `MinibufferPromptRows` must
|
||||||
|
// be APPENDED after `LineWrapFacts` — the final v22 variant,
|
||||||
|
// whose ordinal moves if anything is inserted before any v22
|
||||||
|
// variant. The new variant's own round-trip cannot detect a
|
||||||
|
// shift, which is why the pin sits on the PREVIOUS final variant
|
||||||
|
// (handoff §4).
|
||||||
|
let msg = InstanceMessage::LineWrapFacts {
|
||||||
|
buffer_id: pmacs_protocol::BufferId::from_raw(4),
|
||||||
|
wrap: true,
|
||||||
|
};
|
||||||
|
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
||||||
|
assert_eq!(
|
||||||
|
bytes,
|
||||||
|
[29, 4, 1],
|
||||||
|
"LineWrapFacts' v22 wire bytes changed — a variant was \
|
||||||
|
inserted before it; append new InstanceMessage variants \
|
||||||
|
at the end"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn minibuffer_prompt_rows_round_trips_and_appends_after_line_wrap_facts() {
|
||||||
|
// The v23 variant itself: both shapes, a detail present and a
|
||||||
|
// detail absent (Q#D2-2 — a source with no detail leaves it
|
||||||
|
// `None` and renders as it always did), plus the cleared band.
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
Some("M-x ".to_owned()),
|
||||||
|
"ed".to_owned(),
|
||||||
|
2u32,
|
||||||
|
vec![
|
||||||
|
MinibufferRow {
|
||||||
|
label: "edit.copy".to_owned(),
|
||||||
|
detail: Some("Copy the region".to_owned()),
|
||||||
|
},
|
||||||
|
MinibufferRow {
|
||||||
|
label: "notes.txt".to_owned(),
|
||||||
|
detail: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Some(1u32),
|
||||||
|
7u32,
|
||||||
|
),
|
||||||
|
(None, String::new(), 0, Vec::new(), None, 0),
|
||||||
|
];
|
||||||
|
for (prompt, input, cursor, rows, selected, total) in cases {
|
||||||
|
let msg = InstanceMessage::MinibufferPromptRows {
|
||||||
|
prompt: prompt.clone(),
|
||||||
|
input: input.clone(),
|
||||||
|
cursor,
|
||||||
|
rows: rows.clone(),
|
||||||
|
selected,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
||||||
|
assert_eq!(
|
||||||
|
bytes.first(),
|
||||||
|
Some(&30),
|
||||||
|
"MinibufferPromptRows must be appended after v22 LineWrapFacts"
|
||||||
|
);
|
||||||
|
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
|
||||||
|
assert_eq!(decoded, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn key_event_to_crossterm_round_trips() {
|
fn key_event_to_crossterm_round_trips() {
|
||||||
// Build a protocol KeyEvent, translate to crossterm, translate
|
// Build a protocol KeyEvent, translate to crossterm, translate
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ use crate::cell::{CellSize, Style};
|
||||||
use crate::editor::EditorState;
|
use crate::editor::EditorState;
|
||||||
use crate::protocol::{
|
use crate::protocol::{
|
||||||
AdornmentContent, AdornmentPlacement, ByteRange, Decoration, DecorationKind, DecorationSegment,
|
AdornmentContent, AdornmentPlacement, ByteRange, Decoration, DecorationKind, DecorationSegment,
|
||||||
FrontendId, InlineAdornment, InstanceMessage, MenuPromptRow, PANEL_MIN_VERSION,
|
FrontendId, InlineAdornment, InstanceMessage, MenuPromptRow, MinibufferRow, PANEL_MIN_VERSION,
|
||||||
StatuslineSegment, StyleSegment, StyleSpan,
|
StatuslineSegment, StyleSegment, StyleSpan,
|
||||||
};
|
};
|
||||||
use crate::statusline::{
|
use crate::statusline::{
|
||||||
|
|
@ -95,10 +95,26 @@ type SearchPromptFacts = (Option<String>, Option<u32>, u32, bool, bool);
|
||||||
/// menu.
|
/// menu.
|
||||||
type MenuPromptFacts = (Vec<MenuPromptRow>, Option<u32>);
|
type MenuPromptFacts = (Vec<MenuPromptRow>, Option<u32>);
|
||||||
|
|
||||||
/// Cached `MinibufferPrompt` payload for cached-compare suppression
|
/// Cached minibuffer payload for cached-compare suppression (Q#MB1):
|
||||||
/// (Q#MB1): `(prompt, input, cursor, candidates-window, selected, total)`.
|
/// `(prompt, input, cursor, rows-window, selected, total)`. A `None`
|
||||||
/// A `None` prompt means the minibuffer is closed.
|
/// prompt means the minibuffer is closed.
|
||||||
type MinibufferFacts = (Option<String>, String, u32, Vec<String>, Option<u32>, u32);
|
///
|
||||||
|
/// **ONE cache per peer, not one per variant.** [`SemanticRenderState`]
|
||||||
|
/// is constructed by [`SemanticRenderState::for_peer`] with the
|
||||||
|
/// session's negotiated version baked in on attach and dropped on
|
||||||
|
/// detach, so a cache can never span two negotiated versions and a
|
||||||
|
/// per-variant key would guard nothing. The rows are the cached form
|
||||||
|
/// either way: for a `12..=22` peer every `detail` is `None` (the
|
||||||
|
/// producer does not resolve details it cannot ship), so the cache
|
||||||
|
/// describes exactly what that peer received.
|
||||||
|
type MinibufferFacts = (
|
||||||
|
Option<String>,
|
||||||
|
String,
|
||||||
|
u32,
|
||||||
|
Vec<MinibufferRow>,
|
||||||
|
Option<u32>,
|
||||||
|
u32,
|
||||||
|
);
|
||||||
|
|
||||||
/// Cached `CompletionPopup` payload for cached-compare suppression
|
/// Cached `CompletionPopup` payload for cached-compare suppression
|
||||||
/// (Arc 1a Q#C5): `(anchor, prefix_len, rows-window, selected, total)`.
|
/// (Arc 1a Q#C5): `(anchor, prefix_len, rows-window, selected, total)`.
|
||||||
|
|
@ -115,10 +131,20 @@ type CompletionPopupFacts = (
|
||||||
/// scrolled window around the selection, not the full (≤1024) list.
|
/// scrolled window around the selection, not the full (≤1024) list.
|
||||||
const MB_VISIBLE: usize = 10;
|
const MB_VISIBLE: usize = 10;
|
||||||
|
|
||||||
|
/// The first protocol version that carries
|
||||||
|
/// [`InstanceMessage::MinibufferPromptRows`] (Discovery Stage 2).
|
||||||
|
///
|
||||||
|
/// Named rather than written as a literal `23` at each site, and NOT
|
||||||
|
/// derived from `PROTOCOL_VERSION`: the contract is "the version this
|
||||||
|
/// variant was introduced at", which is an absolute fact, while
|
||||||
|
/// `PROTOCOL_VERSION` moves with every later bump. Handoff §5 records
|
||||||
|
/// five defects of exactly that shape from one previous bump.
|
||||||
|
pub const MINIBUFFER_ROWS_MIN_VERSION: u32 = 23;
|
||||||
|
|
||||||
/// A window of up to [`MB_VISIBLE`] candidates around `selected`, plus
|
/// A window of up to [`MB_VISIBLE`] candidates around `selected`, plus
|
||||||
/// the selection's index *within* that window. Keeps the selected row
|
/// the selection's index *within* that window. Keeps the selected row
|
||||||
/// visible as the user cycles a long list.
|
/// visible as the user cycles a long list.
|
||||||
fn minibuffer_window(candidates: &[String], selected: Option<usize>) -> (Vec<String>, Option<u32>) {
|
fn minibuffer_window<T: Clone>(candidates: &[T], selected: Option<usize>) -> (Vec<T>, Option<u32>) {
|
||||||
if candidates.is_empty() {
|
if candidates.is_empty() {
|
||||||
return (Vec::new(), None);
|
return (Vec::new(), None);
|
||||||
}
|
}
|
||||||
|
|
@ -216,10 +242,18 @@ pub struct SemanticRenderState {
|
||||||
/// Last emitted `MenuPrompt` payload per buffer (Q#CM1), for
|
/// Last emitted `MenuPrompt` payload per buffer (Q#CM1), for
|
||||||
/// cached-compare suppression (see [`MenuPromptFacts`]).
|
/// cached-compare suppression (see [`MenuPromptFacts`]).
|
||||||
last_menu_prompt: HashMap<BufferId, MenuPromptFacts>,
|
last_menu_prompt: HashMap<BufferId, MenuPromptFacts>,
|
||||||
/// Last emitted `MinibufferPrompt` payload (Q#MB1) — a single value,
|
/// Last emitted minibuffer payload (Q#MB1) — a single value, not
|
||||||
/// not per-buffer, because the minibuffer is one global core
|
/// per-buffer, because the minibuffer is one global core instance,
|
||||||
/// instance.
|
/// and a single value across both wire variants, because this state
|
||||||
|
/// belongs to one peer at one negotiated version (see
|
||||||
|
/// [`MinibufferFacts`]).
|
||||||
last_minibuffer: Option<MinibufferFacts>,
|
last_minibuffer: Option<MinibufferFacts>,
|
||||||
|
/// Whether the peer negotiated protocol >= 23 (Discovery Stage 2).
|
||||||
|
/// `true` ⇒ it receives `MinibufferPromptRows` and never the legacy
|
||||||
|
/// variant; `false` ⇒ the frozen `MinibufferPrompt` and never the
|
||||||
|
/// rows form. Also gates the per-row detail lookup: a peer that
|
||||||
|
/// cannot carry a detail does not pay to resolve one.
|
||||||
|
peer_knows_minibuffer_rows: bool,
|
||||||
/// Last emitted `CompletionPopup` payload per buffer (Arc 1a
|
/// Last emitted `CompletionPopup` payload per buffer (Arc 1a
|
||||||
/// Q#C5), for cached-compare suppression (see
|
/// Q#C5), for cached-compare suppression (see
|
||||||
/// [`CompletionPopupFacts`]).
|
/// [`CompletionPopupFacts`]).
|
||||||
|
|
@ -478,6 +512,7 @@ impl SemanticRenderState {
|
||||||
s.peer_knows_theme_facts = negotiated_protocol_version >= 16;
|
s.peer_knows_theme_facts = negotiated_protocol_version >= 16;
|
||||||
s.peer_knows_font_facts = negotiated_protocol_version >= 17;
|
s.peer_knows_font_facts = negotiated_protocol_version >= 17;
|
||||||
s.peer_knows_line_wrap = negotiated_protocol_version >= 22;
|
s.peer_knows_line_wrap = negotiated_protocol_version >= 22;
|
||||||
|
s.peer_knows_minibuffer_rows = negotiated_protocol_version >= MINIBUFFER_ROWS_MIN_VERSION;
|
||||||
s.peer_knows_statusline_segments = negotiated_protocol_version >= 18;
|
s.peer_knows_statusline_segments = negotiated_protocol_version >= 18;
|
||||||
s.peer_knows_terminal_frames = negotiated_protocol_version >= 19;
|
s.peer_knows_terminal_frames = negotiated_protocol_version >= 19;
|
||||||
s.peer_knows_panel_frames = negotiated_protocol_version >= PANEL_MIN_VERSION;
|
s.peer_knows_panel_frames = negotiated_protocol_version >= PANEL_MIN_VERSION;
|
||||||
|
|
@ -500,6 +535,7 @@ impl SemanticRenderState {
|
||||||
last_search_prompt: HashMap::new(),
|
last_search_prompt: HashMap::new(),
|
||||||
last_menu_prompt: HashMap::new(),
|
last_menu_prompt: HashMap::new(),
|
||||||
last_minibuffer: None,
|
last_minibuffer: None,
|
||||||
|
peer_knows_minibuffer_rows: true,
|
||||||
last_completion_popup: HashMap::new(),
|
last_completion_popup: HashMap::new(),
|
||||||
last_summary: HashMap::new(),
|
last_summary: HashMap::new(),
|
||||||
last_status: HashMap::new(),
|
last_status: HashMap::new(),
|
||||||
|
|
@ -1637,11 +1673,20 @@ impl SemanticRenderState {
|
||||||
Some(msg)
|
Some(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `MinibufferPrompt` message for this frame, or `None` when the
|
/// The minibuffer message for this frame, or `None` when the
|
||||||
/// (global) minibuffer state is unchanged (Q#MB1). Emitted only from
|
/// (global) minibuffer state is unchanged (Q#MB1). Emitted only from
|
||||||
/// the active buffer's viewport so the bufferless message ships once
|
/// the active buffer's viewport so the bufferless message ships once
|
||||||
/// per frame. Closed = `prompt: None`; first sight while closed stays
|
/// per frame. Closed = `prompt: None`; first sight while closed stays
|
||||||
/// silent. The daemon keeps the variant off wires negotiated `< 12`.
|
/// silent.
|
||||||
|
///
|
||||||
|
/// **Exactly one variant, chosen by the peer's negotiated version**
|
||||||
|
/// (Discovery Stage 2). `>= 23` gets `MinibufferPromptRows` with
|
||||||
|
/// per-row details; `12..=22` gets the frozen `MinibufferPrompt`
|
||||||
|
/// carrying bare labels. Because the choice is made here, the CLOSE
|
||||||
|
/// necessarily uses the same family as the OPEN — a rows session
|
||||||
|
/// closed by a legacy clear would leave a popup on screen forever.
|
||||||
|
/// The daemon's write loop gates both directions again as
|
||||||
|
/// belt-and-braces.
|
||||||
fn minibuffer_prompt_msg(
|
fn minibuffer_prompt_msg(
|
||||||
&mut self,
|
&mut self,
|
||||||
state: &EditorState,
|
state: &EditorState,
|
||||||
|
|
@ -1662,13 +1707,61 @@ impl SemanticRenderState {
|
||||||
.take_while(|(i, _)| *i < cursor_byte)
|
.take_while(|(i, _)| *i < cursor_byte)
|
||||||
.count() as u32;
|
.count() as u32;
|
||||||
let total = session.candidates.len() as u32;
|
let total = session.candidates.len() as u32;
|
||||||
let (candidates, selected) =
|
let (labels, selected) =
|
||||||
minibuffer_window(&session.candidates, session.selected);
|
minibuffer_window(&session.candidates, session.selected);
|
||||||
|
// Q#D2-2: the detail is per row and optional. Only
|
||||||
|
// the command source has one today; a file-path or
|
||||||
|
// buffer-name prompt leaves it `None` and renders
|
||||||
|
// exactly as it did before v23. Resolved only for a
|
||||||
|
// peer that can carry it, so the cached facts
|
||||||
|
// describe what that peer actually received.
|
||||||
|
let detail_source = self.peer_knows_minibuffer_rows
|
||||||
|
&& matches!(
|
||||||
|
session.source,
|
||||||
|
crate::minibuffer::CompletionSource::Commands
|
||||||
|
);
|
||||||
|
let rows = if detail_source {
|
||||||
|
let commands = state.lua_host.commands().borrow();
|
||||||
|
labels
|
||||||
|
.into_iter()
|
||||||
|
.map(|label| {
|
||||||
|
// FIRST LINE ONLY. `Command.description`
|
||||||
|
// is free-form and MCP registration puts
|
||||||
|
// a whole schema block in it, while the
|
||||||
|
// dropdown sizes itself from
|
||||||
|
// `rows.len()` — one logical row per
|
||||||
|
// candidate. Shipping the block would
|
||||||
|
// shape into more physical lines than
|
||||||
|
// the geometry accounts for and
|
||||||
|
// misalign every row below it. The full
|
||||||
|
// text stays reachable through
|
||||||
|
// `describe-command`.
|
||||||
|
let detail = commands
|
||||||
|
.get(&label)
|
||||||
|
.map(|command| command.description_first_line().to_owned())
|
||||||
|
// A description whose first line is
|
||||||
|
// empty (`"\nArguments:…"`) carries
|
||||||
|
// nothing to render, so it ships as
|
||||||
|
// absent rather than as `Some("")`,
|
||||||
|
// which would draw trailing padding.
|
||||||
|
.filter(|detail| !detail.is_empty());
|
||||||
|
MinibufferRow { label, detail }
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
labels
|
||||||
|
.into_iter()
|
||||||
|
.map(|label| MinibufferRow {
|
||||||
|
label,
|
||||||
|
detail: None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
(
|
(
|
||||||
Some(session.prompt.clone()),
|
Some(session.prompt.clone()),
|
||||||
input,
|
input,
|
||||||
cursor,
|
cursor,
|
||||||
candidates,
|
rows,
|
||||||
selected,
|
selected,
|
||||||
total,
|
total,
|
||||||
)
|
)
|
||||||
|
|
@ -1684,13 +1777,24 @@ impl SemanticRenderState {
|
||||||
self.last_minibuffer = Some(facts);
|
self.last_minibuffer = Some(facts);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let msg = InstanceMessage::MinibufferPrompt {
|
let msg = if self.peer_knows_minibuffer_rows {
|
||||||
prompt: facts.0.clone(),
|
InstanceMessage::MinibufferPromptRows {
|
||||||
input: facts.1.clone(),
|
prompt: facts.0.clone(),
|
||||||
cursor: facts.2,
|
input: facts.1.clone(),
|
||||||
candidates: facts.3.clone(),
|
cursor: facts.2,
|
||||||
selected: facts.4,
|
rows: facts.3.clone(),
|
||||||
total: facts.5,
|
selected: facts.4,
|
||||||
|
total: facts.5,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
InstanceMessage::MinibufferPrompt {
|
||||||
|
prompt: facts.0.clone(),
|
||||||
|
input: facts.1.clone(),
|
||||||
|
cursor: facts.2,
|
||||||
|
candidates: facts.3.iter().map(|row| row.label.clone()).collect(),
|
||||||
|
selected: facts.4,
|
||||||
|
total: facts.5,
|
||||||
|
}
|
||||||
};
|
};
|
||||||
self.last_minibuffer = Some(facts);
|
self.last_minibuffer = Some(facts);
|
||||||
Some(msg)
|
Some(msg)
|
||||||
|
|
@ -5773,9 +5877,28 @@ mod tests {
|
||||||
let short: Vec<String> = vec!["a".into(), "b".into(), "c".into()];
|
let short: Vec<String> = vec!["a".into(), "b".into(), "c".into()];
|
||||||
assert_eq!(minibuffer_window(&short, Some(2)), (short.clone(), Some(2)));
|
assert_eq!(minibuffer_window(&short, Some(2)), (short.clone(), Some(2)));
|
||||||
// Empty.
|
// Empty.
|
||||||
assert_eq!(minibuffer_window(&[], Some(0)), (Vec::new(), None));
|
assert_eq!(
|
||||||
|
minibuffer_window::<String>(&[], Some(0)),
|
||||||
|
(Vec::new(), None)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The v23 rows form: `(prompt, input, rows)`.
|
||||||
|
fn minibuffer_rows_of(
|
||||||
|
msgs: &[InstanceMessage],
|
||||||
|
) -> Option<(Option<String>, String, Vec<MinibufferRow>)> {
|
||||||
|
msgs.iter().find_map(|m| match m {
|
||||||
|
InstanceMessage::MinibufferPromptRows {
|
||||||
|
prompt,
|
||||||
|
input,
|
||||||
|
rows,
|
||||||
|
..
|
||||||
|
} => Some((prompt.clone(), input.clone(), rows.clone())),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The frozen `12..=22` form: `(prompt, input, candidates)`.
|
||||||
fn minibuffer_prompt_of(
|
fn minibuffer_prompt_of(
|
||||||
msgs: &[InstanceMessage],
|
msgs: &[InstanceMessage],
|
||||||
) -> Option<(Option<String>, String, Vec<String>)> {
|
) -> Option<(Option<String>, String, Vec<String>)> {
|
||||||
|
|
@ -5798,7 +5921,7 @@ mod tests {
|
||||||
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
|
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
|
||||||
|
|
||||||
// No minibuffer: the producer stays silent on first sight.
|
// No minibuffer: the producer stays silent on first sight.
|
||||||
assert!(minibuffer_prompt_of(&s.render_frame(&state)).is_none());
|
assert!(minibuffer_rows_of(&s.render_frame(&state)).is_none());
|
||||||
|
|
||||||
// Open an `M-x` prompt (command completion) via the Lua API.
|
// Open an `M-x` prompt (command completion) via the Lua API.
|
||||||
state
|
state
|
||||||
|
|
@ -5807,28 +5930,133 @@ mod tests {
|
||||||
.load("pmacs.minibuffer.read{ prompt = 'M-x ', source = 'commands', on_accept = function() end }")
|
.load("pmacs.minibuffer.read{ prompt = 'M-x ', source = 'commands', on_accept = function() end }")
|
||||||
.exec()
|
.exec()
|
||||||
.expect("open minibuffer");
|
.expect("open minibuffer");
|
||||||
let (prompt, input, cands) =
|
let (prompt, input, rows) =
|
||||||
minibuffer_prompt_of(&s.render_frame(&state)).expect("minibuffer prompt emitted");
|
minibuffer_rows_of(&s.render_frame(&state)).expect("minibuffer prompt emitted");
|
||||||
assert_eq!(prompt.as_deref(), Some("M-x "));
|
assert_eq!(prompt.as_deref(), Some("M-x "));
|
||||||
assert_eq!(input, "");
|
assert_eq!(input, "");
|
||||||
// Empty input matches every command; the wire carries a window.
|
// Empty input matches every command; the wire carries a window.
|
||||||
assert!(!cands.is_empty(), "M-x seeds command candidates");
|
assert!(!rows.is_empty(), "M-x seeds command candidates");
|
||||||
assert!(cands.len() <= MB_VISIBLE, "candidates ship windowed");
|
assert!(rows.len() <= MB_VISIBLE, "candidates ship windowed");
|
||||||
|
|
||||||
// Unchanged → suppressed (cached-compare).
|
// Unchanged → suppressed (cached-compare).
|
||||||
assert!(minibuffer_prompt_of(&s.render_frame(&state)).is_none());
|
assert!(minibuffer_rows_of(&s.render_frame(&state)).is_none());
|
||||||
|
|
||||||
// Cancel: the prompt clears (None).
|
// Cancel: the prompt clears (None), in the SAME family as the
|
||||||
|
// open — a rows session closed by a legacy clear would leave the
|
||||||
|
// dropdown on screen forever.
|
||||||
state
|
state
|
||||||
.lua_host
|
.lua_host
|
||||||
.lua()
|
.lua()
|
||||||
.load("pmacs.minibuffer.cancel()")
|
.load("pmacs.minibuffer.cancel()")
|
||||||
.exec()
|
.exec()
|
||||||
.expect("cancel");
|
.expect("cancel");
|
||||||
let (prompt, _, _) = minibuffer_prompt_of(&s.render_frame(&state)).expect("clear emitted");
|
let frame = s.render_frame(&state);
|
||||||
|
assert!(
|
||||||
|
minibuffer_prompt_of(&frame).is_none(),
|
||||||
|
"a v23 peer must never see the legacy variant, not even to close"
|
||||||
|
);
|
||||||
|
let (prompt, _, _) = minibuffer_rows_of(&frame).expect("clear emitted");
|
||||||
assert!(prompt.is_none(), "cancel clears the minibuffer band");
|
assert!(prompt.is_none(), "cancel clears the minibuffer band");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_v22_peer_gets_the_frozen_variant_and_a_v23_peer_gets_rows_with_details() {
|
||||||
|
// The producer half of the exclusivity guarantee, at the two
|
||||||
|
// versions that straddle the boundary. The real-daemon half —
|
||||||
|
// two sessions negotiating simultaneously — is in
|
||||||
|
// `tests/discovery_stage2_acceptance.rs`.
|
||||||
|
let state = empty_state();
|
||||||
|
let bid = active_buffer(&state);
|
||||||
|
state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.load(
|
||||||
|
"pmacs.command.define{ name = 'mb.probe', description = 'Probe the row detail.', \
|
||||||
|
fn = function() end }",
|
||||||
|
)
|
||||||
|
.exec()
|
||||||
|
.expect("define probe command");
|
||||||
|
|
||||||
|
let mut v22 = SemanticRenderState::for_peer(FrontendId::LOCAL, 22);
|
||||||
|
let mut v23 = SemanticRenderState::for_peer(FrontendId::LOCAL, 23);
|
||||||
|
for s in [&mut v22, &mut v23] {
|
||||||
|
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
|
||||||
|
let _ = s.render_frame(&state);
|
||||||
|
}
|
||||||
|
|
||||||
|
state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.load(
|
||||||
|
"pmacs.minibuffer.read{ prompt = 'M-x ', source = 'commands', \
|
||||||
|
on_accept = function() end }",
|
||||||
|
)
|
||||||
|
.exec()
|
||||||
|
.expect("open minibuffer");
|
||||||
|
state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.load("pmacs.minibuffer.set_contents('mb.probe')")
|
||||||
|
.exec()
|
||||||
|
.expect("narrow to the probe command");
|
||||||
|
|
||||||
|
let v22_frame = v22.render_frame(&state);
|
||||||
|
assert!(
|
||||||
|
minibuffer_rows_of(&v22_frame).is_none(),
|
||||||
|
"a v22 peer must never receive the v23 rows variant"
|
||||||
|
);
|
||||||
|
let (_, _, candidates) =
|
||||||
|
minibuffer_prompt_of(&v22_frame).expect("v22 gets the frozen variant");
|
||||||
|
assert!(
|
||||||
|
candidates.iter().any(|c| c == "mb.probe"),
|
||||||
|
"the frozen variant still carries the candidate names: {candidates:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let v23_frame = v23.render_frame(&state);
|
||||||
|
assert!(
|
||||||
|
minibuffer_prompt_of(&v23_frame).is_none(),
|
||||||
|
"a v23 peer must never receive the frozen variant"
|
||||||
|
);
|
||||||
|
let (_, _, rows) = minibuffer_rows_of(&v23_frame).expect("v23 gets the rows variant");
|
||||||
|
let probe = rows
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.label == "mb.probe")
|
||||||
|
.expect("the probe command is a candidate");
|
||||||
|
assert_eq!(
|
||||||
|
probe.detail.as_deref(),
|
||||||
|
Some("Probe the row detail."),
|
||||||
|
"the row carries the command's registered description"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_source_with_no_detail_ships_rows_with_none() {
|
||||||
|
// Q#D2-2: only the command source has a detail today. A
|
||||||
|
// buffer-name prompt leaves it `None`, and the GPU then renders
|
||||||
|
// exactly what it rendered before v23.
|
||||||
|
let state = empty_state();
|
||||||
|
let mut s = local();
|
||||||
|
let bid = active_buffer(&state);
|
||||||
|
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
|
||||||
|
let _ = s.render_frame(&state);
|
||||||
|
|
||||||
|
state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.load(
|
||||||
|
"pmacs.minibuffer.read{ prompt = 'Buffer: ', source = 'buffers', \
|
||||||
|
on_accept = function() end }",
|
||||||
|
)
|
||||||
|
.exec()
|
||||||
|
.expect("open buffer prompt");
|
||||||
|
let (_, _, rows) = minibuffer_rows_of(&s.render_frame(&state)).expect("prompt emitted");
|
||||||
|
assert!(!rows.is_empty(), "the buffer registry seeds candidates");
|
||||||
|
assert!(
|
||||||
|
rows.iter().all(|r| r.detail.is_none()),
|
||||||
|
"a source with no detail leaves every row's detail None: {rows:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn status_facts_emit_on_change_and_freeze_counts_while_stale() {
|
fn status_facts_emit_on_change_and_freeze_counts_while_stale() {
|
||||||
let state = empty_state();
|
let state = empty_state();
|
||||||
|
|
|
||||||
|
|
@ -337,8 +337,9 @@ fn one_daemon_serves_a_v21_panel_session_and_a_shipped_v20_client() {
|
||||||
#[test]
|
#[test]
|
||||||
fn the_baseline_stays_and_the_counter_offer_activates() {
|
fn the_baseline_stays_and_the_counter_offer_activates() {
|
||||||
// A deliberate tripwire: bumping the wire must be a conscious edit
|
// A deliberate tripwire: bumping the wire must be a conscious edit
|
||||||
// here, not a silent one. v22 is `LineWrapFacts` (long-lines Stage 3).
|
// here, not a silent one. v23 is `MinibufferPromptRows` (Discovery
|
||||||
assert_eq!(PROTOCOL_VERSION, 22);
|
// Stage 2); v22 was `LineWrapFacts` (long-lines Stage 3).
|
||||||
|
assert_eq!(PROTOCOL_VERSION, 23);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ADVERTISED_PROTOCOL_VERSION, 20,
|
ADVERTISED_PROTOCOL_VERSION, 20,
|
||||||
"moving this is the incompatible act the mechanism exists to avoid"
|
"moving this is the incompatible act the mechanism exists to avoid"
|
||||||
|
|
@ -352,10 +353,10 @@ fn the_baseline_stays_and_the_counter_offer_activates() {
|
||||||
// This replaces `assert_eq!(PANEL_MIN_VERSION, PROTOCOL_VERSION)`,
|
// This replaces `assert_eq!(PANEL_MIN_VERSION, PROTOCOL_VERSION)`,
|
||||||
// which asserted a **coincidence**: panel frames were the newest
|
// which asserted a **coincidence**: panel frames were the newest
|
||||||
// feature when it was written, so their minimum happened to equal
|
// feature when it was written, so their minimum happened to equal
|
||||||
// the current wire. Any later feature falsifies that — v22 is the
|
// the current wire. Any later feature falsifies that — v22 was the
|
||||||
// first, and the equality would have had to be edited on every
|
// first and v23 the second, and the equality would have had to be
|
||||||
// subsequent bump while telling a reader something that was never
|
// edited on every subsequent bump while telling a reader something
|
||||||
// the contract.
|
// that was never the contract.
|
||||||
// `const` blocks, matching the line above: these are compile-time
|
// `const` blocks, matching the line above: these are compile-time
|
||||||
// constants, so a runtime `assert!` is both a clippy error and a
|
// constants, so a runtime `assert!` is both a clippy error and a
|
||||||
// weaker check than the language already offers.
|
// weaker check than the language already offers.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,684 @@
|
||||||
|
// discovery_stage2_acceptance.rs --- Discovery Stage 2
|
||||||
|
// (docs/discovery-stage2-framing.md §6).
|
||||||
|
|
||||||
|
//! `M-x` rows stop being bare names.
|
||||||
|
//!
|
||||||
|
//! `Command.description` already existed and was already rendered by
|
||||||
|
//! `help.list-commands`; it was missing at the one moment it would
|
||||||
|
//! change a decision. Carrying it to the row is two independent halves,
|
||||||
|
//! and this suite keeps them separate because they fail separately:
|
||||||
|
//!
|
||||||
|
//! - **The wire half** is a protocol bump, v22 → v23, and it is
|
||||||
|
//! *additive*. `MinibufferPrompt` is FROZEN and still sent to every
|
||||||
|
//! `12..=22` peer, because postcard encodes fields positionally — a
|
||||||
|
//! widened `candidates` would make those peers mis-decode rather than
|
||||||
|
//! ignore, and gating the widened form would have left them with no
|
||||||
|
//! minibuffer message at all. The rich shape lives in an appended
|
||||||
|
//! `MinibufferPromptRows`, and **exactly one of the two reaches any
|
||||||
|
//! peer, ever**.
|
||||||
|
//! - **The TUI half involves no wire at all.** `src/editor.rs` contains
|
||||||
|
//! zero references to `MinibufferPrompt`: `paint_minibuffer` reads
|
||||||
|
//! `core.minibuffer` directly and renders the selected candidate as an
|
||||||
|
//! inline suffix. So it reads `Command.description` from the registry
|
||||||
|
//! in-process, which is why this half is independent of the bump.
|
||||||
|
//!
|
||||||
|
//! The daemon fixtures are `crdt`-gated because a semantic session is
|
||||||
|
//! necessarily a text replica: a non-CRDT build advertises no
|
||||||
|
//! `semantic_render` and cannot host one. They run in the
|
||||||
|
//! `--features crdt` sweep that `scripts/gate --protocol` adds.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use pmacs::bootstrap::BootstrapRoots;
|
||||||
|
use pmacs::editor::EditorState;
|
||||||
|
use pmacs_protocol::{
|
||||||
|
ADVERTISED_PROTOCOL_VERSION, ByteRange, InstanceMessage, MinibufferRow, PROTOCOL_VERSION,
|
||||||
|
is_supported_protocol_version,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(feature = "crdt")]
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
#[cfg(feature = "crdt")]
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
#[cfg(feature = "crdt")]
|
||||||
|
use pmacs_protocol::cell::CellSize;
|
||||||
|
#[cfg(feature = "crdt")]
|
||||||
|
use pmacs_protocol::message::{
|
||||||
|
AttachRequest, FrontendCapabilities, FrontendEvent, Hello, Key, KeyEvent, Modifiers,
|
||||||
|
SessionBootstrapRequest,
|
||||||
|
};
|
||||||
|
#[cfg(feature = "crdt")]
|
||||||
|
use pmacs_protocol::transport::{read_message, write_message};
|
||||||
|
|
||||||
|
#[cfg(feature = "crdt")]
|
||||||
|
use common::daemon::{TestDaemon, build_default_caps};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Version-bump discipline (§6, last bullet)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The bump is deliberate, and the advertised baseline does NOT move.
|
||||||
|
///
|
||||||
|
/// `ADVERTISED_PROTOCOL_VERSION` is pinned at 20 and is the one constant
|
||||||
|
/// that must never be edited (handoff §3/§5): the handshake is
|
||||||
|
/// server-first, so moving it locks out every already-shipped frontend
|
||||||
|
/// before it can counter-offer. An additive family never needs it.
|
||||||
|
#[test]
|
||||||
|
fn the_wire_is_v23_and_the_advertised_baseline_is_unmoved() {
|
||||||
|
assert_eq!(
|
||||||
|
PROTOCOL_VERSION, 23,
|
||||||
|
"v23 is MinibufferPromptRows (Discovery Stage 2)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ADVERTISED_PROTOCOL_VERSION, 20,
|
||||||
|
"moving this is the incompatible act the counter-offer mechanism exists to avoid"
|
||||||
|
);
|
||||||
|
// The whole v12..=22 population this lane is compatible with is
|
||||||
|
// still supported, and the set ends at the new wire — a widened set
|
||||||
|
// is a failure rather than a silent pass.
|
||||||
|
for version in 6..=23 {
|
||||||
|
assert!(
|
||||||
|
is_supported_protocol_version(version),
|
||||||
|
"v{version} must still be supported"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(!is_supported_protocol_version(24));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The TUI half: no wire involvement (§3.4, §6)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn session(name: &str) -> EditorState {
|
||||||
|
let base = Path::new(env!("CARGO_TARGET_TMPDIR"))
|
||||||
|
.join("discovery-stage2")
|
||||||
|
.join(name);
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
|
let roots = BootstrapRoots::isolated_under(&base);
|
||||||
|
for (_, dir) in roots.child_env() {
|
||||||
|
std::fs::create_dir_all(&dir).expect("create controlled root");
|
||||||
|
}
|
||||||
|
let state = EditorState::new_with_roots(&roots);
|
||||||
|
state.install_state_dirs();
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exec(s: &EditorState, src: &str) {
|
||||||
|
s.lua_host.lua().load(src.to_string()).exec().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
|
||||||
|
s.lua_host.lua().load(src.to_string()).eval().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render one frame at `cols` columns and return the bottom row's text.
|
||||||
|
///
|
||||||
|
/// Through `RenderState` and the wire rather than by calling the painter
|
||||||
|
/// directly: the spans are what the TUI actually consumes, so this
|
||||||
|
/// asserts on the cells that reach a screen.
|
||||||
|
fn bottom_row(s: &EditorState, rows: u32, cols: u32) -> String {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
let size = pmacs::cell::CellSize::new(rows, cols);
|
||||||
|
let mut rs = pmacs::instance_render::RenderState::new(size);
|
||||||
|
let msgs = rs.render_frame(s, pmacs::protocol::FrontendId::LOCAL, &HashMap::new(), &[]);
|
||||||
|
|
||||||
|
let mut row = vec![' '; cols as usize];
|
||||||
|
for msg in &msgs {
|
||||||
|
if let pmacs_protocol::InstanceMessage::CellDelta { spans, .. } = msg {
|
||||||
|
for span in spans {
|
||||||
|
if span.start.row != rows - 1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (i, cell) in span.cells.iter().enumerate() {
|
||||||
|
let c = span.start.col as usize + i;
|
||||||
|
if c < cols as usize
|
||||||
|
&& let pmacs::cell::Glyph::Char(ch) = cell.glyph
|
||||||
|
{
|
||||||
|
row[c] = ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.into_iter().collect::<String>().trim_end().to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open `M-x` narrowed to `zzprobe` and return the candidate rows the
|
||||||
|
/// semantic producer ships to a current-wire peer.
|
||||||
|
///
|
||||||
|
/// Through `SemanticRenderState` and the real minibuffer session rather
|
||||||
|
/// than by constructing a message: the clip lives in the producer, so a
|
||||||
|
/// hand-built row would skip the thing under test.
|
||||||
|
fn mx_rows(s: &EditorState) -> Vec<MinibufferRow> {
|
||||||
|
let bid = s.core.borrow().active_buffer_id();
|
||||||
|
let mut render = pmacs::semantic_render::SemanticRenderState::for_peer(
|
||||||
|
pmacs::protocol::FrontendId::LOCAL,
|
||||||
|
PROTOCOL_VERSION,
|
||||||
|
);
|
||||||
|
render.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
|
||||||
|
let _ = render.render_frame(s);
|
||||||
|
|
||||||
|
exec(
|
||||||
|
s,
|
||||||
|
"pmacs.minibuffer.read{ prompt = 'M-x ', source = 'commands', on_accept = function() end }",
|
||||||
|
);
|
||||||
|
exec(s, "pmacs.minibuffer.set_contents('zzprobe')");
|
||||||
|
render
|
||||||
|
.render_frame(s)
|
||||||
|
.into_iter()
|
||||||
|
.find_map(|msg| match msg {
|
||||||
|
InstanceMessage::MinibufferPromptRows { rows, .. } => Some(rows),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.expect("the producer ships a rows prompt")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open `M-x`, narrowed to exactly one command with a known
|
||||||
|
/// description, and report the bottom row at `cols` columns.
|
||||||
|
fn mx_bottom_row(s: &EditorState, cols: u32) -> String {
|
||||||
|
exec(
|
||||||
|
s,
|
||||||
|
"pmacs.minibuffer.read{ prompt = 'M-x ', source = 'commands', on_accept = function() end }",
|
||||||
|
);
|
||||||
|
exec(s, "pmacs.minibuffer.set_contents('zzprobe')");
|
||||||
|
bottom_row(s, 24, cols)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROBE_DESCRIPTION: &str = "Probe the description row.";
|
||||||
|
|
||||||
|
fn define_probe(s: &EditorState) {
|
||||||
|
exec(
|
||||||
|
s,
|
||||||
|
&format!(
|
||||||
|
"pmacs.command.define{{ name = 'zzprobe', description = '{PROBE_DESCRIPTION}', \
|
||||||
|
fn = function() end }}"
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_tui_renders_the_description_beside_the_selected_name() {
|
||||||
|
let s = session("tui-wide");
|
||||||
|
define_probe(&s);
|
||||||
|
let row = mx_bottom_row(&s, 120);
|
||||||
|
assert!(
|
||||||
|
row.contains(&format!("[zzprobe — {PROBE_DESCRIPTION}]")),
|
||||||
|
"the selected candidate carries its description: {row:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_tui_drops_the_description_then_the_whole_suffix_as_width_shrinks() {
|
||||||
|
// §3.4's three ORDERED steps, at the three widths that separate
|
||||||
|
// them. The guarantee is "never a PARTIAL name", which is
|
||||||
|
// achievable; "the name always survives" is not, because the prompt
|
||||||
|
// and the typed input consume the budget first.
|
||||||
|
let s = session("tui-clip");
|
||||||
|
define_probe(&s);
|
||||||
|
|
||||||
|
// 1. Wide: name + description.
|
||||||
|
let wide = mx_bottom_row(&s, 120);
|
||||||
|
assert!(
|
||||||
|
wide.contains(&format!("[zzprobe — {PROBE_DESCRIPTION}]")),
|
||||||
|
"wide: {wide:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Room for the whole name but not the whole description: the
|
||||||
|
// description is dropped, leaving exactly today's `[name]`. No
|
||||||
|
// ellipsis stub, and no prefix of the description either.
|
||||||
|
let medium = mx_bottom_row(&s, 30);
|
||||||
|
assert!(medium.contains("[zzprobe]"), "medium: {medium:?}");
|
||||||
|
assert!(
|
||||||
|
!medium.contains('—'),
|
||||||
|
"a description that does not fit whole is dropped entirely: {medium:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Too narrow for even the whole name: the suffix vanishes. The
|
||||||
|
// assertion is that no PREFIX of the name is emitted — `[zzpr`
|
||||||
|
// would read as a different command, which is worse than nothing.
|
||||||
|
let narrow = mx_bottom_row(&s, 18);
|
||||||
|
assert!(
|
||||||
|
!narrow.contains('['),
|
||||||
|
"a suffix that cannot hold the whole name is omitted entirely: {narrow:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
narrow.starts_with("M-x zzprobe"),
|
||||||
|
"the prompt and the typed input still own the row: {narrow:?}"
|
||||||
|
);
|
||||||
|
for cut in 1.."zzprobe".len() {
|
||||||
|
assert!(
|
||||||
|
!narrow.contains(&format!("[{}", &"zzprobe"[..cut])),
|
||||||
|
"no prefix of the name may be emitted: {narrow:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_source_with_no_detail_renders_exactly_as_before_in_the_tui() {
|
||||||
|
// Q#D2-2: the file-path prompt is the witness. It has no detail, so
|
||||||
|
// its suffix is the pre-v23 `[name]` and nothing else.
|
||||||
|
let s = session("tui-files");
|
||||||
|
let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("discovery-stage2-files");
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).expect("create file-prompt dir");
|
||||||
|
std::fs::write(dir.join("zznotes.txt"), b"x").expect("seed a file");
|
||||||
|
exec(
|
||||||
|
&s,
|
||||||
|
&format!(
|
||||||
|
"pmacs.minibuffer.read{{ prompt = 'File: ', source = 'files', \
|
||||||
|
source_root = '{}', on_accept = function() end }}",
|
||||||
|
dir.display()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
exec(&s, "pmacs.minibuffer.set_contents('zznotes.txt')");
|
||||||
|
let row = bottom_row(&s, 24, 120);
|
||||||
|
assert!(row.contains("[zznotes.txt]"), "file prompt row: {row:?}");
|
||||||
|
assert!(
|
||||||
|
!row.contains('—'),
|
||||||
|
"a source with no detail gains no separator: {row:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Multi-line descriptions reach single-row surfaces as ONE line
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// An MCP-shaped description: tool text, blank line, `Arguments:`, then
|
||||||
|
/// one line per argument.
|
||||||
|
///
|
||||||
|
/// This is the real shape, not an invented one —
|
||||||
|
/// `tests/fixtures/pmacs-mcp-tools/init.lua:272` builds it with
|
||||||
|
/// `table.concat(lines, "\n")` and `m9_6_acceptance.rs:583-598` asserts
|
||||||
|
/// four of its lines, which is why registration accepts it and the
|
||||||
|
/// SURFACES clip instead.
|
||||||
|
const MCP_SHAPED: &str = "Greet someone.\\n\\nArguments:\\n name (string, required)";
|
||||||
|
|
||||||
|
fn define_multiline_probe(s: &EditorState, name: &str, description: &str) {
|
||||||
|
exec(
|
||||||
|
s,
|
||||||
|
&format!(
|
||||||
|
"pmacs.command.define{{ name = '{name}', description = \"{description}\", \
|
||||||
|
fn = function() end }}"
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_multi_line_description_reaches_the_tui_band_as_one_line() {
|
||||||
|
let s = session("tui-multiline");
|
||||||
|
define_multiline_probe(&s, "zzprobe", MCP_SHAPED);
|
||||||
|
let row = mx_bottom_row(&s, 200);
|
||||||
|
assert!(
|
||||||
|
row.contains("[zzprobe — Greet someone.]"),
|
||||||
|
"the band shows the first line only: {row:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!row.contains("Arguments:"),
|
||||||
|
"the schema block must not reach a single-row band: {row:?}"
|
||||||
|
);
|
||||||
|
// `bottom_row` reads one grid row, so anything below would be lost
|
||||||
|
// rather than visibly wrong — assert on the registry-side clip too,
|
||||||
|
// which is what the painter consumed.
|
||||||
|
let clipped: String = eval(&s, "return pmacs.describe.command('zzprobe').description");
|
||||||
|
assert!(
|
||||||
|
clipped.contains("Arguments:"),
|
||||||
|
"describe-command must still see the WHOLE description, or the clip \
|
||||||
|
silently deleted the schema block everywhere: {clipped:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_multi_line_description_reaches_the_gpu_row_as_one_physical_line() {
|
||||||
|
// The geometry hazard, through the real prompt path: the dropdown
|
||||||
|
// sizes itself from `rows.len()` — one logical row per candidate —
|
||||||
|
// so a detail carrying a break would shape into more physical lines
|
||||||
|
// than the geometry accounts for.
|
||||||
|
//
|
||||||
|
// All three break forms, since a clip handling only LF would pass a
|
||||||
|
// bare CR through to the same surface.
|
||||||
|
for (label, description, tail) in [
|
||||||
|
("LF", MCP_SHAPED, "Arguments:"),
|
||||||
|
(
|
||||||
|
"CR",
|
||||||
|
"Greet someone.\\r\\rArguments:\\r name (string, required)",
|
||||||
|
"Arguments:",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"CRLF",
|
||||||
|
"Greet someone.\\r\\n\\r\\nArguments:\\r\\n name (string, required)",
|
||||||
|
"Arguments:",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let s = session(&format!("gpu-multiline-{label}"));
|
||||||
|
define_multiline_probe(&s, "zzprobe", description);
|
||||||
|
let rows = mx_rows(&s);
|
||||||
|
let probe = rows
|
||||||
|
.iter()
|
||||||
|
.find(|row| row.label == "zzprobe")
|
||||||
|
.unwrap_or_else(|| panic!("{label}: the probe command is a candidate"));
|
||||||
|
let detail = probe
|
||||||
|
.detail
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_else(|| panic!("{label}: the row carries a detail"));
|
||||||
|
assert_eq!(
|
||||||
|
detail, "Greet someone.",
|
||||||
|
"{label}: the wire row carries the first line only"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!detail.contains(['\n', '\r']),
|
||||||
|
"{label}: a row detail must carry no line break: {detail:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!detail.contains(tail),
|
||||||
|
"{label}: the schema block must not reach the dropdown"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the full text is still there for the discoverability
|
||||||
|
// path, which is what makes this a rendering decision.
|
||||||
|
let full: String = eval(&s, "return pmacs.describe.command('zzprobe').description");
|
||||||
|
assert!(
|
||||||
|
full.contains("name (string, required)"),
|
||||||
|
"{label}: describe-command must still report every line: {full:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_single_line_description_is_unchanged_on_the_wire() {
|
||||||
|
// The clip did not tighten past its purpose: a description with no
|
||||||
|
// break reaches the row byte-identical, with no truncation marker.
|
||||||
|
let s = session("wire-single-line");
|
||||||
|
define_probe(&s);
|
||||||
|
let rows = mx_rows(&s);
|
||||||
|
let probe = rows
|
||||||
|
.iter()
|
||||||
|
.find(|row| row.label == "zzprobe")
|
||||||
|
.expect("the probe command is a candidate");
|
||||||
|
assert_eq!(probe.detail.as_deref(), Some(PROBE_DESCRIPTION));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typed_but_unmatched_input_is_still_accepted() {
|
||||||
|
// Q#D2-5, the trap this lane arrives with: richer rows make `M-x`
|
||||||
|
// LOOK like a closed set, which invites making acceptance reject
|
||||||
|
// unmatched input. That would be a behaviour change, and it is out
|
||||||
|
// of scope. `resolve_accepted_value` still returns the literal typed
|
||||||
|
// text when nothing is selected.
|
||||||
|
let s = session("open-set");
|
||||||
|
exec(
|
||||||
|
&s,
|
||||||
|
"_G.ACCEPTED = nil
|
||||||
|
pmacs.minibuffer.read{ prompt = 'M-x ', source = 'commands',
|
||||||
|
on_accept = function(v) _G.ACCEPTED = v end }",
|
||||||
|
);
|
||||||
|
exec(
|
||||||
|
&s,
|
||||||
|
"pmacs.minibuffer.set_contents('no-such-command-at-all')",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
eval::<usize>(&s, "return #pmacs.minibuffer.candidates()"),
|
||||||
|
0,
|
||||||
|
"the probe input must match nothing, or this asserts the wrong thing"
|
||||||
|
);
|
||||||
|
exec(&s, "pmacs.minibuffer.accept()");
|
||||||
|
assert_eq!(
|
||||||
|
eval::<String>(&s, "return _G.ACCEPTED"),
|
||||||
|
"no-such-command-at-all",
|
||||||
|
"completion is assistance, not validation"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The wire half: one real daemon, two negotiated versions (§6)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// An `init.lua` that registers the probe command whose description the
|
||||||
|
/// wire must carry.
|
||||||
|
#[cfg(feature = "crdt")]
|
||||||
|
const PROBE_INIT: &str = r#"
|
||||||
|
pmacs.command.define {
|
||||||
|
name = "zzprobe",
|
||||||
|
description = "Probe the description row.",
|
||||||
|
fn = function() end,
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// A minibuffer message, in whichever family it arrived.
|
||||||
|
#[cfg(feature = "crdt")]
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum Mb {
|
||||||
|
Legacy {
|
||||||
|
prompt: Option<String>,
|
||||||
|
candidates: Vec<String>,
|
||||||
|
},
|
||||||
|
Rows {
|
||||||
|
prompt: Option<String>,
|
||||||
|
rows: Vec<MinibufferRow>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "crdt")]
|
||||||
|
fn semantic_caps() -> FrontendCapabilities {
|
||||||
|
FrontendCapabilities {
|
||||||
|
multi_frontend: true,
|
||||||
|
crdt_replica: true,
|
||||||
|
semantic_render: true,
|
||||||
|
..build_default_caps()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attach a semantic session offering exactly `offer`, declare a
|
||||||
|
/// viewport so the projection producer is live, and hand back the
|
||||||
|
/// stream plus this session's frontend id.
|
||||||
|
#[cfg(feature = "crdt")]
|
||||||
|
fn attach_semantic(daemon: &TestDaemon, offer: u32) -> (UnixStream, pmacs_protocol::FrontendId) {
|
||||||
|
let mut stream = daemon.connect();
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(10)))
|
||||||
|
.expect("set read timeout");
|
||||||
|
let hello: Hello = read_message(&mut stream).expect("read daemon Hello");
|
||||||
|
assert_eq!(
|
||||||
|
hello.protocol_version, ADVERTISED_PROTOCOL_VERSION,
|
||||||
|
"the server-first Hello must stay at the compatibility baseline"
|
||||||
|
);
|
||||||
|
let fid = hello.assigned_frontend_id;
|
||||||
|
write_message(
|
||||||
|
&mut stream,
|
||||||
|
&AttachRequest {
|
||||||
|
protocol_version: offer,
|
||||||
|
frontend_capabilities: semantic_caps(),
|
||||||
|
initial_size: CellSize::new(24, 80),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("write AttachRequest");
|
||||||
|
// A v20-or-later semantic session sends the bootstrap envelope; the
|
||||||
|
// daemon reads it unconditionally for those, so skipping it would
|
||||||
|
// desynchronize the stream.
|
||||||
|
if offer >= 20 {
|
||||||
|
write_message(
|
||||||
|
&mut stream,
|
||||||
|
&SessionBootstrapRequest {
|
||||||
|
initial_target: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("write bootstrap");
|
||||||
|
}
|
||||||
|
let document = pump(&mut stream, "first BufferSnapshot", |msg| match msg {
|
||||||
|
InstanceMessage::BufferSnapshot { buffer_id, .. } => Some(*buffer_id),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
write_message(
|
||||||
|
&mut stream,
|
||||||
|
&FrontendEvent::Viewport {
|
||||||
|
frontend_id: fid,
|
||||||
|
buffer_id: document,
|
||||||
|
visible: ByteRange { start: 0, end: 0 },
|
||||||
|
generation: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("declare a viewport");
|
||||||
|
(stream, fid)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "crdt")]
|
||||||
|
fn pump<T>(
|
||||||
|
stream: &mut UnixStream,
|
||||||
|
what: &str,
|
||||||
|
mut want: impl FnMut(&InstanceMessage) -> Option<T>,
|
||||||
|
) -> T {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(20);
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
match read_message::<InstanceMessage>(stream) {
|
||||||
|
Ok(msg) => {
|
||||||
|
if let Some(found) = want(&msg) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => panic!("{what}: read stopped: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("timed out waiting for {what}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect every minibuffer message this session receives, up to and
|
||||||
|
/// including the first one `done` accepts.
|
||||||
|
///
|
||||||
|
/// Collecting rather than filtering is the point: "a v23 peer receives
|
||||||
|
/// the rows form" is only half the guarantee, and the other half — that
|
||||||
|
/// it never receives the legacy form — can only be checked against
|
||||||
|
/// everything that arrived.
|
||||||
|
#[cfg(feature = "crdt")]
|
||||||
|
fn collect_minibuffer(
|
||||||
|
stream: &mut UnixStream,
|
||||||
|
what: &str,
|
||||||
|
mut done: impl FnMut(&Mb) -> bool,
|
||||||
|
) -> Vec<Mb> {
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(20);
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
match read_message::<InstanceMessage>(stream) {
|
||||||
|
Ok(InstanceMessage::MinibufferPrompt {
|
||||||
|
prompt, candidates, ..
|
||||||
|
}) => {
|
||||||
|
seen.push(Mb::Legacy { prompt, candidates });
|
||||||
|
}
|
||||||
|
Ok(InstanceMessage::MinibufferPromptRows { prompt, rows, .. }) => {
|
||||||
|
seen.push(Mb::Rows { prompt, rows });
|
||||||
|
}
|
||||||
|
Ok(_) => continue,
|
||||||
|
Err(error) => panic!("{what}: read stopped: {error}"),
|
||||||
|
}
|
||||||
|
if done(seen.last().expect("just pushed")) {
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("timed out waiting for {what}; saw {seen:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "crdt")]
|
||||||
|
fn send_key(stream: &mut UnixStream, fid: pmacs_protocol::FrontendId, key: Key, mods: Modifiers) {
|
||||||
|
write_message(
|
||||||
|
stream,
|
||||||
|
&FrontendEvent::Key(KeyEvent {
|
||||||
|
frontend_id: fid,
|
||||||
|
key,
|
||||||
|
mods,
|
||||||
|
timestamp_ns: 0,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("write key");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole exclusivity guarantee, on one live daemon: a v22 peer and a
|
||||||
|
/// v23 peer attached **simultaneously** each receive their own variant
|
||||||
|
/// and only their own — open and close alike.
|
||||||
|
///
|
||||||
|
/// One daemon rather than two, and both directions in one fixture. Two
|
||||||
|
/// daemons could each pass their own half while the same build was
|
||||||
|
/// incapable of serving both, which is the only property that matters;
|
||||||
|
/// and a test that only proved "v23 gets rows" would pass with the
|
||||||
|
/// compatibility half broken.
|
||||||
|
#[cfg(feature = "crdt")]
|
||||||
|
#[test]
|
||||||
|
fn one_daemon_serves_a_v23_rows_session_and_a_frozen_v22_session() {
|
||||||
|
let daemon = TestDaemon::spawn_with_config(PROBE_INIT);
|
||||||
|
|
||||||
|
// The compatibility half attaches FIRST, deliberately: it is the
|
||||||
|
// half an over-eager bump destroys, so a regression fails here
|
||||||
|
// rather than after the interesting half has already passed.
|
||||||
|
let (mut legacy, _legacy_fid) = attach_semantic(&daemon, 22);
|
||||||
|
let (mut current, current_fid) = attach_semantic(&daemon, PROTOCOL_VERSION);
|
||||||
|
assert_eq!(PROTOCOL_VERSION, 23);
|
||||||
|
|
||||||
|
// Open the real `M-x` through the real key path, then narrow to the
|
||||||
|
// probe command by typing it — the candidate window is ten rows out
|
||||||
|
// of well over a hundred commands, so an unnarrowed prompt would
|
||||||
|
// assert nothing about the probe.
|
||||||
|
send_key(&mut current, current_fid, Key::Char('x'), Modifiers::ALT);
|
||||||
|
for ch in "zzprobe".chars() {
|
||||||
|
send_key(&mut current, current_fid, Key::Char(ch), Modifiers::NONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
let on_current = collect_minibuffer(&mut current, "v23 open", |mb| match mb {
|
||||||
|
Mb::Rows { prompt, rows } => {
|
||||||
|
prompt.is_some() && rows.iter().any(|row| row.label == "zzprobe")
|
||||||
|
}
|
||||||
|
Mb::Legacy { .. } => false,
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
on_current.iter().all(|mb| matches!(mb, Mb::Rows { .. })),
|
||||||
|
"a v23 peer must never receive the frozen legacy variant: {on_current:?}"
|
||||||
|
);
|
||||||
|
let Some(Mb::Rows { rows, .. }) = on_current.last() else {
|
||||||
|
unreachable!("collect_minibuffer returns on a Rows match")
|
||||||
|
};
|
||||||
|
let probe = rows
|
||||||
|
.iter()
|
||||||
|
.find(|row| row.label == "zzprobe")
|
||||||
|
.expect("the probe command is a candidate");
|
||||||
|
assert_eq!(
|
||||||
|
probe.detail.as_deref(),
|
||||||
|
Some(PROBE_DESCRIPTION),
|
||||||
|
"the description reaches the row through the real prompt path"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The same session state, seen by the v22 peer, in the frozen shape.
|
||||||
|
let on_legacy = collect_minibuffer(&mut legacy, "v22 open", |mb| match mb {
|
||||||
|
Mb::Legacy { prompt, candidates } => {
|
||||||
|
prompt.is_some() && candidates.iter().any(|c| c == "zzprobe")
|
||||||
|
}
|
||||||
|
Mb::Rows { .. } => false,
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
on_legacy.iter().all(|mb| matches!(mb, Mb::Legacy { .. })),
|
||||||
|
"a v22 peer must never receive the v23 rows variant: {on_legacy:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The close must arrive in the SAME family as the open. A rows
|
||||||
|
// session closed by a legacy clear leaves the dropdown on screen
|
||||||
|
// forever, and the witness for "it actually cleared" is a `prompt:
|
||||||
|
// None` in the family the frontend is mirroring.
|
||||||
|
send_key(&mut current, current_fid, Key::Escape, Modifiers::NONE);
|
||||||
|
let closed_current = collect_minibuffer(&mut current, "v23 close", |mb| {
|
||||||
|
matches!(mb, Mb::Rows { prompt: None, .. })
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
closed_current
|
||||||
|
.iter()
|
||||||
|
.all(|mb| matches!(mb, Mb::Rows { .. })),
|
||||||
|
"the v23 close must not arrive as a legacy clear: {closed_current:?}"
|
||||||
|
);
|
||||||
|
let closed_legacy = collect_minibuffer(&mut legacy, "v22 close", |mb| {
|
||||||
|
matches!(mb, Mb::Legacy { prompt: None, .. })
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
closed_legacy
|
||||||
|
.iter()
|
||||||
|
.all(|mb| matches!(mb, Mb::Legacy { .. })),
|
||||||
|
"the v22 close must stay in the frozen family: {closed_legacy:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -789,7 +789,8 @@ fn a13_17_26_protocol_semantic_init_late_join_and_version_cost() {
|
||||||
// Vterm Stage 3 appended the terminal family as v19; GPU initial targets
|
// Vterm Stage 3 appended the terminal family as v19; GPU initial targets
|
||||||
// appended the semantic bootstrap family as v20; bottom-panel Stage 2B-1
|
// appended the semantic bootstrap family as v20; bottom-panel Stage 2B-1
|
||||||
// appended the panel family as v21; long-lines Stage 3 appended
|
// appended the panel family as v21; long-lines Stage 3 appended
|
||||||
// `LineWrapFacts` as v22. This acceptance owns the STATUSLINE
|
// `LineWrapFacts` as v22; Discovery Stage 2 appended
|
||||||
|
// `MinibufferPromptRows` as v23. This acceptance owns the STATUSLINE
|
||||||
// variant's placement and gate, so it tracks the current wire version
|
// variant's placement and gate, so it tracks the current wire version
|
||||||
// rather than pinning 18: the v18 floor it actually cares about is asserted
|
// rather than pinning 18: the v18 floor it actually cares about is asserted
|
||||||
// below and in `peer_accepts_statusline_message`.
|
// below and in `peer_accepts_statusline_message`.
|
||||||
|
|
@ -798,11 +799,11 @@ fn a13_17_26_protocol_semantic_init_late_join_and_version_cost() {
|
||||||
// three lines on purpose. The ceiling assertion is the load-bearing
|
// three lines on purpose. The ceiling assertion is the load-bearing
|
||||||
// one — it says the supported set ENDS here, which is what makes an
|
// one — it says the supported set ENDS here, which is what makes an
|
||||||
// accidentally-widened set a failure rather than a silent pass.
|
// accidentally-widened set a failure rather than a silent pass.
|
||||||
assert_eq!(PROTOCOL_VERSION, 22);
|
assert_eq!(PROTOCOL_VERSION, 23);
|
||||||
for version in 6..=22 {
|
for version in 6..=23 {
|
||||||
assert!(is_supported_protocol_version(version));
|
assert!(is_supported_protocol_version(version));
|
||||||
}
|
}
|
||||||
assert!(!is_supported_protocol_version(23));
|
assert!(!is_supported_protocol_version(24));
|
||||||
let sample = InstanceMessage::StatuslineSegments {
|
let sample = InstanceMessage::StatuslineSegments {
|
||||||
buffer_id: BufferId::from_raw(9),
|
buffer_id: BufferId::from_raw(9),
|
||||||
left: vec![StatuslineSegment {
|
left: vec![StatuslineSegment {
|
||||||
|
|
|
||||||
|
|
@ -888,9 +888,10 @@ fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() {
|
||||||
panic!("timed out waiting for {what}");
|
panic!("timed out waiting for {what}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tripwire: a wire bump must be a conscious edit here. v22 is
|
// Tripwire: a wire bump must be a conscious edit here. v23 is
|
||||||
|
// `MinibufferPromptRows` (Discovery Stage 2); v22 was
|
||||||
// `LineWrapFacts` (long-lines Stage 3).
|
// `LineWrapFacts` (long-lines Stage 3).
|
||||||
assert_eq!(PROTOCOL_VERSION, 22);
|
assert_eq!(PROTOCOL_VERSION, 23);
|
||||||
let daemon = common::daemon::TestDaemon::spawn_with_env_and_init(
|
let daemon = common::daemon::TestDaemon::spawn_with_env_and_init(
|
||||||
&[
|
&[
|
||||||
("PMACS_INSTANCE_SEMANTIC_RENDER", "1"),
|
("PMACS_INSTANCE_SEMANTIC_RENDER", "1"),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue