From a57b97285c3308fbdcc167ab3da9ae1dd78767b6 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Mon, 23 Mar 2026 00:36:52 +0300 Subject: initial release terminal workspace manager for AI coding agents. single rust binary, runs inside your existing terminal. workspaces, tiled panes, intelligent agent state detection. https://herdr.dev --- src/app/actions.rs | 417 +++++++++++++++++ src/app/input.rs | 562 ++++++++++++++++++++++ src/app/mod.rs | 159 +++++++ src/app/state.rs | 130 ++++++ src/config.rs | 279 +++++++++++ src/detect.rs | 1163 ++++++++++++++++++++++++++++++++++++++++++++++ src/events.rs | 26 ++ src/input.rs | 339 ++++++++++++++ src/layout.rs | 470 +++++++++++++++++++ src/main.rs | 228 +++++++++ src/pane.rs | 370 +++++++++++++++ src/persist.rs | 446 ++++++++++++++++++ src/platform/fallback.rs | 11 + src/platform/linux.rs | 27 ++ src/platform/macos.rs | 98 ++++ src/platform/mod.rs | 19 + src/pty_callbacks.rs | 374 +++++++++++++++ src/selection.rs | 253 ++++++++++ src/ui.rs | 870 ++++++++++++++++++++++++++++++++++ src/update.rs | 359 ++++++++++++++ src/workspace.rs | 220 +++++++++ 21 files changed, 6820 insertions(+) create mode 100644 src/app/actions.rs create mode 100644 src/app/input.rs create mode 100644 src/app/mod.rs create mode 100644 src/app/state.rs create mode 100644 src/config.rs create mode 100644 src/detect.rs create mode 100644 src/events.rs create mode 100644 src/input.rs create mode 100644 src/layout.rs create mode 100644 src/main.rs create mode 100644 src/pane.rs create mode 100644 src/persist.rs create mode 100644 src/platform/fallback.rs create mode 100644 src/platform/linux.rs create mode 100644 src/platform/macos.rs create mode 100644 src/platform/mod.rs create mode 100644 src/pty_callbacks.rs create mode 100644 src/selection.rs create mode 100644 src/ui.rs create mode 100644 src/update.rs create mode 100644 src/workspace.rs (limited to 'src') diff --git a/src/app/actions.rs b/src/app/actions.rs new file mode 100644 index 00000000..8e2e9297 --- /dev/null +++ b/src/app/actions.rs @@ -0,0 +1,417 @@ +//! Pure state mutations on AppState. +//! These don't need channels, async, or PTY runtime. + +use tracing::{info, warn}; + +use crate::detect::AgentState; +use crate::events::AppEvent; +use crate::layout::{find_in_direction, NavDirection, PaneId}; + +use super::state::{AppState, Mode}; + +// --------------------------------------------------------------------------- +// Workspace operations +// --------------------------------------------------------------------------- + +impl AppState { + pub fn switch_workspace(&mut self, idx: usize) { + if idx < self.workspaces.len() { + self.active = Some(idx); + self.selected = idx; + for pane in self.workspaces[idx].panes.values_mut() { + pane.seen = true; + } + } + } + + pub fn close_selected_workspace(&mut self) { + if self.workspaces.is_empty() { + return; + } + let name = self.workspaces[self.selected].name.clone(); + info!(workspace = %name, "workspace closed"); + self.workspaces.remove(self.selected); + if self.workspaces.is_empty() { + self.active = None; + self.selected = 0; + } else { + if self.selected >= self.workspaces.len() { + self.selected = self.workspaces.len() - 1; + } + self.active = Some(self.selected); + } + } +} + +// --------------------------------------------------------------------------- +// Pane operations +// --------------------------------------------------------------------------- + +impl AppState { + pub fn navigate_pane(&mut self, direction: NavDirection) { + let panes = &self.view.pane_infos; + if let Some(focused) = panes.iter().find(|p| p.is_focused) { + if let Some(target) = find_in_direction(focused, direction, panes) { + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + ws.layout.focus_pane(target); + } + } + } + } + + pub fn resize_pane(&mut self, direction: NavDirection) { + if let Some(first) = self.view.pane_infos.first() { + let area = self + .view.pane_infos + .iter() + .fold(first.rect, |acc, p| acc.union(p.rect)); + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + ws.layout.resize_focused(direction, 0.05, area); + } + } + } + + pub fn cycle_pane(&mut self, reverse: bool) { + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + if reverse { + ws.layout.focus_prev(); + } else { + ws.layout.focus_next(); + } + } + } + + pub fn toggle_fullscreen(&mut self) { + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + if ws.layout.pane_count() > 1 { + ws.zoomed = !ws.zoomed; + } + } + } + + pub fn close_pane(&mut self) { + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + ws.close_focused(); + } + } +} + +// --------------------------------------------------------------------------- +// Selection +// --------------------------------------------------------------------------- + +impl AppState { + pub fn clear_selection(&mut self) { + self.selection = None; + } + + pub fn copy_selection(&mut self) { + let sel = match self.selection.as_mut() { + Some(s) => { + if !s.finish() { + self.selection = None; + return; + } + s + } + None => return, + }; + + let ws = match self.active.and_then(|i| self.workspaces.get(i)) { + Some(ws) => ws, + None => return, + }; + + let rt = match ws.runtimes.get(&sel.pane_id) { + Some(r) => r, + None => return, + }; + + if let Ok(parser) = rt.parser.read() { + let text = crate::selection::extract_text(parser.screen(), sel); + if !text.is_empty() { + crate::selection::write_osc52(&text); + info!(len = text.len(), "copied selection to clipboard"); + } + } + } +} + +// --------------------------------------------------------------------------- +// Event handling +// --------------------------------------------------------------------------- + +impl AppState { + pub fn handle_app_event(&mut self, event: AppEvent) { + match event { + AppEvent::PaneDied { pane_id } => self.handle_pane_died(pane_id), + AppEvent::UpdateReady { version } => { + self.update_available = Some(version); + self.update_dismissed = false; + } + AppEvent::StateChanged { + pane_id, + agent, + state, + } => { + for (ws_idx, ws) in self.workspaces.iter_mut().enumerate() { + if let Some(pane) = ws.panes.get_mut(&pane_id) { + let is_active_ws = self.active == Some(ws_idx); + if state == AgentState::Idle + && pane.state != AgentState::Idle + && !is_active_ws + { + pane.seen = false; + } + pane.detected_agent = agent; + pane.state = state; + break; + } + } + } + } + } + + fn handle_pane_died(&mut self, pane_id: PaneId) { + let ws_idx = self + .workspaces + .iter() + .position(|ws| ws.panes.contains_key(&pane_id)); + + let Some(ws_idx) = ws_idx else { + warn!(pane = pane_id.raw(), "PaneDied for unknown pane"); + return; + }; + + let ws = &mut self.workspaces[ws_idx]; + + if ws.layout.pane_count() <= 1 { + self.workspaces.remove(ws_idx); + if self.workspaces.is_empty() { + self.active = None; + self.selected = 0; + if self.mode == Mode::Terminal { + self.mode = Mode::Navigate; + } + } else { + if let Some(active) = self.active { + if active >= self.workspaces.len() { + self.active = Some(self.workspaces.len() - 1); + } + } + if self.selected >= self.workspaces.len() { + self.selected = self.workspaces.len() - 1; + } + } + } else { + if ws.layout.focused() == pane_id { + ws.layout.close_focused(); + } else { + let prev_focus = ws.layout.focused(); + ws.layout.focus_pane(pane_id); + ws.layout.close_focused(); + ws.layout.focus_pane(prev_focus); + } + ws.panes.remove(&pane_id); + ws.runtimes.remove(&pane_id); + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::detect::{Agent, AgentState}; + use crate::workspace::Workspace; + use ratatui::layout::Direction; + + fn app_with_workspaces(names: &[&str]) -> AppState { + let mut state = AppState::test_new(); + for name in names { + let ws = Workspace::test_new(name); + state.workspaces.push(ws); + } + if !state.workspaces.is_empty() { + state.active = Some(0); + state.mode = Mode::Terminal; + } + state + } + + #[test] + fn switch_workspace_updates_active_and_selected() { + let mut state = app_with_workspaces(&["a", "b", "c"]); + state.switch_workspace(2); + assert_eq!(state.active, Some(2)); + assert_eq!(state.selected, 2); + } + + #[test] + fn switch_workspace_marks_panes_seen() { + let mut state = app_with_workspaces(&["a", "b"]); + // Mark a pane in workspace 1 as unseen + let id = *state.workspaces[1].panes.keys().next().unwrap(); + state.workspaces[1].panes.get_mut(&id).unwrap().seen = false; + + state.switch_workspace(1); + assert!(state.workspaces[1].panes.get(&id).unwrap().seen); + } + + #[test] + fn switch_workspace_out_of_bounds_is_noop() { + let mut state = app_with_workspaces(&["a"]); + state.switch_workspace(5); + assert_eq!(state.active, Some(0)); + } + + #[test] + fn close_workspace_adjusts_indices() { + let mut state = app_with_workspaces(&["a", "b", "c"]); + state.selected = 1; + state.active = Some(1); + + state.close_selected_workspace(); + + assert_eq!(state.workspaces.len(), 2); + assert_eq!(state.selected, 1); + assert_eq!(state.active, Some(1)); + assert_eq!(state.workspaces[1].name, "c"); + } + + #[test] + fn close_last_workspace_clears_active() { + let mut state = app_with_workspaces(&["only"]); + state.selected = 0; + state.close_selected_workspace(); + + assert!(state.workspaces.is_empty()); + assert_eq!(state.active, None); + assert_eq!(state.selected, 0); + } + + #[test] + fn close_workspace_at_end_adjusts_selected() { + let mut state = app_with_workspaces(&["a", "b"]); + state.selected = 1; + state.active = Some(1); + + state.close_selected_workspace(); + + assert_eq!(state.workspaces.len(), 1); + assert_eq!(state.selected, 0); + assert_eq!(state.active, Some(0)); + } + + #[test] + fn pane_died_last_pane_removes_workspace() { + let mut state = app_with_workspaces(&["a", "b"]); + let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); + + state.handle_pane_died(pane_id); + + assert_eq!(state.workspaces.len(), 1); + assert_eq!(state.workspaces[0].name, "b"); + } + + #[test] + fn pane_died_last_workspace_enters_navigate() { + let mut state = app_with_workspaces(&["only"]); + state.mode = Mode::Terminal; + let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); + + state.handle_pane_died(pane_id); + + assert!(state.workspaces.is_empty()); + assert_eq!(state.mode, Mode::Navigate); + } + + #[test] + fn pane_died_multi_pane_keeps_workspace() { + let mut state = app_with_workspaces(&["test"]); + let second_id = state.workspaces[0].test_split(Direction::Horizontal); + + state.handle_pane_died(second_id); + + assert_eq!(state.workspaces.len(), 1); + assert_eq!(state.workspaces[0].panes.len(), 1); + } + + #[test] + fn pane_died_unknown_pane_is_noop() { + let mut state = app_with_workspaces(&["test"]); + let fake_id = PaneId::from_raw(9999); + + state.handle_pane_died(fake_id); + + assert_eq!(state.workspaces.len(), 1); + } + + #[test] + fn state_changed_updates_pane() { + let mut state = app_with_workspaces(&["test"]); + let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); + + state.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Pi), + state: AgentState::Busy, + }); + + let pane = state.workspaces[0].panes.get(&pane_id).unwrap(); + assert_eq!(pane.state, AgentState::Busy); + assert_eq!(pane.detected_agent, Some(Agent::Pi)); + } + + #[test] + fn state_changed_idle_in_background_marks_unseen() { + let mut state = app_with_workspaces(&["active", "background"]); + state.active = Some(0); + let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); + + // First set it to Busy + state.workspaces[1].panes.get_mut(&bg_pane_id).unwrap().state = AgentState::Busy; + + // Now transition to Idle while in background + state.handle_app_event(AppEvent::StateChanged { + pane_id: bg_pane_id, + agent: Some(Agent::Pi), + state: AgentState::Idle, + }); + + let pane = state.workspaces[1].panes.get(&bg_pane_id).unwrap(); + assert!(!pane.seen); + } + + #[test] + fn toggle_fullscreen_works() { + let mut state = app_with_workspaces(&["test"]); + state.workspaces[0].test_split(Direction::Horizontal); + + assert!(!state.workspaces[0].zoomed); + state.toggle_fullscreen(); + assert!(state.workspaces[0].zoomed); + state.toggle_fullscreen(); + assert!(!state.workspaces[0].zoomed); + } + + #[test] + fn toggle_fullscreen_single_pane_noop() { + let mut state = app_with_workspaces(&["test"]); + state.toggle_fullscreen(); + assert!(!state.workspaces[0].zoomed); + } + + #[test] + fn close_pane_removes_from_workspace() { + let mut state = app_with_workspaces(&["test"]); + state.workspaces[0].test_split(Direction::Horizontal); + assert_eq!(state.workspaces[0].panes.len(), 2); + + state.close_pane(); + assert_eq!(state.workspaces[0].panes.len(), 1); + } +} diff --git a/src/app/input.rs b/src/app/input.rs new file mode 100644 index 00000000..b9a52bda --- /dev/null +++ b/src/app/input.rs @@ -0,0 +1,562 @@ +//! Input handling — translates crossterm key/mouse events into state mutations. + +use bytes::Bytes; +use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::layout::Direction; +use tracing::warn; + +use crate::layout::{NavDirection, PaneInfo, SplitBorder}; +use crate::selection::Selection; + +use super::state::{AppState, ContextMenuState, DragState, Mode, CONTEXT_MENU_ITEMS}; +use super::App; + +// --------------------------------------------------------------------------- +// Key handling +// --------------------------------------------------------------------------- + +impl App { + pub(super) async fn handle_key(&mut self, key: KeyEvent) { + match self.state.mode { + Mode::Navigate => handle_navigate_key(&mut self.state, key), + Mode::Terminal => self.handle_terminal_key(key).await, + Mode::CreateSession => handle_create_key(self, key), + Mode::RenameSession => handle_rename_key(&mut self.state, key), + Mode::Resize => handle_resize_key(&mut self.state, key), + Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key), + Mode::ContextMenu => handle_context_menu_key(&mut self.state, key), + } + } + + pub(super) async fn handle_paste(&mut self, text: String) { + if self.state.mode != Mode::Terminal { + return; + } + if let Some(ws) = self.state.active.and_then(|i| self.state.workspaces.get(i)) { + if let Some(rt) = ws.focused_runtime() { + let bracketed = rt + .parser + .read() + .map(|p| p.screen().bracketed_paste()) + .unwrap_or(false); + + let payload = if bracketed { + format!("\x1b[200~{text}\x1b[201~") + } else { + text + }; + let _ = rt.sender.send(Bytes::from(payload)).await; + } + } + } + + async fn handle_terminal_key(&mut self, key: KeyEvent) { + self.state.clear_selection(); + self.state.update_dismissed = true; + + if self.state.is_prefix(&key) { + self.state.mode = Mode::Navigate; + return; + } + + if let Some(ws) = self.state.active.and_then(|i| self.state.workspaces.get(i)) { + if let Some(rt) = ws.focused_runtime() { + rt.scroll_reset(); + let kitty = rt.kitty_keyboard.load(std::sync::atomic::Ordering::Relaxed); + let bytes = crate::input::encode_key(key, kitty); + if bytes.is_empty() { + warn!(code = ?key.code, mods = ?key.modifiers, state = ?key.state, "key produced empty encoding"); + } else { + let _ = rt.sender.send(Bytes::from(bytes)).await; + } + } + } + } +} + +fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { + state.update_dismissed = true; + + if state.is_prefix(&key) || key.code == KeyCode::Esc { + if state.active.is_some() { + state.mode = Mode::Terminal; + } + return; + } + + // Configurable keybinds (checked before fixed binds) + let kb = &state.keybinds; + if key.code == kb.split_vertical.0 && key.modifiers.contains(kb.split_vertical.1) { + state.split_pane(Direction::Horizontal); + return; + } + if key.code == kb.split_horizontal.0 && key.modifiers.contains(kb.split_horizontal.1) { + state.split_pane(Direction::Vertical); + return; + } + if key.code == kb.close_pane.0 && key.modifiers.contains(kb.close_pane.1) { + state.close_pane(); + return; + } + if key.code == kb.fullscreen.0 && key.modifiers.contains(kb.fullscreen.1) { + state.toggle_fullscreen(); + return; + } + + match key.code { + KeyCode::Char('q') => state.should_quit = true, + KeyCode::Char('n') => { + state.mode = Mode::CreateSession; + state.name_input.clear(); + } + KeyCode::Char('N') => { + if !state.workspaces.is_empty() { + state.name_input = state.workspaces[state.selected].name.clone(); + state.mode = Mode::RenameSession; + } + } + KeyCode::Char('d') => { + if !state.workspaces.is_empty() { + if state.confirm_close { + state.mode = Mode::ConfirmClose; + } else { + state.close_selected_workspace(); + if state.workspaces.is_empty() { + state.mode = Mode::Navigate; + } else { + state.mode = Mode::Terminal; + } + } + } + } + KeyCode::Up => { + if state.selected > 0 { + state.selected -= 1; + } + } + KeyCode::Down => { + if !state.workspaces.is_empty() && state.selected < state.workspaces.len() - 1 { + state.selected += 1; + } + } + KeyCode::Enter => { + if !state.workspaces.is_empty() { + state.switch_workspace(state.selected); + state.mode = Mode::Terminal; + } + } + KeyCode::Char('h') | KeyCode::Left => state.navigate_pane(NavDirection::Left), + KeyCode::Char('j') => state.navigate_pane(NavDirection::Down), + KeyCode::Char('k') => state.navigate_pane(NavDirection::Up), + KeyCode::Char('l') | KeyCode::Right => state.navigate_pane(NavDirection::Right), + KeyCode::Tab => state.cycle_pane(false), + KeyCode::BackTab => state.cycle_pane(true), + KeyCode::Char('r') => state.mode = Mode::Resize, + KeyCode::Char('b') => state.sidebar_collapsed = !state.sidebar_collapsed, + KeyCode::Char(c @ '1'..='9') => { + let idx = (c as usize) - ('1' as usize); + if idx < state.workspaces.len() { + state.switch_workspace(idx); + state.mode = Mode::Terminal; + } + } + _ => {} + } +} + +fn handle_create_key(app: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Enter => { + let name = if app.state.name_input.trim().is_empty() { + format!("workspace-{}", app.state.workspaces.len() + 1) + } else { + app.state.name_input.trim().to_string() + }; + app.state.name_input.clear(); + app.create_workspace(name); + } + KeyCode::Esc => { + app.state.name_input.clear(); + app.state.mode = Mode::Navigate; + } + KeyCode::Backspace => { + app.state.name_input.pop(); + } + KeyCode::Char(c) => { + app.state.name_input.push(c); + } + _ => {} + } +} + +fn handle_rename_key(state: &mut AppState, key: KeyEvent) { + match key.code { + KeyCode::Enter => { + let new_name = if state.name_input.trim().is_empty() { + state.name_input.clone() + } else { + state.name_input.trim().to_string() + }; + if !new_name.is_empty() && !state.workspaces.is_empty() { + state.workspaces[state.selected].name = new_name; + } + state.name_input.clear(); + state.mode = Mode::Navigate; + } + KeyCode::Esc => { + state.name_input.clear(); + state.mode = Mode::Navigate; + } + KeyCode::Backspace => { + state.name_input.pop(); + } + KeyCode::Char(c) => { + state.name_input.push(c); + } + _ => {} + } +} + +fn handle_resize_key(state: &mut AppState, key: KeyEvent) { + match key.code { + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('r') => { + if state.active.is_some() { + state.mode = Mode::Terminal; + } else { + state.mode = Mode::Navigate; + } + } + KeyCode::Char('h') | KeyCode::Left => state.resize_pane(NavDirection::Left), + KeyCode::Char('l') | KeyCode::Right => state.resize_pane(NavDirection::Right), + KeyCode::Char('j') | KeyCode::Down => state.resize_pane(NavDirection::Down), + KeyCode::Char('k') | KeyCode::Up => state.resize_pane(NavDirection::Up), + _ => {} + } +} + +fn handle_confirm_close_key(state: &mut AppState, key: KeyEvent) { + match key.code { + KeyCode::Char('y') | KeyCode::Enter => { + state.close_selected_workspace(); + if state.workspaces.is_empty() { + state.mode = Mode::Navigate; + } else { + state.mode = Mode::Terminal; + } + } + _ => { + state.mode = Mode::Navigate; + } + } +} + +fn handle_context_menu_key(state: &mut AppState, key: KeyEvent) { + match key.code { + KeyCode::Esc => { + state.context_menu = None; + state.mode = Mode::Navigate; + } + KeyCode::Up => { + if let Some(menu) = &mut state.context_menu { + if menu.selected > 0 { + menu.selected -= 1; + } + } + } + KeyCode::Down => { + if let Some(menu) = &mut state.context_menu { + if menu.selected < CONTEXT_MENU_ITEMS.len() - 1 { + menu.selected += 1; + } + } + } + KeyCode::Enter => { + if let Some(menu) = state.context_menu.take() { + match CONTEXT_MENU_ITEMS[menu.selected] { + "Rename" => { + state.selected = menu.ws_idx; + state.name_input = state.workspaces[menu.ws_idx].name.clone(); + state.mode = Mode::RenameSession; + } + "Close" => { + state.selected = menu.ws_idx; + if state.confirm_close { + state.mode = Mode::ConfirmClose; + } else { + state.close_selected_workspace(); + state.mode = Mode::Navigate; + } + } + _ => state.mode = Mode::Navigate, + } + } + } + _ => {} + } +} + +// --------------------------------------------------------------------------- +// Mouse handling +// --------------------------------------------------------------------------- + +impl AppState { + pub(crate) fn handle_mouse(&mut self, mouse: MouseEvent) { + let sidebar = self.view.sidebar_rect; + let in_sidebar = mouse.column >= sidebar.x + && mouse.column < sidebar.x + sidebar.width + && mouse.row >= sidebar.y + && mouse.row < sidebar.y + sidebar.height; + + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => { + self.selection = None; + + if self.mode == Mode::ContextMenu { + if let Some(menu) = &self.context_menu { + let item_idx = self.context_menu_item_at(mouse.column, mouse.row); + if let Some(idx) = item_idx { + let ws_idx = menu.ws_idx; + self.context_menu = None; + match CONTEXT_MENU_ITEMS[idx] { + "Rename" => { + self.selected = ws_idx; + self.name_input = self.workspaces[ws_idx].name.clone(); + self.mode = Mode::RenameSession; + } + "Close" => { + self.selected = ws_idx; + if self.confirm_close { + self.mode = Mode::ConfirmClose; + } else { + self.close_selected_workspace(); + self.mode = Mode::Navigate; + } + } + _ => self.mode = Mode::Navigate, + } + } else { + self.context_menu = None; + self.mode = Mode::Navigate; + } + } + return; + } + + if !in_sidebar { + if let Some(border) = self.find_border_at(mouse.column, mouse.row) { + self.drag = Some(DragState { + path: border.path.clone(), + direction: border.direction, + area: border.area, + }); + return; + } + } + + if in_sidebar { + let bottom_y = sidebar.y + sidebar.height.saturating_sub(1); + if mouse.row == bottom_y { + self.sidebar_collapsed = !self.sidebar_collapsed; + return; + } + + let inner_y = sidebar.y + 1; + if mouse.row >= inner_y { + let idx = (mouse.row - inner_y) as usize; + if idx < self.workspaces.len() { + self.switch_workspace(idx); + self.mode = Mode::Terminal; + } + } + } else if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { + let (row, col) = ( + mouse.row - info.inner_rect.y, + mouse.column - info.inner_rect.x, + ); + self.selection = Some(Selection::anchor( + info.id, + row, + col, + info.inner_rect, + )); + + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + if ws.layout.focused() != info.id { + ws.layout.focus_pane(info.id); + } + } + if self.mode != Mode::Terminal { + self.mode = Mode::Terminal; + } + } else { + if let Some(info) = self.view.pane_infos.iter().find(|p| { + mouse.column >= p.rect.x + && mouse.column < p.rect.x + p.rect.width + && mouse.row >= p.rect.y + && mouse.row < p.rect.y + p.rect.height + }) { + let id = info.id; + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + if ws.layout.focused() != id { + ws.layout.focus_pane(id); + } + } + if self.mode != Mode::Terminal { + self.mode = Mode::Terminal; + } + } + } + } + + MouseEventKind::Drag(MouseButton::Left) => { + if let Some(drag) = &self.drag { + let ratio = match drag.direction { + Direction::Horizontal => { + (mouse.column.saturating_sub(drag.area.x)) as f32 + / drag.area.width.max(1) as f32 + } + Direction::Vertical => { + (mouse.row.saturating_sub(drag.area.y)) as f32 + / drag.area.height.max(1) as f32 + } + }; + let ratio = ratio.clamp(0.1, 0.9); + let path = drag.path.clone(); + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + ws.layout.set_ratio_at(&path, ratio); + } + } else if let Some(sel) = &mut self.selection { + sel.drag(mouse.column, mouse.row); + } + } + + MouseEventKind::Up(MouseButton::Left) => { + if self.drag.take().is_some() { + // Drag ended + } else { + let was_click = self + .selection + .as_ref() + .is_some_and(|s| s.was_just_click()); + if was_click { + self.selection = None; + } else { + self.copy_selection(); + } + } + } + + MouseEventKind::ScrollUp if !in_sidebar => { + self.selection = None; + if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { + if let Some(rt) = ws.focused_runtime() { + rt.scroll_up(3); + } + } + } + MouseEventKind::ScrollDown if !in_sidebar => { + self.selection = None; + if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { + if let Some(rt) = ws.focused_runtime() { + rt.scroll_down(3); + } + } + } + + MouseEventKind::ScrollUp if in_sidebar => { + if self.selected > 0 { + self.selected -= 1; + } + } + MouseEventKind::ScrollDown if in_sidebar => { + if !self.workspaces.is_empty() && self.selected < self.workspaces.len() - 1 { + self.selected += 1; + } + } + + MouseEventKind::Down(MouseButton::Right) if in_sidebar => { + let inner_y = sidebar.y + 1; + if mouse.row >= inner_y { + let idx = (mouse.row - inner_y) as usize; + if idx < self.workspaces.len() { + self.selected = idx; + self.context_menu = Some(ContextMenuState { + ws_idx: idx, + x: mouse.column, + y: mouse.row, + selected: 0, + }); + self.mode = Mode::ContextMenu; + } + } + } + + _ => {} + } + } + + fn context_menu_item_at(&self, col: u16, row: u16) -> Option { + let menu = self.context_menu.as_ref()?; + let menu_w = 14u16; + let inner_x = menu.x; + let inner_y = menu.y + 1; + if col >= inner_x + && col < inner_x + menu_w + && row >= inner_y + && row < inner_y + CONTEXT_MENU_ITEMS.len() as u16 + { + Some((row - inner_y) as usize) + } else { + None + } + } + + fn find_border_at(&self, col: u16, row: u16) -> Option<&SplitBorder> { + self.view.split_borders.iter().find(|b| match b.direction { + Direction::Horizontal => { + (col as i32 - b.pos as i32).unsigned_abs() <= 1 + && row >= b.area.y + && row < b.area.y + b.area.height + } + Direction::Vertical => { + (row as i32 - b.pos as i32).unsigned_abs() <= 1 + && col >= b.area.x + && col < b.area.x + b.area.width + } + }) + } + + fn pane_at(&self, col: u16, row: u16) -> Option<&PaneInfo> { + self.view.pane_infos.iter().find(|p| { + col >= p.inner_rect.x + && col < p.inner_rect.x + p.inner_rect.width + && row >= p.inner_rect.y + && row < p.inner_rect.y + p.inner_rect.height + }) + } +} + +// Note: split_pane needs runtime (event_tx for PTY spawn), so it lives on App +impl AppState { + pub(crate) fn split_pane(&mut self, direction: Direction) { + // Actual PTY spawning happens in Workspace::split_focused + // which needs events channel — this is called from navigate_key + // where we don't have async context, so the workspace handles it + let (rows, cols) = self.estimate_pane_size(); + let new_rows = (rows / 2).max(4); + let new_cols = (cols / 2).max(10); + + let cwd = self + .active + .and_then(|i| self.workspaces.get(i)) + .and_then(|ws| ws.focused_runtime()) + .and_then(|rt| rt.cwd()); + + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + if let Ok(new_id) = ws.split_focused(direction, new_rows, new_cols, cwd) { + ws.layout.focus_pane(new_id); + self.mode = Mode::Terminal; + } + } + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 00000000..62250fb8 --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1,159 @@ +//! Application orchestration. +//! +//! - `state.rs` — AppState, Mode, and pure data structs +//! - `actions.rs` — state mutations (testable without PTYs/async) +//! - `input.rs` — key/mouse → action translation + +mod actions; +mod input; +pub mod state; + +use std::io; +use std::time::Duration; + +use crossterm::event::{self, Event, KeyEventKind}; +use ratatui::layout::Rect; +use ratatui::DefaultTerminal; +use tokio::sync::mpsc; +use tracing::{error, info}; + +use crate::config::Config; +use crate::events::AppEvent; +use crate::workspace::Workspace; + +pub use state::{AppState, Mode, ViewState, CONTEXT_MENU_ITEMS}; + +/// Full application: AppState + runtime concerns (event channels, async I/O). +pub struct App { + pub state: AppState, + pub event_tx: mpsc::Sender, + event_rx: mpsc::Receiver, + no_session: bool, +} + +impl App { + pub fn new(config: &Config, no_session: bool) -> Self { + let (prefix_code, prefix_mods) = config.prefix_key(); + let (event_tx, event_rx) = mpsc::channel::(64); + + // Try to restore previous session + let (workspaces, active, selected) = if no_session { + (Vec::new(), None, 0) + } else if let Some(snap) = crate::persist::load() { + let ws = crate::persist::restore(&snap, 24, 80, event_tx.clone()); + if ws.is_empty() { + info!("session file found but no workspaces restored"); + (Vec::new(), None, 0) + } else { + info!(count = ws.len(), "session restored"); + let active = snap.active.filter(|&i| i < ws.len()); + let selected = snap.selected.min(ws.len().saturating_sub(1)); + (ws, active, selected) + } + } else { + (Vec::new(), None, 0) + }; + + let mode = if active.is_some() { + state::Mode::Terminal + } else { + state::Mode::Navigate + }; + + let state = AppState { + workspaces, + active, + selected, + mode, + should_quit: false, + name_input: String::new(), + view: state::ViewState { + sidebar_rect: Rect::default(), + terminal_area: Rect::default(), + pane_infos: Vec::new(), + split_borders: Vec::new(), + }, + drag: None, + selection: None, + context_menu: None, + update_available: None, + update_dismissed: false, + prefix_code, + prefix_mods, + prefix_label: config.prefix_label(), + sidebar_width: config.ui.sidebar_width, + sidebar_collapsed: false, + confirm_close: config.ui.confirm_close, + accent: crate::config::parse_color(&config.ui.accent), + keybinds: config.keybinds(), + }; + + // Background auto-update (skipped in --no-session / test mode) + if !no_session { + let update_tx = event_tx.clone(); + std::thread::spawn(move || crate::update::auto_update(update_tx)); + } + + Self { + state, + event_tx, + event_rx, + no_session, + } + } + + pub async fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { + while !self.state.should_quit { + terminal.draw(|frame| { + crate::ui::compute_view(&mut self.state, frame.area()); + crate::ui::render(&self.state, frame); + })?; + + // Drain internal events + while let Ok(ev) = self.event_rx.try_recv() { + self.state.handle_app_event(ev); + } + + if event::poll(Duration::from_millis(16))? { + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => { + self.handle_key(key).await; + } + Event::Paste(text) => self.handle_paste(text).await, + Event::Mouse(mouse) => self.state.handle_mouse(mouse), + Event::Resize(_, _) => {} + _ => {} + } + } + } + + // Save session on exit (skip in --no-session mode) + if !self.no_session && !self.state.workspaces.is_empty() { + let snap = crate::persist::capture( + &self.state.workspaces, + self.state.active, + self.state.selected, + ); + crate::persist::save(&snap); + } + + Ok(()) + } + + /// Create a workspace with a real PTY (needs event_tx). + fn create_workspace(&mut self, name: String) { + let (rows, cols) = self.state.estimate_pane_size(); + match Workspace::new(name, 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; + } + } + } +} diff --git a/src/app/state.rs b/src/app/state.rs new file mode 100644 index 00000000..b0a81165 --- /dev/null +++ b/src/app/state.rs @@ -0,0 +1,130 @@ +use crossterm::event::{KeyCode, KeyModifiers}; +use crate::config::Keybinds; +use ratatui::layout::{Direction, Rect}; +use ratatui::style::Color; + +use crate::layout::{PaneInfo, SplitBorder}; +use crate::selection::Selection; +use crate::workspace::Workspace; + +/// Computed view geometry — derived from AppState + terminal size. +/// Updated before each render, consumed by render and mouse handling. +pub struct ViewState { + pub sidebar_rect: Rect, + pub terminal_area: Rect, + pub pane_infos: Vec, + pub split_borders: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + Navigate, + Terminal, + CreateSession, + RenameSession, + Resize, + ConfirmClose, + ContextMenu, +} + +/// Active mouse drag on a split border. +pub(crate) struct DragState { + pub path: Vec, + pub direction: Direction, + pub area: Rect, +} + +/// Right-click context menu state. +pub struct ContextMenuState { + pub ws_idx: usize, + pub x: u16, + pub y: u16, + pub selected: usize, +} + +pub const CONTEXT_MENU_ITEMS: &[&str] = &["Rename", "Close"]; + +/// All application state — pure data, no channels or async runtime. +/// Testable without PTYs or a tokio runtime. +pub struct AppState { + pub workspaces: Vec, + pub active: Option, + pub selected: usize, + pub mode: Mode, + pub should_quit: bool, + pub name_input: String, + // View geometry (computed before render, consumed by render + mouse) + pub view: ViewState, + pub(crate) drag: Option, + pub selection: Option, + pub context_menu: Option, + // Update notification + pub update_available: Option, + pub update_dismissed: bool, + // Config + pub prefix_code: KeyCode, + pub prefix_mods: KeyModifiers, + pub prefix_label: String, + pub sidebar_width: u16, + pub sidebar_collapsed: bool, + pub confirm_close: bool, + pub accent: Color, + pub keybinds: Keybinds, +} + +impl AppState { + pub fn is_prefix(&self, key: &crossterm::event::KeyEvent) -> bool { + key.code == self.prefix_code && key.modifiers.contains(self.prefix_mods) + } + + pub fn estimate_pane_size(&self) -> (u16, u16) { + if let Some(info) = self.view.pane_infos.first() { + (info.rect.height, info.rect.width) + } else { + (24, 80) + } + } +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +#[cfg(test)] +impl AppState { + /// Create an AppState for testing — no channels, no PTYs. + pub fn test_new() -> Self { + Self { + workspaces: Vec::new(), + active: None, + selected: 0, + mode: Mode::Navigate, + should_quit: false, + name_input: String::new(), + view: ViewState { + sidebar_rect: Rect::default(), + terminal_area: Rect::default(), + pane_infos: Vec::new(), + split_borders: Vec::new(), + }, + drag: None, + selection: None, + context_menu: None, + update_available: None, + update_dismissed: false, + prefix_code: KeyCode::Char('s'), + prefix_mods: KeyModifiers::CONTROL, + prefix_label: "ctrl+s".into(), + sidebar_width: 26, + sidebar_collapsed: false, + confirm_close: true, + accent: Color::Cyan, + keybinds: Keybinds { + split_vertical: (KeyCode::Char('v'), KeyModifiers::empty()), + split_horizontal: (KeyCode::Char('-'), KeyModifiers::empty()), + close_pane: (KeyCode::Char('x'), KeyModifiers::empty()), + fullscreen: (KeyCode::Char('f'), KeyModifiers::empty()), + }, + } + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 00000000..0022d230 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,279 @@ +use std::path::PathBuf; + +use crossterm::event::{KeyCode, KeyModifiers}; +use serde::Deserialize; +use tracing::warn; + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct Config { + pub keys: KeysConfig, + pub ui: UiConfig, +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct KeysConfig { + /// Prefix key to toggle navigate mode (e.g. "ctrl+s", "ctrl+a", "ctrl+b"). + pub prefix: String, + /// Split pane vertically (side by side). Default: "v" + pub split_vertical: String, + /// Split pane horizontally (stacked). Default: "-" + pub split_horizontal: String, + /// Close the focused pane. Default: "x" + pub close_pane: String, + /// Toggle fullscreen for the focused pane. Default: "f" + pub fullscreen: String, +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct UiConfig { + pub sidebar_width: u16, + /// Ask for confirmation before closing a workspace. Default: true. + pub confirm_close: bool, + /// Accent color for highlights, borders, and navigation UI. + /// Accepts hex (#89b4fa), named colors (cyan, blue), or RGB (rgb(137,180,250)). + pub accent: String, +} + +impl Default for KeysConfig { + fn default() -> Self { + Self { + prefix: "ctrl+s".into(), + split_vertical: "v".into(), + split_horizontal: "-".into(), + close_pane: "x".into(), + fullscreen: "f".into(), + } + } +} + +impl Default for UiConfig { + fn default() -> Self { + Self { + sidebar_width: 26, + confirm_close: true, + accent: "cyan".into(), + } + } +} + +impl Config { + pub fn load() -> Self { + let path = config_path(); + if path.exists() { + match std::fs::read_to_string(&path) { + Ok(content) => match toml::from_str(&content) { + Ok(config) => return config, + Err(e) => warn!(err = %e, "config parse error, using defaults"), + }, + Err(e) => warn!(err = %e, "config read error, using defaults"), + } + } + Self::default() + } + + pub fn prefix_key(&self) -> (KeyCode, KeyModifiers) { + parse_key_combo(&self.keys.prefix) + } + + /// Human-readable label for the prefix key (shown in status bar). + pub fn prefix_label(&self) -> String { + self.keys.prefix.clone() + } + + /// Parsed keybinds for navigate mode actions. + pub fn keybinds(&self) -> Keybinds { + Keybinds { + split_vertical: parse_key_combo(&self.keys.split_vertical), + split_horizontal: parse_key_combo(&self.keys.split_horizontal), + close_pane: parse_key_combo(&self.keys.close_pane), + fullscreen: parse_key_combo(&self.keys.fullscreen), + } + } +} + +/// Parsed keybinds for navigate mode actions. +#[derive(Debug, Clone)] +pub struct Keybinds { + pub split_vertical: (KeyCode, KeyModifiers), + pub split_horizontal: (KeyCode, KeyModifiers), + pub close_pane: (KeyCode, KeyModifiers), + pub fullscreen: (KeyCode, KeyModifiers), +} + +/// Parse a color string into a ratatui Color. +/// Supports: hex (#rrggbb, #rgb), named colors, rgb(r,g,b). +pub fn parse_color(s: &str) -> ratatui::style::Color { + use ratatui::style::Color; + let s = s.trim().to_lowercase(); + + // Hex: #rrggbb or #rgb + if let Some(hex) = s.strip_prefix('#') { + if hex.len() == 6 { + if let (Ok(r), Ok(g), Ok(b)) = ( + u8::from_str_radix(&hex[0..2], 16), + u8::from_str_radix(&hex[2..4], 16), + u8::from_str_radix(&hex[4..6], 16), + ) { + return Color::Rgb(r, g, b); + } + } else if hex.len() == 3 { + let chars: Vec = hex + .chars() + .filter_map(|c| u8::from_str_radix(&c.to_string(), 16).ok()) + .collect(); + if chars.len() == 3 { + return Color::Rgb(chars[0] * 17, chars[1] * 17, chars[2] * 17); + } + } + } + + // rgb(r, g, b) + if let Some(inner) = s.strip_prefix("rgb(").and_then(|s| s.strip_suffix(')')) { + let parts: Vec<&str> = inner.split(',').collect(); + if parts.len() == 3 { + if let (Ok(r), Ok(g), Ok(b)) = ( + parts[0].trim().parse::(), + parts[1].trim().parse::(), + parts[2].trim().parse::(), + ) { + return Color::Rgb(r, g, b); + } + } + } + + // Named colors + match s.as_str() { + "black" => Color::Black, + "red" => Color::Red, + "green" => Color::Green, + "yellow" => Color::Yellow, + "blue" => Color::Blue, + "magenta" | "purple" => Color::Magenta, + "cyan" => Color::Cyan, + "white" => Color::White, + "gray" | "grey" => Color::Gray, + "darkgray" | "darkgrey" => Color::DarkGray, + "lightred" => Color::LightRed, + "lightgreen" => Color::LightGreen, + "lightyellow" => Color::LightYellow, + "lightblue" => Color::LightBlue, + "lightmagenta" => Color::LightMagenta, + "lightcyan" => Color::LightCyan, + _ => { + warn!(color = s, "unknown color, defaulting to cyan"); + Color::Cyan + } + } +} + +fn config_path() -> PathBuf { + if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") { + PathBuf::from(dir).join("herdr/config.toml") + } else if let Ok(home) = std::env::var("HOME") { + PathBuf::from(home).join(".config/herdr/config.toml") + } else { + PathBuf::from("/tmp/herdr/config.toml") + } +} + +fn parse_key_combo(s: &str) -> (KeyCode, KeyModifiers) { + let parts: Vec<&str> = s.split('+').collect(); + let mut modifiers = KeyModifiers::empty(); + let mut key_str = ""; + + for part in &parts { + match part.trim().to_lowercase().as_str() { + "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL, + "shift" => modifiers |= KeyModifiers::SHIFT, + "alt" | "meta" => modifiers |= KeyModifiers::ALT, + _ => key_str = part.trim(), + } + } + + let code = match key_str.to_lowercase().as_str() { + "space" | " " => KeyCode::Char(' '), + "enter" | "return" => KeyCode::Enter, + "esc" | "escape" => KeyCode::Esc, + "tab" => KeyCode::Tab, + "backspace" | "bs" => KeyCode::Backspace, + s if s.len() == 1 => KeyCode::Char(s.chars().next().unwrap()), + s if s.starts_with('f') => s[1..] + .parse::() + .map(KeyCode::F) + .unwrap_or(KeyCode::Char('s')), + _ => KeyCode::Char('s'), + }; + + (code, modifiers) +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyModifiers}; + + #[test] + fn parse_simple_char() { + assert_eq!(parse_key_combo("v"), (KeyCode::Char('v'), KeyModifiers::empty())); + } + + #[test] + fn parse_ctrl_combo() { + assert_eq!(parse_key_combo("ctrl+s"), (KeyCode::Char('s'), KeyModifiers::CONTROL)); + } + + #[test] + fn parse_special_key() { + assert_eq!(parse_key_combo("enter"), (KeyCode::Enter, KeyModifiers::empty())); + assert_eq!(parse_key_combo("tab"), (KeyCode::Tab, KeyModifiers::empty())); + assert_eq!(parse_key_combo("esc"), (KeyCode::Esc, KeyModifiers::empty())); + } + + #[test] + fn parse_ctrl_shift() { + assert_eq!( + parse_key_combo("ctrl+shift+a"), + (KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::SHIFT) + ); + } + + #[test] + fn parse_f_key() { + assert_eq!(parse_key_combo("f5"), (KeyCode::F(5), KeyModifiers::empty())); + } + + #[test] + fn default_keybinds_parse() { + let config = Config::default(); + let kb = config.keybinds(); + assert_eq!(kb.split_vertical.0, KeyCode::Char('v')); + assert_eq!(kb.split_horizontal.0, KeyCode::Char('-')); + assert_eq!(kb.close_pane.0, KeyCode::Char('x')); + assert_eq!(kb.fullscreen.0, KeyCode::Char('f')); + } + + #[test] + fn custom_keybinds_from_toml() { + let toml = r#" +[keys] +prefix = "ctrl+a" +split_vertical = "s" +split_horizontal = "shift+s" +close_pane = "ctrl+w" +fullscreen = "z" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let (code, mods) = config.prefix_key(); + assert_eq!(code, KeyCode::Char('a')); + assert_eq!(mods, KeyModifiers::CONTROL); + + let kb = config.keybinds(); + assert_eq!(kb.split_vertical.0, KeyCode::Char('s')); + assert_eq!(kb.split_horizontal, (KeyCode::Char('s'), KeyModifiers::SHIFT)); + assert_eq!(kb.close_pane, (KeyCode::Char('w'), KeyModifiers::CONTROL)); + assert_eq!(kb.fullscreen.0, KeyCode::Char('z')); + } +} diff --git a/src/detect.rs b/src/detect.rs new file mode 100644 index 00000000..235a1603 --- /dev/null +++ b/src/detect.rs @@ -0,0 +1,1163 @@ +//! Agent state detection via screen content pattern matching. +//! +//! Each pane's `vt100::Screen` content is read periodically and matched +//! against known agent output patterns to determine state. + +/// The detected state of a terminal pane. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentState { + /// Agent finished, prompt visible, nothing happening. + Idle, + /// Agent is actively working/processing. + Busy, + /// Agent needs human input — approval, question, error. + Waiting, + /// Plain shell or unrecognized program. + Unknown, +} + +impl AgentState { + /// Priority for rolling up pane states to a workspace indicator. + /// Higher = more urgent. + pub fn priority(self) -> u8 { + match self { + Self::Unknown => 0, + Self::Idle => 1, + Self::Busy => 2, + Self::Waiting => 3, + } + } +} + +/// Which agent we detected running in a pane. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Agent { + Pi, + Claude, + Codex, + Gemini, + Cursor, + Cline, + OpenCode, + GithubCopilot, + Kimi, + Droid, + Amp, +} + +/// Identify which agent is running from the process name. +/// Returns `None` for plain shells or unrecognized programs. +pub fn identify_agent(process_name: &str) -> Option { + let name = process_name.to_lowercase(); + // Match against known binary names + match name.as_str() { + "pi" => Some(Agent::Pi), + "claude" | "claude-code" => Some(Agent::Claude), + "codex" => Some(Agent::Codex), + "gemini" => Some(Agent::Gemini), + "cursor" => Some(Agent::Cursor), + "cline" => Some(Agent::Cline), + "opencode" | "open-code" => Some(Agent::OpenCode), + "github-copilot" | "ghcs" => Some(Agent::GithubCopilot), + "kimi" => Some(Agent::Kimi), + "droid" => Some(Agent::Droid), + "amp" | "amp-local" => Some(Agent::Amp), + _ => None, + } +} + +/// Detect the state of an agent from the visible screen content. +/// If `agent` is `None`, returns `Unknown`. +pub fn detect_state(agent: Option, screen_content: &str) -> AgentState { + let Some(agent) = agent else { + return AgentState::Unknown; + }; + match agent { + Agent::Pi => detect_pi(screen_content), + Agent::Claude => detect_claude(screen_content), + Agent::Codex => detect_codex(screen_content), + Agent::Gemini => detect_gemini(screen_content), + Agent::Cursor => detect_cursor(screen_content), + Agent::Cline => detect_cline(screen_content), + Agent::OpenCode => detect_opencode(screen_content), + Agent::GithubCopilot => detect_github_copilot(screen_content), + Agent::Kimi => detect_kimi(screen_content), + Agent::Droid => detect_droid(screen_content), + Agent::Amp => detect_amp(screen_content), + } +} + +/// Roll up multiple pane states into a single workspace state. +/// Returns the highest-priority state among all panes. +pub fn workspace_state(pane_states: &[AgentState]) -> AgentState { + pane_states + .iter() + .max_by_key(|s| s.priority()) + .copied() + .unwrap_or(AgentState::Unknown) +} + +// --------------------------------------------------------------------------- +// Per-agent detectors +// --------------------------------------------------------------------------- + +fn detect_pi(content: &str) -> AgentState { + // pi shows "Working..." when the agent is processing + if content.contains("Working...") { + return AgentState::Busy; + } + AgentState::Idle +} + +/// Claude Code detection. The most complex — it has a structured prompt box UI. +/// +/// Screen layout: +/// ```text +/// (agent output / tool results) +/// ───────────────────────── (top border) +/// ❯ _ (prompt line) +/// ───────────────────────── (bottom border) +/// ``` +fn detect_claude(content: &str) -> AgentState { + let lower = content.to_lowercase(); + + // Search prompt is always idle + if content.contains("⌕ Search…") { + return AgentState::Idle; + } + + // ctrl+r toggle — don't change state + // (we return Idle as a safe default since we don't have previous state here) + if lower.contains("ctrl+r to toggle") { + return AgentState::Idle; + } + + // --- Waiting detection (full content including prompt box) --- + + // "Do you want" or "Would you like" followed by yes/❯ + if has_confirmation_prompt(&lower) { + return AgentState::Waiting; + } + + // Selection prompt: ❯ followed by numbered option + if has_selection_prompt(content) { + return AgentState::Waiting; + } + + // "esc to cancel" indicates permission/confirmation dialog + if lower.contains("esc to cancel") { + return AgentState::Waiting; + } + + // --- Busy detection (content above the prompt box) --- + + let above = content_above_prompt_box(content); + let above_lower = above.to_lowercase(); + + if above_lower.contains("esc to interrupt") || above_lower.contains("ctrl+c to interrupt") { + return AgentState::Busy; + } + + if has_spinner_activity(above) { + return AgentState::Busy; + } + + AgentState::Idle +} + +fn detect_codex(content: &str) -> AgentState { + let lower = content.to_lowercase(); + + // Waiting patterns + if lower.contains("press enter to confirm or esc to cancel") + || lower.contains("| enter to submit answer") + || lower.contains("allow command?") + || lower.contains("[y/n]") + || lower.contains("yes (y)") + { + return AgentState::Waiting; + } + if has_confirmation_prompt(&lower) { + return AgentState::Waiting; + } + + // Busy + if has_interrupt_pattern(&lower) { + return AgentState::Busy; + } + + AgentState::Idle +} + +fn detect_gemini(content: &str) -> AgentState { + let lower = content.to_lowercase(); + + // Waiting — explicit confirmation + if lower.contains("waiting for user confirmation") { + return AgentState::Waiting; + } + + // Waiting — box-drawing confirmation prompts + if content.contains("│ Apply this change") + || content.contains("│ Allow execution") + || content.contains("│ Do you want to proceed") + { + return AgentState::Waiting; + } + if has_confirmation_prompt(&lower) { + return AgentState::Waiting; + } + + // Busy + if lower.contains("esc to cancel") { + return AgentState::Busy; + } + + AgentState::Idle +} + +fn detect_cursor(content: &str) -> AgentState { + let lower = content.to_lowercase(); + + // Waiting + if lower.contains("(y) (enter)") + || lower.contains("keep (n)") + || lower.contains("skip (esc or n)") + { + return AgentState::Waiting; + } + // "allow ...(y)" or "run ...(y)" patterns + if lower.contains("(y)") && (lower.contains("allow") || lower.contains("run")) { + return AgentState::Waiting; + } + + // Busy + if lower.contains("ctrl+c to stop") { + return AgentState::Busy; + } + if has_cursor_spinner(content) { + return AgentState::Busy; + } + + AgentState::Idle +} + +fn detect_cline(content: &str) -> AgentState { + let lower = content.to_lowercase(); + + // Waiting + if lower.contains("let cline use this tool") { + return AgentState::Waiting; + } + // [act mode] or [plan mode] followed by "yes" + if (lower.contains("[act mode]") || lower.contains("[plan mode]")) + && lower.contains("yes") + { + return AgentState::Waiting; + } + + // Idle + if lower.contains("cline is ready for your message") { + return AgentState::Idle; + } + + // Cline defaults to busy (unlike most agents that default to idle) + AgentState::Busy +} + +fn detect_opencode(content: &str) -> AgentState { + // Waiting + if content.contains("△ Permission required") { + return AgentState::Waiting; + } + + // Busy + if has_interrupt_pattern(&content.to_lowercase()) { + return AgentState::Busy; + } + + AgentState::Idle +} + +fn detect_github_copilot(content: &str) -> AgentState { + let lower = content.to_lowercase(); + + // Waiting + if lower.contains("│ do you want") { + return AgentState::Waiting; + } + if lower.contains("confirm with") && lower.contains("enter") { + return AgentState::Waiting; + } + + // Busy + if lower.contains("esc to cancel") { + return AgentState::Busy; + } + + AgentState::Idle +} + +fn detect_kimi(content: &str) -> AgentState { + let lower = content.to_lowercase(); + + // Waiting + if lower.contains("allow?") + || lower.contains("confirm?") + || lower.contains("approve?") + || lower.contains("proceed?") + || lower.contains("[y/n]") + || lower.contains("(y/n)") + { + return AgentState::Waiting; + } + + // Busy + if lower.contains("thinking") + || lower.contains("processing") + || lower.contains("generating") + || lower.contains("waiting for response") + || lower.contains("ctrl+c to cancel") + || lower.contains("ctrl-c to cancel") + { + return AgentState::Busy; + } + + AgentState::Idle +} + +/// Droid detection. +/// +/// Busy: braille spinner line (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) + "Thinking..." + "(Press ESC to stop)" +/// Waiting: EXECUTE prompt with selection box ("Yes, allow" / "No, cancel") + +/// "Use ↑↓ to navigate, Enter to select" +/// Idle: prompt box visible, no spinner, no selection prompt +fn detect_droid(content: &str) -> AgentState { + let lower = content.to_lowercase(); + + // Waiting: EXECUTE approval prompt with selection UI chrome + // Primary (AND): structural keyword + chrome text = certain + let has_execute = content.contains("EXECUTE"); + let has_selection_chrome = lower.contains("enter to select") + || lower.contains("↑↓ to navigate") + || lower.contains("esc to cancel"); + let has_selection_options = lower.contains("> yes, allow") || lower.contains("> no, cancel"); + + if has_execute && (has_selection_chrome || has_selection_options) { + return AgentState::Waiting; + } + // Secondary: selection chrome + options together (no EXECUTE needed) + if has_selection_chrome && has_selection_options { + return AgentState::Waiting; + } + + // Busy: braille spinner character at start of a line + "Thinking..." + // The braille chars (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) are very specific — won't appear in normal content + if has_braille_spinner(content) && lower.contains("esc to stop") { + return AgentState::Busy; + } + // Fallback: "ESC to stop" alone is still a strong signal (it's UI chrome) + if lower.contains("esc to stop") { + return AgentState::Busy; + } + + AgentState::Idle +} + +/// Amp (Sourcegraph) detection. +/// +/// Screen layout when busy: +/// ```text +/// ✓ Search Map the core runtime architecture... +/// ⋯ Oracle ▼ +/// ≈ Running tools... Esc to cancel +/// ``` +/// +/// "Esc to cancel" is the reliable busy indicator — it only appears +/// while amp is actively running tools or thinking. +fn detect_amp(content: &str) -> AgentState { + let lower = content.to_lowercase(); + + if lower.contains("esc to cancel") { + return AgentState::Busy; + } + + AgentState::Idle +} + +/// Check for braille spinner characters at the start of a line. +/// These are the Unicode braille pattern dots used by CLI spinners. +fn has_braille_spinner(content: &str) -> bool { + for line in content.lines() { + let trimmed = line.trim(); + if let Some(c) = trimmed.chars().next() { + if ('\u{2800}'..='\u{28FF}').contains(&c) { + return true; + } + } + } + false +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +/// Check for "do you want"/"would you like" followed by "yes" or "❯" +fn has_confirmation_prompt(lower_content: &str) -> bool { + if let Some(pos) = lower_content + .find("do you want") + .or_else(|| lower_content.find("would you like")) + { + let after = &lower_content[pos..]; + return after.contains("yes") || after.contains('❯'); + } + false +} + +/// Check for "❯" followed by numbered options like "1." +fn has_selection_prompt(content: &str) -> bool { + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('❯') { + // Check if there's a digit followed by a dot nearby + if trimmed.chars().any(|c| c.is_ascii_digit()) + && trimmed.contains('.') + { + return true; + } + } + } + false +} + +/// Check for "esc" + "interrupt" pattern +fn has_interrupt_pattern(lower_content: &str) -> bool { + lower_content.contains("esc to interrupt") + || lower_content.contains("ctrl+c to interrupt") + || (lower_content.contains("esc") && lower_content.contains("interrupt")) +} + +/// Claude Code spinner characters + activity label +/// Pattern: one of ✱✲✳... followed by a word ending in "ing" and "…" +fn has_spinner_activity(content: &str) -> bool { + const SPINNER_CHARS: &str = "✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋✢✣✤✥✦✧✨⊛⊕⊙◉◎◍⁂⁕※⍟☼★☆"; + for line in content.lines() { + let trimmed = line.trim(); + let mut chars = trimmed.chars(); + if let Some(first) = chars.next() { + if SPINNER_CHARS.contains(first) { + // Next should be a space, then a word ending in "ing", then "…" + let rest: String = chars.collect(); + if rest.starts_with(' ') && rest.contains("ing") && rest.contains('\u{2026}') { + return true; + } + } + } + } + false +} + +/// Cursor spinner: ⬡ or ⬢ followed by a word ending in "ing" +fn has_cursor_spinner(content: &str) -> bool { + for line in content.lines() { + let trimmed = line.trim(); + if (trimmed.starts_with('⬡') || trimmed.starts_with('⬢')) + && trimmed.contains("ing") + { + return true; + } + } + false +} + +/// Extract content above Claude's prompt box. +/// The prompt box is two ─── border lines with ❯ between them. +fn content_above_prompt_box(content: &str) -> &str { + let lines: Vec<&str> = content.lines().collect(); + let mut border_count = 0; + + for i in (0..lines.len()).rev() { + let trimmed = lines[i].trim(); + if !trimmed.is_empty() && trimmed.chars().all(|c| c == '─') { + border_count += 1; + if border_count == 2 { + // Return everything above this border + let byte_offset: usize = lines[..i].iter().map(|l| l.len() + 1).sum(); + return &content[..byte_offset.min(content.len())]; + } + } + } + + // No prompt box found, return all content + content +} + +// --------------------------------------------------------------------------- +// Process identification (platform-specific) +// --------------------------------------------------------------------------- + +/// Get the foreground process name for a given child PID. +/// Delegates to platform-specific implementation. +pub fn foreground_process_name(child_pid: u32) -> Option { + crate::platform::foreground_process_name(child_pid) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // ---- Agent identification ---- + + #[test] + fn identify_known_agents() { + assert_eq!(identify_agent("pi"), Some(Agent::Pi)); + assert_eq!(identify_agent("claude"), Some(Agent::Claude)); + assert_eq!(identify_agent("claude-code"), Some(Agent::Claude)); + assert_eq!(identify_agent("codex"), Some(Agent::Codex)); + assert_eq!(identify_agent("gemini"), Some(Agent::Gemini)); + assert_eq!(identify_agent("cursor"), Some(Agent::Cursor)); + assert_eq!(identify_agent("cline"), Some(Agent::Cline)); + assert_eq!(identify_agent("opencode"), Some(Agent::OpenCode)); + assert_eq!(identify_agent("kimi"), Some(Agent::Kimi)); + assert_eq!(identify_agent("ghcs"), Some(Agent::GithubCopilot)); + } + + #[test] + fn identify_unknown_processes() { + assert_eq!(identify_agent("bash"), None); + assert_eq!(identify_agent("zsh"), None); + assert_eq!(identify_agent("vim"), None); + assert_eq!(identify_agent("node"), None); + } + + #[test] + fn identify_case_insensitive() { + assert_eq!(identify_agent("Pi"), Some(Agent::Pi)); + assert_eq!(identify_agent("CLAUDE"), Some(Agent::Claude)); + assert_eq!(identify_agent("Codex"), Some(Agent::Codex)); + } + + // ---- Workspace state rollup ---- + + #[test] + fn workspace_state_waiting_wins() { + let states = [AgentState::Idle, AgentState::Busy, AgentState::Waiting]; + assert_eq!(workspace_state(&states), AgentState::Waiting); + } + + #[test] + fn workspace_state_busy_over_idle() { + let states = [AgentState::Idle, AgentState::Busy, AgentState::Unknown]; + assert_eq!(workspace_state(&states), AgentState::Busy); + } + + #[test] + fn workspace_state_empty() { + assert_eq!(workspace_state(&[]), AgentState::Unknown); + } + + // ---- No agent → Unknown ---- + + #[test] + fn no_agent_returns_unknown() { + assert_eq!(detect_state(None, "anything"), AgentState::Unknown); + } + + // ---- Pi ---- + + #[test] + fn pi_busy_when_working() { + assert_eq!(detect_pi("some output\nWorking..."), AgentState::Busy); + } + + #[test] + fn pi_busy_working_in_middle() { + assert_eq!( + detect_pi("line1\nWorking...\nline3"), + AgentState::Busy + ); + } + + #[test] + fn pi_idle_at_prompt() { + assert_eq!(detect_pi("❯ "), AgentState::Idle); + } + + #[test] + fn pi_idle_no_working_text() { + assert_eq!( + detect_pi("some output\n\n> ready"), + AgentState::Idle + ); + } + + // ---- Claude Code ---- + + #[test] + fn claude_busy_esc_to_interrupt() { + let screen = "Reading file src/main.rs\nesc to interrupt\n─────────\n❯ \n─────────"; + assert_eq!(detect_claude(screen), AgentState::Busy); + } + + #[test] + fn claude_busy_ctrl_c_to_interrupt() { + let screen = "Editing code\nctrl+c to interrupt\n─────────\n❯ \n─────────"; + assert_eq!(detect_claude(screen), AgentState::Busy); + } + + #[test] + fn claude_busy_spinner() { + let screen = "✽ Tempering…\n─────────\n❯ \n─────────"; + assert_eq!(detect_claude(screen), AgentState::Busy); + } + + #[test] + fn claude_busy_spinner_with_detail() { + let screen = "✳ Simplifying recompute_tangents…\n─────────\n❯ \n─────────"; + assert_eq!(detect_claude(screen), AgentState::Busy); + } + + #[test] + fn claude_waiting_do_you_want() { + let screen = "Do you want to run this command?\n\nYes No"; + assert_eq!(detect_claude(screen), AgentState::Waiting); + } + + #[test] + fn claude_waiting_would_you_like() { + let screen = "Would you like to apply these changes?\n\n❯ Yes"; + assert_eq!(detect_claude(screen), AgentState::Waiting); + } + + #[test] + fn claude_waiting_selection_prompt() { + let screen = "Choose an option:\n❯ 1. Apply\n 2. Skip\n 3. Cancel"; + assert_eq!(detect_claude(screen), AgentState::Waiting); + } + + #[test] + fn claude_waiting_esc_to_cancel() { + let screen = "Allow bash: rm -rf /tmp/test?\n\nesc to cancel"; + assert_eq!(detect_claude(screen), AgentState::Waiting); + } + + #[test] + fn claude_idle_prompt_box() { + let screen = "Task complete.\n─────────────\n❯ \n─────────────"; + assert_eq!(detect_claude(screen), AgentState::Idle); + } + + #[test] + fn claude_idle_search() { + let screen = "⌕ Search…\nsome content"; + assert_eq!(detect_claude(screen), AgentState::Idle); + } + + #[test] + fn claude_busy_not_confused_by_old_prompt() { + // The "esc to interrupt" is ABOVE the prompt box — should be busy + let screen = "✽ Writing…\nesc to interrupt\n──────\n❯ \n──────"; + assert_eq!(detect_claude(screen), AgentState::Busy); + } + + // ---- Codex ---- + + #[test] + fn codex_waiting_confirm() { + assert_eq!( + detect_codex("press enter to confirm or esc to cancel"), + AgentState::Waiting + ); + } + + #[test] + fn codex_waiting_allow_command() { + assert_eq!( + detect_codex("allow command?\n[y/n]"), + AgentState::Waiting + ); + } + + #[test] + fn codex_waiting_submit_answer() { + assert_eq!( + detect_codex("Question about approach\n| enter to submit answer"), + AgentState::Waiting + ); + } + + #[test] + fn codex_busy_interrupt() { + assert_eq!( + detect_codex("generating code\nesc to interrupt"), + AgentState::Busy + ); + } + + #[test] + fn codex_idle() { + assert_eq!(detect_codex("❯ "), AgentState::Idle); + } + + // ---- Gemini ---- + + #[test] + fn gemini_waiting_confirmation() { + assert_eq!( + detect_gemini("waiting for user confirmation"), + AgentState::Waiting + ); + } + + #[test] + fn gemini_waiting_apply() { + assert_eq!( + detect_gemini("│ Apply this change\n│ Yes │ No"), + AgentState::Waiting + ); + } + + #[test] + fn gemini_waiting_allow_execution() { + assert_eq!( + detect_gemini("│ Allow execution of: rm test.txt"), + AgentState::Waiting + ); + } + + #[test] + fn gemini_busy() { + assert_eq!( + detect_gemini("thinking...\nesc to cancel"), + AgentState::Busy + ); + } + + #[test] + fn gemini_idle() { + assert_eq!(detect_gemini("❯ "), AgentState::Idle); + } + + // ---- Cursor ---- + + #[test] + fn cursor_waiting_accept() { + assert_eq!( + detect_cursor("Apply changes? (y) (enter) or keep (n)"), + AgentState::Waiting + ); + } + + #[test] + fn cursor_waiting_allow() { + assert_eq!( + detect_cursor("allow file edit (y)"), + AgentState::Waiting + ); + } + + #[test] + fn cursor_busy_spinner() { + assert_eq!( + detect_cursor("⬡ Grepping.."), + AgentState::Busy + ); + } + + #[test] + fn cursor_busy_ctrl_c() { + assert_eq!( + detect_cursor("processing\nctrl+c to stop"), + AgentState::Busy + ); + } + + #[test] + fn cursor_idle() { + assert_eq!(detect_cursor("> "), AgentState::Idle); + } + + // ---- Cline ---- + + #[test] + fn cline_waiting_tool_use() { + assert_eq!( + detect_cline("let cline use this tool"), + AgentState::Waiting + ); + } + + #[test] + fn cline_waiting_act_mode() { + assert_eq!( + detect_cline("[act mode] execute command?\nyes"), + AgentState::Waiting + ); + } + + #[test] + fn cline_idle_ready() { + assert_eq!( + detect_cline("cline is ready for your message"), + AgentState::Idle + ); + } + + #[test] + fn cline_defaults_to_busy() { + // Cline's default is busy (unlike other agents) + assert_eq!(detect_cline("some random output"), AgentState::Busy); + } + + // ---- OpenCode ---- + + #[test] + fn opencode_waiting_permission() { + assert_eq!( + detect_opencode("△ Permission required"), + AgentState::Waiting + ); + } + + #[test] + fn opencode_busy() { + assert_eq!( + detect_opencode("running tool\nesc to interrupt"), + AgentState::Busy + ); + } + + #[test] + fn opencode_idle() { + assert_eq!(detect_opencode("> "), AgentState::Idle); + } + + // ---- GitHub Copilot ---- + + #[test] + fn copilot_waiting_confirm() { + assert_eq!( + detect_github_copilot("confirm with enter"), + AgentState::Waiting + ); + } + + #[test] + fn copilot_waiting_do_you_want() { + assert_eq!( + detect_github_copilot("│ do you want to apply?"), + AgentState::Waiting + ); + } + + #[test] + fn copilot_busy() { + assert_eq!( + detect_github_copilot("generating\nesc to cancel"), + AgentState::Busy + ); + } + + #[test] + fn copilot_idle() { + assert_eq!(detect_github_copilot("> "), AgentState::Idle); + } + + // ---- Kimi ---- + + #[test] + fn kimi_waiting_approve() { + assert_eq!(detect_kimi("approve?"), AgentState::Waiting); + } + + #[test] + fn kimi_waiting_yn() { + assert_eq!(detect_kimi("continue? [y/n]"), AgentState::Waiting); + } + + #[test] + fn kimi_busy_thinking() { + assert_eq!(detect_kimi("thinking"), AgentState::Busy); + } + + #[test] + fn kimi_busy_generating() { + assert_eq!(detect_kimi("generating code"), AgentState::Busy); + } + + #[test] + fn kimi_idle() { + assert_eq!(detect_kimi("> "), AgentState::Idle); + } + + // ---- Droid ---- + + #[test] + fn droid_busy_thinking_with_spinner() { + let screen = "> how u doin\n\n⠴ Thinking... (Press ESC to stop)\n\nAuto (Off)"; + assert_eq!(detect_droid(screen), AgentState::Busy); + } + + #[test] + fn droid_busy_esc_to_stop_alone() { + // ESC to stop without spinner is still busy (UI chrome) + let screen = "Processing\n(Press ESC to stop)"; + assert_eq!(detect_droid(screen), AgentState::Busy); + } + + #[test] + fn droid_waiting_execute_approval() { + let screen = concat!( + "⛬ I'll create some folders.\n\n", + " EXECUTE (mkdir -p /tmp/test, impact: medium)\n\n", + "╭────────────────────╮\n", + "│ > Yes, allow │\n", + "│ Yes, always allow │\n", + "│ No, cancel │\n", + "╰────────────────────╯\n", + " Use ↑↓ to navigate, Enter to select, Esc to cancel\n", + ); + assert_eq!(detect_droid(screen), AgentState::Waiting); + } + + #[test] + fn droid_waiting_selection_with_chrome() { + let screen = "│ > Yes, allow │\n│ No, cancel │\n Use ↑↓ to navigate, Enter to select, Esc to cancel"; + assert_eq!(detect_droid(screen), AgentState::Waiting); + } + + #[test] + fn droid_not_waiting_on_options_text_alone() { + // "Yes, allow" in normal conversation should NOT trigger waiting + let screen = "The user said > Yes, allow the changes"; + assert_eq!(detect_droid(screen), AgentState::Idle); + } + + #[test] + fn droid_idle_prompt() { + let screen = "╭──────────────────╮\n│ > Try something │\n╰──────────────────╯\n? for help"; + assert_eq!(detect_droid(screen), AgentState::Idle); + } + + #[test] + fn droid_idle_after_response() { + let screen = "⛬ Doing well, thanks!\n\nAuto (Off)\n╭──────────╮\n│ > │\n╰──────────╯"; + assert_eq!(detect_droid(screen), AgentState::Idle); + } + + #[test] + fn droid_braille_spinner_detected() { + assert!(has_braille_spinner("⠴ Thinking...")); + assert!(has_braille_spinner(" ⠧ Loading...")); + assert!(has_braille_spinner("text\n⠋ Working\nmore")); + } + + #[test] + fn droid_braille_spinner_no_false_positive() { + assert!(!has_braille_spinner("normal text")); + assert!(!has_braille_spinner("Thinking...")); + assert!(!has_braille_spinner("some ⠴ in middle of text")); + } + + #[test] + fn droid_identified_by_process_name() { + assert_eq!(identify_agent("droid"), Some(Agent::Droid)); + } + + // ---- Amp ---- + + #[test] + fn amp_busy_running_tools() { + let screen = " ✓ Search Map the core runtime architecture\n ⋯ Oracle ▼\n ≈ Running tools... Esc to cancel"; + assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Busy); + } + + #[test] + fn amp_idle() { + let screen = " Response complete.\n\n╭─100% of 272k · $1.20─────────────────────────╮\n│ │\n╰───────────────────────~/Projects/herdr (master)╯"; + assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Idle); + } + + #[test] + fn amp_identified_by_process_name() { + assert_eq!(identify_agent("amp"), Some(Agent::Amp)); + assert_eq!(identify_agent("amp-local"), Some(Agent::Amp)); + } + + // ---- Helpers ---- + + #[test] + fn content_above_prompt_box_extracts_correctly() { + let screen = "line1\nline2\n──────\n❯ \n──────"; + let above = content_above_prompt_box(screen); + assert!(above.contains("line1")); + assert!(above.contains("line2")); + assert!(!above.contains('❯')); + } + + #[test] + fn content_above_prompt_box_no_box() { + let screen = "just some text\nno borders here"; + let above = content_above_prompt_box(screen); + assert_eq!(above, screen); + } + + #[test] + fn spinner_activity_detected() { + assert!(has_spinner_activity("✽ Tempering…")); + assert!(has_spinner_activity("✳ Simplifying recompute_tangents…")); + assert!(has_spinner_activity(" ✶ Reading…")); // with leading whitespace + } + + #[test] + fn spinner_activity_not_false_positive() { + assert!(!has_spinner_activity("normal text")); + assert!(!has_spinner_activity("✽ no ellipsis here")); + assert!(!has_spinner_activity("some ✽ in the middle")); + } + + #[test] + fn cursor_spinner_detected() { + assert!(has_cursor_spinner("⬡ Grepping..")); + assert!(has_cursor_spinner("⬢ Reading…")); + } + + #[test] + fn cursor_spinner_not_false_positive() { + assert!(!has_cursor_spinner("normal text")); + assert!(!has_cursor_spinner("some ⬡ in middle")); + } + + // ---- Process identification (real PTY) ---- + + #[cfg(target_os = "linux")] + #[test] + fn foreground_process_name_detects_sleep() { + use portable_pty::{native_pty_system, CommandBuilder, PtySize}; + + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .expect("failed to open pty"); + + // Spawn "sleep 999" — a known, deterministic process + let mut cmd = CommandBuilder::new("sleep"); + cmd.arg("999"); + let mut child = pair.slave.spawn_command(cmd).expect("failed to spawn"); + let pid = child.process_id().expect("no pid"); + + // Give the process a moment to become the foreground group + std::thread::sleep(std::time::Duration::from_millis(50)); + + let name = foreground_process_name(pid); + assert_eq!(name.as_deref(), Some("sleep"), "expected 'sleep', got {name:?}"); + + // Clean up + child.kill().ok(); + child.wait().ok(); + } + + #[cfg(target_os = "linux")] + #[test] + fn foreground_process_name_detects_shell_running_command() { + use portable_pty::{native_pty_system, CommandBuilder, PtySize}; + use std::io::Write; + + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .expect("failed to open pty"); + + // Spawn a shell, then run a command inside it + let cmd = CommandBuilder::new("sh"); + let mut child = pair.slave.spawn_command(cmd).expect("failed to spawn"); + let pid = child.process_id().expect("no pid"); + + // Write a command to the shell + let mut writer = pair.master.take_writer().expect("no writer"); + // Use exec so sleep replaces sh as the foreground process + writer.write_all(b"exec sleep 999\n").ok(); + drop(writer); + + std::thread::sleep(std::time::Duration::from_millis(100)); + + let name = foreground_process_name(pid); + // The foreground process should now be "sleep", not "sh" + assert_eq!(name.as_deref(), Some("sleep"), "expected 'sleep', got {name:?}"); + + child.kill().ok(); + child.wait().ok(); + } + + #[cfg(target_os = "linux")] + #[test] + fn proc_stat_parsing_handles_spaces_in_comm() { + // Verify our /proc/pid/stat parser correctly extracts fields + // even when (comm) could contain spaces. + let pid = std::process::id(); + let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).unwrap(); + + // Our parsing: find last ')' then split the rest + let close_paren = stat.rfind(')').expect("should have closing paren"); + let rest = &stat[close_paren + 2..]; + let fields: Vec<&str> = rest.split_whitespace().collect(); + + // We should have enough fields (at least 6 for tpgid) + assert!(fields.len() >= 6, "not enough fields in stat: {}", fields.len()); + + // Field 0 should be a valid state char (S, R, D, etc.) + let state = fields[0]; + assert!( + ["S", "R", "D", "Z", "T", "t", "W", "X", "I"].contains(&state), + "unexpected state: {state}" + ); + + // Field 5 (tpgid) should parse as i32 (can be -1 if no controlling terminal) + let tpgid: i32 = fields[5].parse().expect("tpgid should be a number"); + // In CI/test environments without a terminal, tpgid is typically -1 + let _ = tpgid; + } + + // ---- VT100 integration ---- + + #[test] + fn vt100_screen_content_works_with_detection() { + let mut parser = vt100::Parser::new(24, 80, 0); + parser.process(b"Working..."); + let content = parser.screen().contents(); + assert_eq!(detect_pi(&content), AgentState::Busy); + } + + #[test] + fn vt100_screen_with_ansi_colors() { + let mut parser = vt100::Parser::new(24, 80, 0); + // Red "Working..." with ANSI codes — contents() strips formatting + parser.process(b"\x1b[31mWorking...\x1b[0m"); + let content = parser.screen().contents(); + assert_eq!(detect_pi(&content), AgentState::Busy); + } + + #[test] + fn vt100_screen_claude_prompt_box() { + let mut parser = vt100::Parser::new(24, 80, 0); + let screen = "Task complete.\n─────────────\n❯ \n─────────────"; + parser.process(screen.as_bytes()); + let content = parser.screen().contents(); + assert_eq!(detect_claude(&content), AgentState::Idle); + } +} diff --git a/src/events.rs b/src/events.rs new file mode 100644 index 00000000..e86850cb --- /dev/null +++ b/src/events.rs @@ -0,0 +1,26 @@ +//! Internal app events delivered via channel. +//! +//! Background tasks (PTY child watchers, future hook listeners, etc.) send +//! events to the main loop through this channel. No polling needed. + +use crate::detect::{Agent, AgentState}; +use crate::layout::PaneId; + +/// An event from a background task to the main loop. +#[derive(Debug)] +pub enum AppEvent { + /// A pane's child process exited. + PaneDied { + pane_id: PaneId, + }, + /// Agent state changed in a pane (detected by the PTY reader). + StateChanged { + pane_id: PaneId, + agent: Option, + state: AgentState, + }, + /// A new version was downloaded and installed. Restart to use it. + UpdateReady { + version: String, + }, +} diff --git a/src/input.rs b/src/input.rs new file mode 100644 index 00000000..dbbf65df --- /dev/null +++ b/src/input.rs @@ -0,0 +1,339 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +/// Encode a key event for a PTY child. +/// If `kitty_mode` is true, modified keys use CSI u format. +pub fn encode_key(key: KeyEvent, kitty_mode: bool) -> Vec { + if kitty_mode { + if let Some(bytes) = try_encode_csi_u(&key) { + return bytes; + } + } + encode_legacy(key) +} + +/// CSI u encoding: \e[{codepoint};{modifiers}u +/// Used when the child has pushed Kitty keyboard enhancement. +/// Returns None if the key doesn't need CSI u (unmodified basic keys). +fn try_encode_csi_u(key: &KeyEvent) -> Option> { + let mods = key.modifiers; + + // Unmodified keys use legacy encoding (more compatible) + if mods.is_empty() { + return None; + } + + // Plain Ctrl+letter is well-represented in legacy (bytes 1-26) + if mods == KeyModifiers::CONTROL { + if let KeyCode::Char(c) = key.code { + if c.is_ascii_alphabetic() { + return None; // let legacy handle it + } + } + } + + // Special keys (arrows, F-keys, etc.) have well-established legacy + // xterm modified formats (\x1b[1;3A for Alt+Up, etc.) that are universally + // understood. Even Ghostty sends these in legacy format with kitty mode on. + // Only use CSI u for character keys and keys without legacy representations. + match key.code { + KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right + | KeyCode::Home | KeyCode::End | KeyCode::PageUp | KeyCode::PageDown + | KeyCode::Insert | KeyCode::Delete | KeyCode::F(_) => { + return None; // let legacy handle these + } + _ => {} + } + + let codepoint = match key.code { + KeyCode::Char(c) => c as u32, + KeyCode::Enter => 13, + KeyCode::Tab => 9, + KeyCode::Backspace => 127, + KeyCode::Esc => 27, + _ => return None, // fall back to legacy for unhandled keys + }; + + let modifier = kitty_modifier(mods); + + Some(format!("\x1b[{codepoint};{modifier}u").into_bytes()) +} + +/// Legacy terminal encoding (standard escape sequences). +fn encode_legacy(key: KeyEvent) -> Vec { + let mods = key.modifiers; + + // Modified special keys (arrows, home, end, etc.) use xterm format: + // \x1b[1;{modifier}A for arrows/home/end + // \x1b[{n};{modifier}~ for insert/delete/pgup/pgdn + // The ESC-prefix hack doesn't work for these since they're already escape sequences. + if !mods.is_empty() { + if let Some(bytes) = encode_modified_special(key.code, mods) { + return bytes; + } + } + + // Alt modifier on character keys: prefix with ESC + if mods.contains(KeyModifiers::ALT) { + let inner = KeyEvent::new(key.code, mods.difference(KeyModifiers::ALT)); + let mut bytes = vec![0x1b]; + bytes.extend(encode_legacy_inner(inner)); + return bytes; + } + encode_legacy_inner(key) +} + +/// xterm-style encoding for modified special keys. +/// Modifier value: 1 + (shift?1:0) + (alt?2:0) + (ctrl?4:0) +fn encode_modified_special(code: KeyCode, mods: KeyModifiers) -> Option> { + let modifier = xterm_modifier(mods); + if modifier <= 1 { + return None; // no modifiers to encode + } + + match code { + // CSI 1;{mod}{letter} format + KeyCode::Up => Some(format!("\x1b[1;{modifier}A").into_bytes()), + KeyCode::Down => Some(format!("\x1b[1;{modifier}B").into_bytes()), + KeyCode::Right => Some(format!("\x1b[1;{modifier}C").into_bytes()), + KeyCode::Left => Some(format!("\x1b[1;{modifier}D").into_bytes()), + KeyCode::Home => Some(format!("\x1b[1;{modifier}H").into_bytes()), + KeyCode::End => Some(format!("\x1b[1;{modifier}F").into_bytes()), + // CSI {n};{mod}~ format + KeyCode::Insert => Some(format!("\x1b[2;{modifier}~").into_bytes()), + KeyCode::Delete => Some(format!("\x1b[3;{modifier}~").into_bytes()), + KeyCode::PageUp => Some(format!("\x1b[5;{modifier}~").into_bytes()), + KeyCode::PageDown => Some(format!("\x1b[6;{modifier}~").into_bytes()), + // F1-F4: CSI 1;{mod}{P-S} + KeyCode::F(1) => Some(format!("\x1b[1;{modifier}P").into_bytes()), + KeyCode::F(2) => Some(format!("\x1b[1;{modifier}Q").into_bytes()), + KeyCode::F(3) => Some(format!("\x1b[1;{modifier}R").into_bytes()), + KeyCode::F(4) => Some(format!("\x1b[1;{modifier}S").into_bytes()), + // F5-F12: CSI {n};{mod}~ + KeyCode::F(n @ 5..=12) => { + let code = match n { + 5 => 15, + 6 => 17, + 7 => 18, + 8 => 19, + 9 => 20, + 10 => 21, + 11 => 23, + 12 => 24, + _ => unreachable!(), + }; + Some(format!("\x1b[{code};{modifier}~").into_bytes()) + } + _ => None, + } +} + +/// xterm modifier encoding: 1 + shift(1) + alt(2) + ctrl(4) +/// Used for legacy modified special keys (arrows, function keys, etc.) +fn xterm_modifier(mods: KeyModifiers) -> u32 { + let mut m = 1u32; + if mods.contains(KeyModifiers::SHIFT) { + m += 1; + } + if mods.contains(KeyModifiers::ALT) { + m += 2; + } + if mods.contains(KeyModifiers::CONTROL) { + m += 4; + } + m +} + +/// Kitty protocol modifier encoding: 1 + shift(1) + alt(2) + ctrl(4) + super(8) + hyper(16) + meta(32) +/// Superset of xterm — adds Super/Hyper/Meta bits. +fn kitty_modifier(mods: KeyModifiers) -> u32 { + let mut m = xterm_modifier(mods); + if mods.contains(KeyModifiers::SUPER) { + m += 8; + } + if mods.contains(KeyModifiers::HYPER) { + m += 16; + } + if mods.contains(KeyModifiers::META) { + m += 32; + } + m +} + +fn encode_legacy_inner(key: KeyEvent) -> Vec { + match key.code { + KeyCode::Char(ch) => { + if key.modifiers.contains(KeyModifiers::CONTROL) { + let upper = ch.to_ascii_uppercase(); + match upper { + 'A'..='Z' => vec![upper as u8 - 64], + ' ' | '@' | '2' => vec![0], + '[' | '3' => vec![27], + '\\' | '4' => vec![28], + ']' | '5' => vec![29], + '^' | '6' => vec![30], + '_' | '7' | '-' => vec![31], + _ => vec![ch as u8], + } + } else { + let mut buf = [0u8; 4]; + ch.encode_utf8(&mut buf).as_bytes().to_vec() + } + } + KeyCode::Enter => vec![b'\r'], + KeyCode::Backspace => vec![127], + KeyCode::Tab => vec![9], + KeyCode::BackTab => vec![27, 91, 90], + KeyCode::Esc => vec![27], + KeyCode::Left => vec![27, 91, 68], + KeyCode::Right => vec![27, 91, 67], + KeyCode::Up => vec![27, 91, 65], + KeyCode::Down => vec![27, 91, 66], + KeyCode::Home => vec![27, 91, 72], + KeyCode::End => vec![27, 91, 70], + KeyCode::PageUp => vec![27, 91, 53, 126], + KeyCode::PageDown => vec![27, 91, 54, 126], + KeyCode::Delete => vec![27, 91, 51, 126], + KeyCode::Insert => vec![27, 91, 50, 126], + KeyCode::F(n) => encode_f_key(n), + _ => vec![], + } +} + +fn encode_f_key(n: u8) -> Vec { + match n { + 1 => vec![27, 79, 80], + 2 => vec![27, 79, 81], + 3 => vec![27, 79, 82], + 4 => vec![27, 79, 83], + 5 => vec![27, 91, 49, 53, 126], + 6 => vec![27, 91, 49, 55, 126], + 7 => vec![27, 91, 49, 56, 126], + 8 => vec![27, 91, 49, 57, 126], + 9 => vec![27, 91, 50, 48, 126], + 10 => vec![27, 91, 50, 49, 126], + 11 => vec![27, 91, 50, 51, 126], + 12 => vec![27, 91, 50, 52, 126], + _ => vec![], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legacy_enter() { + let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()); + assert_eq!(encode_key(key, false), vec![b'\r']); + } + + #[test] + fn legacy_ctrl_c() { + let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + assert_eq!(encode_key(key, false), vec![3]); + } + + #[test] + fn legacy_shift_enter_is_just_cr() { + let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT); + // Enter/Tab/Backspace/Esc aren't special keys with xterm modifier encoding, + // so Shift+Enter falls through to legacy which just sends CR + assert_eq!(encode_key(key, false), vec![b'\r']); + } + + #[test] + fn legacy_alt_up() { + let key = KeyEvent::new(KeyCode::Up, KeyModifiers::ALT); + // xterm modified key format: CSI 1;3A (3 = 1 + Alt) + assert_eq!(encode_key(key, false), b"\x1b[1;3A"); + } + + #[test] + fn legacy_shift_right() { + let key = KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT); + assert_eq!(encode_key(key, false), b"\x1b[1;2C"); + } + + #[test] + fn legacy_ctrl_left() { + let key = KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL); + assert_eq!(encode_key(key, false), b"\x1b[1;5D"); + } + + #[test] + fn legacy_ctrl_shift_end() { + let key = KeyEvent::new( + KeyCode::End, + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ); + assert_eq!(encode_key(key, false), b"\x1b[1;6F"); + } + + #[test] + fn legacy_alt_delete() { + let key = KeyEvent::new(KeyCode::Delete, KeyModifiers::ALT); + assert_eq!(encode_key(key, false), b"\x1b[3;3~"); + } + + #[test] + fn legacy_shift_f5() { + let key = KeyEvent::new(KeyCode::F(5), KeyModifiers::SHIFT); + assert_eq!(encode_key(key, false), b"\x1b[15;2~"); + } + + #[test] + fn legacy_alt_char_still_esc_prefix() { + // Alt+a on character keys still uses ESC prefix (not xterm modified) + let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT); + assert_eq!(encode_key(key, false), b"\x1ba"); + } + + #[test] + fn kitty_shift_enter() { + let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT); + assert_eq!(encode_key(key, true), b"\x1b[13;2u"); + } + + #[test] + fn kitty_ctrl_shift_a() { + let key = KeyEvent::new( + KeyCode::Char('a'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ); + assert_eq!(encode_key(key, true), b"\x1b[97;6u"); + } + + #[test] + fn kitty_alt_enter() { + let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT); + assert_eq!(encode_key(key, true), b"\x1b[13;3u"); + } + + #[test] + fn kitty_plain_ctrl_c_uses_legacy() { + // Plain Ctrl+letter is well-represented in legacy + let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + assert_eq!(encode_key(key, true), vec![3]); + } + + #[test] + fn kitty_unmodified_uses_legacy() { + let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()); + assert_eq!(encode_key(key, true), b"a"); + } + + #[test] + fn kitty_shift_tab() { + let key = KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT); + assert_eq!(encode_key(key, true), b"\x1b[9;2u"); + } + + #[test] + fn kitty_ctrl_shift_enter() { + let key = KeyEvent::new( + KeyCode::Enter, + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ); + assert_eq!(encode_key(key, true), b"\x1b[13;6u"); + } +} diff --git a/src/layout.rs b/src/layout.rs new file mode 100644 index 00000000..23bdbb7d --- /dev/null +++ b/src/layout.rs @@ -0,0 +1,470 @@ +//! BSP tree layout for tiling panes within a workspace. + +use ratatui::layout::{Direction, Rect}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct PaneId(u32); + +/// Global atomic counter for unique PaneId generation across all workspaces. +static NEXT_PANE_ID: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(1); + +impl PaneId { + /// Allocate a globally unique PaneId. + pub fn alloc() -> Self { + Self(NEXT_PANE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) + } + + pub fn raw(self) -> u32 { + self.0 + } + + /// Reconstruct from a saved u32 (persistence only). + pub fn from_raw(id: u32) -> Self { + Self(id) + } +} + +/// Snapshot of a pane's position and focus state after layout. +#[derive(Clone)] +pub struct PaneInfo { + pub id: PaneId, + /// Outer rect (including borders if present). + pub rect: Rect, + /// Inner rect (content area, excluding borders). Used for selection. + pub inner_rect: Rect, + pub is_focused: bool, +} + +/// Info about a split boundary, used for mouse drag resize. +#[derive(Clone)] +pub struct SplitBorder { + /// Position of the divider line (x for horizontal split, y for vertical). + pub pos: u16, + /// Direction of the split that created this border. + pub direction: Direction, + /// Total area of the split node. + pub area: Rect, + /// Path from root to this split node (false=first, true=second). + pub path: Vec, +} + +/// Cardinal direction for pane navigation. +#[derive(Debug, Clone, Copy)] +pub enum NavDirection { + Left, + Right, + Up, + Down, +} + +/// A node in the BSP tree. Public for serialization. +pub enum Node { + Pane(PaneId), + Split { + direction: Direction, + ratio: f32, + first: Box, + second: Box, + }, +} + +/// BSP tiling layout. Tracks a tree of splits and a focused pane. +pub struct TileLayout { + root: Node, + focus: PaneId, +} + +impl TileLayout { + /// Create a new layout with a single pane (globally unique ID). + /// Returns (layout, root_pane_id) so the caller can create the pane. + pub fn new() -> (Self, PaneId) { + let root_id = PaneId::alloc(); + ( + Self { + root: Node::Pane(root_id), + focus: root_id, + }, + root_id, + ) + } + + pub fn focused(&self) -> PaneId { + self.focus + } + + pub fn pane_count(&self) -> usize { + count_panes(&self.root) + } + + /// Compute rects for all panes given the available area. + pub fn panes(&self, area: Rect) -> Vec { + let mut result = Vec::new(); + collect_panes(&self.root, area, self.focus, &mut result); + result + } + + /// Collect all split boundaries for mouse drag resize. + pub fn splits(&self, area: Rect) -> Vec { + let mut result = Vec::new(); + collect_splits(&self.root, area, vec![], &mut result); + result + } + + /// Split the focused pane. Returns the new pane's id. + pub fn split_focused(&mut self, direction: Direction) -> PaneId { + let new_id = PaneId::alloc(); + let placeholder = PaneId::from_raw(0); + let old = std::mem::replace(&mut self.root, Node::Pane(placeholder)); + self.root = split_at(old, self.focus, direction, new_id); + self.focus = new_id; + new_id + } + + /// Close the focused pane. Returns false if it's the last pane. + pub fn close_focused(&mut self) -> bool { + if self.pane_count() <= 1 { + return false; + } + let target = self.focus; + let ids = self.pane_ids(); + let pos = ids.iter().position(|id| *id == target).unwrap(); + let new_focus = if pos + 1 < ids.len() { + ids[pos + 1] + } else { + ids[pos - 1] + }; + let placeholder = PaneId::from_raw(0); + let old = std::mem::replace(&mut self.root, Node::Pane(placeholder)); + if let Some(new_root) = remove_pane(old, target) { + self.root = new_root; + self.focus = new_focus; + true + } else { + false + } + } + + pub fn focus_next(&mut self) { + let ids = self.pane_ids(); + if let Some(pos) = ids.iter().position(|id| *id == self.focus) { + self.focus = ids[(pos + 1) % ids.len()]; + } + } + + pub fn focus_prev(&mut self) { + let ids = self.pane_ids(); + if let Some(pos) = ids.iter().position(|id| *id == self.focus) { + self.focus = ids[(pos + ids.len() - 1) % ids.len()]; + } + } + + pub fn focus_pane(&mut self, id: PaneId) { + if self.pane_ids().contains(&id) { + self.focus = id; + } + } + + /// Set the ratio of a split node at the given path. + pub fn set_ratio_at(&mut self, path: &[bool], ratio: f32) { + set_ratio_at(&mut self.root, path, ratio.clamp(0.1, 0.9)); + } + + /// Adjust the nearest split in the given direction for the focused pane. + /// `delta` is positive to grow, negative to shrink. + pub fn resize_focused(&mut self, nav: NavDirection, delta: f32, area: Rect) { + let panes = self.panes(area); + let Some(focused) = panes.iter().find(|p| p.is_focused) else { + return; + }; + let focused_rect = focused.rect; + let splits = self.splits(area); + + // Find the split whose border is adjacent to the focused pane in the given direction + let target_dir = match nav { + NavDirection::Left | NavDirection::Right => Direction::Horizontal, + NavDirection::Up | NavDirection::Down => Direction::Vertical, + }; + let grows = matches!(nav, NavDirection::Right | NavDirection::Down); + + // Find the closest matching split border + let best = splits + .iter() + .filter(|s| s.direction == target_dir) + .filter(|s| match target_dir { + Direction::Horizontal => { + // Border must be near the focused pane's left or right edge + let near_right = (s.pos as i32 - (focused_rect.x + focused_rect.width) as i32).unsigned_abs() <= 1; + let near_left = (s.pos as i32 - focused_rect.x as i32).unsigned_abs() <= 1; + near_right || near_left + } + Direction::Vertical => { + let near_bottom = (s.pos as i32 - (focused_rect.y + focused_rect.height) as i32).unsigned_abs() <= 1; + let near_top = (s.pos as i32 - focused_rect.y as i32).unsigned_abs() <= 1; + near_bottom || near_top + } + }) + .min_by_key(|s| { + // Prefer the border in the direction we're resizing toward + match (target_dir, grows) { + (Direction::Horizontal, true) => { + ((focused_rect.x + focused_rect.width) as i32 - s.pos as i32).unsigned_abs() + } + (Direction::Horizontal, false) => { + (focused_rect.x as i32 - s.pos as i32).unsigned_abs() + } + (Direction::Vertical, true) => { + ((focused_rect.y + focused_rect.height) as i32 - s.pos as i32).unsigned_abs() + } + (Direction::Vertical, false) => { + (focused_rect.y as i32 - s.pos as i32).unsigned_abs() + } + } + }); + + if let Some(split) = best { + let path = split.path.clone(); + let current_ratio = get_ratio_at(&self.root, &path).unwrap_or(0.5); + let adj = if grows { delta } else { -delta }; + self.set_ratio_at(&path, current_ratio + adj); + } + } + + pub fn pane_ids(&self) -> Vec { + let mut ids = Vec::new(); + collect_ids(&self.root, &mut ids); + ids + } + + /// Access the tree root for serialization. + pub fn root(&self) -> &Node { + &self.root + } + + /// Reconstruct a layout from a saved tree. + /// Reconstruct a layout from a saved tree. + pub fn from_saved(root: Node, focus: PaneId) -> Self { + Self { root, focus } + } +} + +// --- Directional pane navigation --- + +/// Find the nearest pane in the given direction from `focused`. +pub fn find_in_direction( + focused: &PaneInfo, + direction: NavDirection, + panes: &[PaneInfo], +) -> Option { + let fr = focused.rect; + + panes + .iter() + .filter(|p| p.id != focused.id) + .filter(|p| { + let r = p.rect; + match direction { + NavDirection::Left => { + r.x + r.width <= fr.x && ranges_overlap(r.y, r.height, fr.y, fr.height) + } + NavDirection::Right => { + r.x >= fr.x + fr.width && ranges_overlap(r.y, r.height, fr.y, fr.height) + } + NavDirection::Up => { + r.y + r.height <= fr.y && ranges_overlap(r.x, r.width, fr.x, fr.width) + } + NavDirection::Down => { + r.y >= fr.y + fr.height && ranges_overlap(r.x, r.width, fr.x, fr.width) + } + } + }) + .min_by_key(|p| { + let r = p.rect; + match direction { + NavDirection::Left => fr.x.saturating_sub(r.x + r.width), + NavDirection::Right => r.x.saturating_sub(fr.x + fr.width), + NavDirection::Up => fr.y.saturating_sub(r.y + r.height), + NavDirection::Down => r.y.saturating_sub(fr.y + fr.height), + } + }) + .map(|p| p.id) +} + +fn ranges_overlap(a_start: u16, a_len: u16, b_start: u16, b_len: u16) -> bool { + a_start < b_start + b_len && a_start + a_len > b_start +} + +// --- Tree operations --- + +fn count_panes(node: &Node) -> usize { + match node { + Node::Pane(_) => 1, + Node::Split { first, second, .. } => count_panes(first) + count_panes(second), + } +} + +fn collect_panes(node: &Node, area: Rect, focus: PaneId, result: &mut Vec) { + match node { + Node::Pane(id) => { + result.push(PaneInfo { + id: *id, + rect: area, + // inner_rect is set during render when we know if borders are shown + inner_rect: area, + is_focused: *id == focus, + }); + } + Node::Split { + direction, + ratio, + first, + second, + } => { + let (a, b) = split_rect(area, *direction, *ratio); + collect_panes(first, a, focus, result); + collect_panes(second, b, focus, result); + } + } +} + +fn collect_splits(node: &Node, area: Rect, path: Vec, result: &mut Vec) { + if let Node::Split { + direction, + ratio, + first, + second, + } = node + { + let (a, b) = split_rect(area, *direction, *ratio); + let pos = match direction { + Direction::Horizontal => a.x + a.width, + Direction::Vertical => a.y + a.height, + }; + result.push(SplitBorder { + pos, + direction: *direction, + area, + path: path.clone(), + }); + let mut lp = path.clone(); + lp.push(false); + collect_splits(first, a, lp, result); + let mut rp = path; + rp.push(true); + collect_splits(second, b, rp, result); + } +} + +fn collect_ids(node: &Node, ids: &mut Vec) { + match node { + Node::Pane(id) => ids.push(*id), + Node::Split { first, second, .. } => { + collect_ids(first, ids); + collect_ids(second, ids); + } + } +} + +fn split_at(node: Node, target: PaneId, direction: Direction, new_id: PaneId) -> Node { + match node { + Node::Pane(id) if id == target => Node::Split { + direction, + ratio: 0.5, + first: Box::new(Node::Pane(id)), + second: Box::new(Node::Pane(new_id)), + }, + Node::Pane(_) => node, + Node::Split { + direction: d, + ratio, + first, + second, + } => Node::Split { + direction: d, + ratio, + first: Box::new(split_at(*first, target, direction, new_id)), + second: Box::new(split_at(*second, target, direction, new_id)), + }, + } +} + +fn remove_pane(node: Node, target: PaneId) -> Option { + match node { + Node::Pane(id) if id == target => None, + Node::Pane(_) => Some(node), + Node::Split { + direction, + ratio, + first, + second, + } => match (remove_pane(*first, target), remove_pane(*second, target)) { + (None, Some(s)) => Some(s), + (Some(f), None) => Some(f), + (Some(f), Some(s)) => Some(Node::Split { + direction, + ratio, + first: Box::new(f), + second: Box::new(s), + }), + (None, None) => None, + }, + } +} + +fn set_ratio_at(node: &mut Node, path: &[bool], new_ratio: f32) { + if let Node::Split { + ratio, + first, + second, + .. + } = node + { + if path.is_empty() { + *ratio = new_ratio; + } else if path[0] { + set_ratio_at(second, &path[1..], new_ratio); + } else { + set_ratio_at(first, &path[1..], new_ratio); + } + } +} + +fn get_ratio_at(node: &Node, path: &[bool]) -> Option { + if let Node::Split { + ratio, + first, + second, + .. + } = node + { + if path.is_empty() { + Some(*ratio) + } else if path[0] { + get_ratio_at(second, &path[1..]) + } else { + get_ratio_at(first, &path[1..]) + } + } else { + None + } +} + +fn split_rect(area: Rect, direction: Direction, ratio: f32) -> (Rect, Rect) { + match direction { + Direction::Horizontal => { + let first_w = ((area.width as f32) * ratio).round() as u16; + let second_w = area.width.saturating_sub(first_w); + ( + Rect::new(area.x, area.y, first_w, area.height), + Rect::new(area.x + first_w, area.y, second_w, area.height), + ) + } + Direction::Vertical => { + let first_h = ((area.height as f32) * ratio).round() as u16; + let second_h = area.height.saturating_sub(first_h); + ( + Rect::new(area.x, area.y, area.width, first_h), + Rect::new(area.x, area.y + first_h, area.width, second_h), + ) + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 00000000..a76b913e --- /dev/null +++ b/src/main.rs @@ -0,0 +1,228 @@ +use std::io; + +use crossterm::event::{ + DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, + KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, +}; +use crossterm::execute; +use tracing::info; + +mod app; +mod config; +mod detect; +mod events; +mod input; +mod layout; +mod pane; +mod persist; +mod platform; +mod pty_callbacks; +mod selection; +mod ui; +mod update; +mod workspace; + +fn init_logging() { + use std::fs::{self, OpenOptions}; + use tracing_subscriber::EnvFilter; + + let log_dir = if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") { + std::path::PathBuf::from(dir).join("herdr") + } else if let Ok(home) = std::env::var("HOME") { + std::path::PathBuf::from(home).join(".config/herdr") + } else { + std::path::PathBuf::from("/tmp/herdr") + }; + let _ = fs::create_dir_all(&log_dir); + let log_path = log_dir.join("herdr.log"); + + // Rotate: truncate if over 5MB + if let Ok(meta) = fs::metadata(&log_path) { + if meta.len() > 5 * 1024 * 1024 { + let _ = fs::remove_file(&log_path); + } + } + + let file = match OpenOptions::new().create(true).append(true).open(&log_path) { + Ok(f) => f, + Err(_) => return, // can't open log file, proceed without logging + }; + + let filter = EnvFilter::try_from_env("HERDR_LOG") + .unwrap_or_else(|_| EnvFilter::new("herdr=info")); + + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(file) + .with_ansi(false) + .with_target(false) + .init(); +} + +const DEFAULT_CONFIG: &str = r#"# herdr configuration +# Place this file at ~/.config/herdr/config.toml + +[keys] +# Prefix key to enter navigate mode (e.g. "ctrl+s", "ctrl+a", "ctrl+b") +# prefix = "ctrl+s" + +# Pane controls (in navigate mode) +# split_vertical = "v" +# split_horizontal = "-" +# close_pane = "x" +# fullscreen = "f" + +[ui] +# Sidebar width (auto-scaled based on workspace names, this sets the default) +# sidebar_width = 26 + +# Ask for confirmation before closing a workspace +# confirm_close = true + +# Accent color for highlights, borders, and navigation UI. +# Accepts: hex (#89b4fa), named colors (cyan, blue, magenta), or rgb(r,g,b) +# accent = "cyan" +"#; + +fn main() -> io::Result<()> { + let args: Vec = std::env::args().collect(); + + // Subcommands and flags (no TUI, no logging needed) + if args.get(1).map(|s| s.as_str()) == Some("update") { + match update::self_update() { + Ok(_) => return Ok(()), + Err(e) => { + eprintln!("update failed: {e}"); + std::process::exit(1); + } + } + } + + if args.iter().any(|a| a == "--help" || a == "-h") { + println!("herdr — terminal workspace manager for AI coding agents"); + println!(); + println!("Usage: herdr [options]"); + println!(" herdr update"); + println!(); + println!("Commands:"); + println!(" update Download and install the latest version"); + println!(); + println!("Options:"); + println!(" --no-session Don't restore or save sessions"); + println!(" --default-config Print default configuration and exit"); + println!(" --version, -V Print version and exit"); + println!(" --help, -h Show this help"); + println!(); + println!("Config: ~/.config/herdr/config.toml"); + println!("Logs: ~/.config/herdr/herdr.log"); + println!("Home: https://herdr.dev"); + return Ok(()); + } + + if args.iter().any(|a| a == "--version" || a == "-V") { + println!("herdr {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + + if args.iter().any(|a| a == "--default-config") { + print!("{DEFAULT_CONFIG}"); + return Ok(()); + } + + // Reject unknown flags + let known_flags = ["--no-session", "--version", "-V", "--default-config", "--help", "-h"]; + for arg in &args[1..] { + if arg.starts_with('-') && !known_flags.contains(&arg.as_str()) { + eprintln!("unknown option: {arg}"); + eprintln!("run 'herdr --help' for usage"); + std::process::exit(1); + } + if !arg.starts_with('-') && arg != "update" { + eprintln!("unknown command: {arg}"); + eprintln!("run 'herdr --help' for usage"); + std::process::exit(1); + } + } + + init_logging(); + + let no_session = std::env::args().any(|a| a == "--no-session"); + let in_tmux = std::env::var("TMUX").is_ok(); + + let original_hook = std::panic::take_hook(); + let panic_in_tmux = in_tmux; + std::panic::set_hook(Box::new(move |info| { + tracing::error!("PANIC: {info}"); + if panic_in_tmux { + let _ = std::io::Write::write_all(&mut io::stdout(), b"\x1b[>4;0m"); + } + let _ = execute!( + io::stdout(), + PopKeyboardEnhancementFlags, + DisableBracketedPaste, + DisableMouseCapture + ); + ratatui::restore(); + original_hook(info); + })); + + let config = config::Config::load(); + info!("herdr starting, pid={}", std::process::id()); + + // Background auto-update (non-blocking, best-effort) + // Downloads and installs new version silently, notifies TUI when done. + // Skipped in --no-session mode (testing). + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("failed to create tokio runtime"); + + let result = rt.block_on(async { + let mut terminal = ratatui::init(); + execute!( + io::stdout(), + EnableMouseCapture, + EnableBracketedPaste, + PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) + )?; + + // tmux doesn't understand kitty keyboard protocol push (\e[>1u). + // It uses modifyOtherKeys mode to send CSI u sequences for modified keys. + // Enable modifyOtherKeys mode 2 so tmux sends Shift+Enter as \e[13;2u etc. + if in_tmux { + use std::io::Write; + std::io::stdout().write_all(b"\x1b[>4;2m")?; + std::io::stdout().flush()?; + } + + let mut app = app::App::new(&config, no_session); + let result = app.run(&mut terminal).await; + + // Reset modifyOtherKeys if we enabled it + if in_tmux { + use std::io::Write; + std::io::stdout().write_all(b"\x1b[>4;0m")?; + std::io::stdout().flush()?; + } + + execute!( + io::stdout(), + PopKeyboardEnhancementFlags, + DisableBracketedPaste, + DisableMouseCapture + )?; + ratatui::restore(); + + // Drop app (and all workspaces/panes) before runtime shuts down + drop(app); + + result + }); + + // Shut down runtime immediately — kills lingering PTY reader/writer tasks + rt.shutdown_timeout(std::time::Duration::from_millis(100)); + + info!("herdr exiting"); + result +} diff --git a/src/pane.rs b/src/pane.rs new file mode 100644 index 00000000..821cdec9 --- /dev/null +++ b/src/pane.rs @@ -0,0 +1,370 @@ +use std::io::{BufWriter, Read, Write}; +use std::cell::Cell; +use std::sync::{ + atomic::{AtomicBool, AtomicU32, Ordering}, + Arc, RwLock, +}; + +use bytes::Bytes; +use portable_pty::{native_pty_system, CommandBuilder, PtySize}; +use tokio::sync::mpsc; +use tracing::{debug, error, info, warn}; + +use crate::detect::{Agent, AgentState}; +use crate::events::AppEvent; +use crate::layout::PaneId; +use crate::pty_callbacks::PtyResponses; + +// --------------------------------------------------------------------------- +// PaneState — pure data, constructable without PTYs, testable +// --------------------------------------------------------------------------- + +/// Observable state for a single pane. +/// This is the only part of a pane that workspace logic and tests need. +pub struct PaneState { + pub detected_agent: Option, + pub state: AgentState, + /// Whether the user has seen this pane since its last state change to Idle. + /// False = "Done" (agent finished while user was in another workspace). + pub seen: bool, +} + +impl PaneState { + pub fn new() -> Self { + Self { + detected_agent: None, + state: AgentState::Unknown, + seen: true, + } + } +} + +// --------------------------------------------------------------------------- +// PaneRuntime — PTY, parser, channels, background tasks +// --------------------------------------------------------------------------- + +/// PTY runtime for a pane. Owns the terminal, I/O channels, and background tasks. +/// Dropping this shuts down all background tasks and closes the PTY. +pub struct PaneRuntime { + pub parser: Arc>>, + pub sender: mpsc::Sender, + resize_tx: mpsc::Sender<(u16, u16)>, + current_size: Cell<(u16, u16)>, + child_pid: Arc, + pub kitty_keyboard: Arc, + /// Live screen content snapshot — updated by reader, read by detector. + /// Decouples detection from parser viewport state (scrollback). + /// Kept alive here so the Arc isn't dropped; tasks hold their own clones. + #[allow(dead_code)] + screen_content: Arc>, + // Task handles for deterministic shutdown + detect_handle: tokio::task::AbortHandle, +} + +impl Drop for PaneRuntime { + fn drop(&mut self) { + // Abort detection task immediately. + // Reader/writer/resize tasks shut down naturally via channel close + // and PTY EOF when the rest of PaneRuntime is dropped. + self.detect_handle.abort(); + } +} + +impl PaneRuntime { + pub fn spawn( + pane_id: PaneId, + rows: u16, + cols: u16, + cwd: std::path::PathBuf, + events: mpsc::Sender, + ) -> std::io::Result { + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| std::io::Error::other(e.to_string()))?; + + let responses = PtyResponses::new(); + let kitty_keyboard = responses.kitty_keyboard.clone(); + let parser = Arc::new(RwLock::new(vt100::Parser::new_with_callbacks( + rows, cols, 10000, responses.clone(), + ))); + + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()); + let mut cmd = CommandBuilder::new(&shell); + cmd.cwd(cwd); + + let reader = pair + .master + .try_clone_reader() + .map_err(|e| std::io::Error::other(e.to_string()))?; + let writer = pair + .master + .take_writer() + .map_err(|e| std::io::Error::other(e.to_string()))?; + + // --- Child watcher task --- + let child_pid = Arc::new(AtomicU32::new(0)); + { + let child_pid = child_pid.clone(); + let slave = pair.slave; + let events = events.clone(); + let rt = tokio::runtime::Handle::current(); + tokio::task::spawn_blocking(move || { + match slave.spawn_command(cmd) { + Ok(mut child) => { + if let Some(pid) = child.process_id() { + child_pid.store(pid, Ordering::Release); + info!(pane = pane_id.raw(), pid, "child spawned"); + } + match child.wait() { + Ok(status) => info!(pane = pane_id.raw(), ?status, "child exited"), + Err(e) => error!(pane = pane_id.raw(), err = %e, "child wait failed"), + } + } + Err(e) => error!(pane = pane_id.raw(), err = %e, "failed to spawn shell"), + } + // Use blocking send — PaneDied is critical, must not be dropped + if let Err(e) = rt.block_on(events.send(AppEvent::PaneDied { pane_id })) { + error!(pane = pane_id.raw(), err = %e, "failed to send PaneDied event"); + } + }); + } + + // --- Writer channel --- + let (input_tx, mut input_rx) = mpsc::channel::(32); + + // Live screen snapshot for detection (decoupled from parser scrollback) + let screen_content = Arc::new(RwLock::new(String::new())); + + // --- Reader task: PTY → parser + screen snapshot + terminal query responses --- + { + let mut reader = reader; + let parser = parser.clone(); + let screen_content = screen_content.clone(); + let response_writer = input_tx.clone(); + tokio::task::spawn_blocking(move || { + let mut buf = [0u8; 8192]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Err(e) => { + debug!(pane = pane_id.raw(), err = %e, "pty reader closed"); + break; + } + Ok(n) => { + if let Ok(mut p) = parser.write() { + p.process(&buf[..n]); + // Snapshot live screen content for detection. + // Always reads at scrollback 0 (current view), + // without touching the user's scroll position. + let scrollback = p.screen().scrollback(); + if scrollback > 0 { + p.screen_mut().set_scrollback(0); + } + let content = p.screen().contents(); + if scrollback > 0 { + p.screen_mut().set_scrollback(scrollback); + } + if let Ok(mut sc) = screen_content.write() { + *sc = content; + } + } else { + error!(pane = pane_id.raw(), "parser lock poisoned in reader"); + break; + } + let resp = responses.take(); + if !resp.is_empty() { + if let Err(e) = response_writer.try_send(Bytes::from(resp)) { + warn!(pane = pane_id.raw(), err = %e, "dropped terminal query response"); + } + } + } + } + } + debug!(pane = pane_id.raw(), "reader task exiting"); + }); + } + + // --- Detection task --- + let detect_handle = { + use crate::detect; + use std::time::{Duration, Instant}; + + const TICK_UNIDENTIFIED: Duration = Duration::from_millis(500); + const TICK_IDENTIFIED: Duration = Duration::from_millis(300); + const PROCESS_RECHECK: Duration = Duration::from_secs(5); + + let child_pid = child_pid.clone(); + let screen_content = screen_content.clone(); + let state_events = events.clone(); + + let handle = tokio::spawn(async move { + let mut agent: Option = None; + let mut state = AgentState::Unknown; + let mut last_process_check = Instant::now(); + + tokio::time::sleep(Duration::from_millis(50)).await; + + loop { + let tick = if agent.is_none() { + TICK_UNIDENTIFIED + } else { + TICK_IDENTIFIED + }; + tokio::time::sleep(tick).await; + + let now = Instant::now(); + let should_check_process = agent.is_none() + || now.duration_since(last_process_check) >= PROCESS_RECHECK; + + let mut agent_changed = false; + if should_check_process { + last_process_check = now; + let pid = child_pid.load(Ordering::Acquire); + if pid > 0 { + if let Some(name) = detect::foreground_process_name(pid) { + let new_agent = detect::identify_agent(&name); + if new_agent != agent { + info!( + pane = pane_id.raw(), + ?new_agent, + process = %name, + "agent changed" + ); + agent = new_agent; + agent_changed = true; + } + } + } + } + + let new_state = if let Ok(content) = screen_content.read() { + detect::detect_state(agent, &content) + } else { + continue; + }; + + if new_state != state || agent_changed { + debug!( + pane = pane_id.raw(), + ?state, + ?new_state, + ?agent, + "state changed" + ); + state = new_state; + if let Err(e) = state_events.try_send(AppEvent::StateChanged { + pane_id, + agent, + state: new_state, + }) { + warn!( + pane = pane_id.raw(), + err = %e, + "dropped StateChanged event" + ); + } + } + } + }); + handle.abort_handle() + }; + + // --- Writer task: channel → PTY --- + { + let mut writer = BufWriter::new(writer); + tokio::task::spawn_blocking(move || { + let rt = tokio::runtime::Handle::current(); + while let Some(bytes) = rt.block_on(input_rx.recv()) { + if let Err(e) = writer.write_all(&bytes) { + warn!(pane = pane_id.raw(), err = %e, "pty write failed"); + break; + } + if let Err(e) = writer.flush() { + warn!(pane = pane_id.raw(), err = %e, "pty flush failed"); + break; + } + } + debug!(pane = pane_id.raw(), "writer task exiting"); + }); + } + + // --- Resize task --- + let (resize_tx, mut resize_rx) = mpsc::channel::<(u16, u16)>(4); + { + let master = pair.master; + tokio::task::spawn_blocking(move || { + let rt = tokio::runtime::Handle::current(); + while let Some((rows, cols)) = rt.block_on(resize_rx.recv()) { + if let Err(e) = master.resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) { + warn!(pane = pane_id.raw(), err = %e, rows, cols, "pty resize failed"); + } + } + }); + } + + Ok(Self { + parser, + sender: input_tx, + resize_tx, + current_size: Cell::new((rows, cols)), + child_pid, + kitty_keyboard, + screen_content, + detect_handle, + }) + } + + /// Resize if the dimensions actually changed. + pub fn resize(&self, rows: u16, cols: u16) { + let rows = rows.max(2); + let cols = cols.max(4); + if self.current_size.get() == (rows, cols) { + return; + } + self.current_size.set((rows, cols)); + if let Ok(mut p) = self.parser.write() { + p.screen_mut().set_size(rows, cols); + } + let _ = self.resize_tx.try_send((rows, cols)); + } + + /// Scroll up by N lines (into scrollback history). + pub fn scroll_up(&self, lines: usize) { + if let Ok(mut p) = self.parser.write() { + let current = p.screen().scrollback(); + p.screen_mut().set_scrollback(current + lines); + } + } + + /// Scroll down by N lines (toward live output). + pub fn scroll_down(&self, lines: usize) { + if let Ok(mut p) = self.parser.write() { + let current = p.screen().scrollback(); + p.screen_mut().set_scrollback(current.saturating_sub(lines)); + } + } + + /// Reset scroll to live view (offset = 0). + pub fn scroll_reset(&self) { + if let Ok(mut p) = self.parser.write() { + p.screen_mut().set_scrollback(0); + } + } + + /// Get the current working directory of the child shell process. + pub fn cwd(&self) -> Option { + let pid = self.child_pid.load(Ordering::Relaxed); + crate::platform::process_cwd(pid) + } +} diff --git a/src/persist.rs b/src/persist.rs new file mode 100644 index 00000000..31a37576 --- /dev/null +++ b/src/persist.rs @@ -0,0 +1,446 @@ +//! Session persistence — save/restore workspaces, layouts, and working directories. +//! +//! Stored at `~/.config/herdr/session.json`. + +use std::collections::HashMap; +use std::path::PathBuf; + +use ratatui::layout::Direction; +use serde::{Deserialize, Serialize}; +use tracing::{error, info, warn}; + +use tokio::sync::mpsc; + +use crate::events::AppEvent; +use crate::layout::{Node, PaneId, TileLayout}; +use crate::pane::{PaneRuntime, PaneState}; +use crate::workspace::Workspace; + +/// Current snapshot format version. +const SNAPSHOT_VERSION: u32 = 1; + +/// Serializable snapshot of the entire herdr session. +#[derive(Serialize, Deserialize)] +pub struct SessionSnapshot { + /// Format version — used to detect incompatible changes. + #[serde(default)] + pub version: u32, + pub workspaces: Vec, + pub active: Option, + pub selected: usize, +} + +#[derive(Serialize, Deserialize)] +pub struct WorkspaceSnapshot { + pub name: String, + pub layout: LayoutSnapshot, + pub panes: HashMap, + pub zoomed: bool, + /// Raw pane ID that was focused when saved. + #[serde(default)] + pub focused: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct PaneSnapshot { + pub cwd: PathBuf, +} + +/// Serializable BSP tree. +#[derive(Serialize, Deserialize)] +pub enum LayoutSnapshot { + Pane(u32), + Split { + direction: DirectionSnapshot, + ratio: f32, + first: Box, + second: Box, + }, +} + +#[derive(Serialize, Deserialize)] +pub enum DirectionSnapshot { + Horizontal, + Vertical, +} + +// --- Capture --- + +/// Capture the current app state into a serializable snapshot. +pub fn capture( + workspaces: &[Workspace], + active: Option, + selected: usize, +) -> SessionSnapshot { + SessionSnapshot { + version: SNAPSHOT_VERSION, + workspaces: workspaces.iter().map(capture_workspace).collect(), + active, + selected, + } +} + +fn capture_workspace(ws: &Workspace) -> WorkspaceSnapshot { + let mut panes = HashMap::new(); + for id in ws.panes.keys() { + let cwd = ws.runtimes.get(id) + .and_then(|rt| rt.cwd()) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); + panes.insert(id.raw(), PaneSnapshot { cwd }); + } + WorkspaceSnapshot { + name: ws.name.clone(), + layout: capture_node(ws.layout.root()), + panes, + zoomed: ws.zoomed, + focused: Some(ws.layout.focused().raw()), + } +} + +fn capture_node(node: &Node) -> LayoutSnapshot { + match node { + Node::Pane(id) => LayoutSnapshot::Pane(id.raw()), + Node::Split { + direction, + ratio, + first, + second, + } => LayoutSnapshot::Split { + direction: match direction { + Direction::Horizontal => DirectionSnapshot::Horizontal, + Direction::Vertical => DirectionSnapshot::Vertical, + }, + ratio: *ratio, + first: Box::new(capture_node(first)), + second: Box::new(capture_node(second)), + }, + } +} + +// --- Restore --- + +/// Restore workspaces from a snapshot. Each pane gets a fresh shell in its saved cwd. +pub fn restore( + snapshot: &SessionSnapshot, + rows: u16, + cols: u16, + events: mpsc::Sender, +) -> Vec { + snapshot + .workspaces + .iter() + .filter_map(|ws_snap| restore_workspace(ws_snap, rows, cols, events.clone())) + .collect() +} + +fn restore_workspace( + snap: &WorkspaceSnapshot, + rows: u16, + cols: u16, + events: mpsc::Sender, +) -> Option { + let (node, id_map) = restore_node_remapped(&snap.layout); + let pane_ids = collect_pane_ids(&node); + + // Restore focused pane: map saved raw ID to new ID, fall back to first pane + let focus = snap + .focused + .and_then(|old_raw| id_map.get(&old_raw).copied()) + .or_else(|| pane_ids.first().copied()) + .unwrap_or(PaneId::from_raw(0)); + let layout = TileLayout::from_saved(node, focus); + + let mut panes = HashMap::new(); + let mut runtimes = HashMap::new(); + for id in &pane_ids { + let old_id = id_map.iter().find(|(_, new)| **new == *id).map(|(old, _)| old); + let cwd = old_id + .and_then(|old| snap.panes.get(old)) + .map(|p| p.cwd.clone()) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); + + match PaneRuntime::spawn(*id, rows, cols, cwd, events.clone()) { + Ok(runtime) => { + panes.insert(*id, PaneState::new()); + runtimes.insert(*id, runtime); + } + Err(e) => { + error!(workspace = %snap.name, err = %e, "failed to restore pane"); + return None; + } + } + } + + Some(Workspace { + name: snap.name.clone(), + layout, + panes, + runtimes, + zoomed: snap.zoomed, + events, + }) +} + +/// Restore a layout tree, remapping every pane ID to a fresh globally unique one. +/// Returns the new tree and a map of old_raw_id → new PaneId. +fn restore_node_remapped(snap: &LayoutSnapshot) -> (Node, HashMap) { + let mut id_map = HashMap::new(); + let node = remap_inner(snap, &mut id_map); + (node, id_map) +} + +fn remap_inner(snap: &LayoutSnapshot, id_map: &mut HashMap) -> Node { + match snap { + LayoutSnapshot::Pane(old_id) => { + let new_id = PaneId::alloc(); + id_map.insert(*old_id, new_id); + Node::Pane(new_id) + } + LayoutSnapshot::Split { + direction, + ratio, + first, + second, + } => { + let first_node = remap_inner(first, id_map); + let second_node = remap_inner(second, id_map); + let dir = match direction { + DirectionSnapshot::Horizontal => Direction::Horizontal, + DirectionSnapshot::Vertical => Direction::Vertical, + }; + Node::Split { + direction: dir, + ratio: *ratio, + first: Box::new(first_node), + second: Box::new(second_node), + } + } + } +} + +fn collect_pane_ids(node: &Node) -> Vec { + let mut ids = Vec::new(); + collect_ids_inner(node, &mut ids); + ids +} + +fn collect_ids_inner(node: &Node, ids: &mut Vec) { + match node { + Node::Pane(id) => ids.push(*id), + Node::Split { first, second, .. } => { + collect_ids_inner(first, ids); + collect_ids_inner(second, ids); + } + } +} + +// --- File I/O --- + +fn session_path() -> PathBuf { + if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") { + PathBuf::from(dir).join("herdr/session.json") + } else if let Ok(home) = std::env::var("HOME") { + PathBuf::from(home).join(".config/herdr/session.json") + } else { + PathBuf::from("/tmp/herdr/session.json") + } +} + +pub fn save(snapshot: &SessionSnapshot) { + let path = session_path(); + if let Some(parent) = path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + error!(err = %e, "failed to create session directory"); + return; + } + } + let json = match serde_json::to_string_pretty(snapshot) { + Ok(j) => j, + Err(e) => { + error!(err = %e, "failed to serialize session"); + return; + } + }; + // Atomic write: write to temp file, then rename + let tmp_path = path.with_extension("json.tmp"); + if let Err(e) = std::fs::write(&tmp_path, &json) { + error!(err = %e, "failed to write session temp file"); + return; + } + if let Err(e) = std::fs::rename(&tmp_path, &path) { + error!(err = %e, "failed to rename session file"); + // Clean up temp file + let _ = std::fs::remove_file(&tmp_path); + return; + } + info!(workspaces = snapshot.workspaces.len(), "session saved"); +} + +pub fn load() -> Option { + let path = session_path(); + if !path.exists() { + return None; + } + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => { + warn!(err = %e, "failed to read session file"); + return None; + } + }; + match serde_json::from_str::(&content) { + Ok(snap) => { + if snap.version > SNAPSHOT_VERSION { + warn!( + file_version = snap.version, + supported = SNAPSHOT_VERSION, + "session file is from a newer herdr version, ignoring" + ); + return None; + } + Some(snap) + } + Err(e) => { + warn!(err = %e, "failed to parse session file, ignoring"); + None + } + } +} + +// --- Tests --- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_empty_session() { + let snap = SessionSnapshot { + version: 1, + workspaces: vec![], + active: None, + selected: 0, + }; + let json = serde_json::to_string(&snap).unwrap(); + let restored: SessionSnapshot = serde_json::from_str(&json).unwrap(); + assert!(restored.workspaces.is_empty()); + assert_eq!(restored.active, None); + } + + #[test] + fn round_trip_layout_snapshot() { + let layout = LayoutSnapshot::Split { + direction: DirectionSnapshot::Horizontal, + ratio: 0.6, + first: Box::new(LayoutSnapshot::Pane(0)), + second: Box::new(LayoutSnapshot::Split { + direction: DirectionSnapshot::Vertical, + ratio: 0.5, + first: Box::new(LayoutSnapshot::Pane(1)), + second: Box::new(LayoutSnapshot::Pane(2)), + }), + }; + let json = serde_json::to_string(&layout).unwrap(); + let restored: LayoutSnapshot = serde_json::from_str(&json).unwrap(); + + // Verify structure + match restored { + LayoutSnapshot::Split { ratio, .. } => assert!((ratio - 0.6).abs() < 0.01), + _ => panic!("expected split"), + } + } + + #[test] + fn round_trip_full_workspace_snapshot() { + let mut panes = HashMap::new(); + panes.insert( + 0, + PaneSnapshot { + cwd: PathBuf::from("/home/can/Projects/herdr"), + }, + ); + panes.insert( + 1, + PaneSnapshot { + cwd: PathBuf::from("/home/can/Projects/website"), + }, + ); + + let snap = SessionSnapshot { + workspaces: vec![WorkspaceSnapshot { + name: "pi-mono".to_string(), + layout: LayoutSnapshot::Split { + direction: DirectionSnapshot::Horizontal, + ratio: 0.5, + first: Box::new(LayoutSnapshot::Pane(0)), + second: Box::new(LayoutSnapshot::Pane(1)), + }, + panes, + zoomed: false, + focused: Some(0), + }], + active: Some(0), + selected: 0, + version: 1, + }; + + let json = serde_json::to_string_pretty(&snap).unwrap(); + let restored: SessionSnapshot = serde_json::from_str(&json).unwrap(); + + assert_eq!(restored.workspaces.len(), 1); + assert_eq!(restored.workspaces[0].name, "pi-mono"); + assert_eq!(restored.workspaces[0].panes.len(), 2); + assert_eq!( + restored.workspaces[0].panes[&0].cwd, + PathBuf::from("/home/can/Projects/herdr") + ); + } + + #[test] + fn capture_and_restore_node_round_trip() { + // Create a tree: Split(H, 0.5, Pane(0), Split(V, 0.3, Pane(1), Pane(2))) + let node = Node::Split { + direction: Direction::Horizontal, + ratio: 0.5, + first: Box::new(Node::Pane(PaneId::from_raw(0))), + second: Box::new(Node::Split { + direction: Direction::Vertical, + ratio: 0.3, + first: Box::new(Node::Pane(PaneId::from_raw(1))), + second: Box::new(Node::Pane(PaneId::from_raw(2))), + }), + }; + + let snap = capture_node(&node); + let (restored, id_map) = restore_node_remapped(&snap); + + assert_eq!(id_map.len(), 3); + let ids = collect_pane_ids(&restored); + assert_eq!(ids.len(), 3); + let unique: std::collections::HashSet = ids.iter().map(|id| id.raw()).collect(); + assert_eq!(unique.len(), 3); + } + + #[test] + fn old_unversioned_snapshot_loads_as_version_0() { + // Simulate a snapshot from before versioning was added + let json = r#"{"workspaces":[],"active":null,"selected":0}"#; + let snap: SessionSnapshot = serde_json::from_str(json).unwrap(); + assert_eq!(snap.version, 0); // #[serde(default)] gives 0 + } + + #[test] + fn future_version_is_rejected() { + let json = r#"{"version":999,"workspaces":[],"active":null,"selected":0}"#; + let snap: SessionSnapshot = serde_json::from_str(json).unwrap(); + assert!(snap.version > SNAPSHOT_VERSION); + // load() would reject this — tested via the version check in load() + } + + #[test] + fn focused_pane_default_is_none() { + let json = r#"{"name":"test","layout":{"Pane":0},"panes":{},"zoomed":false}"#; + let ws: WorkspaceSnapshot = serde_json::from_str(json).unwrap(); + assert_eq!(ws.focused, None); // #[serde(default)] + } +} diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs new file mode 100644 index 00000000..735f7c11 --- /dev/null +++ b/src/platform/fallback.rs @@ -0,0 +1,11 @@ +use std::path::PathBuf; + +/// Unsupported platform stub. +pub fn foreground_process_name(_child_pid: u32) -> Option { + None +} + +/// Unsupported platform stub. +pub fn process_cwd(_pid: u32) -> Option { + None +} diff --git a/src/platform/linux.rs b/src/platform/linux.rs new file mode 100644 index 00000000..001ef03e --- /dev/null +++ b/src/platform/linux.rs @@ -0,0 +1,27 @@ +use std::path::PathBuf; + +/// Get the foreground process name for a given child PID. +/// Uses /proc//stat to find the tpgid, then /proc//comm. +pub fn foreground_process_name(child_pid: u32) -> Option { + // /proc//stat format: "pid (comm) state ppid pgrp session tty_nr tpgid ..." + // The (comm) field can contain spaces and parens, so we find the last ')' first. + let stat = std::fs::read_to_string(format!("/proc/{child_pid}/stat")).ok()?; + let rest = stat.get(stat.rfind(')')? + 2..)?; + let fields: Vec<&str> = rest.split_whitespace().collect(); + // After (comm): state(0) ppid(1) pgrp(2) session(3) tty_nr(4) tpgid(5) + let tpgid: i32 = fields.get(5)?.parse().ok()?; + if tpgid <= 0 { + return None; + } + let comm = std::fs::read_to_string(format!("/proc/{tpgid}/comm")).ok()?; + Some(comm.trim().to_string()) +} + +/// Get the current working directory of a process. +/// Uses /proc//cwd symlink. +pub fn process_cwd(pid: u32) -> Option { + if pid == 0 { + return None; + } + std::fs::read_link(format!("/proc/{pid}/cwd")).ok() +} diff --git a/src/platform/macos.rs b/src/platform/macos.rs new file mode 100644 index 00000000..218d00e9 --- /dev/null +++ b/src/platform/macos.rs @@ -0,0 +1,98 @@ +use std::ffi::OsStr; +use std::os::unix::ffi::OsStrExt; +use std::path::PathBuf; + +/// Get the foreground process name for a given child PID. +/// +/// Uses `proc_pidinfo(PROC_PIDTBSDINFO)` to read `e_tpgid` — the foreground +/// process group ID of the child's controlling terminal. Then `proc_name()` +/// to resolve that PID to a short command name. +/// +/// This is the macOS equivalent of Linux's `/proc/{pid}/stat` → tpgid → `/proc/{tpgid}/comm`. +pub fn foreground_process_name(child_pid: u32) -> Option { + if child_pid == 0 { + return None; + } + + // Get BSD info for the child process — contains e_tpgid + let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() }; + let size = std::mem::size_of::() as libc::c_int; + + let ret = unsafe { + libc::proc_pidinfo( + child_pid as libc::c_int, + libc::PROC_PIDTBSDINFO, + 0, + &mut info as *mut _ as *mut libc::c_void, + size, + ) + }; + + if ret != size { + return None; + } + + let fg_pid = info.e_tpgid; + if fg_pid == 0 { + return None; + } + + // Resolve the foreground PID to a process name + let mut buf = [0u8; libc::MAXCOMLEN + 1]; + let len = unsafe { + libc::proc_name( + fg_pid as libc::c_int, + buf.as_mut_ptr() as *mut libc::c_void, + buf.len() as u32, + ) + }; + + if len <= 0 { + return None; + } + + let len = len as usize; + let end = buf[..len].iter().position(|&b| b == 0).unwrap_or(len); + Some(String::from_utf8_lossy(&buf[..end]).into_owned()) +} + +/// Get the current working directory of a process. +/// +/// Uses `proc_pidinfo(PROC_PIDVNODEPATHINFO)` to read `pvi_cdir.vip_path`. +pub fn process_cwd(pid: u32) -> Option { + if pid == 0 { + return None; + } + + let mut pathinfo: libc::proc_vnodepathinfo = unsafe { std::mem::zeroed() }; + let size = std::mem::size_of::() as libc::c_int; + + let ret = unsafe { + libc::proc_pidinfo( + pid as libc::c_int, + libc::PROC_PIDVNODEPATHINFO, + 0, + &mut pathinfo as *mut _ as *mut libc::c_void, + size, + ) + }; + + if ret != size { + return None; + } + + // vip_path is [[c_char; 32]; 32] in libc (workaround for old Rust const generics). + // Reinterpret as flat bytes (total MAXPATHLEN = 1024). + let vip_path = unsafe { + std::slice::from_raw_parts( + pathinfo.pvi_cdir.vip_path.as_ptr() as *const u8, + libc::MAXPATHLEN as usize, + ) + }; + + let nul = vip_path.iter().position(|&b| b == 0)?; + if nul == 0 { + return None; + } + Some(PathBuf::from(OsStr::from_bytes(&vip_path[..nul]))) +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs new file mode 100644 index 00000000..df8869c7 --- /dev/null +++ b/src/platform/mod.rs @@ -0,0 +1,19 @@ +//! Platform-specific process and filesystem operations. +//! +//! Centralizes OS-dependent behavior behind a clean boundary so core +//! modules don't scatter `#[cfg]` branches through product logic. + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +pub use linux::*; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "macos")] +pub use macos::*; + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +mod fallback; +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +pub use fallback::*; diff --git a/src/pty_callbacks.rs b/src/pty_callbacks.rs new file mode 100644 index 00000000..6d5971b0 --- /dev/null +++ b/src/pty_callbacks.rs @@ -0,0 +1,374 @@ +//! Terminal query responses. +//! +//! Programs like fzf, htop, and vim send escape sequences asking "what terminal am I?" +//! and "where's the cursor?". A real terminal responds immediately. We need to do the same. +//! +//! There are ~15 query types that real programs use. We handle the common ones. +//! The vt100 parser handles all OUTPUT sequences (formatting, cursor, etc.) — those +//! don't need responses. Only QUERY sequences need us to reply. + +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, +}; + +/// Collects response bytes that need to be written back to the PTY. +/// Also tracks whether the child has requested the Kitty keyboard protocol. +#[derive(Clone, Default)] +pub struct PtyResponses { + pending: Arc>>, + /// Stack of kitty keyboard enhancement flags pushed by child programs. + /// Each push adds a flags value; pop removes the top. This correctly handles + /// nested programs (e.g. fish pushes mode 1, neovim pushes mode 3, neovim + /// pops → fish's mode 1 is restored). + kitty_stack: Arc>>, + /// Derived from kitty_stack: true when stack is non-empty. + /// Shared with PaneRuntime for fast-path key encoding decisions. + pub kitty_keyboard: Arc, +} + +impl PtyResponses { + pub fn new() -> Self { + Self::default() + } + + /// Take any pending response bytes (clears the buffer). + pub fn take(&self) -> Vec { + let mut pending = self.pending.lock().unwrap(); + std::mem::take(&mut *pending) + } + + fn push(&self, bytes: &[u8]) { + self.pending.lock().unwrap().extend_from_slice(bytes); + } +} + +impl vt100::Callbacks for PtyResponses { + fn unhandled_csi( + &mut self, + screen: &mut vt100::Screen, + i1: Option, + _i2: Option, + params: &[&[u16]], + c: char, + ) { + let param0 = params.first().and_then(|p| p.first()).copied().unwrap_or(0); + + match (i1, c) { + // === Device Attributes === + + // DA1: \e[c or \e[0c → "what terminal are you?" + // Respond as VT220 with ANSI color + (None, 'c') if param0 == 0 => { + self.push(b"\x1b[?62;22c"); + } + + // DA2: \e[>c or \e[>0c → "secondary device attributes" + (Some(b'>'), 'c') => { + // Type 0 (VT100), firmware version 0, ROM version 0 + self.push(b"\x1b[>0;0;0c"); + } + + // === Cursor / Status Reports === + + // DSR: \e[Nn where N selects the report type + (None, 'n') => match param0 { + // CPR: \e[6n → cursor position report + 5 => { + // Device status: "OK" + self.push(b"\x1b[0n"); + } + 6 => { + let (row, col) = screen.cursor_position(); + let response = format!("\x1b[{};{}R", row + 1, col + 1); + self.push(response.as_bytes()); + } + _ => {} + }, + + // DECXCPR: \e[?6n → extended cursor position report (with page) + (Some(b'?'), 'n') if param0 == 6 => { + let (row, col) = screen.cursor_position(); + let response = format!("\x1b[?{};{}R", row + 1, col + 1); + self.push(response.as_bytes()); + } + + // === Mode Queries (DECRQM) === + + // DECRQM: \e[?Np → "is DEC private mode N set?" + // Response: \e[?N;Ps$y where Ps = 1 (set), 2 (reset), 0 (unknown) + (Some(b'?'), 'p') => { + // Report all queried modes as "reset" (2) — safe default + let response = format!("\x1b[?{param0};2$y"); + self.push(response.as_bytes()); + } + + // ANSI DECRQM: \e[Np → "is ANSI mode N set?" + (None, 'p') => { + let response = format!("\x1b[{param0};2$y"); + self.push(response.as_bytes()); + } + + // === Keyboard Protocol === + + // Kitty keyboard query: \e[?u → "what keyboard flags are active?" + (Some(b'?'), 'u') => { + let stack = self.kitty_stack.lock().unwrap(); + let flags = stack.last().copied().unwrap_or(0); + self.push(format!("\x1b[?{flags}u").as_bytes()); + } + + // Kitty keyboard push: \e[>Nu → child wants Kitty key encoding + (Some(b'>'), 'u') => { + let mut stack = self.kitty_stack.lock().unwrap(); + stack.push(param0); + self.kitty_keyboard.store(true, Ordering::Relaxed); + } + + // Kitty keyboard pop: \e[ { + let mut stack = self.kitty_stack.lock().unwrap(); + let count = (param0 as usize).max(1); + for _ in 0..count { + if stack.pop().is_none() { + break; + } + } + self.kitty_keyboard.store(!stack.is_empty(), Ordering::Relaxed); + } + + // === Terminal Identification === + + // XTVERSION: \e[>q → "what terminal version?" + (Some(b'>'), 'q') => { + // Respond in DCS format: \eP>|herdr 0.1\e\\ + self.push(b"\x1bP>|herdr 0.1\x1b\\"); + } + + _ => {} + } + } + + fn unhandled_osc( + &mut self, + _screen: &mut vt100::Screen, + params: &[&[u8]], + ) { + let Some(cmd) = params.first() else { return }; + + match *cmd { + // OSC 10 ; ? ST → query foreground color + b"10" => { + if params.get(1) == Some(&&b"?"[..]) { + // Respond with a default light foreground + self.push(b"\x1b]10;rgb:cccc/cccc/cccc\x1b\\"); + } + } + // OSC 11 ; ? ST → query background color + b"11" => { + if params.get(1) == Some(&&b"?"[..]) { + // Respond with a default dark background + self.push(b"\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\"); + } + } + // OSC 12 ; ? ST → query cursor color + b"12" => { + if params.get(1) == Some(&&b"?"[..]) { + self.push(b"\x1b]12;rgb:cccc/cccc/cccc\x1b\\"); + } + } + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_parser(responses: PtyResponses) -> vt100::Parser { + vt100::Parser::new_with_callbacks(24, 80, 0, responses) + } + + #[test] + fn responds_to_da1() { + let r = PtyResponses::new(); + let mut p = make_parser(r.clone()); + p.process(b"\x1b[c"); + assert_eq!(r.take(), b"\x1b[?62;22c"); + } + + #[test] + fn responds_to_da1_explicit_zero() { + let r = PtyResponses::new(); + let mut p = make_parser(r.clone()); + p.process(b"\x1b[0c"); + assert_eq!(r.take(), b"\x1b[?62;22c"); + } + + #[test] + fn responds_to_da2() { + let r = PtyResponses::new(); + let mut p = make_parser(r.clone()); + p.process(b"\x1b[>c"); + assert_eq!(r.take(), b"\x1b[>0;0;0c"); + } + + #[test] + fn responds_to_cpr() { + let r = PtyResponses::new(); + let mut p = make_parser(r.clone()); + p.process(b"\x1b[5;10H"); // move cursor to row 5, col 10 + p.process(b"\x1b[6n"); + assert_eq!(r.take(), b"\x1b[5;10R"); + } + + #[test] + fn responds_to_dsr_status() { + let r = PtyResponses::new(); + let mut p = make_parser(r.clone()); + p.process(b"\x1b[5n"); // device status report + assert_eq!(r.take(), b"\x1b[0n"); // "OK" + } + + #[test] + fn responds_to_extended_cpr() { + let r = PtyResponses::new(); + let mut p = make_parser(r.clone()); + p.process(b"\x1b[3;7H"); // move cursor + p.process(b"\x1b[?6n"); // extended CPR + assert_eq!(r.take(), b"\x1b[?3;7R"); + } + + #[test] + fn responds_to_decrqm_private() { + let r = PtyResponses::new(); + let mut p = make_parser(r.clone()); + p.process(b"\x1b[?25p"); // query: is cursor visible (mode 25)? + assert_eq!(r.take(), b"\x1b[?25;2$y"); // "reset" (2) + } + + #[test] + fn responds_to_kitty_keyboard_query() { + let r = PtyResponses::new(); + let mut p = make_parser(r.clone()); + p.process(b"\x1b[?u"); + assert_eq!(r.take(), b"\x1b[?0u"); + } + + #[test] + fn kitty_push_pop_stack() { + let r = PtyResponses::new(); + let mut p = make_parser(r.clone()); + + // Initially off + assert!(!r.kitty_keyboard.load(Ordering::Relaxed)); + + // Fish pushes mode 1 + p.process(b"\x1b[>1u"); + assert!(r.kitty_keyboard.load(Ordering::Relaxed)); + p.process(b"\x1b[?u"); + assert_eq!(r.take(), b"\x1b[?1u"); + + // Neovim pushes mode 3 + p.process(b"\x1b[>3u"); + assert!(r.kitty_keyboard.load(Ordering::Relaxed)); + p.process(b"\x1b[?u"); + assert_eq!(r.take(), b"\x1b[?3u"); + + // Neovim pops → fish's mode 1 is restored + p.process(b"\x1b[1u"); + p.process(b"\x1b[>3u"); + p.process(b"\x1b[>5u"); + assert!(r.kitty_keyboard.load(Ordering::Relaxed)); + + // Pop 2 at once + p.process(b"\x1b[<2u"); + assert!(r.kitty_keyboard.load(Ordering::Relaxed)); + p.process(b"\x1b[?u"); + assert_eq!(r.take(), b"\x1b[?1u"); // only first push remains + + // Pop last one + p.process(b"\x1b[q"); + assert_eq!(r.take(), b"\x1bP>|herdr 0.1\x1b\\"); + } + + #[test] + fn responds_to_osc_fg_color_query() { + let r = PtyResponses::new(); + let mut p = make_parser(r.clone()); + p.process(b"\x1b]10;?\x1b\\"); + assert_eq!(r.take(), b"\x1b]10;rgb:cccc/cccc/cccc\x1b\\"); + } + + #[test] + fn responds_to_osc_bg_color_query() { + let r = PtyResponses::new(); + let mut p = make_parser(r.clone()); + p.process(b"\x1b]11;?\x1b\\"); + assert_eq!(r.take(), b"\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\"); + } + + #[test] + fn responds_to_osc_cursor_color_query() { + let r = PtyResponses::new(); + let mut p = make_parser(r.clone()); + p.process(b"\x1b]12;?\x1b\\"); + assert_eq!(r.take(), b"\x1b]12;rgb:cccc/cccc/cccc\x1b\\"); + } + + #[test] + fn no_response_for_regular_output() { + let r = PtyResponses::new(); + let mut p = make_parser(r.clone()); + p.process(b"hello world\x1b[31mred\x1b[0m"); + assert!(r.take().is_empty()); + } + + #[test] + fn multiple_queries_accumulate() { + let r = PtyResponses::new(); + let mut p = make_parser(r.clone()); + p.process(b"\x1b[c\x1b[6n"); + let bytes = r.take(); + assert!(bytes.starts_with(b"\x1b[?62;22c")); + assert!(bytes.ends_with(b"\x1b[1;1R")); + } +} diff --git a/src/selection.rs b/src/selection.rs new file mode 100644 index 00000000..7b020575 --- /dev/null +++ b/src/selection.rs @@ -0,0 +1,253 @@ +//! Text selection and clipboard support. +//! +//! Selection lifecycle: +//! +//! MouseDown in pane → Anchor recorded (no visual yet) +//! MouseDrag → Selection becomes active, cells highlighted +//! MouseUp → Text extracted, copied via OSC 52, highlight stays +//! Next click / key → Selection cleared +//! +//! Coordinates are stored relative to the pane's inner area (the region +//! where terminal content is rendered, excluding borders). + +use ratatui::layout::Rect; +use std::io::Write; + +use crate::layout::PaneId; + +/// Current phase of a selection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Phase { + /// Mouse is down but hasn't moved yet. If released without + /// moving, this was just a click — no selection created. + Anchored, + /// Mouse has moved from the anchor point. Cells are being highlighted. + Dragging, + /// Mouse released after dragging. Selection is visible and text + /// has been copied to clipboard. Cleared on next interaction. + Done, +} + +/// A text selection within a terminal pane. +#[derive(Debug, Clone)] +pub struct Selection { + /// Which pane the selection belongs to. + pub pane_id: PaneId, + /// Anchor position in pane-relative coordinates (row, col). + anchor: (u16, u16), + /// Current/final position in pane-relative coordinates (row, col). + cursor: (u16, u16), + /// Selection phase. + phase: Phase, + /// The inner rect of the pane (for clamping during drag). + /// This is the content area, excluding borders. + pane_inner: Rect, +} + +impl Selection { + /// Start a potential selection. This records the anchor but doesn't + /// make anything visible yet — the user might just be clicking. + pub fn anchor(pane_id: PaneId, row: u16, col: u16, pane_inner: Rect) -> Self { + Self { + pane_id, + anchor: (row, col), + cursor: (row, col), + phase: Phase::Anchored, + pane_inner, + } + } + + /// Extend the selection as the mouse drags. Activates highlighting + /// once the cursor moves to a different cell than the anchor. + /// Screen coordinates are clamped to the pane boundary. + pub fn drag(&mut self, screen_col: u16, screen_row: u16) { + let (row, col) = self.clamp_to_pane(screen_col, screen_row); + self.cursor = (row, col); + if self.cursor != self.anchor { + self.phase = Phase::Dragging; + } + } + + /// Finalize the selection. Returns the selected range if the user + /// actually dragged (not just clicked). Returns None for plain clicks. + pub fn finish(&mut self) -> bool { + if self.phase == Phase::Dragging { + self.phase = Phase::Done; + true + } else { + false + } + } + + /// Whether this selection should be rendered (highlight visible). + pub fn is_visible(&self) -> bool { + self.phase == Phase::Dragging || self.phase == Phase::Done + } + + /// Whether the user just clicked without dragging (not a selection). + pub fn was_just_click(&self) -> bool { + self.phase == Phase::Anchored + } + + /// Returns (start, end) in reading order (top-left to bottom-right). + fn ordered(&self) -> ((u16, u16), (u16, u16)) { + let (ar, ac) = self.anchor; + let (cr, cc) = self.cursor; + if ar < cr || (ar == cr && ac <= cc) { + ((ar, ac), (cr, cc)) + } else { + ((cr, cc), (ar, ac)) + } + } + + /// Check whether a pane-relative cell (row, col) is inside the selection. + pub fn contains(&self, row: u16, col: u16) -> bool { + if !self.is_visible() { + return false; + } + let ((sr, sc), (er, ec)) = self.ordered(); + if row < sr || row > er { + return false; + } + if sr == er { + // Single-line: from sc to ec (inclusive) + col >= sc && col <= ec + } else if row == sr { + // First line: from sc to end of line + col >= sc + } else if row == er { + // Last line: from start to ec + col <= ec + } else { + // Middle rows: fully selected + true + } + } + + /// Clamp screen coordinates to the pane's inner area and convert + /// to pane-relative coordinates. + fn clamp_to_pane(&self, screen_col: u16, screen_row: u16) -> (u16, u16) { + let r = &self.pane_inner; + let clamped_col = screen_col.clamp(r.x, r.x + r.width.saturating_sub(1)); + let clamped_row = screen_row.clamp(r.y, r.y + r.height.saturating_sub(1)); + (clamped_row - r.y, clamped_col - r.x) + } +} + +/// Extract selected text from a vt100 screen. +pub fn extract_text(screen: &vt100::Screen, selection: &Selection) -> String { + let ((sr, sc), (er, ec)) = selection.ordered(); + // contents_between end_col is exclusive in vt100 + screen.contents_between(sr, sc, er, ec + 1) +} + +/// Write text to the system clipboard via OSC 52. +/// +/// OSC 52 format: `ESC ] 52 ; c ; BEL` +/// +/// The terminal emulator (Ghostty, kitty, etc.) intercepts this +/// and sets the system clipboard. Ghostty has `clipboard-write = allow` +/// by default. +pub fn write_osc52(text: &str) { + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(text); + // Use ST (ESC \) terminator — more widely supported than BEL in some terminals + let sequence = format!("\x1b]52;c;{encoded}\x1b\\"); + let _ = std::io::stdout().write_all(sequence.as_bytes()); + let _ = std::io::stdout().flush(); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_sel(sr: u16, sc: u16, er: u16, ec: u16) -> Selection { + let mut sel = Selection::anchor(PaneId::from_raw(0), sr, sc, Rect::new(0, 0, 80, 24)); + sel.cursor = (er, ec); + sel.phase = Phase::Dragging; + sel + } + + #[test] + fn ordering_forward() { + let sel = make_sel(2, 5, 4, 10); + assert_eq!(sel.ordered(), ((2, 5), (4, 10))); + } + + #[test] + fn ordering_backward() { + let sel = make_sel(4, 10, 2, 5); + assert_eq!(sel.ordered(), ((2, 5), (4, 10))); + } + + #[test] + fn single_line_contains() { + let sel = make_sel(2, 5, 2, 15); + assert!(!sel.contains(2, 4)); + assert!(sel.contains(2, 5)); + assert!(sel.contains(2, 10)); + assert!(sel.contains(2, 15)); // inclusive + assert!(!sel.contains(2, 16)); + assert!(!sel.contains(1, 10)); + assert!(!sel.contains(3, 10)); + } + + #[test] + fn multi_line_contains() { + let sel = make_sel(2, 5, 4, 10); + // Row 2: from col 5 to end + assert!(!sel.contains(2, 4)); + assert!(sel.contains(2, 5)); + assert!(sel.contains(2, 79)); + + // Row 3: fully selected + assert!(sel.contains(3, 0)); + assert!(sel.contains(3, 79)); + + // Row 4: from start to col 10 + assert!(sel.contains(4, 0)); + assert!(sel.contains(4, 10)); + assert!(!sel.contains(4, 11)); + } + + #[test] + fn anchored_not_visible() { + let sel = Selection::anchor(PaneId::from_raw(0), 5, 10, Rect::new(0, 0, 80, 24)); + assert!(!sel.is_visible()); + assert!(!sel.contains(5, 10)); + } + + #[test] + fn click_without_drag() { + let mut sel = Selection::anchor(PaneId::from_raw(0), 5, 10, Rect::new(0, 0, 80, 24)); + assert!(sel.was_just_click()); + let copied = sel.finish(); + assert!(!copied); + } + + #[test] + fn drag_then_finish() { + let mut sel = Selection::anchor(PaneId::from_raw(0), 5, 10, Rect::new(10, 5, 80, 24)); + sel.drag(20, 7); // screen coords → clamped to pane + assert!(sel.is_visible()); + assert!(!sel.was_just_click()); + let copied = sel.finish(); + assert!(copied); + } + + #[test] + fn clamp_to_pane_bounds() { + let sel = Selection::anchor(PaneId::from_raw(0), 0, 0, Rect::new(10, 5, 80, 24)); + // Drag way outside pane bounds + let (row, col) = sel.clamp_to_pane(200, 100); + assert_eq!(row, 23); // clamped to height - 1 + assert_eq!(col, 79); // clamped to width - 1 + + // Drag left of pane + let (row, col) = sel.clamp_to_pane(0, 0); + assert_eq!(row, 0); + assert_eq!(col, 0); + } + + +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 00000000..242853ea --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,870 @@ +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}, + Frame, +}; +use tui_term::widget::PseudoTerminal; + +use crate::app::{AppState, Mode}; +use crate::detect::AgentState; +use crate::layout::PaneInfo; + +const COLLAPSED_WIDTH: u16 = 4; // num + space + dot + separator +const MIN_SIDEBAR_WIDTH: u16 = 16; +const MAX_SIDEBAR_WIDTH: u16 = 36; + +/// Compute view geometry and reconcile pane sizes. +/// Called before render to separate mutation from drawing. +pub fn compute_view(app: &mut AppState, area: Rect) { + let sidebar_w = if app.sidebar_collapsed { + COLLAPSED_WIDTH + } else { + compute_sidebar_width(app) + }; + + let [sidebar_area, main_area] = + Layout::horizontal([Constraint::Length(sidebar_w), Constraint::Min(1)]).areas(area); + + let terminal_area = main_area; + + // Compute split borders + let split_borders = app + .active + .and_then(|i| app.workspaces.get(i)) + .map(|ws| ws.layout.splits(terminal_area)) + .unwrap_or_default(); + + // Compute pane layout + reconcile sizes + let pane_infos = compute_pane_infos(app, terminal_area); + + app.view = crate::app::ViewState { + sidebar_rect: sidebar_area, + terminal_area, + pane_infos, + split_borders, + }; +} + +/// Render the UI — reads AppState but does not mutate it. +pub fn render(app: &AppState, frame: &mut Frame) { + let sidebar_area = app.view.sidebar_rect; + let terminal_area = app.view.terminal_area; + + if app.sidebar_collapsed { + render_sidebar_collapsed(app, frame, sidebar_area); + } else { + render_sidebar(app, frame, sidebar_area); + } + render_panes(app, frame, terminal_area); + + match app.mode { + Mode::Navigate => render_navigate_overlay(app, frame, terminal_area), + Mode::Resize => render_resize_overlay(app, frame, terminal_area), + Mode::ConfirmClose => render_confirm_close_overlay(app, frame, terminal_area), + Mode::ContextMenu => { + render_navigate_overlay(app, frame, terminal_area); + render_context_menu(app, frame); + } + Mode::CreateSession | Mode::RenameSession => {} + Mode::Terminal => {} + } + + // Update notification (rendered on top of everything) + if let Some(version) = &app.update_available { + if !app.update_dismissed { + render_update_notification(frame, terminal_area, version, app.accent); + } + } +} + +/// Compute pane layout info and resize pane runtimes to match. +fn compute_pane_infos(app: &AppState, area: Rect) -> Vec { + let Some(ws_idx) = app.active else { + return Vec::new(); + }; + let Some(ws) = app.workspaces.get(ws_idx) else { + return Vec::new(); + }; + + if ws.zoomed { + let focused_id = ws.layout.focused(); + if let Some(rt) = ws.runtimes.get(&focused_id) { + rt.resize(area.height, area.width); + } + return vec![PaneInfo { + id: focused_id, + rect: area, + inner_rect: area, + is_focused: true, + }]; + } + + let multi_pane = ws.layout.pane_count() > 1; + let terminal_active = app.mode == Mode::Terminal; + let mut pane_infos = ws.layout.panes(area); + + for info in &mut pane_infos { + let inner = if multi_pane { + let border_set = if info.is_focused && terminal_active { + ratatui::symbols::border::THICK + } else { + ratatui::symbols::border::PLAIN + }; + let block = Block::default() + .borders(Borders::ALL) + .border_set(border_set); + block.inner(info.rect) + } else { + area + }; + info.inner_rect = inner; + + if let Some(rt) = ws.runtimes.get(&info.id) { + rt.resize(inner.height, inner.width); + } + } + + pane_infos +} + +/// Auto-scale sidebar width based on longest workspace name. +fn compute_sidebar_width(app: &AppState) -> u16 { + if app.workspaces.is_empty() { + return app.sidebar_width; // config default for empty state + } + let max_name = app + .workspaces + .iter() + .map(|ws| { + let pane_count = ws.layout.pane_count(); + let bracket = if pane_count > 1 { 3 + pane_count } else { 0 }; // " [●●]" + ws.name.len() + bracket + }) + .max() + .unwrap_or(8); + // marker(2) + dot(2) + name + bracket + padding(2) + borders(2) + let needed = (max_name as u16) + 8; + needed.clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH) +} + +/// Collapsed sidebar: borderless strip of state dots with selection highlight. +fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) { + let is_navigating = matches!( + app.mode, + Mode::Navigate | Mode::CreateSession | Mode::RenameSession | Mode::Resize | Mode::ConfirmClose | Mode::ContextMenu + ); + + // Thin vertical separator line on the right edge + let sep_style = if is_navigating { + Style::default().fg(app.accent) + } else { + Style::default().fg(Color::DarkGray) + }; + let sep_x = area.x + area.width.saturating_sub(1); + let buf = frame.buffer_mut(); + for y in area.y..area.y + area.height { + buf[(sep_x, y)].set_symbol("│"); + buf[(sep_x, y)].set_style(sep_style); + } + + let content_w = area.width.saturating_sub(1); // leave room for separator + + for (i, ws) in app.workspaces.iter().enumerate() { + if i as u16 >= area.height { + break; + } + let (agg_state, agg_seen) = ws.aggregate_state(); + let (icon, icon_style) = state_icon_style(agg_state, agg_seen); + let is_selected = i == app.selected && is_navigating; + + let num_label = format!("{}", i + 1); + + let row_style = if is_selected { + Style::default().bg(app.accent).fg(Color::Black) + } else { + Style::default() + }; + let dim_style = if is_selected { + row_style + } else { + Style::default().fg(Color::DarkGray) + }; + + let y = area.y + i as u16; + let row_area = Rect::new(area.x, y, content_w, 1); + + // Fill background for selected row + if is_selected { + let buf = frame.buffer_mut(); + for x in row_area.x..row_area.x + row_area.width { + buf[(x, y)].set_style(row_style); + } + } + + let line = Line::from(vec![ + Span::styled(&num_label, dim_style), + Span::styled(" ", if is_selected { row_style } else { Style::default() }), + Span::styled( + icon, + if is_selected { + // Keep icon's fg color, just add the highlight bg + icon_style.bg(app.accent) + } else { + icon_style + }, + ), + ]); + frame.render_widget(Paragraph::new(line), row_area); + } + + // Toggle button at bottom + render_sidebar_toggle(frame, area, true); +} + +fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) { + let is_navigating = matches!( + app.mode, + Mode::Navigate | Mode::CreateSession | Mode::RenameSession | Mode::Resize | Mode::ConfirmClose | Mode::ContextMenu + ); + + let highlight_style = if is_navigating { + Style::default() + .fg(Color::Black) + .bg(app.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Black).bg(Color::DarkGray) + }; + + // Thin separator on right edge (consistent with collapsed) + let sep_style = if is_navigating { + Style::default().fg(app.accent) + } else { + Style::default().fg(Color::DarkGray) + }; + let sep_x = area.x + area.width.saturating_sub(1); + let buf = frame.buffer_mut(); + for y in area.y..area.y + area.height { + buf[(sep_x, y)].set_symbol("│"); + buf[(sep_x, y)].set_style(sep_style); + } + + // Content area (left of separator) + let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height); + + // Title line + let title = match app.mode { + Mode::Terminal => format!(" {} ⏎", app.prefix_label), + Mode::Navigate | Mode::ContextMenu => " NAVIGATE".to_string(), + Mode::Resize => " RESIZE".to_string(), + Mode::CreateSession => " NEW".to_string(), + Mode::RenameSession => " RENAME".to_string(), + Mode::ConfirmClose => " CLOSE?".to_string(), + }; + let title_style = if is_navigating { + Style::default() + .fg(app.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + frame.render_widget( + Paragraph::new(Span::styled(&title, title_style)), + Rect::new(content.x, content.y, content.width, 1), + ); + + // Workspace list (below title) + let list_area = Rect::new( + content.x, + content.y + 1, + content.width, + content.height.saturating_sub(1), + ); + + let items: Vec = app + .workspaces + .iter() + .enumerate() + .map(|(i, ws)| { + let selected = i == app.selected && is_navigating; + // When highlighted: text goes black on highlight bg, but icons keep their color + let text_style = if selected { + highlight_style + } else { + Style::default() + }; + let dim_style = if selected { + highlight_style + } else { + Style::default().fg(Color::DarkGray) + }; + + let marker = if Some(i) == app.active { "▸" } else { " " }; + let (agg_state, agg_seen) = ws.aggregate_state(); + let (rollup_icon, rollup_style) = state_icon_style(agg_state, agg_seen); + // Icon keeps its fg color, but picks up the highlight bg when selected + let icon_style = if selected { + rollup_style.bg(highlight_style.bg.unwrap_or(app.accent)) + } else { + rollup_style + }; + + let num_label = format!("{}", i + 1); + let num_style = if selected { + highlight_style + } else { + Style::default().fg(Color::DarkGray) + }; + + let mut spans = vec![ + Span::styled(num_label, num_style), + Span::styled(format!("{marker}"), text_style), + Span::styled(rollup_icon, icon_style), + Span::styled(format!(" {}", ws.name), text_style), + ]; + + let pane_states = ws.pane_states(); + let has_any_agent = pane_states.iter().any(|(s, _)| *s != AgentState::Unknown); + + if ws.layout.pane_count() > 1 { + if has_any_agent { + spans.push(Span::styled(" [", dim_style)); + for (pane_state, pane_seen) in &pane_states { + let (icon, style) = state_icon_style(*pane_state, *pane_seen); + let pane_icon_style = if selected { + style.bg(highlight_style.bg.unwrap_or(app.accent)) + } else { + style + }; + spans.push(Span::styled(icon, pane_icon_style)); + } + spans.push(Span::styled("]", dim_style)); + } else { + let count = ws.layout.pane_count(); + spans.push(Span::styled(format!(" [{count}]"), dim_style)); + } + } + + ListItem::new(Line::from(spans)) + }) + .collect(); + + // No highlight_style on List — we handle it per-span above to preserve icon colors + let list = List::new(items); + let mut state = ListState::default().with_selected(Some(app.selected)); + frame.render_stateful_widget(list, list_area, &mut state); + + if app.mode == Mode::CreateSession { + let input_y = list_area.y + list_area.height.saturating_sub(1); + let input_area = Rect::new(content.x, input_y, content.width, 1); + let text = format!(" Name: {}\u{2588}", app.name_input); + frame.render_widget( + Paragraph::new(text).style(Style::default().fg(Color::Yellow)), + input_area, + ); + } + + if app.mode == Mode::RenameSession { + let input_y = list_area.y + app.selected as u16; + let input_area = Rect::new(content.x, input_y, content.width, 1); + let text = format!(" ✎ {}\u{2588}", app.name_input); + frame.render_widget(Clear, input_area); + frame.render_widget( + Paragraph::new(text).style(Style::default().fg(Color::Yellow)), + input_area, + ); + } + + // Toggle button at bottom (skip when input field occupies that row) + if app.mode != Mode::CreateSession { + render_sidebar_toggle(frame, area, false); + } +} + +fn render_sidebar_toggle(frame: &mut Frame, area: Rect, collapsed: bool) { + let bottom_y = area.y + area.height.saturating_sub(1); + let content_w = area.width.saturating_sub(1); // exclude separator + if content_w == 0 || area.height == 0 { + return; + } + let icon = if collapsed { "»" } else { "«" }; + // Center the icon in the content area + let x = area.x + content_w / 2; + let toggle_area = Rect::new(x, bottom_y, 1, 1); + frame.render_widget( + Paragraph::new(Span::styled( + icon, + Style::default().fg(Color::DarkGray), + )), + toggle_area, + ); +} + +fn render_panes(app: &AppState, frame: &mut Frame, area: Rect) { + let Some(ws_idx) = app.active else { + render_empty(frame, area, app.accent); + return; + }; + let Some(ws) = app.workspaces.get(ws_idx) else { + render_empty(frame, area, app.accent); + return; + }; + + let multi_pane = ws.layout.pane_count() > 1; + let terminal_active = app.mode == Mode::Terminal; + + for info in &app.view.pane_infos { + if let Some(rt) = ws.runtimes.get(&info.id) { + // Draw borders for multi-pane layouts + if multi_pane { + let (border_style, border_set) = if info.is_focused && terminal_active { + ( + Style::default().fg(app.accent), + ratatui::symbols::border::THICK, + ) + } else if info.is_focused { + ( + Style::default().fg(app.accent), + ratatui::symbols::border::PLAIN, + ) + } else { + ( + Style::default().fg(Color::DarkGray), + ratatui::symbols::border::PLAIN, + ) + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .border_set(border_set); + frame.render_widget(block, info.rect); + } + + // Draw terminal content + if let Ok(parser) = rt.parser.read() { + let pt = PseudoTerminal::new(parser.screen()); + frame.render_widget(pt, info.inner_rect); + } + + // Dim unfocused panes only in navigate mode + let should_dim = !info.is_focused && multi_pane && !terminal_active; + if should_dim { + let inner = info.inner_rect; + let buf = frame.buffer_mut(); + for y in inner.y..inner.y + inner.height { + for x in inner.x..inner.x + inner.width { + let cell = &mut buf[(x, y)]; + let style = cell.style(); + let fg = style.fg.unwrap_or(Color::White); + let dimmed_fg = dim_color(fg); + cell.set_style(style.fg(dimmed_fg)); + } + } + } + + // Selection highlight + render_selection_highlight(&app.selection, frame, info.id, info.inner_rect); + } + } +} + +/// Render selection highlight for a pane by inverting fg/bg colors. +/// Reduce a color's brightness by blending it toward black. +fn dim_color(color: Color) -> Color { + match color { + Color::Rgb(r, g, b) => Color::Rgb(r / 3, g / 3, b / 3), + Color::White => Color::DarkGray, + Color::Gray => Color::DarkGray, + Color::DarkGray => Color::Rgb(30, 30, 30), + Color::Red => Color::Rgb(60, 0, 0), + Color::Green => Color::Rgb(0, 60, 0), + Color::Yellow => Color::Rgb(60, 60, 0), + Color::Blue => Color::Rgb(0, 0, 60), + Color::Magenta => Color::Rgb(60, 0, 60), + Color::Cyan => Color::Rgb(0, 60, 60), + Color::LightRed => Color::Rgb(80, 30, 30), + Color::LightGreen => Color::Rgb(30, 80, 30), + Color::LightYellow => Color::Rgb(80, 80, 30), + Color::LightBlue => Color::Rgb(30, 30, 80), + Color::LightMagenta => Color::Rgb(80, 30, 80), + Color::LightCyan => Color::Rgb(30, 80, 80), + // Indexed colors and others: just use DIM modifier as fallback + _ => Color::DarkGray, + } +} + +fn render_selection_highlight( + selection: &Option, + frame: &mut Frame, + pane_id: crate::layout::PaneId, + inner: Rect, +) { + if let Some(sel) = selection { + if sel.is_visible() && sel.pane_id == pane_id { + let buf = frame.buffer_mut(); + for y in 0..inner.height { + for x in 0..inner.width { + if sel.contains(y, x) { + let cell = &mut buf[(inner.x + x, inner.y + y)]; + // Fixed highlight: white text on blue background. + // Consistent regardless of the cell's original colors. + cell.set_style( + Style::default() + .fg(Color::White) + .bg(Color::Rgb(40, 80, 140)), + ); + } + } + } + } + } +} + +fn render_empty(frame: &mut Frame, area: Rect, accent: Color) { + let lines = vec![ + Line::from(""), + Line::from(""), + Line::from(Span::styled( + " No active workspace", + Style::default().fg(Color::DarkGray), + )), + Line::from(""), + Line::from(vec![ + Span::styled(" Press ", Style::default().fg(Color::DarkGray)), + Span::styled( + "n", + Style::default() + .fg(accent) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" to create one", Style::default().fg(Color::DarkGray)), + ]), + ]; + frame.render_widget( + Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)), + ), + area, + ); +} + +/// Floating overlay for navigate mode — appears at bottom of terminal area. +fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) { + let key = Style::default() + .fg(app.accent) + .add_modifier(Modifier::BOLD); + let dim = Style::default().fg(Color::DarkGray); + let label = Style::default().fg(Color::White); + + let is_full = app + .active + .and_then(|i| app.workspaces.get(i)) + .is_some_and(|ws| ws.zoomed); + + let full_key = if is_full { "unfull" } else { "full" }; + + let line1 = Line::from(vec![ + Span::styled(" n", key), + Span::styled(" new ", dim), + Span::styled("N", key), + Span::styled(" rename ", dim), + Span::styled("d", key), + Span::styled(" close ", dim), + Span::styled("v", key), + Span::styled(" split│ ", dim), + Span::styled("-", key), + Span::styled(" split─ ", dim), + Span::styled("⇥", key), + Span::styled(" pane ", dim), + Span::styled("f", key), + Span::styled(format!(" {full_key} "), dim), + Span::styled("r", key), + Span::styled(" resize ", dim), + Span::styled("b", key), + Span::styled(" sidebar ", dim), + Span::styled("q", key), + Span::styled(" quit", dim), + ]); + + let ws_name = app + .active + .and_then(|i| app.workspaces.get(i)) + .map(|ws| ws.name.as_str()) + .unwrap_or("—"); + + let pane_info = app + .active + .and_then(|i| app.workspaces.get(i)) + .filter(|ws| ws.layout.pane_count() > 1) + .map(|ws| { + let ids = ws.layout.pane_ids(); + let pos = ids + .iter() + .position(|id| *id == ws.layout.focused()) + .unwrap_or(0); + format!(" [{}/{}]", pos + 1, ids.len()) + }) + .unwrap_or_default(); + + let mode_style = Style::default() + .fg(Color::Black) + .bg(app.accent) + .add_modifier(Modifier::BOLD); + + let line2 = Line::from(vec![ + Span::styled(" NAVIGATE ", mode_style), + Span::raw(" "), + Span::styled(ws_name, label), + Span::styled(&pane_info, dim), + Span::raw(" "), + Span::styled("esc", key), + Span::styled(" back ", dim), + Span::styled("↑↓", key), + Span::styled(" select ", dim), + Span::styled("⏎", key), + Span::styled(" open", dim), + ]); + + let overlay_height = 2; + let overlay_y = area.y + area.height.saturating_sub(overlay_height); + let overlay_area = Rect::new(area.x, overlay_y, area.width, overlay_height); + + // Clear the area behind the overlay + frame.render_widget(Clear, overlay_area); + + let bg = Style::default().bg(Color::Black); + let buf = frame.buffer_mut(); + for y in overlay_area.y..overlay_area.y + overlay_area.height { + for x in overlay_area.x..overlay_area.x + overlay_area.width { + buf[(x, y)].set_style(bg); + } + } + + let [row1, row2] = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(overlay_area); + frame.render_widget(Paragraph::new(line1), row1); + frame.render_widget(Paragraph::new(line2), row2); +} + +/// Floating overlay for resize mode. +fn render_resize_overlay(app: &AppState, frame: &mut Frame, area: Rect) { + let key = Style::default() + .fg(app.accent) + .add_modifier(Modifier::BOLD); + let dim = Style::default().fg(Color::DarkGray); + + let mode_style = Style::default() + .fg(Color::Black) + .bg(Color::Magenta) + .add_modifier(Modifier::BOLD); + + let line = Line::from(vec![ + Span::styled(" RESIZE ", mode_style), + Span::raw(" "), + Span::styled("h/l", key), + Span::styled(" width ", dim), + Span::styled("j/k", key), + Span::styled(" height ", dim), + Span::styled("esc", key), + Span::styled(" done", dim), + ]); + + let overlay_y = area.y + area.height.saturating_sub(1); + let overlay_area = Rect::new(area.x, overlay_y, area.width, 1); + + frame.render_widget(Clear, overlay_area); + let bg = Style::default().bg(Color::Black); + let buf = frame.buffer_mut(); + for x in overlay_area.x..overlay_area.x + overlay_area.width { + buf[(x, overlay_y)].set_style(bg); + } + frame.render_widget(Paragraph::new(line), overlay_area); +} + +/// Centered popup confirmation dialog with dimmed background. +fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, area: Rect) { + let ws_name = app + .workspaces + .get(app.selected) + .map(|ws| ws.name.as_str()) + .unwrap_or("?"); + let pane_count = app + .workspaces + .get(app.selected) + .map(|ws| ws.layout.pane_count()) + .unwrap_or(0); + + let pane_text = if pane_count == 1 { + "1 pane".to_string() + } else { + format!("{pane_count} panes") + }; + + // Dim the entire background + let buf = frame.buffer_mut(); + for y in area.y..area.y + area.height { + for x in area.x..area.x + area.width { + let cell = &mut buf[(x, y)]; + cell.set_style(cell.style().add_modifier(Modifier::DIM)); + } + } + + // Centered popup + let popup_w = 44u16.min(area.width.saturating_sub(4)); + let popup_h = 5u16; + let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2; + let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2; + let popup = Rect::new(popup_x, popup_y, popup_w, popup_h); + + let key = Style::default() + .fg(app.accent) + .add_modifier(Modifier::BOLD); + let warn = Style::default() + .fg(Color::Red) + .add_modifier(Modifier::BOLD); + let dim = Style::default().fg(Color::DarkGray); + + let title_line = Line::from(vec![ + Span::styled(" Close workspace?", warn), + ]); + + let detail_line = Line::from(vec![ + Span::styled(format!(" {ws_name}"), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + Span::styled(format!(" — {pane_text}"), dim), + ]); + + let action_line = Line::from(vec![ + Span::raw(" "), + Span::styled("y", key), + Span::styled("/", dim), + Span::styled("enter", key), + Span::styled(" confirm ", dim), + Span::styled("any key", key), + Span::styled(" cancel", dim), + ]); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Red)) + .style(Style::default().bg(Color::Black)); + + let inner = block.inner(popup); + frame.render_widget(Clear, popup); + frame.render_widget(block, popup); + + if inner.height >= 3 { + let rows = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas::<3>(inner); + frame.render_widget(Paragraph::new(title_line), rows[0]); + frame.render_widget(Paragraph::new(detail_line), rows[1]); + frame.render_widget(Paragraph::new(action_line), rows[2]); + } +} + +/// Right-click context menu popup anchored near the click position. +fn render_context_menu(app: &AppState, frame: &mut Frame) { + use crate::app::CONTEXT_MENU_ITEMS; + + let Some(menu) = &app.context_menu else { + return; + }; + + let menu_w = 14u16; + let menu_h = CONTEXT_MENU_ITEMS.len() as u16 + 2; // +2 for border + let area = frame.area(); + + // Position: try to place below-right of click, clamp to screen + let x = menu.x.min(area.width.saturating_sub(menu_w)); + let y = menu.y.min(area.height.saturating_sub(menu_h)); + let menu_rect = Rect::new(x, y, menu_w, menu_h); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(app.accent)) + .style(Style::default().bg(Color::Black)); + let inner = block.inner(menu_rect); + + frame.render_widget(Clear, menu_rect); + frame.render_widget(block, menu_rect); + + let highlight = Style::default() + .fg(Color::Black) + .bg(app.accent) + .add_modifier(Modifier::BOLD); + let normal = Style::default().fg(Color::White); + + for (i, item) in CONTEXT_MENU_ITEMS.iter().enumerate() { + if i as u16 >= inner.height { + break; + } + let style = if i == menu.selected { highlight } else { normal }; + let row = Rect::new(inner.x, inner.y + i as u16, inner.width, 1); + frame.render_widget( + Paragraph::new(format!(" {item}")).style(style), + row, + ); + } +} + +fn render_update_notification(frame: &mut Frame, area: Rect, version: &str, accent: Color) { + let text = format!(" ✦ herdr v{version} installed — restart to update "); + let width = text.len() as u16 + 2; + let x = area.x + area.width.saturating_sub(width) / 2; + let y = area.y + area.height.saturating_sub(3); + let notif_area = Rect::new(x, y, width.min(area.width), 1); + + frame.render_widget(Clear, notif_area); + frame.render_widget( + Paragraph::new(Span::styled( + text, + Style::default() + .fg(Color::Black) + .bg(accent) + .add_modifier(Modifier::BOLD), + )), + notif_area, + ); +} + +/// Visual badge for a pane's state + seen flag. +/// +/// | State | Icon | Color | +/// |--------------------|------|--------| +/// | Busy | ● | Yellow | +/// | Done (idle+unseen) | ● | Blue | +/// | Idle (seen) | ○ | Green | +/// | Unknown | · | Gray | +/// +/// Filled dot = needs attention (working, or finished unseen). +/// Hollow dot = nothing to do here. +fn state_icon_style(state: AgentState, seen: bool) -> (&'static str, Style) { + match (state, seen) { + (AgentState::Waiting, _) => ("●", Style::default().fg(Color::Red)), + (AgentState::Busy, _) => ("●", Style::default().fg(Color::Yellow)), + (AgentState::Idle, false) => ("●", Style::default().fg(Color::Blue)), // Done + (AgentState::Idle, true) => ("○", Style::default().fg(Color::Green)), + (AgentState::Unknown, _) => ("·", Style::default().fg(Color::DarkGray)), + } +} + +fn _build_hints(items: &[(&str, &str)], key_style: Style, dim_style: Style) -> Vec> { + let mut spans = Vec::new(); + spans.push(Span::raw(" ")); + for (i, (k, desc)) in items.iter().enumerate() { + if i > 0 { + spans.push(Span::styled(" ", dim_style)); + } + spans.push(Span::styled(k.to_string(), key_style)); + spans.push(Span::styled(format!(" {desc}"), dim_style)); + } + spans +} diff --git a/src/update.rs b/src/update.rs new file mode 100644 index 00000000..f025f847 --- /dev/null +++ b/src/update.rs @@ -0,0 +1,359 @@ +//! Self-update mechanism. +//! +//! Checks GitHub releases for newer versions and downloads the binary. +//! Uses `curl` as a subprocess for HTTP — no additional Rust HTTP dependencies. +//! JSON parsing uses serde_json (already in deps for persistence). + +use std::env; +use std::fs; +use std::process::Command; + +use serde::Deserialize; + +const GITHUB_REPO: &str = "ogulcancelik/herdr"; +const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +// --------------------------------------------------------------------------- +// Version +// --------------------------------------------------------------------------- + +/// Parsed semver version for comparison. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct Version { + pub major: u32, + pub minor: u32, + pub patch: u32, +} + +impl Version { + pub fn parse(s: &str) -> Option { + let s = s.strip_prefix('v').unwrap_or(s); + let parts: Vec<&str> = s.split('.').collect(); + if parts.len() != 3 { + return None; + } + Some(Self { + major: parts[0].parse().ok()?, + minor: parts[1].parse().ok()?, + patch: parts[2].parse().ok()?, + }) + } + + pub fn current() -> Self { + Self::parse(CURRENT_VERSION).expect("invalid CARGO_PKG_VERSION") + } +} + +impl std::fmt::Display for Version { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}.{}.{}", self.major, self.minor, self.patch) + } +} + +// --------------------------------------------------------------------------- +// GitHub release API types +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct GitHubRelease { + tag_name: String, + assets: Vec, +} + +#[derive(Deserialize)] +struct GitHubAsset { + name: String, + browser_download_url: String, +} + +// --------------------------------------------------------------------------- +// Release info +// --------------------------------------------------------------------------- + +/// Information about an available update. +struct ReleaseInfo { + version: Version, + download_url: String, +} + +/// Check GitHub for the latest release. Returns release info if newer. +fn check_latest() -> Result, String> { + let current = Version::current(); + let url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases/latest"); + + let output = Command::new("curl") + .args([ + "-sfL", + "--max-time", "10", + "-H", "Accept: application/vnd.github+json", + "-H", "User-Agent: herdr-updater", + &url, + ]) + .output() + .map_err(|e| format!("curl failed: {e}"))?; + + if !output.status.success() { + return Err("failed to fetch release info".into()); + } + + let release: GitHubRelease = serde_json::from_slice(&output.stdout) + .map_err(|e| format!("failed to parse release JSON: {e}"))?; + + let latest = Version::parse(&release.tag_name) + .ok_or_else(|| format!("invalid version tag: {}", release.tag_name))?; + + if latest <= current { + return Ok(None); // up to date + } + + // Find the asset for our platform + let (os, arch) = platform_target(); + let asset_name = format!("herdr-{os}-{arch}"); + + let download_url = release + .assets + .iter() + .find(|a| a.name == asset_name) + .map(|a| a.browser_download_url.clone()) + .ok_or_else(|| format!("no binary for {os}-{arch} in release {}", release.tag_name))?; + + Ok(Some(ReleaseInfo { + version: latest, + download_url, + })) +} + +// --------------------------------------------------------------------------- +// Download + install +// --------------------------------------------------------------------------- + +/// Download and install a release. Returns the installed version. +fn download_and_install(release: &ReleaseInfo) -> Result<(), String> { + let current_exe = env::current_exe() + .map_err(|e| format!("can't find current binary: {e}"))?; + + let parent = current_exe + .parent() + .ok_or("can't find binary directory")?; + + // Check write permissions early + let test_path = parent.join(".herdr-write-test"); + if let Err(e) = fs::write(&test_path, b"") { + let _ = fs::remove_file(&test_path); + return Err(format!( + "install directory not writable: {} ({}). Try running with appropriate permissions.", + parent.display(), + e + )); + } + let _ = fs::remove_file(&test_path); + + // Unique temp file (avoids races with concurrent instances) + let tmp_path = parent.join(format!( + ".herdr-update-{}.tmp", + std::process::id() + )); + + // Download the exact asset URL (pinned to the release we checked) + let status = Command::new("curl") + .args(["-sfL", "--max-time", "120", "-o"]) + .arg(&tmp_path) + .arg(&release.download_url) + .status() + .map_err(|e| format!("download failed: {e}"))?; + + if !status.success() { + let _ = fs::remove_file(&tmp_path); + return Err("download failed".into()); + } + + // Make executable + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Err(e) = fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o755)) { + let _ = fs::remove_file(&tmp_path); + return Err(format!("chmod failed: {e}")); + } + } + + // Atomic replace — rename over the current binary. + // On Linux, the running process keeps its fd to the old inode. + // Next launch picks up the new file. + if let Err(e) = fs::rename(&tmp_path, ¤t_exe) { + let _ = fs::remove_file(&tmp_path); + return Err(format!("failed to replace binary: {e}")); + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Manual self-update command (`herdr update`). +pub fn self_update() -> Result { + eprintln!("checking for updates..."); + + let current = Version::current(); + + let release = match check_latest()? { + Some(r) => r, + None => { + eprintln!("already up to date (v{current})"); + return Ok(current); + } + }; + + eprintln!("downloading v{}...", release.version); + download_and_install(&release)?; + eprintln!("updated to v{}", release.version); + + Ok(release.version) +} + +/// Background auto-update: check, download, install, notify TUI. +/// Runs in a background thread at startup. +pub fn auto_update(events: tokio::sync::mpsc::Sender) { + let release = match check_latest() { + Ok(Some(r)) => r, + _ => return, // up to date or failed — silently do nothing + }; + + tracing::info!( + "new version v{} available, downloading from {}", + release.version, + release.download_url + ); + + if let Err(e) = download_and_install(&release) { + tracing::warn!("auto-update failed: {e}"); + return; + } + + tracing::info!("auto-update: v{} installed, restart to use", release.version); + + // Notify the TUI — blocking_send is safe from a std::thread + let _ = events.blocking_send(crate::events::AppEvent::UpdateReady { + version: release.version.to_string(), + }); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn platform_target() -> (&'static str, &'static str) { + let os = if cfg!(target_os = "linux") { + "linux" + } else if cfg!(target_os = "macos") { + "macos" + } else { + "unknown" + }; + + let arch = if cfg!(target_arch = "x86_64") { + "x86_64" + } else if cfg!(target_arch = "aarch64") { + "aarch64" + } else { + "unknown" + }; + + (os, arch) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_version_basic() { + assert_eq!( + Version::parse("1.2.3"), + Some(Version { major: 1, minor: 2, patch: 3 }) + ); + } + + #[test] + fn parse_version_with_v_prefix() { + assert_eq!( + Version::parse("v0.1.0"), + Some(Version { major: 0, minor: 1, patch: 0 }) + ); + } + + #[test] + fn parse_version_invalid() { + assert_eq!(Version::parse("1.2"), None); + assert_eq!(Version::parse("abc"), None); + assert_eq!(Version::parse(""), None); + } + + #[test] + fn version_ordering() { + let v010 = Version::parse("0.1.0").unwrap(); + let v011 = Version::parse("0.1.1").unwrap(); + let v020 = Version::parse("0.2.0").unwrap(); + let v100 = Version::parse("1.0.0").unwrap(); + + assert!(v010 < v011); + assert!(v011 < v020); + assert!(v020 < v100); + assert!(v010 == Version::parse("0.1.0").unwrap()); + } + + #[test] + fn version_display() { + let v = Version { major: 0, minor: 1, patch: 0 }; + assert_eq!(v.to_string(), "0.1.0"); + } + + #[test] + fn current_version_parses() { + let v = Version::current(); + assert!(v.major < 100); + } + + #[test] + fn platform_target_is_known() { + let (os, arch) = platform_target(); + assert!(os == "linux" || os == "macos", "os: {os}"); + assert!(arch == "x86_64" || arch == "aarch64", "arch: {arch}"); + } + + #[test] + fn github_release_deserializes() { + let json = r#"{ + "tag_name": "v0.2.0", + "assets": [ + {"name": "herdr-linux-x86_64", "browser_download_url": "https://example.com/herdr-linux-x86_64"}, + {"name": "herdr-macos-aarch64", "browser_download_url": "https://example.com/herdr-macos-aarch64"} + ] + }"#; + let release: GitHubRelease = serde_json::from_str(json).unwrap(); + assert_eq!(release.tag_name, "v0.2.0"); + assert_eq!(release.assets.len(), 2); + assert_eq!(release.assets[0].name, "herdr-linux-x86_64"); + } + + #[test] + fn github_release_ignores_extra_fields() { + let json = r#"{ + "tag_name": "v0.1.0", + "name": "Release v0.1.0", + "body": "Some notes", + "draft": false, + "prerelease": false, + "assets": [] + }"#; + let release: GitHubRelease = serde_json::from_str(json).unwrap(); + assert_eq!(release.tag_name, "v0.1.0"); + assert!(release.assets.is_empty()); + } +} diff --git a/src/workspace.rs b/src/workspace.rs new file mode 100644 index 00000000..2b8a2902 --- /dev/null +++ b/src/workspace.rs @@ -0,0 +1,220 @@ +use std::collections::HashMap; + +use ratatui::layout::Direction; +use tokio::sync::mpsc; +use tracing::info; + +use crate::detect::{self, AgentState}; +use crate::events::AppEvent; +use crate::layout::{PaneId, TileLayout}; +use crate::pane::{PaneRuntime, PaneState}; + +/// A named workspace containing tiled terminal panes. +pub struct Workspace { + pub name: String, + pub layout: TileLayout, + /// Pane state — always present, testable without PTYs. + pub panes: HashMap, + /// Pane runtimes — only present in production (empty in tests). + pub runtimes: HashMap, + pub zoomed: bool, + pub events: mpsc::Sender, +} + +impl Workspace { + pub fn new( + name: String, + rows: u16, + cols: u16, + events: mpsc::Sender, + ) -> std::io::Result { + let (layout, root_id) = TileLayout::new(); + + let cwd = std::env::current_dir().unwrap_or_else(|_| "/".into()); + let runtime = PaneRuntime::spawn(root_id, rows, cols, cwd, events.clone())?; + + let mut panes = HashMap::new(); + panes.insert(root_id, PaneState::new()); + let mut runtimes = HashMap::new(); + runtimes.insert(root_id, runtime); + + info!(workspace = %name, root_pane = root_id.raw(), "workspace created"); + Ok(Self { + name, + layout, + panes, + runtimes, + zoomed: false, + events, + }) + } + + /// Split the focused pane. Returns the new pane id. + pub fn split_focused( + &mut self, + direction: Direction, + rows: u16, + cols: u16, + cwd: Option, + ) -> std::io::Result { + let new_id = self.layout.split_focused(direction); + let actual_cwd = cwd.unwrap_or_else(|| { + std::env::current_dir().unwrap_or_else(|_| "/".into()) + }); + let runtime = PaneRuntime::spawn(new_id, rows, cols, actual_cwd, self.events.clone())?; + self.panes.insert(new_id, PaneState::new()); + self.runtimes.insert(new_id, runtime); + self.zoomed = false; + Ok(new_id) + } + + /// Close the focused pane. Returns the removed pane id, or None if last pane. + pub fn close_focused(&mut self) -> Option { + if self.layout.pane_count() <= 1 { + return None; + } + let closed = self.layout.focused(); + if self.layout.close_focused() { + self.panes.remove(&closed); + self.runtimes.remove(&closed); + self.zoomed = false; + Some(closed) + } else { + None + } + } + + /// Get the runtime for the focused pane. + pub fn focused_runtime(&self) -> Option<&PaneRuntime> { + self.runtimes.get(&self.layout.focused()) + } + + /// Aggregate state + seen across all panes. + /// Returns the highest-priority state and the worst-case seen flag. + pub fn aggregate_state(&self) -> (AgentState, bool) { + let states: Vec = self.panes.values().map(|p| p.state).collect(); + let state = detect::workspace_state(&states); + let seen = self.panes.values().all(|p| p.seen); + (state, seen) + } + + /// Per-pane (state, seen) in BSP tree order (left-to-right, top-to-bottom). + pub fn pane_states(&self) -> Vec<(AgentState, bool)> { + self.layout + .pane_ids() + .iter() + .map(|id| { + self.panes + .get(id) + .map(|p| (p.state, p.seen)) + .unwrap_or((AgentState::Unknown, true)) + }) + .collect() + } +} + +// --------------------------------------------------------------------------- +// Test helpers — construct workspaces without PTYs +// --------------------------------------------------------------------------- + +#[cfg(test)] +impl Workspace { + /// Create a test workspace with one pane, no PTY runtime. + pub fn test_new(name: &str) -> Self { + let (events, _) = mpsc::channel(64); + let (layout, root_id) = TileLayout::new(); + let mut panes = HashMap::new(); + panes.insert(root_id, PaneState::new()); + Self { + name: name.to_string(), + layout, + panes, + runtimes: HashMap::new(), + zoomed: false, + events, + } + } + + /// Add a test pane (splits focused, no PTY runtime). + pub fn test_split(&mut self, direction: Direction) -> PaneId { + let new_id = self.layout.split_focused(direction); + self.panes.insert(new_id, PaneState::new()); + new_id + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::detect::AgentState; + + #[test] + fn aggregate_state_all_unknown() { + let ws = Workspace::test_new("test"); + let (state, seen) = ws.aggregate_state(); + assert_eq!(state, AgentState::Unknown); + assert!(seen); + } + + #[test] + fn aggregate_state_priority() { + let mut ws = Workspace::test_new("test"); + let id2 = ws.test_split(Direction::Horizontal); + + // Set one pane to Busy, one to Idle + let root_id = *ws.panes.keys().find(|id| **id != id2).unwrap(); + ws.panes.get_mut(&root_id).unwrap().state = AgentState::Idle; + ws.panes.get_mut(&id2).unwrap().state = AgentState::Busy; + + let (state, _) = ws.aggregate_state(); + // Busy should be higher priority than Idle + assert_eq!(state, AgentState::Busy); + } + + #[test] + fn aggregate_seen_any_unseen_means_unseen() { + let mut ws = Workspace::test_new("test"); + let id2 = ws.test_split(Direction::Horizontal); + + ws.panes.get_mut(&id2).unwrap().seen = false; + + let (_, seen) = ws.aggregate_state(); + assert!(!seen); + } + + #[test] + fn close_focused_removes_pane() { + let mut ws = Workspace::test_new("test"); + let _id2 = ws.test_split(Direction::Horizontal); + assert_eq!(ws.panes.len(), 2); + + let closed = ws.close_focused(); + assert!(closed.is_some()); + assert_eq!(ws.panes.len(), 1); + } + + #[test] + fn close_focused_last_pane_returns_none() { + let mut ws = Workspace::test_new("test"); + assert_eq!(ws.panes.len(), 1); + + let closed = ws.close_focused(); + assert!(closed.is_none()); + assert_eq!(ws.panes.len(), 1); + } + + #[test] + fn pane_states_matches_layout_order() { + let mut ws = Workspace::test_new("test"); + let id2 = ws.test_split(Direction::Horizontal); + + // Set second pane to Waiting + ws.panes.get_mut(&id2).unwrap().state = AgentState::Waiting; + + let states = ws.pane_states(); + assert_eq!(states.len(), 2); + // One should be Unknown, one Waiting + assert!(states.iter().any(|(s, _)| *s == AgentState::Waiting)); + assert!(states.iter().any(|(s, _)| *s == AgentState::Unknown)); + } +} -- cgit v1.2.3