diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-29 19:54:52 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-29 19:54:52 +0300 |
| commit | bf6b6d779a63d8952d8db0a7de5daa3454984620 (patch) | |
| tree | 06776548463ce0b70177f85ace997d6e33f07533 | |
| parent | a5847a8039d2754dd2908a13e4625b31f7404d9a (diff) | |
feat(api): use compact public workspace and pane ids
| -rw-r--r-- | README.md | 5 | ||||
| -rw-r--r-- | SKILL.md | 66 | ||||
| -rw-r--r-- | SOCKET_API.md | 46 | ||||
| -rw-r--r-- | src/app/mod.rs | 190 | ||||
| -rw-r--r-- | src/cli.rs | 14 | ||||
| -rw-r--r-- | src/persist.rs | 9 | ||||
| -rw-r--r-- | src/workspace.rs | 30 | ||||
| -rw-r--r-- | tests/api_ping.rs | 12 | ||||
| -rw-r--r-- | tests/cli_wrapper.rs | 102 |
9 files changed, 314 insertions, 160 deletions
@@ -231,7 +231,10 @@ herdr --no-session start without restoring or saving sessio herdr --help show help ``` -pane ids can be passed either as socket ids like `p_1_1` or in a shorter human form like `1-1`. +workspace ids are compact public ids like `1`, `2`, `3`. +pane ids are compact public ids like `1-1`, `1-2`, `2-1`. + +they are positional within the current live session, so numbering compacts when workspaces or panes are closed. these commands are thin wrappers over the socket API: - [`SOCKET_API.md`](./SOCKET_API.md) @@ -24,7 +24,7 @@ the `herdr` binary is available in your PATH. all commands talk to the running h - `waiting` — agent needs user input - `unknown` — no recognized agent, or just a shell -**ids** — workspace ids look like `w_1`, `w_2`. pane ids look like `p_1_1`, `p_1_5` (workspace number + pane number). you can also use short human form: `1` for workspace, `1-1` for pane. +**ids** — workspace ids look like `1`, `2`. pane ids look like `1-1`, `1-2`, `2-1`. these are the public ids you should use. ## discover yourself @@ -36,8 +36,8 @@ herdr pane list ```json {"id":"cli:pane:list","result":{"panes":[ - {"agent":"claude","agent_state":"waiting","cwd":"/home/user/project","focused":true,"pane_id":"p_1_5","workspace_id":"w_1"}, - {"agent":"pi","agent_state":"idle","cwd":"/home/user/project","focused":false,"pane_id":"p_1_1","workspace_id":"w_1"} + {"agent":"claude","agent_state":"waiting","cwd":"/home/user/project","focused":true,"pane_id":"1-2","workspace_id":"1"}, + {"agent":"pi","agent_state":"idle","cwd":"/home/user/project","focused":false,"pane_id":"1-1","workspace_id":"1"} ],"type":"pane_list"}} ``` @@ -49,7 +49,7 @@ herdr workspace list ```json {"id":"cli:workspace:list","result":{"type":"workspace_list","workspaces":[ - {"workspace_id":"w_1","number":1,"label":"project","focused":true,"pane_count":2,"agent_state":"waiting"} + {"workspace_id":"1","number":1,"label":"project","focused":true,"pane_count":2,"agent_state":"waiting"} ]}} ``` @@ -58,7 +58,7 @@ herdr workspace list see what's on another pane's screen: ```bash -herdr pane read p_1_1 --source recent --lines 50 +herdr pane read 1-1 --source recent --lines 50 ``` `--source visible` shows the current viewport. `--source recent` shows recent scrollback (default). @@ -68,13 +68,13 @@ herdr pane read p_1_1 --source recent --lines 50 split your pane to the right and run a dev server: ```bash -herdr pane split p_1_5 --direction right --no-focus +herdr pane split 1-2 --direction right --no-focus ``` this returns the new pane's id. then run a command in it: ```bash -herdr pane run p_1_7 "npm run dev" +herdr pane run 1-3 "npm run dev" ``` `pane run` sends the text and presses Enter. use `--no-focus` on split to keep focus on your pane. @@ -82,7 +82,7 @@ herdr pane run p_1_7 "npm run dev" split downward instead: ```bash -herdr pane split p_1_5 --direction down --no-focus +herdr pane split 1-2 --direction down --no-focus ``` ## wait for output @@ -90,13 +90,13 @@ herdr pane split p_1_5 --direction down --no-focus block until specific text appears in a pane. useful for waiting on servers, builds, tests: ```bash -herdr wait output p_1_7 --match "ready on port 3000" --timeout 30000 +herdr wait output 1-3 --match "ready on port 3000" --timeout 30000 ``` with regex: ```bash -herdr wait output p_1_7 --match "server.*ready" --regex --timeout 30000 +herdr wait output 1-3 --match "server.*ready" --regex --timeout 30000 ``` timeout is in milliseconds. if it times out, exit code is 1. @@ -106,7 +106,7 @@ timeout is in milliseconds. if it times out, exit code is 1. block until another agent reaches a specific state: ```bash -herdr wait agent-state p_1_1 --state idle --timeout 60000 +herdr wait agent-state 1-1 --state idle --timeout 60000 ``` useful when you need another agent to finish before you proceed. @@ -116,19 +116,19 @@ useful when you need another agent to finish before you proceed. send text without pressing Enter: ```bash -herdr pane send-text p_1_1 "hello from claude" +herdr pane send-text 1-1 "hello from claude" ``` press Enter (or other keys): ```bash -herdr pane send-keys p_1_1 Enter +herdr pane send-keys 1-1 Enter ``` `pane run` combines both — sends text then Enter: ```bash -herdr pane run p_1_1 "echo hello" +herdr pane run 1-1 "echo hello" ``` ## workspace management @@ -146,25 +146,25 @@ herdr workspace create --no-focus focus a workspace: ```bash -herdr workspace focus w_2 +herdr workspace focus 2 ``` rename: ```bash -herdr workspace rename w_1 "api server" +herdr workspace rename 1 "api server" ``` close: ```bash -herdr workspace close w_2 +herdr workspace close 2 ``` ## close a pane ```bash -herdr pane close p_1_7 +herdr pane close 1-3 ``` ## recipes @@ -173,14 +173,14 @@ herdr pane close p_1_7 ```bash # split a pane for the server -herdr pane split p_1_5 --direction right --no-focus -# assume new pane is p_1_7 from the response +herdr pane split 1-2 --direction right --no-focus +# assume new pane is 1-3 from the response # start the server -herdr pane run p_1_7 "npm run dev" +herdr pane run 1-3 "npm run dev" # wait until it's ready -herdr wait output p_1_7 --match "ready" --timeout 30000 +herdr wait output 1-3 --match "ready" --timeout 30000 # now read the output to confirm herdr pane read p_1_7 --source recent --lines 20 @@ -189,9 +189,9 @@ herdr pane read p_1_7 --source recent --lines 20 ### run tests in a separate pane and check results ```bash -herdr pane split p_1_5 --direction down --no-focus -herdr pane run p_1_7 "cargo test" -herdr wait output p_1_7 --match "test result" --timeout 60000 +herdr pane split 1-2 --direction down --no-focus +herdr pane run 1-3 "cargo test" +herdr wait output 1-3 --match "test result" --timeout 60000 herdr pane read p_1_7 --source recent --lines 30 ``` @@ -199,28 +199,28 @@ herdr pane read p_1_7 --source recent --lines 30 ```bash herdr pane list -herdr pane read p_1_1 --source recent --lines 80 +herdr pane read 1-1 --source recent --lines 80 ``` ### spawn a new agent and give it a task ```bash -herdr pane split p_1_5 --direction right --no-focus -herdr pane run p_1_7 "claude" +herdr pane split 1-2 --direction right --no-focus +herdr pane run 1-3 "claude" # wait for it to start -herdr wait output p_1_7 --match ">" --timeout 15000 +herdr wait output 1-3 --match ">" --timeout 15000 # send it a task -herdr pane run p_1_7 "review the test coverage in src/api/" +herdr pane run 1-3 "review the test coverage in src/api/" ``` ### coordinate with another agent ```bash # wait for the other agent to finish its current work -herdr wait agent-state p_1_1 --state idle --timeout 120000 +herdr wait agent-state 1-1 --state idle --timeout 120000 # read what it produced -herdr pane read p_1_1 --source recent --lines 100 +herdr pane read 1-1 --source recent --lines 100 ``` ## notes @@ -229,4 +229,4 @@ herdr pane read p_1_1 --source recent --lines 100 - `pane run` = `pane send-text` + `pane send-keys Enter`. use the separate commands when you need finer control. - pane ids from split responses are the source of truth — don't guess pane ids, read them from the response. - `--no-focus` on split keeps your terminal focused. without it, herdr focuses the new pane. -- if you're running inside herdr, the `HERDR_ENV` environment variable is set to `1`. +- if you're running inside herdr, the `HERDR_ENV` environment variable is set to `1`. ids are compact and can change when panes/workspaces are closed. diff --git a/SOCKET_API.md b/SOCKET_API.md index 13ddb6d3..bccfd3c6 100644 --- a/SOCKET_API.md +++ b/SOCKET_API.md @@ -51,7 +51,7 @@ error responses: "id": "req_1", "error": { "code": "pane_not_found", - "message": "pane p_1_99 not found" + "message": "pane 1-99 not found" } } ``` @@ -60,18 +60,20 @@ error responses: workspace ids look like: -- `w_1` -- `w_2` +- `1` +- `2` pane ids look like: -- `p_1_1` -- `p_1_2` -- `p_2_1` +- `1-1` +- `1-2` +- `2-1` that means: -- first number = workspace number -- second number = pane id inside that workspace +- first number = current workspace number +- second number = current pane number within that workspace + +these are compact public ids for the current live session. if a workspace or pane is closed, numbering compacts. ## core request methods @@ -122,7 +124,7 @@ example response: "result": { "type": "workspace_info", "workspace": { - "workspace_id": "w_1", + "workspace_id": "1", "number": 1, "label": "herdr", "focused": true, @@ -140,7 +142,7 @@ example response: "id": "req_read", "method": "pane.read", "params": { - "pane_id": "p_1_1", + "pane_id": "1-1", "source": "recent", "lines": 80 } @@ -160,7 +162,7 @@ low-level input is intentionally explicit: "id": "req_send_text", "method": "pane.send_text", "params": { - "pane_id": "p_1_1", + "pane_id": "1-1", "text": "bun run dev" } } @@ -173,7 +175,7 @@ then: "id": "req_send_keys", "method": "pane.send_keys", "params": { - "pane_id": "p_1_1", + "pane_id": "1-1", "keys": ["Enter"] } } @@ -190,7 +192,7 @@ a future CLI wrapper will likely offer a more ergonomic `pane run` style command "id": "req_wait", "method": "pane.wait_for_output", "params": { - "pane_id": "p_1_1", + "pane_id": "1-1", "source": "recent", "lines": 200, "match": { "type": "substring", "value": "ready" }, @@ -268,7 +270,7 @@ example pushed event: "event": "workspace_created", "data": { "workspace": { - "workspace_id": "w_1", + "workspace_id": "1", "number": 1, "label": "herdr", "focused": true, @@ -289,14 +291,14 @@ example pushed event: "subscriptions": [ { "type": "pane.output_matched", - "pane_id": "p_1_1", + "pane_id": "1-1", "source": "recent", "lines": 200, "match": { "type": "substring", "value": "ready" } }, { "type": "pane.agent_state_changed", - "pane_id": "p_1_1", + "pane_id": "1-1", "state": "idle" } ] @@ -310,11 +312,11 @@ example pushed `pane.output_matched` event: { "event": "pane.output_matched", "data": { - "pane_id": "p_1_1", + "pane_id": "1-1", "matched_line": "server ready", "read": { - "pane_id": "p_1_1", - "workspace_id": "w_1", + "pane_id": "1-1", + "workspace_id": "1", "source": "recent", "text": "...server ready...", "revision": 0, @@ -330,8 +332,8 @@ example pushed `pane.agent_state_changed` event: { "event": "pane.agent_state_changed", "data": { - "pane_id": "p_1_1", - "workspace_id": "w_1", + "pane_id": "1-1", + "workspace_id": "1", "state": "idle", "agent": "pi" } @@ -371,4 +373,4 @@ current wrapper commands include: those commands sit on top of this socket surface rather than replacing it. -for convenience, the CLI accepts pane ids in either raw socket form (`p_1_1`) or short human form (`1-1`). +for convenience, the CLI accepts pane ids in either raw socket form (`1-1`) or short human form (`1-1`). diff --git a/src/app/mod.rs b/src/app/mod.rs index d5fa0ef2..ef424ed8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -211,13 +211,15 @@ impl App { match &ev { AppEvent::PaneDied { pane_id } => { if let Some((ws_idx, _)) = self.find_pane(*pane_id) { - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneExited, - data: crate::api::schema::EventData::PaneExited { - pane_id: format!("p_{}_{}", ws_idx + 1, pane_id.raw()), - workspace_id: format!("w_{}", ws_idx + 1), - }, - }); + if let Some(public_pane_id) = self.public_pane_id(ws_idx, *pane_id) { + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::PaneExited, + data: crate::api::schema::EventData::PaneExited { + pane_id: public_pane_id, + workspace_id: self.public_workspace_id(ws_idx), + }, + }); + } } } AppEvent::StateChanged { @@ -226,27 +228,28 @@ impl App { state, } => { if let Some((ws_idx, pane)) = self.find_pane(*pane_id) { - let pane_id = format!("p_{}_{}", ws_idx + 1, pane_id.raw()); - let workspace_id = format!("w_{}", ws_idx + 1); - if pane.detected_agent != *agent { - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneAgentDetected, - data: crate::api::schema::EventData::PaneAgentDetected { - pane_id: pane_id.clone(), - workspace_id: workspace_id.clone(), - agent: agent.map(agent_name), - }, - }); - } - if pane.state != *state { - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneAgentStateChanged, - data: crate::api::schema::EventData::PaneAgentStateChanged { - pane_id, - workspace_id, - state: pane_agent_state(*state), - }, - }); + if let Some(pane_id) = self.public_pane_id(ws_idx, *pane_id) { + let workspace_id = self.public_workspace_id(ws_idx); + if pane.detected_agent != *agent { + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::PaneAgentDetected, + data: crate::api::schema::EventData::PaneAgentDetected { + pane_id: pane_id.clone(), + workspace_id: workspace_id.clone(), + agent: agent.map(agent_name), + }, + }); + } + if pane.state != *state { + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::PaneAgentStateChanged, + data: crate::api::schema::EventData::PaneAgentStateChanged { + pane_id, + workspace_id, + state: pane_agent_state(*state), + }, + }); + } } } } @@ -286,16 +289,18 @@ impl App { self.emit_event(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::WorkspaceFocused, data: crate::api::schema::EventData::WorkspaceFocused { - workspace_id: format!("w_{}", ws_idx + 1), - }, - }); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneFocused, - data: crate::api::schema::EventData::PaneFocused { - pane_id: format!("p_{}_{}", ws_idx + 1, pane_id.raw()), - workspace_id: format!("w_{}", ws_idx + 1), + workspace_id: self.public_workspace_id(ws_idx), }, }); + if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) { + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::PaneFocused, + data: crate::api::schema::EventData::PaneFocused { + pane_id: public_pane_id, + workspace_id: self.public_workspace_id(ws_idx), + }, + }); + } } self.last_focus = current_focus; @@ -312,6 +317,42 @@ impl App { .find_map(|(ws_idx, ws)| ws.panes.get(&pane_id).map(|pane| (ws_idx, pane))) } + fn public_workspace_id(&self, ws_idx: usize) -> String { + (ws_idx + 1).to_string() + } + + fn public_pane_id(&self, ws_idx: usize, pane_id: crate::layout::PaneId) -> Option<String> { + let ws = self.state.workspaces.get(ws_idx)?; + let pane_number = ws.public_pane_number(pane_id)?; + Some(format!("{}-{pane_number}", ws_idx + 1)) + } + + fn parse_workspace_id(&self, id: &str) -> Option<usize> { + if let Some(raw) = id.strip_prefix("w_") { + return raw.parse::<usize>().ok()?.checked_sub(1); + } + id.parse::<usize>().ok()?.checked_sub(1) + } + + fn parse_pane_id(&self, id: &str) -> Option<(usize, crate::layout::PaneId)> { + if let Some(rest) = id.strip_prefix("p_") { + let (ws_raw, pane_raw) = rest.split_once('_')?; + let ws_idx = ws_raw.parse::<usize>().ok()?.checked_sub(1)?; + let pane_id = crate::layout::PaneId::from_raw(pane_raw.parse::<u32>().ok()?); + return Some((ws_idx, pane_id)); + } + + let (ws_raw, pane_number_raw) = id.split_once('-')?; + let ws_idx = ws_raw.parse::<usize>().ok()?.checked_sub(1)?; + let pane_number = pane_number_raw.parse::<usize>().ok()?; + let ws = self.state.workspaces.get(ws_idx)?; + let pane_id = ws + .public_pane_numbers + .iter() + .find_map(|(pane_id, number)| (*number == pane_number).then_some(*pane_id))?; + Some((ws_idx, pane_id)) + } + fn handle_api_request(&mut self, request: crate::api::schema::Request) -> String { self.drain_internal_events(); use bytes::Bytes; @@ -330,12 +371,12 @@ impl App { .workspaces .iter() .enumerate() - .map(|(idx, _)| workspace_info(&self.state, idx)) + .map(|(idx, _)| self.workspace_info(idx)) .collect(), }, }, Method::WorkspaceGet(target) => { - let Some(index) = parse_workspace_id(&target.workspace_id) else { + let Some(index) = self.parse_workspace_id(&target.workspace_id) else { return serde_json::to_string(&ErrorResponse { id: request.id, error: ErrorBody { @@ -358,7 +399,7 @@ impl App { SuccessResponse { id: request.id, result: ResponseResult::WorkspaceInfo { - workspace: workspace_info(&self.state, index), + workspace: self.workspace_info(index), }, } } @@ -370,7 +411,7 @@ impl App { .unwrap_or_else(|| std::path::PathBuf::from("/")); match self.create_workspace_with_options(cwd, params.focus) { Ok(index) => { - let workspace = workspace_info(&self.state, index); + let workspace = self.workspace_info(index); self.emit_event(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::WorkspaceCreated, data: crate::api::schema::EventData::WorkspaceCreated { @@ -408,7 +449,7 @@ impl App { } } Method::WorkspaceFocus(target) => { - let Some(index) = parse_workspace_id(&target.workspace_id) else { + let Some(index) = self.parse_workspace_id(&target.workspace_id) else { return serde_json::to_string(&ErrorResponse { id: request.id, error: ErrorBody { @@ -432,12 +473,12 @@ impl App { SuccessResponse { id: request.id, result: ResponseResult::WorkspaceInfo { - workspace: workspace_info(&self.state, index), + workspace: self.workspace_info(index), }, } } Method::WorkspaceRename(params) => { - let Some(index) = parse_workspace_id(¶ms.workspace_id) else { + let Some(index) = self.parse_workspace_id(¶ms.workspace_id) else { return serde_json::to_string(&ErrorResponse { id: request.id, error: ErrorBody { @@ -461,12 +502,12 @@ impl App { SuccessResponse { id: request.id, result: ResponseResult::WorkspaceInfo { - workspace: workspace_info(&self.state, index), + workspace: self.workspace_info(index), }, } } Method::WorkspaceClose(target) => { - let Some(index) = parse_workspace_id(&target.workspace_id) else { + let Some(index) = self.parse_workspace_id(&target.workspace_id) else { return serde_json::to_string(&ErrorResponse { id: request.id, error: ErrorBody { @@ -500,7 +541,8 @@ impl App { } } Method::PaneSplit(params) => { - let Some((ws_idx, target_pane_id)) = parse_pane_id(¶ms.target_pane_id) else { + let Some((ws_idx, target_pane_id)) = self.parse_pane_id(¶ms.target_pane_id) + else { return serde_json::to_string(&ErrorResponse { id: request.id, error: ErrorBody { @@ -577,7 +619,7 @@ impl App { } } Method::PaneGet(target) => { - let Some((ws_idx, pane_id)) = parse_pane_id(&target.pane_id) else { + let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { return serde_json::to_string(&ErrorResponse { id: request.id, error: ErrorBody { @@ -603,7 +645,7 @@ impl App { } } Method::PaneRead(params) => { - let Some((ws_idx, pane_id)) = parse_pane_id(¶ms.pane_id) else { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return serde_json::to_string(&ErrorResponse { id: request.id, error: ErrorBody { @@ -643,7 +685,7 @@ impl App { } } Method::PaneSendText(params) => { - let Some((ws_idx, pane_id)) = parse_pane_id(¶ms.pane_id) else { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return serde_json::to_string(&ErrorResponse { id: request.id, error: ErrorBody { @@ -679,7 +721,7 @@ impl App { } } Method::PaneClose(target) => { - let Some((ws_idx, pane_id)) = parse_pane_id(&target.pane_id) else { + let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { return serde_json::to_string(&ErrorResponse { id: request.id, error: ErrorBody { @@ -731,7 +773,7 @@ impl App { } } Method::PaneSendKeys(params) => { - let Some((ws_idx, pane_id)) = parse_pane_id(¶ms.pane_id) else { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return serde_json::to_string(&ErrorResponse { id: request.id, error: ErrorBody { @@ -861,7 +903,7 @@ impl App { workspace_id: Option<&str>, ) -> Result<Vec<crate::api::schema::PaneInfo>, (String, String)> { if let Some(workspace_id) = workspace_id { - let Some(ws_idx) = parse_workspace_id(workspace_id) else { + let Some(ws_idx) = self.parse_workspace_id(workspace_id) else { return Err(( "workspace_not_found".into(), format!("workspace {workspace_id} not found"), @@ -904,8 +946,8 @@ impl App { let pane = ws.panes.get(&pane_id)?; let runtime = ws.runtimes.get(&pane_id); Some(crate::api::schema::PaneInfo { - pane_id: format!("p_{}_{}", ws_idx + 1, pane_id.raw()), - workspace_id: format!("w_{}", ws_idx + 1), + pane_id: self.public_pane_id(ws_idx, pane_id)?, + workspace_id: self.public_workspace_id(ws_idx), focused: self.state.active == Some(ws_idx) && ws.layout.focused() == pane_id, cwd: runtime .and_then(|rt| rt.cwd()) @@ -923,7 +965,7 @@ impl App { ) -> Option<(&crate::pane::PaneRuntime, String)> { let ws = self.state.workspaces.get(ws_idx)?; let runtime = ws.runtimes.get(&pane_id)?; - Some((runtime, format!("w_{}", ws_idx + 1))) + Some((runtime, self.public_workspace_id(ws_idx))) } fn lookup_runtime_sender( @@ -938,6 +980,19 @@ impl App { let runtime = ws.runtimes.get(&pane_id)?; Some((&runtime.sender, runtime)) } + + fn workspace_info(&self, index: usize) -> crate::api::schema::WorkspaceInfo { + let ws = &self.state.workspaces[index]; + let (agg_state, _) = ws.aggregate_state(); + crate::api::schema::WorkspaceInfo { + workspace_id: self.public_workspace_id(index), + number: index + 1, + label: ws.display_name(), + focused: self.state.active == Some(index), + pane_count: ws.panes.len(), + agent_state: pane_agent_state(agg_state), + } + } } fn parse_api_key(key: &str) -> Option<crossterm::event::KeyEvent> { @@ -962,18 +1017,6 @@ fn parse_api_key(key: &str) -> Option<crossterm::event::KeyEvent> { } } -fn parse_workspace_id(id: &str) -> Option<usize> { - id.strip_prefix("w_")?.parse::<usize>().ok()?.checked_sub(1) -} - -fn parse_pane_id(id: &str) -> Option<(usize, crate::layout::PaneId)> { - let rest = id.strip_prefix("p_")?; - let (ws_raw, pane_raw) = rest.split_once('_')?; - let ws_idx = ws_raw.parse::<usize>().ok()?.checked_sub(1)?; - let pane_id = crate::layout::PaneId::from_raw(pane_raw.parse::<u32>().ok()?); - Some((ws_idx, pane_id)) -} - fn pane_agent_state(state: crate::detect::AgentState) -> crate::api::schema::PaneAgentState { match state { crate::detect::AgentState::Idle => crate::api::schema::PaneAgentState::Idle, @@ -983,19 +1026,6 @@ fn pane_agent_state(state: crate::detect::AgentState) -> crate::api::schema::Pan } } -fn workspace_info(state: &AppState, index: usize) -> crate::api::schema::WorkspaceInfo { - let ws = &state.workspaces[index]; - let (agg_state, _) = ws.aggregate_state(); - crate::api::schema::WorkspaceInfo { - workspace_id: format!("w_{}", index + 1), - number: index + 1, - label: ws.display_name(), - focused: state.active == Some(index), - pane_count: ws.panes.len(), - agent_state: pane_agent_state(agg_state), - } -} - fn agent_name(agent: crate::detect::Agent) -> String { match agent { crate::detect::Agent::Pi => "pi", @@ -662,24 +662,10 @@ fn send_request(request: &Request) -> std::io::Result<serde_json::Value> { } fn normalize_workspace_id(value: &str) -> String { - if value.starts_with("w_") { - return value.to_string(); - } - if let Ok(number) = value.parse::<usize>() { - return format!("w_{number}"); - } value.to_string() } fn normalize_pane_id(value: &str) -> String { - if value.starts_with("p_") { - return value.to_string(); - } - if let Some((workspace, pane)) = value.split_once('-') { - if workspace.parse::<usize>().is_ok() && pane.parse::<u32>().is_ok() { - return format!("p_{workspace}_{pane}"); - } - } value.to_string() } diff --git a/src/persist.rs b/src/persist.rs index 9e211730..4f6de57b 100644 --- a/src/persist.rs +++ b/src/persist.rs @@ -187,10 +187,19 @@ fn restore_workspace( .or_else(|| pane_ids.first().copied()) .unwrap_or(PaneId::from_raw(0)); + let public_pane_numbers = layout + .pane_ids() + .into_iter() + .enumerate() + .map(|(index, pane_id)| (pane_id, index + 1)) + .collect(); + Some(Workspace { custom_name: snap.custom_name.clone(), root_pane, layout, + public_pane_numbers, + next_public_pane_number: panes.len() + 1, panes, runtimes, zoomed: snap.zoomed, diff --git a/src/workspace.rs b/src/workspace.rs index 78992a73..ff8e302f 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -17,6 +17,10 @@ pub struct Workspace { /// Identity source for this workspace. pub root_pane: PaneId, pub layout: TileLayout, + /// Stable-ish public pane numbers within this workspace. + /// New panes append at the end; closing a pane compacts higher numbers down. + pub public_pane_numbers: HashMap<PaneId, usize>, + pub(crate) next_public_pane_number: usize, /// Pane state — always present, testable without PTYs. pub panes: HashMap<PaneId, PaneState>, /// Pane runtimes — only present in production (empty in tests). @@ -37,6 +41,8 @@ impl Workspace { let mut panes = HashMap::new(); panes.insert(root_id, PaneState::new()); + let mut public_pane_numbers = HashMap::new(); + public_pane_numbers.insert(root_id, 1); let mut runtimes = HashMap::new(); runtimes.insert(root_id, runtime); @@ -45,6 +51,8 @@ impl Workspace { custom_name: None, root_pane: root_id, layout, + public_pane_numbers, + next_public_pane_number: 2, panes, runtimes, zoomed: false, @@ -65,6 +73,9 @@ impl Workspace { cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); let runtime = PaneRuntime::spawn(new_id, rows, cols, actual_cwd, self.events.clone())?; self.panes.insert(new_id, PaneState::new()); + self.public_pane_numbers + .insert(new_id, self.next_public_pane_number); + self.next_public_pane_number += 1; self.runtimes.insert(new_id, runtime); self.zoomed = false; Ok(new_id) @@ -94,6 +105,14 @@ impl Workspace { self.layout.focus_pane(prev_focus); } + if let Some(removed_number) = self.public_pane_numbers.remove(&pane_id) { + for number in self.public_pane_numbers.values_mut() { + if *number > removed_number { + *number -= 1; + } + } + self.next_public_pane_number = self.public_pane_numbers.len() + 1; + } self.panes.remove(&pane_id); self.runtimes.remove(&pane_id); self.zoomed = false; @@ -110,6 +129,10 @@ impl Workspace { self.layout.pane_ids().into_iter().find(|id| *id != closing) } + pub fn public_pane_number(&self, pane_id: PaneId) -> Option<usize> { + self.public_pane_numbers.get(&pane_id).copied() + } + /// Get the runtime for the focused pane. pub fn focused_runtime(&self) -> Option<&PaneRuntime> { self.runtimes.get(&self.layout.focused()) @@ -251,10 +274,14 @@ impl Workspace { let (layout, root_id) = TileLayout::new(); let mut panes = HashMap::new(); panes.insert(root_id, PaneState::new()); + let mut public_pane_numbers = HashMap::new(); + public_pane_numbers.insert(root_id, 1); Self { custom_name: Some(name.to_string()), root_pane: root_id, layout, + public_pane_numbers, + next_public_pane_number: 2, panes, runtimes: HashMap::new(), zoomed: false, @@ -266,6 +293,9 @@ impl Workspace { pub fn test_split(&mut self, direction: Direction) -> PaneId { let new_id = self.layout.split_focused(direction); self.panes.insert(new_id, PaneState::new()); + self.public_pane_numbers + .insert(new_id, self.next_public_pane_number); + self.next_public_pane_number += 1; new_id } } diff --git a/tests/api_ping.rs b/tests/api_ping.rs index 17bc6b1c..67f89c59 100644 --- a/tests/api_ping.rs +++ b/tests/api_ping.rs @@ -170,7 +170,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"]["workspace"]["workspace_id"], "w_1"); + assert_eq!(created["result"]["workspace"]["workspace_id"], "1"); assert_eq!(created["result"]["workspace"]["number"], 1); assert_eq!(created["result"]["workspace"]["focused"], true); @@ -180,13 +180,13 @@ fn workspace_list_and_create_round_trip() { ); let workspaces = listed["result"]["workspaces"].as_array().unwrap(); assert_eq!(workspaces.len(), 1); - assert_eq!(workspaces[0]["workspace_id"], "w_1"); + assert_eq!(workspaces[0]["workspace_id"], "1"); let fetched = send_request( &socket_path, - r#"{"id":"req_5","method":"workspace.get","params":{"workspace_id":"w_1"}}"#, + r#"{"id":"req_5","method":"workspace.get","params":{"workspace_id":"1"}}"#, ); - assert_eq!(fetched["result"]["workspace"]["workspace_id"], "w_1"); + assert_eq!(fetched["result"]["workspace"]["workspace_id"], "1"); let panes = send_request( &socket_path, @@ -194,7 +194,7 @@ fn workspace_list_and_create_round_trip() { ); let panes = panes["result"]["panes"].as_array().unwrap(); assert_eq!(panes.len(), 1); - assert_eq!(panes[0]["workspace_id"], "w_1"); + assert_eq!(panes[0]["workspace_id"], "1"); let pane_id = panes[0]["pane_id"].as_str().unwrap().to_string(); let pane = send_request( @@ -463,7 +463,7 @@ fn events_subscribe_streams_output_and_agent_state_events() { base.display() ), ); - assert_eq!(created["result"]["workspace"]["workspace_id"], "w_1"); + assert_eq!(created["result"]["workspace"]["workspace_id"], "1"); let panes = send_request( &socket_path, diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs index e758e5f8..c8dbc8b8 100644 --- a/tests/cli_wrapper.rs +++ b/tests/cli_wrapper.rs @@ -111,7 +111,7 @@ fn workspace_and_pane_management_commands_work() { ); assert!(created.status.success()); let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap(); - assert_eq!(created_json["result"]["workspace"]["workspace_id"], "w_1"); + assert_eq!(created_json["result"]["workspace"]["workspace_id"], "1"); let panes = run_cli(&socket_path, &["pane", "list", "--workspace", "1"]); assert!(panes.status.success()); @@ -176,14 +176,14 @@ fn pane_run_read_and_wait_commands_work() { base.display() ), ); - assert_eq!(created["result"]["workspace"]["workspace_id"], "w_1"); + assert_eq!(created["result"]["workspace"]["workspace_id"], "1"); let create = run_cli( &socket_path, &[ "pane", "run", - "p_1_1", + "1-1", "echo alpha && echo beta && printf 'ready\\n'", ], ); @@ -228,6 +228,100 @@ fn pane_run_read_and_wait_commands_work() { } #[test] +fn ids_are_compact_and_positional() { + 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 mut herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let ws1 = run_cli( + &socket_path, + &["workspace", "create", "--cwd", base.to_str().unwrap()], + ); + assert!(ws1.status.success()); + + let split_12 = run_cli( + &socket_path, + &["pane", "split", "1-1", "--direction", "right", "--no-focus"], + ); + let split_12_json: serde_json::Value = serde_json::from_slice(&split_12.stdout).unwrap(); + assert_eq!(split_12_json["result"]["pane"]["pane_id"], "1-2"); + + let split_13 = run_cli( + &socket_path, + &["pane", "split", "1-1", "--direction", "down", "--no-focus"], + ); + let split_13_json: serde_json::Value = serde_json::from_slice(&split_13.stdout).unwrap(); + assert_eq!(split_13_json["result"]["pane"]["pane_id"], "1-3"); + + let ws2 = run_cli( + &socket_path, + &["workspace", "create", "--cwd", "/tmp", "--no-focus"], + ); + let ws2_json: serde_json::Value = serde_json::from_slice(&ws2.stdout).unwrap(); + assert_eq!(ws2_json["result"]["workspace"]["workspace_id"], "2"); + + let ws2_focus = run_cli(&socket_path, &["workspace", "focus", "2"]); + assert!(ws2_focus.status.success()); + let ws2_split = run_cli( + &socket_path, + &["pane", "split", "2-1", "--direction", "right", "--no-focus"], + ); + let ws2_split_json: serde_json::Value = serde_json::from_slice(&ws2_split.stdout).unwrap(); + assert_eq!(ws2_split_json["result"]["pane"]["pane_id"], "2-2"); + + let ws3 = run_cli( + &socket_path, + &["workspace", "create", "--cwd", "/", "--no-focus"], + ); + let ws3_json: serde_json::Value = serde_json::from_slice(&ws3.stdout).unwrap(); + assert_eq!(ws3_json["result"]["workspace"]["workspace_id"], "3"); + + let close_ws2 = run_cli(&socket_path, &["workspace", "close", "2"]); + assert!(close_ws2.status.success()); + + let workspaces = run_cli(&socket_path, &["workspace", "list"]); + let workspaces_json: serde_json::Value = serde_json::from_slice(&workspaces.stdout).unwrap(); + let ids: Vec<String> = workspaces_json["result"]["workspaces"] + .as_array() + .unwrap() + .iter() + .map(|ws| ws["workspace_id"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(ids, vec!["1".to_string(), "2".to_string()]); + + let new_ws = run_cli( + &socket_path, + &["workspace", "create", "--cwd", "/var/tmp", "--no-focus"], + ); + let new_ws_json: serde_json::Value = serde_json::from_slice(&new_ws.stdout).unwrap(); + assert_eq!(new_ws_json["result"]["workspace"]["workspace_id"], "3"); + + let ws3_panes = run_cli(&socket_path, &["pane", "list", "--workspace", "3"]); + let ws3_panes_json: serde_json::Value = serde_json::from_slice(&ws3_panes.stdout).unwrap(); + assert_eq!(ws3_panes_json["result"]["panes"][0]["pane_id"], "3-1"); + + let close_middle = run_cli(&socket_path, &["pane", "close", "1-2"]); + assert!(close_middle.status.success()); + let ws1_panes = run_cli(&socket_path, &["pane", "list", "--workspace", "1"]); + let ws1_panes_json: serde_json::Value = serde_json::from_slice(&ws1_panes.stdout).unwrap(); + let pane_ids: Vec<String> = ws1_panes_json["result"]["panes"] + .as_array() + .unwrap() + .iter() + .map(|pane| pane["pane_id"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(pane_ids, vec!["1-1".to_string(), "1-2".to_string()]); + + let _ = herdr.child.kill(); + let _ = herdr.child.wait(); + let _ = fs::remove_dir_all(base); +} + +#[test] fn wait_agent_state_exits_when_state_matches() { let base = unique_test_dir(); let config_home = base.join("config"); @@ -294,7 +388,7 @@ fn wait_agent_state_exits_when_state_matches() { base.display() ), ); - assert_eq!(created["result"]["workspace"]["workspace_id"], "w_1"); + assert_eq!(created["result"]["workspace"]["workspace_id"], "1"); let start_pi = run_cli(&socket_path, &["pane", "run", "1-1", "pi"]); assert!(start_pi.status.success()); |
