From cf9c68846cbaa9cc66177f5c3b9dd23c15f69bd6 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 06:08:59 -0700 Subject: [PATCH] feat: add ecc2 ctrl-w pane commands --- ecc2/src/tui/app.rs | 7 ++ ecc2/src/tui/dashboard.rs | 140 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index 0abcc8b3..91248342 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -45,8 +45,15 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { continue; } + if dashboard.is_pane_command_mode() { + if dashboard.handle_pane_command_key(key) { + continue; + } + } + match (key.modifiers, key.code) { (KeyModifiers::CONTROL, KeyCode::Char('c')) => break, + (KeyModifiers::CONTROL, KeyCode::Char('w')) => dashboard.begin_pane_command_mode(), (_, KeyCode::Char('q')) => break, _ if dashboard.handle_pane_navigation_key(key) => {} (_, KeyCode::Tab) => dashboard.next_pane(), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index e3cd5508..40f583f2 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -84,6 +84,7 @@ pub struct Dashboard { selected_session: usize, show_help: bool, operator_note: Option, + pane_command_mode: bool, output_follow: bool, output_scroll_offset: usize, last_output_height: usize, @@ -319,6 +320,7 @@ impl Dashboard { selected_session: 0, show_help: false, operator_note: None, + pane_command_mode: false, output_follow: true, output_scroll_offset: 0, last_output_height: 0, @@ -911,6 +913,9 @@ impl Dashboard { self.search_scope.label(), self.search_agent_filter_label() ) + } else if self.pane_command_mode { + " Ctrl+w | [h/j/k/l] move [1-4] focus [s/v/g] layout [+/-] resize [Esc] cancel |" + .to_string() } else { String::new() }; @@ -918,6 +923,7 @@ impl Dashboard { let text = if self.spawn_input.is_some() || self.search_input.is_some() || self.search_query.is_some() + || self.pane_command_mode { format!(" {search_prefix}") } else if let Some(note) = self.operator_note.as_ref() { @@ -997,6 +1003,8 @@ impl Dashboard { " {:<7} Focus Sessions/Output/Metrics/Log directly", self.pane_focus_shortcuts_label() ), + " Ctrl+w Pane command mode: h/j/k/l move, s/v/g layout, 1-4 focus, +/- resize" + .to_string(), " Tab Next pane".to_string(), " S-Tab Previous pane".to_string(), format!( @@ -1081,6 +1089,18 @@ impl Dashboard { self.move_pane_focus(PaneDirection::Down); } + pub fn begin_pane_command_mode(&mut self) { + self.pane_command_mode = true; + self.set_operator_note( + "pane command mode | h/j/k/l move | s/v/g layout | 1-4 focus | +/- resize" + .to_string(), + ); + } + + pub fn is_pane_command_mode(&self) -> bool { + self.pane_command_mode + } + pub fn handle_pane_navigation_key(&mut self, key: KeyEvent) -> bool { match self.cfg.pane_navigation.action_for_key(key) { Some(PaneNavigationAction::FocusSlot(slot)) => { @@ -1107,6 +1127,37 @@ impl Dashboard { } } + pub fn handle_pane_command_key(&mut self, key: KeyEvent) -> bool { + if !self.pane_command_mode { + return false; + } + + self.pane_command_mode = false; + match key.code { + crossterm::event::KeyCode::Esc => { + self.set_operator_note("pane command cancelled".to_string()); + } + crossterm::event::KeyCode::Char('h') => self.focus_pane_left(), + crossterm::event::KeyCode::Char('j') => self.focus_pane_down(), + crossterm::event::KeyCode::Char('k') => self.focus_pane_up(), + crossterm::event::KeyCode::Char('l') => self.focus_pane_right(), + crossterm::event::KeyCode::Char('1') => self.focus_pane_number(1), + crossterm::event::KeyCode::Char('2') => self.focus_pane_number(2), + crossterm::event::KeyCode::Char('3') => self.focus_pane_number(3), + crossterm::event::KeyCode::Char('4') => self.focus_pane_number(4), + crossterm::event::KeyCode::Char('+') | crossterm::event::KeyCode::Char('=') => { + self.increase_pane_size() + } + crossterm::event::KeyCode::Char('-') => self.decrease_pane_size(), + crossterm::event::KeyCode::Char('s') => self.set_pane_layout(PaneLayout::Horizontal), + crossterm::event::KeyCode::Char('v') => self.set_pane_layout(PaneLayout::Vertical), + crossterm::event::KeyCode::Char('g') => self.set_pane_layout(PaneLayout::Grid), + _ => self.set_operator_note("unknown pane command".to_string()), + } + true + } + + pub fn collapse_selected_pane(&mut self) { if self.selected_pane == Pane::Sessions { self.set_operator_note("cannot collapse sessions pane".to_string()); @@ -1144,6 +1195,11 @@ impl Dashboard { self.cycle_pane_layout_with_save(&config_path, |cfg| cfg.save()); } + pub fn set_pane_layout(&mut self, layout: PaneLayout) { + let config_path = crate::config::Config::config_path(); + self.set_pane_layout_with_save(layout, &config_path, |cfg| cfg.save()); + } + fn cycle_pane_layout_with_save(&mut self, config_path: &std::path::Path, save: F) where F: FnOnce(&Config) -> anyhow::Result<()>, @@ -1176,6 +1232,43 @@ impl Dashboard { } } + fn set_pane_layout_with_save( + &mut self, + layout: PaneLayout, + config_path: &std::path::Path, + save: F, + ) where + F: FnOnce(&Config) -> anyhow::Result<()>, + { + if self.cfg.pane_layout == layout { + self.set_operator_note(format!("pane layout already {}", self.layout_label())); + return; + } + + let previous_layout = self.cfg.pane_layout; + let previous_pane_size = self.pane_size_percent; + let previous_selected_pane = self.selected_pane; + + self.cfg.pane_layout = layout; + self.pane_size_percent = configured_pane_size(&self.cfg, self.cfg.pane_layout); + self.persist_current_pane_size(); + self.ensure_selected_pane_visible(); + + match save(&self.cfg) { + Ok(()) => self.set_operator_note(format!( + "pane layout set to {} | saved to {}", + self.layout_label(), + config_path.display() + )), + Err(error) => { + self.cfg.pane_layout = previous_layout; + self.pane_size_percent = previous_pane_size; + self.selected_pane = previous_selected_pane; + self.set_operator_note(format!("failed to persist pane layout: {error}")); + } + } + } + fn auto_split_layout_after_spawn(&mut self, spawned_count: usize) -> Option { let config_path = crate::config::Config::config_path(); self.auto_split_layout_after_spawn_with_save(spawned_count, &config_path, |cfg| cfg.save()) @@ -8471,6 +8564,52 @@ diff --git a/src/next.rs b/src/next.rs assert_eq!(dashboard.pane_move_shortcuts_label(), "a/s/w/d"); } + #[test] + fn pane_command_mode_handles_focus_and_cancel() { + let mut dashboard = test_dashboard(Vec::new(), 0); + + dashboard.begin_pane_command_mode(); + assert!(dashboard.is_pane_command_mode()); + + assert!(dashboard.handle_pane_command_key(KeyEvent::new( + crossterm::event::KeyCode::Char('3'), + crossterm::event::KeyModifiers::NONE, + ))); + assert_eq!(dashboard.selected_pane, Pane::Metrics); + assert!(!dashboard.is_pane_command_mode()); + + dashboard.begin_pane_command_mode(); + assert!(dashboard.handle_pane_command_key(KeyEvent::new( + crossterm::event::KeyCode::Esc, + crossterm::event::KeyModifiers::NONE, + ))); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("pane command cancelled") + ); + assert!(!dashboard.is_pane_command_mode()); + } + + #[test] + fn pane_command_mode_sets_layout() { + let mut dashboard = test_dashboard(Vec::new(), 0); + dashboard.cfg.pane_layout = PaneLayout::Horizontal; + + dashboard.begin_pane_command_mode(); + assert!(dashboard.handle_pane_command_key(KeyEvent::new( + crossterm::event::KeyCode::Char('g'), + crossterm::event::KeyModifiers::NONE, + ))); + + assert_eq!(dashboard.cfg.pane_layout, PaneLayout::Grid); + assert!( + dashboard + .operator_note + .as_deref() + .is_some_and(|note| note.contains("pane layout set to grid | saved to ")) + ); + } + #[test] fn cycle_pane_layout_rotates_and_hides_log_when_leaving_grid() { let mut dashboard = test_dashboard(Vec::new(), 0); @@ -8761,6 +8900,7 @@ diff --git a/src/next.rs b/src/next.rs selected_session, show_help: false, operator_note: None, + pane_command_mode: false, output_follow: true, output_scroll_offset: 0, last_output_height: 0,