Skip to main content

rpi_extensions/
status.rs

1//! Extension status line.
2//!
3//! A tiny host-side registry that lets an extension publish a short status
4//! string (e.g. `langfuse ✓ (trace sent)`) for the TUI to render in its footer.
5//! Mirrors [`UiDialogMailbox`](crate::UiDialogMailbox): the plugin writes
6//! through the `SetStatus` runtime action, and the TUI — the only consumer —
7//! polls [`ExtensionStatusMailbox::revision`] once per render tick and repaints
8//! only when something actually changed.
9//!
10//! Keyed by extension name so several extensions can report at once; keys are
11//! rendered in sorted order, which keeps the line stable frame to frame. An
12//! empty value clears that key. Headless runs simply never read the registry —
13//! writing to it is always cheap and never fails.
14
15use std::collections::BTreeMap;
16use std::sync::atomic::{AtomicU64, Ordering};
17use std::sync::{Arc, Mutex};
18
19/// Shared, cloneable handle to the extension status registry.
20///
21/// Every clone refers to the same map: the host hands one clone to the action
22/// bridge (plugin writes) and one to the TUI (rendering).
23#[derive(Clone, Default)]
24pub struct ExtensionStatusMailbox {
25    entries: Arc<Mutex<BTreeMap<String, String>>>,
26    components: Arc<Mutex<BTreeMap<(u64, String), (crate::ExtensionPanel, bool)>>>,
27    /// Bumped on every accepted write so readers can skip unchanged frames.
28    revision: Arc<AtomicU64>,
29}
30
31impl ExtensionStatusMailbox {
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    /// Set (or, with an empty `value`, clear) one extension's status text.
37    ///
38    /// Returns `true` when the map actually changed — the caller can use that
39    /// to avoid a redundant repaint.
40    pub fn set(&self, key: &str, value: &str) -> bool {
41        let key = key.trim();
42        if key.is_empty() {
43            return false;
44        }
45        let Ok(mut entries) = self.entries.lock() else {
46            return false;
47        };
48        let changed = if value.trim().is_empty() {
49            entries.remove(key).is_some()
50        } else {
51            entries.insert(key.to_string(), value.to_string()) != Some(value.to_string())
52        };
53        if changed {
54            self.revision.fetch_add(1, Ordering::SeqCst);
55        }
56        changed
57    }
58
59    /// Handle the legacy SetStatus footer action.
60    pub fn handle(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
61        let key = args
62            .get("key")
63            .and_then(serde_json::Value::as_str)
64            .ok_or("set_status requires a string `key`")?;
65        if args.get("panel").is_some() {
66            return Err("panel was removed; use UiComponent".into());
67        }
68        let value = args
69            .get("value")
70            .and_then(serde_json::Value::as_str)
71            .unwrap_or("");
72        let changed = self.set(key, value);
73        Ok(serde_json::json!({ "ok": true, "changed": changed }))
74    }
75
76    pub fn component(
77        &self,
78        owner: u64,
79        args: serde_json::Value,
80    ) -> Result<serde_json::Value, String> {
81        let id = args["id"]
82            .as_str()
83            .filter(|s| !s.trim().is_empty() && s.len() <= 128)
84            .ok_or("invalid component id")?;
85        let key = (owner, id.to_owned());
86        let op = args["op"].as_str().ok_or("missing component operation")?;
87        let mut entries = self
88            .components
89            .lock()
90            .map_err(|_| "component registry unavailable")?;
91        let before = entries.get(&key).cloned();
92        let allowed: &[&str] = match op {
93            "register" => &[
94                "op",
95                "id",
96                "type",
97                "slot",
98                "order",
99                "visible",
100                "anchor",
101                "offsetX",
102                "offsetY",
103                "width",
104                "maxHeight",
105                "minScreenWidth",
106                "border",
107            ],
108            "configure" => &[
109                "op",
110                "id",
111                "slot",
112                "order",
113                "anchor",
114                "offsetX",
115                "offsetY",
116                "width",
117                "maxHeight",
118                "minScreenWidth",
119                "border",
120            ],
121            "update" => &["op", "id", "text", "title", "highlights"],
122            _ => &["op", "id"],
123        };
124        if args
125            .as_object()
126            .ok_or("component payload must be an object")?
127            .keys()
128            .any(|k| !allowed.contains(&k.as_str()))
129        {
130            return Err("unexpected component field".into());
131        }
132        let next = match op {
133            "register" if before.is_some() => before.clone(),
134            "remove" => None,
135            "register" | "configure" | "update" | "show" | "hide" => {
136                let (mut spec, mut visible) = if op == "register" {
137                    if entries.keys().filter(|(o, _)| *o == owner).count() >= 32 {
138                        return Err("too many components".into());
139                    }
140                    if args.get("type").and_then(|v| v.as_str()).unwrap_or("text") != "text" {
141                        return Err("unsupported component type".into());
142                    }
143                    (
144                        serde_json::json!({"version":1,"lines":[]}),
145                        args["visible"].as_bool().unwrap_or(true),
146                    )
147                } else {
148                    let (panel, visible) = before.clone().ok_or("component is not registered")?;
149                    (
150                        serde_json::to_value(panel).map_err(|e| e.to_string())?,
151                        visible,
152                    )
153                };
154                if op == "register" || op == "configure" {
155                    if let Some(slot) = args.get("slot") {
156                        let (layout, anchor) = match slot.as_str().ok_or("invalid slot")? {
157                            "footer.after" => ("footer", "top-right"),
158                            "sidebar.left" => ("sidebar", "top-left"),
159                            "sidebar.right" => ("sidebar", "top-right"),
160                            "overlay" => ("overlay", "top-right"),
161                            _ => return Err("unsupported component slot".into()),
162                        };
163                        spec["layout"] = layout.into();
164                        spec["anchor"] = anchor.into();
165                    }
166                    for field in [
167                        "order",
168                        "anchor",
169                        "offsetX",
170                        "offsetY",
171                        "width",
172                        "maxHeight",
173                        "minScreenWidth",
174                        "border",
175                    ] {
176                        if let Some(v) = args.get(field) {
177                            spec[field] = v.clone();
178                        }
179                    }
180                } else if op == "update" {
181                    if let Some(v) = args.get("text") {
182                        spec["lines"] = serde_json::json!(v
183                            .as_str()
184                            .ok_or("text must be a string")?
185                            .split('\n')
186                            .collect::<Vec<_>>());
187                        // Plain text updates clear previous styling.
188                        spec["highlights"] = serde_json::json!([]);
189                    }
190                    if let Some(v) = args.get("highlights") {
191                        spec["highlights"] = v.clone();
192                    }
193                    if let Some(v) = args.get("title") {
194                        spec["title"] = v.as_str().ok_or("title must be a string")?.into();
195                    }
196                } else {
197                    visible = op == "show";
198                }
199                let panel: crate::ExtensionPanel =
200                    serde_json::from_value(spec).map_err(|e| e.to_string())?;
201                panel.validate()?;
202                Some((panel, visible))
203            }
204            _ => return Err("unknown component operation".into()),
205        };
206        let changed = before != next;
207        if changed {
208            match next {
209                Some(v) => {
210                    entries.insert(key, v);
211                }
212                None => {
213                    entries.remove(&key);
214                }
215            }
216            self.revision.fetch_add(1, Ordering::SeqCst);
217        }
218        Ok(serde_json::json!({"ok":true,"changed":changed}))
219    }
220    pub fn remove_owner(&self, owner: u64) {
221        if let Ok(mut entries) = self.components.lock() {
222            let size = entries.len();
223            entries.retain(|(o, _), _| *o != owner);
224            if size != entries.len() {
225                self.revision.fetch_add(1, Ordering::SeqCst);
226            }
227        }
228    }
229
230    /// Monotonic write counter; changes whenever any entry changed.
231    pub fn revision(&self) -> u64 {
232        self.revision.load(Ordering::SeqCst)
233    }
234
235    /// Read one entry without exposing the mutable registry.
236    pub fn get(&self, key: &str) -> Option<String> {
237        self.entries.lock().ok()?.get(key).cloned()
238    }
239
240    /// Snapshot declarative panels separately from the legacy footer registry.
241    pub fn panels(&self) -> BTreeMap<String, crate::ExtensionPanel> {
242        let mut panels = BTreeMap::new();
243        if let Ok(entries) = self.components.lock() {
244            for ((owner, id), (spec, visible)) in entries.iter() {
245                if *visible {
246                    panels.insert(
247                        format!(
248                            "component:{:010}:{owner}:{id}",
249                            (spec.order as i64) - (i32::MIN as i64)
250                        ),
251                        spec.clone(),
252                    );
253                }
254            }
255        }
256        panels
257    }
258
259    /// Render visible footer entries, omitting keys consumed by richer UI.
260    pub fn text_except(&self, keys: &[&str]) -> String {
261        self.entries
262            .lock()
263            .map(|entries| {
264                entries
265                    .iter()
266                    .filter(|(key, value)| {
267                        !keys.contains(&key.as_str()) && !value.trim().is_empty()
268                    })
269                    .map(|(_, value)| value.as_str())
270                    .collect::<Vec<_>>()
271                    .join("  ")
272            })
273            .unwrap_or_default()
274    }
275
276    /// The status line: non-empty values joined by two spaces, in key order.
277    pub fn text(&self) -> String {
278        self.entries
279            .lock()
280            .map(|entries| {
281                entries
282                    .values()
283                    .filter(|value| !value.trim().is_empty())
284                    .cloned()
285                    .collect::<Vec<_>>()
286                    .join("  ")
287            })
288            .unwrap_or_default()
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn text_highlights_are_validated_atomic_and_cleared_on_plain_updates() {
298        let m = ExtensionStatusMailbox::new();
299        m.component(1, serde_json::json!({"op":"register","id":"sample"}))
300            .unwrap();
301        let update = serde_json::json!({"op":"update","id":"sample","text":"Err1",
302            "highlights":[{"text":"Err1","color":"error"}]});
303        m.component(1, update.clone()).unwrap();
304        let before = m.panels();
305        for highlights in [
306            serde_json::json!([{"text":"", "color":"error"}]),
307            serde_json::json!([{"text":"\u{1b}[31m", "color":"error"}]),
308            serde_json::json!([{"text":"Err1", "color":"unknown"}]),
309            serde_json::json!(vec![serde_json::json!({"text":"Err1","color":"error"}); 33]),
310        ] {
311            let mut invalid = update.clone();
312            invalid["highlights"] = highlights;
313            assert!(m.component(1, invalid).is_err());
314            assert_eq!(m.panels(), before);
315        }
316        m.component(
317            1,
318            serde_json::json!({"op":"update","id":"sample","text":"OK"}),
319        )
320        .unwrap();
321        assert!(m.panels().values().next().unwrap().highlights.is_empty());
322    }
323
324    #[test]
325    fn components_keep_layout_content_visibility_and_owners_independent() {
326        let m = ExtensionStatusMailbox::new();
327        let register =
328            serde_json::json!({"op":"register","id":"stats","slot":"footer.after","visible":false});
329        m.component(1, register.clone()).unwrap();
330        m.component(2, register.clone()).unwrap();
331        assert!(m.panels().is_empty());
332        m.component(
333            1,
334            serde_json::json!({"op":"update","id":"stats","text":"TPS 252"}),
335        )
336        .unwrap();
337        m.component(1, serde_json::json!({"op":"show","id":"stats"}))
338            .unwrap();
339        let rev = m.revision();
340        m.component(1, register).unwrap();
341        assert_eq!(m.revision(), rev);
342        assert_eq!(
343            m.panels()["component:2147483648:1:stats"].layout,
344            crate::PanelLayout::Footer
345        );
346        assert!(m
347            .component(
348                1,
349                serde_json::json!({"op":"update","id":"stats","slot":"overlay"})
350            )
351            .is_err());
352        assert!(m
353            .component(
354                3,
355                serde_json::json!({"op":"update","id":"stats","text":"x"})
356            )
357            .is_err());
358        m.component(1, serde_json::json!({"op":"hide","id":"stats"}))
359            .unwrap();
360        m.component(
361            1,
362            serde_json::json!({"op":"configure","id":"stats","slot":"sidebar.left"}),
363        )
364        .unwrap();
365        m.component(1, serde_json::json!({"op":"show","id":"stats"}))
366            .unwrap();
367        assert_eq!(
368            m.panels()["component:2147483648:1:stats"].lines,
369            ["TPS 252"]
370        );
371        m.remove_owner(1);
372        assert!(m.panels().is_empty());
373        m.component(2, serde_json::json!({"op":"show","id":"stats"}))
374            .unwrap();
375        assert_eq!(m.panels().len(), 1);
376    }
377    #[test]
378    fn set_and_clear_round_trip() {
379        let mailbox = ExtensionStatusMailbox::new();
380        assert_eq!(mailbox.text(), "");
381        assert!(mailbox.set("langfuse", "langfuse ✓"));
382        assert_eq!(mailbox.text(), "langfuse ✓");
383        // Re-setting the same value is not a change (no repaint).
384        assert!(!mailbox.set("langfuse", "langfuse ✓"));
385        assert!(mailbox.set("langfuse", "langfuse ✓ (trace sent)"));
386        assert_eq!(mailbox.text(), "langfuse ✓ (trace sent)");
387        let before = mailbox.revision();
388        // Clearing removes the entry and bumps the revision.
389        assert!(mailbox.set("langfuse", ""));
390        assert_eq!(mailbox.text(), "");
391        assert!(mailbox.revision() > before);
392        // Clearing an absent key is a no-op.
393        assert!(!mailbox.set("langfuse", ""));
394        assert!(!mailbox.set("  ", "ignored"));
395    }
396
397    #[test]
398    fn multiple_extensions_render_in_key_order() {
399        let mailbox = ExtensionStatusMailbox::new();
400        mailbox.set("zeta", "z");
401        mailbox.set("alpha", "a");
402        assert_eq!(mailbox.text(), "a  z");
403    }
404
405    #[test]
406    fn handle_parses_the_action_payload() {
407        let mailbox = ExtensionStatusMailbox::new();
408        let out = mailbox
409            .handle(serde_json::json!({"key": "langfuse", "value": "✓"}))
410            .expect("ok");
411        assert_eq!(out["ok"], serde_json::json!(true));
412        assert_eq!(mailbox.text(), "✓");
413        assert!(mailbox.handle(serde_json::json!({"value": "x"})).is_err());
414    }
415}