Skip to main content

synaps_cli/extensions/
widgets.rs

1//! Plugin widget notification types and parser.
2//!
3//! Phase B Phase 3 contract — see
4//! `docs/plans/2026-05-03-extension-contracts-for-rich-plugins.md`.
5//!
6//! Plugins push spontaneous JSON-RPC notifications to create, update, or
7//! dismiss persistent on-screen widgets (HUD panels, status boxes, etc.)
8//! outside the slash-command request/response cycle.
9//! Method names: `widget.upsert`, `widget.dismiss`.
10//!
11//! Wire shapes (params per method):
12//!
13//! - `widget.upsert`:  `{ id, lines: string[], position?: string, title?: string, ttl_secs?: number | null }`
14//! - `widget.dismiss`: `{ id }`
15//!
16//! Valid `position` values: `"top_left"`, `"top_center"`, `"top_right"`,
17//! `"middle_left"`, `"center"`, `"middle_right"`, `"bottom_left"`,
18//! `"bottom_center"`, `"bottom_right"`. Defaults to `"top_right"`.
19//!
20//! `ttl_secs: None` means the widget is persistent (no auto-dismiss).
21//! `ttl_secs: Some(n)` means the widget auto-dismisses after `n` seconds.
22
23use serde_json::Value;
24
25/// Default widget position when the plugin omits the field.
26const DEFAULT_POSITION: &str = "top_right";
27
28/// All valid position string values accepted over the wire.
29const VALID_POSITIONS: &[&str] = &[
30    "top_left",
31    "top_center",
32    "top_right",
33    "middle_left",
34    "center",
35    "middle_right",
36    "bottom_left",
37    "bottom_center",
38    "bottom_right",
39];
40
41/// A single styled text span sent over the wire from an extension.
42/// Extensions specify colors as CSS-style hex strings (e.g. `"#ff0000"`).
43#[derive(Debug, Clone, PartialEq)]
44pub struct StyledSpan {
45    pub text: String,
46    pub fg: Option<String>,
47    pub bg: Option<String>,
48}
49
50/// Parsed widget notification.
51#[derive(Debug, Clone, PartialEq)]
52pub enum WidgetEvent {
53    /// Create or update a widget. `position` is stored as a raw string; the
54    /// TUI layer is responsible for converting it to a `ToastPosition`.
55    Upsert {
56        id: String,
57        lines: Vec<String>,
58        /// Optional per-span styled lines. Each inner Vec is one display line,
59        /// each `StyledSpan` carries text + optional fg/bg colors. When present,
60        /// the TUI renders these instead of plain `lines`.
61        styled_lines: Option<Vec<Vec<StyledSpan>>>,
62        /// One of the nine position strings; always present (defaults to
63        /// `"top_right"` when the plugin omits the field).
64        position: String,
65        title: Option<String>,
66        /// `None` → persistent widget; `Some(n)` → auto-dismiss after n secs.
67        ttl_secs: Option<u64>,
68    },
69    /// Remove a widget by id.
70    Dismiss { id: String },
71}
72
73impl WidgetEvent {
74    pub fn id(&self) -> &str {
75        match self {
76            WidgetEvent::Upsert { id, .. } | WidgetEvent::Dismiss { id } => id,
77        }
78    }
79}
80
81/// Returns true if `method` is one of the recognised widget notifications.
82pub fn is_widget_method(method: &str) -> bool {
83    matches!(method, "widget.upsert" | "widget.dismiss")
84}
85
86/// Parse a `widget.*` notification given the JSON-RPC method and params.
87pub fn parse_widget_event(method: &str, params: &Value) -> Result<WidgetEvent, String> {
88    let obj = params
89        .as_object()
90        .ok_or_else(|| format!("{method} params must be a JSON object"))?;
91
92    let id = obj
93        .get("id")
94        .and_then(Value::as_str)
95        .ok_or_else(|| format!("{method} missing 'id'"))?
96        .to_string();
97    if id.is_empty() {
98        return Err(format!("{method} 'id' must be non-empty"));
99    }
100
101    match method {
102        "widget.upsert" => {
103            // --- lines ---
104            let lines_raw = obj
105                .get("lines")
106                .ok_or_else(|| "widget.upsert missing 'lines'".to_string())?;
107            let lines_arr = lines_raw
108                .as_array()
109                .ok_or_else(|| "widget.upsert 'lines' must be an array".to_string())?;
110            let mut lines = Vec::with_capacity(lines_arr.len());
111            for (i, v) in lines_arr.iter().enumerate() {
112                let s = v.as_str().ok_or_else(|| {
113                    format!("widget.upsert 'lines[{i}]' must be a string")
114                })?;
115                lines.push(s.to_string());
116            }
117
118            // --- position ---
119            let position = match obj.get("position") {
120                None => DEFAULT_POSITION.to_string(),
121                Some(Value::Null) => DEFAULT_POSITION.to_string(),
122                Some(v) => {
123                    let s = v
124                        .as_str()
125                        .ok_or_else(|| "widget.upsert 'position' must be a string".to_string())?;
126                    if !VALID_POSITIONS.contains(&s) {
127                        return Err(format!("widget.upsert unknown position '{s}'"));
128                    }
129                    s.to_string()
130                }
131            };
132
133            // --- title ---
134            let title = match obj.get("title") {
135                None | Some(Value::Null) => None,
136                Some(v) => {
137                    let s = v
138                        .as_str()
139                        .ok_or_else(|| "widget.upsert 'title' must be a string".to_string())?;
140                    if s.is_empty() { None } else { Some(s.to_string()) }
141                }
142            };
143
144            // --- ttl_secs ---
145            let ttl_secs = match obj.get("ttl_secs") {
146                None | Some(Value::Null) => None,
147                Some(v) => {
148                    let n = v.as_u64().ok_or_else(|| {
149                        format!(
150                            "widget.upsert 'ttl_secs' must be a non-negative integer or null, got {v}"
151                        )
152                    })?;
153                    Some(n)
154                }
155            };
156
157            // --- styled_lines (optional) ---
158            let styled_lines = match obj.get("styled_lines") {
159                None | Some(Value::Null) => None,
160                Some(v) => {
161                    let outer = v.as_array().ok_or_else(|| {
162                        "widget.upsert 'styled_lines' must be an array of arrays".to_string()
163                    })?;
164                    let mut result = Vec::with_capacity(outer.len());
165                    for (i, row) in outer.iter().enumerate() {
166                        let spans_arr = row.as_array().ok_or_else(|| {
167                            format!("widget.upsert 'styled_lines[{i}]' must be an array of span objects")
168                        })?;
169                        let mut spans = Vec::with_capacity(spans_arr.len());
170                        for (j, span_val) in spans_arr.iter().enumerate() {
171                            let span_obj = span_val.as_object().ok_or_else(|| {
172                                format!("widget.upsert 'styled_lines[{i}][{j}]' must be an object")
173                            })?;
174                            let text = span_obj.get("text")
175                                .and_then(Value::as_str)
176                                .ok_or_else(|| {
177                                    format!("widget.upsert 'styled_lines[{i}][{j}].text' must be a string")
178                                })?
179                                .to_string();
180                            let fg = span_obj.get("fg")
181                                .and_then(Value::as_str)
182                                .map(str::to_string);
183                            let bg = span_obj.get("bg")
184                                .and_then(Value::as_str)
185                                .map(str::to_string);
186                            spans.push(StyledSpan { text, fg, bg });
187                        }
188                        result.push(spans);
189                    }
190                    Some(result)
191                }
192            };
193
194            Ok(WidgetEvent::Upsert { id, lines, styled_lines, position, title, ttl_secs })
195        }
196
197        "widget.dismiss" => Ok(WidgetEvent::Dismiss { id }),
198
199        other => Err(format!("not a widget method: {other}")),
200    }
201}
202
203/// Event sent from a background notification watcher to the TUI.
204/// Carries the source extension id and the parsed widget event.
205#[derive(Debug, Clone)]
206pub struct ExtensionWidgetEvent {
207    pub extension_id: String,
208    pub event: WidgetEvent,
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use serde_json::json;
215
216    // ── widget.upsert ─────────────────────────────────────────────────────────
217
218    #[test]
219    fn parses_upsert_minimal() {
220        let ev = parse_widget_event(
221            "widget.upsert",
222            &json!({"id": "status", "lines": ["hello"]}),
223        )
224        .unwrap();
225        assert_eq!(
226            ev,
227            WidgetEvent::Upsert {
228                id: "status".into(),
229                lines: vec!["hello".into()],
230                position: "top_right".into(),
231                title: None,
232                ttl_secs: None,
233                styled_lines: None,
234            }
235        );
236    }
237
238    #[test]
239    fn parses_upsert_full() {
240        let ev = parse_widget_event(
241            "widget.upsert",
242            &json!({
243                "id": "hud",
244                "lines": ["line one", "line two"],
245                "position": "bottom_left",
246                "title": "My Widget",
247                "ttl_secs": 30
248            }),
249        )
250        .unwrap();
251        assert_eq!(
252            ev,
253            WidgetEvent::Upsert {
254                id: "hud".into(),
255                lines: vec!["line one".into(), "line two".into()],
256                position: "bottom_left".into(),
257                title: Some("My Widget".into()),
258                ttl_secs: Some(30),
259                styled_lines: None,
260            }
261        );
262    }
263
264    #[test]
265    fn upsert_null_position_defaults_to_top_right() {
266        let ev = parse_widget_event(
267            "widget.upsert",
268            &json!({"id": "w", "lines": [], "position": null}),
269        )
270        .unwrap();
271        assert!(matches!(
272            ev,
273            WidgetEvent::Upsert { position, .. } if position == "top_right"
274        ));
275    }
276
277    #[test]
278    fn upsert_null_ttl_means_persistent() {
279        let ev = parse_widget_event(
280            "widget.upsert",
281            &json!({"id": "w", "lines": [], "ttl_secs": null}),
282        )
283        .unwrap();
284        assert!(matches!(ev, WidgetEvent::Upsert { ttl_secs: None, .. }));
285    }
286
287    #[test]
288    fn upsert_empty_lines_is_valid() {
289        let ev = parse_widget_event(
290            "widget.upsert",
291            &json!({"id": "w", "lines": []}),
292        )
293        .unwrap();
294        assert!(matches!(ev, WidgetEvent::Upsert { lines, .. } if lines.is_empty()));
295    }
296
297    #[test]
298    fn upsert_empty_title_coerces_to_none() {
299        let ev = parse_widget_event(
300            "widget.upsert",
301            &json!({"id": "w", "lines": [], "title": ""}),
302        )
303        .unwrap();
304        assert!(matches!(ev, WidgetEvent::Upsert { title: None, .. }));
305    }
306
307    #[test]
308    fn upsert_all_positions_accepted() {
309        for pos in VALID_POSITIONS {
310            let ev = parse_widget_event(
311                "widget.upsert",
312                &json!({"id": "w", "lines": [], "position": pos}),
313            )
314            .unwrap();
315            assert!(
316                matches!(&ev, WidgetEvent::Upsert { position, .. } if position == pos),
317                "position '{pos}' was rejected"
318            );
319        }
320    }
321
322    // ── widget.dismiss ────────────────────────────────────────────────────────
323
324    #[test]
325    fn parses_dismiss() {
326        let ev =
327            parse_widget_event("widget.dismiss", &json!({"id": "hud"})).unwrap();
328        assert_eq!(ev, WidgetEvent::Dismiss { id: "hud".into() });
329    }
330
331    // ── error cases ───────────────────────────────────────────────────────────
332
333    #[test]
334    fn rejects_missing_id() {
335        assert!(parse_widget_event("widget.upsert", &json!({"lines": []})).is_err());
336        assert!(parse_widget_event("widget.dismiss", &json!({})).is_err());
337    }
338
339    #[test]
340    fn rejects_empty_id() {
341        assert!(
342            parse_widget_event("widget.upsert", &json!({"id": "", "lines": []})).is_err()
343        );
344    }
345
346    #[test]
347    fn rejects_missing_lines() {
348        assert!(parse_widget_event("widget.upsert", &json!({"id": "w"})).is_err());
349    }
350
351    #[test]
352    fn rejects_non_string_line_element() {
353        let err = parse_widget_event(
354            "widget.upsert",
355            &json!({"id": "w", "lines": ["ok", 42]}),
356        )
357        .unwrap_err();
358        assert!(err.contains("lines[1]"));
359    }
360
361    #[test]
362    fn rejects_unknown_position() {
363        let err = parse_widget_event(
364            "widget.upsert",
365            &json!({"id": "w", "lines": [], "position": "floating"}),
366        )
367        .unwrap_err();
368        assert!(err.contains("unknown position"));
369    }
370
371    #[test]
372    fn rejects_non_object_params() {
373        assert!(parse_widget_event("widget.upsert", &json!("bad")).is_err());
374        assert!(parse_widget_event("widget.dismiss", &json!(null)).is_err());
375    }
376
377    #[test]
378    fn rejects_unknown_method() {
379        let err =
380            parse_widget_event("widget.flash", &json!({"id": "w"})).unwrap_err();
381        assert!(err.contains("not a widget method"));
382    }
383
384    // ── helpers ───────────────────────────────────────────────────────────────
385
386    #[test]
387    fn is_widget_method_works() {
388        assert!(is_widget_method("widget.upsert"));
389        assert!(is_widget_method("widget.dismiss"));
390        assert!(!is_widget_method("widget.flash"));
391        assert!(!is_widget_method("task.start"));
392        assert!(!is_widget_method(""));
393    }
394
395    #[test]
396    fn event_id_helper() {
397        let upsert = parse_widget_event(
398            "widget.upsert",
399            &json!({"id": "my-widget", "lines": []}),
400        )
401        .unwrap();
402        assert_eq!(upsert.id(), "my-widget");
403
404        let dismiss =
405            parse_widget_event("widget.dismiss", &json!({"id": "my-widget"})).unwrap();
406        assert_eq!(dismiss.id(), "my-widget");
407    }
408}