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 = 2;
11
12/// Debounce for `InputChanged` snapshots (murmur side). ~200 ms is the
13/// autocomplete sweet spot; >300 ms degrades UX (Algolia guidance).
14pub const INPUT_DEBOUNCE_MS: u64 = 200;
15/// Below this many trimmed chars the Hub falls back to cwd recommendations.
16pub const MIN_QUERY_CHARS: usize = 2;
17/// Snapshot size bound; the tail (what the user is typing now) is kept.
18pub const INPUT_SNAPSHOT_MAX_CHARS: usize = 2000;
19
20/// Directory holding one `<pid>.json` + `<pid>.sock` per live murmur session.
21/// Under the existing `~/.mur/runtime` convention (see `media::runtime_dir`).
22pub fn murmur_run_dir(mur_home: &Path) -> PathBuf {
23    mur_home.join("runtime").join("murmur")
24}
25
26/// On-disk session record (`<pid>.json`) and `Hello` payload.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct PanelSession {
29    pub pid: u32,
30    pub agent: String,
31    pub cwd: String,
32    pub sock: String,
33    #[serde(default)]
34    pub terminal_program: Option<String>,
35    pub proto_version: u32,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum PanelTab {
41    Information,
42    Activities,
43    Preview,
44    Notifications,
45    Schedule,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum PreviewKind {
51    File,
52    Url,
53}
54
55/// murmur → Hub frames.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(tag = "type", rename_all = "snake_case")]
58pub enum PanelFrame {
59    /// Sent once per connection, immediately on accept.
60    Hello {
61        session: PanelSession,
62    },
63    /// Reserved for later phases (cwd/agent changes mid-session).
64    State {
65        cwd: String,
66        agent: String,
67    },
68    /// `/panel <tab>` — open/focus the Panel window on a tab.
69    Panel {
70        focus: PanelTab,
71    },
72    /// `/panel preview <target>` — set preview target (rendered in P3).
73    Preview {
74        kind: PreviewKind,
75        target: String,
76    },
77    /// Live agent-output delta (P4; sent only while the session gate is on).
78    Stream {
79        delta: String,
80    },
81    /// Debounced snapshot of the message input line (never per-keystroke
82    /// deltas). Local-socket only; must never be persisted or forwarded
83    /// (spec §3.2). Build the payload with [`input_snapshot`].
84    InputChanged {
85        text: String,
86    },
87    Bye,
88}
89
90/// Hub → murmur frames. Insert-only by design: nothing here may ever
91/// execute — `Insert` fills the input box and the user presses Enter
92/// (fail-closed; see spec security section).
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(tag = "type", rename_all = "snake_case")]
95pub enum HubFrame {
96    Insert { text: String },
97}
98
99/// Tolerant single-line decode: unknown/malformed frames are `None`
100/// (forward compatibility across proto versions).
101pub fn decode_line<T: serde::de::DeserializeOwned>(line: &str) -> Option<T> {
102    serde_json::from_str(line).ok()
103}
104
105/// Build a privacy-safe `InputChanged` payload: redact secret-looking
106/// tokens, then keep only the trailing `INPUT_SNAPSHOT_MAX_CHARS` chars
107/// (the tail is what the user is currently typing).
108pub fn input_snapshot(text: &str) -> String {
109    let redacted: Vec<String> = text
110        .split_whitespace()
111        .map(|tok| {
112            if looks_secret(tok) {
113                "[redacted]".to_string()
114            } else {
115                tok.to_string()
116            }
117        })
118        .collect();
119    let joined = redacted.join(" ");
120    let n = joined.chars().count();
121    if n <= INPUT_SNAPSHOT_MAX_CHARS {
122        joined
123    } else {
124        joined.chars().skip(n - INPUT_SNAPSHOT_MAX_CHARS).collect()
125    }
126}
127
128/// Conservative secret heuristics: `sk-` API keys, AWS `AKIA` key ids,
129/// and long (32+) unbroken hex/base64-ish runs.
130fn looks_secret(tok: &str) -> bool {
131    let is_b64ish = |s: &str| {
132        !s.is_empty()
133            && s.chars().all(|c| {
134                c.is_ascii_alphanumeric()
135                    || c == '+'
136                    || c == '/'
137                    || c == '='
138                    || c == '_'
139                    || c == '-'
140            })
141    };
142    if let Some(rest) = tok.strip_prefix("sk-")
143        && rest.len() >= 16
144        && is_b64ish(rest)
145    {
146        return true;
147    }
148    if let Some(rest) = tok.strip_prefix("AKIA")
149        && rest.len() == 16
150        && rest
151            .chars()
152            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
153    {
154        return true;
155    }
156    tok.len() >= 32 && is_b64ish(tok) && tok.chars().any(|c| c.is_ascii_digit())
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use std::path::Path;
163
164    #[test]
165    fn run_dir_under_runtime() {
166        assert_eq!(
167            murmur_run_dir(Path::new("/h")),
168            Path::new("/h/runtime/murmur")
169        );
170    }
171
172    #[test]
173    fn frames_round_trip() {
174        let s = PanelSession {
175            pid: 42,
176            agent: "mur".into(),
177            cwd: "/tmp".into(),
178            sock: "/h/runtime/murmur/42.sock".into(),
179            terminal_program: Some("iTerm.app".into()),
180            proto_version: PANEL_PROTO_VERSION,
181        };
182        let line = serde_json::to_string(&PanelFrame::Hello { session: s }).unwrap();
183        assert!(line.contains("\"type\":\"hello\""));
184        assert!(matches!(
185            decode_line::<PanelFrame>(&line),
186            Some(PanelFrame::Hello { .. })
187        ));
188
189        let line = serde_json::to_string(&PanelFrame::Panel {
190            focus: PanelTab::Preview,
191        })
192        .unwrap();
193        assert!(line.contains("\"focus\":\"preview\""));
194
195        let line = serde_json::to_string(&PanelFrame::Panel {
196            focus: PanelTab::Schedule,
197        })
198        .unwrap();
199        assert!(line.contains("\"focus\":\"schedule\""));
200
201        let line = serde_json::to_string(&PanelFrame::Stream {
202            delta: "tok".into(),
203        })
204        .unwrap();
205        assert!(line.contains("\"type\":\"stream\""));
206        assert!(matches!(
207            decode_line::<PanelFrame>(&line),
208            Some(PanelFrame::Stream { delta }) if delta == "tok"
209        ));
210
211        let line = serde_json::to_string(&HubFrame::Insert {
212            text: "/help".into(),
213        })
214        .unwrap();
215        assert!(matches!(
216            decode_line::<HubFrame>(&line),
217            Some(HubFrame::Insert { text }) if text == "/help"
218        ));
219    }
220
221    #[test]
222    fn unknown_frames_are_none() {
223        assert!(decode_line::<PanelFrame>(r#"{"type":"from_the_future"}"#).is_none());
224        assert!(decode_line::<HubFrame>(r#"{"type":"execute","cmd":"rm"}"#).is_none());
225        assert!(decode_line::<HubFrame>("not json").is_none());
226    }
227
228    #[test]
229    fn input_changed_round_trips() {
230        let line = serde_json::to_string(&PanelFrame::InputChanged {
231            text: "run book".into(),
232        })
233        .unwrap();
234        assert!(line.contains("\"type\":\"input_changed\""));
235        assert!(matches!(
236            decode_line::<PanelFrame>(&line),
237            Some(PanelFrame::InputChanged { text }) if text == "run book"
238        ));
239    }
240
241    #[test]
242    fn snapshot_redacts_secrets() {
243        // OpenAI-style key
244        let s = input_snapshot("use sk-abcdefghijklmnop1234 please");
245        assert_eq!(s, "use [redacted] please");
246        // AWS access key id
247        let s = input_snapshot("key AKIAIOSFODNN7EXAMPLE here");
248        assert_eq!(s, "key [redacted] here");
249        // 32+ char hex run
250        let s = input_snapshot("token 0123456789abcdef0123456789abcdef end");
251        assert_eq!(s, "token [redacted] end");
252        // Normal prose untouched, including 31-char hex (below threshold)
253        assert_eq!(input_snapshot("fix the panel"), "fix the panel");
254        let hex31 = "0123456789abcdef0123456789abcde";
255        assert_eq!(input_snapshot(hex31), hex31);
256    }
257
258    #[test]
259    fn snapshot_redacts_across_newlines() {
260        let s = input_snapshot("line1\nsk-abcdefghijklmnop1234\tend");
261        assert_eq!(s, "line1 [redacted] end");
262    }
263
264    #[test]
265    fn snapshot_truncates_to_tail() {
266        let long = "a".repeat(INPUT_SNAPSHOT_MAX_CHARS + 100) + " tail";
267        let s = input_snapshot(&long);
268        assert!(s.chars().count() <= INPUT_SNAPSHOT_MAX_CHARS);
269        assert!(s.ends_with(" tail")); // keep what the user is typing now
270    }
271}