aboutsummaryrefslogtreecommitdiff
path: root/src/app
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/app
parente434904d10da1f2517b2c0411946947c37ffee0d (diff)
feat: add tabs to socket api
Diffstat (limited to 'src/app')
-rw-r--r--src/app/mod.rs367
1 files changed, 366 insertions, 1 deletions
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};