diff --git a/src/app/actions.rs b/src/app/actions.rs index 04ff19ac..279cbabf 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -3,10 +3,9 @@ use tracing::{info, warn}; -use crate::detect::{Agent, AgentState}; +use crate::detect::AgentState; use crate::events::AppEvent; use crate::layout::{find_in_direction, NavDirection, PaneId}; -use crate::pane::EffectiveStateChange; use super::state::{AppState, Mode, ToastKind, ToastNotification}; @@ -44,32 +43,22 @@ fn notification_toast_for_state_change( } } -fn agent_label(agent: Agent) -> &'static str { +fn agent_label(agent: crate::detect::Agent) -> &'static str { match agent { - Agent::Pi => "pi", - Agent::Claude => "claude", - Agent::Codex => "codex", - Agent::Gemini => "gemini", - Agent::Cursor => "cursor", - Agent::Cline => "cline", - Agent::OpenCode => "opencode", - Agent::GithubCopilot => "copilot", - Agent::Kimi => "kimi", - Agent::Droid => "droid", - Agent::Amp => "amp", + crate::detect::Agent::Pi => "pi", + crate::detect::Agent::Claude => "claude", + crate::detect::Agent::Codex => "codex", + crate::detect::Agent::Gemini => "gemini", + crate::detect::Agent::Cursor => "cursor", + crate::detect::Agent::Cline => "cline", + crate::detect::Agent::OpenCode => "opencode", + crate::detect::Agent::GithubCopilot => "copilot", + crate::detect::Agent::Kimi => "kimi", + crate::detect::Agent::Droid => "droid", + crate::detect::Agent::Amp => "amp", } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct PaneStateUpdate { - pub pane_id: PaneId, - pub ws_idx: usize, - pub previous_agent: Option, - pub previous_state: AgentState, - pub agent: Option, - pub state: AgentState, -} - // --------------------------------------------------------------------------- // Workspace operations // --------------------------------------------------------------------------- @@ -79,8 +68,56 @@ impl AppState { if idx < self.workspaces.len() { self.active = Some(idx); self.selected = idx; - for pane in self.workspaces[idx].panes.values_mut() { - pane.seen = true; + if let Some(ws) = self.workspaces.get_mut(idx) { + ws.switch_tab(ws.active_tab); + } + } + } + + pub fn switch_tab(&mut self, idx: usize) { + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + ws.switch_tab(idx); + } + } + + pub fn next_workspace(&mut self) { + if !self.workspaces.is_empty() { + let current = self.active.unwrap_or(self.selected); + let next = (current + 1) % self.workspaces.len(); + self.switch_workspace(next); + } + } + + pub fn previous_workspace(&mut self) { + if !self.workspaces.is_empty() { + let current = self.active.unwrap_or(self.selected); + let prev = if current == 0 { + self.workspaces.len() - 1 + } else { + current - 1 + }; + self.switch_workspace(prev); + } + } + + pub fn next_tab(&mut self) { + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + if !ws.tabs.is_empty() { + let next = (ws.active_tab + 1) % ws.tabs.len(); + ws.switch_tab(next); + } + } + } + + pub fn previous_tab(&mut self) { + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + if !ws.tabs.is_empty() { + let prev = if ws.active_tab == 0 { + ws.tabs.len() - 1 + } else { + ws.active_tab - 1 + }; + ws.switch_tab(prev); } } } @@ -113,8 +150,12 @@ impl AppState { 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); + if let Some(tab) = self + .active + .and_then(|i| self.workspaces.get_mut(i)) + .and_then(|ws| ws.active_tab_mut()) + { + tab.layout.focus_pane(target); } } } @@ -127,33 +168,63 @@ impl AppState { .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); + if let Some(tab) = self + .active + .and_then(|i| self.workspaces.get_mut(i)) + .and_then(|ws| ws.active_tab_mut()) + { + tab.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 let Some(tab) = self + .active + .and_then(|i| self.workspaces.get_mut(i)) + .and_then(|ws| ws.active_tab_mut()) + { if reverse { - ws.layout.focus_prev(); + tab.layout.focus_prev(); } else { - ws.layout.focus_next(); + tab.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; + if let Some(tab) = self + .active + .and_then(|i| self.workspaces.get_mut(i)) + .and_then(|ws| ws.active_tab_mut()) + { + if tab.layout.pane_count() > 1 { + tab.zoomed = !tab.zoomed; } } } pub fn close_pane(&mut self) { + let should_close_workspace = self + .active + .and_then(|i| self.workspaces.get_mut(i)) + .is_some_and(|ws| ws.close_focused()); + if should_close_workspace { + self.close_selected_workspace(); + } + } + + pub fn close_tab(&mut self) { + let should_close_workspace = self + .active + .and_then(|i| self.workspaces.get(i)) + .is_some_and(|ws| ws.tabs.len() <= 1); + if should_close_workspace { + self.close_selected_workspace(); + return; + } if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { - ws.close_focused(); + ws.close_active_tab(); } } } @@ -184,7 +255,7 @@ impl AppState { None => return, }; - let rt = match ws.runtimes.get(&sel.pane_id) { + let rt = match ws.runtime(sel.pane_id) { Some(r) => r, None => return, }; @@ -204,12 +275,9 @@ impl AppState { // --------------------------------------------------------------------------- impl AppState { - pub fn handle_app_event(&mut self, event: AppEvent) -> Vec { + pub fn handle_app_event(&mut self, event: AppEvent) { match event { - AppEvent::PaneDied { pane_id } => { - self.handle_pane_died(pane_id); - Vec::new() - } + AppEvent::PaneDied { pane_id } => self.handle_pane_died(pane_id), AppEvent::UpdateReady { version } => { self.update_available = Some(version.clone()); self.update_dismissed = true; @@ -218,115 +286,65 @@ impl AppState { title: format!("updated to v{version}"), context: "restart to use it".to_string(), }); - Vec::new() } AppEvent::StateChanged { pane_id, agent, state, - } => self - .update_pane_state(pane_id, |pane| pane.set_detected_state(agent, state)) - .into_iter() - .collect(), - AppEvent::HookStateReported { - pane_id, - source, - agent, - state, - message, - } => self - .update_pane_state(pane_id, |pane| { - pane.set_hook_authority(source, agent, state, message) - }) - .into_iter() - .collect(), - AppEvent::HookAuthorityCleared { pane_id, source } => self - .update_pane_state(pane_id, |pane| pane.clear_hook_authority(source.as_deref())) - .into_iter() - .collect(), - AppEvent::HookAgentReleased { - pane_id, - source, - agent, - } => self - .update_pane_state(pane_id, |pane| pane.release_agent(&source, agent)) - .into_iter() - .collect(), - } - } + } => { + for (ws_idx, ws) in self.workspaces.iter_mut().enumerate() { + let workspace_name = ws.display_name(); + let Some(tab_idx) = ws.find_tab_index_for_pane(pane_id) else { + continue; + }; + if let Some(pane) = ws.tabs[tab_idx].panes.get_mut(&pane_id) { + let is_active_ws = self.active == Some(ws_idx) && ws.active_tab == tab_idx; + let prev_state = pane.state; - fn update_pane_state(&mut self, pane_id: PaneId, update: F) -> Option - where - F: FnOnce(&mut crate::pane::PaneState) -> Option, - { - let ws_idx = self - .workspaces - .iter() - .position(|ws| ws.panes.contains_key(&pane_id))?; - let workspace_name = self.workspaces[ws_idx].display_name(); - let change = { - let pane = self.workspaces[ws_idx].panes.get_mut(&pane_id)?; - update(pane)? - }; - self.apply_pane_state_change(ws_idx, pane_id, &workspace_name, change); - Some(PaneStateUpdate { - pane_id, - ws_idx, - previous_agent: change.previous_agent, - previous_state: change.previous_state, - agent: change.agent, - state: change.state, - }) - } + // Mark unseen when transitioning to Idle in background + if state == AgentState::Idle + && prev_state != AgentState::Idle + && !is_active_ws + { + pane.seen = false; + } - fn apply_pane_state_change( - &mut self, - ws_idx: usize, - pane_id: PaneId, - workspace_name: &str, - change: EffectiveStateChange, - ) { - let is_active_ws = self.active == Some(ws_idx); - let Some(pane) = self.workspaces[ws_idx].panes.get_mut(&pane_id) else { - return; - }; + // Blocked prompts should always make noise; done sounds stay background-only. + if self.sound.allows(agent) { + if let Some(sound) = + notification_sound_for_state_change(is_active_ws, prev_state, state) + { + crate::sound::play(sound); + } + } - if change.state == AgentState::Idle - && change.previous_state != AgentState::Idle - && !is_active_ws - { - pane.seen = false; - } + if self.toast_config.enabled { + if let (Some(agent), Some(kind)) = ( + agent, + notification_toast_for_state_change( + is_active_ws, + prev_state, + state, + ), + ) { + let event_text = match kind { + ToastKind::NeedsAttention => "needs attention", + ToastKind::Finished => "finished", + ToastKind::UpdateInstalled => "updated", + }; + self.toast = Some(ToastNotification { + kind, + title: format!("{} {}", agent_label(agent), event_text), + context: format!("{} · {}", workspace_name, ws_idx + 1), + }); + } + } - if self.sound.allows(change.agent) { - if let Some(sound) = notification_sound_for_state_change( - is_active_ws, - change.previous_state, - change.state, - ) { - crate::sound::play(sound); - } - } - - if self.toast_config.enabled { - if let (Some(agent), Some(kind)) = ( - change.agent, - notification_toast_for_state_change( - is_active_ws, - change.previous_state, - change.state, - ), - ) { - let event_text = match kind { - ToastKind::NeedsAttention => "needs attention", - ToastKind::Finished => "finished", - ToastKind::UpdateInstalled => "updated", - }; - self.toast = Some(ToastNotification { - kind, - title: format!("{} {}", agent_label(agent), event_text), - context: format!("{} · {}", workspace_name, ws_idx + 1), - }); + pane.detected_agent = agent; + pane.state = state; + break; + } + } } } } @@ -335,16 +353,19 @@ impl AppState { let ws_idx = self .workspaces .iter() - .position(|ws| ws.panes.contains_key(&pane_id)); + .position(|ws| ws.find_tab_index_for_pane(pane_id).is_some()); let Some(ws_idx) = ws_idx else { warn!(pane = pane_id.raw(), "PaneDied for unknown pane"); return; }; - let ws = &mut self.workspaces[ws_idx]; + let should_close_workspace = { + let ws = &mut self.workspaces[ws_idx]; + ws.remove_pane(pane_id) + }; - if ws.layout.pane_count() <= 1 { + if should_close_workspace { self.workspaces.remove(ws_idx); if self.workspaces.is_empty() { self.active = None; @@ -362,8 +383,6 @@ impl AppState { self.selected = self.workspaces.len() - 1; } } - } else { - ws.remove_pane(pane_id); } } } @@ -540,94 +559,6 @@ mod tests { assert!(!pane.seen); } - #[test] - fn hook_authority_ignores_fallback_state_changes() { - 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::Idle, - }); - state.handle_app_event(AppEvent::HookStateReported { - pane_id, - source: "herdr:pi".into(), - agent: Agent::Pi, - state: AgentState::Working, - message: None, - }); - state.handle_app_event(AppEvent::StateChanged { - pane_id, - agent: Some(Agent::Pi), - state: AgentState::Idle, - }); - - let pane = state.workspaces[0].panes.get(&pane_id).unwrap(); - assert_eq!(pane.fallback_state, AgentState::Idle); - assert_eq!(pane.state, AgentState::Working); - assert!(pane.hook_authority.is_some()); - } - - #[test] - fn hook_authority_clears_when_agent_disappears() { - 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::Idle, - }); - state.handle_app_event(AppEvent::HookStateReported { - pane_id, - source: "herdr:pi".into(), - agent: Agent::Pi, - state: AgentState::Working, - message: None, - }); - state.handle_app_event(AppEvent::StateChanged { - pane_id, - agent: None, - state: AgentState::Unknown, - }); - - let pane = state.workspaces[0].panes.get(&pane_id).unwrap(); - assert!(pane.hook_authority.is_none()); - assert_eq!(pane.detected_agent, None); - assert_eq!(pane.state, AgentState::Unknown); - } - - #[test] - fn hook_agent_release_clears_identity_immediately() { - 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::Idle, - }); - state.handle_app_event(AppEvent::HookStateReported { - pane_id, - source: "herdr:pi".into(), - agent: Agent::Pi, - state: AgentState::Working, - message: None, - }); - state.handle_app_event(AppEvent::HookAgentReleased { - pane_id, - source: "herdr:pi".into(), - agent: Agent::Pi, - }); - - let pane = state.workspaces[0].panes.get(&pane_id).unwrap(); - assert!(pane.hook_authority.is_none()); - assert_eq!(pane.detected_agent, None); - assert_eq!(pane.fallback_state, AgentState::Unknown); - assert_eq!(pane.state, AgentState::Unknown); - } - #[test] fn waiting_sound_plays_even_in_active_workspace() { assert_eq!( diff --git a/src/app/input.rs b/src/app/input.rs index e0f012e9..9a4c9aaf 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -32,6 +32,35 @@ use super::App; // Key handling // --------------------------------------------------------------------------- +fn terminal_direct_navigation_action(state: &AppState, key: &KeyEvent) -> Option { + let kb = &state.keybinds; + if kb + .previous_workspace + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::PreviousWorkspace); + } + if kb + .next_workspace + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::NextWorkspace); + } + if kb + .previous_tab + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::PreviousTab); + } + if kb + .next_tab + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::NextTab); + } + None +} + impl App { pub(super) async fn handle_key(&mut self, key: TerminalKey) { match self.state.mode { @@ -42,7 +71,9 @@ impl App { Mode::Onboarding => self.handle_onboarding_key(key), Mode::ReleaseNotes => self.handle_release_notes_key(key), Mode::Navigate => handle_navigate_key(&mut self.state, key), - Mode::RenameSession => handle_rename_key(&mut self.state, key), + Mode::RenameWorkspace | Mode::RenameTab => { + 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), @@ -223,6 +254,11 @@ impl App { let key_event = key.as_key_event(); + if let Some(action) = terminal_direct_navigation_action(&self.state, &key_event) { + execute_navigate_action(&mut self.state, action); + return; + } + if self.state.is_prefix(&key_event) { self.state.mode = Mode::Navigate; return; @@ -446,6 +482,13 @@ enum NavigateAction { NewWorkspace, RenameWorkspace, CloseWorkspace, + PreviousWorkspace, + NextWorkspace, + NewTab, + RenameTab, + PreviousTab, + NextTab, + CloseTab, SplitVertical, SplitHorizontal, ClosePane, @@ -465,6 +508,45 @@ fn navigate_action_for_key(state: &AppState, key: &KeyEvent) -> Option { if !state.workspaces.is_empty() { state.name_input = state.workspaces[state.selected].display_name(); - state.mode = Mode::RenameSession; + state.mode = Mode::RenameWorkspace; } } NavigateAction::CloseWorkspace => { @@ -508,6 +590,38 @@ fn execute_navigate_action(state: &mut AppState, action: NavigateAction) { } } } + NavigateAction::PreviousWorkspace => { + state.previous_workspace(); + leave_navigate_mode(state); + } + NavigateAction::NextWorkspace => { + state.next_workspace(); + leave_navigate_mode(state); + } + NavigateAction::NewTab => { + state.request_new_tab = true; + leave_navigate_mode(state); + } + NavigateAction::RenameTab => { + if let Some(ws) = state.active.and_then(|i| state.workspaces.get(i)) { + if let Some(name) = ws.active_tab_display_name() { + state.name_input = name; + state.mode = Mode::RenameTab; + } + } + } + NavigateAction::PreviousTab => { + state.previous_tab(); + leave_navigate_mode(state); + } + NavigateAction::NextTab => { + state.next_tab(); + leave_navigate_mode(state); + } + NavigateAction::CloseTab => { + state.close_tab(); + leave_navigate_mode(state); + } NavigateAction::SplitVertical => { state.split_pane(Direction::Horizontal); leave_navigate_mode(state); @@ -567,8 +681,20 @@ fn handle_rename_key(state: &mut AppState, key: KeyEvent) { } else { state.name_input.trim().to_string() }; - if !new_name.is_empty() && !state.workspaces.is_empty() { - state.workspaces[state.selected].set_custom_name(new_name); + if !new_name.is_empty() { + match state.mode { + Mode::RenameWorkspace if !state.workspaces.is_empty() => { + state.workspaces[state.selected].set_custom_name(new_name); + } + Mode::RenameTab => { + if let Some(ws) = state.active.and_then(|i| state.workspaces.get_mut(i)) { + if let Some(tab) = ws.active_tab_mut() { + tab.set_custom_name(new_name); + } + } + } + _ => {} + } } state.name_input.clear(); state.mode = Mode::Navigate; @@ -577,6 +703,9 @@ fn handle_rename_key(state: &mut AppState, key: KeyEvent) { state.name_input.clear(); state.mode = Mode::Navigate; } + KeyCode::Char('c') if key.modifiers == crossterm::event::KeyModifiers::CONTROL => { + state.name_input.clear(); + } KeyCode::Backspace => { state.name_input.pop(); } @@ -653,7 +782,7 @@ fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuState, idx: (ContextMenuKind::Workspace { ws_idx }, Some("Rename")) => { state.selected = ws_idx; state.name_input = state.workspaces[ws_idx].display_name(); - state.mode = Mode::RenameSession; + state.mode = Mode::RenameWorkspace; } (ContextMenuKind::Workspace { ws_idx }, Some("Close")) => { state.selected = ws_idx; @@ -664,6 +793,35 @@ fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuState, idx: state.mode = Mode::Navigate; } } + (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("New tab")) => { + state.selected = ws_idx; + state.active = Some(ws_idx); + state.switch_tab(tab_idx); + state.request_new_tab = true; + state.mode = Mode::Terminal; + } + (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Rename")) => { + state.selected = ws_idx; + state.active = Some(ws_idx); + state.switch_tab(tab_idx); + if let Some(ws) = state.workspaces.get(ws_idx) { + if let Some(name) = ws.active_tab_display_name() { + state.name_input = name; + state.mode = Mode::RenameTab; + } + } + } + (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Close")) => { + state.selected = ws_idx; + state.active = Some(ws_idx); + state.switch_tab(tab_idx); + state.close_tab(); + state.mode = if state.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; + } (ContextMenuKind::Pane, Some("Split vertical")) => { state.split_pane(Direction::Horizontal); state.mode = Mode::Terminal; @@ -760,6 +918,30 @@ impl AppState { && row < button.y + button.height } + fn rename_modal_inner(&self) -> Option { + self.onboarding_modal_inner(56, 7) + } + + fn rename_button_at(&self, col: u16, row: u16) -> Option<&'static str> { + let inner = self.rename_modal_inner()?; + if inner.height < 4 || inner.width < 28 { + return None; + } + let (save, clear, cancel) = crate::ui::rename_button_rects(inner); + if row != save.y { + return None; + } + if col >= save.x && col < save.x + save.width { + Some("save") + } else if col >= clear.x && col < clear.x + clear.width { + Some("clear") + } else if col >= cancel.x && col < cancel.x + cancel.width { + Some("cancel") + } else { + None + } + } + fn release_notes_body_rect(&self) -> Option { let inner = self.release_notes_modal_inner()?; if inner.height < 8 || inner.width < 4 { @@ -1082,6 +1264,19 @@ impl AppState { return None; } + if matches!(self.mode, Mode::RenameWorkspace | Mode::RenameTab) { + match self.rename_button_at(mouse.column, mouse.row) { + Some("save") => handle_rename_key(self, KeyEvent::from(KeyCode::Enter)), + Some("clear") => self.name_input.clear(), + Some("cancel") => handle_rename_key(self, KeyEvent::from(KeyCode::Esc)), + None => { + handle_rename_key(self, KeyEvent::from(KeyCode::Esc)); + } + _ => {} + } + return None; + } + if self.mode == Mode::ContextMenu { let item_idx = self.context_menu_item_at(mouse.column, mouse.row); if let Some(menu) = self.context_menu.take() { @@ -1139,6 +1334,17 @@ impl AppState { } } + if let Some(tab_idx) = self.tab_at(mouse.column, mouse.row) { + self.switch_tab(tab_idx); + self.mode = Mode::Terminal; + return None; + } + if self.on_new_tab_button(mouse.column, mouse.row) { + self.request_new_tab = true; + self.mode = Mode::Terminal; + return None; + } + if in_sidebar { if self.sidebar_collapsed { let idx = (mouse.row - sidebar.y) as usize; @@ -1300,6 +1506,23 @@ impl AppState { } } + MouseEventKind::Down(MouseButton::Right) + if self.tab_at(mouse.column, mouse.row).is_some() => + { + if let (Some(ws_idx), Some(tab_idx)) = + (self.active, self.tab_at(mouse.column, mouse.row)) + { + self.switch_tab(tab_idx); + self.context_menu = Some(ContextMenuState { + kind: ContextMenuKind::Tab { ws_idx, tab_idx }, + x: mouse.column, + y: mouse.row, + selected: 0, + }); + self.mode = Mode::ContextMenu; + } + } + MouseEventKind::Down(MouseButton::Right) if !in_sidebar => { if self.pane_at(mouse.column, mouse.row).is_some() { self.context_menu = Some(ContextMenuState { @@ -1337,6 +1560,29 @@ impl AppState { } /// Find which workspace index a sidebar row belongs to (two-section layout). + fn tab_at(&self, col: u16, row: u16) -> Option { + self.view + .tab_hit_areas + .iter() + .enumerate() + .find_map(|(idx, area)| { + (row >= area.y + && row < area.y + area.height + && col >= area.x + && col < area.x + area.width) + .then_some(idx) + }) + } + + fn on_new_tab_button(&self, col: u16, row: u16) -> bool { + let area = self.view.new_tab_hit_area; + area.width > 0 + && row >= area.y + && row < area.y + area.height + && col >= area.x + && col < area.x + area.width + } + fn workspace_at_row(&self, row: u16) -> Option { let sidebar = self.view.sidebar_rect; let total_h = sidebar.height as usize; @@ -1744,7 +1990,7 @@ mod tests { KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), ); - assert_eq!(state.mode, Mode::RenameSession); + assert_eq!(state.mode, Mode::RenameWorkspace); assert_eq!(state.name_input, "test"); } diff --git a/src/app/mod.rs b/src/app/mod.rs index f6eb9af3..b69c324e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -186,6 +186,7 @@ impl App { mode, should_quit: false, request_new_workspace: false, + request_new_tab: false, request_complete_onboarding: false, name_input: String::new(), onboarding_step: 0, @@ -198,6 +199,9 @@ impl App { }), view: state::ViewState { sidebar_rect: Rect::default(), + tab_bar_rect: Rect::default(), + tab_hit_areas: Vec::new(), + new_tab_hit_area: Rect::default(), terminal_area: Rect::default(), pane_infos: Vec::new(), split_borders: Vec::new(), @@ -250,7 +254,7 @@ impl App { state .workspaces .get(idx) - .map(|ws| (idx, ws.layout.focused())) + .and_then(|ws| ws.focused_pane_id().map(|pane_id| (idx, pane_id))) }); Self { @@ -319,6 +323,12 @@ impl App { needs_render = true; } + if self.state.request_new_tab { + self.state.request_new_tab = false; + self.create_tab(); + needs_render = true; + } + let now = Instant::now(); self.sync_animation_timer(now); @@ -682,7 +692,7 @@ impl App { self.state .workspaces .get(idx) - .map(|ws| (idx, ws.layout.focused())) + .and_then(|ws| ws.focused_pane_id().map(|pane_id| (idx, pane_id))) }); if current_focus == self.last_focus { return; @@ -717,7 +727,7 @@ impl App { .workspaces .iter() .enumerate() - .find_map(|(ws_idx, ws)| ws.panes.get(&pane_id).map(|pane| (ws_idx, pane))) + .find_map(|(ws_idx, ws)| ws.pane_state(pane_id).map(|pane| (ws_idx, pane))) } fn public_workspace_id(&self, ws_idx: usize) -> String { @@ -1451,6 +1461,38 @@ impl App { } } + fn create_tab(&mut self) { + let initial_cwd = self + .state + .active + .and_then(|i| self.state.workspaces.get(i)) + .and_then(|ws| ws.focused_runtime()) + .and_then(|rt| rt.cwd()) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| std::path::PathBuf::from("/")); + if let Err(e) = self.create_tab_with_options(initial_cwd, true) { + error!(err = %e, "failed to create tab"); + } + } + + fn create_tab_with_options( + &mut self, + initial_cwd: std::path::PathBuf, + focus: bool, + ) -> std::io::Result { + let Some(ws_idx) = self.state.active else { + return self.create_workspace_with_options(initial_cwd, focus); + }; + let (rows, cols) = self.state.estimate_pane_size(); + let ws = &mut self.state.workspaces[ws_idx]; + let idx = ws.create_tab(rows, cols, initial_cwd)?; + if focus { + ws.switch_tab(idx); + self.state.mode = Mode::Terminal; + } + Ok(idx) + } + fn create_workspace_with_options( &mut self, initial_cwd: std::path::PathBuf, @@ -1493,9 +1535,9 @@ impl App { )); }; Ok(ws - .layout - .pane_ids() - .into_iter() + .tabs + .iter() + .flat_map(|tab| tab.layout.pane_ids().into_iter()) .filter_map(|pane_id| self.pane_info(ws_idx, pane_id)) .collect()) } else { @@ -1505,9 +1547,9 @@ impl App { .iter() .enumerate() .flat_map(|(ws_idx, ws)| { - ws.layout - .pane_ids() - .into_iter() + ws.tabs + .iter() + .flat_map(|tab| tab.layout.pane_ids().into_iter()) .filter_map(move |pane_id| self.pane_info(ws_idx, pane_id)) }) .collect()) @@ -1520,12 +1562,16 @@ impl App { pane_id: crate::layout::PaneId, ) -> Option { let ws = self.state.workspaces.get(ws_idx)?; - let pane = ws.panes.get(&pane_id)?; - let runtime = ws.runtimes.get(&pane_id); + let pane = ws.pane_state(pane_id)?; + let runtime = ws.runtime(pane_id); + let focused = self.state.active == Some(ws_idx) + && ws + .focused_pane_id() + .is_some_and(|focused| focused == pane_id); Some(crate::api::schema::PaneInfo { pane_id: self.public_pane_id(ws_idx, pane_id)?, workspace_id: self.public_workspace_id(ws_idx), - focused: self.state.active == Some(ws_idx) && ws.layout.focused() == pane_id, + focused, cwd: runtime .and_then(|rt| rt.cwd()) .map(|cwd| cwd.display().to_string()), @@ -1541,7 +1587,7 @@ impl App { pane_id: crate::layout::PaneId, ) -> Option<(&crate::pane::PaneRuntime, String)> { let ws = self.state.workspaces.get(ws_idx)?; - let runtime = ws.runtimes.get(&pane_id)?; + let runtime = ws.runtime(pane_id)?; Some((runtime, self.public_workspace_id(ws_idx))) } @@ -1554,7 +1600,7 @@ impl App { &crate::pane::PaneRuntime, )> { let ws = self.state.workspaces.get(ws_idx)?; - let runtime = ws.runtimes.get(&pane_id)?; + let runtime = ws.runtime(pane_id)?; Some((&runtime.sender, runtime)) } @@ -1566,7 +1612,7 @@ impl App { number: index + 1, label: ws.display_name(), focused: self.state.active == Some(index), - pane_count: ws.panes.len(), + pane_count: ws.public_pane_numbers.len(), agent_state: pane_agent_state(agg_state), } } diff --git a/src/app/state.rs b/src/app/state.rs index 49c740ae..5770154d 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -324,6 +324,9 @@ impl Palette { /// Updated before each render, consumed by render and mouse handling. pub struct ViewState { pub sidebar_rect: Rect, + pub tab_bar_rect: Rect, + pub tab_hit_areas: Vec, + pub new_tab_hit_area: Rect, pub terminal_area: Rect, pub pane_infos: Vec, pub split_borders: Vec, @@ -335,7 +338,8 @@ pub enum Mode { ReleaseNotes, Navigate, Terminal, - RenameSession, + RenameWorkspace, + RenameTab, Resize, ConfirmClose, ContextMenu, @@ -414,6 +418,7 @@ pub(crate) struct DragState { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ContextMenuKind { Workspace { ws_idx: usize }, + Tab { ws_idx: usize, tab_idx: usize }, Pane, } @@ -429,6 +434,7 @@ impl ContextMenuState { pub fn items(&self) -> &'static [&'static str] { match self.kind { ContextMenuKind::Workspace { .. } => &["Rename", "Close"], + ContextMenuKind::Tab { .. } => &["New tab", "Rename", "Close"], ContextMenuKind::Pane => &[ "Split vertical", "Split horizontal", @@ -469,6 +475,7 @@ pub struct AppState { pub mode: Mode, pub should_quit: bool, pub request_new_workspace: bool, + pub request_new_tab: bool, pub request_complete_onboarding: bool, pub name_input: String, pub onboarding_step: usize, @@ -559,6 +566,7 @@ impl AppState { mode: Mode::Navigate, should_quit: false, request_new_workspace: false, + request_new_tab: false, request_complete_onboarding: false, name_input: String::new(), onboarding_step: 0, @@ -566,6 +574,9 @@ impl AppState { release_notes: None, view: ViewState { sidebar_rect: Rect::default(), + tab_bar_rect: Rect::default(), + tab_hit_areas: Vec::new(), + new_tab_hit_area: Rect::default(), terminal_area: Rect::default(), pane_infos: Vec::new(), split_borders: Vec::new(), @@ -597,6 +608,14 @@ impl AppState { rename_workspace_label: "shift+n".into(), close_workspace: (KeyCode::Char('d'), KeyModifiers::empty()), close_workspace_label: "d".into(), + previous_workspace: None, + next_workspace: None, + new_tab: (KeyCode::Char('c'), KeyModifiers::empty()), + new_tab_label: "c".into(), + rename_tab: None, + previous_tab: None, + next_tab: None, + close_tab: None, split_vertical: (KeyCode::Char('v'), KeyModifiers::empty()), split_vertical_label: "v".into(), split_horizontal: (KeyCode::Char('-'), KeyModifiers::empty()), diff --git a/src/config.rs b/src/config.rs index cfd76594..6f5d17ae 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,6 +2,8 @@ use std::path::PathBuf; use crossterm::event::{KeyCode, KeyModifiers}; use serde::Deserialize; + +pub const CONFIG_PATH_ENV_VAR: &str = "HERDR_CONFIG_PATH"; use tracing::warn; use crate::detect::Agent; @@ -80,6 +82,20 @@ pub struct KeysConfig { pub rename_workspace: String, /// Close the selected workspace. Default: "d" pub close_workspace: String, + /// Select the previous workspace. Unset by default. + pub previous_workspace: String, + /// Select the next workspace. Unset by default. + pub next_workspace: String, + /// Create a new tab in the active workspace. Default: "c" + pub new_tab: String, + /// Rename the active tab. Unset by default. + pub rename_tab: String, + /// Select the previous tab. Unset by default. + pub previous_tab: String, + /// Select the next tab. Unset by default. + pub next_tab: String, + /// Close the active tab. Unset by default. + pub close_tab: String, /// Split pane vertically (side by side). Default: "v" pub split_vertical: String, /// Split pane horizontally (stacked). Default: "-" @@ -184,6 +200,13 @@ impl Default for KeysConfig { new_workspace: "n".into(), rename_workspace: "shift+n".into(), close_workspace: "d".into(), + previous_workspace: "".into(), + next_workspace: "".into(), + new_tab: "c".into(), + rename_tab: "".into(), + previous_tab: "".into(), + next_tab: "".into(), + close_tab: "".into(), split_vertical: "v".into(), split_horizontal: "-".into(), close_pane: "x".into(), @@ -343,6 +366,27 @@ impl Config { } } + fn optional_binding( + field: &'static str, + configured_label: &str, + diagnostics: &mut Vec, + ) -> (Option<(KeyCode, KeyModifiers)>, String) { + if configured_label.trim().is_empty() { + return (None, String::new()); + } + let (value, diag) = parse_key_combo_with_diagnostic( + configured_label, + field, + (KeyCode::Null, KeyModifiers::empty()), + ); + if let Some(diag) = diag { + diagnostics.push(diag); + (None, String::new()) + } else { + (Some(value), configured_label.to_string()) + } + } + let mut bindings = vec![ binding( "keys.new_workspace", @@ -365,6 +409,13 @@ impl Config { (KeyCode::Char('d'), KeyModifiers::empty()), &mut diagnostics, ), + binding( + "keys.new_tab", + &self.keys.new_tab, + "c", + (KeyCode::Char('c'), KeyModifiers::empty()), + &mut diagnostics, + ), binding( "keys.split_vertical", &self.keys.split_vertical, @@ -409,6 +460,27 @@ impl Config { ), ]; + let optional_bindings = [ + optional_binding( + "keys.previous_workspace", + &self.keys.previous_workspace, + &mut diagnostics, + ), + optional_binding( + "keys.next_workspace", + &self.keys.next_workspace, + &mut diagnostics, + ), + optional_binding("keys.rename_tab", &self.keys.rename_tab, &mut diagnostics), + optional_binding( + "keys.previous_tab", + &self.keys.previous_tab, + &mut diagnostics, + ), + optional_binding("keys.next_tab", &self.keys.next_tab, &mut diagnostics), + optional_binding("keys.close_tab", &self.keys.close_tab, &mut diagnostics), + ]; + use std::collections::HashMap; let mut seen: HashMap<(KeyCode, KeyModifiers), &str> = HashMap::new(); for binding in &mut bindings { @@ -433,18 +505,26 @@ impl Config { rename_workspace_label: bindings[1].label.clone(), close_workspace: bindings[2].value, close_workspace_label: bindings[2].label.clone(), - split_vertical: bindings[3].value, - split_vertical_label: bindings[3].label.clone(), - split_horizontal: bindings[4].value, - split_horizontal_label: bindings[4].label.clone(), - close_pane: bindings[5].value, - close_pane_label: bindings[5].label.clone(), - fullscreen: bindings[6].value, - fullscreen_label: bindings[6].label.clone(), - resize_mode: bindings[7].value, - resize_mode_label: bindings[7].label.clone(), - toggle_sidebar: bindings[8].value, - toggle_sidebar_label: bindings[8].label.clone(), + previous_workspace: optional_bindings[0].0, + next_workspace: optional_bindings[1].0, + new_tab: bindings[3].value, + new_tab_label: bindings[3].label.clone(), + rename_tab: optional_bindings[2].0, + previous_tab: optional_bindings[3].0, + next_tab: optional_bindings[4].0, + close_tab: optional_bindings[5].0, + split_vertical: bindings[4].value, + split_vertical_label: bindings[4].label.clone(), + split_horizontal: bindings[5].value, + split_horizontal_label: bindings[5].label.clone(), + close_pane: bindings[6].value, + close_pane_label: bindings[6].label.clone(), + fullscreen: bindings[7].value, + fullscreen_label: bindings[7].label.clone(), + resize_mode: bindings[8].value, + resize_mode_label: bindings[8].label.clone(), + toggle_sidebar: bindings[9].value, + toggle_sidebar_label: bindings[9].label.clone(), }; (prefix_diag, prefix, diagnostics, keybinds) @@ -460,6 +540,14 @@ pub struct Keybinds { pub rename_workspace_label: String, pub close_workspace: (KeyCode, KeyModifiers), pub close_workspace_label: String, + pub previous_workspace: Option<(KeyCode, KeyModifiers)>, + pub next_workspace: Option<(KeyCode, KeyModifiers)>, + pub new_tab: (KeyCode, KeyModifiers), + pub new_tab_label: String, + pub rename_tab: Option<(KeyCode, KeyModifiers)>, + pub previous_tab: Option<(KeyCode, KeyModifiers)>, + pub next_tab: Option<(KeyCode, KeyModifiers)>, + pub close_tab: Option<(KeyCode, KeyModifiers)>, pub split_vertical: (KeyCode, KeyModifiers), pub split_vertical_label: String, pub split_horizontal: (KeyCode, KeyModifiers), @@ -554,6 +642,9 @@ pub fn save_onboarding_choices(sound_enabled: bool, toast_enabled: bool) -> std: } pub fn config_path() -> PathBuf { + if let Ok(path) = std::env::var(CONFIG_PATH_ENV_VAR) { + return PathBuf::from(path); + } 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") { diff --git a/src/main.rs b/src/main.rs index a23ec6c3..a5a43b39 100644 --- a/src/main.rs +++ b/src/main.rs @@ -107,6 +107,13 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # new_workspace = "n" # rename_workspace = "shift+n" # close_workspace = "d" +# previous_workspace = "" # optional, unset by default +# next_workspace = "" # optional, unset by default +# new_tab = "c" +# rename_tab = "" # optional, unset by default +# previous_tab = "" # optional, unset by default +# next_tab = "" # optional, unset by default +# close_tab = "" # optional, unset by default # split_vertical = "v" # split_horizontal = "-" # close_pane = "x" @@ -204,8 +211,9 @@ fn main() -> io::Result<()> { println!(" --show-changelog Preview the current version's release notes"); println!(" --help, -h Show this help"); println!(); - println!("Config: ~/.config/herdr/config.toml"); + println!("Config: {}", config::config_path().display()); println!("Logs: ~/.config/herdr/herdr.log"); + println!("Env: HERDR_CONFIG_PATH overrides config file path"); println!("Home: https://herdr.dev"); return Ok(()); } diff --git a/src/persist.rs b/src/persist.rs index 49a36141..3585228e 100644 --- a/src/persist.rs +++ b/src/persist.rs @@ -19,7 +19,7 @@ use crate::pane::{PaneRuntime, PaneState}; use crate::workspace::Workspace; /// Current snapshot format version. -const SNAPSHOT_VERSION: u32 = 2; +const SNAPSHOT_VERSION: u32 = 3; /// Serializable snapshot of the entire herdr session. #[derive(Serialize, Deserialize)] @@ -34,15 +34,23 @@ pub struct SessionSnapshot { #[derive(Serialize, Deserialize)] pub struct WorkspaceSnapshot { + #[serde(default)] + pub custom_name: Option, + pub identity_cwd: PathBuf, + pub tabs: Vec, + #[serde(default)] + pub active_tab: usize, +} + +#[derive(Serialize, Deserialize)] +pub struct TabSnapshot { #[serde(default)] pub custom_name: Option, pub layout: LayoutSnapshot, pub panes: HashMap, pub zoomed: bool, - /// Raw pane ID that was focused when saved. #[serde(default)] pub focused: Option, - /// Raw pane ID used as the workspace identity source. #[serde(default)] pub root_pane: Option, } @@ -87,22 +95,31 @@ pub fn capture( } fn capture_workspace(ws: &Workspace) -> WorkspaceSnapshot { + WorkspaceSnapshot { + custom_name: ws.custom_name.clone(), + identity_cwd: ws.identity_cwd.clone(), + tabs: ws.tabs.iter().map(capture_tab).collect(), + active_tab: ws.active_tab, + } +} + +fn capture_tab(tab: &crate::workspace::Tab) -> TabSnapshot { let mut panes = HashMap::new(); - for id in ws.panes.keys() { - let cwd = ws + for id in tab.panes.keys() { + let cwd = tab .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 { - custom_name: ws.custom_name.clone(), - layout: capture_node(ws.layout.root()), + TabSnapshot { + custom_name: tab.custom_name.clone(), + layout: capture_node(tab.layout.root()), panes, - zoomed: ws.zoomed, - focused: Some(ws.layout.focused().raw()), - root_pane: Some(ws.root_pane.raw()), + zoomed: tab.zoomed, + focused: Some(tab.layout.focused().raw()), + root_pane: Some(tab.root_pane.raw()), } } @@ -161,10 +178,53 @@ fn restore_workspace( render_notify: Arc, render_dirty: Arc, ) -> Option { + let mut tabs = Vec::new(); + let mut public_pane_numbers = HashMap::new(); + let mut next_public_pane_number = 1; + + for (idx, tab_snap) in snap.tabs.iter().enumerate() { + let tab = restore_tab( + tab_snap, + idx + 1, + rows, + cols, + events.clone(), + render_notify.clone(), + render_dirty.clone(), + )?; + for pane_id in tab.layout.pane_ids() { + public_pane_numbers.insert(pane_id, next_public_pane_number); + next_public_pane_number += 1; + } + tabs.push(tab); + } + + if tabs.is_empty() { + return None; + } + + Some(Workspace { + custom_name: snap.custom_name.clone(), + identity_cwd: snap.identity_cwd.clone(), + cached_git_ahead_behind: None, + public_pane_numbers, + next_public_pane_number, + active_tab: snap.active_tab.min(tabs.len().saturating_sub(1)), + tabs, + }) +} + +fn restore_tab( + snap: &TabSnapshot, + number: usize, + rows: u16, + cols: u16, + events: mpsc::Sender, + render_notify: Arc, + render_dirty: Arc, +) -> Option { let (node, id_map) = restore_node_remapped(&snap.layout); let pane_ids = collect_pane_ids(&node); - - // Restore focused pane: map saved raw ID to new ID, fall back to first pane let focus = snap .focused .and_then(|old_raw| id_map.get(&old_raw).copied()) @@ -198,7 +258,7 @@ fn restore_workspace( runtimes.insert(*id, runtime); } Err(e) => { - error!(workspace = ?snap.custom_name, err = %e, "failed to restore pane"); + error!(tab = ?snap.custom_name, err = %e, "failed to restore pane"); return None; } } @@ -210,20 +270,11 @@ fn restore_workspace( .or_else(|| pane_ids.first().copied()) .unwrap_or(PaneId::from_raw(0)); - let public_pane_numbers = layout - .pane_ids() - .into_iter() - .enumerate() - .map(|(index, pane_id)| (pane_id, index + 1)) - .collect(); - - Some(Workspace { + Some(crate::workspace::Tab { custom_name: snap.custom_name.clone(), + number, root_pane, layout, - cached_git_ahead_behind: None, - public_pane_numbers, - next_public_pane_number: panes.len() + 1, panes, runtimes, zoomed: snap.zoomed, @@ -421,16 +472,21 @@ mod tests { let snap = SessionSnapshot { workspaces: vec![WorkspaceSnapshot { custom_name: Some("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), - root_pane: Some(0), + identity_cwd: PathBuf::from("/home/can/Projects/herdr"), + tabs: vec![TabSnapshot { + custom_name: Some("api".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), + root_pane: Some(0), + }], + active_tab: 0, }], active: Some(0), selected: 0, @@ -445,9 +501,10 @@ mod tests { restored.workspaces[0].custom_name.as_deref(), Some("pi-mono") ); - assert_eq!(restored.workspaces[0].panes.len(), 2); + assert_eq!(restored.workspaces[0].tabs.len(), 1); + assert_eq!(restored.workspaces[0].tabs[0].panes.len(), 2); assert_eq!( - restored.workspaces[0].panes[&0].cwd, + restored.workspaces[0].tabs[0].panes[&0].cwd, PathBuf::from("/home/can/Projects/herdr") ); } @@ -494,9 +551,9 @@ mod tests { } #[test] - fn focused_pane_default_is_none() { - let json = r#"{"name":"test","layout":{"Pane":0},"panes":{},"zoomed":false}"#; + fn active_tab_default_is_zero() { + let json = r#"{"custom_name":"test","identity_cwd":"/tmp","tabs":[]}"#; let ws: WorkspaceSnapshot = serde_json::from_str(json).unwrap(); - assert_eq!(ws.focused, None); // #[serde(default)] + assert_eq!(ws.active_tab, 0); } } diff --git a/src/raw_input.rs b/src/raw_input.rs index 859f4c26..dd61ee5a 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -53,6 +53,11 @@ fn drain_buffer(buffer: &mut Vec, tx: &mpsc::Sender) { let Some((event, consumed)) = extract_one_event(buffer) else { break; }; + tracing::debug!( + raw_bytes = ?&buffer[..consumed], + event = ?event, + "raw input event parsed" + ); buffer.drain(..consumed); let _ = tx.blocking_send(event); } diff --git a/src/ui.rs b/src/ui.rs index 9e2db7c6..cbf8fdd2 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -42,20 +42,35 @@ pub fn compute_view(app: &mut AppState, area: Rect) { let [sidebar_area, main_area] = Layout::horizontal([Constraint::Length(sidebar_w), Constraint::Min(1)]).areas(area); - let terminal_area = main_area; + let has_tabs = app.active.and_then(|i| app.workspaces.get(i)).is_some(); + let (tab_bar_rect, terminal_area) = if has_tabs && main_area.height > 1 { + let [tab_bar_rect, terminal_area] = + Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(main_area); + (tab_bar_rect, terminal_area) + } else { + (Rect::default(), main_area) + }; + + let tab_hit_areas = app + .active + .and_then(|i| app.workspaces.get(i)) + .map(|ws| compute_tab_hit_areas(ws, tab_bar_rect)) + .unwrap_or_default(); + let new_tab_hit_area = compute_new_tab_hit_area(&tab_hit_areas, tab_bar_rect); - // 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, + tab_bar_rect, + tab_hit_areas, + new_tab_hit_area, terminal_area, pane_infos, split_borders, @@ -65,6 +80,7 @@ pub fn compute_view(app: &mut AppState, area: Rect) { /// 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 tab_bar_area = app.view.tab_bar_rect; let terminal_area = app.view.terminal_area; if app.sidebar_collapsed { @@ -72,6 +88,7 @@ pub fn render(app: &AppState, frame: &mut Frame) { } else { render_sidebar(app, frame, sidebar_area); } + render_tab_bar(app, frame, tab_bar_area); render_panes(app, frame, terminal_area); match app.mode { @@ -85,7 +102,7 @@ pub fn render(app: &AppState, frame: &mut Frame) { render_context_menu(app, frame); } Mode::Settings => render_settings_overlay(app, frame, frame.area()), - Mode::RenameSession => {} + Mode::RenameWorkspace | Mode::RenameTab => render_rename_overlay(app, frame, frame.area()), Mode::Terminal => {} } @@ -105,6 +122,87 @@ pub fn render(app: &AppState, frame: &mut Frame) { } } +const MIN_TAB_WIDTH: u16 = 8; +const NEW_TAB_WIDTH: u16 = 3; + +fn compute_tab_hit_areas(ws: &crate::workspace::Workspace, area: Rect) -> Vec { + if area.width == 0 || area.height == 0 { + return Vec::new(); + } + + let mut x = area.x; + let mut rects = Vec::new(); + let right = area.x + area.width; + for tab in &ws.tabs { + if x >= right.saturating_sub(NEW_TAB_WIDTH) { + break; + } + let desired = (tab.display_name().chars().count() as u16 + 4).max(MIN_TAB_WIDTH); + let remaining = right.saturating_sub(NEW_TAB_WIDTH).saturating_sub(x); + let width = desired.min(remaining).max(1); + rects.push(Rect::new(x, area.y, width, 1)); + x = x.saturating_add(width + 1); + } + rects +} + +fn compute_new_tab_hit_area(tab_hit_areas: &[Rect], area: Rect) -> Rect { + if area.width == 0 || area.height == 0 { + return Rect::default(); + } + let x = tab_hit_areas + .last() + .map(|rect| rect.x + rect.width + 1) + .unwrap_or(area.x) + .min(area.x + area.width.saturating_sub(NEW_TAB_WIDTH)); + Rect::new(x, area.y, NEW_TAB_WIDTH.min(area.width), 1) +} + +fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) { + if area.width == 0 || area.height == 0 { + return; + } + let Some(ws_idx) = app.active else { + return; + }; + let Some(ws) = app.workspaces.get(ws_idx) else { + return; + }; + + let p = &app.palette; + + frame.render_widget( + Paragraph::new(" ".repeat(area.width as usize)).style(Style::default().bg(p.panel_bg)), + area, + ); + + for (idx, tab) in ws.tabs.iter().enumerate() { + let Some(rect) = app.view.tab_hit_areas.get(idx).copied() else { + break; + }; + let active = idx == ws.active_tab; + let style = if active { + Style::default() + .fg(p.panel_bg) + .bg(p.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(p.overlay1).bg(p.surface0) + }; + let width = rect.width as usize; + let name = tab.display_name(); + let text = format!(" {:width$}", name, width = width.saturating_sub(1)); + frame.render_widget(Paragraph::new(text).style(style), rect); + } + + if app.view.new_tab_hit_area.width > 0 { + frame.render_widget( + Paragraph::new(" + ").style(Style::default().fg(p.overlay1)), + app.view.new_tab_hit_area, + ); + } +} + /// Compute pane layout info and resize pane runtimes to match. fn compute_pane_infos(app: &AppState, area: Rect) -> Vec { let Some(ws_idx) = app.active else { @@ -217,7 +315,7 @@ fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) { let is_navigating = matches!( app.mode, Mode::Navigate - | Mode::RenameSession + | Mode::RenameWorkspace | Mode::Resize | Mode::ConfirmClose | Mode::ContextMenu @@ -284,7 +382,7 @@ fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) { let is_navigating = matches!( app.mode, Mode::Navigate - | Mode::RenameSession + | Mode::RenameWorkspace | Mode::Resize | Mode::ConfirmClose | Mode::ContextMenu @@ -357,8 +455,7 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig let (agg_state, agg_seen) = ws.aggregate_state(); // Determine row height for background fill - let has_second_line = - ws.branch().is_some() || (app.mode == Mode::RenameSession && i == app.selected); + let has_second_line = ws.branch().is_some(); let row_height: u16 = if has_second_line { 2 } else { 1 }; // Background fill: selected gets brighter surface, active gets subtle surface @@ -419,17 +516,9 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig ); row_y += 1; - // Line 2: branch or rename input + // Line 2: branch if row_y < list_bottom { - if app.mode == Mode::RenameSession && i == app.selected { - let text = format!(" {}\u{2588}", app.name_input); - frame.render_widget(Clear, Rect::new(area.x, row_y, area.width, 1)); - frame.render_widget( - Paragraph::new(text).style(Style::default().fg(p.yellow)), - Rect::new(area.x, row_y, area.width, 1), - ); - row_y += 1; - } else if let Some(branch) = ws.branch() { + if let Some(branch) = ws.branch() { let upstream_label = ws.git_ahead_behind().and_then(|(ahead, behind)| { let mut parts = Vec::new(); if ahead > 0 { @@ -1314,11 +1403,13 @@ fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) { let kb = &app.keybinds; let line1 = Line::from(vec![ Span::styled(format!(" {}", kb.new_workspace_label), key), - Span::styled(" new ", dim), + Span::styled(" new ws ", dim), Span::styled(kb.rename_workspace_label.as_str(), key), - Span::styled(" rename ", dim), + Span::styled(" rename ws ", dim), Span::styled(kb.close_workspace_label.as_str(), key), Span::styled(" close ws ", dim), + Span::styled(kb.new_tab_label.as_str(), key), + Span::styled(" new tab ", dim), Span::styled(kb.split_vertical_label.as_str(), key), Span::styled(" split│ ", dim), Span::styled(kb.split_horizontal_label.as_str(), key), @@ -1435,6 +1526,90 @@ fn render_resize_overlay(app: &AppState, frame: &mut Frame, area: Rect) { } /// Centered popup confirmation dialog with dimmed background. +pub(crate) fn rename_button_rects(inner: Rect) -> (Rect, Rect, Rect) { + let save_w = 10u16; + let clear_w = 11u16; + let cancel_w = 12u16; + let gap = 2u16; + let total_w = save_w + gap + clear_w + gap + cancel_w; + let x = inner.x + inner.width.saturating_sub(total_w) / 2; + let y = inner.y + 3; + ( + Rect::new(x, y, save_w, 1), + Rect::new(x + save_w + gap, y, clear_w, 1), + Rect::new(x + save_w + gap + clear_w + gap, y, cancel_w, 1), + ) +} + +fn render_rename_overlay(app: &AppState, frame: &mut Frame, area: Rect) { + dim_background(frame, area); + + let title = match app.mode { + Mode::RenameWorkspace => "rename workspace", + Mode::RenameTab => "rename tab", + _ => return, + }; + + let Some(inner) = render_modal_shell(frame, area, 56, 7, &app.palette) else { + return; + }; + if inner.height < 4 { + return; + } + + let rows = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), + ]) + .areas::<5>(inner); + + render_modal_header(frame, rows[0], title, &app.palette); + + let input_rect = Rect::new(rows[2].x, rows[2].y, rows[2].width, 1); + frame.render_widget(Clear, input_rect); + frame.render_widget( + Paragraph::new(format!(" {}█", app.name_input)).style( + Style::default() + .fg(app.palette.text) + .bg(app.palette.surface0), + ), + input_rect, + ); + + let (save_rect, clear_rect, cancel_rect) = rename_button_rects(inner); + + frame.render_widget( + Paragraph::new(" ↵ save ").style( + Style::default() + .fg(app.palette.panel_bg) + .bg(app.palette.accent) + .add_modifier(Modifier::BOLD), + ), + save_rect, + ); + frame.render_widget( + Paragraph::new(" ^c clear ").style( + Style::default() + .fg(app.palette.text) + .bg(app.palette.surface0) + .add_modifier(Modifier::BOLD), + ), + clear_rect, + ); + frame.render_widget( + Paragraph::new(" esc cancel ").style( + Style::default() + .fg(app.palette.text) + .bg(app.palette.surface0) + .add_modifier(Modifier::BOLD), + ), + cancel_rect, + ); +} + fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, area: Rect) { let ws_name = app .workspaces diff --git a/src/workspace.rs b/src/workspace.rs index 39ee19a0..d10cdfe2 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::ops::{Deref, DerefMut}; use std::path::{Path, PathBuf}; use std::sync::atomic::AtomicBool; use std::sync::Arc; @@ -12,19 +13,12 @@ use crate::events::AppEvent; use crate::layout::{PaneId, TileLayout}; use crate::pane::{PaneRuntime, PaneState}; -/// A named workspace containing tiled terminal panes. -pub struct Workspace { - /// User-provided override. If set, auto-derived identity stops updating. +pub struct Tab { pub custom_name: Option, - /// Identity source for this workspace. + pub number: usize, + /// Identity source for this tab's pane tree. pub root_pane: PaneId, pub layout: TileLayout, - /// Cached ahead/behind counts for the root repo's current branch upstream. - pub(crate) cached_git_ahead_behind: Option<(usize, usize)>, - /// Stable-ish public pane numbers within this workspace. - /// New panes append at the end; closing a pane compacts higher numbers down. - pub public_pane_numbers: HashMap, - pub(crate) next_public_pane_number: usize, /// Pane state — always present, testable without PTYs. pub panes: HashMap, /// Pane runtimes — only present in production (empty in tests). @@ -35,7 +29,7 @@ pub struct Workspace { pub(crate) render_dirty: Arc, } -impl Drop for Workspace { +impl Drop for Tab { fn drop(&mut self) { let runtimes = std::mem::take(&mut self.runtimes); for (pane_id, runtime) in runtimes { @@ -44,8 +38,9 @@ impl Drop for Workspace { } } -impl Workspace { +impl Tab { pub fn new( + number: usize, initial_cwd: PathBuf, rows: u16, cols: u16, @@ -66,19 +61,14 @@ impl Workspace { let mut panes = HashMap::new(); panes.insert(root_id, PaneState::new()); - let mut public_pane_numbers = HashMap::new(); - public_pane_numbers.insert(root_id, 1); let mut runtimes = HashMap::new(); runtimes.insert(root_id, runtime); - info!(root_pane = root_id.raw(), "workspace created"); Ok(Self { custom_name: None, + number, root_pane: root_id, layout, - cached_git_ahead_behind: None, - public_pane_numbers, - next_public_pane_number: 2, panes, runtimes, zoomed: false, @@ -88,7 +78,16 @@ impl Workspace { }) } - /// Split the focused pane. Returns the new pane id. + pub fn display_name(&self) -> String { + self.custom_name + .clone() + .unwrap_or_else(|| self.number.to_string()) + } + + pub fn set_custom_name(&mut self, name: String) { + self.custom_name = Some(name); + } + pub fn split_focused( &mut self, direction: Direction, @@ -109,35 +108,22 @@ impl Workspace { self.render_dirty.clone(), )?; self.panes.insert(new_id, PaneState::new()); - self.public_pane_numbers - .insert(new_id, self.next_public_pane_number); - self.next_public_pane_number += 1; 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 { + pub fn close_focused(&mut self) -> Option<(PaneId, Option)> { let pane_id = self.layout.focused(); - self.close_pane(pane_id) + self.detach_pane(pane_id) } - /// Close a specific pane and terminate everything running inside it. - /// Returns None if it's the last pane and the whole workspace should close. - pub fn close_pane(&mut self, pane_id: PaneId) -> Option { - let (removed, runtime) = self.detach_pane(pane_id)?; - if let Some(runtime) = runtime { - runtime.shutdown(pane_id); - } - Some(removed) + pub fn close_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option)> { + self.detach_pane(pane_id) } - /// Remove a specific pane from this workspace without terminating its runtime. - /// Used when the pane process has already exited and we are only cleaning up state. - /// Returns None if it's the last pane and the whole workspace should close. - pub fn remove_pane(&mut self, pane_id: PaneId) -> Option { - self.detach_pane(pane_id).map(|(removed, _)| removed) + pub fn remove_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option)> { + self.detach_pane(pane_id) } fn detach_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option)> { @@ -156,14 +142,6 @@ impl Workspace { self.layout.focus_pane(prev_focus); } - if let Some(removed_number) = self.public_pane_numbers.remove(&pane_id) { - for number in self.public_pane_numbers.values_mut() { - if *number > removed_number { - *number -= 1; - } - } - self.next_public_pane_number = self.public_pane_numbers.len() + 1; - } self.panes.remove(&pane_id); let runtime = self.runtimes.remove(&pane_id); self.zoomed = false; @@ -180,52 +158,18 @@ impl Workspace { self.layout.pane_ids().into_iter().find(|id| *id != closing) } - pub fn public_pane_number(&self, pane_id: PaneId) -> Option { - self.public_pane_numbers.get(&pane_id).copied() - } - - /// Get the runtime for the focused pane. pub fn focused_runtime(&self) -> Option<&PaneRuntime> { self.runtimes.get(&self.layout.focused()) } - pub fn set_custom_name(&mut self, name: String) { - self.custom_name = Some(name); - } - - pub fn display_name(&self) -> String { - if let Some(name) = &self.custom_name { - return name.clone(); - } - - self.root_cwd() - .as_deref() - .map(derive_label_from_cwd) - .unwrap_or_else(|| "shell".to_string()) - } - - pub fn root_cwd(&self) -> Option { - self.runtimes.get(&self.root_pane).and_then(|rt| rt.cwd()) - } - - /// Aggregate workspace signal for sidebar triage. - /// Returns the most urgent pane's state + seen flag. - pub fn aggregate_state(&self) -> (AgentState, bool) { - self.panes - .values() - .map(|pane| (pane.state, pane.seen)) - .max_by_key(|(state, seen)| pane_attention_priority(*state, *seen)) - .unwrap_or((AgentState::Unknown, true)) - } - pub fn has_working_pane(&self) -> bool { self.panes .values() .any(|pane| pane.state == AgentState::Working) } - /// Per-pane (state, seen) in BSP tree order (left-to-right, top-to-bottom). #[cfg(test)] + #[allow(dead_code)] // retained for focused layout-order assertions in tests pub fn pane_states(&self) -> Vec<(AgentState, bool)> { self.layout .pane_ids() @@ -239,7 +183,6 @@ impl Workspace { .collect() } - /// Per-pane detail for the agent detail panel, in stable layout order. pub fn pane_details(&self) -> Vec { self.layout .pane_ids() @@ -261,28 +204,336 @@ impl Workspace { }) .collect() } +} - /// Get the git branch for this workspace's root cwd. - pub fn branch(&self) -> Option { - self.root_cwd().and_then(|cwd| git_branch(&cwd)) +/// A named workspace containing tabs. +pub struct Workspace { + /// User-provided override. If set, auto-derived identity stops updating. + pub custom_name: Option, + /// Fixed workspace identity source, seeded when the workspace is created. + pub identity_cwd: PathBuf, + /// Cached ahead/behind counts for the workspace repo's current branch upstream. + pub(crate) cached_git_ahead_behind: Option<(usize, usize)>, + /// Stable-ish public pane numbers within this workspace. + /// New panes append at the end; closing a pane compacts higher numbers down. + pub public_pane_numbers: HashMap, + pub(crate) next_public_pane_number: usize, + pub tabs: Vec, + pub active_tab: usize, +} + +impl Deref for Workspace { + type Target = Tab; + + fn deref(&self) -> &Self::Target { + self.active_tab() + .expect("workspace must always have at least one active tab") + } +} + +impl DerefMut for Workspace { + fn deref_mut(&mut self) -> &mut Self::Target { + self.active_tab_mut() + .expect("workspace must always have at least one active tab") + } +} + +impl Workspace { + pub fn new( + initial_cwd: PathBuf, + rows: u16, + cols: u16, + events: mpsc::Sender, + render_notify: Arc, + render_dirty: Arc, + ) -> std::io::Result { + let tab = Tab::new( + 1, + initial_cwd.clone(), + rows, + cols, + events, + render_notify, + render_dirty, + )?; + let mut public_pane_numbers = HashMap::new(); + public_pane_numbers.insert(tab.root_pane, 1); + info!(root_pane = tab.root_pane.raw(), "workspace created"); + Ok(Self { + custom_name: None, + identity_cwd: initial_cwd, + cached_git_ahead_behind: None, + public_pane_numbers, + next_public_pane_number: 2, + tabs: vec![tab], + active_tab: 0, + }) + } + + pub fn active_tab(&self) -> Option<&Tab> { + self.tabs.get(self.active_tab) + } + + pub fn active_tab_mut(&mut self) -> Option<&mut Tab> { + self.tabs.get_mut(self.active_tab) + } + + pub fn active_tab_display_name(&self) -> Option { + self.active_tab().map(Tab::display_name) + } + + pub fn switch_tab(&mut self, idx: usize) { + if idx < self.tabs.len() { + self.active_tab = idx; + if let Some(tab) = self.tabs.get_mut(idx) { + for pane in tab.panes.values_mut() { + pane.seen = true; + } + } + } + } + + pub fn create_tab(&mut self, rows: u16, cols: u16, cwd: PathBuf) -> std::io::Result { + let number = self.tabs.len() + 1; + let events = self + .active_tab() + .map(|tab| tab.events.clone()) + .expect("workspace must always have at least one tab"); + let render_notify = self + .active_tab() + .map(|tab| tab.render_notify.clone()) + .expect("workspace must always have at least one tab"); + let render_dirty = self + .active_tab() + .map(|tab| tab.render_dirty.clone()) + .expect("workspace must always have at least one tab"); + + let tab = Tab::new(number, cwd, rows, cols, events, render_notify, render_dirty)?; + self.register_new_pane(tab.root_pane); + self.tabs.push(tab); + self.active_tab = self.tabs.len() - 1; + Ok(self.active_tab) + } + + pub fn close_tab(&mut self, idx: usize) -> bool { + if self.tabs.len() <= 1 || idx >= self.tabs.len() { + return false; + } + let tab = self.tabs.remove(idx); + for pane_id in tab.panes.keys() { + self.unregister_pane(*pane_id); + } + self.renumber_tabs(); + if self.active_tab >= self.tabs.len() { + self.active_tab = self.tabs.len() - 1; + } else if idx <= self.active_tab && self.active_tab > 0 { + self.active_tab -= 1; + } + true + } + + pub fn close_active_tab(&mut self) -> bool { + self.close_tab(self.active_tab) + } + + pub fn split_focused( + &mut self, + direction: Direction, + rows: u16, + cols: u16, + cwd: Option, + ) -> std::io::Result { + let new_id = self + .active_tab_mut() + .expect("workspace must always have at least one tab") + .split_focused(direction, rows, cols, cwd)?; + self.register_new_pane(new_id); + Ok(new_id) + } + + /// Close the focused pane. Returns true if the workspace should close. + pub fn close_focused(&mut self) -> bool { + let pane_count = self + .active_tab() + .map(|tab| tab.layout.pane_count()) + .unwrap_or(0); + let tab_count = self.tabs.len(); + if pane_count <= 1 { + return tab_count <= 1 || self.close_active_tab_and_report(); + } + + if let Some((removed, runtime)) = self.active_tab_mut().and_then(Tab::close_focused) { + self.unregister_pane(removed); + if let Some(runtime) = runtime { + runtime.shutdown(removed); + } + } + false + } + + /// Remove a specific pane from this workspace without terminating its runtime. + /// Returns true if the workspace should close. + pub fn remove_pane(&mut self, pane_id: PaneId) -> bool { + let Some(tab_idx) = self.find_tab_index_for_pane(pane_id) else { + return false; + }; + let pane_count = self.tabs[tab_idx].layout.pane_count(); + let tab_count = self.tabs.len(); + if pane_count <= 1 { + if tab_count <= 1 { + return true; + } + self.tabs.remove(tab_idx); + self.unregister_pane(pane_id); + self.renumber_tabs(); + if self.active_tab >= self.tabs.len() { + self.active_tab = self.tabs.len() - 1; + } else if tab_idx <= self.active_tab && self.active_tab > 0 { + self.active_tab -= 1; + } + return false; + } + + if let Some((removed, _)) = self.tabs[tab_idx].remove_pane(pane_id) { + self.unregister_pane(removed); + } + false + } + + pub fn public_pane_number(&self, pane_id: PaneId) -> Option { + self.public_pane_numbers.get(&pane_id).copied() + } + + pub fn set_custom_name(&mut self, name: String) { + self.custom_name = Some(name); + } + + pub fn display_name(&self) -> String { + if let Some(name) = &self.custom_name { + return name.clone(); + } + + derive_label_from_cwd(&self.identity_cwd) + } + + pub fn branch(&self) -> Option { + git_branch(&self.identity_cwd) } - /// Cached ahead/behind counts for this workspace's current branch upstream. pub fn git_ahead_behind(&self) -> Option<(usize, usize)> { self.cached_git_ahead_behind } - /// Refresh cached ahead/behind counts from the workspace's current cwd. pub fn refresh_git_ahead_behind(&mut self) { - self.cached_git_ahead_behind = self.root_cwd().and_then(|cwd| git_ahead_behind(&cwd)); + self.cached_git_ahead_behind = git_ahead_behind(&self.identity_cwd); + } + + pub fn aggregate_state(&self) -> (AgentState, bool) { + self.tabs + .iter() + .flat_map(|tab| tab.panes.values()) + .map(|pane| (pane.state, pane.seen)) + .max_by_key(|(state, seen)| pane_attention_priority(*state, *seen)) + .unwrap_or((AgentState::Unknown, true)) + } + + pub fn has_working_pane(&self) -> bool { + self.tabs.iter().any(Tab::has_working_pane) + } + + pub fn pane_details(&self) -> Vec { + self.active_tab().map(Tab::pane_details).unwrap_or_default() + } + + pub fn focused_runtime(&self) -> Option<&PaneRuntime> { + self.active_tab().and_then(Tab::focused_runtime) + } + + pub fn find_tab_index_for_pane(&self, pane_id: PaneId) -> Option { + self.tabs + .iter() + .position(|tab| tab.panes.contains_key(&pane_id)) + } + + pub fn pane_state(&self, pane_id: PaneId) -> Option<&PaneState> { + self.tabs.iter().find_map(|tab| tab.panes.get(&pane_id)) + } + + pub fn runtime(&self, pane_id: PaneId) -> Option<&PaneRuntime> { + self.tabs.iter().find_map(|tab| tab.runtimes.get(&pane_id)) + } + + pub fn focused_pane_id(&self) -> Option { + self.active_tab().map(|tab| tab.layout.focused()) + } + + pub fn close_pane(&mut self, pane_id: PaneId) -> bool { + let tab_idx = match self.find_tab_index_for_pane(pane_id) { + Some(idx) => idx, + None => return false, + }; + let pane_count = self.tabs[tab_idx].layout.pane_count(); + let tab_count = self.tabs.len(); + if pane_count <= 1 { + if tab_count <= 1 { + return true; + } + self.tabs.remove(tab_idx); + self.unregister_pane(pane_id); + self.renumber_tabs(); + if self.active_tab >= self.tabs.len() { + self.active_tab = self.tabs.len() - 1; + } else if tab_idx <= self.active_tab && self.active_tab > 0 { + self.active_tab -= 1; + } + return false; + } + + if let Some((removed, runtime)) = self.tabs[tab_idx].close_pane(pane_id) { + self.unregister_pane(removed); + if let Some(runtime) = runtime { + runtime.shutdown(removed); + } + } + false + } + + fn register_new_pane(&mut self, pane_id: PaneId) { + self.public_pane_numbers + .insert(pane_id, self.next_public_pane_number); + self.next_public_pane_number += 1; + } + + fn unregister_pane(&mut self, pane_id: PaneId) { + if let Some(removed_number) = self.public_pane_numbers.remove(&pane_id) { + for number in self.public_pane_numbers.values_mut() { + if *number > removed_number { + *number -= 1; + } + } + self.next_public_pane_number = self.public_pane_numbers.len() + 1; + } + } + + fn renumber_tabs(&mut self) { + for (idx, tab) in self.tabs.iter_mut().enumerate() { + tab.number = idx + 1; + } + } + + fn close_active_tab_and_report(&mut self) -> bool { + if self.tabs.len() <= 1 { + return true; + } + self.close_active_tab(); + false } } /// Detail info for a single pane, used by the agent detail panel. pub struct PaneDetail { pub label: String, - /// The detected agent, if any. Will be used for context extraction. - #[allow(dead_code)] // used later for triage line extraction + #[allow(dead_code)] pub agent: Option, pub state: AgentState, pub seen: bool, @@ -291,7 +542,7 @@ pub struct PaneDetail { fn pane_attention_priority(state: AgentState, seen: bool) -> u8 { match (state, seen) { (AgentState::Blocked, _) => 4, - (AgentState::Idle, false) => 3, // done, waiting for you to look + (AgentState::Idle, false) => 3, (AgentState::Working, _) => 2, (AgentState::Idle, true) => 1, (AgentState::Unknown, _) => 0, @@ -314,7 +565,7 @@ fn agent_name(agent: Agent) -> &'static str { } } -fn derive_label_from_cwd(cwd: &Path) -> String { +pub fn derive_label_from_cwd(cwd: &Path) -> String { if let Some(repo_root) = git_repo_root(cwd) { if let Some(name) = repo_root.file_name().and_then(|n| n.to_str()) { return name.to_string(); @@ -335,8 +586,6 @@ fn derive_label_from_cwd(cwd: &Path) -> String { .unwrap_or_else(|| cwd.display().to_string()) } -/// Read the current git branch name from .git/HEAD. -/// Returns None if not in a git repo or HEAD is detached. pub fn git_branch(cwd: &Path) -> Option { let repo_root = git_repo_root(cwd)?; let head_path = repo_root.join(".git").join("HEAD"); @@ -364,7 +613,6 @@ fn git_repo_root(start: &Path) -> Option { } } -/// Read ahead/behind counts relative to the current branch upstream. fn git_ahead_behind(cwd: &Path) -> Option<(usize, usize)> { git_repo_root(cwd)?; @@ -390,45 +638,46 @@ fn parse_git_ahead_behind_output(stdout: &str) -> Option<(usize, usize)> { Some((ahead, behind)) } -// --------------------------------------------------------------------------- -// 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 render_notify = Arc::new(Notify::new()); let render_dirty = Arc::new(AtomicBool::new(false)); + let identity_cwd = std::env::current_dir().unwrap_or_else(|_| "/".into()); + let (layout, root_id) = TileLayout::new(); let mut panes = HashMap::new(); panes.insert(root_id, PaneState::new()); - let mut public_pane_numbers = HashMap::new(); - public_pane_numbers.insert(root_id, 1); - Self { - custom_name: Some(name.to_string()), + let tab = Tab { + custom_name: None, + number: 1, root_pane: root_id, layout, - cached_git_ahead_behind: None, - public_pane_numbers, - next_public_pane_number: 2, panes, runtimes: HashMap::new(), zoomed: false, events, render_notify, render_dirty, + }; + let mut public_pane_numbers = HashMap::new(); + public_pane_numbers.insert(tab.root_pane, 1); + Self { + custom_name: Some(name.to_string()), + identity_cwd, + cached_git_ahead_behind: None, + public_pane_numbers, + next_public_pane_number: 2, + tabs: vec![tab], + active_tab: 0, } } - /// 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()); - self.public_pane_numbers - .insert(new_id, self.next_public_pane_number); - self.next_public_pane_number += 1; + let tab = self.active_tab_mut().expect("workspace must have tab"); + let new_id = tab.layout.split_focused(direction); + tab.panes.insert(new_id, PaneState::new()); + self.register_new_pane(new_id); new_id } } @@ -450,10 +699,14 @@ mod tests { fn aggregate_state_priority() { let mut ws = Workspace::test_new("test"); let id2 = ws.test_split(Direction::Horizontal); - - 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::Working; + let root_id = ws.tabs[0] + .panes + .keys() + .find(|id| **id != id2) + .copied() + .unwrap(); + ws.tabs[0].panes.get_mut(&root_id).unwrap().state = AgentState::Idle; + ws.tabs[0].panes.get_mut(&id2).unwrap().state = AgentState::Working; let (state, seen) = ws.aggregate_state(); assert_eq!(state, AgentState::Working); @@ -464,104 +717,19 @@ mod tests { fn aggregate_state_done_unseen_beats_working() { let mut ws = Workspace::test_new("test"); let id2 = ws.test_split(Direction::Horizontal); - - let root_id = *ws.panes.keys().find(|id| **id != id2).unwrap(); - let root = ws.panes.get_mut(&root_id).unwrap(); + let root_id = ws.tabs[0] + .panes + .keys() + .find(|id| **id != id2) + .copied() + .unwrap(); + let root = ws.tabs[0].panes.get_mut(&root_id).unwrap(); root.state = AgentState::Idle; root.seen = false; - ws.panes.get_mut(&id2).unwrap().state = AgentState::Working; + ws.tabs[0].panes.get_mut(&id2).unwrap().state = AgentState::Working; let (state, seen) = ws.aggregate_state(); assert_eq!(state, AgentState::Idle); assert!(!seen); } - - #[test] - fn aggregate_state_blocked_beats_done_unseen() { - let mut ws = Workspace::test_new("test"); - let id2 = ws.test_split(Direction::Horizontal); - - let root_id = *ws.panes.keys().find(|id| **id != id2).unwrap(); - let root = ws.panes.get_mut(&root_id).unwrap(); - root.state = AgentState::Idle; - root.seen = false; - ws.panes.get_mut(&id2).unwrap().state = AgentState::Blocked; - - let (state, seen) = ws.aggregate_state(); - assert_eq!(state, AgentState::Blocked); - 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); - - ws.panes.get_mut(&id2).unwrap().state = AgentState::Blocked; - - let states = ws.pane_states(); - assert_eq!(states.len(), 2); - assert_eq!(states[0].0, AgentState::Unknown); - assert_eq!(states[1].0, AgentState::Blocked); - } - - #[test] - fn pane_details_stay_in_layout_order() { - let mut ws = Workspace::test_new("test"); - let id2 = ws.test_split(Direction::Horizontal); - - let root_id = *ws.panes.keys().find(|id| **id != id2).unwrap(); - ws.panes.get_mut(&root_id).unwrap().detected_agent = Some(Agent::Pi); - ws.panes.get_mut(&root_id).unwrap().state = AgentState::Working; - ws.panes.get_mut(&id2).unwrap().detected_agent = Some(Agent::Claude); - ws.panes.get_mut(&id2).unwrap().state = AgentState::Blocked; - - let details = ws.pane_details(); - assert_eq!(details.len(), 2); - assert_eq!(details[0].label, "pi"); - assert_eq!(details[0].state, AgentState::Working); - assert_eq!(details[1].label, "claude"); - assert_eq!(details[1].state, AgentState::Blocked); - } - - #[test] - fn closing_root_promotes_another_pane() { - let mut ws = Workspace::test_new("test"); - let root = ws.root_pane; - let other = ws.test_split(Direction::Horizontal); - ws.layout.focus_pane(root); - ws.remove_pane(root); - assert_eq!(ws.root_pane, other); - } - - #[test] - fn parse_git_ahead_behind_output_maps_first_field_to_ahead() { - assert_eq!(parse_git_ahead_behind_output("7\t0\n"), Some((7, 0))); - } - - #[test] - fn parse_git_ahead_behind_output_maps_second_field_to_behind() { - assert_eq!(parse_git_ahead_behind_output("0 3\n"), Some((0, 3))); - } } diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs index e371eca4..37c4406e 100644 --- a/tests/cli_wrapper.rs +++ b/tests/cli_wrapper.rs @@ -132,7 +132,7 @@ fn workspace_and_pane_management_commands_work() { let runtime_dir = base.join("runtime"); let socket_path = runtime_dir.join("herdr.sock"); - let mut herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); wait_for_socket(&socket_path, Duration::from_secs(5)); let listed = run_cli(&socket_path, &["workspace", "list"]); @@ -206,7 +206,7 @@ fn pane_run_read_and_wait_commands_work() { let runtime_dir = base.join("runtime"); let socket_path = runtime_dir.join("herdr.sock"); - let mut herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); wait_for_socket(&socket_path, Duration::from_secs(5)); let created = send_request( @@ -272,7 +272,7 @@ fn closing_pane_terminates_processes_inside_it() { let runtime_dir = base.join("runtime"); let socket_path = runtime_dir.join("herdr.sock"); - let mut herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); wait_for_socket(&socket_path, Duration::from_secs(5)); let created = run_cli( @@ -335,7 +335,7 @@ fn closing_workspace_terminates_processes_inside_it() { let runtime_dir = base.join("runtime"); let socket_path = runtime_dir.join("herdr.sock"); - let mut herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); wait_for_socket(&socket_path, Duration::from_secs(5)); let created = run_cli( @@ -390,7 +390,7 @@ fn ids_are_compact_and_positional() { let runtime_dir = base.join("runtime"); let socket_path = runtime_dir.join("herdr.sock"); - let mut herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); wait_for_socket(&socket_path, Duration::from_secs(5)); let ws1 = run_cli( @@ -577,7 +577,7 @@ fn wait_agent_state_exits_when_state_matches() { ), ); let child = pair.slave.spawn_command(cmd).unwrap(); - let mut herdr = SpawnedHerdr { + let herdr = SpawnedHerdr { _master: pair.master, child, };