From f136a4e0d63b7ab5cc8f60135355b20751a2b711 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 05:53:55 -0700 Subject: [PATCH] feat: add ecc2 direct pane focus shortcuts --- ecc2/src/tui/app.rs | 8 ++ ecc2/src/tui/dashboard.rs | 168 +++++++++++++++++++++++++++++++++++++- 2 files changed, 175 insertions(+), 1 deletion(-) diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index d3daedfb..179a8d95 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -47,7 +47,15 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { match (key.modifiers, key.code) { (KeyModifiers::CONTROL, KeyCode::Char('c')) => break, + (KeyModifiers::CONTROL, KeyCode::Char('h')) => dashboard.focus_pane_left(), + (KeyModifiers::CONTROL, KeyCode::Char('j')) => dashboard.focus_pane_down(), + (KeyModifiers::CONTROL, KeyCode::Char('k')) => dashboard.focus_pane_up(), + (KeyModifiers::CONTROL, KeyCode::Char('l')) => dashboard.focus_pane_right(), (_, KeyCode::Char('q')) => break, + (_, KeyCode::Char('1')) => dashboard.focus_pane_number(1), + (_, KeyCode::Char('2')) => dashboard.focus_pane_number(2), + (_, KeyCode::Char('3')) => dashboard.focus_pane_number(3), + (_, KeyCode::Char('4')) => dashboard.focus_pane_number(4), (_, KeyCode::Tab) => dashboard.next_pane(), (KeyModifiers::SHIFT, KeyCode::BackTab) => dashboard.prev_pane(), (_, KeyCode::Char('+')) | (_, KeyCode::Char('=')) => { diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index f339d57e..8c726340 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -168,6 +168,14 @@ enum SearchAgentFilter { SelectedAgentType, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PaneDirection { + Left, + Right, + Up, + Down, +} + #[derive(Debug, Clone, PartialEq, Eq)] struct SearchMatch { session_id: String, @@ -871,7 +879,7 @@ impl Dashboard { fn render_status_bar(&self, frame: &mut Frame, area: Rect) { let base_text = format!( - " [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox approval jump [I] [g]lobal dispatch coordinate [G]lobal collapse pane [h] restore panes [H] timeline [y] timeline filter [E] [v]iew diff conflict proto[c]ol cont[e]nt filter time [f]ilter scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] auto-merge [w] toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [Tab] switch pane [j/k] scroll delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", + " [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox approval jump [I] [g]lobal dispatch coordinate [G]lobal collapse pane [h] restore panes [H] timeline [y] timeline filter [E] [v]iew diff conflict proto[c]ol cont[e]nt filter time [f]ilter scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] auto-merge [w] toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [1-4] focus pane [Tab] cycle pane [Ctrl+h/j/k/l] move pane [j/k] scroll delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", self.layout_label(), self.theme_label() ); @@ -978,8 +986,10 @@ impl Dashboard { " x Cleanup selected worktree", " X Prune inactive worktrees globally", " d Delete selected inactive session", + " 1-4 Focus Sessions/Output/Metrics/Log directly", " Tab Next pane", " S-Tab Previous pane", + " C-hjkl Move pane focus left/down/up/right", " j/↓ Scroll down", " k/↑ Scroll up", " [ or ] Focus previous/next delegate in lead Metrics board", @@ -1025,6 +1035,39 @@ impl Dashboard { self.selected_pane = visible_panes[previous_index]; } + pub fn focus_pane_number(&mut self, slot: usize) { + let Some(target) = Pane::from_shortcut(slot) else { + self.set_operator_note(format!("pane {slot} is not available")); + return; + }; + + if !self.visible_panes().contains(&target) { + self.set_operator_note(format!( + "{} pane is not visible", + target.title().to_lowercase() + )); + return; + } + + self.focus_pane(target); + } + + pub fn focus_pane_left(&mut self) { + self.move_pane_focus(PaneDirection::Left); + } + + pub fn focus_pane_right(&mut self) { + self.move_pane_focus(PaneDirection::Right); + } + + pub fn focus_pane_up(&mut self) { + self.move_pane_focus(PaneDirection::Up); + } + + pub fn focus_pane_down(&mut self) { + self.move_pane_focus(PaneDirection::Down); + } + pub fn collapse_selected_pane(&mut self) { if self.selected_pane == Pane::Sessions { self.set_operator_note("cannot collapse sessions pane".to_string()); @@ -2635,6 +2678,50 @@ impl Dashboard { } } + fn focus_pane(&mut self, pane: Pane) { + self.selected_pane = pane; + self.ensure_selected_pane_visible(); + self.set_operator_note(format!("focused {} pane", pane.title().to_lowercase())); + } + + fn move_pane_focus(&mut self, direction: PaneDirection) { + let visible_panes = self.visible_panes(); + if visible_panes.len() <= 1 { + return; + } + + let pane_areas = self.pane_areas(Rect::new(0, 0, 100, 40)); + let Some(current_rect) = pane_rect(&pane_areas, self.selected_pane) else { + return; + }; + let current_center = pane_center(current_rect); + + let candidate = visible_panes + .into_iter() + .filter(|pane| *pane != self.selected_pane) + .filter_map(|pane| { + let rect = pane_rect(&pane_areas, pane)?; + let center = pane_center(rect); + let dx = center.0 - current_center.0; + let dy = center.1 - current_center.1; + + let (primary, secondary) = match direction { + PaneDirection::Left if dx < 0 => ((-dx) as u16, dy.unsigned_abs()), + PaneDirection::Right if dx > 0 => (dx as u16, dy.unsigned_abs()), + PaneDirection::Up if dy < 0 => ((-dy) as u16, dx.unsigned_abs()), + PaneDirection::Down if dy > 0 => (dy as u16, dx.unsigned_abs()), + _ => return None, + }; + + Some((pane, primary, secondary)) + }) + .min_by_key(|(pane, primary, secondary)| (*primary, *secondary, pane.sort_key())); + + if let Some((pane, _, _)) = candidate { + self.focus_pane(pane); + } + } + fn sync_global_handoff_backlog(&mut self) { let limit = self.sessions.len().max(1); match self.db.unread_task_handoff_targets(limit) { @@ -4154,6 +4241,41 @@ impl Pane { Pane::Log => "Log", } } + + fn from_shortcut(slot: usize) -> Option { + match slot { + 1 => Some(Self::Sessions), + 2 => Some(Self::Output), + 3 => Some(Self::Metrics), + 4 => Some(Self::Log), + _ => None, + } + } + + fn sort_key(self) -> u8 { + match self { + Self::Sessions => 1, + Self::Output => 2, + Self::Metrics => 3, + Self::Log => 4, + } + } +} + +fn pane_rect(pane_areas: &PaneAreas, pane: Pane) -> Option { + match pane { + Pane::Sessions => Some(pane_areas.sessions), + Pane::Output => pane_areas.output, + Pane::Metrics => pane_areas.metrics, + Pane::Log => pane_areas.log, + } +} + +fn pane_center(rect: Rect) -> (i16, i16) { + ( + rect.x as i16 + rect.width as i16 / 2, + rect.y as i16 + rect.height as i16 / 2, + ) } impl OutputFilter { @@ -8220,6 +8342,50 @@ diff --git a/src/next.rs b/src/next.rs assert_eq!(dashboard.selected_pane, Pane::Log); } + #[test] + fn focus_pane_number_selects_visible_panes_and_rejects_hidden_targets() { + let mut dashboard = test_dashboard(Vec::new(), 0); + + dashboard.focus_pane_number(3); + + assert_eq!(dashboard.selected_pane, Pane::Metrics); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("focused metrics pane") + ); + + dashboard.focus_pane_number(4); + + assert_eq!(dashboard.selected_pane, Pane::Metrics); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("log pane is not visible") + ); + } + + #[test] + fn directional_pane_focus_uses_grid_neighbors() { + let mut dashboard = test_dashboard(Vec::new(), 0); + dashboard.cfg.pane_layout = PaneLayout::Grid; + dashboard.pane_size_percent = DEFAULT_GRID_SIZE_PERCENT; + + dashboard.focus_pane_right(); + assert_eq!(dashboard.selected_pane, Pane::Output); + + dashboard.focus_pane_down(); + assert_eq!(dashboard.selected_pane, Pane::Log); + + dashboard.focus_pane_left(); + assert_eq!(dashboard.selected_pane, Pane::Metrics); + + dashboard.focus_pane_up(); + assert_eq!(dashboard.selected_pane, Pane::Sessions); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("focused sessions pane") + ); + } + #[test] fn cycle_pane_layout_rotates_and_hides_log_when_leaving_grid() { let mut dashboard = test_dashboard(Vec::new(), 0);