From 7da2f34b9449f4d2ef955048b3a53d5e801a21db Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 15 Aug 2026 18:42:53 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- src/semantic_render.rs | 43 +++++++++++++++++-- .../bottom_panel_stage2b_daemon_acceptance.rs | 22 ++++++++-- 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/src/semantic_render.rs b/src/semantic_render.rs index ee1fe66..5df199b 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -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(()) => { diff --git a/tests/bottom_panel_stage2b_daemon_acceptance.rs b/tests/bottom_panel_stage2b_daemon_acceptance.rs index 5a09801..880ed25 100644 --- a/tests/bottom_panel_stage2b_daemon_acceptance.rs +++ b/tests/bottom_panel_stage2b_daemon_acceptance.rs @@ -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={}",