aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-13 23:45:46 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-13 23:45:46 +0300
commitb24abba471db0fe618cedfe0ed1059ede8118c64 (patch)
treeb7d4889ea18a3435835b977494a137a20baa1677 /src/app
parent1535a7117a47019f78d306ed4bba2300717357c1 (diff)
refactor extract app api and runtime command modules
Diffstat (limited to 'src/app')
-rw-r--r--src/app/api.rs1229
-rw-r--r--src/app/input.rs138
-rw-r--r--src/app/mod.rs1247
-rw-r--r--src/app/runtime_commands.rs177
4 files changed, 1414 insertions, 1377 deletions
diff --git a/src/app/api.rs b/src/app/api.rs
new file mode 100644
index 00000000..1e969b64
--- /dev/null
+++ b/src/app/api.rs
@@ -0,0 +1,1229 @@
+//! API request handling and response building for [`App`].
+
+use super::{
+ agent_name, detect_state_from_api, encode_api_keys, encode_api_text, pane_agent_status,
+ parse_agent_name, tab_attention_priority, App, Mode,
+};
+
+pub(super) fn api_request_changes_ui(request: &crate::api::schema::Request) -> bool {
+ use crate::api::schema::Method;
+
+ matches!(
+ &request.method,
+ Method::WorkspaceCreate(_)
+ | Method::WorkspaceFocus(_)
+ | Method::WorkspaceRename(_)
+ | Method::WorkspaceClose(_)
+ | Method::TabCreate(_)
+ | Method::TabFocus(_)
+ | Method::TabRename(_)
+ | Method::TabClose(_)
+ | Method::PaneSplit(_)
+ | Method::PaneReportAgent(_)
+ | Method::PaneClearAgentAuthority(_)
+ | Method::PaneReleaseAgent(_)
+ | Method::PaneClose(_)
+ )
+}
+
+impl App {
+ pub(super) fn handle_api_request_message(
+ &mut self,
+ msg: crate::api::ApiRequestMessage,
+ ) -> bool {
+ let changed = api_request_changes_ui(&msg.request);
+ let response = self.handle_api_request(msg.request);
+ let _ = msg.respond_to.send(response);
+ changed
+ }
+
+ pub(super) fn public_workspace_id(&self, ws_idx: usize) -> String {
+ self.state.workspaces[ws_idx].id.clone()
+ }
+
+ pub(super) fn public_tab_id(&self, ws_idx: usize, tab_idx: usize) -> Option<String> {
+ let ws = self.state.workspaces.get(ws_idx)?;
+ ws.tabs.get(tab_idx)?;
+ Some(format!("{}:{}", ws.id, tab_idx + 1))
+ }
+
+ pub(super) fn public_pane_id(
+ &self,
+ ws_idx: usize,
+ pane_id: crate::layout::PaneId,
+ ) -> Option<String> {
+ let ws = self.state.workspaces.get(ws_idx)?;
+ let pane_number = ws.public_pane_number(pane_id)?;
+ Some(format!("{}-{pane_number}", ws.id))
+ }
+
+ pub(super) fn parse_workspace_id(&self, id: &str) -> Option<usize> {
+ self.state
+ .workspaces
+ .iter()
+ .position(|workspace| workspace.id == id)
+ .or_else(|| id.strip_prefix("w_")?.parse::<usize>().ok()?.checked_sub(1))
+ .or_else(|| id.parse::<usize>().ok()?.checked_sub(1))
+ }
+
+ pub(super) fn parse_tab_id(&self, id: &str) -> Option<(usize, usize)> {
+ if let Some(rest) = id.strip_prefix("t_") {
+ let (ws_raw, tab_raw) = rest.rsplit_once('_')?;
+ let ws_idx = self.parse_workspace_id(ws_raw)?;
+ 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.rsplit_once(':')?;
+ let ws_idx = self.parse_workspace_id(ws_raw)?;
+ 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))
+ }
+
+ pub(super) 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.rsplit_once('_') {
+ let ws_idx = self.parse_workspace_id(ws_raw)?;
+ let pane_id = crate::layout::PaneId::from_raw(pane_raw.parse::<u32>().ok()?);
+ return Some((ws_idx, pane_id));
+ }
+
+ let pane_id = crate::layout::PaneId::from_raw(rest.parse::<u32>().ok()?);
+ return self.find_pane(pane_id).map(|(ws_idx, _)| (ws_idx, pane_id));
+ }
+
+ let (ws_raw, pane_number_raw) = id.rsplit_once('-')?;
+ let ws_idx = self.parse_workspace_id(ws_raw)?;
+ let pane_number = pane_number_raw.parse::<usize>().ok()?;
+ let ws = self.state.workspaces.get(ws_idx)?;
+ let pane_id = ws
+ .public_pane_numbers
+ .iter()
+ .find_map(|(pane_id, number)| (*number == pane_number).then_some(*pane_id))?;
+ Some((ws_idx, pane_id))
+ }
+
+ pub(super) fn handle_api_request(&mut self, request: crate::api::schema::Request) -> String {
+ self.drain_internal_events();
+ use bytes::Bytes;
+
+ use crate::api::schema::{
+ ErrorBody, ErrorResponse, Method, PaneListParams, PaneReadResult, ReadSource,
+ ResponseResult, SuccessResponse, TabListParams,
+ };
+
+ let response = match request.method {
+ Method::WorkspaceList(_) => SuccessResponse {
+ id: request.id,
+ result: ResponseResult::WorkspaceList {
+ workspaces: self
+ .state
+ .workspaces
+ .iter()
+ .enumerate()
+ .map(|(idx, _)| self.workspace_info(idx))
+ .collect(),
+ },
+ },
+ Method::WorkspaceGet(target) => {
+ let Some(index) = self.parse_workspace_id(&target.workspace_id) else {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "workspace_not_found".into(),
+ message: format!("workspace {} not found", target.workspace_id),
+ },
+ })
+ .unwrap();
+ };
+ let Some(_) = self.state.workspaces.get(index) else {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "workspace_not_found".into(),
+ message: format!("workspace {} not found", target.workspace_id),
+ },
+ })
+ .unwrap();
+ };
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::WorkspaceInfo {
+ workspace: self.workspace_info(index),
+ },
+ }
+ }
+ Method::WorkspaceCreate(params) => {
+ let cwd = params
+ .cwd
+ .map(std::path::PathBuf::from)
+ .or_else(|| std::env::current_dir().ok())
+ .unwrap_or_else(|| std::path::PathBuf::from("/"));
+ match self.create_workspace_with_options(cwd, params.focus) {
+ Ok(index) => {
+ if let Some(label) = params.label {
+ if let Some(workspace) = self.state.workspaces.get_mut(index) {
+ workspace.set_custom_name(label);
+ }
+ }
+ let workspace = self.workspace_info(index);
+ let tab = self
+ .tab_info(index, 0)
+ .expect("new workspace should have an initial tab");
+ let root_pane = self
+ .root_pane_info(index, 0)
+ .expect("new workspace should have an initial root pane");
+ self.emit_event(crate::api::schema::EventEnvelope {
+ event: crate::api::schema::EventKind::WorkspaceCreated,
+ data: crate::api::schema::EventData::WorkspaceCreated {
+ workspace: workspace.clone(),
+ },
+ });
+ self.emit_event(crate::api::schema::EventEnvelope {
+ event: crate::api::schema::EventKind::TabCreated,
+ data: crate::api::schema::EventData::TabCreated { tab: tab.clone() },
+ });
+ self.emit_event(crate::api::schema::EventEnvelope {
+ event: crate::api::schema::EventKind::PaneCreated,
+ data: crate::api::schema::EventData::PaneCreated {
+ pane: root_pane.clone(),
+ },
+ });
+ SuccessResponse {
+ id: request.id,
+ result: self
+ .workspace_created_result(index)
+ .expect("new workspace should produce a complete create response"),
+ }
+ }
+ Err(err) => {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "workspace_create_failed".into(),
+ message: err.to_string(),
+ },
+ })
+ .unwrap();
+ }
+ }
+ }
+ Method::WorkspaceFocus(target) => {
+ let Some(index) = self.parse_workspace_id(&target.workspace_id) else {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "workspace_not_found".into(),
+ message: format!("workspace {} not found", target.workspace_id),
+ },
+ })
+ .unwrap();
+ };
+ if self.state.workspaces.get(index).is_none() {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "workspace_not_found".into(),
+ message: format!("workspace {} not found", target.workspace_id),
+ },
+ })
+ .unwrap();
+ }
+ self.state.switch_workspace(index);
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::WorkspaceInfo {
+ workspace: self.workspace_info(index),
+ },
+ }
+ }
+ Method::WorkspaceRename(params) => {
+ let Some(index) = self.parse_workspace_id(&params.workspace_id) else {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "workspace_not_found".into(),
+ message: format!("workspace {} not found", params.workspace_id),
+ },
+ })
+ .unwrap();
+ };
+ let Some(ws) = self.state.workspaces.get_mut(index) else {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "workspace_not_found".into(),
+ message: format!("workspace {} not found", params.workspace_id),
+ },
+ })
+ .unwrap();
+ };
+ ws.set_custom_name(params.label.clone());
+ self.schedule_session_save();
+ 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 {
+ workspace: self.workspace_info(index),
+ },
+ }
+ }
+ Method::WorkspaceClose(target) => {
+ let Some(index) = self.parse_workspace_id(&target.workspace_id) else {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "workspace_not_found".into(),
+ message: format!("workspace {} not found", target.workspace_id),
+ },
+ })
+ .unwrap();
+ };
+ if self.state.workspaces.get(index).is_none() {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "workspace_not_found".into(),
+ message: format!("workspace {} not found", target.workspace_id),
+ },
+ })
+ .unwrap();
+ }
+ self.state.selected = index;
+ self.state.close_selected_workspace();
+ self.emit_event(crate::api::schema::EventEnvelope {
+ event: crate::api::schema::EventKind::WorkspaceClosed,
+ data: crate::api::schema::EventData::WorkspaceClosed {
+ workspace_id: target.workspace_id,
+ },
+ });
+ SuccessResponse {
+ id: request.id,
+ 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 crate::api::schema::TabCreateParams {
+ workspace_id,
+ cwd,
+ focus,
+ label,
+ } = params;
+ let ws_idx = 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();
+ };
+ 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 = 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,
+ self.state.pane_scrollback_limit_bytes,
+ self.state.host_terminal_theme,
+ )
+ });
+ match result {
+ Ok(tab_idx) => {
+ if let Some(label) = label {
+ if let Some(tab) = self
+ .state
+ .workspaces
+ .get_mut(ws_idx)
+ .and_then(|ws| ws.tabs.get_mut(tab_idx))
+ {
+ tab.set_custom_name(label);
+ }
+ }
+ if focus {
+ self.state.switch_workspace(ws_idx);
+ self.state.switch_tab(tab_idx);
+ self.state.mode = Mode::Terminal;
+ }
+ self.schedule_session_save();
+ let tab = self.tab_info(ws_idx, tab_idx).unwrap();
+ let root_pane = self
+ .root_pane_info(ws_idx, tab_idx)
+ .expect("new tab should have a root pane");
+ self.emit_event(crate::api::schema::EventEnvelope {
+ event: crate::api::schema::EventKind::TabCreated,
+ data: crate::api::schema::EventData::TabCreated { tab: tab.clone() },
+ });
+ self.emit_event(crate::api::schema::EventEnvelope {
+ event: crate::api::schema::EventKind::PaneCreated,
+ data: crate::api::schema::EventData::PaneCreated {
+ pane: root_pane.clone(),
+ },
+ });
+ SuccessResponse {
+ id: request.id,
+ result: self
+ .tab_created_result(ws_idx, tab_idx)
+ .expect("new tab should produce a complete create response"),
+ }
+ }
+ 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);
+ self.state.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.schedule_session_save();
+ 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.schedule_session_save();
+ 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 {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "pane_not_found".into(),
+ message: format!("pane {} not found", params.target_pane_id),
+ },
+ })
+ .unwrap();
+ };
+ let (rows, cols) = self.state.estimate_pane_size();
+ let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "pane_not_found".into(),
+ message: format!("pane {} not found", params.target_pane_id),
+ },
+ })
+ .unwrap();
+ };
+ ws.layout.focus_pane(target_pane_id);
+ let direction = match params.direction {
+ crate::api::schema::SplitDirection::Right => {
+ ratatui::layout::Direction::Horizontal
+ }
+ crate::api::schema::SplitDirection::Down => {
+ ratatui::layout::Direction::Vertical
+ }
+ };
+ let new_pane_id = match ws.split_focused(
+ direction,
+ rows,
+ cols,
+ params.cwd.map(std::path::PathBuf::from),
+ self.state.pane_scrollback_limit_bytes,
+ self.state.host_terminal_theme,
+ ) {
+ Ok(new_pane_id) => new_pane_id,
+ Err(err) => {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "pane_split_failed".into(),
+ message: err.to_string(),
+ },
+ })
+ .unwrap();
+ }
+ };
+ if !params.focus {
+ ws.layout.focus_pane(target_pane_id);
+ }
+ self.schedule_session_save();
+ let pane = self.pane_info(ws_idx, new_pane_id).unwrap();
+ self.emit_event(crate::api::schema::EventEnvelope {
+ event: crate::api::schema::EventKind::PaneCreated,
+ data: crate::api::schema::EventData::PaneCreated { pane: pane.clone() },
+ });
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::PaneInfo { pane },
+ }
+ }
+ Method::PaneList(PaneListParams { workspace_id }) => {
+ match self.collect_panes_for_workspace(workspace_id.as_deref()) {
+ Ok(panes) => SuccessResponse {
+ id: request.id,
+ result: ResponseResult::PaneList { panes },
+ },
+ Err((code, message)) => {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody { code, message },
+ })
+ .unwrap();
+ }
+ }
+ }
+ Method::PaneGet(target) => {
+ let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "pane_not_found".into(),
+ message: format!("pane {} not found", target.pane_id),
+ },
+ })
+ .unwrap();
+ };
+ let Some(pane) = self.pane_info(ws_idx, pane_id) else {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "pane_not_found".into(),
+ message: format!("pane {} not found", target.pane_id),
+ },
+ })
+ .unwrap();
+ };
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::PaneInfo { pane },
+ }
+ }
+ Method::PaneRead(params) => {
+ let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.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 Some((pane, workspace_id)) = self.lookup_runtime(ws_idx, 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 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(),
+ ReadSource::Recent => pane.recent_text(requested_lines),
+ ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines),
+ };
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::PaneRead {
+ 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,
+ truncated: false,
+ },
+ },
+ }
+ }
+ Method::PaneReportAgent(params) => {
+ let Some((_ws_idx, pane_id)) = self.parse_pane_id(&params.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 Some(agent) = parse_agent_name(&params.agent) else {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "invalid_agent".into(),
+ message: format!("unsupported agent {}", params.agent),
+ },
+ })
+ .unwrap();
+ };
+ self.handle_internal_event(crate::events::AppEvent::HookStateReported {
+ pane_id,
+ source: params.source,
+ agent,
+ state: detect_state_from_api(params.state),
+ message: params.message,
+ });
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::Ok {},
+ }
+ }
+ Method::PaneClearAgentAuthority(params) => {
+ let Some((_ws_idx, pane_id)) = self.parse_pane_id(&params.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();
+ };
+ self.handle_internal_event(crate::events::AppEvent::HookAuthorityCleared {
+ pane_id,
+ source: params.source,
+ });
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::Ok {},
+ }
+ }
+ Method::PaneReleaseAgent(params) => {
+ let Some((_ws_idx, pane_id)) = self.parse_pane_id(&params.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 Some(agent) = parse_agent_name(&params.agent) else {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "invalid_agent".into(),
+ message: format!("unsupported agent {}", params.agent),
+ },
+ })
+ .unwrap();
+ };
+ self.handle_internal_event(crate::events::AppEvent::HookAgentReleased {
+ pane_id,
+ source: params.source,
+ agent,
+ });
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::Ok {},
+ }
+ }
+ Method::PaneSendText(params) => {
+ let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.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 Some(runtime) = self.lookup_runtime_sender(ws_idx, 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();
+ };
+ if let Err(err) = runtime.try_send_bytes(Bytes::from(params.text)) {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "pane_send_failed".into(),
+ message: err.to_string(),
+ },
+ })
+ .unwrap();
+ }
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::Ok {},
+ }
+ }
+ Method::PaneSendInput(params) => {
+ let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.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 Some(runtime) = self.lookup_runtime_sender(ws_idx, 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 encoded_keys = match encode_api_keys(runtime, &params.keys) {
+ Ok(encoded_keys) => encoded_keys,
+ Err(key) => {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "invalid_key".into(),
+ message: format!("unsupported key {key}"),
+ },
+ })
+ .unwrap();
+ }
+ };
+ if !params.text.is_empty() {
+ let text_bytes = encode_api_text(runtime, &params.text);
+ if let Err(err) = runtime.try_send_bytes(Bytes::from(text_bytes)) {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "pane_send_failed".into(),
+ message: err.to_string(),
+ },
+ })
+ .unwrap();
+ }
+ }
+ for bytes in encoded_keys {
+ if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "pane_send_failed".into(),
+ message: err.to_string(),
+ },
+ })
+ .unwrap();
+ }
+ }
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::Ok {},
+ }
+ }
+ Method::PaneClose(target) => {
+ let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "pane_not_found".into(),
+ message: format!("pane {} not found", target.pane_id),
+ },
+ })
+ .unwrap();
+ };
+ let workspace_id = self.state.workspaces[ws_idx].id.clone();
+ let should_close_workspace = {
+ let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "pane_not_found".into(),
+ message: format!("pane {} not found", target.pane_id),
+ },
+ })
+ .unwrap();
+ };
+ ws.close_pane(pane_id)
+ };
+ if should_close_workspace {
+ self.state.selected = ws_idx;
+ self.state.close_selected_workspace();
+ self.emit_event(crate::api::schema::EventEnvelope {
+ event: crate::api::schema::EventKind::PaneClosed,
+ data: crate::api::schema::EventData::PaneClosed {
+ pane_id: target.pane_id.clone(),
+ workspace_id: workspace_id.clone(),
+ },
+ });
+ self.emit_event(crate::api::schema::EventEnvelope {
+ event: crate::api::schema::EventKind::WorkspaceClosed,
+ data: crate::api::schema::EventData::WorkspaceClosed { workspace_id },
+ });
+ } else {
+ self.schedule_session_save();
+ self.emit_event(crate::api::schema::EventEnvelope {
+ event: crate::api::schema::EventKind::PaneClosed,
+ data: crate::api::schema::EventData::PaneClosed {
+ pane_id: target.pane_id,
+ workspace_id,
+ },
+ });
+ }
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::Ok {},
+ }
+ }
+ Method::PaneSendKeys(params) => {
+ let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.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 Some(runtime) = self.lookup_runtime_sender(ws_idx, 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 encoded_keys = match encode_api_keys(runtime, &params.keys) {
+ Ok(encoded_keys) => encoded_keys,
+ Err(key) => {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "invalid_key".into(),
+ message: format!("unsupported key {key}"),
+ },
+ })
+ .unwrap();
+ }
+ };
+ for bytes in encoded_keys {
+ if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "pane_send_failed".into(),
+ message: err.to_string(),
+ },
+ })
+ .unwrap();
+ }
+ }
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::Ok {},
+ }
+ }
+ _ => {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "not_implemented".into(),
+ message: "method not implemented yet".into(),
+ },
+ })
+ .unwrap();
+ }
+ };
+
+ serde_json::to_string(&response).unwrap()
+ }
+
+ pub(super) fn collect_panes_for_workspace(
+ &self,
+ workspace_id: Option<&str>,
+ ) -> Result<Vec<crate::api::schema::PaneInfo>, (String, String)> {
+ if let Some(workspace_id) = workspace_id {
+ let Some(ws_idx) = self.parse_workspace_id(workspace_id) else {
+ return Err((
+ "workspace_not_found".into(),
+ format!("workspace {workspace_id} not found"),
+ ));
+ };
+ let Some(ws) = self.state.workspaces.get(ws_idx) else {
+ return Err((
+ "workspace_not_found".into(),
+ format!("workspace {workspace_id} not found"),
+ ));
+ };
+ Ok(ws
+ .tabs
+ .iter()
+ .flat_map(|tab| tab.layout.pane_ids().into_iter())
+ .filter_map(|pane_id| self.pane_info(ws_idx, pane_id))
+ .collect())
+ } else {
+ Ok(self
+ .state
+ .workspaces
+ .iter()
+ .enumerate()
+ .flat_map(|(ws_idx, ws)| {
+ ws.tabs
+ .iter()
+ .flat_map(|tab| tab.layout.pane_ids().into_iter())
+ .filter_map(move |pane_id| self.pane_info(ws_idx, pane_id))
+ })
+ .collect())
+ }
+ }
+
+ pub(super) 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, seen) = 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_status: pane_agent_status(agg_state, seen),
+ })
+ }
+
+ pub(super) fn workspace_created_result(
+ &self,
+ ws_idx: usize,
+ ) -> Option<crate::api::schema::ResponseResult> {
+ Some(crate::api::schema::ResponseResult::WorkspaceCreated {
+ workspace: self.workspace_info(ws_idx),
+ tab: self.tab_info(ws_idx, 0)?,
+ root_pane: self.root_pane_info(ws_idx, 0)?,
+ })
+ }
+
+ pub(super) fn tab_created_result(
+ &self,
+ ws_idx: usize,
+ tab_idx: usize,
+ ) -> Option<crate::api::schema::ResponseResult> {
+ Some(crate::api::schema::ResponseResult::TabCreated {
+ tab: self.tab_info(ws_idx, tab_idx)?,
+ root_pane: self.root_pane_info(ws_idx, tab_idx)?,
+ })
+ }
+
+ pub(super) fn root_pane_info(
+ &self,
+ ws_idx: usize,
+ tab_idx: usize,
+ ) -> Option<crate::api::schema::PaneInfo> {
+ let ws = self.state.workspaces.get(ws_idx)?;
+ let tab = ws.tabs.get(tab_idx)?;
+ self.pane_info(ws_idx, tab.root_pane)
+ }
+
+ pub(super) fn pane_info(
+ &self,
+ ws_idx: usize,
+ pane_id: crate::layout::PaneId,
+ ) -> Option<crate::api::schema::PaneInfo> {
+ 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())
+ .map(|cwd| cwd.display().to_string()),
+ agent: pane.detected_agent.map(agent_name),
+ agent_status: pane_agent_status(pane.state, pane.seen),
+ revision: 0,
+ })
+ }
+
+ pub(super) fn lookup_runtime(
+ &self,
+ ws_idx: usize,
+ pane_id: crate::layout::PaneId,
+ ) -> Option<(&crate::pane::PaneRuntime, String)> {
+ let ws = self.state.workspaces.get(ws_idx)?;
+ let runtime = ws.runtime(pane_id)?;
+ Some((runtime, self.public_workspace_id(ws_idx)))
+ }
+
+ pub(super) fn lookup_runtime_sender(
+ &self,
+ ws_idx: usize,
+ pane_id: crate::layout::PaneId,
+ ) -> Option<&crate::pane::PaneRuntime> {
+ let ws = self.state.workspaces.get(ws_idx)?;
+ ws.runtime(pane_id)
+ }
+
+ pub(super) fn workspace_info(&self, index: usize) -> crate::api::schema::WorkspaceInfo {
+ let ws = &self.state.workspaces[index];
+ let (agg_state, seen) = ws.aggregate_state();
+ crate::api::schema::WorkspaceInfo {
+ workspace_id: self.public_workspace_id(index),
+ number: index + 1,
+ label: ws.display_name(),
+ focused: self.state.active == Some(index),
+ pane_count: ws.public_pane_numbers.len(),
+ tab_count: ws.tabs.len(),
+ active_tab_id: self
+ .public_tab_id(index, ws.active_tab)
+ .unwrap_or_else(|| format!("{}:{}", ws.id, ws.active_tab + 1)),
+ agent_status: pane_agent_status(agg_state, seen),
+ }
+ }
+}
diff --git a/src/app/input.rs b/src/app/input.rs
index 67f84108..10e5a007 100644
--- a/src/app/input.rs
+++ b/src/app/input.rs
@@ -1,7 +1,5 @@
//! Input handling — translates crossterm key/mouse events into state mutations.
-use std::process::{Command, Stdio};
-
use bytes::Bytes;
use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
@@ -219,142 +217,6 @@ impl App {
}
}
- fn launch_custom_command(&mut self, binding: crate::config::CustomCommandKeybind) {
- let previous_toast = self.state.toast.clone();
- let result = match binding.action {
- crate::config::CustomCommandAction::Shell => self.spawn_custom_command(&binding),
- crate::config::CustomCommandAction::Pane => self.spawn_pane_command(&binding.command),
- };
- match result {
- Ok(()) => leave_navigate_mode(&mut self.state),
- Err(err) => {
- self.state.toast = Some(crate::app::state::ToastNotification {
- kind: crate::app::state::ToastKind::NeedsAttention,
- title: "custom command failed".to_string(),
- context: err.to_string(),
- });
- self.sync_toast_deadline(previous_toast);
- }
- }
- }
-
- fn custom_command_env(&self) -> (Vec<(String, String)>, Option<std::path::PathBuf>) {
- let mut env = vec![(
- crate::api::SOCKET_PATH_ENV_VAR.to_string(),
- crate::api::socket_path().display().to_string(),
- )];
- if let Ok(current_exe) = std::env::current_exe() {
- env.push((
- "HERDR_BIN_PATH".to_string(),
- current_exe.display().to_string(),
- ));
- }
-
- let mut cwd = None;
- if let Some(ws_idx) = self.state.active {
- env.push((
- "HERDR_ACTIVE_WORKSPACE_ID".to_string(),
- self.public_workspace_id(ws_idx),
- ));
- if let Some(workspace) = self.state.workspaces.get(ws_idx) {
- let tab_idx = workspace.active_tab_index();
- if let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) {
- env.push(("HERDR_ACTIVE_TAB_ID".to_string(), tab_id));
- }
- if let Some(pane_id) = workspace.focused_pane_id() {
- if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) {
- env.push(("HERDR_ACTIVE_PANE_ID".to_string(), public_pane_id));
- }
- if let Some(pane_cwd) = workspace
- .active_tab()
- .and_then(|tab| tab.cwd_for_pane(pane_id))
- {
- env.push((
- "HERDR_ACTIVE_PANE_CWD".to_string(),
- pane_cwd.display().to_string(),
- ));
- if pane_cwd.is_dir() {
- cwd = Some(pane_cwd);
- }
- }
- }
- }
- }
- (env, cwd)
- }
-
- fn spawn_custom_command(
- &self,
- binding: &crate::config::CustomCommandKeybind,
- ) -> std::io::Result<()> {
- let mut command = Command::new("/bin/sh");
- command
- .arg("-lc")
- .arg(&binding.command)
- .stdin(Stdio::null())
- .stdout(Stdio::null())
- .stderr(Stdio::null());
- let (env, cwd) = self.custom_command_env();
- command.envs(env);
- if let Some(cwd) = cwd {
- command.current_dir(cwd);
- }
- command.spawn()?;
- Ok(())
- }
-
- fn spawn_pane_command(&mut self, command: &str) -> std::io::Result<()> {
- let Some(ws_idx) = self.state.active else {
- return Err(std::io::Error::other("no active workspace"));
- };
- let (rows, cols) = self.state.estimate_pane_size();
- let new_rows = rows.max(4);
- let new_cols = cols.max(10);
- let (env, _) = self.custom_command_env();
-
- let ws = self
- .state
- .workspaces
- .get_mut(ws_idx)
- .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?;
- let tab_idx = ws.active_tab_index();
- let previous_focus = ws
- .focused_pane_id()
- .ok_or_else(|| std::io::Error::other("no focused pane"))?;
- let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false);
- let cwd = ws
- .active_tab()
- .and_then(|tab| tab.cwd_for_pane(previous_focus));
- let new_pane_id = ws.split_focused_command(
- Direction::Horizontal,
- new_rows,
- new_cols,
- cwd,
- command,
- &env,
- self.state.pane_scrollback_limit_bytes,
- self.state.host_terminal_theme,
- )?;
- ws.active_tab_mut()
- .expect("workspace must have an active tab")
- .layout
- .focus_pane(new_pane_id);
- ws.active_tab_mut()
- .expect("workspace must have an active tab")
- .zoomed = true;
- self.overlay_panes.insert(
- new_pane_id,
- super::OverlayPaneState {
- ws_idx,
- tab_idx,
- previous_focus,
- previous_zoomed,
- },
- );
- self.state.mode = Mode::Terminal;
- Ok(())
- }
-
pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) {
if self.state.mode == Mode::ReleaseNotes {
match mouse.kind {
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 3b3cdbc3..165a6ff3 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -2,10 +2,14 @@
//!
//! - `state.rs` — AppState, Mode, and pure data structs
//! - `actions.rs` — state mutations (testable without PTYs/async)
+//! - `api.rs` — API request handling and response building
//! - `input.rs` — key/mouse → action translation
+//! - `runtime_commands.rs` — custom command launching and overlay restoration
mod actions;
+mod api;
mod input;
+mod runtime_commands;
pub mod state;
use std::collections::{HashMap, HashSet};
@@ -33,17 +37,12 @@ use crate::config::Config;
use crate::events::AppEvent;
use crate::workspace::Workspace;
+#[cfg(test)]
+use self::api::api_request_changes_ui;
+
pub use state::{AppState, Mode, ToastKind, ViewState};
/// Full application: AppState + runtime concerns (event channels, async I/O).
-#[derive(Debug, Clone, Copy)]
-struct OverlayPaneState {
- ws_idx: usize,
- tab_idx: usize,
- previous_focus: crate::layout::PaneId,
- previous_zoomed: bool,
-}
-
pub struct App {
pub state: AppState,
pub event_tx: mpsc::Sender<AppEvent>,
@@ -66,7 +65,7 @@ pub struct App {
suppressed_repeat_keys: HashSet<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
- overlay_panes: HashMap<crate::layout::PaneId, OverlayPaneState>,
+ overlay_panes: HashMap<crate::layout::PaneId, runtime_commands::OverlayPaneState>,
}
enum LoopEvent {
@@ -100,27 +99,6 @@ fn repeat_key_identity(
(key.code, key.modifiers)
}
-fn api_request_changes_ui(request: &crate::api::schema::Request) -> bool {
- use crate::api::schema::Method;
-
- matches!(
- &request.method,
- Method::WorkspaceCreate(_)
- | Method::WorkspaceFocus(_)
- | Method::WorkspaceRename(_)
- | Method::WorkspaceClose(_)
- | Method::TabCreate(_)
- | Method::TabFocus(_)
- | Method::TabRename(_)
- | Method::TabClose(_)
- | Method::PaneSplit(_)
- | Method::PaneReportAgent(_)
- | Method::PaneClearAgentAuthority(_)
- | Method::PaneReleaseAgent(_)
- | Method::PaneClose(_)
- )
-}
-
/// Resolve the palette from config: base theme + optional custom overrides.
fn resolve_palette(config: &crate::config::Config) -> state::Palette {
// Start with the named theme (default: catppuccin)
@@ -535,13 +513,6 @@ impl App {
changed
}
- fn handle_api_request_message(&mut self, msg: crate::api::ApiRequestMessage) -> bool {
- let changed = api_request_changes_ui(&msg.request);
- let response = self.handle_api_request(msg.request);
- let _ = msg.respond_to.send(response);
- changed
- }
-
async fn handle_raw_input_batch(&mut self, first: crate::raw_input::RawInputEvent) -> bool {
let mut changed = self.handle_raw_input_event(first).await;
@@ -845,26 +816,6 @@ impl App {
self.sync_toast_deadline(previous_toast);
}
- fn restore_overlay_after_exit(&mut self, overlay: OverlayPaneState) {
- let Some(ws) = self.state.workspaces.get_mut(overlay.ws_idx) else {
- return;
- };
- if overlay.tab_idx >= ws.tabs.len() {
- return;
- }
-
- ws.active_tab = overlay.tab_idx;
- let tab = &mut ws.tabs[overlay.tab_idx];
- if tab.panes.contains_key(&overlay.previous_focus) {
- tab.layout.focus_pane(overlay.previous_focus);
- }
- tab.zoomed = overlay.previous_zoomed;
-
- if self.state.active == Some(overlay.ws_idx) {
- self.state.mode = Mode::Terminal;
- }
- }
-
fn emit_pane_state_update(&self, update: &crate::app::actions::PaneStateUpdate) {
let Some(pane_id) = self.public_pane_id(update.ws_idx, update.pane_id) else {
return;
@@ -996,1033 +947,6 @@ impl App {
.find_map(|(ws_idx, ws)| ws.pane_state(pane_id).map(|pane| (ws_idx, pane)))
}
- fn public_workspace_id(&self, ws_idx: usize) -> String {
- self.state.workspaces[ws_idx].id.clone()
- }
-
- fn public_tab_id(&self, ws_idx: usize, tab_idx: usize) -> Option<String> {
- let ws = self.state.workspaces.get(ws_idx)?;
- ws.tabs.get(tab_idx)?;
- Some(format!("{}:{}", ws.id, 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)?;
- Some(format!("{}-{pane_number}", ws.id))
- }
-
- fn parse_workspace_id(&self, id: &str) -> Option<usize> {
- self.state
- .workspaces
- .iter()
- .position(|workspace| workspace.id == id)
- .or_else(|| id.strip_prefix("w_")?.parse::<usize>().ok()?.checked_sub(1))
- .or_else(|| 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.rsplit_once('_')?;
- let ws_idx = self.parse_workspace_id(ws_raw)?;
- 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.rsplit_once(':')?;
- let ws_idx = self.parse_workspace_id(ws_raw)?;
- 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.rsplit_once('_') {
- let ws_idx = self.parse_workspace_id(ws_raw)?;
- let pane_id = crate::layout::PaneId::from_raw(pane_raw.parse::<u32>().ok()?);
- return Some((ws_idx, pane_id));
- }
-
- let pane_id = crate::layout::PaneId::from_raw(rest.parse::<u32>().ok()?);
- return self.find_pane(pane_id).map(|(ws_idx, _)| (ws_idx, pane_id));
- }
-
- let (ws_raw, pane_number_raw) = id.rsplit_once('-')?;
- let ws_idx = self.parse_workspace_id(ws_raw)?;
- let pane_number = pane_number_raw.parse::<usize>().ok()?;
- let ws = self.state.workspaces.get(ws_idx)?;
- let pane_id = ws
- .public_pane_numbers
- .iter()
- .find_map(|(pane_id, number)| (*number == pane_number).then_some(*pane_id))?;
- Some((ws_idx, pane_id))
- }
-
- fn handle_api_request(&mut self, request: crate::api::schema::Request) -> String {
- self.drain_internal_events();
- use bytes::Bytes;
-
- use crate::api::schema::{
- ErrorBody, ErrorResponse, Method, PaneListParams, PaneReadResult, ReadSource,
- ResponseResult, SuccessResponse, TabListParams,
- };
-
- let response = match request.method {
- Method::WorkspaceList(_) => SuccessResponse {
- id: request.id,
- result: ResponseResult::WorkspaceList {
- workspaces: self
- .state
- .workspaces
- .iter()
- .enumerate()
- .map(|(idx, _)| self.workspace_info(idx))
- .collect(),
- },
- },
- Method::WorkspaceGet(target) => {
- let Some(index) = self.parse_workspace_id(&target.workspace_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "workspace_not_found".into(),
- message: format!("workspace {} not found", target.workspace_id),
- },
- })
- .unwrap();
- };
- let Some(_) = self.state.workspaces.get(index) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "workspace_not_found".into(),
- message: format!("workspace {} not found", target.workspace_id),
- },
- })
- .unwrap();
- };
- SuccessResponse {
- id: request.id,
- result: ResponseResult::WorkspaceInfo {
- workspace: self.workspace_info(index),
- },
- }
- }
- Method::WorkspaceCreate(params) => {
- let cwd = params
- .cwd
- .map(std::path::PathBuf::from)
- .or_else(|| std::env::current_dir().ok())
- .unwrap_or_else(|| std::path::PathBuf::from("/"));
- match self.create_workspace_with_options(cwd, params.focus) {
- Ok(index) => {
- if let Some(label) = params.label {
- if let Some(workspace) = self.state.workspaces.get_mut(index) {
- workspace.set_custom_name(label);
- }
- }
- let workspace = self.workspace_info(index);
- let tab = self
- .tab_info(index, 0)
- .expect("new workspace should have an initial tab");
- let root_pane = self
- .root_pane_info(index, 0)
- .expect("new workspace should have an initial root pane");
- self.emit_event(crate::api::schema::EventEnvelope {
- event: crate::api::schema::EventKind::WorkspaceCreated,
- data: crate::api::schema::EventData::WorkspaceCreated {
- workspace: workspace.clone(),
- },
- });
- self.emit_event(crate::api::schema::EventEnvelope {
- event: crate::api::schema::EventKind::TabCreated,
- data: crate::api::schema::EventData::TabCreated { tab: tab.clone() },
- });
- self.emit_event(crate::api::schema::EventEnvelope {
- event: crate::api::schema::EventKind::PaneCreated,
- data: crate::api::schema::EventData::PaneCreated {
- pane: root_pane.clone(),
- },
- });
- SuccessResponse {
- id: request.id,
- result: self
- .workspace_created_result(index)
- .expect("new workspace should produce a complete create response"),
- }
- }
- Err(err) => {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "workspace_create_failed".into(),
- message: err.to_string(),
- },
- })
- .unwrap();
- }
- }
- }
- Method::WorkspaceFocus(target) => {
- let Some(index) = self.parse_workspace_id(&target.workspace_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "workspace_not_found".into(),
- message: format!("workspace {} not found", target.workspace_id),
- },
- })
- .unwrap();
- };
- if self.state.workspaces.get(index).is_none() {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "workspace_not_found".into(),
- message: format!("workspace {} not found", target.workspace_id),
- },
- })
- .unwrap();
- }
- self.state.switch_workspace(index);
- SuccessResponse {
- id: request.id,
- result: ResponseResult::WorkspaceInfo {
- workspace: self.workspace_info(index),
- },
- }
- }
- Method::WorkspaceRename(params) => {
- let Some(index) = self.parse_workspace_id(&params.workspace_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "workspace_not_found".into(),
- message: format!("workspace {} not found", params.workspace_id),
- },
- })
- .unwrap();
- };
- let Some(ws) = self.state.workspaces.get_mut(index) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "workspace_not_found".into(),
- message: format!("workspace {} not found", params.workspace_id),
- },
- })
- .unwrap();
- };
- ws.set_custom_name(params.label.clone());
- self.schedule_session_save();
- 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 {
- workspace: self.workspace_info(index),
- },
- }
- }
- Method::WorkspaceClose(target) => {
- let Some(index) = self.parse_workspace_id(&target.workspace_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "workspace_not_found".into(),
- message: format!("workspace {} not found", target.workspace_id),
- },
- })
- .unwrap();
- };
- if self.state.workspaces.get(index).is_none() {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "workspace_not_found".into(),
- message: format!("workspace {} not found", target.workspace_id),
- },
- })
- .unwrap();
- }
- self.state.selected = index;
- self.state.close_selected_workspace();
- self.emit_event(crate::api::schema::EventEnvelope {
- event: crate::api::schema::EventKind::WorkspaceClosed,
- data: crate::api::schema::EventData::WorkspaceClosed {
- workspace_id: target.workspace_id,
- },
- });
- SuccessResponse {
- id: request.id,
- 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 crate::api::schema::TabCreateParams {
- workspace_id,
- cwd,
- focus,
- label,
- } = params;
- let ws_idx = 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();
- };
- 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 = 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,
- self.state.pane_scrollback_limit_bytes,
- self.state.host_terminal_theme,
- )
- });
- match result {
- Ok(tab_idx) => {
- if let Some(label) = label {
- if let Some(tab) = self
- .state
- .workspaces
- .get_mut(ws_idx)
- .and_then(|ws| ws.tabs.get_mut(tab_idx))
- {
- tab.set_custom_name(label);
- }
- }
- if focus {
- self.state.switch_workspace(ws_idx);
- self.state.switch_tab(tab_idx);
- self.state.mode = Mode::Terminal;
- }
- self.schedule_session_save();
- let tab = self.tab_info(ws_idx, tab_idx).unwrap();
- let root_pane = self
- .root_pane_info(ws_idx, tab_idx)
- .expect("new tab should have a root pane");
- self.emit_event(crate::api::schema::EventEnvelope {
- event: crate::api::schema::EventKind::TabCreated,
- data: crate::api::schema::EventData::TabCreated { tab: tab.clone() },
- });
- self.emit_event(crate::api::schema::EventEnvelope {
- event: crate::api::schema::EventKind::PaneCreated,
- data: crate::api::schema::EventData::PaneCreated {
- pane: root_pane.clone(),
- },
- });
- SuccessResponse {
- id: request.id,
- result: self
- .tab_created_result(ws_idx, tab_idx)
- .expect("new tab should produce a complete create response"),
- }
- }
- 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);
- self.state.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.schedule_session_save();
- 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.schedule_session_save();
- 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 {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", params.target_pane_id),
- },
- })
- .unwrap();
- };
- let (rows, cols) = self.state.estimate_pane_size();
- let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", params.target_pane_id),
- },
- })
- .unwrap();
- };
- ws.layout.focus_pane(target_pane_id);
- let direction = match params.direction {
- crate::api::schema::SplitDirection::Right => {
- ratatui::layout::Direction::Horizontal
- }
- crate::api::schema::SplitDirection::Down => {
- ratatui::layout::Direction::Vertical
- }
- };
- let new_pane_id = match ws.split_focused(
- direction,
- rows,
- cols,
- params.cwd.map(std::path::PathBuf::from),
- self.state.pane_scrollback_limit_bytes,
- self.state.host_terminal_theme,
- ) {
- Ok(new_pane_id) => new_pane_id,
- Err(err) => {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_split_failed".into(),
- message: err.to_string(),
- },
- })
- .unwrap();
- }
- };
- if !params.focus {
- ws.layout.focus_pane(target_pane_id);
- }
- self.schedule_session_save();
- let pane = self.pane_info(ws_idx, new_pane_id).unwrap();
- self.emit_event(crate::api::schema::EventEnvelope {
- event: crate::api::schema::EventKind::PaneCreated,
- data: crate::api::schema::EventData::PaneCreated { pane: pane.clone() },
- });
- SuccessResponse {
- id: request.id,
- result: ResponseResult::PaneInfo { pane },
- }
- }
- Method::PaneList(PaneListParams { workspace_id }) => {
- match self.collect_panes_for_workspace(workspace_id.as_deref()) {
- Ok(panes) => SuccessResponse {
- id: request.id,
- result: ResponseResult::PaneList { panes },
- },
- Err((code, message)) => {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody { code, message },
- })
- .unwrap();
- }
- }
- }
- Method::PaneGet(target) => {
- let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", target.pane_id),
- },
- })
- .unwrap();
- };
- let Some(pane) = self.pane_info(ws_idx, pane_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", target.pane_id),
- },
- })
- .unwrap();
- };
- SuccessResponse {
- id: request.id,
- result: ResponseResult::PaneInfo { pane },
- }
- }
- Method::PaneRead(params) => {
- let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.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 Some((pane, workspace_id)) = self.lookup_runtime(ws_idx, 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 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(),
- ReadSource::Recent => pane.recent_text(requested_lines),
- ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines),
- };
- SuccessResponse {
- id: request.id,
- result: ResponseResult::PaneRead {
- 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,
- truncated: false,
- },
- },
- }
- }
- Method::PaneReportAgent(params) => {
- let Some((_ws_idx, pane_id)) = self.parse_pane_id(&params.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 Some(agent) = parse_agent_name(&params.agent) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "invalid_agent".into(),
- message: format!("unsupported agent {}", params.agent),
- },
- })
- .unwrap();
- };
- self.handle_internal_event(crate::events::AppEvent::HookStateReported {
- pane_id,
- source: params.source,
- agent,
- state: detect_state_from_api(params.state),
- message: params.message,
- });
- SuccessResponse {
- id: request.id,
- result: ResponseResult::Ok {},
- }
- }
- Method::PaneClearAgentAuthority(params) => {
- let Some((_ws_idx, pane_id)) = self.parse_pane_id(&params.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();
- };
- self.handle_internal_event(crate::events::AppEvent::HookAuthorityCleared {
- pane_id,
- source: params.source,
- });
- SuccessResponse {
- id: request.id,
- result: ResponseResult::Ok {},
- }
- }
- Method::PaneReleaseAgent(params) => {
- let Some((_ws_idx, pane_id)) = self.parse_pane_id(&params.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 Some(agent) = parse_agent_name(&params.agent) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "invalid_agent".into(),
- message: format!("unsupported agent {}", params.agent),
- },
- })
- .unwrap();
- };
- self.handle_internal_event(crate::events::AppEvent::HookAgentReleased {
- pane_id,
- source: params.source,
- agent,
- });
- SuccessResponse {
- id: request.id,
- result: ResponseResult::Ok {},
- }
- }
- Method::PaneSendText(params) => {
- let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.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 Some(runtime) = self.lookup_runtime_sender(ws_idx, 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();
- };
- if let Err(err) = runtime.try_send_bytes(Bytes::from(params.text)) {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_send_failed".into(),
- message: err.to_string(),
- },
- })
- .unwrap();
- }
- SuccessResponse {
- id: request.id,
- result: ResponseResult::Ok {},
- }
- }
- Method::PaneSendInput(params) => {
- let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.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 Some(runtime) = self.lookup_runtime_sender(ws_idx, 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 encoded_keys = match encode_api_keys(runtime, &params.keys) {
- Ok(encoded_keys) => encoded_keys,
- Err(key) => {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "invalid_key".into(),
- message: format!("unsupported key {key}"),
- },
- })
- .unwrap();
- }
- };
- if !params.text.is_empty() {
- let text_bytes = encode_api_text(runtime, &params.text);
- if let Err(err) = runtime.try_send_bytes(Bytes::from(text_bytes)) {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_send_failed".into(),
- message: err.to_string(),
- },
- })
- .unwrap();
- }
- }
- for bytes in encoded_keys {
- if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_send_failed".into(),
- message: err.to_string(),
- },
- })
- .unwrap();
- }
- }
- SuccessResponse {
- id: request.id,
- result: ResponseResult::Ok {},
- }
- }
- Method::PaneClose(target) => {
- let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", target.pane_id),
- },
- })
- .unwrap();
- };
- let workspace_id = self.state.workspaces[ws_idx].id.clone();
- let should_close_workspace = {
- let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", target.pane_id),
- },
- })
- .unwrap();
- };
- ws.close_pane(pane_id)
- };
- if should_close_workspace {
- self.state.selected = ws_idx;
- self.state.close_selected_workspace();
- self.emit_event(crate::api::schema::EventEnvelope {
- event: crate::api::schema::EventKind::PaneClosed,
- data: crate::api::schema::EventData::PaneClosed {
- pane_id: target.pane_id.clone(),
- workspace_id: workspace_id.clone(),
- },
- });
- self.emit_event(crate::api::schema::EventEnvelope {
- event: crate::api::schema::EventKind::WorkspaceClosed,
- data: crate::api::schema::EventData::WorkspaceClosed { workspace_id },
- });
- } else {
- self.schedule_session_save();
- self.emit_event(crate::api::schema::EventEnvelope {
- event: crate::api::schema::EventKind::PaneClosed,
- data: crate::api::schema::EventData::PaneClosed {
- pane_id: target.pane_id,
- workspace_id,
- },
- });
- }
- SuccessResponse {
- id: request.id,
- result: ResponseResult::Ok {},
- }
- }
- Method::PaneSendKeys(params) => {
- let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.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 Some(runtime) = self.lookup_runtime_sender(ws_idx, 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 encoded_keys = match encode_api_keys(runtime, &params.keys) {
- Ok(encoded_keys) => encoded_keys,
- Err(key) => {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "invalid_key".into(),
- message: format!("unsupported key {key}"),
- },
- })
- .unwrap();
- }
- };
- for bytes in encoded_keys {
- if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_send_failed".into(),
- message: err.to_string(),
- },
- })
- .unwrap();
- }
- }
- SuccessResponse {
- id: request.id,
- result: ResponseResult::Ok {},
- }
- }
- _ => {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "not_implemented".into(),
- message: "method not implemented yet".into(),
- },
- })
- .unwrap();
- }
- };
-
- serde_json::to_string(&response).unwrap()
- }
-
pub(crate) fn dismiss_release_notes(&mut self) {
let preview = self
.state
@@ -2264,161 +1188,6 @@ impl App {
self.schedule_session_save();
Ok(idx)
}
-
- fn collect_panes_for_workspace(
- &self,
- workspace_id: Option<&str>,
- ) -> Result<Vec<crate::api::schema::PaneInfo>, (String, String)> {
- if let Some(workspace_id) = workspace_id {
- let Some(ws_idx) = self.parse_workspace_id(workspace_id) else {
- return Err((
- "workspace_not_found".into(),
- format!("workspace {workspace_id} not found"),
- ));
- };
- let Some(ws) = self.state.workspaces.get(ws_idx) else {
- return Err((
- "workspace_not_found".into(),
- format!("workspace {workspace_id} not found"),
- ));
- };
- Ok(ws
- .tabs
- .iter()
- .flat_map(|tab| tab.layout.pane_ids().into_iter())
- .filter_map(|pane_id| self.pane_info(ws_idx, pane_id))
- .collect())
- } else {
- Ok(self
- .state
- .workspaces
- .iter()
- .enumerate()
- .flat_map(|(ws_idx, ws)| {
- ws.tabs
- .iter()
- .flat_map(|tab| tab.layout.pane_ids().into_iter())
- .filter_map(move |pane_id| self.pane_info(ws_idx, pane_id))
- })
- .collect())
- }
- }
-
- 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, seen) = 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_status: pane_agent_status(agg_state, seen),
- })
- }
-
- fn workspace_created_result(
- &self,
- ws_idx: usize,
- ) -> Option<crate::api::schema::ResponseResult> {
- Some(crate::api::schema::ResponseResult::WorkspaceCreated {
- workspace: self.workspace_info(ws_idx),
- tab: self.tab_info(ws_idx, 0)?,
- root_pane: self.root_pane_info(ws_idx, 0)?,
- })
- }
-
- fn tab_created_result(
- &self,
- ws_idx: usize,
- tab_idx: usize,
- ) -> Option<crate::api::schema::ResponseResult> {
- Some(crate::api::schema::ResponseResult::TabCreated {
- tab: self.tab_info(ws_idx, tab_idx)?,
- root_pane: self.root_pane_info(ws_idx, tab_idx)?,
- })
- }
-
- fn root_pane_info(
- &self,
- ws_idx: usize,
- tab_idx: usize,
- ) -> Option<crate::api::schema::PaneInfo> {
- let ws = self.state.workspaces.get(ws_idx)?;
- let tab = ws.tabs.get(tab_idx)?;
- self.pane_info(ws_idx, tab.root_pane)
- }
-
- fn pane_info(
- &self,
- ws_idx: usize,
- pane_id: crate::layout::PaneId,
- ) -> Option<crate::api::schema::PaneInfo> {
- 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())
- .map(|cwd| cwd.display().to_string()),
- agent: pane.detected_agent.map(agent_name),
- agent_status: pane_agent_status(pane.state, pane.seen),
- revision: 0,
- })
- }
-
- fn lookup_runtime(
- &self,
- ws_idx: usize,
- pane_id: crate::layout::PaneId,
- ) -> Option<(&crate::pane::PaneRuntime, String)> {
- let ws = self.state.workspaces.get(ws_idx)?;
- let runtime = ws.runtime(pane_id)?;
- Some((runtime, self.public_workspace_id(ws_idx)))
- }
-
- fn lookup_runtime_sender(
- &self,
- ws_idx: usize,
- pane_id: crate::layout::PaneId,
- ) -> Option<&crate::pane::PaneRuntime> {
- let ws = self.state.workspaces.get(ws_idx)?;
- ws.runtime(pane_id)
- }
-
- fn workspace_info(&self, index: usize) -> crate::api::schema::WorkspaceInfo {
- let ws = &self.state.workspaces[index];
- let (agg_state, seen) = ws.aggregate_state();
- crate::api::schema::WorkspaceInfo {
- workspace_id: self.public_workspace_id(index),
- number: index + 1,
- label: ws.display_name(),
- focused: self.state.active == Some(index),
- pane_count: ws.public_pane_numbers.len(),
- tab_count: ws.tabs.len(),
- active_tab_id: self
- .public_tab_id(index, ws.active_tab)
- .unwrap_or_else(|| format!("{}:{}", ws.id, ws.active_tab + 1)),
- agent_status: pane_agent_status(agg_state, seen),
- }
- }
}
fn tab_attention_priority(state: crate::detect::AgentState, seen: bool) -> u8 {
diff --git a/src/app/runtime_commands.rs b/src/app/runtime_commands.rs
new file mode 100644
index 00000000..ad9b85bc
--- /dev/null
+++ b/src/app/runtime_commands.rs
@@ -0,0 +1,177 @@
+//! Runtime command launching and overlay pane lifecycle helpers for [`App`].
+
+use std::process::{Command, Stdio};
+
+use ratatui::layout::Direction;
+
+use super::{App, Mode};
+
+#[derive(Debug, Clone, Copy)]
+pub(super) struct OverlayPaneState {
+ ws_idx: usize,
+ tab_idx: usize,
+ previous_focus: crate::layout::PaneId,
+ previous_zoomed: bool,
+}
+
+impl App {
+ pub(super) fn launch_custom_command(&mut self, binding: crate::config::CustomCommandKeybind) {
+ let previous_toast = self.state.toast.clone();
+ let result = match binding.action {
+ crate::config::CustomCommandAction::Shell => self.spawn_custom_command(&binding),
+ crate::config::CustomCommandAction::Pane => self.spawn_pane_command(&binding.command),
+ };
+ match result {
+ Ok(()) => {
+ if self.state.active.is_some() {
+ self.state.mode = Mode::Terminal;
+ }
+ }
+ Err(err) => {
+ self.state.toast = Some(crate::app::state::ToastNotification {
+ kind: crate::app::state::ToastKind::NeedsAttention,
+ title: "custom command failed".to_string(),
+ context: err.to_string(),
+ });
+ self.sync_toast_deadline(previous_toast);
+ }
+ }
+ }
+
+ fn custom_command_env(&self) -> (Vec<(String, String)>, Option<std::path::PathBuf>) {
+ let mut env = vec![(
+ crate::api::SOCKET_PATH_ENV_VAR.to_string(),
+ crate::api::socket_path().display().to_string(),
+ )];
+ if let Ok(current_exe) = std::env::current_exe() {
+ env.push((
+ "HERDR_BIN_PATH".to_string(),
+ current_exe.display().to_string(),
+ ));
+ }
+
+ let mut cwd = None;
+ if let Some(ws_idx) = self.state.active {
+ env.push((
+ "HERDR_ACTIVE_WORKSPACE_ID".to_string(),
+ self.public_workspace_id(ws_idx),
+ ));
+ if let Some(workspace) = self.state.workspaces.get(ws_idx) {
+ let tab_idx = workspace.active_tab_index();
+ if let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) {
+ env.push(("HERDR_ACTIVE_TAB_ID".to_string(), tab_id));
+ }
+ if let Some(pane_id) = workspace.focused_pane_id() {
+ if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) {
+ env.push(("HERDR_ACTIVE_PANE_ID".to_string(), public_pane_id));
+ }
+ if let Some(pane_cwd) = workspace
+ .active_tab()
+ .and_then(|tab| tab.cwd_for_pane(pane_id))
+ {
+ env.push((
+ "HERDR_ACTIVE_PANE_CWD".to_string(),
+ pane_cwd.display().to_string(),
+ ));
+ if pane_cwd.is_dir() {
+ cwd = Some(pane_cwd);
+ }
+ }
+ }
+ }
+ }
+ (env, cwd)
+ }
+
+ fn spawn_custom_command(
+ &self,
+ binding: &crate::config::CustomCommandKeybind,
+ ) -> std::io::Result<()> {
+ let mut command = Command::new("/bin/sh");
+ command
+ .arg("-lc")
+ .arg(&binding.command)
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null());
+ let (env, cwd) = self.custom_command_env();
+ command.envs(env);
+ if let Some(cwd) = cwd {
+ command.current_dir(cwd);
+ }
+ command.spawn()?;
+ Ok(())
+ }
+
+ fn spawn_pane_command(&mut self, command: &str) -> std::io::Result<()> {
+ let Some(ws_idx) = self.state.active else {
+ return Err(std::io::Error::other("no active workspace"));
+ };
+ let (rows, cols) = self.state.estimate_pane_size();
+ let new_rows = rows.max(4);
+ let new_cols = cols.max(10);
+ let (env, _) = self.custom_command_env();
+
+ let ws = self
+ .state
+ .workspaces
+ .get_mut(ws_idx)
+ .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?;
+ let tab_idx = ws.active_tab_index();
+ let previous_focus = ws
+ .focused_pane_id()
+ .ok_or_else(|| std::io::Error::other("no focused pane"))?;
+ let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false);
+ let cwd = ws
+ .active_tab()
+ .and_then(|tab| tab.cwd_for_pane(previous_focus));
+ let new_pane_id = ws.split_focused_command(
+ Direction::Horizontal,
+ new_rows,
+ new_cols,
+ cwd,
+ command,
+ &env,
+ self.state.pane_scrollback_limit_bytes,
+ self.state.host_terminal_theme,
+ )?;
+ ws.active_tab_mut()
+ .expect("workspace must have an active tab")
+ .layout
+ .focus_pane(new_pane_id);
+ ws.active_tab_mut()
+ .expect("workspace must have an active tab")
+ .zoomed = true;
+ self.overlay_panes.insert(
+ new_pane_id,
+ OverlayPaneState {
+ ws_idx,
+ tab_idx,
+ previous_focus,
+ previous_zoomed,
+ },
+ );
+ self.state.mode = Mode::Terminal;
+ Ok(())
+ }
+
+ pub(super) fn restore_overlay_after_exit(&mut self, overlay: OverlayPaneState) {
+ let Some(ws) = self.state.workspaces.get_mut(overlay.ws_idx) else {
+ return;
+ };
+ if overlay.tab_idx >= ws.tabs.len() {
+ return;
+ }
+
+ ws.active_tab = overlay.tab_idx;
+ let tab = &mut ws.tabs[overlay.tab_idx];
+ if tab.panes.contains_key(&overlay.previous_focus) {
+ tab.layout.focus_pane(overlay.previous_focus);
+ }
+ tab.zoomed = overlay.previous_zoomed;
+
+ if self.state.active == Some(overlay.ws_idx) {
+ self.state.mode = Mode::Terminal;
+ }
+ }
+}