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
192pub 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}