Skip to main content

mnml_bridge/
ipc.rs

1//! Tier-2 IPC helpers — write JSONL commands to the host.
2//!
3//! Siblings spawned by mnml as Pty children (or Mount siblings)
4//! get `MNML_IPC_DIR` in env. The host tails
5//! `$MNML_IPC_DIR/command` for JSONL lines and dispatches each
6//! one into `App::dispatch_command`. This module wraps the wire
7//! format so siblings write typed helper calls instead of
8//! hand-rolling JSON.
9//!
10//! Every helper is best-effort: silent no-op on missing env,
11//! silent no-op on IO error. Siblings should treat these as
12//! fire-and-forget notifications, not as commands they need to
13//! confirm succeeded.
14//!
15//! ```no_run
16//! mnml_bridge::toast("processing done");
17//! mnml_bridge::set_activity_badge("agents", 3);
18//! mnml_bridge::register_command(
19//!     "my_sibling.open_dashboard",
20//!     "Open Dashboard",
21//!     Some("plugin"),
22//!     &["<leader>md"],
23//! );
24//! ```
25
26use serde::Serialize;
27use std::fs::OpenOptions;
28use std::io::Write;
29use std::path::PathBuf;
30
31/// Toast severity level. Info + warn share the standard comment
32/// border (calm ambient); error gets a red border so failures
33/// stand out. Persistent toasts respect the same mapping.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
35#[serde(rename_all = "snake_case")]
36pub enum ToastLevel {
37    #[default]
38    Info,
39    Warn,
40    Error,
41}
42
43impl ToastLevel {
44    fn as_str(self) -> &'static str {
45        match self {
46            ToastLevel::Info => "info",
47            ToastLevel::Warn => "warn",
48            ToastLevel::Error => "error",
49        }
50    }
51}
52
53/// Outcome of a progress notification.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
55#[serde(rename_all = "snake_case")]
56pub enum ProgressStatus {
57    Success,
58    Failed,
59    Cancelled,
60}
61
62impl ProgressStatus {
63    fn as_str(self) -> &'static str {
64        match self {
65            ProgressStatus::Success => "success",
66            ProgressStatus::Failed => "failed",
67            ProgressStatus::Cancelled => "cancelled",
68        }
69    }
70}
71
72/// Statusline segment anchor.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
74#[serde(rename_all = "snake_case")]
75pub enum SegmentSide {
76    Left,
77    #[default]
78    Right,
79}
80
81impl SegmentSide {
82    fn as_str(self) -> &'static str {
83        match self {
84            SegmentSide::Left => "left",
85            SegmentSide::Right => "right",
86        }
87    }
88}
89
90/// Options for [`notify`]. All fields are optional-ish — see
91/// individual field docs.
92#[derive(Debug, Clone, Default)]
93pub struct NotifyOpts {
94    pub level: ToastLevel,
95    /// Ring the terminal bell alongside the OS notification.
96    /// Opt-in (default `false`).
97    pub sound: bool,
98    /// Source integration id — determines rate-limit + policy
99    /// lookup. `None` bypasses both (always fires).
100    pub source: Option<String>,
101}
102
103/// Show a toast notification in the host. Non-blocking,
104/// fire-and-forget. Silent no-op when `MNML_IPC_DIR` is unset
105/// (sibling wasn't spawned by mnml) or on any IO error.
106pub fn toast(message: impl AsRef<str>) {
107    let payload = serde_json::json!({
108        "cmd": "toast",
109        "text": message.as_ref(),
110    });
111    let _ = write_line(&payload);
112}
113
114/// Set a notification badge on an activity-bar section (or a
115/// manifest-registered Mount section, keyed by its manifest id).
116/// `count = 0` clears the badge.
117///
118/// Builtin section ids: `"explorer"`, `"search"`, `"git"`,
119/// `"debug"`, `"integrations"`, `"sessions"`, `"agents"`,
120/// `"cloud_agents"`. Mount siblings pass their own manifest `id`.
121pub fn set_activity_badge(section: impl AsRef<str>, count: u32) {
122    let payload = serde_json::json!({
123        "cmd": "set-activity-badge",
124        "section": section.as_ref(),
125        "count": count,
126    });
127    let _ = write_line(&payload);
128}
129
130/// Level-tagged toast helpers — same wire shape as [`toast`] but
131/// with an explicit `level` field. `info` (default) + `warn` render
132/// with the comment border; `error` gets a red border.
133pub fn toast_info(message: impl AsRef<str>) {
134    toast_leveled(message, ToastLevel::Info);
135}
136pub fn toast_warn(message: impl AsRef<str>) {
137    toast_leveled(message, ToastLevel::Warn);
138}
139pub fn toast_error(message: impl AsRef<str>) {
140    toast_leveled(message, ToastLevel::Error);
141}
142
143fn toast_leveled(message: impl AsRef<str>, level: ToastLevel) {
144    let payload = serde_json::json!({
145        "cmd": "toast",
146        "text": message.as_ref(),
147        "level": level.as_str(),
148    });
149    let _ = write_line(&payload);
150}
151
152/// Pin a toast identified by `id`. Repeat calls with the same
153/// id update the text/level in place. Stays visible until
154/// [`toast_dismiss`].
155pub fn toast_persistent(id: impl AsRef<str>, message: impl AsRef<str>, level: ToastLevel) {
156    let payload = serde_json::json!({
157        "cmd": "toast-persistent",
158        "id": id.as_ref(),
159        "text": message.as_ref(),
160        "level": level.as_str(),
161    });
162    let _ = write_line(&payload);
163}
164
165/// Remove a persistent toast by id. No-op if the id isn't
166/// currently pinned.
167pub fn toast_dismiss(id: impl AsRef<str>) {
168    let payload = serde_json::json!({
169        "cmd": "toast-dismiss",
170        "id": id.as_ref(),
171    });
172    let _ = write_line(&payload);
173}
174
175/// Start a progress notification — the host renders an animated
176/// Braille spinner + label. Repeat calls with the same id reset
177/// the item.
178pub fn progress_start(id: impl AsRef<str>, label: impl AsRef<str>) {
179    let payload = serde_json::json!({
180        "cmd": "progress-start",
181        "id": id.as_ref(),
182        "text": label.as_ref(),
183    });
184    let _ = write_line(&payload);
185}
186
187/// Update an in-flight progress. `label` and `percent` are both
188/// optional — pass `None` to keep the previous value. Percent
189/// clamps to 0..=100 host-side.
190pub fn progress_update(id: impl AsRef<str>, label: Option<&str>, percent: Option<u8>) {
191    let mut m = serde_json::Map::new();
192    m.insert("cmd".to_string(), serde_json::json!("progress-update"));
193    m.insert("id".to_string(), serde_json::json!(id.as_ref()));
194    if let Some(l) = label {
195        m.insert("text".to_string(), serde_json::json!(l));
196    }
197    if let Some(p) = percent {
198        m.insert("count".to_string(), serde_json::json!(p));
199    }
200    let _ = write_line(&serde_json::Value::Object(m));
201}
202
203/// Finish a progress notification. `Failed` also fires a
204/// `toast_error` host-side; `Success` / `Cancelled` show the
205/// terminal status glyph and fade after ~2.5s.
206pub fn progress_end(id: impl AsRef<str>, status: ProgressStatus) {
207    let payload = serde_json::json!({
208        "cmd": "progress-end",
209        "id": id.as_ref(),
210        "text": status.as_str(),
211    });
212    let _ = write_line(&payload);
213}
214
215/// Insert or update a sibling statusline segment. Sorted host-side
216/// by priority desc; each segment competes for its lane's
217/// budget.
218#[allow(clippy::too_many_arguments)]
219pub fn statusline_set_segment(
220    id: impl AsRef<str>,
221    side: SegmentSide,
222    text: impl AsRef<str>,
223    color: Option<&str>,
224    click_command: Option<&str>,
225    priority: u8,
226    min_width: u16,
227    max_width: u16,
228) {
229    let mut m = serde_json::Map::new();
230    m.insert(
231        "cmd".to_string(),
232        serde_json::json!("statusline-set-segment"),
233    );
234    m.insert("id".to_string(), serde_json::json!(id.as_ref()));
235    m.insert("side".to_string(), serde_json::json!(side.as_str()));
236    m.insert("text".to_string(), serde_json::json!(text.as_ref()));
237    if let Some(c) = color {
238        m.insert("color".to_string(), serde_json::json!(c));
239    }
240    if let Some(c) = click_command {
241        m.insert("click_command".to_string(), serde_json::json!(c));
242    }
243    m.insert("priority".to_string(), serde_json::json!(priority));
244    m.insert("min_width".to_string(), serde_json::json!(min_width));
245    m.insert("max_width".to_string(), serde_json::json!(max_width));
246    let _ = write_line(&serde_json::Value::Object(m));
247}
248
249/// Remove a sibling statusline segment by id.
250pub fn statusline_clear_segment(id: impl AsRef<str>) {
251    let payload = serde_json::json!({
252        "cmd": "statusline-clear-segment",
253        "id": id.as_ref(),
254    });
255    let _ = write_line(&payload);
256}
257
258/// Fire an OS-level notification. Host emits OSC 9 + 777 escape
259/// sequences after the next render pass — Ghostty / iTerm2 /
260/// kitty / WezTerm route those to native banners. Terminals that
261/// don't recognize the escape silently consume it.
262///
263/// Always fires an in-app toast at `opts.level`. OS emission
264/// respects the integration's `[notifications]` policy + rate
265/// limit; when `opts.source = None`, both checks are bypassed.
266pub fn notify(title: impl AsRef<str>, body: impl AsRef<str>, opts: NotifyOpts) {
267    let mut m = serde_json::Map::new();
268    m.insert("cmd".to_string(), serde_json::json!("notify"));
269    m.insert("title".to_string(), serde_json::json!(title.as_ref()));
270    m.insert("text".to_string(), serde_json::json!(body.as_ref()));
271    m.insert("level".to_string(), serde_json::json!(opts.level.as_str()));
272    if opts.sound {
273        m.insert("sound".to_string(), serde_json::json!(true));
274    }
275    if let Some(src) = &opts.source {
276        m.insert("source".to_string(), serde_json::json!(src));
277    }
278    let _ = write_line(&serde_json::Value::Object(m));
279}
280
281/// Register a plugin command. It becomes runnable from the
282/// palette + optionally bound to one or more key chords in the
283/// host. Keyspec syntax mirrors mnml's own keymap
284/// (`"ctrl+shift+t"`, `"<leader>xt"`, …).
285///
286/// `group` categorises the command in the palette
287/// (`"plugin"` by default). The registered command runs inside
288/// mnml — most siblings pair this with a toast or a fresh
289/// `open-pty` follow-up so the user sees the action.
290pub fn register_command(
291    id: impl AsRef<str>,
292    title: impl AsRef<str>,
293    group: Option<&str>,
294    keys: &[&str],
295) {
296    let payload = serde_json::json!({
297        "cmd": "register-command",
298        "id": id.as_ref(),
299        "title": title.as_ref(),
300        "group": group.unwrap_or("plugin"),
301        "keys": keys,
302    });
303    let _ = write_line(&payload);
304}
305
306fn command_file() -> Option<PathBuf> {
307    let dir = std::env::var_os("MNML_IPC_DIR")?;
308    let mut p = PathBuf::from(dir);
309    p.push("command");
310    Some(p)
311}
312
313fn write_line(value: &serde_json::Value) -> std::io::Result<()> {
314    let path = command_file().ok_or_else(|| {
315        std::io::Error::new(
316            std::io::ErrorKind::NotFound,
317            "MNML_IPC_DIR not set — not spawned by mnml",
318        )
319    })?;
320    write_line_to(&path, value)
321}
322
323/// Append one JSON value + newline to the given file. Exposed
324/// only for tests + advanced callers that want a non-env
325/// `command` file path.
326#[doc(hidden)]
327pub fn write_line_to(path: &std::path::Path, value: &serde_json::Value) -> std::io::Result<()> {
328    let line = serde_json::to_string(value)?;
329    let mut f = OpenOptions::new().create(true).append(true).open(path)?;
330    f.write_all(line.as_bytes())?;
331    f.write_all(b"\n")?;
332    Ok(())
333}
334
335/// Payload builders — the wire shape each helper produces.
336/// Tests hit these directly so they don't need to touch the
337/// global `MNML_IPC_DIR` env var (which races across parallel
338/// tests).
339#[doc(hidden)]
340pub fn toast_payload(message: &str) -> serde_json::Value {
341    serde_json::json!({ "cmd": "toast", "text": message })
342}
343
344#[doc(hidden)]
345pub fn set_activity_badge_payload(section: &str, count: u32) -> serde_json::Value {
346    serde_json::json!({
347        "cmd": "set-activity-badge",
348        "section": section,
349        "count": count,
350    })
351}
352
353#[doc(hidden)]
354pub fn register_command_payload(
355    id: &str,
356    title: &str,
357    group: Option<&str>,
358    keys: &[&str],
359) -> serde_json::Value {
360    serde_json::json!({
361        "cmd": "register-command",
362        "id": id,
363        "title": title,
364        "group": group.unwrap_or("plugin"),
365        "keys": keys,
366    })
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use std::io::Read;
373
374    fn read_file(path: &std::path::Path) -> String {
375        let mut s = String::new();
376        let mut f = std::fs::File::open(path).unwrap();
377        f.read_to_string(&mut s).unwrap();
378        s
379    }
380
381    #[test]
382    fn toast_payload_shape() {
383        let v = toast_payload("hello world");
384        let s = serde_json::to_string(&v).unwrap();
385        assert!(s.contains("\"cmd\":\"toast\""));
386        assert!(s.contains("\"text\":\"hello world\""));
387    }
388
389    #[test]
390    fn set_activity_badge_payload_shape() {
391        let v = set_activity_badge_payload("agents", 5);
392        let s = serde_json::to_string(&v).unwrap();
393        assert!(s.contains("\"cmd\":\"set-activity-badge\""));
394        assert!(s.contains("\"section\":\"agents\""));
395        assert!(s.contains("\"count\":5"));
396    }
397
398    #[test]
399    fn register_command_payload_default_group() {
400        let v = register_command_payload("plug.open", "Open Plug", None, &["ctrl+shift+p"]);
401        let s = serde_json::to_string(&v).unwrap();
402        assert!(s.contains("\"cmd\":\"register-command\""));
403        assert!(s.contains("\"id\":\"plug.open\""));
404        assert!(s.contains("\"title\":\"Open Plug\""));
405        assert!(s.contains("\"group\":\"plugin\""));
406        assert!(s.contains("\"keys\":[\"ctrl+shift+p\"]"));
407    }
408
409    #[test]
410    fn register_command_payload_custom_group_and_multi_key() {
411        let v =
412            register_command_payload("plug.split", "Split", Some("view"), &["ctrl+alt+s", "F5"]);
413        let s = serde_json::to_string(&v).unwrap();
414        assert!(s.contains("\"group\":\"view\""));
415        assert!(s.contains("\"keys\":[\"ctrl+alt+s\",\"F5\"]"));
416    }
417
418    #[test]
419    fn write_line_appends_newline() {
420        let tmp = tempfile::tempdir().unwrap();
421        let p = tmp.path().join("command");
422        write_line_to(&p, &toast_payload("one")).unwrap();
423        write_line_to(&p, &toast_payload("two")).unwrap();
424        let contents = read_file(&p);
425        let lines: Vec<&str> = contents.split_terminator('\n').collect();
426        assert_eq!(lines.len(), 2);
427        assert!(lines[0].contains("\"text\":\"one\""));
428        assert!(lines[1].contains("\"text\":\"two\""));
429    }
430
431    #[test]
432    fn silent_when_env_missing() {
433        // No env manipulation — this only checks the no-panic
434        // property of the public helpers when they can't find
435        // MNML_IPC_DIR. If the test env happens to have it set
436        // (e.g. running under mnml itself), the calls still
437        // succeed silently.
438        let _ = toast_payload("safe");
439    }
440}