1use std::collections::BTreeMap;
16use std::sync::atomic::{AtomicU64, Ordering};
17use std::sync::{Arc, Mutex};
18
19#[derive(Clone, Default)]
24pub struct ExtensionStatusMailbox {
25 entries: Arc<Mutex<BTreeMap<String, String>>>,
26 components: Arc<Mutex<BTreeMap<(u64, String), (crate::ExtensionPanel, bool)>>>,
27 revision: Arc<AtomicU64>,
29}
30
31impl ExtensionStatusMailbox {
32 pub fn new() -> Self {
33 Self::default()
34 }
35
36 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 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 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 pub fn revision(&self) -> u64 {
232 self.revision.load(Ordering::SeqCst)
233 }
234
235 pub fn get(&self, key: &str) -> Option<String> {
237 self.entries.lock().ok()?.get(key).cloned()
238 }
239
240 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 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 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 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 assert!(mailbox.set("langfuse", ""));
390 assert_eq!(mailbox.text(), "");
391 assert!(mailbox.revision() > before);
392 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}