From c7bf1434505b3b8dd3160349e07289eaf9a1970c Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 03:50:29 -0700 Subject: [PATCH] feat: persist ecc2 pane sizes by layout --- ecc2/src/config/mod.rs | 24 ++++++ ecc2/src/session/manager.rs | 2 + ecc2/src/tui/dashboard.rs | 150 ++++++++++++++++++++++++++++++------ 3 files changed, 154 insertions(+), 22 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index b2750bff..75275f81 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -37,6 +37,8 @@ pub struct Config { pub token_budget: u64, pub theme: Theme, pub pane_layout: PaneLayout, + pub linear_pane_size_percent: u16, + pub grid_pane_size_percent: u16, pub risk_thresholds: RiskThresholds, } @@ -65,6 +67,8 @@ impl Default for Config { token_budget: 500_000, theme: Theme::Dark, pane_layout: PaneLayout::Horizontal, + linear_pane_size_percent: 35, + grid_pane_size_percent: 50, risk_thresholds: Self::RISK_THRESHOLDS, } } @@ -149,6 +153,14 @@ 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.linear_pane_size_percent, + defaults.linear_pane_size_percent + ); + assert_eq!( + config.grid_pane_size_percent, + defaults.grid_pane_size_percent + ); assert_eq!(config.risk_thresholds, defaults.risk_thresholds); assert_eq!( config.auto_dispatch_unread_handoffs, @@ -170,6 +182,14 @@ theme = "Dark" assert_eq!(Config::default().pane_layout, PaneLayout::Horizontal); } + #[test] + fn default_pane_sizes_match_dashboard_defaults() { + let config = Config::default(); + + assert_eq!(config.linear_pane_size_percent, 35); + assert_eq!(config.grid_pane_size_percent, 50); + } + #[test] fn pane_layout_deserializes_from_toml() { let config: Config = toml::from_str(r#"pane_layout = "grid""#).unwrap(); @@ -190,6 +210,8 @@ theme = "Dark" config.auto_dispatch_limit_per_session = 9; config.auto_create_worktrees = false; config.auto_merge_ready_worktrees = true; + config.linear_pane_size_percent = 42; + config.grid_pane_size_percent = 55; config.save_to_path(&path).unwrap(); let content = std::fs::read_to_string(&path).unwrap(); @@ -199,6 +221,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.linear_pane_size_percent, 42); + assert_eq!(loaded.grid_pane_size_percent, 55); let _ = std::fs::remove_file(path); } diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index e13cdd6e..61eb07f2 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -1664,6 +1664,8 @@ mod tests { token_budget: 500_000, theme: Theme::Dark, pane_layout: PaneLayout::Horizontal, + linear_pane_size_percent: 35, + grid_pane_size_percent: 50, risk_thresholds: Config::RISK_THRESHOLDS, } } diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index efced3eb..eb996ba8 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -22,7 +22,6 @@ use crate::session::output::OutputStream; #[cfg(test)] use crate::session::{SessionMetrics, WorktreeInfo}; -const DEFAULT_PANE_SIZE_PERCENT: u16 = 35; const DEFAULT_GRID_SIZE_PERCENT: u16 = 50; const OUTPUT_PANE_PERCENT: u16 = 70; const MIN_PANE_SIZE_PERCENT: u16 = 20; @@ -32,13 +31,6 @@ const MAX_LOG_ENTRIES: u64 = 12; const MAX_DIFF_PREVIEW_LINES: usize = 6; const MAX_DIFF_PATCH_LINES: usize = 80; -fn default_pane_size(layout: PaneLayout) -> u16 { - match layout { - PaneLayout::Grid => DEFAULT_GRID_SIZE_PERCENT, - PaneLayout::Horizontal | PaneLayout::Vertical => DEFAULT_PANE_SIZE_PERCENT, - } -} - #[derive(Debug, Clone, PartialEq, Eq)] struct WorktreeDiffColumns { removals: String, @@ -163,7 +155,7 @@ impl Dashboard { cfg: Config, output_store: SessionOutputStore, ) -> Self { - let pane_size_percent = default_pane_size(cfg.pane_layout); + let pane_size_percent = configured_pane_size(&cfg, cfg.pane_layout); let sessions = db.list_sessions().unwrap_or_default(); let output_rx = output_store.subscribe(); let mut session_table_state = TableState::default(); @@ -611,8 +603,8 @@ impl Dashboard { " S-Tab Previous pane", " j/↓ Scroll down", " k/↑ Scroll up", - " +/= Increase pane size", - " - Decrease pane size", + " +/= Increase pane size and persist it", + " - Decrease pane size and persist it", " r Refresh", " ? Toggle help", " q/C-c Quit", @@ -667,7 +659,8 @@ impl Dashboard { PaneLayout::Vertical => PaneLayout::Grid, PaneLayout::Grid => PaneLayout::Horizontal, }; - self.pane_size_percent = default_pane_size(self.cfg.pane_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) { @@ -685,6 +678,61 @@ impl Dashboard { } } + fn adjust_pane_size_with_save( + &mut self, + delta: isize, + config_path: &std::path::Path, + save: F, + ) where + F: FnOnce(&Config) -> anyhow::Result<()>, + { + let previous_size = self.pane_size_percent; + let previous_linear = self.cfg.linear_pane_size_percent; + let previous_grid = self.cfg.grid_pane_size_percent; + let next = (self.pane_size_percent as isize + delta).clamp( + MIN_PANE_SIZE_PERCENT as isize, + MAX_PANE_SIZE_PERCENT as isize, + ) as u16; + + if next == self.pane_size_percent { + self.set_operator_note(format!( + "pane size unchanged at {}% for {} layout", + self.pane_size_percent, + self.layout_label() + )); + return; + } + + self.pane_size_percent = next; + self.persist_current_pane_size(); + + match save(&self.cfg) { + Ok(()) => self.set_operator_note(format!( + "pane size set to {}% for {} layout | saved to {}", + self.pane_size_percent, + self.layout_label(), + config_path.display() + )), + Err(error) => { + self.pane_size_percent = previous_size; + self.cfg.linear_pane_size_percent = previous_linear; + self.cfg.grid_pane_size_percent = previous_grid; + self.set_operator_note(format!("failed to persist pane size: {error}")); + } + } + } + + fn persist_current_pane_size(&mut self) { + match self.cfg.pane_layout { + PaneLayout::Horizontal | PaneLayout::Vertical => { + self.cfg.linear_pane_size_percent = self.pane_size_percent; + } + PaneLayout::Grid => { + self.cfg.grid_pane_size_percent = self.pane_size_percent; + } + } + } + pub fn toggle_theme(&mut self) { let config_path = crate::config::Config::config_path(); self.toggle_theme_with_save(&config_path, |cfg| cfg.save()); @@ -714,15 +762,19 @@ impl Dashboard { } pub fn increase_pane_size(&mut self) { - self.pane_size_percent = - (self.pane_size_percent + PANE_RESIZE_STEP_PERCENT).min(MAX_PANE_SIZE_PERCENT); + let config_path = crate::config::Config::config_path(); + self.adjust_pane_size_with_save(PANE_RESIZE_STEP_PERCENT as isize, &config_path, |cfg| { + cfg.save() + }); } pub fn decrease_pane_size(&mut self) { - self.pane_size_percent = self - .pane_size_percent - .saturating_sub(PANE_RESIZE_STEP_PERCENT) - .max(MIN_PANE_SIZE_PERCENT); + let config_path = crate::config::Config::config_path(); + self.adjust_pane_size_with_save( + -(PANE_RESIZE_STEP_PERCENT as isize), + &config_path, + |cfg| cfg.save(), + ); } pub fn scroll_down(&mut self) { @@ -2677,6 +2729,15 @@ fn truncate_for_dashboard(value: &str, max_chars: usize) -> String { format!("{truncated}…") } +fn configured_pane_size(cfg: &Config, layout: PaneLayout) -> u16 { + let configured = match layout { + PaneLayout::Horizontal | PaneLayout::Vertical => cfg.linear_pane_size_percent, + PaneLayout::Grid => cfg.grid_pane_size_percent, + }; + + configured.clamp(MIN_PANE_SIZE_PERCENT, MAX_PANE_SIZE_PERCENT) +} + fn build_worktree_diff_columns(patch: &str) -> WorktreeDiffColumns { let mut removals = Vec::new(); let mut additions = Vec::new(); @@ -4269,12 +4330,12 @@ diff --git a/src/next.rs b/src/next.rs dashboard.pane_size_percent = DEFAULT_GRID_SIZE_PERCENT; for _ in 0..20 { - dashboard.increase_pane_size(); + dashboard.adjust_pane_size_with_save(5, Path::new("/tmp/ecc2-noop.toml"), |_| Ok(())); } assert_eq!(dashboard.pane_size_percent, MAX_PANE_SIZE_PERCENT); for _ in 0..40 { - dashboard.decrease_pane_size(); + dashboard.adjust_pane_size_with_save(-5, Path::new("/tmp/ecc2-noop.toml"), |_| Ok(())); } assert_eq!(dashboard.pane_size_percent, MIN_PANE_SIZE_PERCENT); } @@ -4299,13 +4360,15 @@ diff --git a/src/next.rs b/src/next.rs fn cycle_pane_layout_rotates_and_hides_log_when_leaving_grid() { let mut dashboard = test_dashboard(Vec::new(), 0); dashboard.cfg.pane_layout = PaneLayout::Grid; + dashboard.cfg.linear_pane_size_percent = 44; + dashboard.cfg.grid_pane_size_percent = 77; dashboard.pane_size_percent = 77; dashboard.selected_pane = Pane::Log; dashboard.cycle_pane_layout(); assert_eq!(dashboard.cfg.pane_layout, PaneLayout::Horizontal); - assert_eq!(dashboard.pane_size_percent, DEFAULT_PANE_SIZE_PERCENT); + assert_eq!(dashboard.pane_size_percent, 44); assert_eq!(dashboard.selected_pane, Pane::Sessions); } @@ -4334,6 +4397,47 @@ diff --git a/src/next.rs b/src/next.rs let _ = std::fs::remove_dir_all(tempdir); } + #[test] + fn pane_resize_persists_linear_setting() { + let mut dashboard = test_dashboard(Vec::new(), 0); + let tempdir = std::env::temp_dir().join(format!("ecc2-pane-size-{}", Uuid::new_v4())); + std::fs::create_dir_all(&tempdir).unwrap(); + let config_path = tempdir.join("ecc2.toml"); + + dashboard.adjust_pane_size_with_save(5, &config_path, |cfg| cfg.save_to_path(&config_path)); + + assert_eq!(dashboard.pane_size_percent, 40); + assert_eq!(dashboard.cfg.linear_pane_size_percent, 40); + let expected_note = format!( + "pane size set to 40% for horizontal layout | saved to {}", + config_path.display() + ); + assert_eq!( + dashboard.operator_note.as_deref(), + Some(expected_note.as_str()) + ); + + let saved = std::fs::read_to_string(&config_path).unwrap(); + let loaded: Config = toml::from_str(&saved).unwrap(); + assert_eq!(loaded.linear_pane_size_percent, 40); + assert_eq!(loaded.grid_pane_size_percent, 50); + let _ = std::fs::remove_dir_all(tempdir); + } + + #[test] + fn cycle_pane_layout_uses_persisted_grid_size() { + let mut dashboard = test_dashboard(Vec::new(), 0); + dashboard.cfg.pane_layout = PaneLayout::Vertical; + dashboard.cfg.linear_pane_size_percent = 41; + dashboard.cfg.grid_pane_size_percent = 63; + dashboard.pane_size_percent = 41; + + dashboard.cycle_pane_layout_with_save(Path::new("/tmp/ecc2-noop.toml"), |_| Ok(())); + + assert_eq!(dashboard.cfg.pane_layout, PaneLayout::Grid); + assert_eq!(dashboard.pane_size_percent, 63); + } + #[test] fn toggle_theme_persists_config() { let mut dashboard = test_dashboard(Vec::new(), 0); @@ -4381,7 +4485,7 @@ diff --git a/src/next.rs b/src/next.rs Dashboard { db: StateStore::open(Path::new(":memory:")).expect("open test db"), - pane_size_percent: default_pane_size(cfg.pane_layout), + pane_size_percent: configured_pane_size(&cfg, cfg.pane_layout), cfg, output_store, output_rx, @@ -4433,6 +4537,8 @@ diff --git a/src/next.rs b/src/next.rs token_budget: 500_000, theme: Theme::Dark, pane_layout: PaneLayout::Horizontal, + linear_pane_size_percent: 35, + grid_pane_size_percent: 50, risk_thresholds: Config::RISK_THRESHOLDS, } }