diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-02 23:59:54 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-03 00:04:35 +0300 |
| commit | 544c7ebebbc1db65a2949642d367edc446ae559b (patch) | |
| tree | e952f462ab99ebb4375d7952e8994711d30ed019 | |
| parent | e434904d10da1f2517b2c0411946947c37ffee0d (diff) | |
feat: add tabs to socket api
| -rw-r--r-- | README.md | 12 | ||||
| -rw-r--r-- | SKILL.md | 51 | ||||
| -rw-r--r-- | SOCKET_API.md | 158 | ||||
| -rw-r--r-- | src/api/mod.rs | 16 | ||||
| -rw-r--r-- | src/api/schema.rs | 106 | ||||
| -rw-r--r-- | src/app/mod.rs | 367 | ||||
| -rw-r--r-- | src/cli.rs | 180 | ||||
| -rw-r--r-- | src/main.rs | 2 | ||||
| -rw-r--r-- | tests/api_ping.rs | 113 | ||||
| -rw-r--r-- | tests/cli_wrapper.rs | 49 |
10 files changed, 1031 insertions, 23 deletions
@@ -79,11 +79,12 @@ herdr is not just a passive manager for humans watching agents. it gives agents - [`SKILL.md`](./SKILL.md) — the reusable agent skill. use this if you want an agent inside herdr to learn the workflow quickly through the existing cli surface. - [`SOCKET_API.md`](./SOCKET_API.md) — the direct integration doc. use this if you want the low-level socket protocol, event subscriptions, or the cli wrapper reference that sits on top of it. -those two paths meet at the same control surface. the built-in `herdr workspace ...`, `herdr pane ...`, and `herdr wait ...` commands all talk to the same local socket api. +those two paths meet at the same control surface. the built-in `herdr workspace ...`, `herdr tab ...`, `herdr pane ...`, and `herdr wait ...` commands all talk to the same local socket api. that means agents running inside herdr can do useful orchestration work themselves: - create new workspaces for parallel tasks +- create and focus tabs inside a workspace - split panes for servers, logs, tests, or scratch work - spawn other agents in sibling panes - read pane output or wait for output matches @@ -243,6 +244,8 @@ for all keybindings, onboarding, notification, sound, ui options, and environmen herdr saves your workspaces, tabs, pane layouts, pane working directories, and focused tab/pane on exit. when you restart, everything is restored. sessions are stored at `~/.config/herdr/session.json`. +through the socket api, tabs are first-class too now. raw integrations and cli wrappers can list/create/focus/rename/close tabs directly while pane ids stay workspace-scoped. + use `--no-session` to start fresh. ## how agent detection works @@ -296,7 +299,7 @@ for direct integration details, use the docs instead of reverse-engineering the - [`SKILL.md`](./SKILL.md) — reusable agent skill for agents already running inside herdr - [`SOCKET_API.md`](./SOCKET_API.md) — canonical socket protocol + cli wrapper reference -`SOCKET_API.md` now covers transport, request/response envelopes, workspace and pane methods, subscription behavior, and the `herdr workspace`, `herdr pane`, and `herdr wait` commands that wrap the same socket surface. +`SOCKET_API.md` now covers transport, request/response envelopes, workspace, tab, and pane methods, subscription behavior, and the `herdr workspace`, `herdr tab`, `herdr pane`, and `herdr wait` commands that wrap the same socket surface. ## what's coming @@ -315,12 +318,13 @@ there will be rough edges. if you hit one, [open an issue](https://github.com/og ## cli wrappers -herdr's workspace, pane, and wait commands are documented in [`SOCKET_API.md`](./SOCKET_API.md) together with the socket methods they wrap. +herdr's workspace, tab, pane, and wait commands are documented in [`SOCKET_API.md`](./SOCKET_API.md) together with the socket methods they wrap. workspace ids are compact public ids like `1`, `2`, `3`. +tab ids are compact public ids like `1:1`, `1:2`, `2:1`. pane ids are compact public ids like `1-1`, `1-2`, `2-1`. -even with tabs enabled, pane ids remain workspace-scoped public ids rather than `workspace-tab-pane` triples. they are positional within the current live session, so numbering compacts when workspaces or panes are closed. +even with tabs enabled, pane ids remain workspace-scoped public ids rather than `workspace-tab-pane` triples. both tab ids and pane ids are positional within the current live session, so numbering compacts when tabs, workspaces, or panes are closed. ## building from source @@ -1,30 +1,33 @@ --- name: herdr -description: "Control herdr from inside it. Split panes, spawn agents, read output, wait for state changes, and manage workspaces — all via CLI commands that talk to the running herdr instance over a local unix socket. Use when running inside herdr (HERDR_ENV=1)." +description: "Control herdr from inside it. Manage workspaces and tabs, split panes, spawn agents, read output, and wait for state changes — all via CLI commands that talk to the running herdr instance over a local unix socket. Use when running inside herdr (HERDR_ENV=1)." --- # herdr — agent skill -you are running inside herdr, a terminal-native agent multiplexer. herdr gives you workspaces and panes — each pane is a real terminal with its own shell, agent, server, or log stream — and you can control all of it from the cli. +you are running inside herdr, a terminal-native agent multiplexer. herdr gives you workspaces, tabs, and panes — each pane is a real terminal with its own shell, agent, server, or log stream — and you can control all of it from the cli. this means you can: - see what other panes and agents are doing +- create tabs for separate subcontexts inside one workspace - split panes and run commands in them - start servers, watch logs, and run tests in sibling panes - wait for specific output before continuing - wait for another agent to finish - spawn more agent instances -the `herdr` binary is available in your PATH. its workspace, pane, and wait commands talk to the running herdr instance over a local unix socket. +the `herdr` binary is available in your PATH. its workspace, tab, pane, and wait commands talk to the running herdr instance over a local unix socket. if you need the raw protocol or full api reference, read [`SOCKET_API.md`](./SOCKET_API.md). ## concepts -**workspaces** are project contexts. each workspace has one or more panes. +**workspaces** are project contexts. each workspace has one or more tabs. -**panes** are terminal splits inside a workspace. each pane runs its own process — a shell, an agent, a server, anything. +**tabs** are subcontexts inside a workspace. each tab has one or more panes. + +**panes** are terminal splits inside a tab. each pane runs its own process — a shell, an agent, a server, anything. **agent state** is detected automatically by herdr. each pane can be: @@ -33,9 +36,9 @@ if you need the raw protocol or full api reference, read [`SOCKET_API.md`](./SOC - `blocked` — agent needs input or approval - `unknown` — no recognized agent, or just a shell -**ids** — workspace ids look like `1`, `2`. pane ids look like `1-1`, `1-2`, `2-1`. these are compact public ids for the current live session. +**ids** — workspace ids look like `1`, `2`. tab ids look like `1:1`, `1:2`, `2:1`. pane ids look like `1-1`, `1-2`, `2-1`. these are compact public ids for the current live session. -important: ids can compact when panes or workspaces are closed. do not treat them as durable ids. +important: ids can compact when tabs, panes, or workspaces are closed. do not treat them as durable ids. ## discover yourself @@ -53,6 +56,38 @@ list workspaces: herdr workspace list ``` +## tab management + +list tabs in the current workspace: + +```bash +herdr tab list --workspace 1 +``` + +create a new tab: + +```bash +herdr tab create --workspace 1 +``` + +rename it: + +```bash +herdr tab rename 1:2 "logs" +``` + +focus it: + +```bash +herdr tab focus 1:2 +``` + +close it: + +```bash +herdr tab close 1:2 +``` + ## read another pane see what is on another pane's screen: @@ -221,7 +256,7 @@ herdr pane read 1-1 --source recent --lines 100 ## notes -- `workspace list`, `pane list`, `workspace create`, `pane split`, `wait output`, and `wait agent-state` print json on success. +- `workspace list`, `workspace create`, `tab list`, `tab create`, `tab get`, `tab focus`, `tab rename`, `tab close`, `pane list`, `pane get`, `pane split`, `wait output`, and `wait agent-state` print json on success. - `pane read` prints text, not json. - `pane send-text`, `pane send-keys`, and `pane run` print nothing on success. - parse ids from `workspace create` and `pane split` responses when you need the new ids. do not guess. diff --git a/SOCKET_API.md b/SOCKET_API.md index cff166c5..53fa9d52 100644 --- a/SOCKET_API.md +++ b/SOCKET_API.md @@ -9,7 +9,7 @@ if you are teaching an agent that is already running inside herdr, start with [` there are three practical ways to integrate with herdr: - **agent skill** — [`SKILL.md`](./SKILL.md). best when an agent inside herdr just needs to learn the workflow quickly. -- **cli wrappers** — `herdr workspace ...`, `herdr pane ...`, `herdr wait ...`. best for shell scripts and simple orchestration. +- **cli wrappers** — `herdr workspace ...`, `herdr tab ...`, `herdr pane ...`, `herdr wait ...`. best for shell scripts and simple orchestration. - **raw socket api** — best when you want direct request/response control or long-lived event subscriptions. these layers are intentionally stacked on top of the same control surface. @@ -87,7 +87,12 @@ that means: these are compact public ids for the current live session. they are **not durable database ids**. if a workspace or pane closes, higher numbers compact down. -herdr now supports tabs inside workspaces in the ui, but pane ids remain workspace-scoped public ids like `1-2` rather than `workspace-tab-pane` triples. tabs are not exposed as first-class socket api objects yet. +tabs are first-class socket api objects now. + +- tab ids look like `1:1`, `1:2`, `2:1` +- first number = workspace number +- second number = tab number within that workspace +- pane ids still stay workspace-scoped like `1-2` rather than becoming `workspace-tab-pane` triples ## core objects @@ -100,6 +105,22 @@ herdr now supports tabs inside workspaces in the ui, but pane ids remain workspa "label": "herdr", "focused": true, "pane_count": 1, + "tab_count": 1, + "active_tab_id": "1:1", + "agent_state": "unknown" +} +``` + +`tab_info` responses contain objects like: + +```json +{ + "tab_id": "1:1", + "workspace_id": "1", + "number": 1, + "label": "1", + "focused": true, + "pane_count": 1, "agent_state": "unknown" } ``` @@ -110,6 +131,7 @@ herdr now supports tabs inside workspaces in the ui, but pane ids remain workspa { "pane_id": "1-1", "workspace_id": "1", + "tab_id": "1:1", "focused": true, "cwd": "/home/can/Projects/herdr", "agent": "pi", @@ -124,6 +146,7 @@ herdr now supports tabs inside workspaces in the ui, but pane ids remain workspa { "pane_id": "1-1", "workspace_id": "1", + "tab_id": "1:1", "source": "recent", "text": "...", "revision": 0, @@ -149,6 +172,12 @@ agent states are: | `workspace.focus` | focus a workspace | `workspace_info` | | `workspace.rename` | rename a workspace | `workspace_info` | | `workspace.close` | close a workspace | `ok` | +| `tab.list` | list tabs, optionally filtered by workspace | `tab_list` | +| `tab.get` | inspect one tab | `tab_info` | +| `tab.create` | create a tab in a workspace | `tab_info` | +| `tab.focus` | focus a tab | `tab_info` | +| `tab.rename` | rename a tab | `tab_info` | +| `tab.close` | close a tab | `ok` | | `pane.list` | list panes, optionally filtered by workspace | `pane_list` | | `pane.get` | inspect one pane | `pane_info` | | `pane.read` | read pane output | `pane_read` | @@ -218,6 +247,8 @@ example response: "label": "herdr", "focused": true, "pane_count": 1, + "tab_count": 1, + "active_tab_id": "1:1", "agent_state": "unknown" } } @@ -270,6 +301,104 @@ returns: } ``` +## tab methods + +### `tab.list` + +request with no filter: + +```json +{ + "id": "req_tabs", + "method": "tab.list", + "params": {} +} +``` + +request filtered to one workspace: + +```json +{ + "id": "req_tabs_ws", + "method": "tab.list", + "params": { + "workspace_id": "1" + } +} +``` + +returns `tab_list`. + +### `tab.get` + +params: + +```json +{ + "tab_id": "1:2" +} +``` + +returns `tab_info`. + +### `tab.create` + +params: + +```json +{ + "workspace_id": "1", + "cwd": "/home/can/Projects/herdr", + "focus": true +} +``` + +notes: + +- `workspace_id` is optional and defaults to the active workspace +- `cwd` is optional; if omitted, herdr uses the focused pane cwd in that workspace when available +- `focus` is optional in raw socket requests and defaults to `false` +- the cli wrapper focuses by default unless you pass `--no-focus` + +returns `tab_info` for the new tab. + +### `tab.focus` + +params: + +```json +{ + "tab_id": "1:2" +} +``` + +returns focused `tab_info`. + +### `tab.rename` + +params: + +```json +{ + "tab_id": "1:2", + "label": "logs" +} +``` + +returns updated `tab_info`. + +### `tab.close` + +params: + +```json +{ + "tab_id": "1:2" +} +``` + +returns `ok`. the last tab in a workspace cannot be closed. + ## pane methods ### `pane.list` @@ -345,6 +474,7 @@ example response: "read": { "pane_id": "1-1", "workspace_id": "1", + "tab_id": "1:1", "source": "recent", "text": "...", "revision": 0, @@ -464,6 +594,7 @@ example success response: "read": { "pane_id": "1-1", "workspace_id": "1", + "tab_id": "1:1", "source": "recent", "text": "...server ready...", "revision": 0, @@ -497,6 +628,10 @@ base lifecycle subscriptions: - `workspace.created` - `workspace.closed` - `workspace.focused` +- `tab.created` +- `tab.closed` +- `tab.focused` +- `tab.renamed` - `pane.created` - `pane.closed` - `pane.focused` @@ -533,6 +668,10 @@ request: "subscriptions": [ { "type": "workspace.created" }, { "type": "workspace.focused" }, + { "type": "tab.created" }, + { "type": "tab.focused" }, + { "type": "tab.renamed" }, + { "type": "tab.closed" }, { "type": "pane.created" }, { "type": "pane.focused" }, { "type": "pane.agent_detected" }, @@ -555,6 +694,8 @@ example pushed event: "label": "herdr", "focused": true, "pane_count": 1, + "tab_count": 1, + "active_tab_id": "1:1", "agent_state": "unknown" } } @@ -604,6 +745,7 @@ example pushed `pane.output_matched` event: "read": { "pane_id": "1-1", "workspace_id": "1", + "tab_id": "1:1", "source": "recent", "text": "...server ready...", "revision": 0, @@ -644,6 +786,17 @@ herdr workspace rename <workspace_id> <label> herdr workspace close <workspace_id> ``` +tab commands: + +```text +herdr tab list [--workspace <workspace_id>] +herdr tab create [--workspace <workspace_id>] [--cwd PATH] [--no-focus] +herdr tab get <tab_id> +herdr tab focus <tab_id> +herdr tab rename <tab_id> <label> +herdr tab close <tab_id> +``` + pane commands: ```text @@ -667,6 +820,7 @@ herdr wait agent-state <pane_id> --state <idle|working|blocked|unknown> [--timeo ### cli behavior notes - `workspace create` focuses by default; pass `--no-focus` to keep focus where it is +- `tab create` focuses by default; pass `--no-focus` to keep focus where it is - `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 send-text`, `pane send-keys`, and `pane run` print nothing on success diff --git a/src/api/mod.rs b/src/api/mod.rs index bf95bf29..2e7692a4 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -430,6 +430,22 @@ impl ActiveSubscription { event_kind: crate::api::schema::EventKind::WorkspaceFocused, last_sequence: 0, })), + Subscription::TabCreated {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::TabCreated, + last_sequence: 0, + })), + Subscription::TabClosed {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::TabClosed, + last_sequence: 0, + })), + Subscription::TabFocused {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::TabFocused, + last_sequence: 0, + })), + Subscription::TabRenamed {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::TabRenamed, + last_sequence: 0, + })), Subscription::PaneCreated {} => Ok(Self::Event(ActiveEventSubscription { event_kind: crate::api::schema::EventKind::PaneCreated, last_sequence: 0, diff --git a/src/api/schema.rs b/src/api/schema.rs index a187a889..5a27b01b 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -24,6 +24,18 @@ pub enum Method { WorkspaceRename(WorkspaceRenameParams), #[serde(rename = "workspace.close")] WorkspaceClose(WorkspaceTarget), + #[serde(rename = "tab.create")] + TabCreate(TabCreateParams), + #[serde(rename = "tab.list")] + TabList(TabListParams), + #[serde(rename = "tab.get")] + TabGet(TabTarget), + #[serde(rename = "tab.focus")] + TabFocus(TabTarget), + #[serde(rename = "tab.rename")] + TabRename(TabRenameParams), + #[serde(rename = "tab.close")] + TabClose(TabTarget), #[serde(rename = "pane.split")] PaneSplit(PaneSplitParams), #[serde(rename = "pane.list")] @@ -69,6 +81,11 @@ pub struct PaneTarget { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TabTarget { + pub tab_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WorkspaceCreateParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option<String>, @@ -83,6 +100,28 @@ pub struct WorkspaceRenameParams { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TabCreateParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, + #[serde(default)] + pub focus: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct TabListParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TabRenameParams { + pub tab_id: String, + pub label: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PaneSplitParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_id: Option<String>, @@ -174,6 +213,14 @@ pub enum Subscription { WorkspaceClosed {}, #[serde(rename = "workspace.focused")] WorkspaceFocused {}, + #[serde(rename = "tab.created")] + TabCreated {}, + #[serde(rename = "tab.closed")] + TabClosed {}, + #[serde(rename = "tab.focused")] + TabFocused {}, + #[serde(rename = "tab.renamed")] + TabRenamed {}, #[serde(rename = "pane.created")] PaneCreated {}, #[serde(rename = "pane.closed")] @@ -247,6 +294,23 @@ pub enum EventMatch { WorkspaceFocused { workspace_id: String, }, + TabCreated { + #[serde(default, skip_serializing_if = "Option::is_none")] + tab_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option<String>, + }, + TabClosed { + tab_id: String, + }, + TabRenamed { + tab_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + label: Option<String>, + }, + TabFocused { + tab_id: String, + }, PaneCreated { #[serde(default, skip_serializing_if = "Option::is_none")] pane_id: Option<String>, @@ -285,6 +349,10 @@ pub enum EventKind { WorkspaceClosed, WorkspaceRenamed, WorkspaceFocused, + TabCreated, + TabClosed, + TabRenamed, + TabFocused, PaneCreated, PaneClosed, PaneFocused, @@ -324,6 +392,12 @@ pub enum ResponseResult { WorkspaceList { workspaces: Vec<WorkspaceInfo>, }, + TabInfo { + tab: TabInfo, + }, + TabList { + tabs: Vec<TabInfo>, + }, PaneInfo { pane: PaneInfo, }, @@ -353,6 +427,19 @@ pub struct WorkspaceInfo { pub label: String, pub focused: bool, pub pane_count: usize, + pub tab_count: usize, + pub active_tab_id: String, + pub agent_state: PaneAgentState, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TabInfo { + pub tab_id: String, + pub workspace_id: String, + pub number: usize, + pub label: String, + pub focused: bool, + pub pane_count: usize, pub agent_state: PaneAgentState, } @@ -360,6 +447,7 @@ pub struct WorkspaceInfo { pub struct PaneInfo { pub pane_id: String, pub workspace_id: String, + pub tab_id: String, pub focused: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option<String>, @@ -373,6 +461,7 @@ pub struct PaneInfo { pub struct PaneReadResult { pub pane_id: String, pub workspace_id: String, + pub tab_id: String, pub source: ReadSource, pub text: String, pub revision: u64, @@ -438,6 +527,22 @@ pub enum EventData { WorkspaceFocused { workspace_id: String, }, + TabCreated { + tab: TabInfo, + }, + TabClosed { + tab_id: String, + workspace_id: String, + }, + TabRenamed { + tab_id: String, + workspace_id: String, + label: String, + }, + TabFocused { + tab_id: String, + workspace_id: String, + }, PaneCreated { pane: PaneInfo, }, @@ -682,6 +787,7 @@ mod tests { read: PaneReadResult { pane_id: "p_1_1".into(), workspace_id: "w_1".into(), + tab_id: "t_1_1".into(), source: ReadSource::Recent, text: "auth: received\n".into(), revision: 0, diff --git a/src/app/mod.rs b/src/app/mod.rs index 5f5d5072..311e5f82 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -90,6 +90,10 @@ fn api_request_changes_ui(request: &crate::api::schema::Request) -> bool { | Method::WorkspaceFocus(_) | Method::WorkspaceRename(_) | Method::WorkspaceClose(_) + | Method::TabCreate(_) + | Method::TabFocus(_) + | Method::TabRename(_) + | Method::TabClose(_) | Method::PaneSplit(_) | Method::PaneReportAgent(_) | Method::PaneClearAgentAuthority(_) @@ -706,6 +710,15 @@ impl App { workspace_id: self.public_workspace_id(ws_idx), }, }); + if let Some(tab_id) = self.public_tab_id(ws_idx, self.state.workspaces[ws_idx].active_tab) { + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::TabFocused, + data: crate::api::schema::EventData::TabFocused { + tab_id, + 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, @@ -735,6 +748,11 @@ impl App { (ws_idx + 1).to_string() } + fn public_tab_id(&self, ws_idx: usize, tab_idx: usize) -> Option<String> { + self.state.workspaces.get(ws_idx)?.tabs.get(tab_idx)?; + Some(format!("{}:{}", ws_idx + 1, tab_idx + 1)) + } + 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)?; @@ -748,6 +766,22 @@ impl App { id.parse::<usize>().ok()?.checked_sub(1) } + fn parse_tab_id(&self, id: &str) -> Option<(usize, usize)> { + if let Some(rest) = id.strip_prefix("t_") { + let (ws_raw, tab_raw) = rest.split_once('_')?; + let ws_idx = ws_raw.parse::<usize>().ok()?.checked_sub(1)?; + let tab_idx = tab_raw.parse::<usize>().ok()?.checked_sub(1)?; + self.state.workspaces.get(ws_idx)?.tabs.get(tab_idx)?; + return Some((ws_idx, tab_idx)); + } + + let (ws_raw, tab_raw) = id.split_once(':')?; + let ws_idx = ws_raw.parse::<usize>().ok()?.checked_sub(1)?; + let tab_idx = tab_raw.parse::<usize>().ok()?.checked_sub(1)?; + self.state.workspaces.get(ws_idx)?.tabs.get(tab_idx)?; + Some((ws_idx, tab_idx)) + } + fn parse_pane_id(&self, id: &str) -> Option<(usize, crate::layout::PaneId)> { if let Some(rest) = id.strip_prefix("p_") { if let Some((ws_raw, pane_raw)) = rest.split_once('_') { @@ -777,7 +811,7 @@ impl App { use crate::api::schema::{ ErrorBody, ErrorResponse, Method, PaneListParams, PaneReadResult, ReadSource, - ResponseResult, SuccessResponse, + ResponseResult, SuccessResponse, TabListParams, }; let response = match request.method { @@ -836,6 +870,12 @@ impl App { 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() @@ -917,6 +957,13 @@ impl App { .unwrap(); }; ws.set_custom_name(params.label.clone()); + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::WorkspaceRenamed, + data: crate::api::schema::EventData::WorkspaceRenamed { + workspace_id: self.public_workspace_id(index), + label: params.label, + }, + }); SuccessResponse { id: request.id, result: ResponseResult::WorkspaceInfo { @@ -958,6 +1005,278 @@ impl App { result: ResponseResult::Ok {}, } } + Method::TabList(TabListParams { workspace_id }) => { + let tabs = if let Some(workspace_id) = workspace_id { + let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "workspace_not_found".into(), + message: format!("workspace {} not found", workspace_id), + }, + }) + .unwrap(); + }; + let Some(ws) = self.state.workspaces.get(ws_idx) else { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "workspace_not_found".into(), + message: format!("workspace {} not found", workspace_id), + }, + }) + .unwrap(); + }; + (0..ws.tabs.len()) + .filter_map(|tab_idx| self.tab_info(ws_idx, tab_idx)) + .collect() + } else { + let mut tabs = Vec::new(); + for (ws_idx, ws) in self.state.workspaces.iter().enumerate() { + for tab_idx in 0..ws.tabs.len() { + if let Some(tab) = self.tab_info(ws_idx, tab_idx) { + tabs.push(tab); + } + } + } + tabs + }; + SuccessResponse { + id: request.id, + result: ResponseResult::TabList { tabs }, + } + } + Method::TabGet(target) => { + let Some((ws_idx, tab_idx)) = self.parse_tab_id(&target.tab_id) else { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "tab_not_found".into(), + message: format!("tab {} not found", target.tab_id), + }, + }) + .unwrap(); + }; + let Some(tab) = self.tab_info(ws_idx, tab_idx) else { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "tab_not_found".into(), + message: format!("tab {} not found", target.tab_id), + }, + }) + .unwrap(); + }; + SuccessResponse { + id: request.id, + result: ResponseResult::TabInfo { tab }, + } + } + Method::TabCreate(params) => { + let ws_idx = if let Some(workspace_id) = params.workspace_id { + let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "workspace_not_found".into(), + message: format!("workspace {} not found", workspace_id), + }, + }) + .unwrap(); + }; + ws_idx + } else if let Some(active) = self.state.active { + active + } else { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "workspace_not_found".into(), + message: "no active workspace".into(), + }, + }) + .unwrap(); + }; + let cwd = params + .cwd + .map(std::path::PathBuf::from) + .or_else(|| { + self.state.workspaces.get(ws_idx).and_then(|ws| { + ws.active_tab() + .and_then(|tab| tab.focused_runtime()) + .and_then(|rt| rt.cwd()) + }) + }) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| std::path::PathBuf::from("/")); + let (rows, cols) = self.state.estimate_pane_size(); + let result = self + .state + .workspaces + .get_mut(ws_idx) + .ok_or_else(|| std::io::Error::other("workspace disappeared")) + .and_then(|ws| ws.create_tab(rows, cols, cwd)); + match result { + Ok(tab_idx) => { + if params.focus { + self.state.switch_workspace(ws_idx); + if let Some(ws) = self.state.workspaces.get_mut(ws_idx) { + ws.switch_tab(tab_idx); + } + self.state.mode = Mode::Terminal; + } + 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 }, + }); + } + } + SuccessResponse { + id: request.id, + result: ResponseResult::TabInfo { tab }, + } + } + Err(err) => { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "tab_create_failed".into(), + message: err.to_string(), + }, + }) + .unwrap(); + } + } + } + Method::TabFocus(target) => { + let Some((ws_idx, tab_idx)) = self.parse_tab_id(&target.tab_id) else { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "tab_not_found".into(), + message: format!("tab {} not found", target.tab_id), + }, + }) + .unwrap(); + }; + self.state.switch_workspace(ws_idx); + if let Some(ws) = self.state.workspaces.get_mut(ws_idx) { + ws.switch_tab(tab_idx); + } + let tab = self.tab_info(ws_idx, tab_idx).unwrap(); + SuccessResponse { + id: request.id, + result: ResponseResult::TabInfo { tab }, + } + } + Method::TabRename(params) => { + let Some((ws_idx, tab_idx)) = self.parse_tab_id(¶ms.tab_id) else { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "tab_not_found".into(), + message: format!("tab {} not found", params.tab_id), + }, + }) + .unwrap(); + }; + let Some(tab) = self + .state + .workspaces + .get_mut(ws_idx) + .and_then(|ws| ws.tabs.get_mut(tab_idx)) + else { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "tab_not_found".into(), + message: format!("tab {} not found", params.tab_id), + }, + }) + .unwrap(); + }; + tab.set_custom_name(params.label.clone()); + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::TabRenamed, + data: crate::api::schema::EventData::TabRenamed { + tab_id: self.public_tab_id(ws_idx, tab_idx).unwrap(), + workspace_id: self.public_workspace_id(ws_idx), + label: params.label, + }, + }); + let tab = self.tab_info(ws_idx, tab_idx).unwrap(); + SuccessResponse { + id: request.id, + result: ResponseResult::TabInfo { tab }, + } + } + Method::TabClose(target) => { + let Some((ws_idx, tab_idx)) = self.parse_tab_id(&target.tab_id) else { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "tab_not_found".into(), + message: format!("tab {} not found", target.tab_id), + }, + }) + .unwrap(); + }; + let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "tab_not_found".into(), + message: format!("tab {} not found", target.tab_id), + }, + }) + .unwrap(); + }; + if ws.tabs.len() <= 1 { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "tab_close_failed".into(), + message: "cannot close the last tab in a workspace".into(), + }, + }) + .unwrap(); + } + if !ws.close_tab(tab_idx) { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "tab_close_failed".into(), + message: format!("tab {} could not be closed", target.tab_id), + }, + }) + .unwrap(); + } + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::TabClosed, + data: crate::api::schema::EventData::TabClosed { + tab_id: target.tab_id, + workspace_id: self.public_workspace_id(ws_idx), + }, + }); + SuccessResponse { + id: request.id, + result: ResponseResult::Ok {}, + } + } Method::PaneSplit(params) => { let Some((ws_idx, target_pane_id)) = self.parse_pane_id(¶ms.target_pane_id) else { @@ -1083,6 +1402,16 @@ impl App { }) .unwrap(); }; + let Some(tab_idx) = self.state.workspaces.get(ws_idx).and_then(|ws| ws.find_tab_index_for_pane(pane_id)) else { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "pane_not_found".into(), + message: format!("pane {} not found", params.pane_id), + }, + }) + .unwrap(); + }; let requested_lines = params.lines.unwrap_or(80).min(1000) as usize; let text = match params.source { ReadSource::Visible => pane.visible_text(), @@ -1094,6 +1423,7 @@ impl App { read: PaneReadResult { pane_id: params.pane_id, workspace_id, + tab_id: self.public_tab_id(ws_idx, tab_idx).unwrap(), source: params.source, text, revision: 0, @@ -1557,6 +1887,26 @@ impl App { } } + fn tab_info(&self, ws_idx: usize, tab_idx: usize) -> Option<crate::api::schema::TabInfo> { + let ws = self.state.workspaces.get(ws_idx)?; + let tab = ws.tabs.get(tab_idx)?; + let (agg_state, _) = tab + .panes + .values() + .map(|pane| (pane.state, pane.seen)) + .max_by_key(|(state, seen)| tab_attention_priority(*state, *seen)) + .unwrap_or((crate::detect::AgentState::Unknown, true)); + Some(crate::api::schema::TabInfo { + tab_id: self.public_tab_id(ws_idx, tab_idx)?, + workspace_id: self.public_workspace_id(ws_idx), + number: tab_idx + 1, + label: tab.display_name(), + focused: self.state.active == Some(ws_idx) && ws.active_tab == tab_idx, + pane_count: tab.panes.len(), + agent_state: pane_agent_state(agg_state), + }) + } + fn pane_info( &self, ws_idx: usize, @@ -1565,13 +1915,16 @@ impl App { let ws = self.state.workspaces.get(ws_idx)?; let pane = ws.pane_state(pane_id)?; let runtime = ws.runtime(pane_id); + let tab_idx = ws.find_tab_index_for_pane(pane_id)?; let focused = self.state.active == Some(ws_idx) + && ws.active_tab == tab_idx && ws .focused_pane_id() .is_some_and(|focused| focused == pane_id); Some(crate::api::schema::PaneInfo { pane_id: self.public_pane_id(ws_idx, pane_id)?, workspace_id: self.public_workspace_id(ws_idx), + tab_id: self.public_tab_id(ws_idx, tab_idx)?, focused, cwd: runtime .and_then(|rt| rt.cwd()) @@ -1614,11 +1967,23 @@ impl App { label: ws.display_name(), focused: self.state.active == Some(index), pane_count: ws.public_pane_numbers.len(), + tab_count: ws.tabs.len(), + active_tab_id: self.public_tab_id(index, ws.active_tab).unwrap_or_else(|| format!("{}:{}", index + 1, ws.active_tab + 1)), agent_state: pane_agent_state(agg_state), } } } +fn tab_attention_priority(state: crate::detect::AgentState, seen: bool) -> u8 { + match (state, seen) { + (crate::detect::AgentState::Blocked, _) => 4, + (crate::detect::AgentState::Idle, false) => 3, + (crate::detect::AgentState::Working, _) => 2, + (crate::detect::AgentState::Idle, true) => 1, + (crate::detect::AgentState::Unknown, _) => 0, + } +} + fn parse_api_key(key: &str) -> Option<crossterm::event::KeyEvent> { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; @@ -8,8 +8,8 @@ use crate::api; use crate::api::schema::{ EmptyParams, Method, OutputMatch, PaneAgentState, PaneListParams, PaneReadParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneTarget, PaneWaitForOutputParams, - ReadSource, Request, SplitDirection, Subscription, WorkspaceCreateParams, - WorkspaceRenameParams, WorkspaceTarget, + ReadSource, Request, SplitDirection, Subscription, TabCreateParams, TabListParams, TabRenameParams, + TabTarget, WorkspaceCreateParams, WorkspaceRenameParams, WorkspaceTarget, }; pub enum CommandOutcome { @@ -24,6 +24,7 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> { let exit_code = match command { "workspace" => run_workspace_command(&args[2..])?, + "tab" => run_tab_command(&args[2..])?, "pane" => run_pane_command(&args[2..])?, "wait" => run_wait_command(&args[2..])?, "integration" => run_integration_command(&args[2..])?, @@ -53,6 +54,26 @@ fn run_workspace_command(args: &[String]) -> std::io::Result<i32> { } } +fn run_tab_command(args: &[String]) -> std::io::Result<i32> { + let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { + print_tab_help(); + return Ok(2); + }; + + match subcommand { + "list" => tab_list(&args[1..]), + "create" => tab_create(&args[1..]), + "get" => tab_get(&args[1..]), + "focus" => tab_focus(&args[1..]), + "rename" => tab_rename(&args[1..]), + "close" => tab_close(&args[1..]), + _ => { + print_tab_help(); + Ok(2) + } + } +} + fn run_pane_command(args: &[String]) -> std::io::Result<i32> { let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { print_pane_help(); @@ -220,6 +241,147 @@ fn workspace_close(args: &[String]) -> std::io::Result<i32> { })?) } +fn tab_list(args: &[String]) -> std::io::Result<i32> { + let mut workspace_id = None; + + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--workspace" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --workspace"); + return Ok(2); + }; + workspace_id = Some(normalize_workspace_id(value)); + index += 2; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + print_response(&send_request(&Request { + id: "cli:tab:list".into(), + method: Method::TabList(TabListParams { workspace_id }), + })?) +} + +fn tab_create(args: &[String]) -> std::io::Result<i32> { + let mut workspace_id = None; + let mut cwd = None; + let mut focus = true; + + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--workspace" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --workspace"); + return Ok(2); + }; + workspace_id = Some(normalize_workspace_id(value)); + index += 2; + } + "--cwd" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --cwd"); + return Ok(2); + }; + cwd = Some(value.clone()); + index += 2; + } + "--no-focus" => { + focus = false; + index += 1; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + print_response(&send_request(&Request { + id: "cli:tab:create".into(), + method: Method::TabCreate(TabCreateParams { + workspace_id, + cwd, + focus, + }), + })?) +} + +fn tab_get(args: &[String]) -> std::io::Result<i32> { + let Some(raw_tab_id) = args.first() else { + eprintln!("usage: herdr tab get <tab_id>"); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr tab get <tab_id>"); + return Ok(2); + } + + print_response(&send_request(&Request { + id: "cli:tab:get".into(), + method: Method::TabGet(TabTarget { + tab_id: normalize_tab_id(raw_tab_id), + }), + })?) +} + +fn tab_focus(args: &[String]) -> std::io::Result<i32> { + let Some(raw_tab_id) = args.first() else { + eprintln!("usage: herdr tab focus <tab_id>"); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr tab focus <tab_id>"); + return Ok(2); + } + + print_response(&send_request(&Request { + id: "cli:tab:focus".into(), + method: Method::TabFocus(TabTarget { + tab_id: normalize_tab_id(raw_tab_id), + }), + })?) +} + +fn tab_rename(args: &[String]) -> std::io::Result<i32> { + if args.len() < 2 { + eprintln!("usage: herdr tab rename <tab_id> <label>"); + return Ok(2); + } + + print_response(&send_request(&Request { + id: "cli:tab:rename".into(), + method: Method::TabRename(TabRenameParams { + tab_id: normalize_tab_id(&args[0]), + label: args[1..].join(" "), + }), + })?) +} + +fn tab_close(args: &[String]) -> std::io::Result<i32> { + let Some(raw_tab_id) = args.first() else { + eprintln!("usage: herdr tab close <tab_id>"); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr tab close <tab_id>"); + return Ok(2); + } + + print_response(&send_request(&Request { + id: "cli:tab:close".into(), + method: Method::TabClose(TabTarget { + tab_id: normalize_tab_id(raw_tab_id), + }), + })?) +} + fn pane_list(args: &[String]) -> std::io::Result<i32> { let mut workspace_id = None; @@ -832,6 +994,10 @@ fn normalize_workspace_id(value: &str) -> String { value.to_string() } +fn normalize_tab_id(value: &str) -> String { + value.to_string() +} + fn normalize_pane_id(value: &str) -> String { value.to_string() } @@ -890,6 +1056,16 @@ fn print_workspace_help() { eprintln!(" herdr workspace close <workspace_id>"); } +fn print_tab_help() { + eprintln!("herdr tab commands:"); + eprintln!(" herdr tab list [--workspace <workspace_id>]"); + eprintln!(" herdr tab create [--workspace <workspace_id>] [--cwd PATH] [--no-focus]"); + eprintln!(" herdr tab get <tab_id>"); + eprintln!(" herdr tab focus <tab_id>"); + eprintln!(" herdr tab rename <tab_id> <label>"); + eprintln!(" herdr tab close <tab_id>"); +} + fn print_pane_help() { eprintln!("herdr pane commands:"); eprintln!(" herdr pane list [--workspace <workspace_id>]"); diff --git a/src/main.rs b/src/main.rs index 4d0a9442..385f6f5f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -197,6 +197,7 @@ fn main() -> io::Result<()> { println!("Usage: herdr [options]"); println!(" herdr update"); println!(" herdr workspace <subcommand> ..."); + println!(" herdr tab <subcommand> ..."); println!(" herdr pane <subcommand> ..."); println!(" herdr wait <subcommand> ..."); println!(" herdr integration <subcommand> ..."); @@ -204,6 +205,7 @@ fn main() -> io::Result<()> { println!("Commands:"); println!(" update Download and install the latest version"); println!(" workspace workspace helpers over the socket api"); + println!(" tab tab helpers over the socket api"); println!(" pane pane control helpers over the socket api"); println!(" wait blocking wait helpers over the socket api"); println!(" integration manage built-in agent integrations"); diff --git a/tests/api_ping.rs b/tests/api_ping.rs index 6b7cbf24..94e1e3f9 100644 --- a/tests/api_ping.rs +++ b/tests/api_ping.rs @@ -230,6 +230,8 @@ fn workspace_list_and_create_round_trip() { assert_eq!(created["result"]["workspace"]["workspace_id"], "1"); 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"]["workspace"]["active_tab_id"], "1:1"); let listed = send_request( &socket_path, @@ -252,6 +254,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"], "1"); + assert_eq!(panes[0]["tab_id"], "1:1"); let pane_id = panes[0]["pane_id"].as_str().unwrap().to_string(); let pane = send_request( @@ -271,6 +274,7 @@ fn workspace_list_and_create_round_trip() { ), ); assert_eq!(read["result"]["read"]["pane_id"], pane_id); + assert_eq!(read["result"]["read"]["tab_id"], "1:1"); assert!(read["result"]["read"]["text"].is_string()); let send_text = send_request( @@ -347,6 +351,72 @@ fn workspace_list_and_create_round_trip() { #[cfg(not(target_os = "macos"))] #[test] +fn tab_methods_round_trip_over_socket() { + 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_t1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#, + base.display() + ), + ); + assert_eq!(created["result"]["workspace"]["active_tab_id"], "1:1"); + + let tab_created = send_request( + &socket_path, + r#"{"id":"req_t2","method":"tab.create","params":{"workspace_id":"1","focus":true}}"#, + ); + assert_eq!(tab_created["result"]["type"], "tab_info"); + assert_eq!(tab_created["result"]["tab"]["tab_id"], "1:2"); + assert_eq!(tab_created["result"]["tab"]["focused"], true); + + let tab_list = send_request( + &socket_path, + r#"{"id":"req_t3","method":"tab.list","params":{"workspace_id":"1"}}"#, + ); + let tabs = tab_list["result"]["tabs"].as_array().unwrap(); + assert_eq!(tabs.len(), 2); + assert_eq!(tabs[0]["tab_id"], "1:1"); + assert_eq!(tabs[1]["tab_id"], "1:2"); + + let tab_get = send_request( + &socket_path, + r#"{"id":"req_t4","method":"tab.get","params":{"tab_id":"1:2"}}"#, + ); + assert_eq!(tab_get["result"]["tab"]["tab_id"], "1:2"); + + let renamed = send_request( + &socket_path, + r#"{"id":"req_t5","method":"tab.rename","params":{"tab_id":"1:2","label":"logs"}}"#, + ); + assert_eq!(renamed["result"]["tab"]["label"], "logs"); + + let focused = send_request( + &socket_path, + r#"{"id":"req_t6","method":"tab.focus","params":{"tab_id":"1:1"}}"#, + ); + assert_eq!(focused["result"]["tab"]["tab_id"], "1:1"); + assert_eq!(focused["result"]["tab"]["focused"], true); + + let closed = send_request( + &socket_path, + r#"{"id":"req_t7","method":"tab.close","params":{"tab_id":"1:2"}}"#, + ); + assert_eq!(closed["result"]["type"], "ok"); + + cleanup_spawned_herdr(child, base); +} + +#[cfg(not(target_os = "macos"))] +#[test] fn events_subscribe_streams_lifecycle_and_agent_events() { let _lock = test_lock(); let base = unique_test_dir(); @@ -382,7 +452,7 @@ fn events_subscribe_streams_lifecycle_and_agent_events() { let mut reader = open_subscription( &socket_path, - r#"{"id":"sub_life","method":"events.subscribe","params":{"subscriptions":[{"type":"workspace.created"},{"type":"workspace.focused"},{"type":"pane.created"},{"type":"pane.focused"},{"type":"pane.agent_detected"},{"type":"pane.closed"},{"type":"workspace.closed"}]}}"#, + r#"{"id":"sub_life","method":"events.subscribe","params":{"subscriptions":[{"type":"workspace.created"},{"type":"workspace.focused"},{"type":"tab.created"},{"type":"tab.focused"},{"type":"tab.renamed"},{"type":"tab.closed"},{"type":"pane.created"},{"type":"pane.focused"},{"type":"pane.agent_detected"},{"type":"pane.closed"},{"type":"workspace.closed"}]}}"#, ); let ack = reader.read_json_line(Duration::from_secs(2)); @@ -410,6 +480,10 @@ fn events_subscribe_streams_lifecycle_and_agent_events() { let workspace_focused = wait_for_event(&mut reader, "workspace_focused", Duration::from_secs(2)); assert_eq!(workspace_focused["data"]["workspace_id"], workspace_id); + let tab_created = wait_for_event(&mut reader, "tab_created", Duration::from_secs(2)); + assert_eq!(tab_created["data"]["tab"]["tab_id"], "1:1"); + let tab_focused = wait_for_event(&mut reader, "tab_focused", Duration::from_secs(2)); + assert_eq!(tab_focused["data"]["tab_id"], "1:1"); let pane_created = wait_for_event(&mut reader, "pane_created", Duration::from_secs(2)); let pane_id = pane_created["data"]["pane"]["pane_id"] .as_str() @@ -439,13 +513,32 @@ fn events_subscribe_streams_lifecycle_and_agent_events() { assert_eq!(agent_detected["data"]["pane_id"], pane_id); assert_eq!(agent_detected["data"]["agent"], "pi"); - let split = send_request( + let new_tab = send_request( &socket_path, &format!( - r#"{{"id":"req_l4","method":"pane.split","params":{{"target_pane_id":"{}","direction":"right","focus":true}}}}"#, - pane_id + r#"{{"id":"req_l4","method":"tab.create","params":{{"workspace_id":"{}","focus":true}}}}"#, + workspace_id ), ); + assert_eq!(new_tab["result"]["tab"]["tab_id"], "1:2"); + let created_tab_event = wait_for_event(&mut reader, "tab_created", Duration::from_secs(2)); + assert_eq!(created_tab_event["data"]["tab"]["tab_id"], "1:2"); + let focused_tab_event = wait_for_event(&mut reader, "tab_focused", Duration::from_secs(2)); + assert_eq!(focused_tab_event["data"]["tab_id"], "1:2"); + + let renamed_tab = send_request( + &socket_path, + r#"{"id":"req_l5","method":"tab.rename","params":{"tab_id":"1:2","label":"logs"}}"#, + ); + assert_eq!(renamed_tab["result"]["tab"]["label"], "logs"); + let renamed_event = wait_for_event(&mut reader, "tab_renamed", Duration::from_secs(2)); + assert_eq!(renamed_event["data"]["tab_id"], "1:2"); + assert_eq!(renamed_event["data"]["label"], "logs"); + + let split = send_request( + &socket_path, + r#"{"id":"req_l6","method":"pane.split","params":{"target_pane_id":"1-2","direction":"right","focus":true}}"#, + ); let split_pane_id = split["result"]["pane"]["pane_id"] .as_str() .unwrap() @@ -456,7 +549,7 @@ fn events_subscribe_streams_lifecycle_and_agent_events() { let closed = send_request( &socket_path, &format!( - r#"{{"id":"req_l5","method":"pane.close","params":{{"pane_id":"{}"}}}}"#, + r#"{{"id":"req_l7","method":"pane.close","params":{{"pane_id":"{}"}}}}"#, split_pane_id ), ); @@ -464,10 +557,18 @@ fn events_subscribe_streams_lifecycle_and_agent_events() { let pane_closed = wait_for_event(&mut reader, "pane_closed", Duration::from_secs(2)); assert_eq!(pane_closed["data"]["pane_id"], split_pane_id); + let closed_tab = send_request( + &socket_path, + r#"{"id":"req_l8","method":"tab.close","params":{"tab_id":"1:2"}}"#, + ); + assert_eq!(closed_tab["result"]["type"], "ok"); + let tab_closed = wait_for_event(&mut reader, "tab_closed", Duration::from_secs(2)); + assert_eq!(tab_closed["data"]["tab_id"], "1:2"); + let closed_ws = send_request( &socket_path, &format!( - r#"{{"id":"req_l6","method":"workspace.close","params":{{"workspace_id":"{}"}}}}"#, + r#"{{"id":"req_l9","method":"workspace.close","params":{{"workspace_id":"{}"}}}}"#, workspace_id ), ); diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs index 37c4406e..0f90baa2 100644 --- a/tests/cli_wrapper.rs +++ b/tests/cli_wrapper.rs @@ -200,6 +200,55 @@ fn workspace_and_pane_management_commands_work() { } #[test] +fn tab_management_commands_work() { + 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 herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let created = run_cli( + &socket_path, + &["workspace", "create", "--cwd", base.to_str().unwrap()], + ); + assert!(created.status.success()); + + let created_tab = run_cli(&socket_path, &["tab", "create", "--workspace", "1"]); + assert!(created_tab.status.success()); + let created_tab_json: serde_json::Value = serde_json::from_slice(&created_tab.stdout).unwrap(); + assert_eq!(created_tab_json["result"]["tab"]["tab_id"], "1:2"); + + let listed_tabs = run_cli(&socket_path, &["tab", "list", "--workspace", "1"]); + assert!(listed_tabs.status.success()); + let listed_tabs_json: serde_json::Value = serde_json::from_slice(&listed_tabs.stdout).unwrap(); + assert_eq!(listed_tabs_json["result"]["tabs"].as_array().unwrap().len(), 2); + + let renamed_tab = run_cli(&socket_path, &["tab", "rename", "1:2", "logs"]); + assert!(renamed_tab.status.success()); + let renamed_tab_json: serde_json::Value = serde_json::from_slice(&renamed_tab.stdout).unwrap(); + assert_eq!(renamed_tab_json["result"]["tab"]["label"], "logs"); + + let focused_tab = run_cli(&socket_path, &["tab", "focus", "1:1"]); + assert!(focused_tab.status.success()); + let focused_tab_json: serde_json::Value = serde_json::from_slice(&focused_tab.stdout).unwrap(); + assert_eq!(focused_tab_json["result"]["tab"]["tab_id"], "1:1"); + + let tab_get = run_cli(&socket_path, &["tab", "get", "1:2"]); + assert!(tab_get.status.success()); + let tab_get_json: serde_json::Value = serde_json::from_slice(&tab_get.stdout).unwrap(); + assert_eq!(tab_get_json["result"]["tab"]["tab_id"], "1:2"); + + let closed_tab = run_cli(&socket_path, &["tab", "close", "1:2"]); + assert!(closed_tab.status.success()); + let closed_tab_json: serde_json::Value = serde_json::from_slice(&closed_tab.stdout).unwrap(); + assert_eq!(closed_tab_json["result"]["type"], "ok"); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] fn pane_run_read_and_wait_commands_work() { let base = unique_test_dir(); let config_home = base.join("config"); |
