feat(panel): outbound family selection (G6a/G7a half)

The producer half of bilateral gating. Inbound routing, the four
refusal quadrants and the positive routing controls are the next
commit; the full eleven-stage `--protocol` gate runs at that
checkpoint, once all of them discriminate.

`SemanticRenderState` gains `peer_knows_mapped_panel`, set in
`for_peer` beside the six existing capability bools --- the pattern
this codebase already uses to bake a negotiated version into the
producer. A `>= v25` peer receives `PresentMapped`; every older peer
receives legacy `Present`, unchanged.

**THE ORDERING IS LOAD-BEARING AND IS WRITTEN DOWN AT THE SITE.**
Projection first, THEN capture-and-advance the mapping key, THEN
construct the payload. A terminal projection registers the view whose
scroll anchor the key reads, so capturing earlier stamps a frame with a
key derived from an anchor that does not yet exist.

One case is deliberately not a fallback: if a `>= v25` peer has no
presentable mapping to stamp, the producer publishes `Absent` rather
than dropping to legacy `Present`. Falling back would hand a peer the
family it did not negotiate --- the bypass the gate exists to close,
arriving from the daemon's own side.

**Thirty existing rows broke, and that is the gate working.** The
fixture negotiates `PROTOCOL_VERSION`, so every panel test suddenly
received the mapped family where it matched `Present` literally. Those
rows are about the PROJECTION, not the wrapper, so the fixture helpers
are family-agnostic now and which family carries a frame is pinned by
the G6/G7/G8 rows rather than incidentally by thirty others.

Verified: focused suite 37/37, `cargo test --lib` 1945 green, clippy
clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-15 18:42:53 +02:00
parent 921989a77c
commit 7da2f34b94
No known key found for this signature in database
2 changed files with 57 additions and 8 deletions

View File

@ -254,6 +254,15 @@ pub struct SemanticRenderState {
/// 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,
/// §5b — whether the peer negotiated `>= v25` and therefore takes
/// the **mapped** panel family.
///
/// The families are exclusive in both directions: a `>= v25` peer
/// receives `PresentMapped` and never legacy `Present`, and a
/// `<= v24` peer the reverse. "Send whichever and let the receiver
/// cope" would make negotiation a sender convention rather than a
/// gate.
peer_knows_mapped_panel: bool,
/// Last emitted `CompletionPopup` payload per buffer (Arc 1a
/// Q#C5), for cached-compare suppression (see
/// [`CompletionPopupFacts`]).
@ -536,6 +545,8 @@ impl SemanticRenderState {
s.peer_knows_statusline_segments = negotiated_protocol_version >= 18;
s.peer_knows_terminal_frames = negotiated_protocol_version >= 19;
s.peer_knows_panel_frames = negotiated_protocol_version >= PANEL_MIN_VERSION;
s.peer_knows_mapped_panel =
negotiated_protocol_version >= pmacs_protocol::PANEL_MAPPING_MIN_VERSION;
s
}
@ -556,6 +567,7 @@ impl SemanticRenderState {
last_menu_prompt: HashMap::new(),
last_minibuffer: None,
peer_knows_minibuffer_rows: true,
peer_knows_mapped_panel: false,
last_completion_popup: HashMap::new(),
last_summary: HashMap::new(),
last_status: HashMap::new(),
@ -1436,7 +1448,7 @@ impl SemanticRenderState {
self.publish_absent_panel(out);
return;
};
let payload = PanelFramePayload::Present(PanelFrame {
let frame = PanelFrame {
buffer_id: projection.buffer_id,
panel_epoch,
geometry_epoch: geometry.geometry_epoch,
@ -1444,7 +1456,28 @@ impl SemanticRenderState {
cells: projection.cells,
cursor: projection.cursor,
focused: projection.focused,
});
};
// §5b — ORDER MATTERS. The projection is prepared above, THEN
// the key is captured, THEN the payload is built. A terminal
// projection registers the view whose scroll anchor the key
// reads, so capturing earlier would stamp a frame with a key
// derived from an unregistered anchor.
let payload = if self.peer_knows_mapped_panel {
let snapshot = state.panel_mapping_snapshot(self.frontend_id);
let Some(mapping_generation) = self.panel_mapping_generation(snapshot) else {
// No presentable mapping means nothing to stamp. Falling
// back to the legacy variant here would hand a v25 peer
// the family it did not negotiate.
self.publish_absent_panel(out);
return;
};
PanelFramePayload::PresentMapped {
frame,
mapping_generation,
}
} else {
PanelFramePayload::Present(frame)
};
// Complete-payload comparison FIRST, like the terminal pass: only
// validated payloads are ever stored, so a payload equal to the
// baseline has already passed and re-running the per-cell width
@ -1453,8 +1486,10 @@ impl SemanticRenderState {
self.panel_error_latched = false;
return;
}
let PanelFramePayload::Present(frame) = &payload else {
unreachable!("the Present payload was constructed immediately above");
let (PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. }) =
&payload
else {
unreachable!("a Present payload was constructed immediately above");
};
match frame.validate() {
Ok(()) => {

View File

@ -117,9 +117,16 @@ impl Session {
first
}
/// The frame from whichever Present family this session negotiated.
///
/// Family-agnostic on purpose: these rows are about the PROJECTION,
/// and which wrapper carries it is §5b's own concern, pinned by the
/// G6/G7/G8 rows rather than incidentally by thirty others.
fn present(&mut self) -> PanelFrame {
match self.frame() {
Some(PanelFramePayload::Present(frame)) => frame,
Some(
PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. },
) => frame,
other => panic!("expected a Present panel payload, got {other:?}"),
}
}
@ -522,7 +529,10 @@ fn acc41_degenerate_geometry_fails_closed_to_zero_usable_grid() {
session.declare(1, ROWS, COLS);
open_panel(&session, "*panel*", 4);
assert!(
matches!(session.frame(), Some(PanelFramePayload::Present(_))),
matches!(
session.frame(),
Some(PanelFramePayload::Present(_) | PanelFramePayload::PresentMapped { .. })
),
"{label}: fixture precondition — a band was visible first"
);
@ -898,7 +908,9 @@ fn acc45_one_statusline_invocation_serves_the_document_and_the_panel() {
let panel_rows = messages
.iter()
.find_map(|message| match message {
InstanceMessage::PanelFrame(PanelFramePayload::Present(frame)) => Some(rows_of(frame)),
InstanceMessage::PanelFrame(
PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. },
) => Some(rows_of(frame)),
_ => None,
})
.expect("a panel frame");
@ -1327,7 +1339,9 @@ fn sweep_a_panel_wider_than_the_terminal_cap_still_presents_its_terminal() {
session.state.core.borrow_mut().focus_window(FID, panel);
let frame = match session.frame() {
Some(PanelFramePayload::Present(frame)) => frame,
Some(
PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. },
) => frame,
other => panic!(
"a legally wide panel must still present its terminal; got {other:?} \
— and the durable state says hidden={}",