diff --git a/src/app/input.rs b/src/app/input.rs index d8ac86c3..3b63f28f 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -5,7 +5,7 @@ use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKin use crate::input::TerminalKey; use ratatui::layout::{Direction, Rect}; -use tracing::warn; +use tracing::{debug, warn}; use crate::layout::{NavDirection, PaneInfo, SplitBorder}; use crate::selection::Selection; @@ -33,6 +33,10 @@ use super::App; // Key handling // --------------------------------------------------------------------------- +fn is_modifier_only_key(code: &KeyCode) -> bool { + matches!(code, KeyCode::Modifier(_)) +} + fn terminal_direct_navigation_action(state: &AppState, key: &KeyEvent) -> Option { let kb = &state.keybinds; if kb @@ -344,6 +348,13 @@ impl App { let key_event = key.as_key_event(); if let Some(action) = terminal_direct_navigation_action(&self.state, &key_event) { + debug!( + code = ?key_event.code, + modifiers = ?key_event.modifiers, + kind = ?key_event.kind, + action = ?action, + "intercepted terminal direct navigation key before forwarding to pane" + ); execute_navigate_action(&mut self.state, action); return; } @@ -353,16 +364,36 @@ impl App { return; } + if is_modifier_only_key(&key_event.code) { + debug!( + code = ?key_event.code, + modifiers = ?key_event.modifiers, + kind = ?key_event.kind, + "dropping modifier-only terminal key event instead of forwarding it to pane" + ); + 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 flags = rt .kitty_keyboard_flags .load(std::sync::atomic::Ordering::Relaxed); - let bytes = crate::input::encode_terminal_key( - key, - crate::input::KeyboardProtocol::from_kitty_flags(flags), - ); + let protocol = crate::input::KeyboardProtocol::from_kitty_flags(flags); + let bytes = crate::input::encode_terminal_key(key, protocol); + if matches!(key_event.code, KeyCode::Esc) + || key_event.modifiers.contains(crossterm::event::KeyModifiers::ALT) + { + debug!( + code = ?key_event.code, + modifiers = ?key_event.modifiers, + kind = ?key_event.kind, + protocol = ?protocol, + encoded = ?bytes, + "forwarding potentially-ambiguous terminal key to pane" + ); + } if bytes.is_empty() { if key.kind != crossterm::event::KeyEventKind::Release && !matches!( @@ -1831,6 +1862,16 @@ impl AppState { self.mode = Mode::Terminal; return None; } + + if let Some((ws_idx, tab_idx, pane_id)) = self.agent_detail_target_at(mouse.row) { + self.switch_workspace(ws_idx); + if let Some(ws) = self.workspaces.get_mut(ws_idx) { + ws.switch_tab(tab_idx); + ws.layout.focus_pane(pane_id); + } + self.mode = Mode::Terminal; + return None; + } } else if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { let (row, col) = ( mouse.row - info.inner_rect.y, @@ -2062,6 +2103,59 @@ impl AppState { None } + fn agent_detail_target_at( + &self, + row: u16, + ) -> Option<(usize, usize, crate::layout::PaneId)> { + if self.sidebar_collapsed { + return None; + } + + let content = Rect::new( + self.view.sidebar_rect.x, + self.view.sidebar_rect.y, + self.view.sidebar_rect.width.saturating_sub(1), + self.view.sidebar_rect.height, + ); + if content.width == 0 || content.height == 0 { + return None; + } + + let total_h = content.height as usize; + let ws_h = (total_h + 1) / 2; + let detail_area = Rect::new( + content.x, + content.y + ws_h as u16, + content.width, + total_h.saturating_sub(ws_h) as u16, + ); + if detail_area.height < 4 || row < detail_area.y + 3 || row >= detail_area.y + detail_area.height { + return None; + } + + let detail_ws_idx = if matches!( + self.mode, + Mode::Navigate + | Mode::RenameWorkspace + | Mode::Resize + | Mode::ConfirmClose + | Mode::ContextMenu + | Mode::Settings + | Mode::GlobalMenu + | Mode::KeybindHelp + ) { + self.selected + } else { + self.active? + }; + + let ws = self.workspaces.get(detail_ws_idx)?; + let detail_idx = (row - (detail_area.y + 3)) as usize; + let details = ws.pane_details(); + let detail = details.get(detail_idx)?; + Some((detail_ws_idx, detail.tab_idx, detail.pane_id)) + } + fn screen_rect(&self) -> Rect { let sidebar = self.view.sidebar_rect; let terminal = self.view.terminal_area; @@ -2884,6 +2978,25 @@ mod tests { assert_eq!(app.state.workspaces[0].display_name(), "a"); } + #[test] + fn clicking_agent_detail_row_switches_to_correct_tab_and_pane() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + ws.tabs[0].set_custom_name("main".into()); + let second_tab = ws.test_add_tab(Some("logs")); + let second_pane = ws.tabs[second_tab].root_pane; + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 14)); + + assert_eq!(app.state.workspaces[0].active_tab, 1); + assert_eq!(app.state.workspaces[0].tabs[1].layout.focused(), second_pane); + assert_eq!(app.state.mode, Mode::Terminal); + } + #[test] fn dragging_sidebar_divider_sets_manual_width() { let mut app = app_for_mouse_test(); diff --git a/src/raw_input.rs b/src/raw_input.rs index dd61ee5a..77aaecdb 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -76,6 +76,10 @@ fn flush_incomplete_buffer(buffer: &mut Vec, tx: &mpsc::Sender u16 { .sidebar_width .clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH); } - let max_line = app + let max_workspace_line = app .workspaces .iter() .enumerate() @@ -309,7 +309,14 @@ fn compute_sidebar_width(app: &AppState) -> u16 { }) .max() .unwrap_or(12); - ((max_line as u16) + 2).clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH) + let max_agent_line = app + .workspaces + .iter() + .flat_map(|ws| ws.pane_details().into_iter()) + .map(|detail| 3 + detail.label.len() + 1 + state_label(detail.state, detail.seen).len()) + .max() + .unwrap_or(0); + ((max_workspace_line.max(max_agent_line) as u16) + 2).clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH) } /// Collapsed sidebar: pure glance mode. diff --git a/src/workspace.rs b/src/workspace.rs index d10cdfe2..2de109d9 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -192,11 +192,15 @@ impl Tab { let agent = pane.and_then(|p| p.detected_agent); let state = pane.map(|p| p.state).unwrap_or(AgentState::Unknown); let seen = pane.map(|p| p.seen).unwrap_or(true); - let label = agent + let agent_label = agent .map(|a| agent_name(a).to_string()) .unwrap_or_else(|| "shell".to_string()); PaneDetail { - label, + pane_id: *id, + tab_idx: self.number.saturating_sub(1), + tab_label: self.display_name(), + label: agent_label.clone(), + agent_label, agent, state, seen, @@ -442,7 +446,17 @@ impl Workspace { } pub fn pane_details(&self) -> Vec { - self.active_tab().map(Tab::pane_details).unwrap_or_default() + let multi_tab = self.tabs.len() > 1; + self.tabs + .iter() + .flat_map(Tab::pane_details) + .map(|mut detail| { + if multi_tab { + detail.label = format!("{} / {}", detail.tab_label, detail.agent_label); + } + detail + }) + .collect() } pub fn focused_runtime(&self) -> Option<&PaneRuntime> { @@ -532,7 +546,11 @@ impl Workspace { /// Detail info for a single pane, used by the agent detail panel. pub struct PaneDetail { + pub pane_id: PaneId, + pub tab_idx: usize, + pub tab_label: String, pub label: String, + pub agent_label: String, #[allow(dead_code)] pub agent: Option, pub state: AgentState, @@ -680,12 +698,36 @@ impl Workspace { self.register_new_pane(new_id); new_id } + + pub fn test_add_tab(&mut self, name: Option<&str>) -> usize { + let (events, _) = mpsc::channel(64); + let render_notify = Arc::new(Notify::new()); + let render_dirty = Arc::new(AtomicBool::new(false)); + let (layout, root_id) = TileLayout::new(); + let mut panes = HashMap::new(); + panes.insert(root_id, PaneState::new()); + let tab = Tab { + custom_name: name.map(str::to_string), + number: self.tabs.len() + 1, + root_pane: root_id, + layout, + panes, + runtimes: HashMap::new(), + zoomed: false, + events, + render_notify, + render_dirty, + }; + self.register_new_pane(root_id); + self.tabs.push(tab); + self.tabs.len() - 1 + } } #[cfg(test)] mod tests { use super::*; - use crate::detect::AgentState; + use crate::detect::{Agent, AgentState}; #[test] fn aggregate_state_all_unknown() { @@ -732,4 +774,29 @@ mod tests { assert_eq!(state, AgentState::Idle); assert!(!seen); } + + #[test] + fn pane_details_include_tab_context_when_workspace_has_multiple_tabs() { + let mut ws = Workspace::test_new("test"); + ws.tabs[0].set_custom_name("main".into()); + let root_pane = ws.tabs[0].root_pane; + ws.tabs[0] + .panes + .get_mut(&root_pane) + .unwrap() + .detected_agent = Some(Agent::Pi); + + let tab_idx = ws.test_add_tab(Some("logs")); + let second_root_pane = ws.tabs[tab_idx].root_pane; + ws.tabs[tab_idx] + .panes + .get_mut(&second_root_pane) + .unwrap() + .detected_agent = Some(Agent::Claude); + + let details = ws.pane_details(); + assert_eq!(details.len(), 2); + assert!(details.iter().any(|detail| detail.label == "main / pi")); + assert!(details.iter().any(|detail| detail.label == "logs / claude")); + } }