Skip to main content

agent_engine/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
113                    .as_str()
114                    .ok_or_else(|| format!("widget.upsert 'lines[{i}]' must be a string"))?;
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() {
141                        None
142                    } else {
143                        Some(s.to_string())
144                    }
145                }
146            };
147
148            // --- ttl_secs ---
149            let ttl_secs = match obj.get("ttl_secs") {
150                None | Some(Value::Null) => None,
151                Some(v) => {
152                    let n = v.as_u64().ok_or_else(|| {
153                        format!(
154                            "widget.upsert 'ttl_secs' must be a non-negative integer or null, got {v}"
155                        )
156                    })?;
157                    Some(n)
158                }
159            };
160
161            // --- styled_lines (optional) ---
162            let styled_lines = match obj.get("styled_lines") {
163                None | Some(Value::Null) => None,
164                Some(v) => {
165                    let outer = v.as_array().ok_or_else(|| {
166                        "widget.upsert 'styled_lines' must be an array of arrays".to_string()
167                    })?;
168                    let mut result = Vec::with_capacity(outer.len());
169                    for (i, row) in outer.iter().enumerate() {
170                        let spans_arr = row.as_array().ok_or_else(|| {
171                            format!("widget.upsert 'styled_lines[{i}]' must be an array of span objects")
172                        })?;
173                        let mut spans = Vec::with_capacity(spans_arr.len());
174                        for (j, span_val) in spans_arr.iter().enumerate() {
175                            let span_obj = span_val.as_object().ok_or_else(|| {
176                                format!("widget.upsert 'styled_lines[{i}][{j}]' must be an object")
177                            })?;
178                            let text = span_obj.get("text")
179                                .and_then(Value::as_str)
180                                .ok_or_else(|| {
181                                    format!("widget.upsert 'styled_lines[{i}][{j}].text' must be a string")
182                                })?
183                                .to_string();
184                            let fg = span_obj
185                                .get("fg")
186                                .and_then(Value::as_str)
187                                .map(str::to_string);
188                            let bg = span_obj
189                                .get("bg")
190                                .and_then(Value::as_str)
191                                .map(str::to_string);
192                            spans.push(StyledSpan { text, fg, bg });
193                        }
194                        result.push(spans);
195                    }
196                    Some(result)
197                }
198            };
199
200            Ok(WidgetEvent::Upsert {
201                id,
202                lines,
203                styled_lines,
204                position,
205                title,
206                ttl_secs,
207            })
208        }
209
210        "widget.dismiss" => Ok(WidgetEvent::Dismiss { id }),
211
212        other => Err(format!("not a widget method: {other}")),
213    }
214}
215
216/// Event sent from a background notification watcher to the TUI.
217/// Carries the source extension id and the parsed widget event.
218#[derive(Debug, Clone)]
219pub struct ExtensionWidgetEvent {
220    pub extension_id: String,
221    pub event: WidgetEvent,
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use serde_json::json;
228
229    // ── widget.upsert ─────────────────────────────────────────────────────────
230
231    #[test]
232    fn parses_upsert_minimal() {
233        let ev = parse_widget_event(
234            "widget.upsert",
235            &json!({"id": "status", "lines": ["hello"]}),
236        )
237        .unwrap();
238        assert_eq!(
239            ev,
240            WidgetEvent::Upsert {
241                id: "status".into(),
242                lines: vec!["hello".into()],
243                position: "top_right".into(),
244                title: None,
245                ttl_secs: None,
246                styled_lines: None,
247            }
248        );
249    }
250
251    #[test]
252    fn parses_upsert_full() {
253        let ev = parse_widget_event(
254            "widget.upsert",
255            &json!({
256                "id": "hud",
257                "lines": ["line one", "line two"],
258                "position": "bottom_left",
259                "title": "My Widget",
260                "ttl_secs": 30
261            }),
262        )
263        .unwrap();
264        assert_eq!(
265            ev,
266            WidgetEvent::Upsert {
267                id: "hud".into(),
268                lines: vec!["line one".into(), "line two".into()],
269                position: "bottom_left".into(),
270                title: Some("My Widget".into()),
271                ttl_secs: Some(30),
272                styled_lines: None,
273            }
274        );
275    }
276
277    #[test]
278    fn upsert_null_position_defaults_to_top_right() {
279        let ev = parse_widget_event(
280            "widget.upsert",
281            &json!({"id": "w", "lines": [], "position": null}),
282        )
283        .unwrap();
284        assert!(matches!(
285            ev,
286            WidgetEvent::Upsert { position, .. } if position == "top_right"
287        ));
288    }
289
290    #[test]
291    fn upsert_null_ttl_means_persistent() {
292        let ev = parse_widget_event(
293            "widget.upsert",
294            &json!({"id": "w", "lines": [], "ttl_secs": null}),
295        )
296        .unwrap();
297        assert!(matches!(ev, WidgetEvent::Upsert { ttl_secs: None, .. }));
298    }
299
300    #[test]
301    fn upsert_empty_lines_is_valid() {
302        let ev = parse_widget_event("widget.upsert", &json!({"id": "w", "lines": []})).unwrap();
303        assert!(matches!(ev, WidgetEvent::Upsert { lines, .. } if lines.is_empty()));
304    }
305
306    #[test]
307    fn upsert_empty_title_coerces_to_none() {
308        let ev = parse_widget_event(
309            "widget.upsert",
310            &json!({"id": "w", "lines": [], "title": ""}),
311        )
312        .unwrap();
313        assert!(matches!(ev, WidgetEvent::Upsert { title: None, .. }));
314    }
315
316    #[test]
317    fn upsert_all_positions_accepted() {
318        for pos in VALID_POSITIONS {
319            let ev = parse_widget_event(
320                "widget.upsert",
321                &json!({"id": "w", "lines": [], "position": pos}),
322            )
323            .unwrap();
324            assert!(
325                matches!(&ev, WidgetEvent::Upsert { position, .. } if position == pos),
326                "position '{pos}' was rejected"
327            );
328        }
329    }
330
331    // ── widget.dismiss ────────────────────────────────────────────────────────
332
333    #[test]
334    fn parses_dismiss() {
335        let ev = parse_widget_event("widget.dismiss", &json!({"id": "hud"})).unwrap();
336        assert_eq!(ev, WidgetEvent::Dismiss { id: "hud".into() });
337    }
338
339    // ── error cases ───────────────────────────────────────────────────────────
340
341    #[test]
342    fn rejects_missing_id() {
343        assert!(parse_widget_event("widget.upsert", &json!({"lines": []})).is_err());
344        assert!(parse_widget_event("widget.dismiss", &json!({})).is_err());
345    }
346
347    #[test]
348    fn rejects_empty_id() {
349        assert!(parse_widget_event("widget.upsert", &json!({"id": "", "lines": []})).is_err());
350    }
351
352    #[test]
353    fn rejects_missing_lines() {
354        assert!(parse_widget_event("widget.upsert", &json!({"id": "w"})).is_err());
355    }
356
357    #[test]
358    fn rejects_non_string_line_element() {
359        let err = parse_widget_event("widget.upsert", &json!({"id": "w", "lines": ["ok", 42]}))
360            .unwrap_err();
361        assert!(err.contains("lines[1]"));
362    }
363
364    #[test]
365    fn rejects_unknown_position() {
366        let err = parse_widget_event(
367            "widget.upsert",
368            &json!({"id": "w", "lines": [], "position": "floating"}),
369        )
370        .unwrap_err();
371        assert!(err.contains("unknown position"));
372    }
373
374    #[test]
375    fn rejects_non_object_params() {
376        assert!(parse_widget_event("widget.upsert", &json!("bad")).is_err());
377        assert!(parse_widget_event("widget.dismiss", &json!(null)).is_err());
378    }
379
380    #[test]
381    fn rejects_unknown_method() {
382        let err = parse_widget_event("widget.flash", &json!({"id": "w"})).unwrap_err();
383        assert!(err.contains("not a widget method"));
384    }
385
386    // ── helpers ───────────────────────────────────────────────────────────────
387
388    #[test]
389    fn is_widget_method_works() {
390        assert!(is_widget_method("widget.upsert"));
391        assert!(is_widget_method("widget.dismiss"));
392        assert!(!is_widget_method("widget.flash"));
393        assert!(!is_widget_method("task.start"));
394        assert!(!is_widget_method(""));
395    }
396
397    #[test]
398    fn event_id_helper() {
399        let upsert =
400            parse_widget_event("widget.upsert", &json!({"id": "my-widget", "lines": []})).unwrap();
401        assert_eq!(upsert.id(), "my-widget");
402
403        let dismiss = parse_widget_event("widget.dismiss", &json!({"id": "my-widget"})).unwrap();
404        assert_eq!(dismiss.id(), "my-widget");
405    }
406}