From a820e913895ca6137139901deb5783c6e143ba57 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 20 May 2026 09:55:27 -0400 Subject: [PATCH] session 1 commit 4/4: message envelopes moved to pmacs-protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The big move that completes session 1. Wire types moved from src/protocol.rs to pmacs-protocol/src/message.rs: - Input event family: Key, Modifiers, KeyEvent, MouseButton, MouseKind, MouseEvent, FrontendEvent (and its variants — Resize, KeyEvent, MouseEvent, Resume, Pause, Detach, ResizeAck, CrdtOp, Viewport). - Instance-side message family: CursorState, InstanceSignal, GoodbyeReason, InstanceMessage (Hello/Cursor/CellDelta/CursorByte/ CrdtOp/BufferSnapshot/Goodbye/PresenceUpdate + the SemanticFrame variants). - SelectionSnapshot. - SemanticFrame family components: StyleSpan, StyleSegment, DecorationKind, Decoration, DecorationSegment, AdornmentPlacement, AdornmentContent, InlineAdornment, BlockAdornment, ResourceBody. - Handshake: PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, is_supported_protocol_version, InstanceIdentity, InstanceCapabilities, FrontendCapabilities, NegotiatedCapabilities, negotiate_capabilities, Hello, AttachRequest. What stays in src/protocol.rs: - AttachTarget / AttachError / AttachTargetParseError / AttachTargetValidationError / AttachTargetError / AttachmentHandle (CLI / binding internals, not wire). - crossterm_translate submodule (the crossterm ↔ pmacs-protocol-types translation layer; sits at the binding boundary, not on the wire). - Existing tests (wire-format roundtrip + AttachTarget + crossterm translation), unchanged — they reach the moved types through the 'pub use pmacs_protocol::*' re-export. Mechanical rewrites inside the moved chunk: crate::buffer::BufferId → crate::BufferId, crate::rope::Position → crate::Position, crate::rope::CrdtOp → crate::CrdtOp (the message module is inside pmacs-protocol; identity types live at the crate root). Feature re-added on pmacs-protocol: 'crdt' (was removed in commit 3 as I'd thought CrdtOp was the only feature-gated thing — but InstanceCapabilities::default and FrontendCapabilities::default both call cfg!(feature = 'crdt') for their multi_frontend / crdt_replica / semantic_render defaults). Re-added with a doc comment explaining why. The parent pmacs crate's 'crdt' feature now activates 'pmacs-protocol/crdt' so the cfg!() check evaluates consistently in both crates. Full gate green: fmt, clippy --all-targets -D warnings, lib 1314, m4_acceptance 83, m8_1/m8_9/m8_10 10/26/19, m9_1 18, m5_8 5, m11_5_semantic_acceptance --features crdt 2. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.toml | 2 +- pmacs-protocol/Cargo.toml | 14 +- pmacs-protocol/src/lib.rs | 12 +- pmacs-protocol/src/message.rs | 1350 ++++++++++++++++++++++++++++++++ src/protocol.rs | 1355 +-------------------------------- 5 files changed, 1383 insertions(+), 1350 deletions(-) create mode 100644 pmacs-protocol/src/message.rs diff --git a/Cargo.toml b/Cargo.toml index 5c4e2b5..1c46ca0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,7 +72,7 @@ lua54 = ["mlua/lua54", "mlua/vendored"] # field on the Buffer struct layout, no branch on apply_edit). v1.0 # builds enable `crdt`; the rope-projection redirect from M10.1 means # the feature flip is invisible to v0.1 frontends and to workers. -crdt = ["dep:loro"] +crdt = ["dep:loro", "pmacs-protocol/crdt"] [dependencies] crossterm = "0.28" diff --git a/pmacs-protocol/Cargo.toml b/pmacs-protocol/Cargo.toml index 27adff8..a1ced85 100644 --- a/pmacs-protocol/Cargo.toml +++ b/pmacs-protocol/Cargo.toml @@ -32,11 +32,15 @@ cast_precision_loss = "allow" similar_names = "allow" multiple_crate_versions = "allow" -# No features — the wire type set is unconditional. `CrdtOp` is -# always compiled (matching the original `pmacs::rope::CrdtOp`'s -# "not `#[cfg]`-gated to avoid feature-flag proliferation through -# every Edit consumer" decision); the parent `pmacs` crate's `crdt` -# feature gates loro and op-application, not wire shape. +[features] +# Mirrors the parent `pmacs` crate's `crdt` feature. The wire type +# set is unconditional (`CrdtOp` is always compiled — see +# `crdt.rs`); the feature exists so `cfg!(feature = "crdt")` checks +# inside capability-default helpers (`InstanceCapabilities::default`, +# `FrontendCapabilities::default`) evaluate to the same value here +# as in the parent crate. The parent activates it via +# `pmacs-protocol/crdt` from its own `crdt` feature. +crdt = [] [dependencies] serde = { workspace = true } diff --git a/pmacs-protocol/src/lib.rs b/pmacs-protocol/src/lib.rs index 45dfd1b..be5da5c 100644 --- a/pmacs-protocol/src/lib.rs +++ b/pmacs-protocol/src/lib.rs @@ -37,12 +37,18 @@ pub mod cell; pub mod crdt; pub mod ids; +pub mod message; pub use cell::{ Attachment, Cell, CellCoord, CellSize, Color, DiffSpan, Glyph, Style, UnderlineStyle, }; pub use crdt::CrdtOp; pub use ids::{BufferId, ByteRange, FrontendId, Position}; - -// The `SemanticFrame` family and the top-level message envelopes -// follow in the final commit within this PR. +pub use message::{ + AdornmentContent, AdornmentPlacement, AttachRequest, BlockAdornment, CursorState, Decoration, + DecorationKind, DecorationSegment, FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, + InlineAdornment, InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, Key, + KeyEvent, Modifiers, MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities, + PROTOCOL_VERSION, ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, StyleSegment, + StyleSpan, is_supported_protocol_version, negotiate_capabilities, +}; diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs new file mode 100644 index 0000000..ea9d91c --- /dev/null +++ b/pmacs-protocol/src/message.rs @@ -0,0 +1,1350 @@ +//! Wire message envelopes — moved from `pmacs::protocol` in session 1 +//! of the `pmacs-gpu` arc. Contains the input event types +//! (`Key`/`Modifiers`/`KeyEvent`, mouse types, `FrontendEvent`), the +//! instance-side message types (`InstanceMessage`, `CursorState`, +//! `InstanceSignal`, `GoodbyeReason`, `SelectionSnapshot`), the +//! `SemanticFrame` family (`StyleSpan`/`StyleSegment`, `Decoration`, +//! `Adornment*`, `ResourceBody`), and the attach handshake +//! (`Hello`/`AttachRequest`/`InstanceCapabilities`/`FrontendCapabilities`/ +//! `NegotiatedCapabilities`/`InstanceIdentity` + `PROTOCOL_VERSION` / +//! `SUPPORTED_PROTOCOL_VERSIONS`). +//! +//! The original `pmacs::protocol` module keeps internal CLI / binding +//! types (`AttachTarget`, `AttachError`, `AttachmentHandle`, the +//! `crossterm_translate` submodule) plus the existing wire-format +//! roundtrip tests; it re-exports everything below so existing +//! `crate::protocol::*` imports stay stable. + +use crate::cell::{Cell, CellCoord, CellSize, DiffSpan}; +use crate::ids::{ByteRange, FrontendId}; + +// --------------------------------------------------------------------------- +// Key encoding +// --------------------------------------------------------------------------- + +/// Key code, normalized away from any specific terminal protocol. +/// +/// `Char` covers printable input. The named variants cover the keys +/// terminals report distinctly (arrows, function keys, etc.). `Unknown` +/// is the escape hatch: a key the protocol layer cannot encode in +/// any of the named variants is preserved as a u32 sentinel so it +/// can round-trip through serialization without becoming an error. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)] +pub enum Key { + /// A printable character. The character is the user-visible + /// codepoint after layout / IME processing. + Char(char), + /// A function key. `n` is 1-based: `F(1)` is F1. + F(u8), + /// Backspace / `^H`. + Backspace, + /// Enter / Return / `^M`. + Enter, + /// Left arrow. + Left, + /// Right arrow. + Right, + /// Up arrow. + Up, + /// Down arrow. + Down, + /// Home key. + Home, + /// End key. + End, + /// Page Up. + PageUp, + /// Page Down. + PageDown, + /// Tab. + Tab, + /// Shift-Tab. + BackTab, + /// Forward delete. + Delete, + /// Insert. + Insert, + /// Escape. + Escape, + /// Caps Lock. + CapsLock, + /// Scroll Lock. + ScrollLock, + /// Num Lock. + NumLock, + /// Print Screen. + PrintScreen, + /// Pause / Break. + Pause, + /// Menu / context-menu key. + Menu, + /// Numeric-keypad center key. + KeypadBegin, + /// The "null" keycode (terminal-protocol artifact). + Null, + /// A key the protocol layer does not recognize. The `u32` + /// preserves whatever sentinel value the upstream layer attached + /// (e.g. a media-key code from kitty's keyboard protocol). Round-trips + /// through serialization but is not actionable by commands. + Unknown(u32), +} + +/// Modifier-key set. Bit-flag encoding for compact wire shape. +/// +/// `META` corresponds to the "logo" / "super" key on most keyboards. +/// `HYPER` is reserved for the rare keyboards that distinguish it +/// from `META` (kitty's keyboard protocol surfaces both). +#[derive( + Copy, Clone, Eq, PartialEq, Hash, Debug, Default, serde::Serialize, serde::Deserialize, +)] +pub struct Modifiers(u8); + +impl Modifiers { + /// Empty set: no modifiers held. + pub const NONE: Modifiers = Modifiers(0); + /// Shift. + pub const SHIFT: Modifiers = Modifiers(1 << 0); + /// Control. + pub const CTRL: Modifiers = Modifiers(1 << 1); + /// Alt / Option. + pub const ALT: Modifiers = Modifiers(1 << 2); + /// Meta / Super / Logo / Command. + pub const META: Modifiers = Modifiers(1 << 3); + /// Hyper. Distinguished from `META` only on keyboards that + /// surface both (kitty's keyboard protocol). + pub const HYPER: Modifiers = Modifiers(1 << 4); + + /// Construct from a raw bit set. Bits outside the defined range + /// are silently masked off so a future-extended wire cannot smuggle + /// undefined bits past current decoders. + #[must_use] + pub const fn from_bits_truncate(bits: u8) -> Self { + Self(bits & 0b0001_1111) + } + + /// Raw bit set. + #[must_use] + pub const fn bits(self) -> u8 { + self.0 + } + + /// Whether `self` includes every bit set in `other`. + #[must_use] + pub const fn contains(self, other: Modifiers) -> bool { + (self.0 & other.0) == other.0 + } + + /// Whether no modifiers are held. + #[must_use] + pub const fn is_empty(self) -> bool { + self.0 == 0 + } +} + +impl std::ops::BitOr for Modifiers { + type Output = Modifiers; + fn bitor(self, rhs: Modifiers) -> Modifiers { + Modifiers(self.0 | rhs.0) + } +} + +impl std::ops::BitOrAssign for Modifiers { + fn bitor_assign(&mut self, rhs: Modifiers) { + self.0 |= rhs.0; + } +} + +/// A keyboard event. +#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct KeyEvent { + /// Frontend that produced the event. + pub frontend_id: FrontendId, + /// The key code. + pub key: Key, + /// Modifier set held when the key was pressed. + pub mods: Modifiers, + /// Monotonic timestamp at which the frontend captured the event. + /// Zero means "no timestamp available" (e.g. test-synthesized + /// events). + pub timestamp_ns: u64, +} + +// --------------------------------------------------------------------------- +// Mouse encoding +// --------------------------------------------------------------------------- + +/// Mouse button. +#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum MouseButton { + /// Left button. + Left, + /// Right button. + Right, + /// Middle button. + Middle, +} + +/// Kind of mouse interaction. +#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum MouseKind { + /// Button pressed. + Down(MouseButton), + /// Button released. + Up(MouseButton), + /// Drag with the named button held. + Drag(MouseButton), + /// Pointer moved with no button held. + Move, + /// Wheel scrolled up. + ScrollUp, + /// Wheel scrolled down. + ScrollDown, + /// Wheel scrolled left. + ScrollLeft, + /// Wheel scrolled right. + ScrollRight, +} + +/// A mouse event. +#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct MouseEvent { + /// Frontend that produced the event. + pub frontend_id: FrontendId, + /// Kind of mouse interaction. + pub kind: MouseKind, + /// Cell-grid coordinate of the pointer at the moment of the event. + pub coord: CellCoord, + /// Modifiers held during the event. + pub mods: Modifiers, +} + +// --------------------------------------------------------------------------- +// Frontend → Instance events +// --------------------------------------------------------------------------- + +/// Input event from frontend to instance. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum FrontendEvent { + /// A key event. + Key(KeyEvent), + /// A mouse event. + Mouse(MouseEvent), + /// Frontend's terminal resized. + Resize { + /// Frontend that resized. + frontend_id: FrontendId, + /// New size, in cells. + size: CellSize, + }, + /// Bracketed-paste payload from the frontend. + Paste { + /// Frontend that produced the paste. + frontend_id: FrontendId, + /// Raw bytes pasted (the instance decodes as UTF-8 if relevant). + data: Vec, + }, + /// Frontend gained input focus. + FocusGained(FrontendId), + /// Frontend lost input focus. + FocusLost(FrontendId), + /// Frontend is going away. Instance treats this as immediate + /// detach; no acknowledgement required. + Detach(FrontendId), + /// T M10.5: CRDT operation produced by this frontend's local + /// edit, sent to the instance for broadcast to the other + /// attached frontends. The actual flow that produces these + /// (frontend maintaining a local CRDT state, applying edits + /// optimistically, sending the resulting op) is wired in M10.8 + /// + M10.10; M10.5 declares the wire shape so the protocol + /// version bump (1 → 2) covers it. + /// + /// Only sent by v1.0 frontends (`protocol_version = 2`); v0.1 + /// frontends never emit this variant. Sessions negotiated at + /// protocol version 1 must NOT receive this on the + /// instance-side dispatcher (the daemon filters per-session; + /// the editor-core treats it as an unknown frontend event if + /// it ever arrives from a v1 session, which it shouldn't). + CrdtOp { + /// Which attached frontend produced this op. The instance + /// uses this to avoid echoing the op back to its sender. + frontend_id: FrontendId, + /// Which buffer this op affects. The instance routes the + /// op to that buffer's CRDT state. + buffer_id: crate::BufferId, + /// The CRDT operation payload — `peer_id` + opaque wire bytes + /// loro's `import_updates` decodes. + op: crate::CrdtOp, + }, + /// T M11.1: the buffer byte range a semantic frontend currently + /// has on screen, in buffer coordinates. Replaces the + /// instance-derived grid viewport for `semantic_render` sessions: + /// the instance scopes its `StyleSpans` / `Decorations` / … to + /// this range rather than shipping a whole file's styling. + /// + /// **No pixels.** This carries a byte range, never viewport pixel + /// size, DPI, font metrics, or glyph advances — the contract + /// boundary invariant from the semantic-frontend design note. The + /// frontend owns all visual-motion semantics and resolves + /// pixel→offset locally; there is deliberately no hit-test + /// request variant and no `SemanticResize`, both of which would + /// leak pixels across the boundary. + /// + /// `generation` ties the declared range to a CRDT version so the + /// instance can ignore a viewport that races a not-yet-applied + /// edit (symmetric with `StyleSpans::generation`). + /// + /// Only emitted by sessions that negotiated `semantic_render`; + /// a non-semantic session never sends it. M11.1 declares the + /// wire shape; the instance-side consumer is wired with the + /// projection seam in M11.2. + Viewport { + /// Which frontend's viewport this is. + frontend_id: FrontendId, + /// Which buffer the visible range indexes into. + buffer_id: crate::BufferId, + /// Half-open byte range currently on screen. + visible: ByteRange, + /// CRDT generation the frontend computed `visible` against. + generation: u64, + }, +} + +impl FrontendEvent { + /// The frontend that produced this event. + #[must_use] + pub fn frontend_id(&self) -> FrontendId { + match self { + Self::Key(e) => e.frontend_id, + Self::Mouse(e) => e.frontend_id, + Self::Resize { frontend_id, .. } + | Self::Paste { frontend_id, .. } + | Self::FocusGained(frontend_id) + | Self::FocusLost(frontend_id) + | Self::Detach(frontend_id) + | Self::CrdtOp { frontend_id, .. } + | Self::Viewport { frontend_id, .. } => *frontend_id, + } + } +} + +// --------------------------------------------------------------------------- +// Instance → Frontend messages +// --------------------------------------------------------------------------- + +/// Cursor position and visibility. +#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct CursorState { + /// Cell where the cursor should be drawn. + pub coord: CellCoord, + /// Whether the cursor is visible at all. + pub visible: bool, +} + +/// Instance-level signal that is not a render message. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum InstanceSignal { + /// Terminal bell. + Bell, + /// Window-title change request. + Title(String), + /// Clipboard set request (OSC 52). + Clipboard(Vec), +} + +/// Reason an instance terminates an attachment. +/// +/// Only the four variants the v0.1 daemon actually emits or rejects on. +/// `Evicted` (multi-frontend takeover) and similar will land alongside +/// the v0.3 multi-frontend work; until then `AlreadyAttached` covers +/// the single-slot equivalent. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum GoodbyeReason { + /// Instance is shutting down (SIGTERM / SIGINT or clean exit). + ShuttingDown, + /// Frontend's `protocol_version` does not match the instance's. + /// The handshake fails before any further messages. + VersionMismatch { + /// The instance's `PROTOCOL_VERSION`. + server: u32, + /// The version the frontend announced in its `AttachRequest`. + client: u32, + }, + /// Another frontend is currently attached. v0.1 rejects concurrent + /// attaches; v0.3 will replace this with eviction or multiplexing. + AlreadyAttached, + /// Frontend sent a malformed message or otherwise violated the + /// protocol. The connection is closed without further dialogue. + ProtocolError, + /// T M10.7: frontend declared one or more negotiated capability + /// bits that the instance cannot honor. The handshake fails after + /// the version check but before any further messages. + /// + /// `missing` lists the capability *field names* (e.g., + /// `"multi_frontend"`, `"crdt_replica"`) the frontend requested + /// (`true`) that the instance reports as `false`. These strings + /// are stable wire-format identifiers: they are exactly the + /// `FrontendCapabilities` / `InstanceCapabilities` field names, + /// not human-readable descriptions. The frontend translates them + /// for display via [`AttachError`]'s formatting. Renaming a + /// capability bit requires changing both the field name AND the + /// missing-string emission in `negotiate_capabilities` in + /// lockstep — see the M10.7 audit's wire-format-stability + /// section. + CapabilityMismatch { + /// The capability bit names the frontend asked for that the + /// instance does not support. Each entry is a verbatim + /// `FrontendCapabilities` field name. + missing: Vec, + }, +} + +/// Rendering and signals from instance to frontend. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum InstanceMessage { + /// Cell deltas. `full_grid = true` is the initial sync sent on + /// fresh attach (or after a resize where the previous grid is no + /// longer applicable); `full_grid = false` is a differential + /// frame. + CellDelta { + /// One run of changed cells per `DiffSpan`. + spans: Vec, + /// Whether `spans` represents a full-grid resync (true on + /// fresh attach or post-resize) versus an incremental frame. + full_grid: bool, + }, + /// Cursor position and visibility update. + Cursor(Option), + /// Modeline cells. Reserved for v0.3 GUI use; v0.1 ships modeline + /// inside [`InstanceMessage::CellDelta`]. The variant exists in + /// the protocol from day one so adding the discrete channel later + /// is not a breaking change. + ModeLine(Vec), + /// Side-channel signal (bell, title, clipboard). + Signal(InstanceSignal), + /// Instance is terminating the attachment. + Goodbye(GoodbyeReason), + /// T M10.5: CRDT operation broadcast from the instance to all + /// attached frontends. The originating frontend produced this op + /// (via `FrontendEvent::CrdtOp` or via a local editor-core edit + /// that synthesizes one); the instance fans it out so every + /// attached frontend can apply the op to its local CRDT state. + /// + /// Only sent to v1.0 frontends — sessions negotiated at + /// `protocol_version = 1` never receive this variant, per + /// `§sec:m10-backward-compat`. The daemon filters at the + /// outgoing-message path; this variant simply existing in the + /// enum is not a wire-compat issue for v1 sessions because the + /// daemon never emits it to them. + /// + /// M10.5 declares the wire shape. M10.8 wires the editor-core → + /// daemon → frontend flow that actually emits these. + CrdtOp { + /// Which buffer this op affects. v1.0 frontends maintain + /// a per-buffer local CRDT state; this routes to the right + /// one. + buffer_id: crate::BufferId, + /// The CRDT operation payload — `peer_id` + opaque wire bytes + /// loro's `import_updates` decodes. + op: crate::CrdtOp, + }, + /// T M10.6: cursor + selection state of one attached frontend, + /// broadcast to the other v1.0 frontends so they can render + /// peer-presence overlays. Coalesced at the daemon: rapid cursor + /// movement produces one `PresenceUpdate` per tick per source + /// frontend, carrying the *final* state, not intermediate values. + /// + /// Sender exclusion: the source frontend never receives its own + /// `PresenceUpdate`. v0.1 sessions (negotiated `protocol_version = + /// 1`) are filtered out at the daemon's outgoing-message path. + /// + /// M10.6 declares the wire shape AND wires the daemon-side + /// sweep with per-session filter. In single-frontend deployments + /// the recipient list is structurally empty (sender exclusion + /// with no other v2 sessions); M10.8 enables the multi-frontend + /// case where this message actually crosses the wire. The + /// frontend's renderer for peer-cursor overlays is also M10.8. + PresenceUpdate { + /// Which attached frontend this presence belongs to. v1.0 + /// frontends use this to label the peer-cursor overlay + /// ("user 4 is editing here"). + frontend_id: FrontendId, + /// Which buffer the source frontend's cursor is in. + buffer_id: crate::BufferId, + /// Byte offset of the source frontend's cursor within + /// `buffer_id`. Frontends convert to line/column at render + /// time via the rope's coord-mapping; the wire carries the + /// canonical byte offset to avoid encoding-vs-rendering + /// drift across frontends. + cursor: crate::Position, + /// Active selection range, if any. + selection: Option, + }, + /// T M10.10: bootstrap a frontend's local CRDT replica with the + /// instance's current authoritative state. Sent once per active + /// buffer at `SessionEstablished` time (and on subsequent + /// buffer-creation events) to frontends that negotiated + /// `crdt_replica: true`. Frontends that didn't negotiate the + /// capability never receive this variant — the daemon's + /// outgoing-message filter gates the send on + /// `NegotiatedCapabilities::crdt_replica`. + /// + /// `crdt_snapshot` carries loro's run-encoded snapshot + /// (`CrdtState::export_snapshot()`) — the CRDT-internal state + /// including peer IDs, version vectors, and op-history structure. + /// Raw byte contents are insufficient because a fresh CRDT replica + /// initialized from bytes alone diverges on the first concurrent + /// edit. + /// + /// Cursor position is intentionally absent: cursor is per-frontend + /// window state (M10.8 `FrontendView`), not per-buffer CRDT + /// state. The same buffer can appear in multiple windows on one + /// frontend with different cursors; coupling cursor to + /// `BufferSnapshot` would break this model. + BufferSnapshot { + /// Which buffer's CRDT state this snapshot represents. + buffer_id: crate::BufferId, + /// `loro::LoroDoc::export(ExportMode::Snapshot)` output. Applied + /// to a fresh `CrdtState::new(peer_id_from_frontend(my_id))` + /// via `import_snapshot(bytes)` on the receiving frontend. + crdt_snapshot: Vec, + }, + /// T M10.10: the active buffer for a replica frontend, with the + /// cursor position within it. + /// + /// # Semantics (Day 3 step 3b composition-check broadened + /// contract) + /// + /// `CursorByte` represents "the active buffer for this frontend + /// is `buffer_id`; the cursor in that buffer is at `byte_pos`." + /// Not just "the cursor moved." This contract matters: a narrow + /// "cursor moved" emission would miss active-buffer-changed- + /// without-cursor-motion events (Lua-driven buffer switch + /// landing at the same byte position), and the frontend's + /// active-buffer tracking would go stale. + /// + /// Daemon emits `CursorByte` on every per-tick render frame for + /// replica frontends, derived fresh from `active_window_for(fid)`. + /// Cursor move, active-buffer change, and active-window change + /// all produce a new emission carrying the current `(buffer_id, + /// byte_pos)`. The per-tick rate (16ms at 60Hz) is the same as + /// `Cursor`'s grid-coord variant. + /// + /// # Why a separate variant from `Cursor` + /// + /// `Cursor` carries grid coordinates (row/col cells) — the + /// frontend uses them to paint the cursor. The optimistic-apply + /// path needs byte position (CRDT insert/delete is byte-indexed), + /// which the grid coordinate can't recover without duplicating + /// the daemon's view-layout logic (tab expansion, line wrap, + /// double-width chars, viewport offset). `CursorByte` is the + /// authoritative byte position for the active buffer. + /// + /// # Atomicity with `Cursor` + /// + /// The daemon emits `Cursor` and `CursorByte` together for + /// replica frontends — both derived from the same render-frame + /// iteration so they describe the cursor in the same instant in + /// two reference frames. Non-replica frontends receive only + /// `Cursor` (existing behavior). The replica frontend that sees + /// `Cursor` without a paired `CursorByte` would interpret stale + /// byte position; the daemon guarantees both emit together by + /// derivation, not by message-protocol atomicity. + /// + /// # Wire-format compatibility + /// + /// New variant in v2; receivers without M10.10 hard-error on + /// decode (postcard does not gracefully degrade unknown variants, + /// per M10.10-FRAMING.md Refinement 3). Capability-gated: daemon + /// sends only to frontends that negotiated `crdt_replica: true`. + /// `PROTOCOL_VERSION` stays at 2. + CursorByte { + /// The buffer the cursor is in. A replica frontend tracks + /// per-buffer cursors; this routes the update to the right + /// entry. + buffer_id: crate::BufferId, + /// Byte offset of the cursor within `buffer_id`. Source of + /// truth for the optimistic-apply path's insert / delete + /// position arguments. Wire type matches + /// `PresenceUpdate::cursor` (`u64`) for consistency; frontend + /// converts to `usize` for the loro API. + byte_pos: crate::Position, + }, + /// T M11.1 — syntax + face styling over the semantic frontend's + /// current viewport range. `generation` ties the spans to a CRDT + /// version so the frontend can discard styling that predates an + /// edit it has already applied optimistically. Ships **no text** — + /// the frontend holds the rope via the `crdt_replica` machinery + /// and interprets these spans over it. + /// + /// # Diff shape (T M11.4) + /// + /// Mirrors `CellDelta`'s `full_grid` + changed-runs structure, + /// lifted from positional cells to byte-anchored ranges. `full = + /// true` is a resync: the frontend discards all prior styling for + /// `buffer_id` and the `segments` are authoritative for the whole + /// declared viewport (first frame after a `Viewport`, a viewport + /// jump, or a generation discontinuity). `full = false` is + /// incremental: each [`StyleSegment`] replaces styling **only** + /// within its `range`; bytes covered by no segment keep their + /// previously-applied style. A frame whose styling is unchanged + /// ships no `StyleSpans` at all. + /// + /// Because byte offsets cascade on edits (an insert shifts every + /// later span), an incremental frame after an edit dirties + /// `[edit, viewport_end)` — still bounded, and no-edit frames + /// (cursor move, scroll within the declared viewport, selection) + /// cost nothing. Each segment carries *all* current spans + /// intersecting its range (clipped), not only changed ones, so an + /// unchanged span overlapping a dirty range is faithfully + /// reconstructed. + /// + /// Gated on negotiated `semantic_render`; never sent to a grid + /// session (the daemon's per-session outgoing filter — wired with + /// the producer in M11.2 — never emits it there, so postcard's + /// hard-error on unknown variants is mooted exactly as it is for + /// `CursorByte`). + StyleSpans { + /// Buffer these spans interpret. + buffer_id: crate::BufferId, + /// CRDT generation the spans were computed against. + generation: u64, + /// `true` → discard all prior styling for `buffer_id` first; + /// `segments` are authoritative for the declared viewport. + full: bool, + /// Dirty byte regions and the styling now covering them. + segments: Vec, + }, + /// T M11.1 — diagnostics, search hits, current-line, and any + /// other "this region means something" overlay, as offset ranges + /// plus a kind. Peer selection is **not** here — it stays on the + /// existing `PresenceUpdate` path. Gated on `semantic_render`. + /// + /// T M11.4 — same `full` + segment diff shape as `StyleSpans` + /// (see its docs), and gains `generation` for parity: a frontend + /// wants the CRDT version decorations were computed against for + /// the same optimistic-edit race reason styling does. + Decorations { + /// Buffer these decorations apply to. + buffer_id: crate::BufferId, + /// CRDT generation the decorations were computed against. + generation: u64, + /// `true` → discard all prior decorations for `buffer_id` + /// first; `segments` are authoritative for the viewport. + full: bool, + /// Dirty byte regions and the decorations now covering them. + segments: Vec, + }, + /// T M11.1 — inlay hints, blame, lens, virtual text. Anchored at + /// a single offset with a placement; occupies no document bytes — + /// the frontend interleaves it at layout time. Gated on + /// `semantic_render`. + InlineAdornments { + /// Buffer these adornments annotate. + buffer_id: crate::BufferId, + /// The adornment items for the declared viewport. + items: Vec, + }, + /// Coarse whole-file styling summary for a minimap / scrollbar + /// overview, resolving design-note Open Q#2. One [`Style`] per + /// source line (the *dominant* style for that line by byte count + /// across the producer's current spans); the frontend maps minimap + /// rows to one or more of these. Unlike [`Self::StyleSpans`], this + /// is **not** viewport-scoped — the minimap shows the whole file. + /// + /// Recomputed when the buffer's CRDT `generation` advances; an + /// unchanged buffer ships no further summary. A coarser + /// representation (fixed bands) or finer (run-length style runs) + /// is recorded in `docs/semantic-frontend-protocol.md` as future + /// refinements; per-line dominant style is the v1 choice because + /// minimap rows naturally correspond to code lines. + /// + /// Gated on negotiated `semantic_render`; the daemon emits it only + /// for sessions that have a [`crate::semantic_render::SemanticRenderState`] + /// (structural gating, same as every other semantic family). + FileStyleSummary { + /// Buffer this summary describes. + buffer_id: crate::BufferId, + /// CRDT generation the summary was computed against. The + /// frontend can discard a summary that predates an edit it + /// has already applied optimistically. + generation: u64, + /// One [`crate::cell::Style`] per source line, in line order + /// from line 0. Empty when the buffer is empty. + lines: Vec, + }, + /// T M11.1 — diff zones, folded-region placeholders, anything + /// occupying its own vertical band. Anchored to the offset of the + /// line it precedes or replaces; the frontend allocates the + /// vertical space. Gated on `semantic_render`. + BlockAdornments { + /// Buffer these adornments annotate. + buffer_id: crate::BufferId, + /// The block items for the declared viewport. + items: Vec, + }, + /// T M11.1 — the instance's authoritative fold set as document + /// facts. Folding is an instance command-semantics concern (Lua + /// can fold); the visual collapse is a frontend layout concern — + /// the frontend renders the placeholder and adjusts its own + /// layout. Gated on `semantic_render`. + FoldState { + /// Buffer whose fold set this is. + buffer_id: crate::BufferId, + /// Folded byte ranges. + folds: Vec, + }, + /// T M11.1 — out-of-band content an adornment refers to (images, + /// blame avatars). Sent once, referenced by `handle`, so it is + /// not re-shipped per frame. Gated on `semantic_render`. + ResourceOffer { + /// Stable handle adornments reference via + /// [`AdornmentContent::Resource`]. + handle: u64, + /// MIME type of `body`. + mime: String, + /// Inline bytes or a URI the frontend resolves itself. + body: ResourceBody, + }, +} + +/// Flat selection state for the wire. +/// +/// Mirrors [`crate::window::Selection`] but as a self-contained pair +/// of byte offsets — `anchor` is where the selection began, +/// `active` is the current selection cursor. Either may be the +/// numerically larger value; callers wanting `(lo, hi)` order +/// compute it locally. +/// +/// Kept flat (no nested types) so [`PartialEq`] equality is exactly +/// wire-representation equality: two `SelectionSnapshot`s compare +/// equal iff they serialize to identical bytes. The presence-diff +/// sweep relies on this — see [`crate::presence::SessionRegistry`]. +#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct SelectionSnapshot { + /// Where the selection began. + pub anchor: crate::Position, + /// The active end (typically the cursor at the moment of the + /// snapshot). + pub active: crate::Position, +} + +// --------------------------------------------------------------------------- +// T M11.1 — Semantic-frontend projection types +// +// The payloads of the `InstanceMessage::StyleSpans` … `ResourceOffer` +// family and `FrontendEvent::Viewport`. Everything is anchored in +// **byte offsets** (consistent with `CursorByte`): line/col is a +// frontend rendering concern, CRDT position is replica-internal. The +// instance never learns a pixel — see the contract boundary in +// `docs/semantic-frontend-protocol.md`. +// +// The variant/kind sets here are provisional and co-evolve within the +// M11 arc behind the `semantic_render` capability + protocol v3, +// exactly as the CRDT op shape evolved M10.5→M10.10 behind +// `crdt_replica`. They are not a wire-compat hazard for non-semantic +// sessions: the daemon's per-session outgoing filter (wired with the +// producer in M11.2) never emits the family to a session that didn't +// negotiate `semantic_render`, so postcard's hard-error on unknown +// variants is mooted exactly as it is for `CursorByte`. +// --------------------------------------------------------------------------- + +// `ByteRange` moved to `pmacs-protocol` (see the re-export near the +// top of this file). + +/// One run of buffer bytes carrying a resolved visual style. The +/// instance is the single syntax/face authority; the frontend lays +/// the style out locally over rope text it already holds. Reuses +/// [`crate::cell::Style`] so the grid and semantic projections share +/// one style vocabulary. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct StyleSpan { + /// Byte range this style covers. + pub range: ByteRange, + /// The resolved style (syntax highlight ∘ faces ∘ theme). + pub style: crate::cell::Style, +} + +/// T M11.4 — one dirty byte region of an `InstanceMessage::StyleSpans` +/// frame and the styling now covering it. The semantic analog of a +/// `CellDelta` changed-run: the frontend clears styling within +/// `range` and applies `spans` (already clipped to `range`). `spans` +/// is every current span intersecting `range`, not only changed ones, +/// so an unchanged span overlapping the dirty region is preserved. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct StyleSegment { + /// The byte region the frontend should clear and repaint. + pub range: ByteRange, + /// Spans intersecting `range`, each clipped to it. + pub spans: Vec, +} + +/// What a [`Decoration`] region *means*. Provisional variant set (see +/// the module-section note above). Peer selection is deliberately +/// absent — it stays on the `PresenceUpdate` path. +#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum DecorationKind { + /// LSP diagnostic, error severity. + DiagnosticError, + /// LSP diagnostic, warning severity. + DiagnosticWarning, + /// LSP diagnostic, information severity. + DiagnosticInfo, + /// LSP diagnostic, hint severity. + DiagnosticHint, + /// The local selection region. + Selection, + /// A non-active search match. + SearchMatch, + /// The currently-focused search match. + SearchMatchActive, + /// The line containing the cursor. + CurrentLine, +} + +/// A byte range tagged with what it means. The frontend decides how +/// to paint each [`DecorationKind`] (squiggle, highlight, gutter +/// mark) — the instance only states the fact. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Decoration { + /// Byte range the decoration covers. + pub range: ByteRange, + /// What the region signifies. + pub kind: DecorationKind, +} + +/// T M11.4 — `StyleSegment`'s analog for the `Decorations` family: +/// one dirty byte region and the decorations now covering it (every +/// current decoration intersecting `range`, clipped to it). +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct DecorationSegment { + /// The byte region the frontend should clear and repaint. + pub range: ByteRange, + /// Decorations intersecting `range`, each clipped to it. + pub decorations: Vec, +} + +/// Where an [`InlineAdornment`] sits relative to its anchor offset. +#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum AdornmentPlacement { + /// On its own, before the line containing `at`. + BeforeLine, + /// At the end of the line containing `at`. + EndOfLine, + /// Inline, exactly at the byte offset `at`. + AtOffset, +} + +/// Adornment payload: either inline styled text, or a handle into a +/// previously-sent [`InstanceMessage::ResourceOffer`] so out-of-band +/// content (images, blame avatars) is shipped once, not per frame. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum AdornmentContent { + /// Styled virtual text. + Text { + /// The virtual text to display. + text: String, + /// Its style. + style: crate::cell::Style, + }, + /// A handle into a `ResourceOffer`. + Resource { + /// The offered resource's handle. + handle: u64, + }, +} + +/// Virtual text occupying no document bytes (inlay hints, blame, +/// lens). Anchored at a single offset; the frontend interleaves it +/// at layout time. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct InlineAdornment { + /// Buffer byte offset this adornment anchors to. + pub at: u64, + /// Placement relative to `at`. + pub placement: AdornmentPlacement, + /// What to render. + pub content: AdornmentContent, +} + +/// Content occupying its own vertical band (diff zones, folded-region +/// placeholders). Anchored to the offset of the line it precedes or +/// replaces. `replaces` is `Some` when the band stands in for a +/// collapsed region (the frontend renders the placeholder instead of +/// that range), `None` for an additive band. The frontend allocates +/// the vertical space — the instance never dictates pixel height. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct BlockAdornment { + /// Buffer byte offset of the line this band precedes/replaces. + pub at: u64, + /// The byte range this band stands in for, if it replaces one. + pub replaces: Option, + /// What to render in the band. + pub content: AdornmentContent, +} + +/// The body of an [`InstanceMessage::ResourceOffer`] — inline bytes +/// for small payloads, or a URI the frontend resolves itself for +/// large or remote resources. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum ResourceBody { + /// The resource bytes, carried inline. + Inline(Vec), + /// A URI the frontend fetches/resolves on its own. + Uri(String), +} +// --------------------------------------------------------------------------- +// Handshake — version, identity, capabilities +// --------------------------------------------------------------------------- + +/// Wire-protocol version. Bumped on any breaking change to the +/// `Hello` / `AttachRequest` / event-message shapes. +/// +/// The handshake compares against [`SUPPORTED_PROTOCOL_VERSIONS`]; +/// mismatches close the connection with +/// [`GoodbyeReason::VersionMismatch`]. v1.0 servers and clients accept +/// either the v0.1 wire (version 1) or the v1.0 wire (version 2) per +/// `§sec:m10-backward-compat` — both directions of the version +/// asymmetry need symmetric relaxation so v0.1-era binaries connect +/// to v1.0-era binaries (and vice versa) once both have shipped. +/// +/// T M10.5: bumped from 1 to 2. The v0.1 wire (version 1) remains +/// accepted by v1.0 binaries; CRDT-only message variants +/// (`InstanceMessage::CrdtOp`, `FrontendEvent::CrdtOp`) are filtered +/// per-session for v1 negotiated sessions. +/// +/// T M11.1: bumped from 2 to 3. The v1.0 wire (version 2) remains +/// accepted; the semantic-frontend variant family +/// (`InstanceMessage::StyleSpans` … `ResourceOffer`, +/// `FrontendEvent::Viewport`) is filtered per-session for sessions +/// that did not negotiate `semantic_render`. Mechanically identical +/// to the M10.5 bump: the slice-membership handshake check (not +/// strict equality) means v0.1/v1.0 binaries keep connecting +/// unchanged, and the new variants simply existing in the enums is +/// not a wire-compat issue for non-semantic sessions because the +/// daemon never emits them to those sessions. +pub const PROTOCOL_VERSION: u32 = 3; + +/// T M10.5: the set of protocol versions a v1.0 binary accepts on +/// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept +/// `[1, 2]` so the version asymmetry the §sec:m10-backward-compat +/// spec section describes is handled symmetrically on both sides. +/// +/// T M11.1: extended to `[1, 2, 3]`. v1.1 binaries accept the v0.1 +/// (1), v1.0 (2), and semantic-frontend (3) wires. The check remains +/// slice membership — "is the peer's `protocol_version` present in +/// this slice?" — not strict equality on `PROTOCOL_VERSION`. The +/// session's negotiated version (the peer's) is recorded for +/// downstream filtering: v1 sessions don't receive +/// `InstanceMessage::CrdtOp` / `PresenceUpdate` messages even from +/// a v3 daemon, and only sessions that negotiated `semantic_render` +/// receive the `SemanticFrame` variant family. +pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[1, 2, 3]; + +/// T M10.5: predicate for the handshake check. Returns `true` if +/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`]. +#[must_use] +pub fn is_supported_protocol_version(peer_version: u32) -> bool { + SUPPORTED_PROTOCOL_VERSIONS.contains(&peer_version) +} + +/// Identifies an instance for client-side display. +/// +/// Sent inside [`Hello`] from instance to frontend. Use of `uptime_secs` +/// instead of an absolute start time is deliberate: instance and +/// frontend may run on machines whose clocks disagree, so the frontend +/// computes "instance has been running N seconds" using only the +/// instance's view of time. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct InstanceIdentity { + /// Pmacs version string (`env!("CARGO_PKG_VERSION")`). + pub pmacs_version: String, + /// Short git hash if the build embedded one. `None` for releases or + /// source-tarball builds where no git checkout was available. + pub build_hash: Option, + /// The name the instance was launched under (`--socket NAME`). + /// `None` for the default daemon (no `--socket` argument). + pub instance_name: Option, + /// Seconds since the instance started, from the instance's clock. + /// Frontend displays "running 47m" by interpreting this against + /// its own notion of "now," avoiding cross-machine clock skew. + pub uptime_secs: u64, + /// Working directory the instance is running in. Encoded as a + /// UTF-8 string; non-UTF-8 paths are rejected at the boundary. + pub working_directory: String, +} + +impl InstanceIdentity { + /// Build an identity for the running pmacs process. + /// + /// `instance_name` is the user-facing name (typically the + /// `--socket NAME` value for the daemon path; `None` for the + /// in-process Local mode and the unnamed default daemon). + /// `started` is the wall-clock anchor used to compute + /// [`Self::uptime_secs`]; the elapsed seconds are evaluated at the + /// call site, so calling twice on different days surfaces different + /// uptimes from the same anchor. + /// + /// The version comes from `CARGO_PKG_VERSION` and the build hash + /// from the optional `PMACS_GIT_HASH` environment variable populated + /// by the build script. + #[must_use] + pub fn for_running_process(instance_name: Option, started: std::time::Instant) -> Self { + Self { + pmacs_version: env!("CARGO_PKG_VERSION").into(), + build_hash: option_env!("PMACS_GIT_HASH").map(String::from), + instance_name, + uptime_secs: started.elapsed().as_secs(), + working_directory: std::env::current_dir() + .ok() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(), + } + } +} + +/// Capabilities the instance advertises to attaching frontends. +/// +/// Empty for v0.1; the type exists so that adding capabilities in v0.2+ +/// is not a breaking-change. Symmetric with [`FrontendCapabilities`]. +/// +/// T M10.5: added `multi_frontend` and `crdt_replica` bits with +/// `#[serde(default)]` so v1 wire bytes still deserialize. The +/// negotiation logic (which side advertises what, and what the +/// instance does with mismatches) is M10.7 scope; M10.5 just makes +/// the bit positions stable in the wire format. +/// +/// T M10.5/8: bit defaults evolve with the substrate. +/// +/// - M10.5 declared the bits with `#[serde(default)]` so v1 wire +/// bytes deserialize forward-compatibly. M10.5–M10.7 set both bits +/// to `false` so a frontend declaring `multi_frontend: true` got +/// `Goodbye(CapabilityMismatch)` — the multi-frontend path +/// wasn't actually wired yet. +/// - **T M10.8 Day 4 flip**: the instance's `multi_frontend` and +/// `crdt_replica` defaults flip to `true`. This is the "M10.8 enables +/// multi-frontend" moment — the underlying dispatcher (Day 3) and +/// broadcast routing (Day 4) support both capabilities, so the +/// instance advertises them. +/// +/// The frontend-side defaults remain `false` (a frontend that omits +/// the field is conservatively treated as not supporting the +/// capability; matches v0.1 wire-format semantics). +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct InstanceCapabilities { + /// T M10.5: instance can host multi-frontend sessions on the + /// same buffer (per `§sec:m10-collab`). T M10.8 Day 4: default + /// flipped to `true` — the dispatcher supports multiple + /// attached frontends. + #[serde(default = "default_true")] + pub multi_frontend: bool, + /// T M10.5: instance can broadcast `InstanceMessage::CrdtOp` + /// messages. T M10.8 Day 4: default flipped to `true` — the + /// broadcast routing for CRDT ops wires up in this milestone. + #[serde(default = "default_true")] + pub crdt_replica: bool, + /// T M11.1: instance can produce the semantic-frontend variant + /// family (`InstanceMessage::StyleSpans` … `ResourceOffer`) and + /// consume `FrontendEvent::Viewport`. + /// + /// T M11.1 declared the bit position + negotiation mechanics with + /// the default `false` (no producer yet). T M11.2 landed the + /// instance-side projection seam (`SemanticRenderState`) and + /// flipped the default to `cfg!(feature = "crdt")`: the instance + /// now advertises `semantic_render` on CRDT builds. It tracks the + /// `crdt` feature rather than being unconditional because the + /// negotiation dependency rule makes a semantic session + /// necessarily a text replica — a non-CRDT build can host + /// neither. See [`Default`] impl below. + #[serde(default)] + pub semantic_render: bool, +} + +// Clippy in non-CRDT builds notes that `cfg!(feature = "crdt")` +// evaluates to `false`, making this impl derivable. In CRDT builds +// the values are `true`, so the impl is genuinely manual. Allow. +#[allow(clippy::derivable_impls)] +impl Default for InstanceCapabilities { + fn default() -> Self { + // T M10.10 — the `crdt_replica` default tracks the `crdt` + // Cargo feature. A daemon built without the `crdt` feature + // can't honor a `crdt_replica: true` negotiation (the + // CRDT-handling code paths are conditionally compiled out + // — `send_buffer_snapshots`, `apply_remote_crdt_op`, the + // dispatcher's CursorByte emit). Advertising `true` + // unconditionally would be wire-protocol false advertising. + // + // `multi_frontend` is conceptually independent of CRDT but + // in M10.10's architecture every multi-frontend participant + // is also a CRDT replica; gating both on the same feature + // keeps the daemon's advertised capabilities consistent + // with what it can actually do. + // + // T M11.1 declared `semantic_render` defaulting to `false` + // unconditionally — no projection-seam producer existed, so + // advertising it would have been wire-protocol false + // advertising (the M10.5→M10.7 "bits false until the path is + // wired" discipline). + // + // T M11.2 — **the flip**: the instance-side projection seam + // (`SemanticRenderState`, the producer) has landed and the + // dispatcher selects it per session, so the instance now + // advertises `semantic_render`. It tracks `cfg!(feature = + // "crdt")` like `crdt_replica` because the negotiation + // dependency rule makes a semantic session necessarily a + // text replica; a non-CRDT build can host neither. This is + // the "M11.2 enables semantic" moment, exactly analogous to + // the M10.8 Day-4 multi_frontend/crdt_replica flip. + Self { + multi_frontend: cfg!(feature = "crdt"), + crdt_replica: cfg!(feature = "crdt"), + semantic_render: cfg!(feature = "crdt"), + } + } +} + +#[allow(clippy::missing_const_for_fn)] +fn default_true() -> bool { + true +} + +/// Capabilities the frontend advertises to the instance. +/// +/// All bools default to `false` so a frontend that omits a field via an +/// older `AttachRequest` is conservatively treated as not supporting +/// the capability. New capabilities added in v0.2+ get +/// `#[serde(default)]` so old wire bytes still deserialize. +// A capability set is exactly the case `struct_excessive_bools` warns +// against — but each flag is independent and the alternative (an enum +// or bitset) loses the per-field `#[serde(default)]` semantics that +// make schema evolution work. +#[allow(clippy::struct_excessive_bools)] +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct FrontendCapabilities { + /// Frontend understands DEC 2026 `BeginSynchronizedUpdate` / + /// `EndSynchronizedUpdate` markers. Instance strips them when false. + #[serde(default)] + pub synchronized_output: bool, + /// Frontend can render Unicode beyond the Basic Multilingual Plane. + /// Instance can substitute a fallback glyph when false. + #[serde(default)] + pub unicode_smp: bool, + /// Frontend supports 24-bit color (truecolor SGR sequences). + /// Instance maps to the 256-color palette when false. + #[serde(default)] + pub true_color: bool, + /// Frontend captures and forwards mouse events. + #[serde(default)] + pub mouse: bool, + /// Frontend supports bracketed paste — distinguishes pasted bytes + /// from typed bytes. Instance treats all input as keystrokes when false. + #[serde(default)] + pub bracketed_paste: bool, + /// Optional human-readable terminal identifier for logs and + /// debugging only. The instance does not branch on this value; + /// branching is done on the explicit capability bits above. + #[serde(default)] + pub terminal_kind: Option, + /// T M10.5: frontend can participate in multi-frontend sessions + /// (per `§sec:m10-collab`). false for v0.1 frontends — they + /// attach as single-frontend and never receive `CrdtOp` / + /// `PresenceUpdate` broadcasts. v1.0 frontends opt in via M10.7's + /// negotiation handshake. M10.5 declares the bit position; M10.7 + /// wires the negotiation. + /// + /// Default is `false` — v1 frontends are treated as not + /// supporting this feature, which matches reality (v1 frontends + /// have no local CRDT state). A `true` default would have v1 + /// frontends claimed to support features they don't. + #[serde(default)] + pub multi_frontend: bool, + /// T M10.5: frontend can apply incoming `CrdtOp` messages to a + /// local CRDT state. false for v0.1; v1.0 opts in. M10.7 wires + /// negotiation; M10.5 declares the bit position. + #[serde(default)] + pub crdt_replica: bool, + /// T M11.1: frontend is a semantic (layout-local) renderer — it + /// consumes the `InstanceMessage::StyleSpans` … `ResourceOffer` + /// family and emits `FrontendEvent::Viewport`. false for v0.1 and + /// v1.0 grid/TUI frontends; a future GPU/GUI frontend opts in. + /// + /// A semantic frontend is *required* to also be a text replica: + /// the semantic frame ships no text, so the frontend must hold + /// the rope locally via the `crdt_replica` machinery. This + /// dependency is enforced in [`negotiate_capabilities`], not just + /// documented — declaring `semantic_render: true` without + /// `crdt_replica: true` is a capability mismatch, never a silent + /// degrade. + #[serde(default)] + pub semantic_render: bool, +} + +/// T M10.7 — the negotiated capability bits for one attached session. +/// +/// Computed by [`negotiate_capabilities`] from the frontend's +/// [`FrontendCapabilities`] and the instance's [`InstanceCapabilities`]. +/// Each negotiated bit is the AND of the two declared bits. Fields +/// added here in future milestones append at the end with sensible +/// defaults so existing call sites stay valid. +/// +/// This is a daemon-internal struct (not on the wire); the +/// negotiation result is communicated to the frontend via the +/// success of the handshake (no capability-mismatch `Goodbye`) and +/// the instance's behavior thereafter. +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub struct NegotiatedCapabilities { + /// Session is eligible for multi-frontend operation. True iff + /// both the frontend and the instance declared `multi_frontend = + /// true`. v0.1 frontends always end up here as `false` (the v0.1 + /// wire format does not carry the field; `#[serde(default)]` + /// makes the deserialized value `false`). + pub multi_frontend: bool, + /// Session can produce/consume `InstanceMessage::CrdtOp` / + /// `FrontendEvent::CrdtOp`. True iff both sides declared + /// `crdt_replica = true`. The daemon's outgoing-message filter for + /// `CrdtOp` consults this in M10.8. + pub crdt_replica: bool, + /// T M11.1 — session uses the semantic projection: it + /// produces/consumes the `InstanceMessage::StyleSpans` … + /// `ResourceOffer` family and `FrontendEvent::Viewport`. True iff + /// both sides declared `semantic_render = true` *and* the session + /// also negotiated `crdt_replica = true` (a semantic session is a + /// text replica; see [`negotiate_capabilities`]). The daemon's + /// per-session outgoing filter gates the entire semantic family + /// on this bit — wired with the producer in M11.2. + pub semantic_render: bool, +} + +/// T M10.7 — pure-function capability negotiation. +/// +/// For each negotiated bit (`multi_frontend`, `crdt_replica`, +/// `semantic_render`): +/// +/// | Frontend wants | Instance has | Result | +/// |----------------|--------------|--------| +/// | `false` | `false` | bit `false`, no error | +/// | `false` | `true` | bit `false`, no error | +/// | `true` | `true` | bit `true`, no error | +/// | `true` | `false` | bit appears in `missing` | +/// +/// If any bit ends up in `missing`, the negotiation fails as a whole +/// (returns `Err`). Otherwise the negotiated bits are returned as +/// [`NegotiatedCapabilities`]. The `Err` form gathers ALL missing +/// bits into one `CapabilityMismatch` — one round-trip carries the +/// complete picture rather than serial rejections. Missing bits are +/// ordered `multi_frontend`, `crdt_replica`, `semantic_render` for +/// deterministic wire output. +/// +/// # T M11.1 — the `semantic_render ⇒ crdt_replica` dependency +/// +/// A semantic-render session ships no text on the semantic frame; +/// the frontend holds the rope locally via the `crdt_replica` +/// machinery (`BufferSnapshot` to bootstrap, `CrdtOp` to stay live). +/// So `semantic_render` is only coherent on a session that also +/// negotiated `crdt_replica`. When the AND-rule would yield +/// `semantic_render = true` but the session did not also negotiate +/// `crdt_replica = true`, this function rejects with +/// `"semantic_render"` in `missing` rather than silently degrading +/// the session to a text-only replica. The rejected identifier is +/// `"semantic_render"` (the capability whose precondition is unmet), +/// not `"crdt_replica"`. +/// +/// # Wire-format stability +/// +/// The strings emitted into `missing` are exactly the +/// `FrontendCapabilities` field names (`"multi_frontend"`, +/// `"crdt_replica"`). These are stable wire-format identifiers, not +/// human-readable descriptions. User-facing translation is the +/// frontend's responsibility (see [`AttachError`]'s `Display` impl). +/// Renaming a capability bit requires updating both the field name +/// and the missing-string emission here in lockstep. +pub fn negotiate_capabilities( + frontend: &FrontendCapabilities, + instance: &InstanceCapabilities, +) -> Result { + let mut missing = Vec::new(); + let multi_frontend = match (frontend.multi_frontend, instance.multi_frontend) { + (true, false) => { + missing.push("multi_frontend".to_string()); + false + } + (a, b) => a && b, + }; + let crdt_replica = match (frontend.crdt_replica, instance.crdt_replica) { + (true, false) => { + missing.push("crdt_replica".to_string()); + false + } + (a, b) => a && b, + }; + let semantic_render = match (frontend.semantic_render, instance.semantic_render) { + (true, false) => { + missing.push("semantic_render".to_string()); + false + } + (a, b) => a && b, + }; + // T M11.1 — dependency rule. A semantic session is a text replica + // (the semantic frame carries no text). If both sides declared + // `semantic_render` but the session did not also negotiate + // `crdt_replica`, reject rather than silently degrade. Guard + // against a duplicate push: the only path where `semantic_render` + // is already in `missing` is the `(true, false)` arm above, which + // also sets the local `semantic_render` to false, so the + // condition below cannot re-fire for that case — but the explicit + // membership check keeps this robust against future reordering. + if semantic_render && !crdt_replica && !missing.iter().any(|m| m == "semantic_render") { + missing.push("semantic_render".to_string()); + } + let semantic_render = semantic_render && crdt_replica; + if missing.is_empty() { + Ok(NegotiatedCapabilities { + multi_frontend, + crdt_replica, + semantic_render, + }) + } else { + Err(GoodbyeReason::CapabilityMismatch { missing }) + } +} + +/// First message sent by the instance to a freshly-attached frontend. +/// +/// Sent immediately after the connection is accepted, before reading +/// the frontend's [`AttachRequest`]. The frontend uses +/// `instance_identity` for status display and `protocol_version` / +/// `instance_capabilities` for compatibility decisions. +/// +/// The instance also stamps the `assigned_frontend_id` which the +/// frontend will use as the `FrontendId` on every event it sends. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Hello { + /// The instance's `PROTOCOL_VERSION`. + pub protocol_version: u32, + /// `FrontendId` assigned to this attachment by the instance. The + /// frontend stamps this onto subsequent events. v0.1 daemons start + /// allocation at `FrontendId(2)` (1 reserved for the in-process TUI). + pub assigned_frontend_id: FrontendId, + /// Instance self-identification (version, name, uptime, cwd). + pub instance_identity: InstanceIdentity, + /// Instance capabilities. Empty for v0.1. + pub instance_capabilities: InstanceCapabilities, +} + +/// First message sent by a frontend after receiving [`Hello`]. +/// +/// Carries the frontend's view of the protocol version, the +/// capabilities it can support, and its initial terminal size. On +/// version mismatch the instance closes with +/// [`GoodbyeReason::VersionMismatch`] and no further messages flow. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct AttachRequest { + /// The frontend's `PROTOCOL_VERSION`. + pub protocol_version: u32, + /// Frontend capabilities. Defaults to all-false if omitted. + #[serde(default)] + pub frontend_capabilities: FrontendCapabilities, + /// The frontend's terminal size at attach time. Authoritative + /// until the frontend sends a [`FrontendEvent::Resize`]. The + /// instance uses this for the initial full-grid render. + pub initial_size: CellSize, +} diff --git a/src/protocol.rs b/src/protocol.rs index 7710364..8caa2e2 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -40,895 +40,25 @@ //! that touches `crossterm`. SSH transports do not use this submodule; //! they decode the wire directly into [`KeyEvent`] / [`MouseEvent`]. -use crate::cell::{Cell, CellCoord, CellSize, DiffSpan}; +// Cell wire types reach this module through the `pub use +// pmacs_protocol::*` block below; the `crossterm_translate` +// submodule's `use super::{CellCoord, ...}` resolves through that +// glob. use std::path::PathBuf; // --------------------------------------------------------------------------- -// Frontend identity, byte ranges — re-exports from `pmacs-protocol` +// Wire types — re-exports from `pmacs-protocol` // --------------------------------------------------------------------------- -// Session 1 of the `pmacs-gpu` arc moved the wire-types subset of -// this module into the `pmacs-protocol` crate. See -// `docs/pmacs-gpu-design.md`. Existing `crate::protocol::FrontendId` -// / `crate::protocol::ByteRange` imports resolve through these -// re-exports; new consumers (`pmacs-gpu`, debug tools) should depend -// on `pmacs-protocol` directly. -pub use pmacs_protocol::{ByteRange, FrontendId}; - -// --------------------------------------------------------------------------- -// Key encoding -// --------------------------------------------------------------------------- - -/// Key code, normalized away from any specific terminal protocol. -/// -/// `Char` covers printable input. The named variants cover the keys -/// terminals report distinctly (arrows, function keys, etc.). `Unknown` -/// is the escape hatch: a key the protocol layer cannot encode in -/// any of the named variants is preserved as a u32 sentinel so it -/// can round-trip through serialization without becoming an error. -#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)] -pub enum Key { - /// A printable character. The character is the user-visible - /// codepoint after layout / IME processing. - Char(char), - /// A function key. `n` is 1-based: `F(1)` is F1. - F(u8), - /// Backspace / `^H`. - Backspace, - /// Enter / Return / `^M`. - Enter, - /// Left arrow. - Left, - /// Right arrow. - Right, - /// Up arrow. - Up, - /// Down arrow. - Down, - /// Home key. - Home, - /// End key. - End, - /// Page Up. - PageUp, - /// Page Down. - PageDown, - /// Tab. - Tab, - /// Shift-Tab. - BackTab, - /// Forward delete. - Delete, - /// Insert. - Insert, - /// Escape. - Escape, - /// Caps Lock. - CapsLock, - /// Scroll Lock. - ScrollLock, - /// Num Lock. - NumLock, - /// Print Screen. - PrintScreen, - /// Pause / Break. - Pause, - /// Menu / context-menu key. - Menu, - /// Numeric-keypad center key. - KeypadBegin, - /// The "null" keycode (terminal-protocol artifact). - Null, - /// A key the protocol layer does not recognize. The `u32` - /// preserves whatever sentinel value the upstream layer attached - /// (e.g. a media-key code from kitty's keyboard protocol). Round-trips - /// through serialization but is not actionable by commands. - Unknown(u32), -} - -/// Modifier-key set. Bit-flag encoding for compact wire shape. -/// -/// `META` corresponds to the "logo" / "super" key on most keyboards. -/// `HYPER` is reserved for the rare keyboards that distinguish it -/// from `META` (kitty's keyboard protocol surfaces both). -#[derive( - Copy, Clone, Eq, PartialEq, Hash, Debug, Default, serde::Serialize, serde::Deserialize, -)] -pub struct Modifiers(u8); - -impl Modifiers { - /// Empty set: no modifiers held. - pub const NONE: Modifiers = Modifiers(0); - /// Shift. - pub const SHIFT: Modifiers = Modifiers(1 << 0); - /// Control. - pub const CTRL: Modifiers = Modifiers(1 << 1); - /// Alt / Option. - pub const ALT: Modifiers = Modifiers(1 << 2); - /// Meta / Super / Logo / Command. - pub const META: Modifiers = Modifiers(1 << 3); - /// Hyper. Distinguished from `META` only on keyboards that - /// surface both (kitty's keyboard protocol). - pub const HYPER: Modifiers = Modifiers(1 << 4); - - /// Construct from a raw bit set. Bits outside the defined range - /// are silently masked off so a future-extended wire cannot smuggle - /// undefined bits past current decoders. - #[must_use] - pub const fn from_bits_truncate(bits: u8) -> Self { - Self(bits & 0b0001_1111) - } - - /// Raw bit set. - #[must_use] - pub const fn bits(self) -> u8 { - self.0 - } - - /// Whether `self` includes every bit set in `other`. - #[must_use] - pub const fn contains(self, other: Modifiers) -> bool { - (self.0 & other.0) == other.0 - } - - /// Whether no modifiers are held. - #[must_use] - pub const fn is_empty(self) -> bool { - self.0 == 0 - } -} - -impl std::ops::BitOr for Modifiers { - type Output = Modifiers; - fn bitor(self, rhs: Modifiers) -> Modifiers { - Modifiers(self.0 | rhs.0) - } -} - -impl std::ops::BitOrAssign for Modifiers { - fn bitor_assign(&mut self, rhs: Modifiers) { - self.0 |= rhs.0; - } -} - -/// A keyboard event. -#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct KeyEvent { - /// Frontend that produced the event. - pub frontend_id: FrontendId, - /// The key code. - pub key: Key, - /// Modifier set held when the key was pressed. - pub mods: Modifiers, - /// Monotonic timestamp at which the frontend captured the event. - /// Zero means "no timestamp available" (e.g. test-synthesized - /// events). - pub timestamp_ns: u64, -} - -// --------------------------------------------------------------------------- -// Mouse encoding -// --------------------------------------------------------------------------- - -/// Mouse button. -#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum MouseButton { - /// Left button. - Left, - /// Right button. - Right, - /// Middle button. - Middle, -} - -/// Kind of mouse interaction. -#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum MouseKind { - /// Button pressed. - Down(MouseButton), - /// Button released. - Up(MouseButton), - /// Drag with the named button held. - Drag(MouseButton), - /// Pointer moved with no button held. - Move, - /// Wheel scrolled up. - ScrollUp, - /// Wheel scrolled down. - ScrollDown, - /// Wheel scrolled left. - ScrollLeft, - /// Wheel scrolled right. - ScrollRight, -} - -/// A mouse event. -#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct MouseEvent { - /// Frontend that produced the event. - pub frontend_id: FrontendId, - /// Kind of mouse interaction. - pub kind: MouseKind, - /// Cell-grid coordinate of the pointer at the moment of the event. - pub coord: CellCoord, - /// Modifiers held during the event. - pub mods: Modifiers, -} - -// --------------------------------------------------------------------------- -// Frontend → Instance events -// --------------------------------------------------------------------------- - -/// Input event from frontend to instance. -#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum FrontendEvent { - /// A key event. - Key(KeyEvent), - /// A mouse event. - Mouse(MouseEvent), - /// Frontend's terminal resized. - Resize { - /// Frontend that resized. - frontend_id: FrontendId, - /// New size, in cells. - size: CellSize, - }, - /// Bracketed-paste payload from the frontend. - Paste { - /// Frontend that produced the paste. - frontend_id: FrontendId, - /// Raw bytes pasted (the instance decodes as UTF-8 if relevant). - data: Vec, - }, - /// Frontend gained input focus. - FocusGained(FrontendId), - /// Frontend lost input focus. - FocusLost(FrontendId), - /// Frontend is going away. Instance treats this as immediate - /// detach; no acknowledgement required. - Detach(FrontendId), - /// T M10.5: CRDT operation produced by this frontend's local - /// edit, sent to the instance for broadcast to the other - /// attached frontends. The actual flow that produces these - /// (frontend maintaining a local CRDT state, applying edits - /// optimistically, sending the resulting op) is wired in M10.8 - /// + M10.10; M10.5 declares the wire shape so the protocol - /// version bump (1 → 2) covers it. - /// - /// Only sent by v1.0 frontends (`protocol_version = 2`); v0.1 - /// frontends never emit this variant. Sessions negotiated at - /// protocol version 1 must NOT receive this on the - /// instance-side dispatcher (the daemon filters per-session; - /// the editor-core treats it as an unknown frontend event if - /// it ever arrives from a v1 session, which it shouldn't). - CrdtOp { - /// Which attached frontend produced this op. The instance - /// uses this to avoid echoing the op back to its sender. - frontend_id: FrontendId, - /// Which buffer this op affects. The instance routes the - /// op to that buffer's CRDT state. - buffer_id: crate::buffer::BufferId, - /// The CRDT operation payload — `peer_id` + opaque wire bytes - /// loro's `import_updates` decodes. - op: crate::rope::CrdtOp, - }, - /// T M11.1: the buffer byte range a semantic frontend currently - /// has on screen, in buffer coordinates. Replaces the - /// instance-derived grid viewport for `semantic_render` sessions: - /// the instance scopes its `StyleSpans` / `Decorations` / … to - /// this range rather than shipping a whole file's styling. - /// - /// **No pixels.** This carries a byte range, never viewport pixel - /// size, DPI, font metrics, or glyph advances — the contract - /// boundary invariant from the semantic-frontend design note. The - /// frontend owns all visual-motion semantics and resolves - /// pixel→offset locally; there is deliberately no hit-test - /// request variant and no `SemanticResize`, both of which would - /// leak pixels across the boundary. - /// - /// `generation` ties the declared range to a CRDT version so the - /// instance can ignore a viewport that races a not-yet-applied - /// edit (symmetric with `StyleSpans::generation`). - /// - /// Only emitted by sessions that negotiated `semantic_render`; - /// a non-semantic session never sends it. M11.1 declares the - /// wire shape; the instance-side consumer is wired with the - /// projection seam in M11.2. - Viewport { - /// Which frontend's viewport this is. - frontend_id: FrontendId, - /// Which buffer the visible range indexes into. - buffer_id: crate::buffer::BufferId, - /// Half-open byte range currently on screen. - visible: ByteRange, - /// CRDT generation the frontend computed `visible` against. - generation: u64, - }, -} - -impl FrontendEvent { - /// The frontend that produced this event. - #[must_use] - pub fn frontend_id(&self) -> FrontendId { - match self { - Self::Key(e) => e.frontend_id, - Self::Mouse(e) => e.frontend_id, - Self::Resize { frontend_id, .. } - | Self::Paste { frontend_id, .. } - | Self::FocusGained(frontend_id) - | Self::FocusLost(frontend_id) - | Self::Detach(frontend_id) - | Self::CrdtOp { frontend_id, .. } - | Self::Viewport { frontend_id, .. } => *frontend_id, - } - } -} - -// --------------------------------------------------------------------------- -// Instance → Frontend messages -// --------------------------------------------------------------------------- - -/// Cursor position and visibility. -#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct CursorState { - /// Cell where the cursor should be drawn. - pub coord: CellCoord, - /// Whether the cursor is visible at all. - pub visible: bool, -} - -/// Instance-level signal that is not a render message. -#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum InstanceSignal { - /// Terminal bell. - Bell, - /// Window-title change request. - Title(String), - /// Clipboard set request (OSC 52). - Clipboard(Vec), -} - -/// Reason an instance terminates an attachment. -/// -/// Only the four variants the v0.1 daemon actually emits or rejects on. -/// `Evicted` (multi-frontend takeover) and similar will land alongside -/// the v0.3 multi-frontend work; until then `AlreadyAttached` covers -/// the single-slot equivalent. -#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum GoodbyeReason { - /// Instance is shutting down (SIGTERM / SIGINT or clean exit). - ShuttingDown, - /// Frontend's `protocol_version` does not match the instance's. - /// The handshake fails before any further messages. - VersionMismatch { - /// The instance's `PROTOCOL_VERSION`. - server: u32, - /// The version the frontend announced in its `AttachRequest`. - client: u32, - }, - /// Another frontend is currently attached. v0.1 rejects concurrent - /// attaches; v0.3 will replace this with eviction or multiplexing. - AlreadyAttached, - /// Frontend sent a malformed message or otherwise violated the - /// protocol. The connection is closed without further dialogue. - ProtocolError, - /// T M10.7: frontend declared one or more negotiated capability - /// bits that the instance cannot honor. The handshake fails after - /// the version check but before any further messages. - /// - /// `missing` lists the capability *field names* (e.g., - /// `"multi_frontend"`, `"crdt_replica"`) the frontend requested - /// (`true`) that the instance reports as `false`. These strings - /// are stable wire-format identifiers: they are exactly the - /// `FrontendCapabilities` / `InstanceCapabilities` field names, - /// not human-readable descriptions. The frontend translates them - /// for display via [`AttachError`]'s formatting. Renaming a - /// capability bit requires changing both the field name AND the - /// missing-string emission in `negotiate_capabilities` in - /// lockstep — see the M10.7 audit's wire-format-stability - /// section. - CapabilityMismatch { - /// The capability bit names the frontend asked for that the - /// instance does not support. Each entry is a verbatim - /// `FrontendCapabilities` field name. - missing: Vec, - }, -} - -/// Rendering and signals from instance to frontend. -#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum InstanceMessage { - /// Cell deltas. `full_grid = true` is the initial sync sent on - /// fresh attach (or after a resize where the previous grid is no - /// longer applicable); `full_grid = false` is a differential - /// frame. - CellDelta { - /// One run of changed cells per `DiffSpan`. - spans: Vec, - /// Whether `spans` represents a full-grid resync (true on - /// fresh attach or post-resize) versus an incremental frame. - full_grid: bool, - }, - /// Cursor position and visibility update. - Cursor(Option), - /// Modeline cells. Reserved for v0.3 GUI use; v0.1 ships modeline - /// inside [`InstanceMessage::CellDelta`]. The variant exists in - /// the protocol from day one so adding the discrete channel later - /// is not a breaking change. - ModeLine(Vec), - /// Side-channel signal (bell, title, clipboard). - Signal(InstanceSignal), - /// Instance is terminating the attachment. - Goodbye(GoodbyeReason), - /// T M10.5: CRDT operation broadcast from the instance to all - /// attached frontends. The originating frontend produced this op - /// (via `FrontendEvent::CrdtOp` or via a local editor-core edit - /// that synthesizes one); the instance fans it out so every - /// attached frontend can apply the op to its local CRDT state. - /// - /// Only sent to v1.0 frontends — sessions negotiated at - /// `protocol_version = 1` never receive this variant, per - /// `§sec:m10-backward-compat`. The daemon filters at the - /// outgoing-message path; this variant simply existing in the - /// enum is not a wire-compat issue for v1 sessions because the - /// daemon never emits it to them. - /// - /// M10.5 declares the wire shape. M10.8 wires the editor-core → - /// daemon → frontend flow that actually emits these. - CrdtOp { - /// Which buffer this op affects. v1.0 frontends maintain - /// a per-buffer local CRDT state; this routes to the right - /// one. - buffer_id: crate::buffer::BufferId, - /// The CRDT operation payload — `peer_id` + opaque wire bytes - /// loro's `import_updates` decodes. - op: crate::rope::CrdtOp, - }, - /// T M10.6: cursor + selection state of one attached frontend, - /// broadcast to the other v1.0 frontends so they can render - /// peer-presence overlays. Coalesced at the daemon: rapid cursor - /// movement produces one `PresenceUpdate` per tick per source - /// frontend, carrying the *final* state, not intermediate values. - /// - /// Sender exclusion: the source frontend never receives its own - /// `PresenceUpdate`. v0.1 sessions (negotiated `protocol_version = - /// 1`) are filtered out at the daemon's outgoing-message path. - /// - /// M10.6 declares the wire shape AND wires the daemon-side - /// sweep with per-session filter. In single-frontend deployments - /// the recipient list is structurally empty (sender exclusion - /// with no other v2 sessions); M10.8 enables the multi-frontend - /// case where this message actually crosses the wire. The - /// frontend's renderer for peer-cursor overlays is also M10.8. - PresenceUpdate { - /// Which attached frontend this presence belongs to. v1.0 - /// frontends use this to label the peer-cursor overlay - /// ("user 4 is editing here"). - frontend_id: FrontendId, - /// Which buffer the source frontend's cursor is in. - buffer_id: crate::buffer::BufferId, - /// Byte offset of the source frontend's cursor within - /// `buffer_id`. Frontends convert to line/column at render - /// time via the rope's coord-mapping; the wire carries the - /// canonical byte offset to avoid encoding-vs-rendering - /// drift across frontends. - cursor: crate::rope::Position, - /// Active selection range, if any. - selection: Option, - }, - /// T M10.10: bootstrap a frontend's local CRDT replica with the - /// instance's current authoritative state. Sent once per active - /// buffer at `SessionEstablished` time (and on subsequent - /// buffer-creation events) to frontends that negotiated - /// `crdt_replica: true`. Frontends that didn't negotiate the - /// capability never receive this variant — the daemon's - /// outgoing-message filter gates the send on - /// `NegotiatedCapabilities::crdt_replica`. - /// - /// `crdt_snapshot` carries loro's run-encoded snapshot - /// (`CrdtState::export_snapshot()`) — the CRDT-internal state - /// including peer IDs, version vectors, and op-history structure. - /// Raw byte contents are insufficient because a fresh CRDT replica - /// initialized from bytes alone diverges on the first concurrent - /// edit. - /// - /// Cursor position is intentionally absent: cursor is per-frontend - /// window state (M10.8 `FrontendView`), not per-buffer CRDT - /// state. The same buffer can appear in multiple windows on one - /// frontend with different cursors; coupling cursor to - /// `BufferSnapshot` would break this model. - BufferSnapshot { - /// Which buffer's CRDT state this snapshot represents. - buffer_id: crate::buffer::BufferId, - /// `loro::LoroDoc::export(ExportMode::Snapshot)` output. Applied - /// to a fresh `CrdtState::new(peer_id_from_frontend(my_id))` - /// via `import_snapshot(bytes)` on the receiving frontend. - crdt_snapshot: Vec, - }, - /// T M10.10: the active buffer for a replica frontend, with the - /// cursor position within it. - /// - /// # Semantics (Day 3 step 3b composition-check broadened - /// contract) - /// - /// `CursorByte` represents "the active buffer for this frontend - /// is `buffer_id`; the cursor in that buffer is at `byte_pos`." - /// Not just "the cursor moved." This contract matters: a narrow - /// "cursor moved" emission would miss active-buffer-changed- - /// without-cursor-motion events (Lua-driven buffer switch - /// landing at the same byte position), and the frontend's - /// active-buffer tracking would go stale. - /// - /// Daemon emits `CursorByte` on every per-tick render frame for - /// replica frontends, derived fresh from `active_window_for(fid)`. - /// Cursor move, active-buffer change, and active-window change - /// all produce a new emission carrying the current `(buffer_id, - /// byte_pos)`. The per-tick rate (16ms at 60Hz) is the same as - /// `Cursor`'s grid-coord variant. - /// - /// # Why a separate variant from `Cursor` - /// - /// `Cursor` carries grid coordinates (row/col cells) — the - /// frontend uses them to paint the cursor. The optimistic-apply - /// path needs byte position (CRDT insert/delete is byte-indexed), - /// which the grid coordinate can't recover without duplicating - /// the daemon's view-layout logic (tab expansion, line wrap, - /// double-width chars, viewport offset). `CursorByte` is the - /// authoritative byte position for the active buffer. - /// - /// # Atomicity with `Cursor` - /// - /// The daemon emits `Cursor` and `CursorByte` together for - /// replica frontends — both derived from the same render-frame - /// iteration so they describe the cursor in the same instant in - /// two reference frames. Non-replica frontends receive only - /// `Cursor` (existing behavior). The replica frontend that sees - /// `Cursor` without a paired `CursorByte` would interpret stale - /// byte position; the daemon guarantees both emit together by - /// derivation, not by message-protocol atomicity. - /// - /// # Wire-format compatibility - /// - /// New variant in v2; receivers without M10.10 hard-error on - /// decode (postcard does not gracefully degrade unknown variants, - /// per M10.10-FRAMING.md Refinement 3). Capability-gated: daemon - /// sends only to frontends that negotiated `crdt_replica: true`. - /// `PROTOCOL_VERSION` stays at 2. - CursorByte { - /// The buffer the cursor is in. A replica frontend tracks - /// per-buffer cursors; this routes the update to the right - /// entry. - buffer_id: crate::buffer::BufferId, - /// Byte offset of the cursor within `buffer_id`. Source of - /// truth for the optimistic-apply path's insert / delete - /// position arguments. Wire type matches - /// `PresenceUpdate::cursor` (`u64`) for consistency; frontend - /// converts to `usize` for the loro API. - byte_pos: crate::rope::Position, - }, - /// T M11.1 — syntax + face styling over the semantic frontend's - /// current viewport range. `generation` ties the spans to a CRDT - /// version so the frontend can discard styling that predates an - /// edit it has already applied optimistically. Ships **no text** — - /// the frontend holds the rope via the `crdt_replica` machinery - /// and interprets these spans over it. - /// - /// # Diff shape (T M11.4) - /// - /// Mirrors `CellDelta`'s `full_grid` + changed-runs structure, - /// lifted from positional cells to byte-anchored ranges. `full = - /// true` is a resync: the frontend discards all prior styling for - /// `buffer_id` and the `segments` are authoritative for the whole - /// declared viewport (first frame after a `Viewport`, a viewport - /// jump, or a generation discontinuity). `full = false` is - /// incremental: each [`StyleSegment`] replaces styling **only** - /// within its `range`; bytes covered by no segment keep their - /// previously-applied style. A frame whose styling is unchanged - /// ships no `StyleSpans` at all. - /// - /// Because byte offsets cascade on edits (an insert shifts every - /// later span), an incremental frame after an edit dirties - /// `[edit, viewport_end)` — still bounded, and no-edit frames - /// (cursor move, scroll within the declared viewport, selection) - /// cost nothing. Each segment carries *all* current spans - /// intersecting its range (clipped), not only changed ones, so an - /// unchanged span overlapping a dirty range is faithfully - /// reconstructed. - /// - /// Gated on negotiated `semantic_render`; never sent to a grid - /// session (the daemon's per-session outgoing filter — wired with - /// the producer in M11.2 — never emits it there, so postcard's - /// hard-error on unknown variants is mooted exactly as it is for - /// `CursorByte`). - StyleSpans { - /// Buffer these spans interpret. - buffer_id: crate::buffer::BufferId, - /// CRDT generation the spans were computed against. - generation: u64, - /// `true` → discard all prior styling for `buffer_id` first; - /// `segments` are authoritative for the declared viewport. - full: bool, - /// Dirty byte regions and the styling now covering them. - segments: Vec, - }, - /// T M11.1 — diagnostics, search hits, current-line, and any - /// other "this region means something" overlay, as offset ranges - /// plus a kind. Peer selection is **not** here — it stays on the - /// existing `PresenceUpdate` path. Gated on `semantic_render`. - /// - /// T M11.4 — same `full` + segment diff shape as `StyleSpans` - /// (see its docs), and gains `generation` for parity: a frontend - /// wants the CRDT version decorations were computed against for - /// the same optimistic-edit race reason styling does. - Decorations { - /// Buffer these decorations apply to. - buffer_id: crate::buffer::BufferId, - /// CRDT generation the decorations were computed against. - generation: u64, - /// `true` → discard all prior decorations for `buffer_id` - /// first; `segments` are authoritative for the viewport. - full: bool, - /// Dirty byte regions and the decorations now covering them. - segments: Vec, - }, - /// T M11.1 — inlay hints, blame, lens, virtual text. Anchored at - /// a single offset with a placement; occupies no document bytes — - /// the frontend interleaves it at layout time. Gated on - /// `semantic_render`. - InlineAdornments { - /// Buffer these adornments annotate. - buffer_id: crate::buffer::BufferId, - /// The adornment items for the declared viewport. - items: Vec, - }, - /// Coarse whole-file styling summary for a minimap / scrollbar - /// overview, resolving design-note Open Q#2. One [`Style`] per - /// source line (the *dominant* style for that line by byte count - /// across the producer's current spans); the frontend maps minimap - /// rows to one or more of these. Unlike [`Self::StyleSpans`], this - /// is **not** viewport-scoped — the minimap shows the whole file. - /// - /// Recomputed when the buffer's CRDT `generation` advances; an - /// unchanged buffer ships no further summary. A coarser - /// representation (fixed bands) or finer (run-length style runs) - /// is recorded in `docs/semantic-frontend-protocol.md` as future - /// refinements; per-line dominant style is the v1 choice because - /// minimap rows naturally correspond to code lines. - /// - /// Gated on negotiated `semantic_render`; the daemon emits it only - /// for sessions that have a [`crate::semantic_render::SemanticRenderState`] - /// (structural gating, same as every other semantic family). - FileStyleSummary { - /// Buffer this summary describes. - buffer_id: crate::buffer::BufferId, - /// CRDT generation the summary was computed against. The - /// frontend can discard a summary that predates an edit it - /// has already applied optimistically. - generation: u64, - /// One [`crate::cell::Style`] per source line, in line order - /// from line 0. Empty when the buffer is empty. - lines: Vec, - }, - /// T M11.1 — diff zones, folded-region placeholders, anything - /// occupying its own vertical band. Anchored to the offset of the - /// line it precedes or replaces; the frontend allocates the - /// vertical space. Gated on `semantic_render`. - BlockAdornments { - /// Buffer these adornments annotate. - buffer_id: crate::buffer::BufferId, - /// The block items for the declared viewport. - items: Vec, - }, - /// T M11.1 — the instance's authoritative fold set as document - /// facts. Folding is an instance command-semantics concern (Lua - /// can fold); the visual collapse is a frontend layout concern — - /// the frontend renders the placeholder and adjusts its own - /// layout. Gated on `semantic_render`. - FoldState { - /// Buffer whose fold set this is. - buffer_id: crate::buffer::BufferId, - /// Folded byte ranges. - folds: Vec, - }, - /// T M11.1 — out-of-band content an adornment refers to (images, - /// blame avatars). Sent once, referenced by `handle`, so it is - /// not re-shipped per frame. Gated on `semantic_render`. - ResourceOffer { - /// Stable handle adornments reference via - /// [`AdornmentContent::Resource`]. - handle: u64, - /// MIME type of `body`. - mime: String, - /// Inline bytes or a URI the frontend resolves itself. - body: ResourceBody, - }, -} - -/// Flat selection state for the wire. -/// -/// Mirrors [`crate::window::Selection`] but as a self-contained pair -/// of byte offsets — `anchor` is where the selection began, -/// `active` is the current selection cursor. Either may be the -/// numerically larger value; callers wanting `(lo, hi)` order -/// compute it locally. -/// -/// Kept flat (no nested types) so [`PartialEq`] equality is exactly -/// wire-representation equality: two `SelectionSnapshot`s compare -/// equal iff they serialize to identical bytes. The presence-diff -/// sweep relies on this — see [`crate::presence::SessionRegistry`]. -#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct SelectionSnapshot { - /// Where the selection began. - pub anchor: crate::rope::Position, - /// The active end (typically the cursor at the moment of the - /// snapshot). - pub active: crate::rope::Position, -} - -// --------------------------------------------------------------------------- -// T M11.1 — Semantic-frontend projection types -// -// The payloads of the `InstanceMessage::StyleSpans` … `ResourceOffer` -// family and `FrontendEvent::Viewport`. Everything is anchored in -// **byte offsets** (consistent with `CursorByte`): line/col is a -// frontend rendering concern, CRDT position is replica-internal. The -// instance never learns a pixel — see the contract boundary in -// `docs/semantic-frontend-protocol.md`. -// -// The variant/kind sets here are provisional and co-evolve within the -// M11 arc behind the `semantic_render` capability + protocol v3, -// exactly as the CRDT op shape evolved M10.5→M10.10 behind -// `crdt_replica`. They are not a wire-compat hazard for non-semantic -// sessions: the daemon's per-session outgoing filter (wired with the -// producer in M11.2) never emits the family to a session that didn't -// negotiate `semantic_render`, so postcard's hard-error on unknown -// variants is mooted exactly as it is for `CursorByte`. -// --------------------------------------------------------------------------- - -// `ByteRange` moved to `pmacs-protocol` (see the re-export near the -// top of this file). - -/// One run of buffer bytes carrying a resolved visual style. The -/// instance is the single syntax/face authority; the frontend lays -/// the style out locally over rope text it already holds. Reuses -/// [`crate::cell::Style`] so the grid and semantic projections share -/// one style vocabulary. -#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct StyleSpan { - /// Byte range this style covers. - pub range: ByteRange, - /// The resolved style (syntax highlight ∘ faces ∘ theme). - pub style: crate::cell::Style, -} - -/// T M11.4 — one dirty byte region of an `InstanceMessage::StyleSpans` -/// frame and the styling now covering it. The semantic analog of a -/// `CellDelta` changed-run: the frontend clears styling within -/// `range` and applies `spans` (already clipped to `range`). `spans` -/// is every current span intersecting `range`, not only changed ones, -/// so an unchanged span overlapping the dirty region is preserved. -#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct StyleSegment { - /// The byte region the frontend should clear and repaint. - pub range: ByteRange, - /// Spans intersecting `range`, each clipped to it. - pub spans: Vec, -} - -/// What a [`Decoration`] region *means*. Provisional variant set (see -/// the module-section note above). Peer selection is deliberately -/// absent — it stays on the `PresenceUpdate` path. -#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum DecorationKind { - /// LSP diagnostic, error severity. - DiagnosticError, - /// LSP diagnostic, warning severity. - DiagnosticWarning, - /// LSP diagnostic, information severity. - DiagnosticInfo, - /// LSP diagnostic, hint severity. - DiagnosticHint, - /// The local selection region. - Selection, - /// A non-active search match. - SearchMatch, - /// The currently-focused search match. - SearchMatchActive, - /// The line containing the cursor. - CurrentLine, -} - -/// A byte range tagged with what it means. The frontend decides how -/// to paint each [`DecorationKind`] (squiggle, highlight, gutter -/// mark) — the instance only states the fact. -#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct Decoration { - /// Byte range the decoration covers. - pub range: ByteRange, - /// What the region signifies. - pub kind: DecorationKind, -} - -/// T M11.4 — `StyleSegment`'s analog for the `Decorations` family: -/// one dirty byte region and the decorations now covering it (every -/// current decoration intersecting `range`, clipped to it). -#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct DecorationSegment { - /// The byte region the frontend should clear and repaint. - pub range: ByteRange, - /// Decorations intersecting `range`, each clipped to it. - pub decorations: Vec, -} - -/// Where an [`InlineAdornment`] sits relative to its anchor offset. -#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum AdornmentPlacement { - /// On its own, before the line containing `at`. - BeforeLine, - /// At the end of the line containing `at`. - EndOfLine, - /// Inline, exactly at the byte offset `at`. - AtOffset, -} - -/// Adornment payload: either inline styled text, or a handle into a -/// previously-sent [`InstanceMessage::ResourceOffer`] so out-of-band -/// content (images, blame avatars) is shipped once, not per frame. -#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum AdornmentContent { - /// Styled virtual text. - Text { - /// The virtual text to display. - text: String, - /// Its style. - style: crate::cell::Style, - }, - /// A handle into a `ResourceOffer`. - Resource { - /// The offered resource's handle. - handle: u64, - }, -} - -/// Virtual text occupying no document bytes (inlay hints, blame, -/// lens). Anchored at a single offset; the frontend interleaves it -/// at layout time. -#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct InlineAdornment { - /// Buffer byte offset this adornment anchors to. - pub at: u64, - /// Placement relative to `at`. - pub placement: AdornmentPlacement, - /// What to render. - pub content: AdornmentContent, -} - -/// Content occupying its own vertical band (diff zones, folded-region -/// placeholders). Anchored to the offset of the line it precedes or -/// replaces. `replaces` is `Some` when the band stands in for a -/// collapsed region (the frontend renders the placeholder instead of -/// that range), `None` for an additive band. The frontend allocates -/// the vertical space — the instance never dictates pixel height. -#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct BlockAdornment { - /// Buffer byte offset of the line this band precedes/replaces. - pub at: u64, - /// The byte range this band stands in for, if it replaces one. - pub replaces: Option, - /// What to render in the band. - pub content: AdornmentContent, -} - -/// The body of an [`InstanceMessage::ResourceOffer`] — inline bytes -/// for small payloads, or a URI the frontend resolves itself for -/// large or remote resources. -#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum ResourceBody { - /// The resource bytes, carried inline. - Inline(Vec), - /// A URI the frontend fetches/resolves on its own. - Uri(String), -} +// Session 1 of the `pmacs-gpu` arc moved every wire type out of this +// module into the `pmacs-protocol` crate (`docs/pmacs-gpu-design.md`). +// What stays below are the CLI / binding internals (`AttachTarget` and +// friends, `AttachmentHandle`, the `crossterm_translate` submodule) +// plus the existing wire-format roundtrip tests. The blanket re-export +// keeps every `crate::protocol::*` import path working unchanged; new +// consumers (`pmacs-gpu`, debug tools) should depend on `pmacs-protocol` +// directly. +pub use pmacs_protocol::*; // --------------------------------------------------------------------------- // Attachment @@ -1424,463 +554,6 @@ impl AttachmentHandle { } } -// --------------------------------------------------------------------------- -// Handshake — version, identity, capabilities -// --------------------------------------------------------------------------- - -/// Wire-protocol version. Bumped on any breaking change to the -/// `Hello` / `AttachRequest` / event-message shapes. -/// -/// The handshake compares against [`SUPPORTED_PROTOCOL_VERSIONS`]; -/// mismatches close the connection with -/// [`GoodbyeReason::VersionMismatch`]. v1.0 servers and clients accept -/// either the v0.1 wire (version 1) or the v1.0 wire (version 2) per -/// `§sec:m10-backward-compat` — both directions of the version -/// asymmetry need symmetric relaxation so v0.1-era binaries connect -/// to v1.0-era binaries (and vice versa) once both have shipped. -/// -/// T M10.5: bumped from 1 to 2. The v0.1 wire (version 1) remains -/// accepted by v1.0 binaries; CRDT-only message variants -/// (`InstanceMessage::CrdtOp`, `FrontendEvent::CrdtOp`) are filtered -/// per-session for v1 negotiated sessions. -/// -/// T M11.1: bumped from 2 to 3. The v1.0 wire (version 2) remains -/// accepted; the semantic-frontend variant family -/// (`InstanceMessage::StyleSpans` … `ResourceOffer`, -/// `FrontendEvent::Viewport`) is filtered per-session for sessions -/// that did not negotiate `semantic_render`. Mechanically identical -/// to the M10.5 bump: the slice-membership handshake check (not -/// strict equality) means v0.1/v1.0 binaries keep connecting -/// unchanged, and the new variants simply existing in the enums is -/// not a wire-compat issue for non-semantic sessions because the -/// daemon never emits them to those sessions. -pub const PROTOCOL_VERSION: u32 = 3; - -/// T M10.5: the set of protocol versions a v1.0 binary accepts on -/// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept -/// `[1, 2]` so the version asymmetry the §sec:m10-backward-compat -/// spec section describes is handled symmetrically on both sides. -/// -/// T M11.1: extended to `[1, 2, 3]`. v1.1 binaries accept the v0.1 -/// (1), v1.0 (2), and semantic-frontend (3) wires. The check remains -/// slice membership — "is the peer's `protocol_version` present in -/// this slice?" — not strict equality on `PROTOCOL_VERSION`. The -/// session's negotiated version (the peer's) is recorded for -/// downstream filtering: v1 sessions don't receive -/// `InstanceMessage::CrdtOp` / `PresenceUpdate` messages even from -/// a v3 daemon, and only sessions that negotiated `semantic_render` -/// receive the `SemanticFrame` variant family. -pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[1, 2, 3]; - -/// T M10.5: predicate for the handshake check. Returns `true` if -/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`]. -#[must_use] -pub fn is_supported_protocol_version(peer_version: u32) -> bool { - SUPPORTED_PROTOCOL_VERSIONS.contains(&peer_version) -} - -/// Identifies an instance for client-side display. -/// -/// Sent inside [`Hello`] from instance to frontend. Use of `uptime_secs` -/// instead of an absolute start time is deliberate: instance and -/// frontend may run on machines whose clocks disagree, so the frontend -/// computes "instance has been running N seconds" using only the -/// instance's view of time. -#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct InstanceIdentity { - /// Pmacs version string (`env!("CARGO_PKG_VERSION")`). - pub pmacs_version: String, - /// Short git hash if the build embedded one. `None` for releases or - /// source-tarball builds where no git checkout was available. - pub build_hash: Option, - /// The name the instance was launched under (`--socket NAME`). - /// `None` for the default daemon (no `--socket` argument). - pub instance_name: Option, - /// Seconds since the instance started, from the instance's clock. - /// Frontend displays "running 47m" by interpreting this against - /// its own notion of "now," avoiding cross-machine clock skew. - pub uptime_secs: u64, - /// Working directory the instance is running in. Encoded as a - /// UTF-8 string; non-UTF-8 paths are rejected at the boundary. - pub working_directory: String, -} - -impl InstanceIdentity { - /// Build an identity for the running pmacs process. - /// - /// `instance_name` is the user-facing name (typically the - /// `--socket NAME` value for the daemon path; `None` for the - /// in-process Local mode and the unnamed default daemon). - /// `started` is the wall-clock anchor used to compute - /// [`Self::uptime_secs`]; the elapsed seconds are evaluated at the - /// call site, so calling twice on different days surfaces different - /// uptimes from the same anchor. - /// - /// The version comes from `CARGO_PKG_VERSION` and the build hash - /// from the optional `PMACS_GIT_HASH` environment variable populated - /// by the build script. - #[must_use] - pub fn for_running_process(instance_name: Option, started: std::time::Instant) -> Self { - Self { - pmacs_version: env!("CARGO_PKG_VERSION").into(), - build_hash: option_env!("PMACS_GIT_HASH").map(String::from), - instance_name, - uptime_secs: started.elapsed().as_secs(), - working_directory: std::env::current_dir() - .ok() - .map(|p| p.to_string_lossy().into_owned()) - .unwrap_or_default(), - } - } -} - -/// Capabilities the instance advertises to attaching frontends. -/// -/// Empty for v0.1; the type exists so that adding capabilities in v0.2+ -/// is not a breaking-change. Symmetric with [`FrontendCapabilities`]. -/// -/// T M10.5: added `multi_frontend` and `crdt_replica` bits with -/// `#[serde(default)]` so v1 wire bytes still deserialize. The -/// negotiation logic (which side advertises what, and what the -/// instance does with mismatches) is M10.7 scope; M10.5 just makes -/// the bit positions stable in the wire format. -/// -/// T M10.5/8: bit defaults evolve with the substrate. -/// -/// - M10.5 declared the bits with `#[serde(default)]` so v1 wire -/// bytes deserialize forward-compatibly. M10.5–M10.7 set both bits -/// to `false` so a frontend declaring `multi_frontend: true` got -/// `Goodbye(CapabilityMismatch)` — the multi-frontend path -/// wasn't actually wired yet. -/// - **T M10.8 Day 4 flip**: the instance's `multi_frontend` and -/// `crdt_replica` defaults flip to `true`. This is the "M10.8 enables -/// multi-frontend" moment — the underlying dispatcher (Day 3) and -/// broadcast routing (Day 4) support both capabilities, so the -/// instance advertises them. -/// -/// The frontend-side defaults remain `false` (a frontend that omits -/// the field is conservatively treated as not supporting the -/// capability; matches v0.1 wire-format semantics). -#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct InstanceCapabilities { - /// T M10.5: instance can host multi-frontend sessions on the - /// same buffer (per `§sec:m10-collab`). T M10.8 Day 4: default - /// flipped to `true` — the dispatcher supports multiple - /// attached frontends. - #[serde(default = "default_true")] - pub multi_frontend: bool, - /// T M10.5: instance can broadcast `InstanceMessage::CrdtOp` - /// messages. T M10.8 Day 4: default flipped to `true` — the - /// broadcast routing for CRDT ops wires up in this milestone. - #[serde(default = "default_true")] - pub crdt_replica: bool, - /// T M11.1: instance can produce the semantic-frontend variant - /// family (`InstanceMessage::StyleSpans` … `ResourceOffer`) and - /// consume `FrontendEvent::Viewport`. - /// - /// T M11.1 declared the bit position + negotiation mechanics with - /// the default `false` (no producer yet). T M11.2 landed the - /// instance-side projection seam (`SemanticRenderState`) and - /// flipped the default to `cfg!(feature = "crdt")`: the instance - /// now advertises `semantic_render` on CRDT builds. It tracks the - /// `crdt` feature rather than being unconditional because the - /// negotiation dependency rule makes a semantic session - /// necessarily a text replica — a non-CRDT build can host - /// neither. See [`Default`] impl below. - #[serde(default)] - pub semantic_render: bool, -} - -// Clippy in non-CRDT builds notes that `cfg!(feature = "crdt")` -// evaluates to `false`, making this impl derivable. In CRDT builds -// the values are `true`, so the impl is genuinely manual. Allow. -#[allow(clippy::derivable_impls)] -impl Default for InstanceCapabilities { - fn default() -> Self { - // T M10.10 — the `crdt_replica` default tracks the `crdt` - // Cargo feature. A daemon built without the `crdt` feature - // can't honor a `crdt_replica: true` negotiation (the - // CRDT-handling code paths are conditionally compiled out - // — `send_buffer_snapshots`, `apply_remote_crdt_op`, the - // dispatcher's CursorByte emit). Advertising `true` - // unconditionally would be wire-protocol false advertising. - // - // `multi_frontend` is conceptually independent of CRDT but - // in M10.10's architecture every multi-frontend participant - // is also a CRDT replica; gating both on the same feature - // keeps the daemon's advertised capabilities consistent - // with what it can actually do. - // - // T M11.1 declared `semantic_render` defaulting to `false` - // unconditionally — no projection-seam producer existed, so - // advertising it would have been wire-protocol false - // advertising (the M10.5→M10.7 "bits false until the path is - // wired" discipline). - // - // T M11.2 — **the flip**: the instance-side projection seam - // (`SemanticRenderState`, the producer) has landed and the - // dispatcher selects it per session, so the instance now - // advertises `semantic_render`. It tracks `cfg!(feature = - // "crdt")` like `crdt_replica` because the negotiation - // dependency rule makes a semantic session necessarily a - // text replica; a non-CRDT build can host neither. This is - // the "M11.2 enables semantic" moment, exactly analogous to - // the M10.8 Day-4 multi_frontend/crdt_replica flip. - Self { - multi_frontend: cfg!(feature = "crdt"), - crdt_replica: cfg!(feature = "crdt"), - semantic_render: cfg!(feature = "crdt"), - } - } -} - -#[allow(clippy::missing_const_for_fn)] -fn default_true() -> bool { - true -} - -/// Capabilities the frontend advertises to the instance. -/// -/// All bools default to `false` so a frontend that omits a field via an -/// older `AttachRequest` is conservatively treated as not supporting -/// the capability. New capabilities added in v0.2+ get -/// `#[serde(default)]` so old wire bytes still deserialize. -// A capability set is exactly the case `struct_excessive_bools` warns -// against — but each flag is independent and the alternative (an enum -// or bitset) loses the per-field `#[serde(default)]` semantics that -// make schema evolution work. -#[allow(clippy::struct_excessive_bools)] -#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct FrontendCapabilities { - /// Frontend understands DEC 2026 `BeginSynchronizedUpdate` / - /// `EndSynchronizedUpdate` markers. Instance strips them when false. - #[serde(default)] - pub synchronized_output: bool, - /// Frontend can render Unicode beyond the Basic Multilingual Plane. - /// Instance can substitute a fallback glyph when false. - #[serde(default)] - pub unicode_smp: bool, - /// Frontend supports 24-bit color (truecolor SGR sequences). - /// Instance maps to the 256-color palette when false. - #[serde(default)] - pub true_color: bool, - /// Frontend captures and forwards mouse events. - #[serde(default)] - pub mouse: bool, - /// Frontend supports bracketed paste — distinguishes pasted bytes - /// from typed bytes. Instance treats all input as keystrokes when false. - #[serde(default)] - pub bracketed_paste: bool, - /// Optional human-readable terminal identifier for logs and - /// debugging only. The instance does not branch on this value; - /// branching is done on the explicit capability bits above. - #[serde(default)] - pub terminal_kind: Option, - /// T M10.5: frontend can participate in multi-frontend sessions - /// (per `§sec:m10-collab`). false for v0.1 frontends — they - /// attach as single-frontend and never receive `CrdtOp` / - /// `PresenceUpdate` broadcasts. v1.0 frontends opt in via M10.7's - /// negotiation handshake. M10.5 declares the bit position; M10.7 - /// wires the negotiation. - /// - /// Default is `false` — v1 frontends are treated as not - /// supporting this feature, which matches reality (v1 frontends - /// have no local CRDT state). A `true` default would have v1 - /// frontends claimed to support features they don't. - #[serde(default)] - pub multi_frontend: bool, - /// T M10.5: frontend can apply incoming `CrdtOp` messages to a - /// local CRDT state. false for v0.1; v1.0 opts in. M10.7 wires - /// negotiation; M10.5 declares the bit position. - #[serde(default)] - pub crdt_replica: bool, - /// T M11.1: frontend is a semantic (layout-local) renderer — it - /// consumes the `InstanceMessage::StyleSpans` … `ResourceOffer` - /// family and emits `FrontendEvent::Viewport`. false for v0.1 and - /// v1.0 grid/TUI frontends; a future GPU/GUI frontend opts in. - /// - /// A semantic frontend is *required* to also be a text replica: - /// the semantic frame ships no text, so the frontend must hold - /// the rope locally via the `crdt_replica` machinery. This - /// dependency is enforced in [`negotiate_capabilities`], not just - /// documented — declaring `semantic_render: true` without - /// `crdt_replica: true` is a capability mismatch, never a silent - /// degrade. - #[serde(default)] - pub semantic_render: bool, -} - -/// T M10.7 — the negotiated capability bits for one attached session. -/// -/// Computed by [`negotiate_capabilities`] from the frontend's -/// [`FrontendCapabilities`] and the instance's [`InstanceCapabilities`]. -/// Each negotiated bit is the AND of the two declared bits. Fields -/// added here in future milestones append at the end with sensible -/// defaults so existing call sites stay valid. -/// -/// This is a daemon-internal struct (not on the wire); the -/// negotiation result is communicated to the frontend via the -/// success of the handshake (no capability-mismatch `Goodbye`) and -/// the instance's behavior thereafter. -#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] -pub struct NegotiatedCapabilities { - /// Session is eligible for multi-frontend operation. True iff - /// both the frontend and the instance declared `multi_frontend = - /// true`. v0.1 frontends always end up here as `false` (the v0.1 - /// wire format does not carry the field; `#[serde(default)]` - /// makes the deserialized value `false`). - pub multi_frontend: bool, - /// Session can produce/consume `InstanceMessage::CrdtOp` / - /// `FrontendEvent::CrdtOp`. True iff both sides declared - /// `crdt_replica = true`. The daemon's outgoing-message filter for - /// `CrdtOp` consults this in M10.8. - pub crdt_replica: bool, - /// T M11.1 — session uses the semantic projection: it - /// produces/consumes the `InstanceMessage::StyleSpans` … - /// `ResourceOffer` family and `FrontendEvent::Viewport`. True iff - /// both sides declared `semantic_render = true` *and* the session - /// also negotiated `crdt_replica = true` (a semantic session is a - /// text replica; see [`negotiate_capabilities`]). The daemon's - /// per-session outgoing filter gates the entire semantic family - /// on this bit — wired with the producer in M11.2. - pub semantic_render: bool, -} - -/// T M10.7 — pure-function capability negotiation. -/// -/// For each negotiated bit (`multi_frontend`, `crdt_replica`, -/// `semantic_render`): -/// -/// | Frontend wants | Instance has | Result | -/// |----------------|--------------|--------| -/// | `false` | `false` | bit `false`, no error | -/// | `false` | `true` | bit `false`, no error | -/// | `true` | `true` | bit `true`, no error | -/// | `true` | `false` | bit appears in `missing` | -/// -/// If any bit ends up in `missing`, the negotiation fails as a whole -/// (returns `Err`). Otherwise the negotiated bits are returned as -/// [`NegotiatedCapabilities`]. The `Err` form gathers ALL missing -/// bits into one `CapabilityMismatch` — one round-trip carries the -/// complete picture rather than serial rejections. Missing bits are -/// ordered `multi_frontend`, `crdt_replica`, `semantic_render` for -/// deterministic wire output. -/// -/// # T M11.1 — the `semantic_render ⇒ crdt_replica` dependency -/// -/// A semantic-render session ships no text on the semantic frame; -/// the frontend holds the rope locally via the `crdt_replica` -/// machinery (`BufferSnapshot` to bootstrap, `CrdtOp` to stay live). -/// So `semantic_render` is only coherent on a session that also -/// negotiated `crdt_replica`. When the AND-rule would yield -/// `semantic_render = true` but the session did not also negotiate -/// `crdt_replica = true`, this function rejects with -/// `"semantic_render"` in `missing` rather than silently degrading -/// the session to a text-only replica. The rejected identifier is -/// `"semantic_render"` (the capability whose precondition is unmet), -/// not `"crdt_replica"`. -/// -/// # Wire-format stability -/// -/// The strings emitted into `missing` are exactly the -/// `FrontendCapabilities` field names (`"multi_frontend"`, -/// `"crdt_replica"`). These are stable wire-format identifiers, not -/// human-readable descriptions. User-facing translation is the -/// frontend's responsibility (see [`AttachError`]'s `Display` impl). -/// Renaming a capability bit requires updating both the field name -/// and the missing-string emission here in lockstep. -pub fn negotiate_capabilities( - frontend: &FrontendCapabilities, - instance: &InstanceCapabilities, -) -> Result { - let mut missing = Vec::new(); - let multi_frontend = match (frontend.multi_frontend, instance.multi_frontend) { - (true, false) => { - missing.push("multi_frontend".to_string()); - false - } - (a, b) => a && b, - }; - let crdt_replica = match (frontend.crdt_replica, instance.crdt_replica) { - (true, false) => { - missing.push("crdt_replica".to_string()); - false - } - (a, b) => a && b, - }; - let semantic_render = match (frontend.semantic_render, instance.semantic_render) { - (true, false) => { - missing.push("semantic_render".to_string()); - false - } - (a, b) => a && b, - }; - // T M11.1 — dependency rule. A semantic session is a text replica - // (the semantic frame carries no text). If both sides declared - // `semantic_render` but the session did not also negotiate - // `crdt_replica`, reject rather than silently degrade. Guard - // against a duplicate push: the only path where `semantic_render` - // is already in `missing` is the `(true, false)` arm above, which - // also sets the local `semantic_render` to false, so the - // condition below cannot re-fire for that case — but the explicit - // membership check keeps this robust against future reordering. - if semantic_render && !crdt_replica && !missing.iter().any(|m| m == "semantic_render") { - missing.push("semantic_render".to_string()); - } - let semantic_render = semantic_render && crdt_replica; - if missing.is_empty() { - Ok(NegotiatedCapabilities { - multi_frontend, - crdt_replica, - semantic_render, - }) - } else { - Err(GoodbyeReason::CapabilityMismatch { missing }) - } -} - -/// First message sent by the instance to a freshly-attached frontend. -/// -/// Sent immediately after the connection is accepted, before reading -/// the frontend's [`AttachRequest`]. The frontend uses -/// `instance_identity` for status display and `protocol_version` / -/// `instance_capabilities` for compatibility decisions. -/// -/// The instance also stamps the `assigned_frontend_id` which the -/// frontend will use as the `FrontendId` on every event it sends. -#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct Hello { - /// The instance's `PROTOCOL_VERSION`. - pub protocol_version: u32, - /// `FrontendId` assigned to this attachment by the instance. The - /// frontend stamps this onto subsequent events. v0.1 daemons start - /// allocation at `FrontendId(2)` (1 reserved for the in-process TUI). - pub assigned_frontend_id: FrontendId, - /// Instance self-identification (version, name, uptime, cwd). - pub instance_identity: InstanceIdentity, - /// Instance capabilities. Empty for v0.1. - pub instance_capabilities: InstanceCapabilities, -} - -/// First message sent by a frontend after receiving [`Hello`]. -/// -/// Carries the frontend's view of the protocol version, the -/// capabilities it can support, and its initial terminal size. On -/// version mismatch the instance closes with -/// [`GoodbyeReason::VersionMismatch`] and no further messages flow. -#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct AttachRequest { - /// The frontend's `PROTOCOL_VERSION`. - pub protocol_version: u32, - /// Frontend capabilities. Defaults to all-false if omitted. - #[serde(default)] - pub frontend_capabilities: FrontendCapabilities, - /// The frontend's terminal size at attach time. Authoritative - /// until the frontend sends a [`FrontendEvent::Resize`]. The - /// instance uses this for the initial full-grid render. - pub initial_size: CellSize, -} - // --------------------------------------------------------------------------- // Crossterm translation (the only crossterm seam in this module) // ---------------------------------------------------------------------------