diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-29 04:23:22 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-29 04:23:22 +0300 |
| commit | fd1e3b8b056b2937fc0a5c90bf482e01d269f6b2 (patch) | |
| tree | 4bd41fc4c4682c4fae42e5ea742ee2b93c7672ff /src/app | |
| parent | 60baa765d942dcc40ee8bf0e58400aa634b6741a (diff) | |
feat: add initial socket api for workspace and pane control
add unix socket request/response api scaffolding
implement initial workspace and pane commands:
- ping
- workspace.list
- workspace.get
- workspace.create
- pane.list
- pane.get
- pane.read
- pane.send_text
- pane.send_keys
- pane.wait_for_output
improve pane read semantics:
- visible trims trailing blank viewport rows
- recent reads from parser scrollback/current screen
- wait_for_output returns matched snapshot plus matched_line
add real subprocess integration tests for socket api
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/mod.rs | 460 |
1 files changed, 447 insertions, 13 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs index f8036315..48700577 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -28,13 +28,19 @@ pub struct App { pub state: AppState, pub event_tx: mpsc::Sender<AppEvent>, event_rx: mpsc::Receiver<AppEvent>, + api_rx: std::sync::mpsc::Receiver<crate::api::ApiRequestMessage>, no_session: bool, config_diagnostic_deadline: Option<Instant>, toast_deadline: Option<Instant>, } impl App { - pub fn new(config: &Config, no_session: bool, config_diagnostic: Option<String>) -> Self { + pub fn new( + config: &Config, + no_session: bool, + config_diagnostic: Option<String>, + api_rx: std::sync::mpsc::Receiver<crate::api::ApiRequestMessage>, + ) -> Self { let (prefix_code, prefix_mods) = config.prefix_key(); let (event_tx, event_rx) = mpsc::channel::<AppEvent>(64); @@ -114,6 +120,7 @@ impl App { state, event_tx, event_rx, + api_rx, no_session, } } @@ -141,6 +148,11 @@ impl App { crate::ui::render(&self.state, frame); })?; + while let Ok(msg) = self.api_rx.try_recv() { + let response = self.handle_api_request(msg.request); + let _ = msg.respond_to.send(response); + } + // Drain internal events while let Ok(ev) = self.event_rx.try_recv() { let previous_toast = self.state.toast.clone(); @@ -192,6 +204,265 @@ impl App { Ok(()) } + fn handle_api_request(&mut self, request: crate::api::schema::Request) -> String { + use bytes::Bytes; + + use crate::api::schema::{ + ErrorBody, ErrorResponse, Method, PaneListParams, PaneReadResult, ReadSource, + ResponseResult, SuccessResponse, + }; + + let response = match request.method { + Method::WorkspaceList(_) => SuccessResponse { + id: request.id, + result: ResponseResult::WorkspaceList { + workspaces: self + .state + .workspaces + .iter() + .enumerate() + .map(|(idx, _)| workspace_info(&self.state, idx)) + .collect(), + }, + }, + Method::WorkspaceGet(target) => { + let Some(index) = 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: workspace_info(&self.state, 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) => SuccessResponse { + id: request.id, + result: ResponseResult::WorkspaceInfo { + workspace: workspace_info(&self.state, index), + }, + }, + Err(err) => { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "workspace_create_failed".into(), + message: err.to_string(), + }, + }) + .unwrap(); + } + } + } + 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)) = 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)) = parse_pane_id(¶ms.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 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), + }; + SuccessResponse { + id: request.id, + result: ResponseResult::PaneRead { + read: PaneReadResult { + pane_id: params.pane_id, + workspace_id, + source: params.source, + text, + revision: 0, + truncated: false, + }, + }, + } + } + Method::PaneSendText(params) => { + let Some((ws_idx, pane_id)) = parse_pane_id(¶ms.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.0.try_send(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::PaneSendKeys(params) => { + let Some((ws_idx, pane_id)) = parse_pane_id(¶ms.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(); + }; + for key in params.keys { + let Some(key_event) = parse_api_key(&key) else { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "invalid_key".into(), + message: format!("unsupported key {}", key), + }, + }) + .unwrap(); + }; + let kitty = runtime + .1 + .kitty_keyboard + .load(std::sync::atomic::Ordering::Relaxed); + let bytes = crate::input::encode_key(key_event, kitty); + if let Err(err) = runtime.0.try_send(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 complete_onboarding(&mut self) { let (sound_enabled, toast_enabled) = match self.state.onboarding_selected { 0 => (false, false), @@ -220,7 +491,6 @@ impl App { /// Create a workspace with a real PTY (needs event_tx). fn create_workspace(&mut self) { - let (rows, cols) = self.state.estimate_pane_size(); let initial_cwd = self .state .active @@ -229,17 +499,181 @@ impl App { .and_then(|rt| rt.cwd()) .or_else(|| std::env::current_dir().ok()) .unwrap_or_else(|| std::path::PathBuf::from("/")); - match Workspace::new(initial_cwd, rows, cols, self.event_tx.clone()) { - Ok(ws) => { - self.state.workspaces.push(ws); - let idx = self.state.workspaces.len() - 1; - self.state.switch_workspace(idx); - self.state.mode = Mode::Terminal; - } - Err(e) => { - error!(err = %e, "failed to create workspace"); - self.state.mode = Mode::Navigate; - } + if let Err(e) = self.create_workspace_with_options(initial_cwd, true) { + error!(err = %e, "failed to create workspace"); + self.state.mode = Mode::Navigate; + } + } + + fn create_workspace_with_options( + &mut self, + initial_cwd: std::path::PathBuf, + focus: bool, + ) -> std::io::Result<usize> { + let (rows, cols) = self.state.estimate_pane_size(); + let ws = Workspace::new(initial_cwd, rows, cols, self.event_tx.clone())?; + self.state.workspaces.push(ws); + let idx = self.state.workspaces.len() - 1; + if focus || self.state.active.is_none() { + self.state.switch_workspace(idx); + self.state.mode = Mode::Terminal; } + 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) = 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 + .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.layout + .pane_ids() + .into_iter() + .filter_map(move |pane_id| self.pane_info(ws_idx, pane_id)) + }) + .collect()) + } + } + + 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.panes.get(&pane_id)?; + let runtime = ws.runtimes.get(&pane_id); + Some(crate::api::schema::PaneInfo { + pane_id: format!("p_{}_{}", ws_idx + 1, pane_id.raw()), + workspace_id: format!("w_{}", ws_idx + 1), + focused: self.state.active == Some(ws_idx) && ws.layout.focused() == pane_id, + cwd: runtime + .and_then(|rt| rt.cwd()) + .map(|cwd| cwd.display().to_string()), + agent: pane.detected_agent.map(agent_name), + agent_state: pane_agent_state(pane.state), + 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.runtimes.get(&pane_id)?; + Some((runtime, format!("w_{}", ws_idx + 1))) + } + + fn lookup_runtime_sender( + &self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + ) -> Option<( + &tokio::sync::mpsc::Sender<bytes::Bytes>, + &crate::pane::PaneRuntime, + )> { + let ws = self.state.workspaces.get(ws_idx)?; + let runtime = ws.runtimes.get(&pane_id)?; + Some((&runtime.sender, runtime)) + } +} + +fn parse_api_key(key: &str) -> Option<crossterm::event::KeyEvent> { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + let normalized = key.trim(); + match normalized { + "Enter" | "enter" => Some(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())), + "Tab" | "tab" => Some(KeyEvent::new(KeyCode::Tab, KeyModifiers::empty())), + "Esc" | "esc" => Some(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())), + "Backspace" | "backspace" => Some(KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty())), + "Up" | "up" => Some(KeyEvent::new(KeyCode::Up, KeyModifiers::empty())), + "Down" | "down" => Some(KeyEvent::new(KeyCode::Down, KeyModifiers::empty())), + "Left" | "left" => Some(KeyEvent::new(KeyCode::Left, KeyModifiers::empty())), + "Right" | "right" => Some(KeyEvent::new(KeyCode::Right, KeyModifiers::empty())), + "C-c" | "c-c" | "ctrl+c" => Some(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), + _ if normalized.len() == 1 => normalized + .chars() + .next() + .map(|ch| KeyEvent::new(KeyCode::Char(ch), KeyModifiers::empty())), + _ => None, + } +} + +fn parse_workspace_id(id: &str) -> Option<usize> { + id.strip_prefix("w_")?.parse::<usize>().ok()?.checked_sub(1) +} + +fn parse_pane_id(id: &str) -> Option<(usize, crate::layout::PaneId)> { + let rest = id.strip_prefix("p_")?; + let (ws_raw, pane_raw) = rest.split_once('_')?; + let ws_idx = ws_raw.parse::<usize>().ok()?.checked_sub(1)?; + let pane_id = crate::layout::PaneId::from_raw(pane_raw.parse::<u32>().ok()?); + Some((ws_idx, pane_id)) +} + +fn pane_agent_state(state: crate::detect::AgentState) -> crate::api::schema::PaneAgentState { + match state { + crate::detect::AgentState::Idle => crate::api::schema::PaneAgentState::Idle, + crate::detect::AgentState::Busy => crate::api::schema::PaneAgentState::Busy, + crate::detect::AgentState::Waiting => crate::api::schema::PaneAgentState::Waiting, + crate::detect::AgentState::Unknown => crate::api::schema::PaneAgentState::Unknown, + } +} + +fn workspace_info(state: &AppState, index: usize) -> crate::api::schema::WorkspaceInfo { + let ws = &state.workspaces[index]; + let (agg_state, _) = ws.aggregate_state(); + crate::api::schema::WorkspaceInfo { + workspace_id: format!("w_{}", index + 1), + number: index + 1, + label: ws.display_name(), + focused: state.active == Some(index), + pane_count: ws.panes.len(), + agent_state: pane_agent_state(agg_state), + } +} + +fn agent_name(agent: crate::detect::Agent) -> String { + match agent { + crate::detect::Agent::Pi => "pi", + crate::detect::Agent::Claude => "claude", + crate::detect::Agent::Codex => "codex", + crate::detect::Agent::Gemini => "gemini", + crate::detect::Agent::Cursor => "cursor", + crate::detect::Agent::Cline => "cline", + crate::detect::Agent::OpenCode => "opencode", + crate::detect::Agent::GithubCopilot => "copilot", + crate::detect::Agent::Kimi => "kimi", + crate::detect::Agent::Droid => "droid", + crate::detect::Agent::Amp => "amp", } + .to_string() } |
