diff --git a/README.md b/README.md index 9d6ca60e..75d1e1ec 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/SKILL.md b/SKILL.md index 0b0efe2e..d2ada62d 100644 --- a/SKILL.md +++ b/SKILL.md @@ -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. diff --git a/SOCKET_API.md b/SOCKET_API.md index 2739b96d..9a74e3ad 100644 --- a/SOCKET_API.md +++ b/SOCKET_API.md @@ -844,9 +844,11 @@ herdr wait agent-status --status [ - `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 diff --git a/src/api/schema.rs b/src/api/schema.rs index 42a9c259..38393141 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -405,12 +405,21 @@ pub enum ResponseResult { WorkspaceInfo { workspace: WorkspaceInfo, }, + WorkspaceCreated { + workspace: WorkspaceInfo, + tab: TabInfo, + root_pane: PaneInfo, + }, WorkspaceList { workspaces: Vec, }, TabInfo { tab: TabInfo, }, + TabCreated { + tab: TabInfo, + root_pane: PaneInfo, + }, TabList { tabs: Vec, }, @@ -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 { diff --git a/src/app/mod.rs b/src/app/mod.rs index 8d089a9d..9393521a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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 { + 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 { + 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 { + 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(); diff --git a/src/workspace.rs b/src/workspace.rs index 447e6061..5fabe47f 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -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 { diff --git a/tests/api_ping.rs b/tests/api_ping.rs index d5fa190c..c513fa4f 100644 --- a/tests/api_ping.rs +++ b/tests/api_ping.rs @@ -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,