aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-02 23:59:54 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-03 00:04:35 +0300
commit544c7ebebbc1db65a2949642d367edc446ae559b (patch)
treee952f462ab99ebb4375d7952e8994711d30ed019
parente434904d10da1f2517b2c0411946947c37ffee0d (diff)
feat: add tabs to socket api
-rw-r--r--README.md12
-rw-r--r--SKILL.md51
-rw-r--r--SOCKET_API.md158
-rw-r--r--src/api/mod.rs16
-rw-r--r--src/api/schema.rs106
-rw-r--r--src/app/mod.rs367
-rw-r--r--src/cli.rs180
-rw-r--r--src/main.rs2
-rw-r--r--tests/api_ping.rs113
-rw-r--r--tests/cli_wrapper.rs49
10 files changed, 1031 insertions, 23 deletions
diff --git a/README.md b/README.md
index 4f94ab27..7a336f60 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/SKILL.md b/SKILL.md
index 5032bf0e..78d1a3d5 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -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(&params.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(&params.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};
diff --git a/src/cli.rs b/src/cli.rs
index d13f059e..30f11a0e 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -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");