Skip to main content

aegis_tools/
widget.rs

1use anyhow::Result;
2use async_trait::async_trait;
3use serde::{Deserialize, Serialize};
4use serde_json::{json, Value};
5use std::path::PathBuf;
6
7use crate::registry::{Tool, ToolContext};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Widget {
11    pub id: String,
12    #[serde(rename = "type")]
13    pub widget_type: String,
14    #[serde(default)]
15    pub label: String,
16    #[serde(default)]
17    pub value: Value,
18    #[serde(default = "default_max")]
19    pub max: u64,
20    #[serde(default)]
21    pub content: String,
22    #[serde(default = "default_color")]
23    pub color: String,
24}
25
26fn default_max() -> u64 {
27    100
28}
29fn default_color() -> String {
30    "dim".to_string()
31}
32
33#[derive(Debug, Default, Serialize, Deserialize)]
34pub struct WidgetFile {
35    #[serde(default)]
36    pub widgets: Vec<Widget>,
37}
38
39pub fn widgets_path() -> PathBuf {
40    aegis_types::paths::config_dir().join("widgets.json")
41}
42
43pub fn load_widgets() -> Vec<Widget> {
44    let path = widgets_path();
45    if !path.exists() {
46        return Vec::new();
47    }
48    std::fs::read_to_string(&path)
49        .ok()
50        .and_then(|s| serde_json::from_str::<WidgetFile>(&s).ok())
51        .map(|f| f.widgets)
52        .unwrap_or_default()
53}
54
55fn save_widgets(widgets: &[Widget]) -> Result<()> {
56    let path = widgets_path();
57    let _ = std::fs::create_dir_all(path.parent().expect("widgets path has parent"));
58    let file = WidgetFile {
59        widgets: widgets.to_vec(),
60    };
61    let json = serde_json::to_string_pretty(&file)?;
62    std::fs::write(&path, json)?;
63    Ok(())
64}
65
66pub struct WidgetTool;
67
68#[async_trait]
69impl Tool for WidgetTool {
70    fn name(&self) -> &str {
71        "widget"
72    }
73    fn description(&self) -> &str {
74        "Manage persistent widgets displayed below the input prompt. Use to show \
75         the user ongoing status (git branch, test coverage, deploy state, etc.) \
76         that persists across turns and sessions. Widget types: kv (label: value), \
77         bar (label with progress), text (free-form line)."
78    }
79    fn parameters(&self) -> Value {
80        json!({
81            "type": "object",
82            "properties": {
83                "action": {
84                    "type": "string",
85                    "enum": ["set", "remove", "list", "clear"],
86                    "description": "Action to perform"
87                },
88                "id": {
89                    "type": "string",
90                    "description": "Unique widget identifier (required for set/remove)"
91                },
92                "type": {
93                    "type": "string",
94                    "enum": ["kv", "bar", "text"],
95                    "description": "Widget type (required for set)"
96                },
97                "label": {
98                    "type": "string",
99                    "description": "Display label (for kv/bar types)"
100                },
101                "value": {
102                    "description": "Display value: string for kv, number for bar"
103                },
104                "max": {
105                    "type": "integer",
106                    "description": "Max value for bar type (default 100)"
107                },
108                "content": {
109                    "type": "string",
110                    "description": "Full text content (for text type)"
111                },
112                "color": {
113                    "type": "string",
114                    "enum": ["cyan", "green", "yellow", "red", "magenta", "dim", "white"],
115                    "description": "Display color (default: dim)"
116                }
117            },
118            "required": ["action"]
119        })
120    }
121
122    async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
123        let action = args["action"].as_str().unwrap_or("list");
124
125        match action {
126            "set" => {
127                let id = match args["id"].as_str() {
128                    Some(id) if !id.is_empty() => id.to_string(),
129                    _ => return Ok("Error: 'id' is required for set action.".to_string()),
130                };
131                let widget_type = match args["type"].as_str() {
132                    Some(t) if matches!(t, "kv" | "bar" | "text") => t.to_string(),
133                    _ => return Ok("Error: 'type' must be one of: kv, bar, text.".to_string()),
134                };
135
136                let widget = Widget {
137                    id: id.clone(),
138                    widget_type,
139                    label: args["label"].as_str().unwrap_or("").to_string(),
140                    value: args["value"].clone(),
141                    max: args["max"].as_u64().unwrap_or(100),
142                    content: args["content"].as_str().unwrap_or("").to_string(),
143                    color: args["color"].as_str().unwrap_or("dim").to_string(),
144                };
145
146                let mut widgets = load_widgets();
147                if let Some(pos) = widgets.iter().position(|w| w.id == id) {
148                    widgets[pos] = widget;
149                } else {
150                    widgets.push(widget);
151                }
152                save_widgets(&widgets)?;
153                Ok(format!("Widget '{id}' set. ({} total)", widgets.len()))
154            }
155            "remove" => {
156                let id = match args["id"].as_str() {
157                    Some(id) if !id.is_empty() => id,
158                    _ => return Ok("Error: 'id' is required for remove action.".to_string()),
159                };
160                let mut widgets = load_widgets();
161                let before = widgets.len();
162                widgets.retain(|w| w.id != id);
163                if widgets.len() == before {
164                    return Ok(format!("No widget found with id '{id}'."));
165                }
166                save_widgets(&widgets)?;
167                Ok(format!("Widget '{id}' removed. ({} remaining)", widgets.len()))
168            }
169            "list" => {
170                let widgets = load_widgets();
171                if widgets.is_empty() {
172                    return Ok("No widgets configured.".to_string());
173                }
174                let mut out = String::new();
175                for w in &widgets {
176                    out.push_str(&format!(
177                        "- [{}] type={}, label={:?}, value={}, color={}\n",
178                        w.id, w.widget_type, w.label, w.value, w.color
179                    ));
180                }
181                Ok(out)
182            }
183            "clear" => {
184                save_widgets(&[])?;
185                Ok("All widgets cleared.".to_string())
186            }
187            _ => Ok("Unknown action. Use: set, remove, list, clear.".to_string()),
188        }
189    }
190}
191
192/// Render widgets into displayable lines. Each line is prefixed with `  ┊ `.
193/// Returns empty vec if no widgets are configured.
194pub fn render_widget_lines(widgets: &[Widget]) -> Vec<String> {
195    widgets
196        .iter()
197        .filter_map(|w| {
198            let body = match w.widget_type.as_str() {
199                "kv" => {
200                    let val = match &w.value {
201                        Value::String(s) => s.clone(),
202                        Value::Number(n) => n.to_string(),
203                        Value::Bool(b) => b.to_string(),
204                        _ => w.value.to_string(),
205                    };
206                    format!("{}: {}", w.label, val)
207                }
208                "bar" => {
209                    let val = w.value.as_f64().unwrap_or(0.0) as u64;
210                    let max = w.max.max(1);
211                    let pct = (val * 100 / max).min(100);
212                    let filled = (val * 10 / max).min(10) as usize;
213                    let empty = 10 - filled;
214                    format!(
215                        "{}: {}% {}{}",
216                        w.label,
217                        pct,
218                        "\u{25b0}".repeat(filled),
219                        "\u{25b1}".repeat(empty),
220                    )
221                }
222                "text" => w.content.clone(),
223                _ => return None,
224            };
225            Some(format!("  \u{250a} {body}"))
226        })
227        .collect()
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn test_render_kv_widget() {
236        let w = Widget {
237            id: "branch".to_string(),
238            widget_type: "kv".to_string(),
239            label: "Branch".to_string(),
240            value: Value::String("main".to_string()),
241            max: 100,
242            content: String::new(),
243            color: "cyan".to_string(),
244        };
245        let lines = render_widget_lines(&[w]);
246        assert_eq!(lines.len(), 1);
247        assert!(lines[0].contains("Branch: main"));
248    }
249
250    #[test]
251    fn test_render_bar_widget() {
252        let w = Widget {
253            id: "cov".to_string(),
254            widget_type: "bar".to_string(),
255            label: "Coverage".to_string(),
256            value: json!(80),
257            max: 100,
258            content: String::new(),
259            color: "green".to_string(),
260        };
261        let lines = render_widget_lines(&[w]);
262        assert_eq!(lines.len(), 1);
263        assert!(lines[0].contains("Coverage: 80%"));
264        assert!(lines[0].contains("\u{25b0}"));
265    }
266
267    #[test]
268    fn test_render_text_widget() {
269        let w = Widget {
270            id: "note".to_string(),
271            widget_type: "text".to_string(),
272            label: String::new(),
273            value: Value::Null,
274            max: 100,
275            content: "Deploy OK".to_string(),
276            color: "dim".to_string(),
277        };
278        let lines = render_widget_lines(&[w]);
279        assert_eq!(lines.len(), 1);
280        assert!(lines[0].contains("Deploy OK"));
281    }
282
283    #[test]
284    fn test_render_empty() {
285        let lines = render_widget_lines(&[]);
286        assert!(lines.is_empty());
287    }
288}