feat(api): return root panes from workspace and tab creation

This commit is contained in:
Ogulcan Celik 2026-04-09 21:57:28 +03:00
parent a6cbc97651
commit c45a3b2dc1
7 changed files with 243 additions and 47 deletions

View File

@ -310,7 +310,7 @@ the heuristics are matched against each agent's real terminal output: prompt box
herdr's workspace, tab, pane, and wait commands are documented in [`SOCKET_API.md`](./SOCKET_API.md) together with the socket methods they wrap.
both `workspace create` and `tab create` support optional `--label` flags, so scripts and agents can name contexts immediately instead of renaming them after creation.
both `workspace create` and `tab create` support optional `--label` flags, so scripts and agents can name contexts immediately instead of renaming them after creation. both create commands also return the created root pane in their json response, so clients can act on the new pane without an extra lookup.
workspace ids are compact public ids like `1`, `2`, `3`.
tab ids are compact public ids like `1:1`, `1:2`, `2:1`.

View File

@ -289,7 +289,7 @@ herdr pane read 1-1 --source recent --lines 100
- `pane read` prints text, not json.
- `pane read --source recent-unwrapped` is useful when you want to inspect the same unwrapped transcript that `wait output --source recent` matches against.
- `pane send-text`, `pane send-keys`, and `pane run` print nothing on success.
- parse ids from `workspace create`, `tab create`, and `pane split` responses when you need new ids. for `pane split`, the new pane id is at `result.pane.pane_id`.
- parse ids from `workspace create`, `tab create`, and `pane split` responses when you need new ids. `workspace create` returns `result.workspace`, `result.tab`, and `result.root_pane`. `tab create` returns `result.tab` and `result.root_pane`. for `pane split`, the new pane id is at `result.pane.pane_id`.
- use `pane read` for current output that already exists. use `wait output` for future output you expect next.
- `--no-focus` on split, tab create, and workspace create keeps your current terminal context focused.
- without `--label`, workspace create keeps cwd-based naming and tab create keeps numbered naming.

View File

