feat: make ecc2 pane navigation shortcuts configurable

This commit is contained in:
Affaan Mustafa
2026-04-09 06:05:27 -07:00
parent c6e26ddea4
commit a54799127c
4 changed files with 339 additions and 60 deletions

View File

@@ -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<PaneNavigationAction> {
[
(&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::<Vec<_>>()
.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::<Vec<_>>()
.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<char> {
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);