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}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum PreviewKind {
42    File,
43    Url,
44}
45
46/// murmur → Hub frames.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(tag = "type", rename_all = "snake_case")]
49pub enum PanelFrame {
50    /// Sent once per connection, immediately on accept.
51    Hello {
52        session: PanelSession,
53    },
54    /// Reserved for later phases (cwd/agent changes mid-session).
55    State {
56        cwd: String,
57        agent: String,
58    },
59    /// `/panel <tab>` — open/focus the Panel window on a tab.
60    Panel {
61        focus: PanelTab,
62    },
63    /// `/panel preview <target>` — set preview target (rendered in P3).
64    Preview {
65        kind: PreviewKind,
66        target: String,
67    },
68    Bye,
69}
70
71/// Hub → murmur frames. Insert-only by design: nothing here may ever
72/// execute — `Insert` fills the input box and the user presses Enter
73/// (fail-closed; see spec security section).
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(tag = "type", rename_all = "snake_case")]
76pub enum HubFrame {
77    Insert { text: String },
78}
79
80/// Tolerant single-line decode: unknown/malformed frames are `None`
81/// (forward compatibility across proto versions).
82pub fn decode_line<T: serde::de::DeserializeOwned>(line: &str) -> Option<T> {
83    serde_json::from_str(line).ok()
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use std::path::Path;
90
91    #[test]
92    fn run_dir_under_runtime() {
93        assert_eq!(
94            murmur_run_dir(Path::new("/h")),
95            Path::new("/h/runtime/murmur")
96        );
97    }
98
99    #[test]
100    fn frames_round_trip() {
101        let s = PanelSession {
102            pid: 42,
103            agent: "mur".into(),
104            cwd: "/tmp".into(),
105            sock: "/h/runtime/murmur/42.sock".into(),
106            terminal_program: Some("iTerm.app".into()),
107            proto_version: PANEL_PROTO_VERSION,
108        };
109        let line = serde_json::to_string(&PanelFrame::Hello { session: s }).unwrap();
110        assert!(line.contains("\"type\":\"hello\""));
111        assert!(matches!(
112            decode_line::<PanelFrame>(&line),
113            Some(PanelFrame::Hello { .. })
114        ));
115
116        let line = serde_json::to_string(&PanelFrame::Panel {
117            focus: PanelTab::Preview,
118        })
119        .unwrap();
120        assert!(line.contains("\"focus\":\"preview\""));
121
122        let line = serde_json::to_string(&HubFrame::Insert {
123            text: "/help".into(),
124        })
125        .unwrap();
126        assert!(matches!(
127            decode_line::<HubFrame>(&line),
128            Some(HubFrame::Insert { text }) if text == "/help"
129        ));
130    }
131
132    #[test]
133    fn unknown_frames_are_none() {
134        assert!(decode_line::<PanelFrame>(r#"{"type":"from_the_future"}"#).is_none());
135        assert!(decode_line::<HubFrame>(r#"{"type":"execute","cmd":"rm"}"#).is_none());
136        assert!(decode_line::<HubFrame>("not json").is_none());
137    }
138}