diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index 75275f81..aaa0500f 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -37,11 +38,34 @@ pub struct Config { pub token_budget: u64, pub theme: Theme, pub pane_layout: PaneLayout, + pub pane_navigation: PaneNavigationConfig, pub linear_pane_size_percent: u16, pub grid_pane_size_percent: u16, pub risk_thresholds: RiskThresholds, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct PaneNavigationConfig { + pub focus_sessions: String, + pub focus_output: String, + pub focus_metrics: String, + pub focus_log: String, + pub move_left: String, + pub move_down: String, + pub move_up: String, + pub move_right: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PaneNavigationAction { + FocusSlot(usize), + MoveLeft, + MoveDown, + MoveUp, + MoveRight, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Theme { Dark, @@ -67,6 +91,7 @@ impl Default for Config { token_budget: 500_000, theme: Theme::Dark, pane_layout: PaneLayout::Horizontal, + pane_navigation: PaneNavigationConfig::default(), linear_pane_size_percent: 35, grid_pane_size_percent: 50, risk_thresholds: Self::RISK_THRESHOLDS, @@ -115,6 +140,117 @@ impl Config { } } +impl Default for PaneNavigationConfig { + fn default() -> Self { + Self { + focus_sessions: "1".to_string(), + focus_output: "2".to_string(), + focus_metrics: "3".to_string(), + focus_log: "4".to_string(), + move_left: "ctrl-h".to_string(), + move_down: "ctrl-j".to_string(), + move_up: "ctrl-k".to_string(), + move_right: "ctrl-l".to_string(), + } + } +} + +impl PaneNavigationConfig { + pub fn action_for_key(&self, key: KeyEvent) -> Option { + [ + (&self.focus_sessions, PaneNavigationAction::FocusSlot(1)), + (&self.focus_output, PaneNavigationAction::FocusSlot(2)), + (&self.focus_metrics, PaneNavigationAction::FocusSlot(3)), + (&self.focus_log, PaneNavigationAction::FocusSlot(4)), + (&self.move_left, PaneNavigationAction::MoveLeft), + (&self.move_down, PaneNavigationAction::MoveDown), + (&self.move_up, PaneNavigationAction::MoveUp), + (&self.move_right, PaneNavigationAction::MoveRight), + ] + .into_iter() + .find_map(|(binding, action)| shortcut_matches(binding, key).then_some(action)) + } + + pub fn focus_shortcuts_label(&self) -> String { + [ + self.focus_sessions.as_str(), + self.focus_output.as_str(), + self.focus_metrics.as_str(), + self.focus_log.as_str(), + ] + .into_iter() + .map(shortcut_label) + .collect::>() + .join("/") + } + + pub fn movement_shortcuts_label(&self) -> String { + [ + self.move_left.as_str(), + self.move_down.as_str(), + self.move_up.as_str(), + self.move_right.as_str(), + ] + .into_iter() + .map(shortcut_label) + .collect::>() + .join("/") + } +} + +fn shortcut_matches(spec: &str, key: KeyEvent) -> bool { + parse_shortcut(spec).is_some_and(|(modifiers, code)| key.modifiers == modifiers && key.code == code) +} + +fn parse_shortcut(spec: &str) -> Option<(KeyModifiers, KeyCode)> { + let normalized = spec.trim().to_ascii_lowercase().replace('+', "-"); + if normalized.is_empty() { + return None; + } + + if normalized == "tab" { + return Some((KeyModifiers::NONE, KeyCode::Tab)); + } + + if normalized == "shift-tab" || normalized == "s-tab" { + return Some((KeyModifiers::SHIFT, KeyCode::BackTab)); + } + + if let Some(rest) = normalized + .strip_prefix("ctrl-") + .or_else(|| normalized.strip_prefix("c-")) + { + return parse_single_char(rest).map(|ch| (KeyModifiers::CONTROL, KeyCode::Char(ch))); + } + + parse_single_char(&normalized).map(|ch| (KeyModifiers::NONE, KeyCode::Char(ch))) +} + +fn parse_single_char(value: &str) -> Option { + let mut chars = value.chars(); + let ch = chars.next()?; + (chars.next().is_none()).then_some(ch) +} + +fn shortcut_label(spec: &str) -> String { + let normalized = spec.trim().to_ascii_lowercase().replace('+', "-"); + if normalized == "tab" { + return "Tab".to_string(); + } + if normalized == "shift-tab" || normalized == "s-tab" { + return "S-Tab".to_string(); + } + if let Some(rest) = normalized + .strip_prefix("ctrl-") + .or_else(|| normalized.strip_prefix("c-")) + { + if let Some(ch) = parse_single_char(rest) { + return format!("Ctrl+{ch}"); + } + } + normalized +} + impl Default for RiskThresholds { fn default() -> Self { Config::RISK_THRESHOLDS @@ -124,6 +260,7 @@ impl Default for RiskThresholds { #[cfg(test)] mod tests { use super::{Config, PaneLayout}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use uuid::Uuid; #[test] @@ -153,6 +290,7 @@ theme = "Dark" assert_eq!(config.cost_budget_usd, defaults.cost_budget_usd); assert_eq!(config.token_budget, defaults.token_budget); assert_eq!(config.pane_layout, defaults.pane_layout); + assert_eq!(config.pane_navigation, defaults.pane_navigation); assert_eq!( config.linear_pane_size_percent, defaults.linear_pane_size_percent @@ -197,6 +335,70 @@ theme = "Dark" assert_eq!(config.pane_layout, PaneLayout::Grid); } + #[test] + fn pane_navigation_deserializes_from_toml() { + let config: Config = toml::from_str( + r#" +[pane_navigation] +focus_sessions = "q" +focus_output = "w" +focus_metrics = "e" +focus_log = "r" +move_left = "a" +move_down = "s" +move_up = "w" +move_right = "d" +"#, + ) + .unwrap(); + + assert_eq!(config.pane_navigation.focus_sessions, "q"); + assert_eq!(config.pane_navigation.focus_output, "w"); + assert_eq!(config.pane_navigation.focus_metrics, "e"); + assert_eq!(config.pane_navigation.focus_log, "r"); + assert_eq!(config.pane_navigation.move_left, "a"); + assert_eq!(config.pane_navigation.move_down, "s"); + assert_eq!(config.pane_navigation.move_up, "w"); + assert_eq!(config.pane_navigation.move_right, "d"); + } + + #[test] + fn pane_navigation_matches_default_shortcuts() { + let navigation = Config::default().pane_navigation; + + assert_eq!( + navigation.action_for_key(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)), + Some(super::PaneNavigationAction::FocusSlot(1)) + ); + assert_eq!( + navigation.action_for_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)), + Some(super::PaneNavigationAction::MoveRight) + ); + } + + #[test] + fn pane_navigation_matches_custom_shortcuts() { + let navigation = super::PaneNavigationConfig { + focus_sessions: "q".to_string(), + focus_output: "w".to_string(), + focus_metrics: "e".to_string(), + focus_log: "r".to_string(), + move_left: "a".to_string(), + move_down: "s".to_string(), + move_up: "w".to_string(), + move_right: "d".to_string(), + }; + + assert_eq!( + navigation.action_for_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)), + Some(super::PaneNavigationAction::FocusSlot(3)) + ); + assert_eq!( + navigation.action_for_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)), + Some(super::PaneNavigationAction::MoveRight) + ); + } + #[test] fn default_risk_thresholds_are_applied() { assert_eq!(Config::default().risk_thresholds, Config::RISK_THRESHOLDS); @@ -210,6 +412,8 @@ theme = "Dark" config.auto_dispatch_limit_per_session = 9; config.auto_create_worktrees = false; config.auto_merge_ready_worktrees = true; + config.pane_navigation.focus_metrics = "e".to_string(); + config.pane_navigation.move_right = "d".to_string(); config.linear_pane_size_percent = 42; config.grid_pane_size_percent = 55; @@ -221,6 +425,8 @@ theme = "Dark" assert_eq!(loaded.auto_dispatch_limit_per_session, 9); assert!(!loaded.auto_create_worktrees); assert!(loaded.auto_merge_ready_worktrees); + assert_eq!(loaded.pane_navigation.focus_metrics, "e"); + assert_eq!(loaded.pane_navigation.move_right, "d"); assert_eq!(loaded.linear_pane_size_percent, 42); assert_eq!(loaded.grid_pane_size_percent, 55); diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 61eb07f2..e83e838f 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -1664,6 +1664,7 @@ mod tests { token_budget: 500_000, theme: Theme::Dark, pane_layout: PaneLayout::Horizontal, + pane_navigation: Default::default(), linear_pane_size_percent: 35, grid_pane_size_percent: 50, risk_thresholds: Config::RISK_THRESHOLDS, diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index 179a8d95..0abcc8b3 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -47,15 +47,8 @@ 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), + _ if dashboard.handle_pane_navigation_key(key) => {} (_, 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 32e9543c..e3cd5508 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -1,4 +1,5 @@ use chrono::{Duration, Utc}; +use crossterm::event::KeyEvent; use ratatui::{ prelude::*, widgets::{ @@ -11,7 +12,7 @@ use tokio::sync::broadcast; use super::widgets::{budget_state, format_currency, format_token_count, BudgetState, TokenMeter}; use crate::comms; -use crate::config::{Config, PaneLayout, Theme}; +use crate::config::{Config, PaneLayout, PaneNavigationAction, Theme}; use crate::observability::ToolLogEntry; use crate::session::manager; use crate::session::output::{ @@ -883,7 +884,9 @@ 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 [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 ", + " [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 [{}] focus pane [Tab] cycle pane [{}] move pane [j/k] scroll delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", + self.pane_focus_shortcuts_label(), + self.pane_move_shortcuts_label(), self.layout_label(), self.theme_label() ); @@ -956,56 +959,62 @@ impl Dashboard { fn render_help(&self, frame: &mut Frame, area: Rect) { let help = vec![ - "Keyboard Shortcuts:", - "", - " n New session", - " N Natural-language multi-agent spawn prompt", - " a Assign follow-up work from selected session", - " b Rebalance backed-up delegate handoff backlog for selected lead", - " B Rebalance backed-up delegate handoff backlog across lead teams", - " i Drain unread task handoffs from selected lead", - " I Jump to the next unread approval/conflict target session", - " g Auto-dispatch unread handoffs across lead sessions", - " G Dispatch then rebalance backlog across lead teams", - " h Collapse the focused non-session pane", - " H Restore all collapsed panes", - " y Toggle selected-session timeline view", - " E Cycle timeline event filter", - " v Toggle selected worktree diff in output pane", - " c Show conflict-resolution protocol for selected conflicted worktree", - " e Cycle output content filter: all/errors/tool calls/file changes", - " f Cycle output or timeline time range between all/15m/1h/24h", - " A Toggle search or timeline scope between selected session and all sessions", - " o Toggle search agent filter between all agents and selected agent type", - " m Merge selected ready worktree into base and clean it up", - " M Merge all ready inactive worktrees and clean them up", - " l Cycle pane layout and persist it", - " T Toggle theme and persist it", - " t Toggle default worktree creation for new sessions and delegated work", - " p Toggle daemon auto-dispatch policy and persist config", - " w Toggle daemon auto-merge for ready inactive worktrees", - " ,/. Decrease/increase auto-dispatch limit per lead", - " s Stop selected session", - " u Resume selected session", - " 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", - " Enter Open focused delegate from lead Metrics board", - " / Search current session output", - " n/N Next/previous search match when search is active", - " Esc Clear active search or cancel search input", - " +/= Increase pane size and persist it", - " - Decrease pane size and persist it", - " r Refresh", - " ? Toggle help", - " q/C-c Quit", + "Keyboard Shortcuts:".to_string(), + "".to_string(), + " n New session".to_string(), + " N Natural-language multi-agent spawn prompt".to_string(), + " a Assign follow-up work from selected session".to_string(), + " b Rebalance backed-up delegate handoff backlog for selected lead".to_string(), + " B Rebalance backed-up delegate handoff backlog across lead teams".to_string(), + " i Drain unread task handoffs from selected lead".to_string(), + " I Jump to the next unread approval/conflict target session".to_string(), + " g Auto-dispatch unread handoffs across lead sessions".to_string(), + " G Dispatch then rebalance backlog across lead teams".to_string(), + " h Collapse the focused non-session pane".to_string(), + " H Restore all collapsed panes".to_string(), + " y Toggle selected-session timeline view".to_string(), + " E Cycle timeline event filter".to_string(), + " v Toggle selected worktree diff in output pane".to_string(), + " c Show conflict-resolution protocol for selected conflicted worktree".to_string(), + " e Cycle output content filter: all/errors/tool calls/file changes".to_string(), + " f Cycle output or timeline time range between all/15m/1h/24h".to_string(), + " A Toggle search or timeline scope between selected session and all sessions".to_string(), + " o Toggle search agent filter between all agents and selected agent type".to_string(), + " m Merge selected ready worktree into base and clean it up".to_string(), + " M Merge all ready inactive worktrees and clean them up".to_string(), + " l Cycle pane layout and persist it".to_string(), + " T Toggle theme and persist it".to_string(), + " t Toggle default worktree creation for new sessions and delegated work".to_string(), + " p Toggle daemon auto-dispatch policy and persist config".to_string(), + " w Toggle daemon auto-merge for ready inactive worktrees".to_string(), + " ,/. Decrease/increase auto-dispatch limit per lead".to_string(), + " s Stop selected session".to_string(), + " u Resume selected session".to_string(), + " x Cleanup selected worktree".to_string(), + " X Prune inactive worktrees globally".to_string(), + " d Delete selected inactive session".to_string(), + format!( + " {:<7} Focus Sessions/Output/Metrics/Log directly", + self.pane_focus_shortcuts_label() + ), + " Tab Next pane".to_string(), + " S-Tab Previous pane".to_string(), + format!( + " {:<7} Move pane focus left/down/up/right", + self.pane_move_shortcuts_label() + ), + " j/↓ Scroll down".to_string(), + " k/↑ Scroll up".to_string(), + " [ or ] Focus previous/next delegate in lead Metrics board".to_string(), + " Enter Open focused delegate from lead Metrics board".to_string(), + " / Search current session output".to_string(), + " n/N Next/previous search match when search is active".to_string(), + " Esc Clear active search or cancel search input".to_string(), + " +/= Increase pane size and persist it".to_string(), + " - Decrease pane size and persist it".to_string(), + " r Refresh".to_string(), + " ? Toggle help".to_string(), + " q/C-c Quit".to_string(), ]; let paragraph = Paragraph::new(help.join("\n")).block( @@ -1072,6 +1081,32 @@ impl Dashboard { self.move_pane_focus(PaneDirection::Down); } + pub fn handle_pane_navigation_key(&mut self, key: KeyEvent) -> bool { + match self.cfg.pane_navigation.action_for_key(key) { + Some(PaneNavigationAction::FocusSlot(slot)) => { + self.focus_pane_number(slot); + true + } + Some(PaneNavigationAction::MoveLeft) => { + self.focus_pane_left(); + true + } + Some(PaneNavigationAction::MoveDown) => { + self.focus_pane_down(); + true + } + Some(PaneNavigationAction::MoveUp) => { + self.focus_pane_up(); + true + } + Some(PaneNavigationAction::MoveRight) => { + self.focus_pane_right(); + true + } + None => false, + } + } + pub fn collapse_selected_pane(&mut self) { if self.selected_pane == Pane::Sessions { self.set_operator_note("cannot collapse sessions pane".to_string()); @@ -2726,6 +2761,14 @@ impl Dashboard { } } + fn pane_focus_shortcuts_label(&self) -> String { + self.cfg.pane_navigation.focus_shortcuts_label() + } + + fn pane_move_shortcuts_label(&self) -> String { + self.cfg.pane_navigation.movement_shortcuts_label() + } + fn sync_global_handoff_backlog(&mut self) { let limit = self.sessions.len().max(1); match self.db.unread_task_handoff_targets(limit) { @@ -8393,6 +8436,41 @@ diff --git a/src/next.rs b/src/next.rs ); } + #[test] + fn configured_pane_navigation_keys_override_defaults() { + let mut dashboard = test_dashboard(Vec::new(), 0); + dashboard.cfg.pane_navigation.focus_metrics = "e".to_string(); + dashboard.cfg.pane_navigation.move_left = "a".to_string(); + + assert!(dashboard.handle_pane_navigation_key(KeyEvent::new( + crossterm::event::KeyCode::Char('e'), + crossterm::event::KeyModifiers::NONE, + ))); + assert_eq!(dashboard.selected_pane, Pane::Metrics); + + assert!(dashboard.handle_pane_navigation_key(KeyEvent::new( + crossterm::event::KeyCode::Char('a'), + crossterm::event::KeyModifiers::NONE, + ))); + assert_eq!(dashboard.selected_pane, Pane::Sessions); + } + + #[test] + fn pane_navigation_labels_use_configured_bindings() { + let mut dashboard = test_dashboard(Vec::new(), 0); + dashboard.cfg.pane_navigation.focus_sessions = "q".to_string(); + dashboard.cfg.pane_navigation.focus_output = "w".to_string(); + dashboard.cfg.pane_navigation.focus_metrics = "e".to_string(); + dashboard.cfg.pane_navigation.focus_log = "r".to_string(); + dashboard.cfg.pane_navigation.move_left = "a".to_string(); + dashboard.cfg.pane_navigation.move_down = "s".to_string(); + dashboard.cfg.pane_navigation.move_up = "w".to_string(); + dashboard.cfg.pane_navigation.move_right = "d".to_string(); + + assert_eq!(dashboard.pane_focus_shortcuts_label(), "q/w/e/r"); + assert_eq!(dashboard.pane_move_shortcuts_label(), "a/s/w/d"); + } + #[test] fn cycle_pane_layout_rotates_and_hides_log_when_leaving_grid() { let mut dashboard = test_dashboard(Vec::new(), 0); @@ -8717,6 +8795,7 @@ diff --git a/src/next.rs b/src/next.rs token_budget: 500_000, theme: Theme::Dark, pane_layout: PaneLayout::Horizontal, + pane_navigation: Default::default(), linear_pane_size_percent: 35, grid_pane_size_percent: 50, risk_thresholds: Config::RISK_THRESHOLDS,