aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/actions.rs417
-rw-r--r--src/app/input.rs562
-rw-r--r--src/app/mod.rs159
-rw-r--r--src/app/state.rs130
-rw-r--r--src/config.rs279
-rw-r--r--src/detect.rs1163
-rw-r--r--src/events.rs26
-rw-r--r--src/input.rs339
-rw-r--r--src/layout.rs470
-rw-r--r--src/main.rs228
-rw-r--r--src/pane.rs370
-rw-r--r--src/persist.rs446
-rw-r--r--src/platform/fallback.rs11
-rw-r--r--src/platform/linux.rs27
-rw-r--r--src/platform/macos.rs98
-rw-r--r--src/platform/mod.rs19
-rw-r--r--src/pty_callbacks.rs374
-rw-r--r--src/selection.rs253
-rw-r--r--src/ui.rs870
-rw-r--r--src/update.rs359
-rw-r--r--src/workspace.rs220
21 files changed, 6820 insertions, 0 deletions
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<usize> {
+ 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<AppEvent>,
+ event_rx: mpsc::Receiver<AppEvent>,
+ 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::<AppEvent>(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<PaneInfo>,
+ pub split_borders: Vec<SplitBorder>,
+}
+
+#[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<bool>,
+ 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<Workspace>,
+ pub active: Option<usize>,
+ 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<DragState>,
+ pub selection: Option<Selection>,
+ pub context_menu: Option<ContextMenuState>,
+ // Update notification
+ pub update_available: Option<String>,
+ 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<u8> = 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::<u8>(),
+ parts[1].trim().parse::<u8>(),
+ parts[2].trim().parse::<u8>(),
+ ) {
+ 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::<u8>()
+ .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<Agent> {
+ 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<Agent>, 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<String> {
+ 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<Agent>,
+ 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<u8> {
+ 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<Vec<u8>> {
+ 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<u8> {
+ 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<Vec<u8>> {
+ 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<u8> {
+ 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<u8> {
+ 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<bool>,
+}
+
+/// 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<Node>,
+ second: Box<Node>,
+ },
+}
+
+/// 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<PaneInfo> {
+ 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<SplitBorder> {
+ 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<PaneId> {
+ 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<PaneId> {
+ 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<PaneInfo>) {
+ 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<bool>, result: &mut Vec<SplitBorder>) {
+ 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<PaneId>) {
+ 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<Node> {
+ 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<f32> {
+ 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<String> = 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<Agent>,
+ 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<RwLock<vt100::Parser<PtyResponses>>>,
+ pub sender: mpsc::Sender<Bytes>,
+ resize_tx: mpsc::Sender<(u16, u16)>,
+ current_size: Cell<(u16, u16)>,
+ child_pid: Arc<AtomicU32>,
+ pub kitty_keyboard: Arc<AtomicBool>,
+ /// 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<RwLock<String>>,
+ // 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<AppEvent>,
+ ) -> std::io::Result<Self> {
+ 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::<Bytes>(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<Agent> = 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<std::path::PathBuf> {
+ 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<WorkspaceSnapshot>,
+ pub active: Option<usize>,
+ pub selected: usize,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct WorkspaceSnapshot {
+ pub name: String,
+ pub layout: LayoutSnapshot,
+ pub panes: HashMap<u32, PaneSnapshot>,
+ pub zoomed: bool,
+ /// Raw pane ID that was focused when saved.
+ #[serde(default)]
+ pub focused: Option<u32>,
+}
+
+#[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<LayoutSnapshot>,
+ second: Box<LayoutSnapshot>,
+ },
+}
+
+#[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<usize>,
+ 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<AppEvent>,
+) -> Vec<Workspace> {
+ 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<AppEvent>,
+) -> Option<Workspace> {
+ 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<u32, PaneId>) {
+ 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<u32, PaneId>) -> 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<PaneId> {
+ let mut ids = Vec::new();
+ collect_ids_inner(node, &mut ids);
+ ids
+}
+
+fn collect_ids_inner(node: &Node, ids: &mut Vec<PaneId>) {
+ 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<SessionSnapshot> {
+ 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::<SessionSnapshot>(&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<u32> = 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<String> {
+ None
+}
+
+/// Unsupported platform stub.
+pub fn process_cwd(_pid: u32) -> Option<PathBuf> {
+ 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/<pid>/stat to find the tpgid, then /proc/<tpgid>/comm.
+pub fn foreground_process_name(child_pid: u32) -> Option<String> {
+ // /proc/<pid>/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/<pid>/cwd symlink.
+pub fn process_cwd(pid: u32) -> Option<PathBuf> {
+ 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<String> {
+ 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::<libc::proc_bsdinfo>() 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<PathBuf> {
+ if pid == 0 {
+ return None;
+ }
+
+ let mut pathinfo: libc::proc_vnodepathinfo = unsafe { std::mem::zeroed() };
+ let size = std::mem::size_of::<libc::proc_vnodepathinfo>() 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<Mutex<Vec<u8>>>,
+ /// 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<Mutex<Vec<u16>>>,
+ /// Derived from kitty_stack: true when stack is non-empty.
+ /// Shared with PaneRuntime for fast-path key encoding decisions.
+ pub kitty_keyboard: Arc<AtomicBool>,
+}
+
+impl PtyResponses {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Take any pending response bytes (clears the buffer).
+ pub fn take(&self) -> Vec<u8> {
+ 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<u8>,
+ _i2: Option<u8>,
+ 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[<Nu → child reverts to previous mode
+ // N = number of entries to pop (default 1)
+ (Some(b'<'), 'u') => {
+ 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<PtyResponses> {
+ 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[<u");
+ assert!(r.kitty_keyboard.load(Ordering::Relaxed));
+ p.process(b"\x1b[?u");
+ assert_eq!(r.take(), b"\x1b[?1u");
+
+ // Fish pops → back to legacy
+ p.process(b"\x1b[<u");
+ assert!(!r.kitty_keyboard.load(Ordering::Relaxed));
+ p.process(b"\x1b[?u");
+ assert_eq!(r.take(), b"\x1b[?0u");
+ }
+
+ #[test]
+ fn kitty_pop_on_empty_is_harmless() {
+ let r = PtyResponses::new();
+ let mut p = make_parser(r.clone());
+
+ // Pop with nothing on stack — should not crash
+ p.process(b"\x1b[<u");
+ assert!(!r.kitty_keyboard.load(Ordering::Relaxed));
+ }
+
+ #[test]
+ fn kitty_pop_count() {
+ let r = PtyResponses::new();
+ let mut p = make_parser(r.clone());
+
+ // Push three times
+ 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[<u");
+ assert!(!r.kitty_keyboard.load(Ordering::Relaxed));
+ }
+
+ #[test]
+ fn responds_to_xtversion() {
+ let r = PtyResponses::new();
+ let mut p = make_parser(r.clone());
+ 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 ; <base64> 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<PaneInfo> {
+ 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<ListItem> = 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<crate::selection::Selection>,
+ 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<Span<'static>> {
+ 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<Self> {
+ 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<GitHubAsset>,
+}
+
+#[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<Option<ReleaseInfo>, 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, &current_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<Version, String> {
+ 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<crate::events::AppEvent>) {
+ 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<PaneId, PaneState>,
+ /// Pane runtimes — only present in production (empty in tests).
+ pub runtimes: HashMap<PaneId, PaneRuntime>,
+ pub zoomed: bool,
+ pub events: mpsc::Sender<AppEvent>,
+}
+
+impl Workspace {
+ pub fn new(
+ name: String,
+ rows: u16,
+ cols: u16,
+ events: mpsc::Sender<AppEvent>,
+ ) -> std::io::Result<Self> {
+ 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::path::PathBuf>,
+ ) -> std::io::Result<PaneId> {
+ 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<PaneId> {
+ 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<AgentState> = 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));
+ }
+}