Skip to main content

mnml_bridge/
lib.rs

1//! # mnml-bridge — Mount protocol for mnml sibling tools
2//!
3//! Bridge / Mount is the integration layer that lets sibling tools
4//! (`mnml-db-postgres`, `mnml-forge-bitbucket`, …) render their UI as a
5//! first-class pane inside mnml — owning the activity-bar icon, the
6//! rail content, and the editor body — instead of running as a
7//! plain `Pty` pane.
8//!
9//! ## The four tiers
10//!
11//! 1. **Env vars** — every Pty mnml spawns sees `MNML_WORKSPACE`,
12//!    `MNML_THEME`, and `MNML_IPC_DIR`. Zero protocol; just read on
13//!    startup. (Available today for any sibling.)
14//! 2. **JSONL sibling → host** — sibling writes JSONL commands to
15//!    `$MNML_IPC_DIR/command`; mnml ingests them. `toast`,
16//!    `open-pty`, `open` (file), more coming. One-way.
17//! 3. **mnml-bridge SDK** — this crate. Typed Rust API around tiers
18//!    1 + 2, plus the Mount protocol below.
19//! 4. **Mount** — sibling connects to a Unix-socket-per-mount,
20//!    streams cell+style frames back, receives input events. Owns
21//!    rail + body areas of an activity-bar section.
22//!
23//! ## Wire shape
24//!
25//! Length-prefixed JSON. Every message is a `Frame` or `Input`. The
26//! 4-byte little-endian length precedes the JSON body so framing is
27//! trivial (no streaming JSON parser needed).
28//!
29//! Host → Sibling:
30//!   - `MountHello { cols, rows }` first
31//!   - `Resize { cols, rows }` on terminal resize
32//!   - `Input { event }` on every routed key / mouse event
33//!
34//! Sibling → Host:
35//!   - `Frame { cells: Vec<Vec<Cell>> }` whenever the sibling has a
36//!     new screen state. Cell-perfect; the host stamps these into
37//!     its own ratatui frame.
38//!
39//! V1 keeps it simple: full frames, no diffing. A ~24x80 panel is
40//! ~2 KB of JSON; serialization cost is negligible vs ratatui's
41//! own draw cycle.
42
43use serde::{Deserialize, Serialize};
44
45#[cfg(feature = "client")]
46pub mod client;
47
48#[cfg(feature = "client")]
49pub use client::Mount;
50
51pub mod install;
52pub mod ipc;
53pub use install::{
54    ChipSpec, CommandSpec, ContextMenuEntry, IntegrationSpec, MenuBarEntry, NotificationsSpec,
55    OsNotifyPolicy, Requires, SettingsPage, StatuslineSpec, install_integration,
56    integration_manifest_path, list_installed_integrations, sibling_glyphs_dir,
57    uninstall_integration,
58};
59pub use ipc::{
60    NotifyOpts, ProgressStatus, SegmentSide, ToastLevel, notify, progress_end, progress_start,
61    progress_update, register_command, set_activity_badge, statusline_clear_segment,
62    statusline_set_segment, toast, toast_dismiss, toast_error, toast_info, toast_persistent,
63    toast_warn,
64};
65
66/// A single terminal cell — one grapheme + style. Mirrors
67/// ratatui's `buffer::Cell` shape but with serde derived.
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69pub struct Cell {
70    /// The grapheme cluster painted in this cell. Multi-codepoint
71    /// (e.g. flag emoji) is fine; mnml stamps the whole thing into
72    /// a single buffer cell.
73    pub symbol: String,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub fg: Option<RgbOrIndex>,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub bg: Option<RgbOrIndex>,
78    /// Bitfield of [`Modifier`] flags. Stored as u16 for compact wire shape.
79    #[serde(default, skip_serializing_if = "is_zero_u16")]
80    pub modifiers: u16,
81}
82
83fn is_zero_u16(v: &u16) -> bool {
84    *v == 0
85}
86
87/// Either a true-color RGB triple or a 256-color palette index.
88#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
89#[serde(untagged)]
90pub enum RgbOrIndex {
91    /// `[r, g, b]` 24-bit color.
92    Rgb([u8; 3]),
93    /// 0-255 palette index (terminal default semantics: 0-7 ANSI,
94    /// 8-15 bright, 16-231 6×6×6 cube, 232-255 grayscale).
95    Index(u8),
96}
97
98/// Bitflags for [`Cell::modifiers`]. Mirrors ratatui's `Modifier`
99/// constants so a sibling can reuse its existing styling.
100pub mod modifier {
101    pub const BOLD: u16 = 1 << 0;
102    pub const DIM: u16 = 1 << 1;
103    pub const ITALIC: u16 = 1 << 2;
104    pub const UNDERLINED: u16 = 1 << 3;
105    pub const SLOW_BLINK: u16 = 1 << 4;
106    pub const RAPID_BLINK: u16 = 1 << 5;
107    pub const REVERSED: u16 = 1 << 6;
108    pub const HIDDEN: u16 = 1 << 7;
109    pub const CROSSED_OUT: u16 = 1 << 8;
110}
111
112/// Sent by the host once on connection, then on every terminal resize.
113#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
114pub struct Geometry {
115    pub cols: u16,
116    pub rows: u16,
117}
118
119/// Routed input event from the host. Key / mouse events that
120/// happened inside the mount's area are forwarded as-is.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122#[serde(tag = "kind", rename_all = "snake_case")]
123pub enum InputEvent {
124    /// A single keypress (key spec, e.g. `"down"`, `"ctrl+c"`).
125    Key { spec: String },
126    /// Mouse click. `button` is `"left" | "middle" | "right"`.
127    Click { col: u16, row: u16, button: String },
128    /// Mouse wheel. Positive `dy` ⇒ scroll up.
129    Scroll { col: u16, row: u16, dy: i16 },
130    /// Mouse hover (cursor moved over the mount).
131    Hover { col: u16, row: u16 },
132}
133
134/// Host → sibling messages.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136#[serde(tag = "kind", rename_all = "snake_case")]
137pub enum HostMessage {
138    /// First message after connect — tells the sibling the initial
139    /// area size.
140    Hello { geometry: Geometry, theme: String },
141    /// Sent on terminal / pane resize.
142    Resize { geometry: Geometry },
143    /// Forwarded user input.
144    Input { event: InputEvent },
145    /// Host is going away (mnml quitting, mount being unmounted).
146    Goodbye,
147}
148
149/// Sibling → host messages.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151#[serde(tag = "kind", rename_all = "snake_case")]
152pub enum SiblingMessage {
153    /// A full screen of cells. `cells.len()` rows × `cells[i].len()`
154    /// cols — must match the most recent `Hello`/`Resize` geometry.
155    /// Rows shorter than the advertised `cols` are right-padded
156    /// with default cells by the host.
157    Frame { cells: Vec<Vec<Cell>> },
158    /// Sibling is voluntarily exiting (clean shutdown).
159    Bye,
160}
161
162/// Read a length-prefixed JSON message from a stream.
163///
164/// Wire format: `[u8; 4]` little-endian length, then `length` bytes
165/// of UTF-8 JSON. Returns `Ok(None)` on clean EOF, `Err` on truncated
166/// reads or malformed JSON.
167pub fn read_message<R, T>(r: &mut R) -> std::io::Result<Option<T>>
168where
169    R: std::io::Read,
170    T: serde::de::DeserializeOwned,
171{
172    let mut len_buf = [0u8; 4];
173    match r.read_exact(&mut len_buf) {
174        Ok(()) => {}
175        Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
176        Err(e) => return Err(e),
177    }
178    let len = u32::from_le_bytes(len_buf) as usize;
179    if len > 16 * 1024 * 1024 {
180        return Err(std::io::Error::new(
181            std::io::ErrorKind::InvalidData,
182            format!("bridge message too large: {len} bytes"),
183        ));
184    }
185    let mut body = vec![0u8; len];
186    r.read_exact(&mut body)?;
187    let parsed: T = serde_json::from_slice(&body).map_err(|e| {
188        std::io::Error::new(
189            std::io::ErrorKind::InvalidData,
190            format!("bridge JSON parse: {e}"),
191        )
192    })?;
193    Ok(Some(parsed))
194}
195
196/// Write a length-prefixed JSON message to a stream.
197pub fn write_message<W, T>(w: &mut W, msg: &T) -> std::io::Result<()>
198where
199    W: std::io::Write,
200    T: Serialize,
201{
202    let body = serde_json::to_vec(msg).map_err(|e| {
203        std::io::Error::new(
204            std::io::ErrorKind::InvalidData,
205            format!("bridge JSON serialize: {e}"),
206        )
207    })?;
208    let len = body.len() as u32;
209    w.write_all(&len.to_le_bytes())?;
210    w.write_all(&body)?;
211    Ok(())
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn frame_roundtrip() {
220        let frame = SiblingMessage::Frame {
221            cells: vec![vec![Cell {
222                symbol: "x".to_string(),
223                fg: Some(RgbOrIndex::Rgb([255, 0, 0])),
224                bg: None,
225                modifiers: modifier::BOLD,
226            }]],
227        };
228        let mut buf = Vec::new();
229        write_message(&mut buf, &frame).unwrap();
230        let mut cursor = std::io::Cursor::new(&buf);
231        let back: SiblingMessage = read_message(&mut cursor).unwrap().unwrap();
232        match back {
233            SiblingMessage::Frame { cells } => {
234                assert_eq!(cells.len(), 1);
235                assert_eq!(cells[0][0].symbol, "x");
236                assert_eq!(cells[0][0].fg, Some(RgbOrIndex::Rgb([255, 0, 0])));
237                assert_eq!(cells[0][0].modifiers, modifier::BOLD);
238            }
239            _ => panic!("wrong variant"),
240        }
241    }
242
243    #[test]
244    fn host_hello_roundtrip() {
245        let hello = HostMessage::Hello {
246            geometry: Geometry { cols: 80, rows: 24 },
247            theme: "cyberdream".to_string(),
248        };
249        let mut buf = Vec::new();
250        write_message(&mut buf, &hello).unwrap();
251        let mut cursor = std::io::Cursor::new(&buf);
252        let back: HostMessage = read_message(&mut cursor).unwrap().unwrap();
253        match back {
254            HostMessage::Hello { geometry, theme } => {
255                assert_eq!(geometry.cols, 80);
256                assert_eq!(geometry.rows, 24);
257                assert_eq!(theme, "cyberdream");
258            }
259            _ => panic!("wrong variant"),
260        }
261    }
262
263    #[test]
264    fn eof_returns_none() {
265        let mut empty = std::io::Cursor::new(Vec::<u8>::new());
266        let res: Option<HostMessage> = read_message(&mut empty).unwrap();
267        assert!(res.is_none());
268    }
269
270    #[test]
271    fn rejects_oversize_length() {
272        // 4-byte length = 100 MB; should be rejected before allocation.
273        let mut buf = (100u32 * 1024 * 1024).to_le_bytes().to_vec();
274        buf.extend_from_slice(b"junk");
275        let mut cursor = std::io::Cursor::new(&buf);
276        let res: std::io::Result<Option<HostMessage>> = read_message(&mut cursor);
277        assert!(res.is_err());
278    }
279}