aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-03-23 00:36:52 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-03-27 21:37:05 +0300
commita57b97285c3308fbdcc167ab3da9ae1dd78767b6 (patch)
tree25a0eb4df100ea9b09c7551fa7cc25f8d86027ef /src/app
initial release
terminal workspace manager for AI coding agents. single rust binary, runs inside your existing terminal. workspaces, tiled panes, intelligent agent state detection. https://herdr.dev
Diffstat (limited to 'src/app')
-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
4 files changed, 1268 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()),
+ },
+ }
+ }
+}