From 81f54e23a93018b8e945df1dba453abe35168673 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 17:06:36 -0400 Subject: [PATCH 1/7] Add the frame-geometry epoch machine and the panel projection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bottom-panel Stage 2B-2, first half: the daemon-side primitives the panel producer needs. `GeometryUpdate` is three-valued rather than a boolean because the caller must act differently on each arm. `declare_frame_geometry` stays the grid/LOCAL allocator, keeps value dedup, and moves from `saturating_add` to checked allocation with a fail-closed exhaustion arm: it clears the declaration back to unknown, which is already non-presentable, so reconciliation hides the panel rather than painting one sized to a frame that no longer exists. `accept_frame_geometry` is the separate semantic path. No value dedup — a font or scale change can invalidate a panel frame while `CellSize` is identical, which is exactly what daemon-side dedup cannot see (Q#BP2S1) — and a lower epoch is rejected even when it carries identical data. `panel_grid_size` derives Q#BP15a's third geometry: full declared width, `fixed_rows` clamped by the recursive document minimum and then by the shared wire area budget, with the stored request left alone. `prepare_panel_projection` paints the side window through the Stage 2A extracted painter, gating folds on the OWNING frontend rather than `fold_map_for_window`'s active-frontend gate (Q#BP17), and takes the side window's statusline segments as a parameter so one provider invocation serves both surfaces. `window_cursor_cell` is `paint_frame`'s caret derivation lifted out so the band does not become a second, drifting copy of it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- src/editor.rs | 360 +++++++++++++++++++++++++++++++++++++++++---- src/editor_core.rs | 176 ++++++++++++++++++++-- 2 files changed, 498 insertions(+), 38 deletions(-) diff --git a/src/editor.rs b/src/editor.rs index 935ee4c..1118254 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -25,7 +25,7 @@ use unicode_width::UnicodeWidthStr; use crate::async_runtime::SharedAsyncRuntime; use crate::cell::{CellCoord, CellSize}; -use crate::editor_core::EditorCore; +use crate::editor_core::{EditorCore, GeometryUpdate}; use crate::frontend::{Event, Frontend, KeyEvent, KeyEventKind, MouseEvent, install_panic_hook}; use crate::key::{Chord, display_sequence}; use crate::keymap_stack::{Action, KeyDispatcher}; @@ -1196,13 +1196,49 @@ impl EditorState { /// (Q#BP2b / Q#BP15a). /// /// The single seam for grid and `LOCAL` views, whose real attach and - /// resize sizes ARE the declaration. A semantic view never calls this - /// in Stage 1; its geometry stays **unknown**. - pub fn sync_frame_geometry(&self, frontend_id: FrontendId, total: CellSize) { - self.core + /// resize sizes ARE the declaration. A semantic view never calls this; + /// its geometry arrives through + /// [`Self::accept_semantic_frame_geometry`]. + /// + /// Reconciliation runs on every call, not only on + /// [`GeometryUpdate::Advanced`]: panel presentability depends on the + /// layout as well as on the geometry, and this is also the defensive + /// pre-paint reconciliation point. The exhaustion arm is exactly why + /// it must still run after a `Rejected` — `declare_frame_geometry` + /// cleared the declaration to unknown, and the panel has to hide. + pub fn sync_frame_geometry(&self, frontend_id: FrontendId, total: CellSize) -> GeometryUpdate { + let update = self + .core .borrow_mut() .declare_frame_geometry(frontend_id, total); self.reconcile_panel_layout(frontend_id); + update + } + + /// Accept an authenticated semantic frontend's + /// `FrontendEvent::FrontendCellGeometry` declaration (Q#BP15a). + /// + /// The three outcomes are acted on differently, and that is the whole + /// point of the three-valued result: `Advanced` reconciles panel + /// layout, `Duplicate` returns without touching panel state, and + /// `Rejected` drops the event before any reconciliation. A + /// `Duplicate` that reconciled would do redundant work on every + /// repeated declaration; a `Rejected` that reconciled would let a + /// stale or conflicting declaration move the panel. + pub fn accept_semantic_frame_geometry( + &self, + frontend_id: FrontendId, + geometry_epoch: u64, + total: CellSize, + ) -> GeometryUpdate { + let update = + self.core + .borrow_mut() + .accept_frame_geometry(frontend_id, geometry_epoch, total); + if update == GeometryUpdate::Advanced { + self.reconcile_panel_layout(frontend_id); + } + update } /// Local-frontend compatibility wrapper. @@ -1875,6 +1911,231 @@ impl EditorState { true } + /// Paint one semantic frontend's side window into a panel-sized grid + /// (Q#BP8, Q#BP15, Q#BP15a, Q#BP17). + /// + /// Returns `None` for every non-presentable state — no side window, + /// a hidden panel, unknown geometry, a zero-column frame, or a grid + /// too small for the structural floor. The caller turns that into an + /// **authoritative** + /// [`pmacs_protocol::panel::PanelFramePayload::Absent`]: silence + /// would leave the receiver's retained band on screen forever. + /// + /// `statusline` is the side window's evaluated segments, supplied by + /// the caller from the *same* provider invocation that produced the + /// document's wire segments (parent acceptance 45). Evaluating again + /// here would run every provider twice per frame. + /// + /// **Folds are gated on the OWNING frontend (Q#BP17).** The panel is + /// painted for `frontend_id`, which is not necessarily the acting + /// frontend, so `EditorCore::fold_map_for_window` — which gates on + /// the *active* frontend — is the wrong source and is deliberately + /// not called. + #[must_use] + pub fn prepare_panel_projection( + &self, + frontend_id: FrontendId, + statusline: Option<&crate::statusline::StatuslineWindowSegments>, + ) -> Option { + let (size, window_id, buffer_id, focused, fold_projection) = { + let core = self.core.borrow(); + let size = core.panel_grid_size(frontend_id)?; + let window_id = core.side_window_for(frontend_id)?; + let buffer_id = core.windows.get(&window_id)?.buffer_id; + let view = core.views.get(&frontend_id)?; + ( + size, + window_id, + buffer_id, + view.active == window_id, + view.fold_projection, + ) + }; + let outer = Rect::new(0, 0, size.rows, size.cols); + let content = Rect::new(0, 0, size.rows.saturating_sub(1), size.cols); + let placement = WindowPlacement { outer, content }; + let theme = { + let handle = self.syntax_registry.theme(); + let t = handle.lock().expect("theme mutex poisoned"); + t.clone() + }; + let mut cells = vec![crate::cell::Cell::default(); (size.rows * size.cols) as usize]; + let mut grid = crate::cell::CellGrid { + cells: &mut cells, + stride: size.cols, + size, + }; + + // Q#BP7 / Q#BP15a: a terminal panel's grid excludes its one mode + // line, and its geometry reaches the shared screen through the + // same view-size path the grid frontends use — never the 24×80 + // attach placeholder and never the full-window declaration. + let terminal = self.terminal_manager.borrow().is_terminal(buffer_id); + let cursor = if terminal { + let key = TerminalViewKey::new(frontend_id, window_id, buffer_id); + let snapshot = self + .terminal_manager + .borrow_mut() + .snapshot_for_view(key, content.size)?; + paint_terminal_snapshot(&mut grid, content, &snapshot, &theme); + let registry = self.core.borrow().registry.clone(); + let reg = registry.borrow(); + if let Ok(buf) = reg.get(buffer_id) { + let coord = snapshot.cursor.unwrap_or_default(); + let scroll = if snapshot.scroll_offset == 0 { + String::new() + } else { + format!("↑{}", snapshot.scroll_offset) + }; + paint_mode_line( + &mut grid, + &outer, + buf.name(), + false, + focused, + coord.row, + coord.col, + &scroll, + "", + mode_line_style(&theme), + statusline.map_or(&[], |segments| segments.left.as_slice()), + statusline.map_or(&[], |segments| segments.right.as_slice()), + &theme, + ); + } + snapshot + .cursor + .filter(|coord| coord.row < content.size.rows && coord.col < content.size.cols) + } else { + let registry = self.core.borrow().registry.clone(); + let reg = registry.borrow(); + let diag_store = self.lsp_manager.borrow().diag_store(); + let mut core = self.core.borrow_mut(); + let window = core.windows.get_mut(&window_id)?; + let buf = reg.get(buffer_id).ok()?; + let folds = if fold_projection { + crate::fold_view::map_for_window(&self.fold_registry, window) + } else { + None + }; + window.last_visible_rows = content.size.rows; + // A2A-3 / parent 48: the auto-scroll clamp belongs to the + // FOCUSED window only. Running it for a passive panel would + // move a `view_top` the user is not driving. + if focused { + prepare_window_cursor_visible(window, buf, content.size.rows, folds.as_ref()); + } + paint_window_content( + &mut grid, + window, + buf, + placement, + folds.as_ref(), + focused, + &theme, + statusline, + &diag_store, + ); + window_cursor_cell(window, buf, folds.as_ref(), outer) + }; + + Some(PanelProjection { + window_id, + buffer_id, + size, + cells, + cursor, + focused, + }) + } + + /// Apply an accepted `FrontendEvent::PanelResizeRows` (Q#BP15a). + /// + /// The request is expressed as a boundary move rather than a direct + /// `fixed_rows` write, so it lands on the same Q#BP5b clamp path a + /// TUI divider drag takes — including the interactive + /// `window.min-height` preference resolved per leaf. A request the + /// clamp cannot satisfy is a no-op, not an error. + /// + /// Returns whether the effective allocation actually moved. + pub fn apply_panel_resize_rows(&self, frontend_id: FrontendId, rows: u32) -> bool { + let (side, area_rows, current) = { + let core = self.core.borrow(); + match ( + core.side_window_for(frontend_id), + core.frontend_area_rows(frontend_id), + ) { + (Some(side), Some(area_rows)) => ( + side, + area_rows, + core.panel_allocation(frontend_id, area_rows), + ), + _ => return false, + } + }; + let Some(current) = current else { + return false; + }; + let Ok(rows) = EditorCore::clamp_panel_rows(rows) else { + return false; + }; + let Ok(delta) = i32::try_from(i64::from(rows) - i64::from(current)) else { + return false; + }; + if delta == 0 { + return false; + } + let _ = self.resize_window_boundary(frontend_id, side, delta, area_rows); + self.reconcile_panel_layout(frontend_id); + self.core + .borrow() + .panel_allocation(frontend_id, area_rows) + .is_some_and(|now| now != current) + } + + /// Apply an accepted `FrontendEvent::PanelPointer` gesture (Q#BP16). + /// + /// Steps 2, 5, and 6 of Q#BP16's ladder are re-derived here from the + /// daemon's own state — a live, non-hidden side window whose current + /// buffer matches the payload, and a coordinate inside the grid the + /// daemon derived. Steps 1, 3, and 4 (source authentication and both + /// epochs) belong to the caller, because only the session holds the + /// declaration the frontend was actually looking at. + /// + /// **Click-to-focus only in Stage 2B-2.** A `Down`/`Up`/wheel/context + /// gesture activates the panel; replaying it into selection, listview + /// rows, or child SGR reporting is parent acceptance 48, which needs + /// the GPU band and lands in Stage 2B-3. Bare hover neither focuses + /// nor claims anything, exactly as on the document terminal path. + /// + /// Returns whether the gesture was accepted. + pub fn dispatch_semantic_panel_pointer( + &self, + frontend_id: FrontendId, + buffer_id: crate::buffer::BufferId, + coord: CellCoord, + kind: pmacs_protocol::MouseKind, + ) -> bool { + let Some(size) = self.core.borrow().panel_grid_size(frontend_id) else { + return false; + }; + if coord.row >= size.rows || coord.col >= size.cols { + return false; + } + let mut core = self.core.borrow_mut(); + let Some(side) = core.side_window_for(frontend_id) else { + return false; + }; + if core.windows.get(&side).map(|window| window.buffer_id) != Some(buffer_id) { + return false; + } + if !matches!(kind, pmacs_protocol::MouseKind::Move) { + core.focus_window(frontend_id, side); + core.active_frontend = frontend_id; + } + true + } + /// Precompute owned terminal view snapshots before entering paint borrows. pub fn prepare_terminal_views( &mut self, @@ -3085,6 +3346,29 @@ const SCROLL_LINES: i32 = 3; /// therefore only appears when a line-number mode reserves a gutter. const FOLD_GUTTER_GLYPH: char = '▸'; +/// One painted side window, ready to become a +/// [`pmacs_protocol::panel::PanelFrame`] (bottom-panel Stage 2B-2). +/// +/// The producer carries the identity fields as well as the cells because +/// the presentation epoch is allocated from them: `window_id` changes on +/// a new side window and `buffer_id` on a replacement, and either one +/// moving is what makes a stale `PanelPointer` unaddressable (Q#BP16). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PanelProjection { + /// The side window this frame projects. + pub window_id: WindowId, + /// Buffer that window is currently showing. + pub buffer_id: crate::buffer::BufferId, + /// Panel grid dimensions, mode line included. + pub size: CellSize, + /// Row-major cells; exactly `size.area()` entries. + pub cells: Vec, + /// Panel caret, or `None` when it is scrolled out of the band. + pub cursor: Option, + /// Whether the panel currently owns this frontend's focus. + pub focused: bool, +} + /// Shared outer/content geometry consumed by terminal paint and PTY resize. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct WindowPlacement { @@ -3943,44 +4227,64 @@ pub fn paint_frame( let registry = core.registry.clone(); let reg = registry.borrow(); let aw = &core.windows[&active]; - let inner_rows = inner_rows(&active_rect); let buf = reg.get(aw.buffer_id).ok()?; - // Arc 6 Stage 2 (Q#FD16, round-2 F3): a logical cursor on a hidden - // line renders at its hidden component's head POSITION — the visible - // head row *and* that head's end-of-content column, i.e. exactly - // where Stage 1 moves point on a fold-at-cursor. Row-only clamping - // would leave the column unspecified; resolving through the merged - // component (rather than the innermost containing fold) also keeps a - // crossing overlap from landing on another hidden position. let folds = crate::fold_view::map_for_window(&state.fold_registry, aw); - let cursor = match folds.as_ref() { - Some(map) => map.visible_position(aw.text_view.line_at_offset(aw.cursor), aw.cursor), - None => aw.cursor, - }; - let disp = aw.text_view.pos_to_display(buf, cursor)?; - let row_offset = match folds.as_ref() { + window_cursor_cell(aw, buf, folds.as_ref(), active_rect) +} + +/// Where one window's caret lands in the cell grid, or `None` when it is +/// scrolled out of that window's text area. +/// +/// Extracted from `paint_frame`'s tail for bottom-panel Stage 2B-2: the +/// panel band ships its own caret in +/// [`pmacs_protocol::panel::PanelFrame::cursor`], and a second derivation +/// would be the exact shape of Stage 1's `Layout::compute` two-caller +/// defect — one consumer silently reckoning against different geometry. +/// +/// Arc 6 Stage 2 (Q#FD16, round-2 F3): a logical cursor on a hidden line +/// renders at its hidden component's head POSITION — the visible head row +/// *and* that head's end-of-content column, i.e. exactly where Stage 1 +/// moves point on a fold-at-cursor. Row-only clamping would leave the +/// column unspecified; resolving through the merged component (rather +/// than the innermost containing fold) also keeps a crossing overlap from +/// landing on another hidden position. +fn window_cursor_cell( + window: &crate::window::Window, + buf: &crate::buffer::Buffer, + folds: Option<&crate::fold_view::VisibleLineMap>, + rect: Rect, +) -> Option { + let inner_rows = inner_rows(&rect); + let cursor = match folds { Some(map) => { - let top = map.clamp_view_top(aw.view_top); + map.visible_position(window.text_view.line_at_offset(window.cursor), window.cursor) + } + None => window.cursor, + }; + let disp = window.text_view.pos_to_display(buf, cursor)?; + let row_offset = match folds { + Some(map) => { + let top = map.clamp_view_top(window.view_top); let row = disp.row as usize; if row < top { return None; } map.visible_rows_between(top, row) } - None => (disp.row as usize).checked_sub(aw.view_top)?, + None => (disp.row as usize).checked_sub(window.view_top)?, }; if row_offset >= inner_rows as usize { return None; } - // UX gutter: the terminal caret sits in the text area, past the - // reserved gutter strip (mirrors the viewport shift above). + // UX gutter: the caret sits in the text area, past the reserved + // gutter strip (mirrors the viewport shift in `paint_window_content`). let gutter_w = { - let w = aw.gutter_width(); - if w >= active_rect.size.cols { 0 } else { w } + let w = window.gutter_width(); + if w >= rect.size.cols { 0 } else { w } }; - let grid_row = active_rect.origin.row + u32::try_from(row_offset).ok()?; - let max_col = active_rect.origin.col + active_rect.size.cols.saturating_sub(1); - let grid_col = (active_rect.origin.col + gutter_w + disp.col).min(max_col); + let grid_row = rect.origin.row + u32::try_from(row_offset).ok()?; + let max_col = rect.origin.col + rect.size.cols.saturating_sub(1); + let grid_col = (rect.origin.col + gutter_w + disp.col).min(max_col); Some(CellCoord::new(grid_row, grid_col)) } diff --git a/src/editor_core.rs b/src/editor_core.rs index 7fd90c6..bd80a3f 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -236,6 +236,28 @@ pub struct PanelReconciliation { pub released_terminal: Option, } +/// What a frame-geometry declaration did (Q#BP2S1, Stage 2 §3.1). +/// +/// Three-valued rather than a boolean because the caller must act +/// differently on each, and collapsing the middle arm is a defect in one +/// direction or the other: folded into `Advanced` it reconciles panel +/// layout on every repeated declaration; folded into `Rejected` it +/// reports a stale-event condition that never happened. A `Duplicate` +/// **is** accepted — which is why a narrower internal boolean would have +/// to be named `advanced`, never `accepted`. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum GeometryUpdate { + /// The epoch advanced and the declaration was stored verbatim. Run + /// panel reconciliation. + Advanced, + /// Same epoch, same total: already current. Do no work. + Duplicate, + /// Same epoch with a different total, a lower epoch, the reserved + /// epoch `0`, an unknown frontend, or allocator exhaustion. Drop the + /// event before any reconciliation. + Rejected, +} + /// Row extent of an arbitrary subtree, derived from its leaves' computed /// rects: leaves tile their parent, so the union's height is the node's. fn node_row_extent(node: &LayoutNode, placements: &HashMap) -> u32 { @@ -3240,29 +3262,163 @@ impl EditorCore { (geometry.total.rows >= 2 && geometry.total.cols > 0).then(|| geometry.total.rows - 1) } - /// Cache a frontend's authoritative frame capacity (Q#BP2b). + /// A frontend's current authoritative frame-geometry declaration. + /// + /// The panel producer echoes `geometry_epoch` into every + /// [`pmacs_protocol::panel::PanelFrame`] it ships, and the daemon + /// compares an inbound panel event's epoch against it (Q#BP16 step + /// 3), so the epoch has to be readable, not only the size. + #[must_use] + pub fn frame_geometry_for( + &self, + fid: FrontendId, + ) -> Option { + self.views.get(&fid)?.frame_geometry + } + + /// Cache a frontend's authoritative frame capacity — the **grid / + /// `LOCAL`** allocator (Q#BP2b, Stage 2 §3.1). /// /// Grid / `LOCAL` views call this from their real attach and resize - /// sizes with an internally minted epoch; a semantic view stays - /// `None` until Stage 2's authenticated declaration. A repeated - /// identical size is not a new declaration. - pub fn declare_frame_geometry(&mut self, fid: FrontendId, total: crate::cell::CellSize) { + /// sizes with an internally minted epoch; a semantic view never takes + /// this path at all — it goes through + /// [`Self::accept_frame_geometry`], which applies the frontend-owned + /// epoch verbatim and does **no** value dedup. + /// + /// Value dedup is correct *here* and only here: a grid frontend's + /// cells are the unit it declares, so an unchanged grid under + /// unchanged metrics leaves any existing frame valid. It is wrong on + /// the semantic path, where a font or scale change can invalidate a + /// panel frame while [`crate::cell::CellSize`] is identical — the + /// case daemon-side dedup cannot see (Q#BP2S1). + /// + /// **Exhaustion fails closed.** Allocation is checked rather than + /// saturating: `saturating_add` pins at `u64::MAX`, after which two + /// different geometries share one declaration id. On exhaustion the + /// authoritative declaration is *cleared* to `None` (unknown), which + /// is already non-presentable under Q#BP2b, so the caller's + /// reconciliation hides the panel. Retaining the last valid geometry + /// would keep painting a panel sized to a frame that no longer + /// exists. + pub fn declare_frame_geometry( + &mut self, + fid: FrontendId, + total: crate::cell::CellSize, + ) -> GeometryUpdate { let Some(view) = self.views.get_mut(&fid) else { - return; + return GeometryUpdate::Rejected; }; if view .frame_geometry .is_some_and(|geometry| geometry.total == total) { - return; + return GeometryUpdate::Duplicate; } - let next = view - .frame_geometry - .map_or(1, |geometry| geometry.geometry_epoch.saturating_add(1)); + let next = match view.frame_geometry { + None => Some(1), + Some(geometry) => geometry.geometry_epoch.checked_add(1), + }; + let Some(next) = next else { + view.frame_geometry = None; + return GeometryUpdate::Rejected; + }; view.frame_geometry = Some(crate::window::DeclaredFrameGeometry { geometry_epoch: next, total, }); + GeometryUpdate::Advanced + } + + /// Accept a **semantic** frontend's authoritative geometry + /// declaration (Q#BP15a, Stage 2 §3.1). + /// + /// Deliberately a second method rather than + /// [`Self::declare_frame_geometry`] with an optional epoch: the two + /// regimes differ in whether value dedup applies, and one ambiguous + /// entry point would let a future caller silently take the wrong one. + /// + /// | Incoming declaration | Result | + /// | --- | --- | + /// | epoch **greater** than stored | [`GeometryUpdate::Advanced`], stored **verbatim**, even when `total` is unchanged | + /// | same epoch, same `total` | [`GeometryUpdate::Duplicate`] | + /// | same epoch, **different** `total` | [`GeometryUpdate::Rejected`] | + /// | **lower** epoch, any `total` | [`GeometryUpdate::Rejected`] | + /// + /// The last row is not an optimization: a lower epoch carrying + /// *identical* data is still stale, and accepting it would let a + /// reordered declaration resurrect geometry the frontend has moved + /// past. + /// + /// Epoch `0` is reserved for "never declared" and is rejected on the + /// wire. + pub fn accept_frame_geometry( + &mut self, + fid: FrontendId, + geometry_epoch: u64, + total: crate::cell::CellSize, + ) -> GeometryUpdate { + if geometry_epoch == 0 { + return GeometryUpdate::Rejected; + } + let Some(view) = self.views.get_mut(&fid) else { + return GeometryUpdate::Rejected; + }; + match view.frame_geometry { + Some(stored) if geometry_epoch < stored.geometry_epoch => GeometryUpdate::Rejected, + Some(stored) if geometry_epoch == stored.geometry_epoch => { + if stored.total == total { + GeometryUpdate::Duplicate + } else { + GeometryUpdate::Rejected + } + } + _ => { + view.frame_geometry = Some(crate::window::DeclaredFrameGeometry { + geometry_epoch, + total, + }); + GeometryUpdate::Advanced + } + } + } + + /// The **third** geometry of Q#BP15a: the panel grid the daemon + /// derives and paints, or `None` when no panel is presentable. + /// + /// Columns are the frontend's full declared width. Rows are the + /// stored `fixed_rows` request clamped by Q#BP2's recursive document + /// minimum ([`Self::panel_allocation`]) and then by the shared wire + /// area budget, so a very wide frame cannot produce a frame the + /// protocol would reject. **The stored request is never rewritten** + /// — a later narrower geometry restores it. + /// + /// Returns `None` — the Q#BP2b hidden arm — when geometry is unknown, + /// when the panel is hidden or absent, when the frame declares zero + /// columns, or when even [`MIN_WINDOW_OUTER_ROWS`] rows would exceed + /// the area budget. + #[must_use] + pub fn panel_grid_size(&self, fid: FrontendId) -> Option { + let view = self.views.get(&fid)?; + if view.panel_hidden { + return None; + } + self.side_window_for(fid)?; + let geometry = view.frame_geometry?; + let cols = geometry.total.cols; + if cols == 0 { + return None; + } + let area_rows = self.frontend_area_rows(fid)?; + let rows = self.panel_allocation(fid, area_rows)?; + // The wire's area bound is a transport-safety limit, not a + // policy: clamp rows against it rather than shipping a frame the + // shared validator would reject whole. + let budget_rows = u32::try_from( + pmacs_protocol::panel::MAX_PANEL_VISIBLE_CELLS / (cols as usize).max(1), + ) + .unwrap_or(u32::MAX); + let rows = rows.min(budget_rows); + (rows >= MIN_WINDOW_OUTER_ROWS).then(|| crate::cell::CellSize::new(rows, cols)) } /// Core half of the idempotent panel-reconciliation transaction From 817b134ae84cbc394b09e3654bd9cdc14254ffe5 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 17:28:43 -0400 Subject: [PATCH 2/7] Produce PanelFrame and gate the inbound panel events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bottom-panel Stage 2B-2, second half: the producer, the presentation epoch, and the three inbound event gates. The producer lives beside the terminal pass in `semantic_render.rs` and follows its shape: compare the complete payload first, validate only a payload that differs, and store only what was actually shipped. The presentation epoch is allocated from the side window and its buffer, so a new side window, a replaced buffer, and every `Absent` -> `Present` transition each take a fresh identity; `Absent` clears the identity, which is what makes close/hide/reopen of the SAME persistent buffer unaddressable by a stale `PanelPointer`. Allocation is checked and exhaustion fails closed to `Absent` rather than wrapping into a live identity. The `Absent` baseline is seeded rather than left empty: a fresh session has no band, so the opening state is a fact the peer already holds. The band rides both render paths and does not wait for a declared byte viewport: it is a separate surface, and gating it on the document declaration would leave the first panel unpaintable. Its mode line takes the side window's segments from the SAME provider invocation that serves the document's wire segments. Inbound, `peer_may_send_panel_events` checks four facts together — an installed semantic projection, the negotiated version, the daemon's own capability bit, and (via the transport source) that the payload's claimed id is never consulted. `panel_event_epochs_are_current` then runs Q#BP16 steps 2-4 as one predicate so no caller can check the geometry epoch and forget the presentation epoch. Two daemon gates moved from `panel_capable` to `!semantic_render`. Stage 1 could conflate them because panel capability implied grid; now that a semantic view can be panel-capable, a capability-keyed gate would feed it the permanent 24x80 attach placeholder that Q#BP15a forbids, and parent acceptance 40 would fail through the attach line rather than through the projection. Not a live defect — no production semantic session is panel-capable yet — but it is the landmine Stage 2B-3 would have stepped on. `panel_capable` is unchanged for production negotiation and the unsolicited `Hello` still advertises v20. Nothing here is reachable by a user. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- src/daemon.rs | 689 +++++++++++- src/editor.rs | 11 +- src/editor_core.rs | 7 +- src/protocol.rs | 15 + src/semantic_render.rs | 276 ++++- .../bottom_panel_stage2b_daemon_acceptance.rs | 984 ++++++++++++++++++ 6 files changed, 1958 insertions(+), 24 deletions(-) create mode 100644 tests/bottom_panel_stage2b_daemon_acceptance.rs diff --git a/src/daemon.rs b/src/daemon.rs index b665d07..16364cd 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -69,8 +69,8 @@ use crate::protocol::crossterm_translate::{key_to_crossterm, mouse_to_crossterm} use crate::protocol::{ ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendEvent, FrontendId, GoodbyeReason, Hello, InitialTarget, InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage, - InstanceSignal, MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, PointerKind, - SelectionSnapshot, SessionBootstrapRequest, + InstanceSignal, MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, + PANEL_MIN_VERSION, PointerKind, SelectionSnapshot, SessionBootstrapRequest, }; use crate::socket_path::{SocketPathError, ensure_runtime_subdir}; use crate::transport::{read_message, write_message}; @@ -898,13 +898,22 @@ fn peer_declared_terminal_support( /// Q#BP13). /// /// Grid sessions paint the whole cell grid the daemon composes, so a side -/// window is just another leaf for them. A semantic session needs the -/// Stage 2 `PanelFrame` band, which does not exist yet — so Stage 1 -/// answers `false` for every semantic peer, whatever it declares. No -/// client-asserted standalone boolean is trusted: the answer is derived -/// from the daemon's own negotiated state, and Stage 2 turns the version -/// arm on (`semantic_render && negotiated_protocol_version >= -/// PANEL_MIN_VERSION`). +/// window is just another leaf for them. A semantic session needs the GPU +/// band, which does not exist yet — so this still answers `false` for +/// every semantic peer, whatever it declares. No client-asserted +/// standalone boolean is trusted: the answer is derived from the daemon's +/// own negotiated state. +/// +/// **Stage 2B-2 deliberately does not turn the version arm on.** The +/// daemon-side projection and epoch machine below are complete and +/// exercised through a test-only panel-capable view, but the production +/// flip (`semantic_render && negotiated_protocol_version >= +/// PANEL_MIN_VERSION`) belongs to Stage 2B-3, together with the +/// compatibility-preserving activation the server-first `Hello` requires: +/// [`ADVERTISED_PROTOCOL_VERSION`](pmacs_protocol::ADVERTISED_PROTOCOL_VERSION) +/// is still 20, so no session can negotiate 21 yet, and denying only the +/// events while still *placing* such a peer in a side window would leave +/// its window invisible. fn peer_declared_panel_support(session_state: crate::presence::SessionState) -> bool { !session_state.negotiated_capabilities.semantic_render } @@ -917,6 +926,73 @@ fn peer_accepts_terminal_message(protocol_version: u32, message: &InstanceMessag protocol_version >= 19 || !matches!(message, InstanceMessage::TerminalFrame(_)) } +/// The same belt-and-braces write-loop gate for the additive +/// protocol-v21 panel frame (Q#BP9). +/// +/// The producer already skips construction for a peer below +/// [`PANEL_MIN_VERSION`]; this filter independently prevents an unknown +/// discriminant reaching one, so neither gate alone is load-bearing. +fn peer_accepts_panel_message(protocol_version: u32, message: &InstanceMessage) -> bool { + protocol_version >= PANEL_MIN_VERSION || !matches!(message, InstanceMessage::PanelFrame(_)) +} + +/// Whether an authenticated source may send the v21 panel event family +/// (Q#BP9's "every gate keys on the daemon's own state"). +/// +/// All three inbound events require the same four facts, and they are +/// checked together so no arm can satisfy three and forget the fourth: +/// an installed **semantic** projection, a negotiated version that +/// carries the variants, and a `FrontendView` this daemon itself marked +/// panel-capable. A grid session, a pre-panel semantic peer, or a +/// non-panel-capable view is rejected before any payload state is +/// trusted. +/// +/// The claimed `frontend_id` in the payload is never consulted anywhere: +/// routing is by the authenticated transport `source`, so a forged id +/// addresses nothing. +/// Q#BP16 steps 2–4: the event addresses the panel declaration this +/// session most recently shipped, under the geometry it most recently +/// accepted. +/// +/// Three facts, one predicate, because they close three different holes +/// and no two of them subsume the third: +/// +/// * the latest declaration is a `Present` (an `Absent` cleared input +/// authority, so nothing is addressable), +/// * its echoed `geometry_epoch` equals both the payload's **and** the +/// daemon's latest accepted declaration — the font/scale/resize race, +/// * its `panel_epoch` equals the payload's — close/hide/reopen of the +/// same persistent buffer, which a `buffer_id` alone cannot see. +fn panel_event_epochs_are_current( + editor: &EditorState, + semantic_states: &HashMap, + source: FrontendId, + geometry_epoch: u64, + panel_epoch: u64, +) -> bool { + semantic_states + .get(&source) + .is_some_and(|sem| sem.panel_declaration_matches(geometry_epoch, panel_epoch)) + && editor + .core + .borrow() + .frame_geometry_for(source) + .is_some_and(|geometry| geometry.geometry_epoch == geometry_epoch) +} + +fn peer_may_send_panel_events( + editor: &EditorState, + session_registry: &SessionRegistry, + semantic_states: &HashMap, + source: FrontendId, +) -> bool { + semantic_states.contains_key(&source) + && session_registry + .session_state(source) + .is_some_and(|state| state.negotiated_protocol_version >= PANEL_MIN_VERSION) + && editor.core.borrow().panel_capable_for(source) +} + /// T M10.8 — dispatcher loop. The single thread that owns the editor. /// /// All attached frontends' inputs arrive via the `dispatcher_rx` @@ -1376,6 +1452,12 @@ fn dispatcher_loop( if !peer_accepts_statusline_message(negotiated_protocol_version, msg) { continue; } + // Bottom panel Q#BP9 — PanelFrame gated at v21. A v20 + // peer receives no band and, per Q#BP13, is never + // placed in a side window either. + if !peer_accepts_panel_message(negotiated_protocol_version, msg) { + continue; + } // T M10.10 Day 4 / M10.11 F2 — the criterion-1 // jitter site: render-write latency. // @@ -1950,10 +2032,17 @@ fn handle_session_established( term_sizes.insert(frontend_id, initial_size); // Bottom-panel arc (Q#BP2b): a grid session's real attach size IS its // authoritative geometry declaration, cached BEFORE any input can - // reach it. A semantic session deliberately stays UNKNOWN — Stage 2's + // reach it. A semantic session deliberately stays UNKNOWN — its // authenticated `FrontendCellGeometry` fills it, and the permanent // 24x80 attach placeholder is never consulted for panel layout. - if editor.core.borrow().panel_capable_for(frontend_id) { + // + // Stage 2B-2: the gate is `!semantic_render`, NOT `panel_capable`. + // Stage 1 could conflate them because panel capability implied grid; + // once a semantic session can be panel-capable, a capability-keyed + // gate would feed it exactly the placeholder Q#BP15a forbids, and + // parent acceptance 40 would fail through this line rather than + // through the projection. + if !semantic_render && editor.core.borrow().panel_capable_for(frontend_id) { editor.sync_frame_geometry(frontend_id, initial_size); } @@ -2043,7 +2132,17 @@ fn handle_dispatcher_event( // longer satisfy the panel hides it, moves focus out, // and releases its terminal controller here — before // the next drained event dispatches. - if editor.core.borrow().panel_capable_for(source) { + // + // Stage 2B-2: gated on the absence of a semantic + // projection as well as on capability. A semantic + // frontend's `Resize` describes its own surface in + // whatever units it chose; only `FrontendCellGeometry` + // is its authoritative cell-equivalent declaration + // (Q#BP15a), and letting `Resize` mint an epoch here + // would let the two allocators interleave. + if !semantic_states.contains_key(&source) + && editor.core.borrow().panel_capable_for(source) + { editor.sync_frame_geometry(source, size); } } @@ -2189,6 +2288,80 @@ fn handle_dispatcher_event( ); } } + FrontendEvent::FrontendCellGeometry { + geometry_epoch, + total, + .. + } => { + // Bottom panel Q#BP15a — the frontend's authoritative + // cell-equivalent layout capacity. Routed by the + // authenticated `source`; the payload's `frontend_id` + // is never read. + // + // Deliberately does NOT require a side window: the + // daemon needs columns before it can paint a first + // panel frame, so gating this on panel presence would + // deadlock the first open. "Without a side window" + // refers to side-window presence only — the protocol, + // session, and capability gates all still apply. + if peer_may_send_panel_events(editor, session_registry, semantic_states, source) + { + editor.accept_semantic_frame_geometry(source, geometry_epoch, total); + } + } + FrontendEvent::PanelResizeRows { + geometry_epoch, + panel_epoch, + rows, + .. + } => { + // Bottom panel Q#BP15a / Q#BP16 — a divider drag's + // requested rows. Accepted only against the currently + // visible `Present` declaration, matching BOTH the + // latest accepted frontend geometry and that + // declaration's presentation epoch, so a drag racing a + // font change or a panel reopen cannot resize its + // successor. + if peer_may_send_panel_events(editor, session_registry, semantic_states, source) + && panel_event_epochs_are_current( + editor, + semantic_states, + source, + geometry_epoch, + panel_epoch, + ) + { + editor.apply_panel_resize_rows(source, rows); + } + } + FrontendEvent::PanelPointer { + geometry_epoch, + panel_epoch, + buffer_id, + coord, + kind, + .. + } => { + // Bottom panel Q#BP16 — a gesture the frontend + // hit-tested to a panel CELL. Steps 1, 3, and 4 of the + // ladder are checked here (authenticated source, both + // epochs against the declaration the frontend was + // looking at); steps 2, 5, and 6 are re-derived from + // the daemon's own state inside the dispatcher. Any + // failure drops the event before any view, controller, + // selection, menu, or PTY mutation. + if peer_may_send_panel_events(editor, session_registry, semantic_states, source) + && panel_event_epochs_are_current( + editor, + semantic_states, + source, + geometry_epoch, + panel_epoch, + ) + { + editor.dispatch_semantic_panel_pointer(source, buffer_id, coord, kind); + } + } FrontendEvent::Pointer { buffer_id, byte, @@ -3401,6 +3574,11 @@ fn apply_event( // A grid session has no panel band at all, so one arriving // here is a protocol violation; drop it rather than letting // a payload-trusted id reach a view. + // + // Stage 2B-2 added the real routing arms, which `peer_may_ + // send_panel_events` already refuses for a grid session, so + // this is now the belt-and-braces half of the same gate — + // exactly like the `Pointer` and `TerminalResize` arms above. eprintln!( "pmacs daemon: panel declaration from a grid session; dropping \ (grid sessions negotiate no panel band)" @@ -5537,4 +5715,491 @@ mod tests { "#9: a focused TERMINAL panel must not suppress the document viewport — the document window should still have aligned to the declared buffer" ); } + + // ----------------------------------------------------------------- + // Bottom-panel Stage 2B-2 — inbound panel-event routing, driven + // through `handle_dispatcher_event` (the real dispatcher seam). + // + // Deliberately NOT `crdt`-gated: CI never enables that feature, so a + // gated pin is dark exactly where it needs to run. + // ----------------------------------------------------------------- + + /// A semantic, panel-capable frontend. `with_panel` decides whether + /// it also owns a side window — `FrontendCellGeometry` must be + /// accepted **without** one (Q#BP15a breaks the first-open cycle), + /// while the two gesture events must not be. + fn semantic_panel_view( + editor: &crate::editor::EditorState, + fid: FrontendId, + with_panel: bool, + ) -> (crate::window::WindowId, Option) { + use crate::window::{FrontendView, Layout, LayoutNode, Orientation, Window, WindowParams}; + + let mut core = editor.core.borrow_mut(); + let doc_buf = core.active_window().buffer_id; + let document = crate::window::WindowId::next(); + let doc_view = { + let reg = core.registry.borrow(); + crate::text_view::TextView::new(reg.get(doc_buf).expect("doc")) + }; + core.windows + .insert(document, Window::new(document, doc_buf, doc_view)); + let panel = with_panel.then(|| { + let panel_buf = core.registry.borrow_mut().create("*panel*"); + let panel_id = crate::window::WindowId::next(); + let panel_view = { + let reg = core.registry.borrow(); + crate::text_view::TextView::new(reg.get(panel_buf).expect("panel")) + }; + let mut window = Window::new(panel_id, panel_buf, panel_view); + let mut params = WindowParams::default(); + params.side = Some(crate::window::Side::Bottom); + params.fixed_rows = Some(4); + window.params = params; + core.windows.insert(panel_id, window); + panel_id + }); + let layout = match panel { + Some(panel) => Layout { + root: LayoutNode::Split { + orientation: Orientation::Horizontal, + children: vec![LayoutNode::Leaf(document), LayoutNode::Leaf(panel)], + weights: vec![1, 1], + }, + }, + None => Layout::single(document), + }; + core.register_frontend_view( + fid, + FrontendView { + layout, + active: document, + fold_projection: false, + // Stage 2B-2 is dark: production negotiation still sets + // this `false` for every semantic session, so the + // projection is exercised through a test-only view (the + // framing's §7.2.2 posture). + panel_capable: true, + frame_geometry: None, + panel_hidden: false, + }, + ); + (document, panel) + } + + fn session(version: u32, semantic: bool) -> crate::presence::SessionState { + crate::presence::SessionState { + negotiated_protocol_version: version, + negotiated_capabilities: crate::protocol::NegotiatedCapabilities { + semantic_render: semantic, + crdt_replica: true, + ..Default::default() + }, + color_slot: 0, + } + } + + /// Drive one authenticated event through the real dispatcher while + /// keeping the caller's projection state, so a test can ship a + /// `PanelFrame` first and then send an event addressing it. + /// + /// The session is registered because the dispatcher drops any event + /// from an uninstalled session before it reaches a handler. + fn dispatch_panel_event( + editor: &mut crate::editor::EditorState, + fid: FrontendId, + version: u32, + semantic_states: &mut HashMap, + render_states: &mut HashMap, + event: FrontendEvent, + ) { + let mut streams = HashMap::new(); + let mut term_sizes = HashMap::new(); + term_sizes.insert(fid, CellSize::new(24, 80)); + let mut last_idle = HashMap::new(); + let mut last_active = HashMap::new(); + let mut bells = HashMap::new(); + let mut registry = SessionRegistry::new(); + registry.register_session(fid, session(version, !render_states.contains_key(&fid))); + handle_dispatcher_event( + DispatcherEvent::FrontendEvent { source: fid, event }, + editor, + render_states, + semantic_states, + &mut streams, + &mut term_sizes, + &mut last_idle, + &mut last_active, + &mut bells, + &mut registry, + ); + } + + fn geometry_event(claimed: FrontendId, epoch: u64, rows: u32, cols: u32) -> FrontendEvent { + FrontendEvent::FrontendCellGeometry { + frontend_id: claimed, + geometry_epoch: epoch, + total: CellSize::new(rows, cols), + } + } + + /// Criterion 50 (accept half) + Q#BP15a: the declaration is valid + /// with no side window at all. Gating it on panel presence would + /// deadlock the first open, because the daemon needs columns before + /// it can paint a first frame. + #[test] + fn frontend_cell_geometry_is_accepted_without_a_side_window() { + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(701); + semantic_panel_view(&editor, fid, false); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + ); + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + geometry_event(fid, 1, 40, 100), + ); + + let stored = editor.core.borrow().frame_geometry_for(fid); + assert_eq!( + stored.map(|geometry| (geometry.geometry_epoch, geometry.total)), + Some((1, CellSize::new(40, 100))), + "a panel-capable semantic source declares geometry with no side window" + ); + } + + /// Criterion 50 (reject half): a GRID session has no panel band, so + /// its declaration is dropped before it can reach a view. + #[test] + fn frontend_cell_geometry_from_a_grid_session_is_dropped() { + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(702); + semantic_panel_view(&editor, fid, false); + // A grid session: a `RenderState`, no semantic projection. + let mut render_states = HashMap::new(); + render_states.insert(fid, RenderState::new(CellSize::new(24, 80))); + // The grid arm would otherwise mint an epoch from its own attach + // size, so start from a known state and assert the epoch never + // answers the wire declaration. + let before = editor.core.borrow().frame_geometry_for(fid); + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut HashMap::new(), + &mut render_states, + geometry_event(fid, 9, 40, 100), + ); + + let after = editor.core.borrow().frame_geometry_for(fid); + assert_eq!( + before, after, + "a grid session's panel declaration is dropped" + ); + assert_eq!(after, None, "…and nothing was stored at all"); + } + + /// Criterion 50 (reject half): a peer that negotiated v20 never + /// negotiated these variants, so its declaration is not trusted even + /// though its view is panel-capable. + #[test] + fn frontend_cell_geometry_below_the_panel_version_is_dropped() { + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(703); + semantic_panel_view(&editor, fid, false); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PANEL_MIN_VERSION - 1), + ); + + dispatch_panel_event( + &mut editor, + fid, + PANEL_MIN_VERSION - 1, + &mut semantic_states, + &mut HashMap::new(), + geometry_event(fid, 1, 40, 100), + ); + + assert_eq!( + editor.core.borrow().frame_geometry_for(fid), + None, + "a pre-panel semantic peer's declaration is dropped" + ); + } + + /// Criterion 50 (reject half) + Q#BP13: capability is the gate, not + /// only the wire version. A semantic view this daemon did not mark + /// panel-capable declares nothing. + #[test] + fn frontend_cell_geometry_from_a_non_panel_capable_view_is_dropped() { + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(704); + let view = build_fresh_frontend_view(&mut editor, false, false); + editor.core.borrow_mut().register_frontend_view(fid, view); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + ); + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + geometry_event(fid, 1, 40, 100), + ); + + assert_eq!( + editor.core.borrow().frame_geometry_for(fid), + None, + "a non-panel-capable semantic view declares no panel geometry" + ); + } + + /// Criterion 50 (forged-source half): routing is by the + /// authenticated transport source, never the payload's claimed id, so + /// a forged id reaches no other frontend's view. + #[test] + fn a_forged_frontend_id_in_a_geometry_payload_addresses_nothing() { + let mut editor = crate::editor::EditorState::new(); + let source = FrontendId(705); + let victim = FrontendId(706); + semantic_panel_view(&editor, source, false); + semantic_panel_view(&editor, victim, false); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + source, + crate::semantic_render::SemanticRenderState::for_peer(source, PROTOCOL_VERSION), + ); + + dispatch_panel_event( + &mut editor, + source, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + geometry_event(victim, 1, 40, 100), + ); + + let core = editor.core.borrow(); + assert_eq!( + core.frame_geometry_for(victim), + None, + "the claimed id must not be able to declare another frontend's geometry" + ); + assert!( + core.frame_geometry_for(source).is_some(), + "…while the authenticated source's own declaration still lands" + ); + } + + /// Ship one real `PanelFrame` so the session holds a live `Present` + /// declaration, and return its two epochs. + fn shipped_declaration( + editor: &crate::editor::EditorState, + fid: FrontendId, + semantic_states: &mut HashMap, + ) -> (u64, u64) { + let sem = semantic_states.get_mut(&fid).expect("semantic projection"); + let messages = sem.render_frame(editor); + assert!( + messages + .iter() + .any(|msg| matches!(msg, InstanceMessage::PanelFrame(_))), + "fixture precondition: the frame must actually ship a panel declaration" + ); + let frame = sem.panel_declaration().expect("a Present declaration"); + (frame.geometry_epoch, frame.panel_epoch) + } + + /// Criterion 50: a gesture from a source whose latest declaration is + /// not a visible `Present` is dropped. + #[test] + fn a_panel_pointer_without_a_present_declaration_is_dropped() { + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(707); + let (document, panel) = semantic_panel_view(&editor, fid, true); + let panel = panel.expect("panel window"); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + ); + // No geometry declared yet, so nothing has been shipped and the + // seeded baseline is `Absent`. + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + FrontendEvent::PanelPointer { + frontend_id: fid, + geometry_epoch: 1, + panel_epoch: 1, + buffer_id, + coord: pmacs_protocol::CellCoord::new(0, 0), + kind: pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left), + mods: pmacs_protocol::Modifiers::default(), + }, + ); + + assert_eq!( + editor.core.borrow().views[&fid].active, + document, + "a gesture with no live Present declaration must not focus the panel" + ); + } + + /// Criterion 49: the accepted case, and the two epoch races beside + /// it. Without the accepted arm the drops above would pass for a + /// dispatcher that ignores the event family entirely. + #[test] + fn panel_pointer_epochs_decide_focus() { + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(708); + let (document, panel) = semantic_panel_view(&editor, fid, true); + let panel = panel.expect("panel window"); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + ); + editor.accept_semantic_frame_geometry(fid, 1, CellSize::new(24, 80)); + let (geometry_epoch, panel_epoch) = shipped_declaration(&editor, fid, &mut semantic_states); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + let down = |geometry_epoch, panel_epoch| FrontendEvent::PanelPointer { + frontend_id: fid, + geometry_epoch, + panel_epoch, + buffer_id, + coord: pmacs_protocol::CellCoord::new(0, 0), + kind: pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left), + mods: pmacs_protocol::Modifiers::default(), + }; + + for (label, event) in [ + ( + "stale geometry epoch", + down(geometry_epoch + 1, panel_epoch), + ), + ( + "stale presentation epoch", + down(geometry_epoch, panel_epoch + 1), + ), + ] { + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + event, + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + document, + "{label}: the gesture must drop before it can move focus" + ); + } + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + down(geometry_epoch, panel_epoch), + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + panel, + "a gesture matching BOTH epochs activates the panel (click-to-focus)" + ); + } + + /// Criteria 49/50 for the resize event: matching epochs move the + /// stored request, a stale presentation epoch does not. + #[test] + fn panel_resize_rows_honors_both_epochs() { + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(709); + let (_document, panel) = semantic_panel_view(&editor, fid, true); + let panel = panel.expect("panel window"); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + ); + editor.accept_semantic_frame_geometry(fid, 1, CellSize::new(24, 80)); + let (geometry_epoch, panel_epoch) = shipped_declaration(&editor, fid, &mut semantic_states); + let before = editor.core.borrow().windows[&panel].params.fixed_rows; + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + FrontendEvent::PanelResizeRows { + frontend_id: fid, + geometry_epoch, + panel_epoch: panel_epoch + 1, + rows: 9, + }, + ); + assert_eq!( + editor.core.borrow().windows[&panel].params.fixed_rows, + before, + "a stale presentation epoch must not resize the panel" + ); + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + FrontendEvent::PanelResizeRows { + frontend_id: fid, + geometry_epoch, + panel_epoch, + rows: 9, + }, + ); + assert_eq!( + editor.core.borrow().windows[&panel].params.fixed_rows, + Some(9), + "matching epochs move the stored request" + ); + } + + /// Q#BP9's write-loop gate, independent of the producer's own flag. + #[test] + fn the_panel_frame_write_gate_rejects_v20_independently() { + let frame = InstanceMessage::PanelFrame(pmacs_protocol::panel::PanelFramePayload::Absent); + assert!(!peer_accepts_panel_message(PANEL_MIN_VERSION - 1, &frame)); + assert!(peer_accepts_panel_message(PANEL_MIN_VERSION, &frame)); + assert!( + peer_accepts_panel_message( + PANEL_MIN_VERSION - 1, + &InstanceMessage::DispatchIdle { idle: true } + ), + "the filter must be scoped to the panel variant" + ); + } } diff --git a/src/editor.rs b/src/editor.rs index 1118254..613bcf7 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1932,6 +1932,10 @@ impl EditorState { /// the *active* frontend — is the wrong source and is deliberately /// not called. #[must_use] + #[allow( + clippy::too_many_lines, + reason = "one panel paint transaction: derive the grid, paint the window, resolve the caret" + )] pub fn prepare_panel_projection( &self, frontend_id: FrontendId, @@ -4256,9 +4260,10 @@ fn window_cursor_cell( ) -> Option { let inner_rows = inner_rows(&rect); let cursor = match folds { - Some(map) => { - map.visible_position(window.text_view.line_at_offset(window.cursor), window.cursor) - } + Some(map) => map.visible_position( + window.text_view.line_at_offset(window.cursor), + window.cursor, + ), None => window.cursor, }; let disp = window.text_view.pos_to_display(buf, cursor)?; diff --git a/src/editor_core.rs b/src/editor_core.rs index bd80a3f..7292284 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -3413,10 +3413,9 @@ impl EditorCore { // The wire's area bound is a transport-safety limit, not a // policy: clamp rows against it rather than shipping a frame the // shared validator would reject whole. - let budget_rows = u32::try_from( - pmacs_protocol::panel::MAX_PANEL_VISIBLE_CELLS / (cols as usize).max(1), - ) - .unwrap_or(u32::MAX); + let budget_rows = + u32::try_from(pmacs_protocol::panel::MAX_PANEL_VISIBLE_CELLS / (cols as usize).max(1)) + .unwrap_or(u32::MAX); let rows = rows.min(budget_rows); (rows >= MIN_WINDOW_OUTER_ROWS).then(|| crate::cell::CellSize::new(rows, cols)) } diff --git a/src/protocol.rs b/src/protocol.rs index baf2709..30341f0 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -60,6 +60,21 @@ use std::path::PathBuf; // directly. pub use pmacs_protocol::*; +/// Lowest negotiated protocol version that carries the bottom-panel wire +/// family (Q#BP9): [`InstanceMessage::PanelFrame`] daemon→frontend, and +/// `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}` +/// frontend→daemon. +/// +/// One constant rather than a literal at each gate, because the panel +/// bump gates in **both** directions: the send filter, the producer's +/// peer flag, and the three inbound event gates must move together or one +/// side starts trusting a wire the other never negotiated. +/// +/// Distinct from [`ADVERTISED_PROTOCOL_VERSION`], which the production +/// daemon still holds at 20: the v21 schema is reserved, and the +/// compatibility-preserving activation is bottom-panel Stage 2B-3's. +pub const PANEL_MIN_VERSION: u32 = 21; + // --------------------------------------------------------------------------- // Attachment // --------------------------------------------------------------------------- diff --git a/src/semantic_render.rs b/src/semantic_render.rs index db96fc4..c42835c 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -38,14 +38,16 @@ use crate::cell::{CellSize, Style}; use crate::editor::EditorState; use crate::protocol::{ AdornmentContent, AdornmentPlacement, ByteRange, Decoration, DecorationKind, DecorationSegment, - FrontendId, InlineAdornment, InstanceMessage, MenuPromptRow, StatuslineSegment, StyleSegment, - StyleSpan, + FrontendId, InlineAdornment, InstanceMessage, MenuPromptRow, PANEL_MIN_VERSION, + StatuslineSegment, StyleSegment, StyleSpan, }; use crate::statusline::{ StatuslineEvaluation, StatuslineEvaluationOutcome, StatuslineEvaluationTarget, - evaluate_statusline, + StatuslineWindowSegments, evaluate_statusline, }; use crate::terminal::TerminalFrame; +use crate::window::WindowId; +use pmacs_protocol::panel::{PanelFrame, PanelFramePayload}; /// The viewport a `semantic_render` frontend last declared. #[derive(Clone, Debug, Eq, PartialEq)] @@ -317,6 +319,54 @@ pub struct SemanticRenderState { terminal_error_latched: bool, /// Whether the most recent render pass projected a terminal. terminal_active: bool, + /// Whether the peer negotiated protocol v21, which is where + /// [`InstanceMessage::PanelFrame`] was appended (Q#BP9). A v20 peer + /// receives no band at all — and, per Q#BP13, is never *placed* in a + /// side window either, because denying only the message would leave + /// its window invisible. + peer_knows_panel_frames: bool, + /// The panel payload this peer last received, compared in FULL. + /// + /// Seeded to `Absent` rather than `None`: a fresh session starts with + /// no band, so the opening state is a fact rather than an absence, + /// and seeding it keeps every session from paying one redundant + /// `Absent` before it ever shows a panel. + /// + /// `Absent` is authoritative and duplicate-suppressed like any other + /// payload — hide and close must both send it, because the receiver + /// retains its last valid frame and silence would leave a stale band + /// on screen indefinitely (Q#BP15). + last_panel_payload: Option, + /// Highest presentation epoch allocated for this session; `0` means + /// none has been. Advanced only when a frame is actually shipped, so + /// a frame that fails validation does not burn an identity the peer + /// never saw. + panel_epoch_used: u64, + /// Identity behind the `Present` in `last_panel_payload`, or `None` + /// when the last payload was `Absent`. + /// + /// Cleared by every `Absent`, which is what makes hide/reopen and + /// close/reopen of the **same** persistent buffer allocate a fresh + /// epoch — the hole a `buffer_id` alone cannot close (Q#BP16). + panel_presentation: Option, + /// Whether an invalid panel frame was already reported since the last + /// valid one. Bounds the log exactly like `terminal_error_latched`. + panel_error_latched: bool, +} + +/// The presentation identity a shipped [`PanelFrame`] carries. +/// +/// `window_id` moves when a new side window is created and `buffer_id` +/// when the panel's buffer is replaced; either one changing allocates a +/// new `panel_epoch`, and that is what stops a stale `PanelPointer` from +/// addressing a reopened panel as if it were the old one (Q#BP16). +/// `WindowId` deliberately stays off the wire — the epoch is the opaque +/// stand-in for it. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +struct PanelPresentation { + window_id: WindowId, + buffer_id: BufferId, + panel_epoch: u64, } /// One [`SemanticRenderState::diag_line_cache`] entry: the line-start @@ -413,6 +463,7 @@ impl SemanticRenderState { s.peer_knows_font_facts = negotiated_protocol_version >= 17; 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 } @@ -464,9 +515,45 @@ impl SemanticRenderState { last_terminal_frame: None, terminal_error_latched: false, terminal_active: false, + peer_knows_panel_frames: true, + // Q#BP15: a fresh session has no band, and that is a fact the + // peer already holds. Seeding the baseline keeps the first + // frame from shipping a redundant authoritative `Absent`. + last_panel_payload: Some(PanelFramePayload::Absent), + panel_epoch_used: 0, + panel_presentation: None, + panel_error_latched: false, } } + /// The `Present` panel declaration this session last shipped. + /// + /// The daemon reads it to run steps 3 and 4 of Q#BP16's validation + /// ladder: an inbound `PanelPointer` or `PanelResizeRows` must name + /// the geometry and presentation epochs of the frame the frontend was + /// actually looking at. `None` after an `Absent` — which is precisely + /// how `Absent` "clears input authority". + #[must_use] + pub fn panel_declaration(&self) -> Option<&PanelFrame> { + match self.last_panel_payload.as_ref()? { + PanelFramePayload::Present(frame) => Some(frame), + PanelFramePayload::Absent => None, + } + } + + /// Whether the last shipped declaration is a `Present` whose epochs + /// both match an inbound panel event (Q#BP16 steps 2–4). + /// + /// Rolled into one predicate so no caller can check the geometry + /// epoch and forget the presentation epoch: they close different + /// holes and neither subsumes the other. + #[must_use] + pub fn panel_declaration_matches(&self, geometry_epoch: u64, panel_epoch: u64) -> bool { + self.panel_declaration().is_some_and(|frame| { + frame.geometry_epoch == geometry_epoch && frame.panel_epoch == panel_epoch + }) + } + /// Record the frontend's declared on-screen byte range. Called by /// the dispatcher when it receives /// [`crate::protocol::FrontendEvent::Viewport`]. Replaces any @@ -616,8 +703,15 @@ impl SemanticRenderState { return messages; } let Some(vp) = self.viewport.clone() else { - // Emit nothing before the frontend declares a viewport. - return Vec::new(); + // Emit nothing document-scoped before the frontend declares a + // viewport — but the band is a SEPARATE surface (Q#BP15a), + // and gating it on the document declaration would make the + // first panel unpaintable on a frontend that has not declared + // one yet. Its statusline is `None` here for the same reason: + // the semantic fan-out is keyed on the declared buffer. + let mut out = Vec::new(); + self.emit_panel_frame(state, None, &mut out); + return out; }; // Evaluate callbacks before any long-lived core borrow and before @@ -825,9 +919,19 @@ impl SemanticRenderState { out.extend(self.theme_facts_msg(state)); out.extend(self.font_facts_msg(state)); // Q#SL6/Q#SL8: face inventory must precede segment text. + // Parent acceptance 45: ONE provider invocation supplies both the + // primary-document wire segments and the panel mode line, so the + // side half is taken from this same evaluation before it is + // consumed. + let side_window = state.core.borrow().side_window_for(self.frontend_id); + let panel_statusline = statusline_evaluation + .as_ref() + .and_then(|evaluation| self.panel_statusline(evaluation, side_window)) + .cloned(); if let Some(evaluation) = statusline_evaluation { self.emit_statusline_segments(evaluation, statusline_document_window, &mut out); } + self.emit_panel_frame(state, panel_statusline.as_ref(), &mut out); out } @@ -965,9 +1069,19 @@ impl SemanticRenderState { out.extend(self.theme_facts_msg(state)); out.extend(self.font_facts_msg(state)); // Q#SL6/Q#SL8: face inventory must precede segment text. + // The band rides the terminal path too: a frontend whose DOCUMENT + // surface is a full-window terminal can still hold a side window, + // and suppressing the panel here would leave the peer's retained + // band on screen with no way to clear it. + let side_window = state.core.borrow().side_window_for(self.frontend_id); + let panel_statusline = statusline_evaluation + .as_ref() + .and_then(|evaluation| self.panel_statusline(evaluation, side_window)) + .cloned(); if let Some(evaluation) = statusline_evaluation { self.emit_statusline_segments(evaluation, statusline_document_window, &mut out); } + self.emit_panel_frame(state, panel_statusline.as_ref(), &mut out); out } @@ -1033,6 +1147,158 @@ impl SemanticRenderState { } } + /// The side window's evaluated segments from **this frame's** single + /// provider invocation (parent acceptance 45). + /// + /// Selected by window identity, exactly like the document half: the + /// fan-out yields the primary document *and* the visible side window, + /// and taking "some context for my frontend" would depend on capture + /// order and could paint the document's status text into the panel's + /// mode line. + /// + /// An `Invalidated` evaluation yields `None`, which is the panel's + /// authoritative-empty: a callback that mutated layout or focus + /// invalidates the whole evaluation, so the band paints its plain + /// mode line rather than stale provider text. + fn panel_statusline<'a>( + &self, + evaluation: &'a StatuslineEvaluation, + side_window: Option, + ) -> Option<&'a StatuslineWindowSegments> { + let side_window = side_window?; + match &evaluation.outcome { + StatuslineEvaluationOutcome::Ready(windows) => windows.iter().find(|window| { + window.context.frontend_id == self.frontend_id + && window.context.window_id == side_window + }), + StatuslineEvaluationOutcome::Invalidated { .. } + | StatuslineEvaluationOutcome::NoMessage(_) => None, + } + } + + /// Project this frontend's side window as an + /// [`InstanceMessage::PanelFrame`] (Q#BP15). + /// + /// Runs on every frame of a panel-capable v21 semantic session, + /// independently of the document byte viewport: the band is a + /// separate surface, and gating it on a declared viewport would leave + /// the first panel unpaintable on a frontend that has not yet + /// declared one. + /// + /// Not reset by [`Self::on_buffer_snapshot_sent`]: a `BufferSnapshot` + /// resets *document* mirror state, and the band is neither + /// buffer-scoped to the document nor rebuilt from it. + fn emit_panel_frame( + &mut self, + state: &EditorState, + statusline: Option<&StatuslineWindowSegments>, + out: &mut Vec, + ) { + // Q#BP13: capability, not merely wire version. A session that + // cannot render a band must not be shipped one — and, on the + // production path, is never placed in a side window either. + if !self.peer_knows_panel_frames || !state.core.borrow().panel_capable_for(self.frontend_id) + { + return; + } + // Q#BP15a: `Present` echoes the daemon's latest ACCEPTED geometry + // declaration. Read before painting so the frame cannot answer a + // declaration that arrived mid-projection. + let geometry = state.core.borrow().frame_geometry_for(self.frontend_id); + let projection = + geometry.and_then(|_| state.prepare_panel_projection(self.frontend_id, statusline)); + let (Some(geometry), Some(projection)) = (geometry, projection) else { + self.publish_absent_panel(out); + return; + }; + let identity = (projection.window_id, projection.buffer_id); + let panel_epoch = match self.panel_presentation { + Some(presentation) if (presentation.window_id, presentation.buffer_id) == identity => { + Some(presentation.panel_epoch) + } + // A new side window, a replaced buffer, or any `Absent` → + // `Present` transition (which cleared `panel_presentation`) + // takes a fresh identity. + _ => self.panel_epoch_used.checked_add(1), + }; + let Some(panel_epoch) = panel_epoch else { + // Q#BP15: allocation is checked and exhaustion fails closed + // to `Absent`. Wrapping would let a new panel inherit a live + // identity and accept gestures aimed at its predecessor. + self.publish_absent_panel(out); + return; + }; + let payload = PanelFramePayload::Present(PanelFrame { + buffer_id: projection.buffer_id, + panel_epoch, + geometry_epoch: geometry.geometry_epoch, + size: projection.size, + cells: projection.cells, + cursor: projection.cursor, + focused: projection.focused, + }); + // 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 + // and topology checks would recompute a verdict we hold. + if self.last_panel_payload.as_ref() == Some(&payload) { + self.panel_error_latched = false; + return; + } + let PanelFramePayload::Present(frame) = &payload else { + unreachable!("the Present payload was constructed immediately above"); + }; + match frame.validate() { + Ok(()) => { + self.panel_error_latched = false; + self.panel_epoch_used = self.panel_epoch_used.max(panel_epoch); + self.panel_presentation = Some(PanelPresentation { + window_id: projection.window_id, + buffer_id: projection.buffer_id, + panel_epoch, + }); + self.last_panel_payload = Some(payload.clone()); + out.push(InstanceMessage::PanelFrame(payload)); + } + Err(error) => { + // Atomic rejection: the peer keeps its last valid frame, + // this session keeps the presentation identity behind it, + // and one bounded log line marks the condition. Advancing + // `panel_epoch_used` here would burn an identity the peer + // never saw. + if !self.panel_error_latched { + self.panel_error_latched = true; + eprintln!( + "pmacs: panel frame for {:?} on {:?} failed validation, \ + retaining the last valid frame: {error}", + projection.buffer_id, self.frontend_id + ); + } + } + } + } + + /// Publish the authoritative `Absent` for every non-presentable state + /// (Q#BP15, Q#BP2b). + /// + /// Clears the declared presentation on this side before any later + /// event can validate against it — that is what "`Absent` clears + /// input authority" means. The whole-frame geometry declaration + /// deliberately survives: it is answered by the frontend, not by the + /// panel's presence. + fn publish_absent_panel(&mut self, out: &mut Vec) { + self.panel_presentation = None; + if self.last_panel_payload.as_ref() == Some(&PanelFramePayload::Absent) { + // A duplicate `Absent` does no work — but the clear above + // still runs, so the state stays idempotent rather than + // depending on which duplicate arrived first. + return; + } + self.panel_error_latched = false; + self.last_panel_payload = Some(PanelFramePayload::Absent); + out.push(InstanceMessage::PanelFrame(PanelFramePayload::Absent)); + } + fn emit_statusline_payload( &mut self, buffer_id: BufferId, diff --git a/tests/bottom_panel_stage2b_daemon_acceptance.rs b/tests/bottom_panel_stage2b_daemon_acceptance.rs new file mode 100644 index 0000000..f7f5ada --- /dev/null +++ b/tests/bottom_panel_stage2b_daemon_acceptance.rs @@ -0,0 +1,984 @@ +// bottom_panel_stage2b_daemon_acceptance.rs --- bottom-panel Stage 2B-2 +// (docs/bottom-panel-stage2-framing.md §7.2.2; parent acceptance 38, 39 +// receiver half, 40, 41 daemon half, 42, 45, 49, 51, 52, plus A2B-1). + +//! The daemon panel projection and the epoch machine. +//! +//! Everything here runs through a **test-only** panel-capable semantic +//! view: production negotiation still sets `panel_capable = false` for +//! every semantic session, and the compatibility-preserving v21 +//! activation is Stage 2B-3's. Nothing in this slice is user-reachable. +//! +//! Two disciplines the framing is explicit about: +//! +//! * **Every geometry claim is asserted against the frame the producer +//! actually shipped**, never against `panel_grid_size` alone — the +//! grid the daemon derives is only meaningful if it reaches the wire. +//! * **Every drop is paired with its accepted counterpart in the same +//! fixture.** A suite that only proved "stale events are dropped" +//! would pass against a producer that ships nothing at all. + +use std::collections::HashMap; + +use pmacs::cell::{CellSize, Glyph}; +use pmacs::editor::EditorState; +use pmacs::editor_core::GeometryUpdate; +use pmacs::protocol::{FrontendId, InstanceMessage, PROTOCOL_VERSION}; +use pmacs::semantic_render::SemanticRenderState; +use pmacs::window::{FrontendView, Layout, Window, WindowId}; +use pmacs_protocol::panel::{PanelFrame, PanelFramePayload}; + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +const FID: FrontendId = FrontendId(64); +/// Deliberately NOT `24x80`: parent acceptance 40 requires the first +/// open to be sized from the frontend's own declaration, and a fixture +/// that happened to match the attach placeholder could not tell the two +/// apart. +const ROWS: u32 = 40; +const COLS: u32 = 120; + +struct Session { + state: EditorState, + render: SemanticRenderState, + document: WindowId, +} + +impl Session { + /// A semantic, panel-capable frontend with one document window and + /// no geometry declared yet. + fn new() -> Self { + let state = EditorState::new(); + exec(&state, "pmacs.lsp.config = {}"); + let document = { + let mut core = state.core.borrow_mut(); + let buffer_id = core.active_buffer_id(); + let text_view = { + let reg = core.registry.borrow(); + pmacs::text_view::TextView::new(reg.get(buffer_id).expect("buffer")) + }; + let window = WindowId::next(); + core.windows + .insert(window, Window::new(window, buffer_id, text_view)); + core.register_frontend_view( + FID, + FrontendView { + layout: Layout::single(window), + active: window, + // Semantic sessions do not project folds (Q#FD21); + // parent acceptance 52 depends on it. + fold_projection: false, + // Test-only. Production negotiation still says false. + panel_capable: true, + // Q#BP15a: UNKNOWN, never the attach placeholder. + frame_geometry: None, + panel_hidden: false, + }, + ); + // Programmatic Lua calls act for the ambient active frontend, + // so every `pmacs.window.*` call below targets this session. + core.active_frontend = FID; + window + }; + Self { + state, + render: SemanticRenderState::for_peer(FID, PROTOCOL_VERSION), + document, + } + } + + fn declare(&self, epoch: u64, rows: u32, cols: u32) -> GeometryUpdate { + self.state + .accept_semantic_frame_geometry(FID, epoch, CellSize::new(rows, cols)) + } + + /// Project one frame and return the panel payload it carried, or + /// `None` when the frame said nothing about the band (which is what + /// duplicate suppression looks like from the wire). + fn frame(&mut self) -> Option { + let messages = self.render.render_frame(&self.state); + let mut panels = messages.into_iter().filter_map(|message| match message { + InstanceMessage::PanelFrame(payload) => Some(payload), + _ => None, + }); + let first = panels.next(); + assert!( + panels.next().is_none(), + "one frame ships at most one panel payload" + ); + first + } + + fn present(&mut self) -> PanelFrame { + match self.frame() { + Some(PanelFramePayload::Present(frame)) => frame, + other => panic!("expected a Present panel payload, got {other:?}"), + } + } + + fn side_window(&self) -> Option { + self.state.core.borrow().side_window_for(FID) + } +} + +fn exec(state: &EditorState, src: &str) { + state.lua_host.lua().load(src.to_string()).exec().unwrap(); +} + +/// The panel grid's text as rows of strings, mode line included. +fn rows_of(frame: &PanelFrame) -> Vec { + frame + .cells + .chunks(frame.size.cols as usize) + .map(|row| { + row.iter() + .map(|cell| match &cell.glyph { + Glyph::Char(ch) => ch.to_string(), + Glyph::Cluster(bytes) => String::from_utf8_lossy(bytes).into_owned(), + Glyph::Continuation => String::new(), + }) + .collect::() + }) + .collect() +} + +/// Replace the panel buffer's contents through the real Lua data API. +fn set_panel_text(session: &Session, text: &str) { + exec( + &session.state, + &format!( + "local len = PANEL_BUF:len() + if len > 0 then PANEL_BUF:delete(0, len) end + PANEL_BUF:insert(0, {text:?})" + ), + ); +} + +/// Close the panel: focus it, then take the real `close_active` path. +fn close_panel(session: &Session) { + let panel = session.side_window().expect("side window"); + session.state.core.borrow_mut().focus_window(FID, panel); + exec(&session.state, "pmacs.window.close()"); + session.state.reconcile_panel_layout(FID); +} + +fn open_panel(session: &Session, name: &str, rows: u32) { + exec( + &session.state, + &format!( + "PANEL_BUF = pmacs.buffer.create(\"{name}\") + pmacs.window.display(PANEL_BUF, {{ side = \"bottom\", height = {rows} }})" + ), + ); +} + +// --------------------------------------------------------------------------- +// 40 + A2B-1 — unknown geometry is first-class; the placeholder is never +// consulted +// --------------------------------------------------------------------------- + +#[test] +fn acc40_a_panel_opened_before_any_declaration_stays_absent() { + let mut session = Session::new(); + open_panel(&session, "*panel*", 4); + assert!(session.side_window().is_some(), "the side window exists"); + + assert_eq!( + session.frame(), + None, + "with geometry UNKNOWN the band is non-presentable, and the seeded \ + Absent baseline means there is nothing new to say" + ); + assert_eq!( + session.state.core.borrow().panel_grid_size(FID), + None, + "no grid is derivable before a real declaration" + ); +} + +#[test] +fn acc40_the_first_frame_is_sized_from_the_declaration_not_the_placeholder() { + let mut session = Session::new(); + open_panel(&session, "*panel*", 4); + assert_eq!(session.declare(1, ROWS, COLS), GeometryUpdate::Advanced); + + let frame = session.present(); + assert_eq!( + frame.size.cols, COLS, + "columns come from the frontend's declaration; the permanent 24x80 \ + attach placeholder would have produced 80" + ); + assert_eq!( + frame.size.rows, 4, + "rows are the clamped fixed_rows request" + ); + assert_eq!( + frame.geometry_epoch, 1, + "the frame echoes the declaration it answers" + ); + assert!(frame.panel_epoch >= 1, "presentation epochs start at 1"); +} + +// --------------------------------------------------------------------------- +// 38 / 49 — the full lifecycle, and a new epoch at every identity change +// --------------------------------------------------------------------------- + +#[test] +fn acc38_open_replace_hide_reappear_close() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*first*", 4); + + // --- open --------------------------------------------------------- + let opened = session.present(); + let first_buffer = opened.buffer_id; + + // --- replace the panel's buffer ----------------------------------- + exec( + &session.state, + "SECOND = pmacs.buffer.create(\"*second*\") + pmacs.window.display(SECOND, { side = \"bottom\" })", + ); + let replaced = session.present(); + assert_ne!( + replaced.buffer_id, first_buffer, + "the panel really is showing another buffer" + ); + assert!( + replaced.panel_epoch > opened.panel_epoch, + "49: buffer replacement must move the presentation epoch \ + ({} -> {})", + opened.panel_epoch, + replaced.panel_epoch + ); + + // --- hidden by a frame too small to satisfy it -------------------- + // Q#BP2b: hiding is a durable transition, and the band must be + // cleared AUTHORITATIVELY. Silence would leave the retained frame + // on the peer's screen indefinitely. + session.declare(2, 4, COLS); + assert_eq!( + session.frame(), + Some(PanelFramePayload::Absent), + "38: hiding sends an authoritative Absent" + ); + assert!( + session.side_window().is_some(), + "…while the side window itself survives, with its request intact" + ); + + // --- reappear ----------------------------------------------------- + session.declare(3, ROWS, COLS); + let reappeared = session.present(); + assert!( + reappeared.panel_epoch > replaced.panel_epoch, + "49: every Absent -> Present transition takes a fresh epoch \ + ({} -> {})", + replaced.panel_epoch, + reappeared.panel_epoch + ); + assert_eq!( + reappeared.buffer_id, replaced.buffer_id, + "…even though the SAME persistent buffer came back, which is exactly \ + the hole a buffer id alone cannot close" + ); + + // --- close -------------------------------------------------------- + close_panel(&session); + assert_eq!(session.side_window(), None, "the side window is gone"); + assert_eq!( + session.frame(), + Some(PanelFramePayload::Absent), + "38: closing sends an authoritative Absent" + ); +} + +#[test] +fn acc49_close_and_reopen_of_the_same_buffer_takes_a_new_epoch() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + exec( + &session.state, + "PERSISTENT = pmacs.buffer.create(\"*persistent*\") + pmacs.window.display(PERSISTENT, { side = \"bottom\", height = 4 })", + ); + let first = session.present(); + + close_panel(&session); + assert_eq!(session.frame(), Some(PanelFramePayload::Absent)); + + exec( + &session.state, + "pmacs.window.display(PERSISTENT, { side = \"bottom\", height = 4 })", + ); + let second = session.present(); + + assert_eq!( + second.buffer_id, first.buffer_id, + "the SAME persistent buffer is back — a buffer id alone cannot \ + distinguish this from the original presentation" + ); + assert!( + second.panel_epoch > first.panel_epoch, + "49: the presentation epoch must have moved ({} -> {})", + first.panel_epoch, + second.panel_epoch + ); +} + +// --------------------------------------------------------------------------- +// 39 (receiver half) — duplicates do no work; an invalid frame is +// rejected atomically and the previous valid frame is retained +// --------------------------------------------------------------------------- + +#[test] +fn acc39_a_duplicate_frame_does_no_work() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + let _first = session.present(); + + assert_eq!( + session.frame(), + None, + "39: nothing changed, so the second frame ships no panel payload" + ); + assert_eq!( + session.frame(), + None, + "…and it stays quiet, rather than alternating" + ); +} + +#[test] +fn acc39_a_duplicate_absent_does_no_work() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + let _present = session.present(); + + close_panel(&session); + assert_eq!( + session.frame(), + Some(PanelFramePayload::Absent), + "the first Absent is authoritative and must be sent" + ); + assert_eq!( + session.frame(), + None, + "39: a duplicate Absent is suppressed exactly like any other payload" + ); +} + +/// 39, receiver half: rejection is atomic and the peer keeps its last +/// valid frame. +/// +/// The producer-reachable route to an invalid frame runs through the +/// **mode line**, not the text: `TextView::render` drops zero-width marks +/// and never emits a cluster, and the terminal screen caps its clusters +/// at `MAX_TERMINAL_GRAPHEME_BYTES` — but `prepare_mode_line_runs` emits +/// whole grapheme clusters verbatim, and one of its inputs is the buffer +/// **name**. A name carrying a cluster past +/// `MAX_WIRE_GRID_GRAPHEME_BYTES` therefore produces a structurally +/// invalid panel frame through the ordinary display path. +#[test] +fn acc39_an_invalid_frame_is_rejected_and_the_previous_one_is_retained() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + set_panel_text(&session, "before"); + let valid = session.present(); + assert!( + rows_of(&valid)[0].starts_with("before"), + "fixture precondition: the retained frame really shows the old text" + ); + + // One grapheme cluster far past the shared per-cell byte ceiling. + let monster = format!("x{}", "\u{301}".repeat(300)); + assert!( + monster.len() > pmacs_protocol::wire_grid::MAX_WIRE_GRID_GRAPHEME_BYTES, + "fixture precondition: the cluster really exceeds the shared ceiling" + ); + exec( + &session.state, + &format!( + "BAD = pmacs.buffer.create({monster:?}) + pmacs.window.display(BAD, {{ side = \"bottom\" }})" + ), + ); + + assert_eq!( + session.frame(), + None, + "39: an invalid frame is not shipped, whole or in part" + ); + let retained = session + .render + .panel_declaration() + .expect("the previous valid frame is retained"); + assert_eq!( + retained, &valid, + "39: the receiver's authority is unchanged — same cells, same epochs" + ); + + // The rejection also burned no presentation identity: nothing the + // peer ever saw carried the epoch the rejected frame would have used. + exec( + &session.state, + "GOOD = pmacs.buffer.create(\"*good*\") + pmacs.window.display(GOOD, { side = \"bottom\" })", + ); + let recovered = session.present(); + assert_eq!( + recovered.panel_epoch, + valid.panel_epoch + 1, + "the next shipped identity is the one the rejected frame did not \ + consume" + ); +} + +// --------------------------------------------------------------------------- +// 41 (daemon half) — the daemon alone derives the grid +// --------------------------------------------------------------------------- + +#[test] +fn acc41_row_clamping_preserves_the_stored_request() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 12); + assert_eq!(session.present().size.rows, 12, "the request fits at first"); + + let panel = session.side_window().expect("side window"); + // A frame with room for the document minimum plus three panel rows. + session.declare(2, 6, COLS); + let clamped = session.present(); + assert_eq!( + clamped.size.rows, 3, + "the panel is clamped, not the document subtree" + ); + assert_eq!( + session.state.core.borrow().windows[&panel] + .params + .fixed_rows, + Some(12), + "41: the STORED request survives the clamp, so a later wider frame \ + can restore it" + ); + + session.declare(3, ROWS, COLS); + assert_eq!( + session.present().size.rows, + 12, + "41: and it is restored exactly" + ); +} + +#[test] +fn acc41_the_wire_area_budget_clamps_rows_and_can_hide_the_panel() { + let max_cells = pmacs_protocol::panel::MAX_PANEL_VISIBLE_CELLS as u32; + + // Wide enough that only four rows fit inside the shared area bound. + let mut session = Session::new(); + let cols = max_cells / 4; + session.declare(1, 60, cols); + open_panel(&session, "*panel*", 20); + let frame = session.present(); + assert_eq!(frame.size.cols, cols); + assert_eq!( + frame.size.rows, 4, + "41: rows are clamped by the shared wire-area budget, not only by \ + the layout" + ); + frame + .validate() + .expect("41: a clamped frame is one the shared validator accepts"); + + // Wide enough that not even the structural two-row floor fits. + session.declare(2, 60, max_cells); + assert_eq!( + session.frame(), + Some(PanelFramePayload::Absent), + "41: when even two rows exceed the budget the panel follows the \ + Q#BP2b hidden arm" + ); +} + +#[test] +fn acc41_degenerate_geometry_fails_closed_to_zero_usable_grid() { + for (label, rows, cols) in [ + ("zero columns", ROWS, 0), + ("zero rows", 0, COLS), + ("a frame shorter than its own status row", 1, COLS), + ] { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + assert!( + matches!(session.frame(), Some(PanelFramePayload::Present(_))), + "{label}: fixture precondition — a band was visible first" + ); + + session.declare(2, rows, cols); + assert_eq!( + session.frame(), + Some(PanelFramePayload::Absent), + "{label}: declares zero usable geometry and hides, without \ + overflow or an oversized allocation" + ); + } +} + +// --------------------------------------------------------------------------- +// A2B-1 — the epoch state machine, row by row +// --------------------------------------------------------------------------- + +#[test] +fn a2b1_the_semantic_acceptance_table_holds_row_by_row() { + let session = Session::new(); + let total = CellSize::new(ROWS, COLS); + + assert_eq!( + session.declare(0, ROWS, COLS), + GeometryUpdate::Rejected, + "epoch 0 is reserved for 'never declared' and is rejected on the wire" + ); + assert_eq!( + session.state.core.borrow().frame_geometry_for(FID), + None, + "…and stores nothing" + ); + + assert_eq!(session.declare(5, ROWS, COLS), GeometryUpdate::Advanced); + assert_eq!(session.declare(5, ROWS, COLS), GeometryUpdate::Duplicate); + assert_eq!( + session.declare(5, ROWS, COLS + 1), + GeometryUpdate::Rejected, + "the same epoch with a different total is conflicting" + ); + assert_eq!( + session.declare(4, ROWS, COLS), + GeometryUpdate::Rejected, + "a LOWER epoch carrying identical data is still stale" + ); + assert_eq!( + session + .state + .core + .borrow() + .frame_geometry_for(FID) + .map(|geometry| (geometry.geometry_epoch, geometry.total)), + Some((5, total)), + "every rejection left the stored declaration untouched" + ); + + assert_eq!( + session.declare(6, ROWS, COLS), + GeometryUpdate::Advanced, + "Q#BP2S1: a greater epoch is accepted even when the total is \ + IDENTICAL — the font/scale case daemon-side value dedup cannot see" + ); + assert_eq!( + session + .state + .core + .borrow() + .frame_geometry_for(FID) + .map(|geometry| geometry.geometry_epoch), + Some(6), + "…and it is stored verbatim" + ); +} + +#[test] +fn a2b1_a_duplicate_reconciles_nothing_while_an_advance_does() { + let session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + + // Force the derived visibility cache to a value only reconciliation + // can correct. `Duplicate` must leave it alone; `Advanced` must not. + let force_hidden = |session: &Session| { + session + .state + .core + .borrow_mut() + .views + .get_mut(&FID) + .expect("view") + .panel_hidden = true; + }; + + force_hidden(&session); + assert_eq!(session.declare(1, ROWS, COLS), GeometryUpdate::Duplicate); + assert!( + session.state.core.borrow().panel_hidden_for(FID), + "a Duplicate returns without touching panel state" + ); + + assert_eq!( + session.declare(2, ROWS, COLS), + GeometryUpdate::Advanced, + "…while an advance with the same total still reconciles" + ); + assert!( + !session.state.core.borrow().panel_hidden_for(FID), + "Advanced ran the reconciliation the Duplicate skipped" + ); +} + +#[test] +fn a2b1_a_rejected_declaration_reconciles_nothing() { + let session = Session::new(); + session.declare(4, ROWS, COLS); + open_panel(&session, "*panel*", 4); + session + .state + .core + .borrow_mut() + .views + .get_mut(&FID) + .expect("view") + .panel_hidden = true; + + assert_eq!(session.declare(3, ROWS, COLS), GeometryUpdate::Rejected); + assert!( + session.state.core.borrow().panel_hidden_for(FID), + "a Rejected declaration is dropped BEFORE any reconciliation" + ); +} + +#[test] +fn a2b1_grid_allocator_exhaustion_clears_the_declaration_and_hides() { + // The grid/LOCAL allocator, which mints its own epochs. `LOCAL` is + // panel-capable, so this is the production path for a TUI. + let state = EditorState::new(); + exec(&state, "pmacs.lsp.config = {}"); + state.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + exec( + &state, + "pmacs.window.display(pmacs.buffer.create(\"*panel*\"), \ + { side = \"bottom\", height = 4 })", + ); + assert!( + !state.core.borrow().panel_hidden_for(FrontendId::LOCAL), + "fixture precondition: the panel is visible before exhaustion" + ); + + // Drive the allocator to its last id. + state + .core + .borrow_mut() + .views + .get_mut(&FrontendId::LOCAL) + .expect("view") + .frame_geometry = Some(pmacs::window::DeclaredFrameGeometry { + geometry_epoch: u64::MAX, + total: CellSize::new(ROWS, COLS), + }); + + // A REAL resize now needs an id the allocator cannot mint. + assert_eq!( + state.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS + 1, COLS)), + GeometryUpdate::Rejected, + "checked allocation refuses rather than pinning at u64::MAX, where \ + two different geometries would share one id" + ); + assert_eq!( + state.core.borrow().frame_geometry_for(FrontendId::LOCAL), + None, + "A2B-1: exhaustion CLEARS the authoritative declaration; retaining \ + the old one would keep painting a panel sized to a frame that no \ + longer exists" + ); + assert!( + state.core.borrow().panel_hidden_for(FrontendId::LOCAL), + "A2B-1: unknown geometry is non-presentable, so the panel hides" + ); + assert_eq!( + state.core.borrow().panel_grid_size(FrontendId::LOCAL), + None, + "…and no stale-geometry grid is derivable afterwards" + ); +} + +// --------------------------------------------------------------------------- +// 51 — a non-panel-capable semantic frontend gets no band at all +// --------------------------------------------------------------------------- + +#[test] +fn acc51_a_pre_panel_semantic_frontend_is_never_sent_a_panel_frame() { + let mut session = Session::new(); + session + .state + .core + .borrow_mut() + .views + .get_mut(&FID) + .expect("view") + .panel_capable = false; + session.declare(1, ROWS, COLS); + // The Stage 1 fallback discards `side`, so this lands in the document + // window; assert that directly rather than assuming it. + open_panel(&session, "*panel*", 4); + assert_eq!( + session.side_window(), + None, + "51: capability fallback placed the buffer in a document window" + ); + assert_eq!( + session.frame(), + None, + "51: and no band message is produced for it in any case" + ); +} + +#[test] +fn acc51_a_v20_peer_is_sent_no_panel_frame_even_when_capable() { + let mut session = Session::new(); + session.render = SemanticRenderState::for_peer(FID, PROTOCOL_VERSION - 1); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + + assert!( + session.state.core.borrow().panel_grid_size(FID).is_some(), + "fixture precondition: the daemon CAN derive a grid here" + ); + assert_eq!( + session.frame(), + None, + "51: a peer below the panel version receives no PanelFrame" + ); +} + +// --------------------------------------------------------------------------- +// 42 — focusing the panel does not disturb the document projection +// --------------------------------------------------------------------------- + +#[test] +fn acc42_focusing_the_panel_leaves_the_document_surface_alone() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + let panel = session.side_window().expect("side window"); + let document_buffer = session.state.core.borrow().windows[&session.document].buffer_id; + + let unfocused = session.present(); + assert!(!unfocused.focused, "the panel does not own focus yet"); + + session.state.core.borrow_mut().focus_window(FID, panel); + let focused = session.present(); + assert!(focused.focused, "the frame reports the focus transition"); + + let core = session.state.core.borrow(); + assert_eq!( + core.primary_document_window(FID), + Some(session.document), + "42: the document surface is unchanged while the panel is focused" + ); + assert_eq!( + core.primary_document_buffer(FID), + Some(document_buffer), + "42: and still names the document buffer, not the panel's" + ); +} + +// --------------------------------------------------------------------------- +// 52 — the panel honors the OWNING frontend's fold projection +// --------------------------------------------------------------------------- + +#[test] +fn acc52_a_non_projecting_frontend_sees_every_source_line_in_its_panel() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 6); + set_panel_text(&session, "alpha\nbravo\ncharlie\ndelta"); + exec( + &session.state, + "assert(pmacs.fold.fold(PANEL_BUF, { start = 0, ['end'] = 17 }))", + ); + + let rows = rows_of(&session.present()); + let painted = rows.join("\n"); + for line in ["alpha", "bravo", "charlie"] { + assert!( + painted.contains(line), + "52: fold_projection = false means the panel collapses nothing; \ + {line:?} is missing from\n{painted}" + ); + } + + // The discriminating half: the same fold DOES collapse for a + // projecting frontend, so the assertion above is not vacuous. + session + .state + .core + .borrow_mut() + .views + .get_mut(&FID) + .expect("view") + .fold_projection = true; + let projected = rows_of(&session.present()).join("\n"); + assert!( + !projected.contains("bravo"), + "sanity: with projection on, the folded lines really do disappear \ + from\n{projected}" + ); +} + +// --------------------------------------------------------------------------- +// 45 — one provider invocation supplies both surfaces +// --------------------------------------------------------------------------- + +#[test] +fn acc45_one_statusline_invocation_serves_the_document_and_the_panel() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + let panel_buffer = { + let core = session.state.core.borrow(); + let side = core.side_window_for(FID).expect("side window"); + core.windows[&side].buffer_id + }; + // The semantic fan-out is keyed on the DECLARED viewport buffer, so + // the document half only runs once the frontend has declared one. + let document_buffer = session.state.core.borrow().windows[&session.document].buffer_id; + session.render.set_viewport( + document_buffer, + pmacs::protocol::ByteRange { start: 0, end: 0 }, + 0, + ); + + exec( + &session.state, + "CALLS = 0 + pmacs.statusline.register { + name = 'probe', side = 'left', + fn = function(ctx) CALLS = CALLS + 1; return 'W' .. tostring(ctx.window) end, + }", + ); + + let messages = session.render.render_frame(&session.state); + let calls: u32 = session + .state + .lua_host + .lua() + .load("return CALLS") + .eval() + .expect("counter"); + assert_eq!( + calls, 2, + "45: exactly one invocation per visible context — the primary \ + document and the visible side window — and no second evaluation \ + for the band" + ); + + let panel_rows = messages + .iter() + .find_map(|message| match message { + InstanceMessage::PanelFrame(PanelFramePayload::Present(frame)) => Some(rows_of(frame)), + _ => None, + }) + .expect("a panel frame"); + let mode_line = panel_rows.last().expect("mode line").clone(); + let panel_name = { + let core = session.state.core.borrow(); + let reg = core.registry.borrow(); + reg.get(panel_buffer) + .expect("panel buffer") + .name() + .to_owned() + }; + assert!( + mode_line.contains(&panel_name), + "45: the band's mode line carries the SIDE window's provider text, \ + not the document's; got {mode_line:?}" + ); +} + +// --------------------------------------------------------------------------- +// The producer never touches a passive panel's scroll state +// --------------------------------------------------------------------------- + +#[test] +fn a_passive_panel_keeps_its_view_top_while_a_focused_one_scrolls_to_its_caret() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + let panel = session.side_window().expect("side window"); + let body: String = (0..40).map(|n| format!("line {n}\n")).collect(); + set_panel_text(&session, &body); + + // Put the panel's caret far below its viewport while it is PASSIVE. + { + let mut core = session.state.core.borrow_mut(); + let window = core.windows.get_mut(&panel).expect("panel window"); + window.cursor = 200; + window.view_top = 0; + } + let _ = session.present(); + assert_eq!( + session.state.core.borrow().windows[&panel].view_top, + 0, + "a passive panel's view_top is not moved by the projection" + ); + + session.state.core.borrow_mut().focus_window(FID, panel); + let _ = session.present(); + assert!( + session.state.core.borrow().windows[&panel].view_top > 0, + "…while a FOCUSED panel runs the shared auto-scroll clamp" + ); +} + +// --------------------------------------------------------------------------- +// The band rides the terminal document path too +// --------------------------------------------------------------------------- + +#[test] +fn the_band_is_projected_even_when_the_document_surface_is_a_terminal() { + // A frontend with no declared byte viewport takes neither the + // document nor the terminal pass; the band must still be produced, + // or the first panel would be unpaintable. + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + assert!( + session.render.panel_declaration().is_none(), + "fixture precondition: nothing shipped yet" + ); + let frame = session.present(); + assert_eq!(frame.size.cols, COLS); + assert!( + session.render.panel_declaration().is_some(), + "the declaration is recorded for the inbound validation ladder" + ); +} + +// --------------------------------------------------------------------------- +// Sanity: the projected cells really are the panel window's content +// --------------------------------------------------------------------------- + +#[test] +fn the_projection_paints_the_side_windows_own_buffer() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + set_panel_text(&session, "panel-content"); + let frame = session.present(); + let rows = rows_of(&frame); + assert_eq!(rows.len(), frame.size.rows as usize); + assert!( + rows[0].starts_with("panel-content"), + "the first row is the panel buffer's first line, got {:?}", + rows[0] + ); + assert_eq!( + frame.cells.len(), + (frame.size.rows * frame.size.cols) as usize, + "exactly size.area() cells" + ); + frame.validate().expect("a produced frame is a valid frame"); + let _ = HashMap::::new(); +} From a251b89e2bd6f7a5ad641bdfe0854901c0339b20 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 17:32:27 -0400 Subject: [PATCH 3/7] Pin the two epoch races the session check alone cannot see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Q#BP16 step 3 compares an inbound event's geometry epoch against BOTH the declaration the frontend was looking at and the daemon's latest accepted one, and mutation testing showed the second half unpinned: no fixture made the two diverge, so deleting it changed nothing. They diverge exactly once — between a declaration being accepted and the next frame answering it — which is the font/scale/resize race the epoch exists for. Also pins the attach/resize gate change at the seam it would break: a semantic frontend's `Resize` must mint no frame geometry even when its view is panel-capable, while a grid frontend's real frame size still IS its declaration. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- src/daemon.rs | 159 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/src/daemon.rs b/src/daemon.rs index 16364cd..8aaa434 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -6132,6 +6132,97 @@ mod tests { ); } + /// Criterion 49's font/scale/resize race, and the reason Q#BP16 step + /// 3 compares the payload's geometry epoch against **both** the + /// shipped declaration and the daemon's latest accepted one. + /// + /// A declaration accepted between two renders makes the two diverge: + /// the frontend is still looking at a frame answering the old epoch, + /// so a gesture matching that frame passes the session-side check + /// alone. Only the daemon-side comparison catches it — which is + /// exactly "an older retained frame neither paints nor accepts input + /// after a new `geometry_epoch` until a matching `Present` arrives". + #[test] + fn a_gesture_answering_a_superseded_geometry_is_dropped() { + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(712); + let (document, panel) = semantic_panel_view(&editor, fid, true); + let panel = panel.expect("panel window"); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + ); + editor.accept_semantic_frame_geometry(fid, 1, CellSize::new(24, 80)); + let (geometry_epoch, panel_epoch) = shipped_declaration(&editor, fid, &mut semantic_states); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + + // Q#BP2S1: a font or scale change that leaves `CellSize` + // IDENTICAL still advances the epoch, and no frame answering it + // has been painted yet. + assert_eq!( + editor.accept_semantic_frame_geometry(fid, 2, CellSize::new(24, 80)), + crate::editor_core::GeometryUpdate::Advanced + ); + assert_eq!( + semantic_states[&fid] + .panel_declaration() + .map(|frame| frame.geometry_epoch), + Some(geometry_epoch), + "fixture precondition: the SHIPPED declaration still answers the \ + old epoch, so the session-side check alone would accept" + ); + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + FrontendEvent::PanelPointer { + frontend_id: fid, + geometry_epoch, + panel_epoch, + buffer_id, + coord: pmacs_protocol::CellCoord::new(0, 0), + kind: pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left), + mods: pmacs_protocol::Modifiers::default(), + }, + ); + + assert_eq!( + editor.core.borrow().views[&fid].active, + document, + "a gesture hit-tested against superseded geometry must drop" + ); + + // The accepted counterpart: once a frame answering the new epoch + // ships, the same gesture shape is honored again. + let (fresh_geometry, fresh_panel) = shipped_declaration(&editor, fid, &mut semantic_states); + assert_eq!(fresh_geometry, 2, "the new frame answers the new epoch"); + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + FrontendEvent::PanelPointer { + frontend_id: fid, + geometry_epoch: fresh_geometry, + panel_epoch: fresh_panel, + buffer_id, + coord: pmacs_protocol::CellCoord::new(0, 0), + kind: pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left), + mods: pmacs_protocol::Modifiers::default(), + }, + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + panel, + "…and a gesture answering the CURRENT geometry is accepted" + ); + } + /// Criteria 49/50 for the resize event: matching epochs move the /// stored request, a stale presentation epoch does not. #[test] @@ -6188,6 +6279,74 @@ mod tests { ); } + /// Parent acceptance 40, at the seam it would actually break: a + /// semantic frontend's `Resize` must not mint frame geometry, even + /// when its view is panel-capable. + /// + /// Stage 1's gate was `panel_capable_for`, which was equivalent to + /// "grid" only because no semantic session could be panel-capable. + /// Once one can be, that gate feeds it exactly the placeholder + /// Q#BP15a forbids — and the failure would show up as a wrongly + /// sized first panel, far from this line. + #[test] + fn a_semantic_resize_does_not_mint_frame_geometry() { + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(710); + semantic_panel_view(&editor, fid, true); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + ); + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + FrontendEvent::Resize { + frontend_id: fid, + size: CellSize::new(24, 80), + }, + ); + + assert_eq!( + editor.core.borrow().frame_geometry_for(fid), + None, + "40: only FrontendCellGeometry is a semantic frontend's \ + authoritative cell-equivalent declaration" + ); + + // The discriminating half: a GRID frontend's resize still is its + // declaration, so the gate is not simply switched off. + let grid = FrontendId(711); + let view = build_fresh_frontend_view(&mut editor, true, true); + editor.core.borrow_mut().register_frontend_view(grid, view); + let mut render_states = HashMap::new(); + render_states.insert(grid, RenderState::new(CellSize::new(24, 80))); + dispatch_panel_event( + &mut editor, + grid, + PROTOCOL_VERSION, + &mut HashMap::new(), + &mut render_states, + FrontendEvent::Resize { + frontend_id: grid, + size: CellSize::new(30, 90), + }, + ); + assert_eq!( + editor + .core + .borrow() + .frame_geometry_for(grid) + .map(|geometry| geometry.total), + Some(CellSize::new(30, 90)), + "a grid frontend's real frame size IS its declaration" + ); + } + /// Q#BP9's write-loop gate, independent of the producer's own flag. #[test] fn the_panel_frame_write_gate_rejects_v20_independently() { From 37f1ca936bd4651ac040d4c929a4ee04f1325111 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 17:34:40 -0400 Subject: [PATCH 4/7] Keep the passive-panel fixture inside its buffer `clippy::format_collect` rejected the generated body; the replacement was shorter than the hard-coded caret offset, so the caret landed past the end and the focused half stopped exercising the clamp. Derive the offset from the body instead of restating it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- tests/bottom_panel_stage2b_daemon_acceptance.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/bottom_panel_stage2b_daemon_acceptance.rs b/tests/bottom_panel_stage2b_daemon_acceptance.rs index f7f5ada..adf3774 100644 --- a/tests/bottom_panel_stage2b_daemon_acceptance.rs +++ b/tests/bottom_panel_stage2b_daemon_acceptance.rs @@ -907,14 +907,15 @@ fn a_passive_panel_keeps_its_view_top_while_a_focused_one_scrolls_to_its_caret() session.declare(1, ROWS, COLS); open_panel(&session, "*panel*", 4); let panel = session.side_window().expect("side window"); - let body: String = (0..40).map(|n| format!("line {n}\n")).collect(); + let body = vec!["line-of-text"; 40].join("\n"); + let last_byte = body.len() as u64; set_panel_text(&session, &body); // Put the panel's caret far below its viewport while it is PASSIVE. { let mut core = session.state.core.borrow_mut(); let window = core.windows.get_mut(&panel).expect("panel window"); - window.cursor = 200; + window.cursor = last_byte; window.view_top = 0; } let _ = session.present(); From 3ecb03d94913585c5f8e9b528d9178fd979db7d5 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 18:52:43 -0400 Subject: [PATCH 5/7] Close review round 1: five findings, plus one the sweep found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All five review findings reproduced with a failing test before any fix, and every fix falsified by reverting it. R1-1 — the wire-area clamp lived only in `panel_grid_size`, so the daemon shipped an authoritative `Absent` while `panel_hidden` stayed false: keys kept reaching the invisible window and a panel terminal kept its controller. Q#BP2b calls hiding a DURABLE state transition and the exhaustion arm had made it a per-frame effect. Fixed structurally rather than pointwise: `presentable_panel_grid` is now the one derivation behind both the renderer and `reconcile_panel_layout_core`, so the two cannot drift apart again. R1-2 — closing and reopening the same PERSISTENT buffer inside one dispatcher burst left the shipped declaration intact while the window it described was already dead, and same-buffer/same-size made the successor indistinguishable by every other field. A presentation epoch only identifies a presentation if something checks that the presentation it names is still on screen, so `panel_declaration_matches` now takes the live side window and buffer. R1-3 — the semantic terminal-layout twin consulted only the full-document declaration, which a panel terminal deliberately lacks, so the child kept its opening geometry through the drain. `sync_semantic_panel_terminal_ layout` is the missing case; it resolves through `side_window_for` while its sibling resolves through `primary_document_window`, so the two are disjoint by construction and nothing is resized twice per tick. R2-4 — `NoMessage` means publish nothing, not publish empty. Treating it like `Invalidated` removed the band's provider text on a transient buffer-follow mismatch. The band repaints its whole mode line every frame, so "publish nothing" has to be a retained baseline; it is keyed by window id so a replaced panel inherits nothing. R2-5 — non-`Move` activation is Q#BP16's TERMINAL clause, because the shared adapter claims the controller for wheel steps too. A document panel keeps scroll-without-focus, matching `dispatch_mouse`. The sweep for R1-1's and R1-3's shape found one more, and it is the same bug as R1-1: a panel wider than the terminal subsystem's per-axis cap is legal on the wire (Bet B5') but its content rect was refused by `snapshot_for_view`, collapsing the projection to `None` — a per-frame `Absent` with the durable state still saying visible, reachable with one `FrontendCellGeometry` declaration. The band is legitimately that wide, so the child is clamped to the columns a PTY can have and the remainder paints as band background, exactly as a narrower snapshot already does. One knowingly per-frame `Absent` remains and is recorded in the code rather than fixed: presentation-epoch exhaustion, which takes 2^64 shipped presentation changes in one session and cannot be reached by any frontend. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- src/daemon.rs | 440 +++++++++++++++++- src/editor.rs | 151 +++++- src/editor_core.rs | 45 +- src/semantic_render.rs | 136 ++++-- .../bottom_panel_stage2b_daemon_acceptance.rs | 235 ++++++++++ 5 files changed, 947 insertions(+), 60 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index 8aaa434..c97e032 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -947,22 +947,23 @@ fn peer_accepts_panel_message(protocol_version: u32, message: &InstanceMessage) /// non-panel-capable view is rejected before any payload state is /// trusted. /// -/// The claimed `frontend_id` in the payload is never consulted anywhere: -/// routing is by the authenticated transport `source`, so a forged id -/// addresses nothing. /// Q#BP16 steps 2–4: the event addresses the panel declaration this /// session most recently shipped, under the geometry it most recently -/// accepted. +/// accepted, and that declaration still describes the panel on screen. /// -/// Three facts, one predicate, because they close three different holes -/// and no two of them subsume the third: +/// Four facts, one predicate, because they close four different holes +/// and no three of them subsume the fourth: /// /// * the latest declaration is a `Present` (an `Absent` cleared input /// authority, so nothing is addressable), /// * its echoed `geometry_epoch` equals both the payload's **and** the /// daemon's latest accepted declaration — the font/scale/resize race, /// * its `panel_epoch` equals the payload's — close/hide/reopen of the -/// same persistent buffer, which a `buffer_id` alone cannot see. +/// same persistent buffer, which a `buffer_id` alone cannot see, +/// * and the presentation behind it is the side window that is live +/// **now** — because a close/reopen inside one dispatcher burst does +/// not invalidate the shipped declaration, only the window it named +/// (review round 1, R1-2). fn panel_event_epochs_are_current( editor: &EditorState, semantic_states: &HashMap, @@ -970,16 +971,33 @@ fn panel_event_epochs_are_current( geometry_epoch: u64, panel_epoch: u64, ) -> bool { - semantic_states - .get(&source) - .is_some_and(|sem| sem.panel_declaration_matches(geometry_epoch, panel_epoch)) - && editor - .core - .borrow() - .frame_geometry_for(source) - .is_some_and(|geometry| geometry.geometry_epoch == geometry_epoch) + let core = editor.core.borrow(); + let live_presentation = core.side_window_for(source).and_then(|window_id| { + core.windows + .get(&window_id) + .map(|window| (window_id, window.buffer_id)) + }); + semantic_states.get(&source).is_some_and(|sem| { + sem.panel_declaration_matches(geometry_epoch, panel_epoch, live_presentation) + }) && core + .frame_geometry_for(source) + .is_some_and(|geometry| geometry.geometry_epoch == geometry_epoch) } +/// Whether an authenticated source may send the v21 panel event family +/// (Q#BP9's "every gate keys on the daemon's own state"). +/// +/// All three inbound events require the same three facts, and they are +/// checked together so no arm can satisfy two and forget the third: an +/// installed **semantic** projection, a negotiated version that carries +/// the variants, and a `FrontendView` this daemon itself marked +/// panel-capable. A grid session, a pre-panel semantic peer, or a +/// non-panel-capable view is rejected before any payload state is +/// trusted. +/// +/// The claimed `frontend_id` in the payload is never consulted anywhere: +/// routing is by the authenticated transport `source`, so a forged id +/// addresses nothing. fn peer_may_send_panel_events( editor: &EditorState, session_registry: &SessionRegistry, @@ -3428,6 +3446,16 @@ fn sync_terminal_layouts_for_tick( if let Some((buffer_id, size)) = state.terminal_viewport() { editor.sync_semantic_terminal_layout(*frontend_id, buffer_id, size); } + // Bottom-panel R1-3: the band is the semantic frontend's + // OTHER terminal surface, and it has no declaration to + // consult — the daemon derives its geometry (Q#BP15a). The + // grid arm below gets this for free because it resolves + // through `controller_view_for_frontend`, which is + // window-agnostic; the semantic arm above is keyed to the + // document declaration and structurally cannot see a side + // window. Both calls target disjoint windows, so this is a + // second CASE, not a second resize of the same child. + editor.sync_semantic_panel_terminal_layout(*frontend_id); } else if let Some(size) = term_sizes.get(frontend_id).copied() { editor.sync_terminal_grid_geometry(*frontend_id, size); } @@ -6347,6 +6375,388 @@ mod tests { ); } + // ----------------------------------------------------------------- + // Review round 1 — three findings the mutation pass could not reach, + // because each is a behaviour that was never modelled rather than a + // line that was written wrong. + // ----------------------------------------------------------------- + + /// R1-2: closing and reopening the SAME persistent buffer inside one + /// dispatcher burst — before the next render can ship a new + /// declaration — must not let a stale gesture address the successor. + /// + /// Same buffer, same size, same geometry: `buffer_id` and the grid + /// bounds are identical on both sides, so the only thing that can + /// tell the two presentations apart is the presentation identity + /// itself (Q#BP16). Validating the last SHIPPED declaration alone is + /// not enough — it still describes the dead window. + #[test] + #[allow( + clippy::too_many_lines, + reason = "one close/reopen transaction plus both event kinds and the accepted counterpart" + )] + fn a_stale_panel_epoch_cannot_address_a_reopened_same_buffer_panel() { + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(713); + let (document, panel) = semantic_panel_view(&editor, fid, true); + let first_panel = panel.expect("panel window"); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + ); + editor.accept_semantic_frame_geometry(fid, 1, CellSize::new(24, 80)); + let (geometry_epoch, panel_epoch) = shipped_declaration(&editor, fid, &mut semantic_states); + let buffer_id = editor.core.borrow().windows[&first_panel].buffer_id; + + // Close and reopen the SAME buffer, with no render in between. + { + let mut core = editor.core.borrow_mut(); + core.active_frontend = fid; + core.focus_window(fid, first_panel); + assert!( + core.close_active(), + "closing a side window is legal even as the only other window" + ); + } + editor.reconcile_panel_layout(fid); + { + let mut core = editor.core.borrow_mut(); + let mut request = crate::editor_core::DisplayRequest::new(buffer_id); + request.side = Some(crate::window::Side::Bottom); + request.height = Some(4); + core.display_buffer(fid, &request) + .expect("reopen the panel"); + } + editor.reconcile_panel_layout(fid); + let second_panel = editor.core.borrow().side_window_for(fid).expect("reopened"); + assert_ne!( + second_panel, first_panel, + "fixture precondition: the successor is a different window" + ); + assert_eq!( + editor.core.borrow().windows[&second_panel].buffer_id, + buffer_id, + "…showing the SAME persistent buffer, which is what makes it \ + indistinguishable by buffer id" + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + document, + "fixture precondition: focus is on the document after the reopen" + ); + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + FrontendEvent::PanelPointer { + frontend_id: fid, + geometry_epoch, + panel_epoch, + buffer_id, + coord: pmacs_protocol::CellCoord::new(0, 0), + kind: pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left), + mods: pmacs_protocol::Modifiers::default(), + }, + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + document, + "R1-2: a gesture aimed at the CLOSED presentation must not \ + activate its successor" + ); + + // …and the resize event follows the same ladder. + let before = editor.core.borrow().windows[&second_panel] + .params + .fixed_rows; + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + FrontendEvent::PanelResizeRows { + frontend_id: fid, + geometry_epoch, + panel_epoch, + rows: 9, + }, + ); + assert_eq!( + editor.core.borrow().windows[&second_panel] + .params + .fixed_rows, + before, + "R1-2: nor resize it" + ); + + // The accepted counterpart: once a frame describing the successor + // ships, the same gesture shape is honored. + let (fresh_geometry, fresh_panel_epoch) = + shipped_declaration(&editor, fid, &mut semantic_states); + assert_ne!( + fresh_panel_epoch, panel_epoch, + "the successor took a fresh presentation identity" + ); + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + FrontendEvent::PanelPointer { + frontend_id: fid, + geometry_epoch: fresh_geometry, + panel_epoch: fresh_panel_epoch, + buffer_id, + coord: pmacs_protocol::CellCoord::new(0, 0), + kind: pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left), + mods: pmacs_protocol::Modifiers::default(), + }, + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + second_panel, + "…and the CURRENT presentation is addressable" + ); + } + + /// R2-5: Q#BP16 keeps scroll-without-focus for a NON-terminal panel. + /// Non-`Move` activation is the terminal-specific clause, because the + /// shared terminal adapter claims the controller for wheel steps too. + #[test] + #[allow( + clippy::too_many_lines, + reason = "the two panel kinds are the discriminating pair and must share one fixture shape" + )] + fn a_wheel_step_focuses_a_terminal_panel_but_not_a_document_panel() { + use crate::terminal::TerminalSpec; + + // --- non-terminal panel: the wheel must NOT focus ------------- + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(714); + let (document, panel) = semantic_panel_view(&editor, fid, true); + let panel = panel.expect("panel window"); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + ); + editor.accept_semantic_frame_geometry(fid, 1, CellSize::new(24, 80)); + let (geometry_epoch, panel_epoch) = shipped_declaration(&editor, fid, &mut semantic_states); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + let wheel = |buffer_id, geometry_epoch, panel_epoch| FrontendEvent::PanelPointer { + frontend_id: fid, + geometry_epoch, + panel_epoch, + buffer_id, + coord: pmacs_protocol::CellCoord::new(0, 0), + kind: pmacs_protocol::MouseKind::ScrollUp, + mods: pmacs_protocol::Modifiers::default(), + }; + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + wheel(buffer_id, geometry_epoch, panel_epoch), + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + document, + "R2-5: wheel motion over a document panel scrolls without focus" + ); + + // …while a press still focuses it, so the assertion above is not + // "panel pointers do nothing". + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + FrontendEvent::PanelPointer { + frontend_id: fid, + geometry_epoch, + panel_epoch, + buffer_id, + coord: pmacs_protocol::CellCoord::new(0, 0), + kind: pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left), + mods: pmacs_protocol::Modifiers::default(), + }, + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + panel, + "click-to-focus is unchanged" + ); + + // --- terminal panel: every non-Move gesture DOES focus -------- + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(715); + let (document, panel) = semantic_panel_view(&editor, fid, true); + let panel = panel.expect("panel window"); + let mut spec = TerminalSpec::new("/bin/sh"); + spec.args = vec!["-c".into(), "sleep 30".into()]; + spec.rows = 4; + spec.cols = 20; + let terminal_buffer = editor + .terminal_manager + .borrow_mut() + .open( + spec, + &mut editor.core.borrow_mut(), + &mut editor.process_supervisor.borrow_mut(), + ) + .expect("open panel terminal"); + { + let mut core = editor.core.borrow_mut(); + let text_view = { + let registry = core.registry.clone(); + let registry = registry.borrow(); + crate::text_view::TextView::new( + registry.get(terminal_buffer).expect("terminal buffer"), + ) + }; + let window = core.windows.get_mut(&panel).expect("panel window"); + window.buffer_id = terminal_buffer; + window.text_view = text_view; + } + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + ); + editor.accept_semantic_frame_geometry(fid, 1, CellSize::new(24, 80)); + let (geometry_epoch, panel_epoch) = shipped_declaration(&editor, fid, &mut semantic_states); + assert_eq!( + editor.core.borrow().views[&fid].active, + document, + "fixture precondition: the terminal panel starts passive" + ); + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + wheel(terminal_buffer, geometry_epoch, panel_epoch), + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + panel, + "R2-5: a TERMINAL panel activates on every non-Move gesture, \ + because the shared adapter claims the controller for wheel \ + steps too" + ); + } + + /// R1-3: a semantic frontend's PANEL terminal must be resized to the + /// daemon-derived content grid before the child's output is drained. + /// + /// The two terminal-layout syncs are twins and must stay alternatives + /// (that is why `sync_terminal_layouts_for_tick` exists), but the grid + /// twin resolves through `controller_view_for_frontend` and therefore + /// covers a side window for free, while the semantic twin consulted + /// only the full-document declaration — which a panel terminal + /// deliberately does not have. + #[test] + fn a_semantic_panel_terminal_is_resized_before_the_child_drain() { + use crate::terminal::{TerminalSpec, view::TerminalViewKey}; + + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(716); + let (_document, panel) = semantic_panel_view(&editor, fid, true); + let panel = panel.expect("panel window"); + let mut spec = TerminalSpec::new("/bin/sh"); + spec.args = vec!["-c".into(), "sleep 30".into()]; + spec.rows = 4; + spec.cols = 20; + let terminal_buffer = editor + .terminal_manager + .borrow_mut() + .open( + spec, + &mut editor.core.borrow_mut(), + &mut editor.process_supervisor.borrow_mut(), + ) + .expect("open panel terminal"); + { + let mut core = editor.core.borrow_mut(); + let text_view = { + let registry = core.registry.clone(); + let registry = registry.borrow(); + crate::text_view::TextView::new( + registry.get(terminal_buffer).expect("terminal buffer"), + ) + }; + let window = core.windows.get_mut(&panel).expect("panel window"); + window.buffer_id = terminal_buffer; + window.text_view = text_view; + } + // The panel view owns the child: only the durable controller's + // declaration reaches the PTY, and the per-tick liveness pass + // releases a controller whose window is not focused, so the panel + // has to actually own focus. Registering the view first mirrors + // what the first projection does. + let key = TerminalViewKey::new(fid, panel, terminal_buffer); + editor.core.borrow_mut().focus_window(fid, panel); + { + let mut manager = editor.terminal_manager.borrow_mut(); + manager.record_view_size(key, CellSize::new(4, 20)); + assert!( + manager.claim_controller(key), + "fixture precondition: the panel view controls the child" + ); + } + + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + ); + editor.accept_semantic_frame_geometry(fid, 1, CellSize::new(24, 120)); + assert_eq!( + editor.core.borrow().panel_grid_size(fid), + Some(CellSize::new(4, 120)), + "fixture precondition: the daemon derives a 4x120 band, so its \ + CONTENT grid is 3x120" + ); + assert_eq!( + editor + .terminal_manager + .borrow() + .screen_size(terminal_buffer), + Some(CellSize::new(4, 20)), + "fixture precondition: the child still has its opening size" + ); + + sync_terminal_layouts_for_tick( + &mut editor, + &[fid], + &HashMap::from([(fid, CellSize::new(24, 120))]), + &semantic_states, + ); + + assert_eq!( + editor + .terminal_manager + .borrow() + .screen_size(terminal_buffer), + Some(CellSize::new(3, 120)), + "R1-3: the panel terminal adopts the daemon-derived content \ + grid at the tick's layout step, BEFORE tick_processes drains \ + the child" + ); + } + /// Q#BP9's write-loop gate, independent of the producer's own flag. #[test] fn the_panel_frame_write_gate_rejects_v20_independently() { diff --git a/src/editor.rs b/src/editor.rs index 613bcf7..4be27be 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1870,6 +1870,91 @@ impl EditorState { } } + /// Sync a semantic frontend's **panel** terminal to the + /// daemon-derived content grid (Q#BP7 / Q#BP15a). + /// + /// The sibling of [`Self::sync_semantic_terminal_layout`], and the + /// case review round 1 (R1-3) found missing. The two arms are + /// disjoint by construction rather than by discipline: + /// `sync_semantic_terminal_layout` resolves its window through + /// `primary_document_window`, so it can never reach a side window, + /// and this one resolves through `side_window_for`, so it can never + /// reach the document. Nothing is ever resized twice per tick — the + /// failure mode the extraction of `sync_terminal_layouts_for_tick` + /// exists to prevent. + /// + /// A panel terminal has **no** `FrontendEvent::TerminalResize` + /// declaration to consult: the daemon derives its geometry, the + /// frontend never asserts it (Q#BP15a). So the size comes from + /// `panel_grid_size` minus the panel's one mode line, and it must be + /// applied at the tick's layout step — before `tick_processes` + /// drains the child — or the program formats its output against a + /// geometry the band is not showing. + /// + /// Recording the view size is unconditional for a resolvable panel + /// (that is what gives a passive view its own clipped projection); + /// only the durable controller resizes the shared PTY. + /// + /// Returns whether the shared screen geometry actually changed. + pub fn sync_semantic_panel_terminal_layout(&mut self, frontend_id: FrontendId) -> bool { + let Some((window_id, buffer_id, content)) = ({ + let core = self.core.borrow(); + core.panel_grid_size(frontend_id).and_then(|size| { + let window_id = core.side_window_for(frontend_id)?; + let buffer_id = core.windows.get(&window_id)?.buffer_id; + Some(( + window_id, + buffer_id, + CellSize::new(size.rows.saturating_sub(1), size.cols), + )) + }) + }) else { + return false; + }; + if content.rows == 0 || content.cols == 0 { + return false; + } + if !self.terminal_manager.borrow().is_terminal(buffer_id) { + return false; + } + let key = TerminalViewKey::new(frontend_id, window_id, buffer_id); + let content = terminal_projection_size(content); + if !self + .terminal_manager + .borrow_mut() + .record_view_size(key, content) + { + return false; + } + let controls = self + .terminal_manager + .borrow() + .controller(buffer_id) + .is_some_and(|controller| controller.matches(key)); + if !controls { + return false; + } + if self.terminal_manager.borrow().screen_size(buffer_id) == Some(content) { + return false; + } + let (Ok(rows), Ok(cols)) = (u16::try_from(content.rows), u16::try_from(content.cols)) + else { + return false; + }; + let result = self.terminal_manager.borrow_mut().resize( + buffer_id, + rows, + cols, + &mut self.process_supervisor.borrow_mut(), + ); + if let Err(error) = result { + self.core.borrow_mut().status = error.to_string(); + false + } else { + true + } + } + /// Apply a semantic frontend's terminal-cell pointer gesture. /// /// The gesture must name the authenticated frontend's active @@ -1980,7 +2065,7 @@ impl EditorState { let snapshot = self .terminal_manager .borrow_mut() - .snapshot_for_view(key, content.size)?; + .snapshot_for_view(key, terminal_projection_size(content.size))?; paint_terminal_snapshot(&mut grid, content, &snapshot, &theme); let registry = self.core.borrow().registry.clone(); let reg = registry.borrow(); @@ -2106,11 +2191,27 @@ impl EditorState { /// epochs) belong to the caller, because only the session holds the /// declaration the frontend was actually looking at. /// - /// **Click-to-focus only in Stage 2B-2.** A `Down`/`Up`/wheel/context - /// gesture activates the panel; replaying it into selection, listview - /// rows, or child SGR reporting is parent acceptance 48, which needs - /// the GPU band and lands in Stage 2B-3. Bare hover neither focuses - /// nor claims anything, exactly as on the document terminal path. + /// **Activation is not uniform, and Q#BP16 says so explicitly.** A + /// **press** focuses any panel — that is click-to-focus, and + /// `Down(Right)` is the context-menu gesture, so both buttons count. + /// Everything else depends on what the panel holds: + /// + /// * a **terminal** panel activates on *every* non-`Move` gesture, + /// because the shared terminal adapter claims the controller for + /// wheel, press, drag, and release alike — leaving a wheel step + /// unactivated would hand the child to a window that does not own + /// focus; + /// * a **document** panel keeps today's **scroll-without-focus** + /// behaviour, matching `dispatch_mouse`, where a wheel notch moves + /// a viewport without selecting the window (and preserves a kill + /// chain for the same reason). + /// + /// Review round 1 (R2-5) found the terminal clause applied to both. + /// Bare hover neither focuses nor claims, on either kind. + /// + /// **Replay is out of scope in Stage 2B-2.** Driving selection, + /// listview rows, or child SGR reporting is parent acceptance 48, + /// which needs the GPU band and lands in Stage 2B-3. /// /// Returns whether the gesture was accepted. pub fn dispatch_semantic_panel_pointer( @@ -2126,6 +2227,7 @@ impl EditorState { if coord.row >= size.rows || coord.col >= size.cols { return false; } + let is_terminal = self.terminal_manager.borrow().is_terminal(buffer_id); let mut core = self.core.borrow_mut(); let Some(side) = core.side_window_for(frontend_id) else { return false; @@ -2133,7 +2235,12 @@ impl EditorState { if core.windows.get(&side).map(|window| window.buffer_id) != Some(buffer_id) { return false; } - if !matches!(kind, pmacs_protocol::MouseKind::Move) { + let activates = if is_terminal { + !matches!(kind, pmacs_protocol::MouseKind::Move) + } else { + matches!(kind, pmacs_protocol::MouseKind::Down(_)) + }; + if activates { core.focus_window(frontend_id, side); core.active_frontend = frontend_id; } @@ -3350,6 +3457,36 @@ const SCROLL_LINES: i32 = 3; /// therefore only appears when a line-number mode reserves a gutter. const FOLD_GUTTER_GLYPH: char = '▸'; +/// The largest viewport the terminal subsystem will actually project, +/// for a window content rect that may legitimately be larger. +/// +/// A panel deliberately does **not** inherit the terminal's per-axis PTY +/// caps (Bet B5'): a 4K surface at a small font is legitimately wider +/// than 512 columns, and `PanelFrame` answers only to the shared area +/// bound. The terminal *screen* keeps its own policy, so without this +/// clamp `snapshot_for_view` refused the panel's content rect, the whole +/// projection collapsed to `None`, and the band went per-frame `Absent` +/// while `panel_hidden` still said "visible" — review round 1's R1-1 +/// shape, found again by its own sweep. +/// +/// Clamping rather than hiding is the right answer because the band is +/// legitimately that wide: the child occupies the columns a PTY can +/// have, and the remainder paints as band background exactly as a +/// snapshot narrower than its window already does. Rows are shed for the +/// area bound rather than columns, so a wide band keeps its full width. +fn terminal_projection_size(content: CellSize) -> CellSize { + let cols = content + .cols + .min(u32::from(crate::terminal::MAX_TERMINAL_COLS)); + let rows = content + .rows + .min(u32::from(crate::terminal::MAX_TERMINAL_ROWS)); + let rows_within_area = + u32::try_from(crate::terminal::MAX_TERMINAL_VISIBLE_CELLS / (cols as usize).max(1)) + .unwrap_or(u32::MAX); + CellSize::new(rows.min(rows_within_area), cols) +} + /// One painted side window, ready to become a /// [`pmacs_protocol::panel::PanelFrame`] (bottom-panel Stage 2B-2). /// diff --git a/src/editor_core.rs b/src/editor_core.rs index 7292284..661b767 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -3398,10 +3398,33 @@ impl EditorCore { /// the area budget. #[must_use] pub fn panel_grid_size(&self, fid: FrontendId) -> Option { - let view = self.views.get(&fid)?; - if view.panel_hidden { + if self.views.get(&fid)?.panel_hidden { return None; } + self.presentable_panel_grid(fid) + } + + /// The panel grid this frontend's layout and geometry **could** + /// present, ignoring the cached `panel_hidden` bit. + /// + /// This is the single derivation behind both [`Self::panel_grid_size`] + /// and [`Self::reconcile_panel_layout_core`]'s satisfiability test, + /// and it is one function on purpose (review round 1, R1-1). When the + /// wire-area clamp lived only in the renderer, the daemon shipped an + /// authoritative `Absent` while `panel_hidden` stayed `false` — so + /// keys still reached the invisible window and a panel terminal kept + /// its controller. Q#BP2b is explicit that hiding is a **durable + /// state transition**, never a per-frame effect, and two derivations + /// of "can this panel be shown" is exactly how it became one. + /// + /// The area bound is a transport-safety limit rather than a frontend + /// policy, so it is applied uniformly rather than only on the + /// semantic path. It cannot bind for a grid frontend at any physically + /// reachable width — two rows fit until roughly 131,000 columns — so + /// one shared rule costs nothing and removes the drift. + #[must_use] + fn presentable_panel_grid(&self, fid: FrontendId) -> Option { + let view = self.views.get(&fid)?; self.side_window_for(fid)?; let geometry = view.frame_geometry?; let cols = geometry.total.cols; @@ -3410,9 +3433,6 @@ impl EditorCore { } let area_rows = self.frontend_area_rows(fid)?; let rows = self.panel_allocation(fid, area_rows)?; - // The wire's area bound is a transport-safety limit, not a - // policy: clamp rows against it rather than shipping a frame the - // shared validator would reject whole. let budget_rows = u32::try_from(pmacs_protocol::panel::MAX_PANEL_VISIBLE_CELLS / (cols as usize).max(1)) .unwrap_or(u32::MAX); @@ -3440,13 +3460,14 @@ impl EditorCore { return result; }; let was_hidden = self.views.get(&fid).is_some_and(|view| view.panel_hidden); - // Unknown geometry (a semantic view before Stage 2's declaration) - // and a zero-column frame are both non-presentable, and follow the - // hidden arm rather than being sized against a placeholder. - let satisfiable = self - .frontend_area_rows(fid) - .and_then(|rows| self.panel_allocation(fid, rows)) - .is_some(); + // Unknown geometry (a semantic view before Stage 2's declaration), + // a zero-column frame, a layout that cannot spare the rows, and a + // grid the shared wire budget cannot carry are ALL non-presentable + // and all follow the hidden arm. One derivation, shared with the + // renderer (R1-1): a condition that only the renderer knew about + // produced a blank band with the durable state still saying + // "visible". + let satisfiable = self.presentable_panel_grid(fid).is_some(); let Some(view) = self.views.get_mut(&fid) else { return result; }; diff --git a/src/semantic_render.rs b/src/semantic_render.rs index c42835c..47b85c4 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -352,6 +352,15 @@ pub struct SemanticRenderState { /// Whether an invalid panel frame was already reported since the last /// valid one. Bounds the log exactly like `terminal_error_latched`. panel_error_latched: bool, + /// The band's last PUBLISHED statusline segments and the side window + /// they belong to (review round 1, R2-4). + /// + /// The band repaints its whole mode line every frame, so + /// "publish nothing" has to be expressed as "paint what was published + /// last" — there is no wire-level suppression to fall back on the way + /// `StatuslineSegments` has. Keyed by window id so a replaced panel + /// never inherits its predecessor's provider text. + last_panel_statusline: Option<(WindowId, StatuslineWindowSegments)>, } /// The presentation identity a shipped [`PanelFrame`] carries. @@ -523,6 +532,7 @@ impl SemanticRenderState { panel_epoch_used: 0, panel_presentation: None, panel_error_latched: false, + last_panel_statusline: None, } } @@ -542,14 +552,37 @@ impl SemanticRenderState { } /// Whether the last shipped declaration is a `Present` whose epochs - /// both match an inbound panel event (Q#BP16 steps 2–4). + /// both match an inbound panel event **and** which still describes + /// the side window that is live now (Q#BP16 steps 2–4). /// /// Rolled into one predicate so no caller can check the geometry /// epoch and forget the presentation epoch: they close different /// holes and neither subsumes the other. + /// + /// `live_presentation` is the frontend's **current** side window and + /// its buffer. Comparing it is what closes review round 1's R1-2: + /// closing and reopening the same *persistent* buffer inside one + /// dispatcher burst leaves the shipped declaration intact while the + /// window it describes is already dead, and same-buffer/same-size + /// makes the successor indistinguishable by every other field. A + /// presentation epoch only identifies a presentation if something + /// checks that the presentation it names is still the one on screen. + /// + /// `None` means "no side window right now", which never matches — an + /// event cannot address a panel that does not exist. #[must_use] - pub fn panel_declaration_matches(&self, geometry_epoch: u64, panel_epoch: u64) -> bool { - self.panel_declaration().is_some_and(|frame| { + pub fn panel_declaration_matches( + &self, + geometry_epoch: u64, + panel_epoch: u64, + live_presentation: Option<(WindowId, BufferId)>, + ) -> bool { + let Some((window_id, buffer_id)) = live_presentation else { + return false; + }; + self.panel_presentation.is_some_and(|presentation| { + presentation.window_id == window_id && presentation.buffer_id == buffer_id + }) && self.panel_declaration().is_some_and(|frame| { frame.geometry_epoch == geometry_epoch && frame.panel_epoch == panel_epoch }) } @@ -924,10 +957,7 @@ impl SemanticRenderState { // side half is taken from this same evaluation before it is // consumed. let side_window = state.core.borrow().side_window_for(self.frontend_id); - let panel_statusline = statusline_evaluation - .as_ref() - .and_then(|evaluation| self.panel_statusline(evaluation, side_window)) - .cloned(); + let panel_statusline = self.panel_statusline(statusline_evaluation.as_ref(), side_window); if let Some(evaluation) = statusline_evaluation { self.emit_statusline_segments(evaluation, statusline_document_window, &mut out); } @@ -1074,10 +1104,7 @@ impl SemanticRenderState { // and suppressing the panel here would leave the peer's retained // band on screen with no way to clear it. let side_window = state.core.borrow().side_window_for(self.frontend_id); - let panel_statusline = statusline_evaluation - .as_ref() - .and_then(|evaluation| self.panel_statusline(evaluation, side_window)) - .cloned(); + let panel_statusline = self.panel_statusline(statusline_evaluation.as_ref(), side_window); if let Some(evaluation) = statusline_evaluation { self.emit_statusline_segments(evaluation, statusline_document_window, &mut out); } @@ -1156,23 +1183,64 @@ impl SemanticRenderState { /// order and could paint the document's status text into the panel's /// mode line. /// - /// An `Invalidated` evaluation yields `None`, which is the panel's - /// authoritative-empty: a callback that mutated layout or focus - /// invalidates the whole evaluation, so the band paints its plain - /// mode line rather than stale provider text. - fn panel_statusline<'a>( - &self, - evaluation: &'a StatuslineEvaluation, + /// The three outcomes are **not** interchangeable, and review round 1 + /// (R2-4) found two of them collapsed: + /// + /// * `Ready` is authoritative — including an empty result. It + /// replaces the retained baseline. + /// * `Invalidated` discards all evaluated text: a callback mutated + /// registry, layout, or focus mid-evaluation, so the band clears to + /// its plain mode line and the baseline dies with it. + /// * `NoMessage` means **publish nothing**. Phase 1 was already stale + /// — most reachably a buffer-follow mismatch, where the primary + /// document window has moved off the buffer the frontend declared. + /// The band therefore keeps what it last published. Treating this + /// like `Invalidated` *removes* provider text on a transient + /// condition that said nothing about it. + /// + /// The baseline is keyed by window id so it can never leak across a + /// panel replacement: a new side window starts with no retained text. + fn panel_statusline( + &mut self, + evaluation: Option<&StatuslineEvaluation>, side_window: Option, - ) -> Option<&'a StatuslineWindowSegments> { - let side_window = side_window?; + ) -> Option { + let Some(side_window) = side_window else { + // No band to publish for; drop any baseline so a later panel + // cannot inherit a dead window's text. + self.last_panel_statusline = None; + return None; + }; + let retained = |state: &Self| { + state + .last_panel_statusline + .as_ref() + .filter(|(window_id, _)| *window_id == side_window) + .map(|(_, segments)| segments.clone()) + }; + let Some(evaluation) = evaluation else { + // No evaluation ran at all (an unsupported peer, or a frame + // before the document viewport exists). Nothing was + // published, so nothing is retracted. + return retained(self); + }; match &evaluation.outcome { - StatuslineEvaluationOutcome::Ready(windows) => windows.iter().find(|window| { - window.context.frontend_id == self.frontend_id - && window.context.window_id == side_window - }), - StatuslineEvaluationOutcome::Invalidated { .. } - | StatuslineEvaluationOutcome::NoMessage(_) => None, + StatuslineEvaluationOutcome::Ready(windows) => { + let found = windows + .iter() + .find(|window| { + window.context.frontend_id == self.frontend_id + && window.context.window_id == side_window + }) + .cloned(); + self.last_panel_statusline = found.clone().map(|segments| (side_window, segments)); + found + } + StatuslineEvaluationOutcome::Invalidated { .. } => { + self.last_panel_statusline = None; + None + } + StatuslineEvaluationOutcome::NoMessage(_) => retained(self), } } @@ -1225,6 +1293,22 @@ impl SemanticRenderState { // Q#BP15: allocation is checked and exhaustion fails closed // to `Absent`. Wrapping would let a new panel inherit a live // identity and accept gestures aimed at its predecessor. + // + // **The one knowingly per-frame `Absent` left in this file**, + // and it is recorded rather than fixed. Review round 1's R1-1 + // established that a band cleared on the wire must also move + // the durable `panel_hidden` state, or keys keep reaching an + // invisible window; this arm cannot, because the producer + // holds session state and `panel_hidden` is recomputed by + // core reconciliation from geometry alone. Making it durable + // needs a new "presentation permanently unavailable" reason + // in `FrontendView`, which is machinery for a state that + // takes 2^64 shipped presentation changes in ONE session to + // reach — unlike the wire-area exhaustion in + // `presentable_panel_grid`, which any frontend can trigger + // with one declaration. If the epoch ever becomes + // frontend-supplied, this stops being unreachable and needs + // the durable arm. self.publish_absent_panel(out); return; }; diff --git a/tests/bottom_panel_stage2b_daemon_acceptance.rs b/tests/bottom_panel_stage2b_daemon_acceptance.rs index adf3774..ab0d36f 100644 --- a/tests/bottom_panel_stage2b_daemon_acceptance.rs +++ b/tests/bottom_panel_stage2b_daemon_acceptance.rs @@ -983,3 +983,238 @@ fn the_projection_paints_the_side_windows_own_buffer() { frame.validate().expect("a produced frame is a valid frame"); let _ = HashMap::::new(); } + +// --------------------------------------------------------------------------- +// Review round 1 — R1-1 and R2-4 +// --------------------------------------------------------------------------- + +/// R1-1: exhausting the wire-area budget must be a **durable** hide, not +/// a per-frame one. +/// +/// Q#BP2b is explicit that hiding is a durable state transition: a +/// render-time dodge still routes keys to an invisible window and leaves +/// the terminal controller claimed. `panel_grid_size` gained a budget +/// clamp that `reconcile_panel_layout_core` did not share, so the band +/// went `Absent` on the wire while `panel_hidden` stayed false — the +/// exact per-frame-effect shape the Stage 1 record warns about. +#[test] +fn r1_1_wire_area_exhaustion_is_a_durable_hide_not_a_blank_frame() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + let panel = session.side_window().expect("side window"); + session.state.core.borrow_mut().focus_window(FID, panel); + assert!( + session.present().focused, + "fixture precondition: the panel owns focus while it is presentable" + ); + + // Wide enough that not even the structural two-row floor fits inside + // the shared area bound. + let max_cells = u32::try_from(pmacs_protocol::panel::MAX_PANEL_VISIBLE_CELLS).expect("bound"); + session.declare(2, ROWS, max_cells); + + assert_eq!( + session.frame(), + Some(PanelFramePayload::Absent), + "the band is cleared authoritatively" + ); + let core = session.state.core.borrow(); + assert!( + core.panel_hidden_for(FID), + "R1-1: …and the DURABLE hidden state must move with it, or keys \ + keep reaching an invisible window" + ); + assert_ne!( + core.views[&FID].active, panel, + "R1-1: focus must leave a panel that can no longer be presented" + ); +} + +/// R1-1's controller half: a panel terminal that stops being presentable +/// must have its child released, because the resize path merely returns +/// on zero content without releasing anything. +#[test] +fn r1_1_wire_area_exhaustion_releases_a_panel_terminals_controller() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + exec( + &session.state, + "TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", \ + args = { \"-c\", \"sleep 30\" }, display = \"panel\" }", + ); + let panel = session.side_window().expect("terminal panel"); + let buffer: pmacs::lua_bindings::BufferIdLua = session + .state + .lua_host + .lua() + .load("return TERM_BUF") + .eval() + .unwrap(); + session.state.core.borrow_mut().focus_window(FID, panel); + let _ = session.present(); + let key = pmacs::terminal::TerminalViewKey::new(FID, panel, buffer.0); + assert!( + session + .state + .terminal_manager + .borrow_mut() + .claim_controller(key), + "fixture precondition: the panel view controls the child" + ); + + let max_cells = u32::try_from(pmacs_protocol::panel::MAX_PANEL_VISIBLE_CELLS).expect("bound"); + session.declare(2, ROWS, max_cells); + assert_eq!(session.frame(), Some(PanelFramePayload::Absent)); + + assert!( + session + .state + .terminal_manager + .borrow() + .controller(buffer.0) + .is_none(), + "R1-1: the child's controller is released with the durable hide" + ); + exec(&session.state, "pmacs.terminal.terminate(TERM_BUF)"); +} + +/// R2-4: `NoMessage` means publish **nothing**, so the band keeps the +/// text it last published. Treating it like `Invalidated` *removes* +/// provider text on a transient buffer-follow mismatch. +#[test] +fn r2_4_nomessage_retains_the_bands_published_segments() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + let panel = session.side_window().expect("side window"); + let document_buffer = session.state.core.borrow().windows[&session.document].buffer_id; + session.render.set_viewport( + document_buffer, + pmacs::protocol::ByteRange { start: 0, end: 0 }, + 0, + ); + exec( + &session.state, + "pmacs.statusline.register { + name = 'probe', side = 'left', + fn = function(ctx) return 'W' .. tostring(ctx.window) end, + }", + ); + + let marker = format!("W{}", panel.raw()); + let mode_line = |session: &mut Session| { + rows_of(&session.present()) + .last() + .expect("mode line") + .clone() + }; + assert!( + mode_line(&mut session).contains(&marker), + "fixture precondition: a Ready evaluation paints the band's own \ + provider text" + ); + + // A buffer-follow mismatch: the primary document window moves off the + // buffer the frontend declared, so phase 1 is already stale and the + // evaluator returns NoMessage. + exec( + &session.state, + "pmacs.window.switch_buffer(pmacs.buffer.create(\"*elsewhere*\"))", + ); + // Force a repaint: without a content change the payload would be + // duplicate-suppressed and this would assert nothing. + set_panel_text(&session, "changed"); + assert!( + mode_line(&mut session).contains(&marker), + "R2-4: NoMessage publishes nothing, so the band keeps its last \ + published segments" + ); + + // The discriminating half: `Invalidated` DOES clear them, because a + // callback that mutated the registry invalidates all evaluated text. + // The evaluation has to reach the callback phase first, so re-declare + // the viewport onto the buffer the document window now shows — + // otherwise phase 1 stays stale and this would still be NoMessage. + let elsewhere = session.state.core.borrow().windows[&session.document].buffer_id; + session.render.set_viewport( + elsewhere, + pmacs::protocol::ByteRange { start: 0, end: 0 }, + 0, + ); + exec( + &session.state, + "SELF = pmacs.statusline.register { + name = 'self-unregistering', side = 'right', + fn = function() pmacs.statusline.unregister(SELF); return 'STALE' end, + }", + ); + set_panel_text(&session, "changed again"); + let after = mode_line(&mut session); + assert!( + !after.contains(&marker) && !after.contains("STALE"), + "an Invalidated evaluation discards ALL callback text, got {after:?}" + ); +} + +// --------------------------------------------------------------------------- +// Review round 1 sweep — the same defect shape, found elsewhere +// --------------------------------------------------------------------------- + +/// Sweep result: a panel **wider than the terminal subsystem's per-axis +/// cap** hosting a terminal reproduced R1-1's shape all over again. +/// +/// Bet B5' makes a panel wider than 512 columns legal on the wire — a 4K +/// surface at a small font is ordinary, and the frontend declares that +/// width itself. But the terminal screen keeps its own PTY policy +/// (`MAX_TERMINAL_COLS`), so `snapshot_for_view` refused the panel's +/// content rect, the projection returned `None`, and the band went +/// **per-frame `Absent` while `panel_hidden` stayed false** — keys still +/// reaching an invisible window, controller still claimed. +/// +/// The band is legitimately that wide, so hiding it would be the wrong +/// answer: the terminal projects into the columns it can occupy and the +/// remainder is band background, exactly as a snapshot narrower than its +/// window already paints. +#[test] +fn sweep_a_panel_wider_than_the_terminal_cap_still_presents_its_terminal() { + let wide = u32::from(pmacs::terminal::MAX_TERMINAL_COLS) + 88; + let mut session = Session::new(); + session.declare(1, ROWS, wide); + exec( + &session.state, + "TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", \ + args = { \"-c\", \"sleep 30\" }, display = \"panel\" }", + ); + let panel = session.side_window().expect("terminal panel"); + session.state.core.borrow_mut().focus_window(FID, panel); + + let frame = match session.frame() { + Some(PanelFramePayload::Present(frame)) => frame, + other => panic!( + "a legally wide panel must still present its terminal; got {other:?} \ + — and the durable state says hidden={}", + session.state.core.borrow().panel_hidden_for(FID) + ), + }; + assert_eq!( + frame.size.cols, wide, + "the band keeps the width the frontend declared (Bet B5')" + ); + frame + .validate() + .expect("and it is a frame the shared validator accepts"); + + // The durable state and the wire agree — which is the property R1-1 + // was really about. + assert!( + !session.state.core.borrow().panel_hidden_for(FID), + "a presented band is not durably hidden" + ); + assert_eq!( + session.state.core.borrow().views[&FID].active, + panel, + "…and focus is still legitimately in it" + ); + exec(&session.state, "pmacs.terminal.terminate(TERM_BUF)"); +} From bfaaf2bff6e4c90a82c1d1cb429a00cb0e11d661 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 21:30:50 -0400 Subject: [PATCH 6/7] Close PR 187 review round 2 Retain panel statusline segments only for the exact side-window and buffer presentation that published them. Clear that baseline whenever the daemon publishes authoritative Absent. Pin both transitions: same-window buffer replacement under NoMessage, and Absent-to-Present under NoMessage. --- src/semantic_render.rs | 53 +++++--- .../bottom_panel_stage2b_daemon_acceptance.rs | 116 ++++++++++++++++++ 2 files changed, 152 insertions(+), 17 deletions(-) diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 47b85c4..c7f5984 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -352,15 +352,16 @@ pub struct SemanticRenderState { /// Whether an invalid panel frame was already reported since the last /// valid one. Bounds the log exactly like `terminal_error_latched`. panel_error_latched: bool, - /// The band's last PUBLISHED statusline segments and the side window - /// they belong to (review round 1, R2-4). + /// The band's last PUBLISHED statusline segments and the side-window + /// presentation they belong to (review rounds 1 and 2, R2-4). /// /// The band repaints its whole mode line every frame, so /// "publish nothing" has to be expressed as "paint what was published /// last" — there is no wire-level suppression to fall back on the way - /// `StatuslineSegments` has. Keyed by window id so a replaced panel - /// never inherits its predecessor's provider text. - last_panel_statusline: Option<(WindowId, StatuslineWindowSegments)>, + /// `StatuslineSegments` has. Side affinity can replace a buffer in the + /// same window, so both identities are required to prevent one panel + /// presentation from inheriting its predecessor's provider text. + last_panel_statusline: Option<((WindowId, BufferId), StatuslineWindowSegments)>, } /// The presentation identity a shipped [`PanelFrame`] carries. @@ -956,8 +957,9 @@ impl SemanticRenderState { // primary-document wire segments and the panel mode line, so the // side half is taken from this same evaluation before it is // consumed. - let side_window = state.core.borrow().side_window_for(self.frontend_id); - let panel_statusline = self.panel_statusline(statusline_evaluation.as_ref(), side_window); + let panel_presentation = self.panel_statusline_presentation(state); + let panel_statusline = + self.panel_statusline(statusline_evaluation.as_ref(), panel_presentation); if let Some(evaluation) = statusline_evaluation { self.emit_statusline_segments(evaluation, statusline_document_window, &mut out); } @@ -1103,8 +1105,9 @@ impl SemanticRenderState { // surface is a full-window terminal can still hold a side window, // and suppressing the panel here would leave the peer's retained // band on screen with no way to clear it. - let side_window = state.core.borrow().side_window_for(self.frontend_id); - let panel_statusline = self.panel_statusline(statusline_evaluation.as_ref(), side_window); + let panel_presentation = self.panel_statusline_presentation(state); + let panel_statusline = + self.panel_statusline(statusline_evaluation.as_ref(), panel_presentation); if let Some(evaluation) = statusline_evaluation { self.emit_statusline_segments(evaluation, statusline_document_window, &mut out); } @@ -1198,14 +1201,23 @@ impl SemanticRenderState { /// like `Invalidated` *removes* provider text on a transient /// condition that said nothing about it. /// - /// The baseline is keyed by window id so it can never leak across a - /// panel replacement: a new side window starts with no retained text. + /// The baseline is keyed by both window and buffer identity. Side + /// affinity deliberately replaces the buffer in an existing side + /// window, and that replacement is a new presentation even though the + /// `WindowId` is stable. + fn panel_statusline_presentation(&self, state: &EditorState) -> Option<(WindowId, BufferId)> { + let core = state.core.borrow(); + let window_id = core.side_window_for(self.frontend_id)?; + let buffer_id = core.windows.get(&window_id)?.buffer_id; + Some((window_id, buffer_id)) + } + fn panel_statusline( &mut self, evaluation: Option<&StatuslineEvaluation>, - side_window: Option, + panel_presentation: Option<(WindowId, BufferId)>, ) -> Option { - let Some(side_window) = side_window else { + let Some((side_window, side_buffer)) = panel_presentation else { // No band to publish for; drop any baseline so a later panel // cannot inherit a dead window's text. self.last_panel_statusline = None; @@ -1215,7 +1227,7 @@ impl SemanticRenderState { state .last_panel_statusline .as_ref() - .filter(|(window_id, _)| *window_id == side_window) + .filter(|(presentation, _)| *presentation == (side_window, side_buffer)) .map(|(_, segments)| segments.clone()) }; let Some(evaluation) = evaluation else { @@ -1231,9 +1243,12 @@ impl SemanticRenderState { .find(|window| { window.context.frontend_id == self.frontend_id && window.context.window_id == side_window + && window.context.buffer_id == side_buffer }) .cloned(); - self.last_panel_statusline = found.clone().map(|segments| (side_window, segments)); + self.last_panel_statusline = found + .clone() + .map(|segments| ((side_window, side_buffer), segments)); found } StatuslineEvaluationOutcome::Invalidated { .. } => { @@ -1372,9 +1387,13 @@ impl SemanticRenderState { /// panel's presence. fn publish_absent_panel(&mut self, out: &mut Vec) { self.panel_presentation = None; + // `Absent` also clears the peer's retained mode line. A later + // `Present` under `NoMessage` therefore has nothing it can + // legitimately retain, even if the same window and buffer reopen. + self.last_panel_statusline = None; if self.last_panel_payload.as_ref() == Some(&PanelFramePayload::Absent) { - // A duplicate `Absent` does no work — but the clear above - // still runs, so the state stays idempotent rather than + // A duplicate `Absent` does no wire work — but both clears + // above still run, so the state stays idempotent rather than // depending on which duplicate arrived first. return; } diff --git a/tests/bottom_panel_stage2b_daemon_acceptance.rs b/tests/bottom_panel_stage2b_daemon_acceptance.rs index ab0d36f..28eed30 100644 --- a/tests/bottom_panel_stage2b_daemon_acceptance.rs +++ b/tests/bottom_panel_stage2b_daemon_acceptance.rs @@ -1157,6 +1157,122 @@ fn r2_4_nomessage_retains_the_bands_published_segments() { ); } +/// R2-4's retained baseline follows one window-and-buffer presentation, +/// not merely the side `WindowId`. Side affinity reuses the window when it +/// replaces the buffer, so `NoMessage` must not carry provider text from +/// the predecessor into the replacement. +#[test] +fn r2_4_nomessage_does_not_cross_a_same_window_buffer_replacement() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*first-panel*", 4); + let panel = session.side_window().expect("side window"); + let document_buffer = session.state.core.borrow().windows[&session.document].buffer_id; + session.render.set_viewport( + document_buffer, + pmacs::protocol::ByteRange { start: 0, end: 0 }, + 0, + ); + exec( + &session.state, + "pmacs.statusline.register { + name = 'buffer-probe', side = 'left', + fn = function(ctx) return 'CTX:' .. ctx.buffer:name() end, + }", + ); + + let first = rows_of(&session.present()) + .last() + .expect("mode line") + .clone(); + assert!( + first.contains("CTX:*first-panel*"), + "fixture precondition: the first panel publishes its buffer-scoped \ + segment; got {first:?}" + ); + + // Replace the buffer in the existing side WindowId, then move the + // document off its declared viewport in the same transaction so phase + // 1 returns NoMessage before it can publish the replacement context. + exec( + &session.state, + "SECOND = pmacs.buffer.create('*second-panel*') + pmacs.window.display(SECOND, { side = 'bottom' }) + pmacs.window.switch_buffer(pmacs.buffer.create('*elsewhere*'))", + ); + assert_eq!( + session.side_window(), + Some(panel), + "fixture precondition: side-buffer replacement reuses the WindowId" + ); + + let replaced = rows_of(&session.present()) + .last() + .expect("mode line") + .clone(); + assert!( + replaced.contains("*second-panel*"), + "fixture precondition: the replacement panel was painted; got {replaced:?}" + ); + assert!( + !replaced.contains("CTX:*first-panel*"), + "NoMessage must not carry buffer-scoped segments across a new panel \ + presentation; got {replaced:?}" + ); +} + +/// An authoritative `Absent` clears the peer's retained band, including +/// its mode line. Reopening the same panel under `NoMessage` therefore +/// starts with no provider segments to retain. +#[test] +fn r2_4_nomessage_does_not_resurrect_segments_after_absent() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + let document_buffer = session.state.core.borrow().windows[&session.document].buffer_id; + session.render.set_viewport( + document_buffer, + pmacs::protocol::ByteRange { start: 0, end: 0 }, + 0, + ); + exec( + &session.state, + "pmacs.statusline.register { + name = 'absence-probe', side = 'left', + fn = function() return 'BEFORE-ABSENT' end, + }", + ); + assert!( + rows_of(&session.present()) + .last() + .expect("mode line") + .contains("BEFORE-ABSENT"), + "fixture precondition: the segment was published" + ); + + exec( + &session.state, + "pmacs.window.switch_buffer(pmacs.buffer.create('*elsewhere*'))", + ); + session.declare(2, 3, COLS); + assert_eq!( + session.frame(), + Some(PanelFramePayload::Absent), + "fixture precondition: the peer's panel state was cleared" + ); + + session.declare(3, ROWS, COLS); + let reappeared = rows_of(&session.present()) + .last() + .expect("mode line") + .clone(); + assert!( + !reappeared.contains("BEFORE-ABSENT"), + "NoMessage cannot retain across Absent because the peer has no panel \ + statusline state left to retain; got {reappeared:?}" + ); +} + // --------------------------------------------------------------------------- // Review round 1 sweep — the same defect shape, found elsewhere // --------------------------------------------------------------------------- From 61202d50c12222f908ee8afadc92661c5b541ffc Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 21:59:43 -0400 Subject: [PATCH 7/7] Record PR 187 and its review state Advance the canonical landed-base anchors through the docs-only main updates, give PR 187 a complete volatile lane, and stop calling Stage 2B-2 the next unstarted slice in the durable handoff. Record both review rounds, the round-2 code checkpoint, verification, and exact cross-machine recovery. --- docs/active-work.md | 77 +++++++++++++++++++++++++++++++++++-------- docs/agent-handoff.md | 32 ++++++++++-------- 2 files changed, 82 insertions(+), 27 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index a303151..225a7d7 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -38,16 +38,17 @@ is gone again and its durable lesson is in `docs/agent-handoff.md` §5; machine-local: `origin` may name this canonical URL, a release mirror, or something else, and therefore has no authority by name alone. - Canonical base at this snapshot: - `githubsucks/main` @ `0442d78` (the M4 config-sink race fix #174, atop - bottom-panel Stage 2B-1 #184, the Journey/GPU directory-target ratchet - #183, Journey Stage 1a #182 and the previously recorded landed work). + `githubsucks/main` @ `7586905` (the docs-only coherence listview + correction #189, atop the docs-only landed-state refresh #185, the M4 + config-sink race fix #174, bottom-panel Stage 2B-1 #184, the + Journey/GPU directory-target ratchet #183, Journey Stage 1a #182 and + the previously recorded landed work). **Protocol schema support is `v6..=v21`; the production server-first `Hello` still advertises v20** — two different facts, and #184 landed only the first. The - previous snapshot named `7fd646d`, and **the + previous snapshot named `0442d78`, and **the recovery floor advances with it**: the check below now requires - `0442d78` or newer, so a tree at `7fd646d` — or at `6bee09d` — no - longer passes. That is + `7586905` or newer, so a tree at `0442d78` no longer passes. That is deliberate — the floor moves with the base, because a check that accepts an older commit than the declared base passes on a tree the rest of this file does not describe. @@ -86,7 +87,7 @@ git worktree list git status --short --branch ``` -The `git log` command must expose `0442d78` — the base named above — or a +The `git log` command must expose `7586905` — the base named above — or a newer intentional main. Keep this threshold and the canonical-base line in step: a recovery check that accepts an older commit than the base it declares canonical will pass on a tree the rest of this file does not @@ -228,17 +229,18 @@ If it does not, stop and repair the remote/fetch configuration. never been enforced. Any CI job that compiles the `crdt` targets has to fix them first or it will be red on arrival. -## Bottom-panel lane (Arc 7) — 2B-1 MERGED; 2B-2 IN FLIGHT +## Bottom-panel lane (Arc 7) — 2B-1 MERGED; 2B-2 PR #187 OPEN Stage 1, the Stage 2 framing, Stage 2A, and **Stage 2B-1 are all on `main`**. Framing revision 5's three-way split of 2B was explicitly approved on 2026-07-27; revision 6 records PR #184's review correction. -**2B-2 — the daemon panel projection and epoch machine — is under way** -on branch `bottom-panel-stage2b2`, worktree `../pmacs-bp-stage2b2`, -branched fresh from `githubsucks/main` @ `6bee09d`. It is *not* stacked -on the 2B-1 branch, which is the rule for every slice in this arc. Note -that `main` has since advanced to `0442d78`; the only difference is the -test-only #174, so the slice's integration surface is unchanged. +**2B-2 — the daemon panel projection and epoch machine — is open as +[PR #187](https://github.com/levineuwirth/pmacs/pull/187)** on branch +`bottom-panel-stage2b2`, worktree `../pmacs-bp-stage2b2`. It branched +fresh from landed 2B-1 rather than stacking on its feature branch, and +has since integrated landed main through `7586905` (#174, #185, and +#189). The exact pre-round-2 reviewed head was merge checkpoint +`0dba358`; the round-2 code-and-test checkpoint is `bfaaf2b`. **2B-2's boundaries, restated because they are easy to overrun:** the production `Hello` stays at v20 and `panel_capable` stays `false`. The @@ -247,6 +249,53 @@ activation, the GPU band, and the negotiated capability flip are all 2B-3's, and 2B-3 may **not** simply change the unsolicited `Hello` to 21. +- **What PR #187 ships, dark by construction:** the semantic daemon's + `FrontendCellGeometry` epoch machine; one reconciled panel grid + derivation; `PanelFrame::{Present, Absent}` projection on both document + and terminal semantic paths; stable presentation epochs; resize and + pointer validation against the live window/buffer/epochs; the panel's + own statusline context; and pre-drain semantic panel-terminal resize. + It does not add the GPU consumer or enable the capability. +- **Review round 1 closed five findings plus one sweep result at + `3ecb03d`.** The wire-area clamp became durable hide state; a stale + same-buffer reopen can no longer retain input authority; semantic panel + terminals resize before child drain; `NoMessage` retains a published + band baseline while `Invalidated` clears it; wheel activation follows + the terminal-only focus rule; and legally wide panels clamp their PTY + content without disappearing. +- **Review round 2 closed two findings at `bfaaf2b` plus this ledger + commit.** Side affinity can replace the buffer while preserving the + `WindowId`, so retained panel statusline segments are now keyed by the + full `(WindowId, BufferId)` presentation. Every authoritative `Absent` + also clears that baseline, including duplicate-suppressed `Absent`, so + a later `Present` under `NoMessage` cannot resurrect peer state that + was already cleared. Two acceptance tests bite those exact transitions. + This lane and `docs/agent-handoff.md` now name the open PR, current + landed base, checkpoint, and 2B-3 ordering instead of calling 2B-2 + merely “next.” +- **Round-2 verification at code checkpoint `bfaaf2b`:** formatting and + strict workspace Clippy; library **1,863 passed + 3 ignored** default + and **2,048 passed + 4 ignored** CRDT; bottom-panel Stage 1 / 2A / + 2B-1 / 2B-2 **46 / 17 / 16 / 28**; statusline **8 CRDT**; semantic + routing **2 CRDT**; M4 **121 passed + 3 ignored + 1 filtered**; + required GPU **202/202**; isolated-config full workspace sweep; and + `git diff --check`. The first workspace sweep had one GPU rendering + failure in `failures_and_display_math_render_as_source`; that test had + passed in the immediately preceding required-GPU run, passed alone, + and the complete workspace rerun passed. Real-daemon and managed-attach + cases were rerun outside the tool sandbox after its local-socket policy + produced `Operation not permitted`; the authoritative reruns passed. +- **Cross-machine recovery (fresh clone):** + + ```sh + git fetch githubsucks --prune + git switch --track -c bottom-panel-stage2b2 githubsucks/bottom-panel-stage2b2 + git rev-parse HEAD + ``` + + Require the remote branch to contain `bfaaf2b` or newer and confirm + PR #187's exact-head checks before resuming. Do not start 2B-3 until + #187 lands. - **Stage 2B-1 MERGED as #184** (`main` @ `6bee09d`, 2026-07-28; all twelve checks green on the reviewed head `5539b6e`; two review rounds plus a gate-found follow-up). Branch diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 6c99502..96dbeee 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,9 +1,10 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-28, after the M4 config-sink race fix (#174) and -bottom-panel Stage 2B-1 (#184) merged; the canonical landed base is -`0442d78`. #174 is test-only. #184 is the substantive one — the reserved -protocol-v21 +**Last updated: 2026-07-28, after the docs-only coherence listview +correction (#189) and landed-state refresh (#185) merged; the canonical +landed base is `7586905`. The runtime anchor beneath them is the M4 +config-sink race fix (#174) and bottom-panel Stage 2B-1 (#184). #174 is +test-only. #184 is the substantive one — the reserved protocol-v21 bottom-panel wire family, dark by construction, with the production handshake deliberately still advertising v20 — following the Journey/GPU directory-target ratchet (#183), following Journey Stage 1a (#182), @@ -51,9 +52,11 @@ commands, read `docs/active-work.md` immediately after this file. ## 1. Where the project stands (2026-07-28) -- `main` @ `0442d78` (the M4 config-sink race fix #174 — test-only — - atop bottom-panel Stage 2B-1 #184, the Journey/GPU - directory-target ratchet #183, Journey Stage 1a #182, incorporating +- `main` @ `7586905` (the docs-only coherence listview correction #189, + atop the docs-only landed-state refresh #185, the M4 config-sink race + fix #174 — test-only — atop bottom-panel Stage 2B-1 #184, the + Journey/GPU directory-target ratchet #183, Journey Stage 1a #182, + incorporating terminal configuration + copy mode landed docs #180, Lean 4 Stage 4b #181, the dired Stage 1 landed docs #169 and the PTY-terminate diagnostic #176, terminal copy mode @@ -518,20 +521,23 @@ commands, read `docs/active-work.md` immediately after this file. `bottom_panel_stage1_acceptance` 46; kill ring 30; compile 67; M4 121; required GPU 152; initial-target 14 CRDT; all three vterm suites; folding Stage 2 48. All 12 CI checks green at merge. - - **Stage 2 (the GPU panel band) is FRAMED, and its first two slices - have LANDED** — `docs/bottom-panel-stage2-framing.md` rev 6, four + - **Stage 2 (the GPU panel band) is FRAMED; its first two slices have + LANDED and its third is open as PR #187** — + `docs/bottom-panel-stage2-framing.md` rev 6, four framing review rounds, no open framing items; the rev-5 implementation split was explicitly approved 2026-07-27 and rev 6 records PR #184's server-first compatibility and gate correction. It reserves protocol **v21** and ships as four serial implementation slices: **2A** classified census routing + per-window painter extraction (no wire change, #177), **2B-1** the - wire (#184), **2B-2** the daemon projection and epoch machine, - then **2B-3** the GPU band, compatible v21 activation, and the + wire (#184), **2B-2** the daemon projection and epoch machine + (implemented but not landed in PR #187), then **2B-3** the GPU band, + compatible v21 activation, and the negotiated `panel_capable` flip. Production attachment remains v20 through 2B-2. Parent acceptance 37–55 remains authoritative. - Stage 3 is the adopter default flip. **2B-2 is the next slice, and - it branches from `0442d78` or newer.** + Stage 3 is the adopter default flip. **Do not start 2B-3 until PR + #187 lands.** Its branch, checkpoints, two review rounds, verification, + and exact recovery commands live in `docs/active-work.md`. - **The §1.3 census is CLASSIFIED, not uniformly redirected.** Only the Projection class (#1–#12, #21–#22) routes through `primary_document_window`; focus/input (#13–#15, #23), focus chrome