Skip to main content

agentmux_srv/server/
ui_shortcuts.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Agent-facing shortcuts (`ListShortcuts` / `RunCommand` / `PressKeys`),
5//! docs/specs/PLAN_SHORTCUTS_VERIFIED_AND_APP_API_2026_10_10.md §4. Same
6//! identity model as the rest of `ui_handlers`: the caller's own pane is
7//! derived from its verified identity, never taken from the request.
8
9use axum::extract::State;
10use axum::http::StatusCode;
11use axum::response::IntoResponse;
12use axum::Json;
13use serde_json::json;
14
15use agentmux_common::api_types::{UiPressKeysRequest, UiRunCommandRequest, UiShortcutsListRequest};
16
17use super::ui_handlers::{err_response, proxy_data, target_block_id};
18use super::AppState;
19
20// Each acts in the window that holds the caller's own pane (owner decision §8.1): the
21// host resolves that window from the verified block and calls the page's
22// `window.__agentmux_shortcuts` (frontend keybindings/app-api.ts), which
23// reveals `target` (a pane in that window, in any tab) before acting on it.
24
25/// Commands no agent may run: agents are first-class owners, so only what
26/// has no protection. A permanent delete can't be undone. The frontend
27/// refuses it too, and PressKeys of any key bound to it.
28fn refused_command(command: &str) -> Option<&'static str> {
29    match command {
30        "files:deletePermanently" => Some("it can't be undone"),
31        _ => None,
32    }
33}
34
35/// `POST /api/v1/ui/shortcuts/list` — backs `ListShortcuts`.
36pub(crate) async fn handle_ui_shortcuts_list(
37    State(state): State<AppState>,
38    caller: Option<axum::Extension<crate::server::caller::Caller>>,
39    Json(req): Json<UiShortcutsListRequest>,
40) -> impl IntoResponse {
41    let block_id = match target_block_id(&state, caller.as_deref(), &req.auth, None) {
42        Ok(b) => b,
43        Err((code, e)) => return err_response(code, e),
44    };
45    proxy_data(&state, "list_shortcuts", json!({ "block_id": block_id }), std::time::Duration::from_secs(10)).await
46}
47
48/// `POST /api/v1/ui/shortcuts/run` — backs `RunCommand`.
49pub(crate) async fn handle_ui_run_command(
50    State(state): State<AppState>,
51    caller: Option<axum::Extension<crate::server::caller::Caller>>,
52    Json(req): Json<UiRunCommandRequest>,
53) -> impl IntoResponse {
54    let block_id = match target_block_id(&state, caller.as_deref(), &req.auth, None) {
55        Ok(b) => b,
56        Err((code, e)) => return err_response(code, e),
57    };
58    if let Some(why) = refused_command(&req.command) {
59        return err_response(StatusCode::FORBIDDEN, format!("{} is not available to agents: {why}", req.command));
60    }
61    if req.command == "pane:close" {
62        return close_pane(&state, &req, &block_id).await;
63    }
64    tracing::info!(agent_id = %req.auth.agent_id, block_id = %block_id, command = %req.command, "[ui-automation] run command");
65    proxy_data(
66        &state,
67        "run_command",
68        json!({ "block_id": block_id, "command": req.command, "target": req.target }),
69        std::time::Duration::from_secs(10),
70    )
71    .await
72}
73
74/// `POST /api/v1/ui/shortcuts/press` — backs `PressKeys`.
75pub(crate) async fn handle_ui_press_keys(
76    State(state): State<AppState>,
77    caller: Option<axum::Extension<crate::server::caller::Caller>>,
78    Json(req): Json<UiPressKeysRequest>,
79) -> impl IntoResponse {
80    let block_id = match target_block_id(&state, caller.as_deref(), &req.auth, None) {
81        Ok(b) => b,
82        Err((code, e)) => return err_response(code, e),
83    };
84    tracing::info!(agent_id = %req.auth.agent_id, block_id = %block_id, keys = %req.keys, "[ui-automation] press keys");
85    proxy_data(
86        &state,
87        "press_keys",
88        json!({ "block_id": block_id, "keys": req.keys, "target": req.target }),
89        std::time::Duration::from_secs(10),
90    )
91    .await
92}
93
94/// RunCommand `pane:close`: the pane named by `target`, closed the way
95/// ClosePane closes it (`app_api::pane::close_pane_as`). A pane the agent is
96/// in closes at once; another agent's pane waits 15 s for its user to keep
97/// it, and the answer says so. The key itself closes the focused pane at
98/// once, with no undo, so the page never runs it for MuxAPI.
99async fn close_pane(state: &AppState, req: &UiRunCommandRequest, own_block_id: &str) -> axum::response::Response {
100    let Some(target) = req.target.as_deref().map(str::trim).filter(|t| !t.is_empty()) else {
101        return err_response(
102            StatusCode::BAD_REQUEST,
103            "pane:close needs `target`, the pane to close (another agent's pane gets the user's 15-second undo)".to_string(),
104        );
105    };
106    let (code, body) = crate::server::app_api::pane::close_pane_as(
107        state,
108        &req.auth.agent_id,
109        own_block_id,
110        target,
111        Some("RunCommand pane:close"),
112        "RunCommand",
113    )
114    .await;
115    match code {
116        StatusCode::OK => Json(json!({ "ok": true, "data": { "ran": true, "closed": body } })).into_response(),
117        StatusCode::ACCEPTED => Json(json!({ "ok": true, "data": {
118            "ran": false,
119            "pending": body,
120            "reason": "it's another agent's pane: its user has 15 seconds to keep it, and it closes after that unless they do",
121        } }))
122        .into_response(),
123        _ => err_response(code, body.get("error").and_then(|e| e.as_str()).unwrap_or("close failed").to_string()),
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::refused_command;
130
131    #[test]
132    fn refuses_only_the_commands_the_owner_ruled_out() {
133        assert!(refused_command("files:deletePermanently").is_some());
134        assert!(refused_command("pane:close").is_none(), "pane:close goes through ClosePane's undo");
135        for allowed in ["tab:close", "split:right", "pane:replaceWithLauncher", "files:trash"] {
136            assert!(refused_command(allowed).is_none(), "{allowed} keeps its own confirmation or undo");
137        }
138    }
139}