Skip to main content

mur_common/
panel.rs

1//! Wire protocol + on-disk session records for the murmur Panel (companion
2//! window). Shared by the murmur TUI (socket server side) and
3//! `mur-gui-core::panel_bridge` (Hub client side). One JSON object per line.
4//! Design: docs/superpowers/specs/2026-07-05-murmur-panel-companion-design.md
5
6use std::path::{Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9
10pub const PANEL_PROTO_VERSION: u32 = 1;
11
12/// Directory holding one `<pid>.json` + `<pid>.sock` per live murmur session.
13/// Under the existing `~/.mur/runtime` convention (see `media::runtime_dir`).
14pub fn murmur_run_dir(mur_home: &Path) -> PathBuf {
15    mur_home.join("runtime").join("murmur")
16}
17
18/// On-disk session record (`<pid>.json`) and `Hello` payload.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct PanelSession {
21    pub pid: u32,
22    pub agent: String,
23    pub cwd: String,
24    pub sock: String,
25    #[serde(default)]
26    pub terminal_program: Option<String>,
27    pub proto_version: u32,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum PanelTab {
33    Information,
34    Activities,
35    Preview,
36    Notifications,
37    Schedule,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum PreviewKind {
43    File,
44    Url,
45}
46
47/// murmur → Hub frames.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(tag = "type", rename_all = "snake_case")]
50pub enum PanelFrame {
51    /// Sent once per connection, immediately on accept.
52    Hello {
53        session: PanelSession,
54    },
55    /// Reserved for later phases (cwd/agent changes mid-session).
56    State {
57        cwd: String,
58        agent: String,
59    },
60    /// `/panel <tab>` — open/focus the Panel window on a tab.
61    Panel {
62        focus: PanelTab,
63    },
64    /// `/panel preview <target>` — set preview target (rendered in P3).
65    Preview {
66        kind: PreviewKind,
67        target: String,
68    },
69    /// Live agent-output delta (P4; sent only while the session gate is on).
70    Stream {
71        delta: String,
72    },
73    Bye,
74}
75
76/// Hub → murmur frames. Insert-only by design: nothing here may ever
77/// execute — `Insert` fills the input box and the user presses Enter
78/// (fail-closed; see spec security section).
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(tag = "type", rename_all = "snake_case")]
81pub enum HubFrame {
82    Insert { text: String },
83}
84
85/// Tolerant single-line decode: unknown/malformed frames are `None`
86/// (forward compatibility across proto versions).
87pub fn decode_line<T: serde::de::DeserializeOwned>(line: &str) -> Option<T> {
88    serde_json::from_str(line).ok()
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use std::path::Path;
95
96    #[test]
97    fn run_dir_under_runtime() {
98        assert_eq!(
99            murmur_run_dir(Path::new("/h")),
100            Path::new("/h/runtime/murmur")
101        );
102    }
103
104    #[test]
105    fn frames_round_trip() {
106        let s = PanelSession {
107            pid: 42,
108            agent: "mur".into(),
109            cwd: "/tmp".into(),
110            sock: "/h/runtime/murmur/42.sock".into(),
111            terminal_program: Some("iTerm.app".into()),
112            proto_version: PANEL_PROTO_VERSION,
113        };
114        let line = serde_json::to_string(&PanelFrame::Hello { session: s }).unwrap();
115        assert!(line.contains("\"type\":\"hello\""));
116        assert!(matches!(
117            decode_line::<PanelFrame>(&line),
118            Some(PanelFrame::Hello { .. })
119        ));
120
121        let line = serde_json::to_string(&PanelFrame::Panel {
122            focus: PanelTab::Preview,
123        })
124        .unwrap();
125        assert!(line.contains("\"focus\":\"preview\""));
126
127        let line = serde_json::to_string(&PanelFrame::Panel {
128            focus: PanelTab::Schedule,
129        })
130        .unwrap();
131        assert!(line.contains("\"focus\":\"schedule\""));
132
133        let line = serde_json::to_string(&PanelFrame::Stream {
134            delta: "tok".into(),
135        })
136        .unwrap();
137        assert!(line.contains("\"type\":\"stream\""));
138        assert!(matches!(
139            decode_line::<PanelFrame>(&line),
140            Some(PanelFrame::Stream { delta }) if delta == "tok"
141        ));
142
143        let line = serde_json::to_string(&HubFrame::Insert {
144            text: "/help".into(),
145        })
146        .unwrap();
147        assert!(matches!(
148            decode_line::<HubFrame>(&line),
149            Some(HubFrame::Insert { text }) if text == "/help"
150        ));
151    }
152
153    #[test]
154    fn unknown_frames_are_none() {
155        assert!(decode_line::<PanelFrame>(r#"{"type":"from_the_future"}"#).is_none());
156        assert!(decode_line::<HubFrame>(r#"{"type":"execute","cmd":"rm"}"#).is_none());
157        assert!(decode_line::<HubFrame>("not json").is_none());
158    }
159}