aboutsummaryrefslogtreecommitdiff
path: root/src
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 /src
parente434904d10da1f2517b2c0411946947c37ffee0d (diff)
feat: add tabs to socket api
Diffstat (limited to 'src')
-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
5 files changed, 668 insertions, 3 deletions
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");