Skip to main content

agentmux_mcp/tools/
widgets.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Widgets: list them, install one the user then approves, open its pane
5//! (docs/specs/SPEC_USER_WIDGETS_AND_WIDGET_API_2026_10_09.md §10).
6
7use super::*;
8
9/// How long `WidgetInstall` waits for the user by default, and at most.
10const INSTALL_WAIT_SECS: u64 = 300;
11const INSTALL_WAIT_MAX_SECS: u64 = 600;
12
13fn require_srv(local_url: &str, auth_key: &str) -> Result<()> {
14    if local_url.is_empty() || auth_key.is_empty() {
15        anyhow::bail!("AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. Is this agent pane opened via AgentMux?");
16    }
17    Ok(())
18}
19
20async fn list(cx: &ToolCtx<'_>) -> Result<Vec<Value>> {
21    let url = format!("{}/api/v1/widgets", cx.local_url.trim_end_matches('/'));
22    let resp = cx.client.get(&url).header(AUTH_KEY_HEADER, cx.auth_key).send().await.map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
23    if !resp.status().is_success() {
24        let status = resp.status();
25        anyhow::bail!("listing widgets failed: HTTP {status}: {}", resp.text().await.unwrap_or_default());
26    }
27    let body: Value = resp.json().await.map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
28    Ok(body.get("packages").and_then(Value::as_array).cloned().unwrap_or_default())
29}
30
31/// A path the agent gave, made absolute against its own workspace.
32fn resolve_path(path: &str) -> String {
33    let p = std::path::Path::new(path);
34    if p.is_absolute() {
35        return path.to_string();
36    }
37    let base = std::env::var("AGENTMUX_AGENT_WORKDIR")
38        .ok()
39        .filter(|s| !s.is_empty())
40        .map(std::path::PathBuf::from)
41        .or_else(|| std::env::current_dir().ok())
42        .unwrap_or_default();
43    base.join(p).to_string_lossy().into_owned()
44}
45
46pub(super) async fn call(name: &str, arguments: &Value, cx: &ToolCtx<'_>) -> Result<String> {
47    match name {
48        "WidgetList" => {
49            require_srv(cx.local_url, cx.auth_key)?;
50            let rows: Vec<Value> = list(cx)
51                .await?
52                .iter()
53                .map(|p| {
54                    json!({
55                        "id": p["id"], "name": p["name"], "version": p["version"], "kind": p["kind"], "state": p["state"],
56                        "permissions": p["permissions"], "error": p["error"], "folder": p["folder"],
57                        "views": p["panes"].as_array().map(|ps| ps.iter().map(|x| x["view"].clone()).collect::<Vec<_>>()).unwrap_or_default(),
58                    })
59                })
60                .collect();
61            Ok(serde_json::to_string_pretty(&json!({ "widgets": rows })).unwrap_or_default())
62        }
63        "WidgetInstall" => {
64            require_srv(cx.local_url, cx.auth_key)?;
65            let path = arguments
66                .get("path")
67                .and_then(Value::as_str)
68                .filter(|s| !s.is_empty())
69                .ok_or_else(|| anyhow::anyhow!("missing required parameter: path"))?;
70            let wait = arguments.get("wait_secs").and_then(Value::as_u64).unwrap_or(INSTALL_WAIT_SECS).min(INSTALL_WAIT_MAX_SECS);
71            // The prompt names this agent only from its signed identity; an
72            // agent with no signing key yet is shown as unverified.
73            let body = json!({
74                "path": resolve_path(path),
75                "replace": arguments.get("replace").and_then(Value::as_bool).unwrap_or(true),
76                "auth": crate::identity::sign_ui_automation_auth().ok(),
77                "wait_secs": wait,
78            });
79            let url = format!("{}/api/v1/widgets/install", cx.local_url.trim_end_matches('/'));
80            let resp = cx
81                .client
82                .post(&url)
83                .header(AUTH_KEY_HEADER, cx.auth_key)
84                .timeout(std::time::Duration::from_secs(wait + 60))
85                .json(&body)
86                .send()
87                .await
88                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
89            let status = resp.status();
90            let result: Value = resp.json().await.unwrap_or_default();
91            if !status.is_success() {
92                anyhow::bail!("the widget wasn't installed: {}", result["error"].as_str().unwrap_or("unknown error"));
93            }
94            Ok(serde_json::to_string_pretty(&result).unwrap_or_default())
95        }
96        "OpenWidget" => {
97            require_srv(cx.local_url, cx.auth_key)?;
98            let view = arguments
99                .get("view")
100                .and_then(Value::as_str)
101                .filter(|s| !s.is_empty())
102                .ok_or_else(|| anyhow::anyhow!("missing required parameter: view"))?;
103            let packages = list(cx).await?;
104            let found = packages.iter().find_map(|p| {
105                p["panes"].as_array().and_then(|ps| ps.iter().find(|x| x["view"] == view)).map(|pane| (p, pane))
106            });
107            let Some((pkg, pane)) = found else {
108                anyhow::bail!("no installed widget has the view {view}; WidgetList shows the views there are");
109            };
110            if pkg["state"] != "approved" {
111                anyhow::bail!(
112                    "{} is {}, so it can't open; the user approves or turns it on in Settings → Widgets",
113                    pkg["id"].as_str().unwrap_or(""),
114                    pkg["state"].as_str().unwrap_or("not approved")
115                );
116            }
117            // The pane's defaults, then the agent's own keys, all in the
118            // widget's namespace (`widget:<id>:<key>`).
119            let id = pkg["id"].as_str().unwrap_or_default();
120            let mut meta = pane["default_meta"].as_object().cloned().unwrap_or_default();
121            if let Some(own) = arguments.get("meta").and_then(Value::as_object) {
122                for (k, v) in own {
123                    meta.insert(format!("widget:{id}:{k}"), v.clone());
124                }
125            }
126            meta.insert("view".into(), json!(view));
127            let split = arguments.get("split").and_then(Value::as_str).filter(|s| matches!(*s, "right" | "down")).unwrap_or("right");
128            let mut req = json!({ "view": view, "meta": meta, "focus": true });
129            if !cx.block_id.is_empty() {
130                req["split_direction"] = json!(split);
131                req["split_reference_block_id"] = json!(cx.block_id);
132            }
133            let url = format!("{}/api/v1/pane/open", cx.local_url.trim_end_matches('/'));
134            let resp = cx
135                .client
136                .post(&url)
137                .header(AUTH_KEY_HEADER, cx.auth_key)
138                .json(&req)
139                .send()
140                .await
141                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
142            let status = resp.status();
143            let result: Value = resp.json().await.unwrap_or_default();
144            if !status.is_success() {
145                anyhow::bail!("the pane didn't open: HTTP {status}: {result}");
146            }
147            Ok(format!("Opened {view} (pane {}).", result["block_id"].as_str().unwrap_or("?")))
148        }
149        _ => Err(not_in_family()),
150    }
151}