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    /// Bumped on every accepted write so readers can skip unchanged frames.
27    revision: Arc<AtomicU64>,
28}
29
30impl ExtensionStatusMailbox {
31    pub fn new() -> Self {
32        Self::default()
33    }
34
35    /// Set (or, with an empty `value`, clear) one extension's status text.
36    ///
37    /// Returns `true` when the map actually changed — the caller can use that
38    /// to avoid a redundant repaint.
39    pub fn set(&self, key: &str, value: &str) -> bool {
40        let key = key.trim();
41        if key.is_empty() {
42            return false;
43        }
44        let Ok(mut entries) = self.entries.lock() else {
45            return false;
46        };
47        let changed = if value.trim().is_empty() {
48            entries.remove(key).is_some()
49        } else {
50            entries.insert(key.to_string(), value.to_string()) != Some(value.to_string())
51        };
52        if changed {
53            self.revision.fetch_add(1, Ordering::SeqCst);
54        }
55        changed
56    }
57
58    /// Handle the `SetStatus` runtime action: `{"key": "...", "value": "..."}`
59    /// (an empty/absent `value` clears the key).
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        let value = args
66            .get("value")
67            .and_then(serde_json::Value::as_str)
68            .unwrap_or("");
69        let changed = self.set(key, value);
70        Ok(serde_json::json!({ "ok": true, "changed": changed }))
71    }
72
73    /// Monotonic write counter; changes whenever any entry changed.
74    pub fn revision(&self) -> u64 {
75        self.revision.load(Ordering::SeqCst)
76    }
77
78    /// The status line: non-empty values joined by two spaces, in key order.
79    pub fn text(&self) -> String {
80        self.entries
81            .lock()
82            .map(|entries| {
83                entries
84                    .values()
85                    .filter(|value| !value.trim().is_empty())
86                    .cloned()
87                    .collect::<Vec<_>>()
88                    .join("  ")
89            })
90            .unwrap_or_default()
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn set_and_clear_round_trip() {
100        let mailbox = ExtensionStatusMailbox::new();
101        assert_eq!(mailbox.text(), "");
102        assert!(mailbox.set("langfuse", "langfuse ✓"));
103        assert_eq!(mailbox.text(), "langfuse ✓");
104        // Re-setting the same value is not a change (no repaint).
105        assert!(!mailbox.set("langfuse", "langfuse ✓"));
106        assert!(mailbox.set("langfuse", "langfuse ✓ (trace sent)"));
107        assert_eq!(mailbox.text(), "langfuse ✓ (trace sent)");
108        let before = mailbox.revision();
109        // Clearing removes the entry and bumps the revision.
110        assert!(mailbox.set("langfuse", ""));
111        assert_eq!(mailbox.text(), "");
112        assert!(mailbox.revision() > before);
113        // Clearing an absent key is a no-op.
114        assert!(!mailbox.set("langfuse", ""));
115        assert!(!mailbox.set("  ", "ignored"));
116    }
117
118    #[test]
119    fn multiple_extensions_render_in_key_order() {
120        let mailbox = ExtensionStatusMailbox::new();
121        mailbox.set("zeta", "z");
122        mailbox.set("alpha", "a");
123        assert_eq!(mailbox.text(), "a  z");
124    }
125
126    #[test]
127    fn handle_parses_the_action_payload() {
128        let mailbox = ExtensionStatusMailbox::new();
129        let out = mailbox
130            .handle(serde_json::json!({"key": "langfuse", "value": "✓"}))
131            .expect("ok");
132        assert_eq!(out["ok"], serde_json::json!(true));
133        assert_eq!(mailbox.text(), "✓");
134        assert!(mailbox.handle(serde_json::json!({"value": "x"})).is_err());
135    }
136}