1use 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
20fn refused_command(command: &str) -> Option<&'static str> {
29 match command {
30 "files:deletePermanently" => Some("it can't be undone"),
31 _ => None,
32 }
33}
34
35pub(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
48pub(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
74pub(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
94async 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}