Skip to main content

agentmux_srv/server/
http_naming.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Window, tab, pane and workspace naming.
5//! Split out of server/mod.rs unchanged (SPEC_LARGE_FILE_MODULE_ANALYSIS_2026_09_30.md §4.1).
6
7use super::*;
8
9/// `POST /api/v1/window/name` — set a window's display name
10/// (`window:displayname`), which the frontend turns into the OS/taskbar title.
11/// Defaults to the caller's own window (resolved from `block_id`). Routes
12/// through the same `object.UpdateObjectMeta` service path the InstancePanel
13/// rename uses, so persistence + live-title update are identical.
14/// agentmux-mcp's `SetWindowName` tool POSTs here.
15pub(super) async fn handle_window_name(
16    State(state): State<AppState>,
17    Json(req): Json<WindowNameRequest>,
18) -> impl IntoResponse {
19    // window:displayname is documented as ≤64 chars (window-title.ts).
20    let name: String = req.name.trim().chars().take(64).collect();
21    if name.is_empty() {
22        return (StatusCode::BAD_REQUEST, Json(json!({ "error": "name must not be empty" }))).into_response();
23    }
24
25    let window_id = match req.window_id.filter(|w| !w.is_empty()) {
26        Some(w) => w,
27        None => {
28            let block_id = req.block_id.unwrap_or_default();
29            if block_id.is_empty() {
30                return (StatusCode::BAD_REQUEST, Json(json!({ "error": "provide window_id or block_id" }))).into_response();
31            }
32            match service::resolve_agent_context(&state.mstore, &block_id) {
33                Ok(ctx) => match ctx.window_id {
34                    Some(w) => w,
35                    None => {
36                        return (
37                            StatusCode::NOT_FOUND,
38                            Json(json!({ "error": "no live window for this agent (tab not attached to a window)" })),
39                        )
40                            .into_response()
41                    }
42                },
43                Err(e) => return (StatusCode::NOT_FOUND, Json(json!({ "error": e }))).into_response(),
44            }
45        }
46    };
47
48    let call = crate::backend::service::WebCallType {
49        service: "object".to_string(),
50        method: "UpdateObjectMeta".to_string(),
51        uicontext: None,
52        args: vec![
53            json!(format!("window:{window_id}")),
54            json!({ "window:displayname": name }),
55        ],
56    };
57    finish_name_call(
58        &state,
59        call,
60        json!({ "success": true, "window_id": window_id, "name": name }),
61    )
62    .await
63}
64
65/// Trim a user-supplied name and clamp to `max` chars; `None` if empty.
66pub(super) fn clean_name(raw: &str, max: usize) -> Option<String> {
67    let n: String = raw.trim().chars().take(max).collect();
68    if n.is_empty() {
69        None
70    } else {
71        Some(n)
72    }
73}
74
75/// `POST /api/v1/tab/name` — rename a tab. Defaults to the caller's own tab
76/// (resolved from `block_id`). Routes through `object.UpdateTabName`.
77pub(super) async fn handle_tab_name(
78    State(state): State<AppState>,
79    Json(req): Json<TabNameRequest>,
80) -> impl IntoResponse {
81    let name = match clean_name(&req.name, 128) {
82        Some(n) => n,
83        None => return (StatusCode::BAD_REQUEST, Json(json!({ "error": "name must not be empty" }))).into_response(),
84    };
85    let tab_id = match req.tab_id.filter(|t| !t.is_empty()) {
86        Some(t) => t,
87        None => match resolve_own(&state, req.block_id, |c| Some(c.tab_id.clone())) {
88            Ok(t) => t,
89            Err(resp) => return resp,
90        },
91    };
92    let call = crate::backend::service::WebCallType {
93        service: "object".to_string(),
94        method: "UpdateTabName".to_string(),
95        uicontext: None,
96        args: vec![json!(tab_id), json!(name)],
97    };
98    finish_name_call(&state, call, json!({ "success": true, "tab_id": tab_id, "name": name })).await
99}
100
101/// `POST /api/v1/pane/title` — set a pane's display title (`frame:title`).
102/// Targets the caller's own pane (its `block_id`). Routes through
103/// `object.UpdateObjectMeta`.
104pub(super) async fn handle_pane_title(
105    State(state): State<AppState>,
106    Json(req): Json<PaneTitleRequest>,
107) -> impl IntoResponse {
108    let title = match clean_name(&req.title, 128) {
109        Some(t) => t,
110        None => return (StatusCode::BAD_REQUEST, Json(json!({ "error": "title must not be empty" }))).into_response(),
111    };
112    let block_id = match req.block_id.filter(|b| !b.is_empty()) {
113        Some(b) => b,
114        None => return (StatusCode::BAD_REQUEST, Json(json!({ "error": "missing block_id" }))).into_response(),
115    };
116    let call = crate::backend::service::WebCallType {
117        service: "object".to_string(),
118        method: "UpdateObjectMeta".to_string(),
119        uicontext: None,
120        args: vec![
121            json!(format!("block:{block_id}")),
122            json!({ "frame:title": title }),
123        ],
124    };
125    finish_name_call(&state, call, json!({ "success": true, "block_id": block_id, "title": title })).await
126}
127
128/// `POST /api/v1/workspace/name` — rename a workspace. Defaults to the
129/// caller's own workspace (resolved from `block_id`). Routes through
130/// `workspace.UpdateWorkspace`.
131pub(super) async fn handle_workspace_name(
132    State(state): State<AppState>,
133    Json(req): Json<WorkspaceNameRequest>,
134) -> impl IntoResponse {
135    let name = match clean_name(&req.name, 128) {
136        Some(n) => n,
137        None => return (StatusCode::BAD_REQUEST, Json(json!({ "error": "name must not be empty" }))).into_response(),
138    };
139    let workspace_id = match req.workspace_id.filter(|w| !w.is_empty()) {
140        Some(w) => w,
141        None => match resolve_own(&state, req.block_id, |c| c.workspace_id.clone()) {
142            Ok(w) => w,
143            Err(resp) => return resp,
144        },
145    };
146    let call = crate::backend::service::WebCallType {
147        service: "workspace".to_string(),
148        method: "UpdateWorkspace".to_string(),
149        uicontext: None,
150        args: vec![json!(workspace_id), json!(name)],
151    };
152    finish_name_call(&state, call, json!({ "success": true, "workspace_id": workspace_id, "name": name })).await
153}
154
155/// Resolve a target id from the caller's own context (via `block_id`), using
156/// `pick` to select the field. Returns the route's error response on failure
157/// (missing block_id, unresolvable block, or the field is `None`).
158pub(super) fn resolve_own(
159    state: &AppState,
160    block_id: Option<String>,
161    pick: impl Fn(&service::AgentContext) -> Option<String>,
162) -> Result<String, Response> {
163    let block_id = block_id.unwrap_or_default();
164    if block_id.is_empty() {
165        return Err((StatusCode::BAD_REQUEST, Json(json!({ "error": "provide an explicit target id or block_id" }))).into_response());
166    }
167    let ctx = service::resolve_agent_context(&state.mstore, &block_id)
168        .map_err(|e| (StatusCode::NOT_FOUND, Json(json!({ "error": e }))).into_response())?;
169    pick(&ctx).filter(|s| !s.is_empty()).ok_or_else(|| {
170        (
171            StatusCode::NOT_FOUND,
172            Json(json!({ "error": "no such target resolved for this agent" })),
173        )
174            .into_response()
175    })
176}
177
178/// Run a naming service call and map the result to a JSON HTTP response.
179pub(super) async fn finish_name_call(
180    state: &AppState,
181    call: crate::backend::service::WebCallType,
182    ok_body: serde_json::Value,
183) -> Response {
184    let result = service::run_service_call(state, &call).await;
185    if let Some(err) = result.error {
186        return (app_api_error_status(&err), Json(json!({ "error": err }))).into_response();
187    }
188    Json(ok_body).into_response()
189}