feat: add ecc2 ctrl-w pane commands

This commit is contained in:
Affaan Mustafa
2026-04-09 06:08:59 -07:00
parent a54799127c
commit cf9c68846c
2 changed files with 147 additions and 0 deletions

View File

@@ -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(),

View File

@@ -84,6 +84,7 @@ pub struct Dashboard {
selected_session: usize,
show_help: bool,
operator_note: Option<String>,
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<F>(&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<F>(
&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<String> {
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,