@ -844,9 +844,11 @@ herdr wait agent-status <pane_id> --status <idle|working|blocked|done|unknown> [
- `workspace create` focuses by default; pass `--no-focus` to keep focus where it is
- `workspace create` without `--label` keeps the default cwd-based workspace naming
- `workspace create --label` applies the custom workspace name immediately
- `workspace create` returns `result.workspace`, `result.tab`, and `result.root_pane`
- `tab create` focuses by default; pass `--no-focus` to keep focus where it is
- `tab create` without `--label` keeps the default numbered tab naming
- `tab create --label` applies the custom tab name immediately
- `tab create` returns `result.tab` and `result.root_pane`
- `pane split` focuses the new pane by default; pass `--no-focus` to keep focus on the original pane
- `pane read` prints **text**, not json
- `pane read --source recent-unwrapped` returns recent terminal text with soft wraps joined back together

View File

@ -405,12 +405,21 @@ pub enum ResponseResult {
WorkspaceInfo {
workspace: WorkspaceInfo,
},
WorkspaceCreated {
workspace: WorkspaceInfo,
tab: TabInfo,
root_pane: PaneInfo,
},
WorkspaceList {
workspaces: Vec<WorkspaceInfo>,
},
TabInfo {
tab: TabInfo,
},
TabCreated {
tab: TabInfo,
root_pane: PaneInfo,
},
TabList {
tabs: Vec<TabInfo>,
},
@ -864,6 +873,40 @@ mod tests {
assert_eq!(restored, response);
}
#[test]
fn create_response_round_trips_with_root_pane() {
let response = SuccessResponse {
id: "req_2".into(),
result: ResponseResult::TabCreated {
tab: TabInfo {
tab_id: "w_1:2".into(),
workspace_id: "w_1".into(),
number: 2,
label: "review".into(),
focused: false,
pane_count: 1,
agent_status: AgentStatus::Unknown,
},
root_pane: PaneInfo {
pane_id: "w_1-3".into(),
workspace_id: "w_1".into(),
tab_id: "w_1:2".into(),
focused: false,
cwd: Some("/tmp/review".into()),
agent: None,
agent_status: AgentStatus::Unknown,
revision: 0,
},
},
};
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains("\"type\":\"tab_created\""));
assert!(json.contains("\"root_pane\""));
let restored: SuccessResponse = serde_json::from_str(&json).unwrap();
assert_eq!(restored, response);
}
#[test]
fn error_response_round_trips() {
let response = ErrorResponse {

View File

@ -1075,34 +1075,33 @@ impl App {
}
}
let workspace = self.workspace_info(index);
let tab = self
.tab_info(index, 0)
.expect("new workspace should have an initial tab");
let root_pane = self
.root_pane_info(index, 0)
.expect("new workspace should have an initial root pane");
self.emit_event(crate::api::schema::EventEnvelope {
event: crate::api::schema::EventKind::WorkspaceCreated,
data: crate::api::schema::EventData::WorkspaceCreated {
workspace: workspace.clone(),
},
});
if let Some(tab) = self.tab_info(index, 0) {
self.emit_event(crate::api::schema::EventEnvelope {
event: crate::api::schema::EventKind::TabCreated,
data: crate::api::schema::EventData::TabCreated { tab },
});
}
if let Some(pane_id) = self.state.workspaces[index]
.layout
.pane_ids()
.first()
.copied()
{
if let Some(pane) = self.pane_info(index, pane_id) {
self.emit_event(crate::api::schema::EventEnvelope {
event: crate::api::schema::EventKind::PaneCreated,
data: crate::api::schema::EventData::PaneCreated { pane },
});
}
}
self.emit_event(crate::api::schema::EventEnvelope {
event: crate::api::schema::EventKind::TabCreated,
data: crate::api::schema::EventData::TabCreated { tab: tab.clone() },
});
self.emit_event(crate::api::schema::EventEnvelope {
event: crate::api::schema::EventKind::PaneCreated,
data: crate::api::schema::EventData::PaneCreated {
pane: root_pane.clone(),
},
});
SuccessResponse {
id: request.id,
result: ResponseResult::WorkspaceInfo { workspace },
result: self
.workspace_created_result(index)
.expect("new workspace should produce a complete create response"),
}
}
Err(err) => {
@ -1360,28 +1359,24 @@ impl App {
}
self.schedule_session_save();
let tab = self.tab_info(ws_idx, tab_idx).unwrap();
if let Some(pane_id) = self.state.workspaces[ws_idx].tabs[tab_idx]
.layout
.pane_ids()
.first()
.copied()
{
self.emit_event(crate::api::schema::EventEnvelope {
event: crate::api::schema::EventKind::TabCreated,
data: crate::api::schema::EventData::TabCreated {
tab: tab.clone(),
},
});
if let Some(pane) = self.pane_info(ws_idx, pane_id) {
self.emit_event(crate::api::schema::EventEnvelope {
event: crate::api::schema::EventKind::PaneCreated,
data: crate::api::schema::EventData::PaneCreated { pane },
});
}
}
let root_pane = self
.root_pane_info(ws_idx, tab_idx)
.expect("new tab should have a root pane");
self.emit_event(crate::api::schema::EventEnvelope {
event: crate::api::schema::EventKind::TabCreated,
data: crate::api::schema::EventData::TabCreated { tab: tab.clone() },
});
self.emit_event(crate::api::schema::EventEnvelope {
event: crate::api::schema::EventKind::PaneCreated,
data: crate::api::schema::EventData::PaneCreated {
pane: root_pane.clone(),
},
});
SuccessResponse {
id: request.id,
result: ResponseResult::TabInfo { tab },
result: self
.tab_created_result(ws_idx, tab_idx)
.expect("new tab should produce a complete create response"),
}
}
Err(err) => {
@ -2279,6 +2274,38 @@ impl App {
})
}
fn workspace_created_result(
&self,
ws_idx: usize,
) -> Option<crate::api::schema::ResponseResult> {
Some(crate::api::schema::ResponseResult::WorkspaceCreated {
workspace: self.workspace_info(ws_idx),
tab: self.tab_info(ws_idx, 0)?,
root_pane: self.root_pane_info(ws_idx, 0)?,
})
}
fn tab_created_result(
&self,
ws_idx: usize,
tab_idx: usize,
) -> Option<crate::api::schema::ResponseResult> {
Some(crate::api::schema::ResponseResult::TabCreated {
tab: self.tab_info(ws_idx, tab_idx)?,
root_pane: self.root_pane_info(ws_idx, tab_idx)?,
})
}
fn root_pane_info(
&self,
ws_idx: usize,
tab_idx: usize,
) -> Option<crate::api::schema::PaneInfo> {
let ws = self.state.workspaces.get(ws_idx)?;
let tab = ws.tabs.get(tab_idx)?;
self.pane_info(ws_idx, tab.root_pane)
}
fn pane_info(
&self,
ws_idx: usize,
@ -2689,6 +2716,48 @@ mod tests {
assert!(api_request_changes_ui(&mutating));
}
#[test]
fn workspace_create_response_includes_initial_tab_and_root_pane() {
let mut app = test_app();
app.state.workspaces = vec![Workspace::test_new("api-root-pane")];
app.state.active = Some(0);
app.state.selected = 0;
let crate::api::schema::ResponseResult::WorkspaceCreated {
workspace,
tab,
root_pane,
} = app.workspace_created_result(0).unwrap()
else {
panic!("expected workspace_created response");
};
assert_eq!(workspace.label, "api-root-pane");
assert_eq!(tab.workspace_id, workspace.workspace_id);
assert_eq!(root_pane.workspace_id, workspace.workspace_id);
assert_eq!(root_pane.tab_id, tab.tab_id);
}
#[test]
fn tab_create_response_includes_root_pane() {
let mut app = test_app();
let mut workspace = Workspace::test_new("api-tab-root-pane");
workspace.test_add_tab(None);
app.state.workspaces = vec![workspace];
app.state.active = Some(0);
app.state.selected = 0;
let crate::api::schema::ResponseResult::TabCreated { tab, root_pane } =
app.tab_created_result(0, 1).unwrap()
else {
panic!("expected tab_created response");
};
assert_eq!(tab.workspace_id, root_pane.workspace_id);
assert_eq!(root_pane.tab_id, tab.tab_id);
assert_eq!(tab.pane_count, 1);
}
#[test]
fn workspace_creation_in_navigate_mode_uses_selected_workspace_seed_cwd() {
let mut app = test_app();

View File

@ -379,8 +379,7 @@ impl Workspace {
)?;
self.register_new_pane(tab.root_pane);
self.tabs.push(tab);
self.active_tab = self.tabs.len() - 1;
Ok(self.active_tab)
Ok(self.tabs.len() - 1)
}
pub fn close_tab(&mut self, idx: usize) -> bool {

View File

@ -238,7 +238,7 @@ fn workspace_list_and_create_round_trip() {
),
);
assert_eq!(created["id"], "req_3");
assert_eq!(created["result"]["type"], "workspace_info");
assert_eq!(created["result"]["type"], "workspace_created");
let workspace_id = created["result"]["workspace"]["workspace_id"]
.as_str()
.unwrap()
@ -247,9 +247,15 @@ fn workspace_list_and_create_round_trip() {
.as_str()
.unwrap()
.to_string();
let root_pane_id = created["result"]["root_pane"]["pane_id"]
.as_str()
.unwrap()
.to_string();
assert_eq!(created["result"]["workspace"]["number"], 1);
assert_eq!(created["result"]["workspace"]["focused"], true);
assert_eq!(created["result"]["workspace"]["tab_count"], 1);
assert_eq!(created["result"]["tab"]["tab_id"], active_tab_id);
assert_eq!(created["result"]["root_pane"]["tab_id"], active_tab_id);
assert_eq!(active_tab_id, format!("{workspace_id}:1"));
let listed = send_request(
@ -278,6 +284,7 @@ fn workspace_list_and_create_round_trip() {
assert_eq!(panes[0]["workspace_id"], workspace_id);
assert_eq!(panes[0]["tab_id"], active_tab_id);
let pane_id = panes[0]["pane_id"].as_str().unwrap().to_string();
assert_eq!(pane_id, root_pane_id);
let pane = send_request(
&socket_path,
@ -429,13 +436,18 @@ fn tab_methods_round_trip_over_socket() {
workspace_id
),
);
assert_eq!(tab_created["result"]["type"], "tab_info");
assert_eq!(tab_created["result"]["type"], "tab_created");
let second_tab_id = tab_created["result"]["tab"]["tab_id"]
.as_str()
.unwrap()
.to_string();
let second_root_pane_id = tab_created["result"]["root_pane"]["pane_id"]
.as_str()
.unwrap()
.to_string();
assert_eq!(second_tab_id, format!("{workspace_id}:2"));
assert_eq!(tab_created["result"]["tab"]["focused"], true);
assert_eq!(tab_created["result"]["root_pane"]["tab_id"], second_tab_id);
let tab_list = send_request(
&socket_path,
@ -447,6 +459,18 @@ fn tab_methods_round_trip_over_socket() {
let tabs = tab_list["result"]["tabs"].as_array().unwrap();
assert_eq!(tabs.len(), 2);
assert_eq!(tabs[0]["tab_id"], first_tab_id);
let panes = send_request(
&socket_path,
&format!(
r#"{{"id":"req_t3b","method":"pane.list","params":{{"workspace_id":"{}"}}}}"#,
workspace_id
),
);
let panes = panes["result"]["panes"].as_array().unwrap();
assert!(panes
.iter()
.any(|pane| pane["pane_id"] == second_root_pane_id));
assert_eq!(tabs[1]["tab_id"], second_tab_id);
let tab_get = send_request(
@ -489,6 +513,65 @@ fn tab_methods_round_trip_over_socket() {
cleanup_spawned_herdr(child, base);
}
#[cfg(not(target_os = "macos"))]
#[test]
fn tab_create_with_no_focus_preserves_active_tab() {
let _lock = test_lock();
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let child = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
let created = send_request(
&socket_path,
&format!(
r#"{{"id":"req_nf_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#,
base.display()
),
);
let workspace_id = created["result"]["workspace"]["workspace_id"]
.as_str()
.unwrap()
.to_string();
let first_tab_id = created["result"]["tab"]["tab_id"]
.as_str()
.unwrap()
.to_string();
let tab_created = send_request(
&socket_path,
&format!(
r#"{{"id":"req_nf_2","method":"tab.create","params":{{"workspace_id":"{}","focus":false}}}}"#,
workspace_id
),
);
assert_eq!(tab_created["result"]["type"], "tab_created");
let second_tab_id = tab_created["result"]["tab"]["tab_id"]
.as_str()
.unwrap()
.to_string();
assert_eq!(second_tab_id, format!("{workspace_id}:2"));
assert_eq!(tab_created["result"]["tab"]["focused"], false);
let tab_list = send_request(
&socket_path,
&format!(
r#"{{"id":"req_nf_3","method":"tab.list","params":{{"workspace_id":"{}"}}}}"#,
workspace_id
),
);
let tabs = tab_list["result"]["tabs"].as_array().unwrap();
assert_eq!(tabs[0]["tab_id"], first_tab_id);
assert_eq!(tabs[0]["focused"], true);
assert_eq!(tabs[1]["tab_id"], second_tab_id);
assert_eq!(tabs[1]["focused"], false);
cleanup_spawned_herdr(child, base);
}
#[cfg(not(target_os = "macos"))]
#[test]
fn events_subscribe_streams_workspace_tab_and_agent_events() {
@ -1314,7 +1397,7 @@ fn pane_info_and_subscriptions_expose_done_agent_status() {
workspace_id
),
);
assert_eq!(tab_created["result"]["type"], "tab_info");
assert_eq!(tab_created["result"]["type"], "tab_created");
let mut reader = open_subscription(
&socket_path,