aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-03-29 04:23:22 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-03-29 04:23:22 +0300
commitfd1e3b8b056b2937fc0a5c90bf482e01d269f6b2 (patch)
tree4bd41fc4c4682c4fae42e5ea742ee2b93c7672ff /src/app
parent60baa765d942dcc40ee8bf0e58400aa634b6741a (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.rs460
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(&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 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(&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.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(&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();
+ };
+ 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()
}