From 63299b15b34be14f41945c88a9323cc060c91e30 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 03:43:28 -0700 Subject: [PATCH] feat: add ecc2 runtime theme toggle --- ecc2/src/config/mod.rs | 2 +- ecc2/src/tui/app.rs | 1 + ecc2/src/tui/dashboard.rs | 119 ++++++++++++++++++++++++++++++++++---- 3 files changed, 111 insertions(+), 11 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index 634a301a..b2750bff 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -40,7 +40,7 @@ pub struct Config { pub risk_thresholds: RiskThresholds, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Theme { Dark, Light, diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index 36b69667..1ecc3072 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -50,6 +50,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('m')) => dashboard.merge_selected_worktree().await, (_, KeyCode::Char('M')) => dashboard.merge_ready_worktrees().await, (_, KeyCode::Char('l')) => dashboard.cycle_pane_layout(), + (_, KeyCode::Char('T')) => dashboard.toggle_theme(), (_, KeyCode::Char('p')) => dashboard.toggle_auto_dispatch_policy(), (_, KeyCode::Char('t')) => dashboard.toggle_auto_worktree_policy(), (_, KeyCode::Char('w')) => dashboard.toggle_auto_merge_policy(), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 1b46eea1..efced3eb 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -9,7 +9,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}; +use crate::config::{Config, PaneLayout, Theme}; use crate::observability::ToolLogEntry; use crate::session::manager; use crate::session::output::{OutputEvent, OutputLine, SessionOutputStore, OUTPUT_BUFFER_LIMIT}; @@ -45,6 +45,14 @@ struct WorktreeDiffColumns { additions: String, } +#[derive(Debug, Clone, Copy)] +struct ThemePalette { + accent: Color, + row_highlight_bg: Color, + muted: Color, + help_border: Color, +} + pub struct Dashboard { db: StateStore, cfg: Config, @@ -244,11 +252,13 @@ impl Dashboard { .filter(|session| session.state == SessionState::Running) .count(); let total = self.sessions.len(); + let palette = self.theme_palette(); let title = format!( - " ECC 2.0 | {running} running / {total} total | {} {}% ", + " ECC 2.0 | {running} running / {total} total | {} {}% | {} ", self.layout_label(), - self.pane_size_percent + self.pane_size_percent, + self.theme_label() ); let tabs = Tabs::new( self.visible_panes() @@ -260,7 +270,7 @@ impl Dashboard { .select(self.selected_pane_index()) .highlight_style( Style::default() - .fg(Color::Cyan) + .fg(palette.accent) .add_modifier(Modifier::BOLD), ); @@ -332,7 +342,7 @@ impl Dashboard { .highlight_spacing(HighlightSpacing::Always) .row_highlight_style( Style::default() - .bg(Color::DarkGray) + .bg(self.theme_palette().row_highlight_bg) .add_modifier(Modifier::BOLD), ); @@ -530,8 +540,9 @@ impl Dashboard { fn render_status_bar(&self, frame: &mut Frame, area: Rect) { let text = format!( - " [n]ew session [a]ssign re[b]alance global re[B]alance dra[i]n inbox [g]lobal dispatch coordinate [G]lobal [v]iew diff conflict proto[c]ol [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 [Tab] switch pane [j/k] scroll [+/-] resize [l]ayout {} [?] help [q]uit ", - self.layout_label() + " [n]ew session [a]ssign re[b]alance global re[B]alance dra[i]n inbox [g]lobal dispatch coordinate [G]lobal [v]iew diff conflict proto[c]ol [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 [Tab] switch pane [j/k] scroll [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", + self.layout_label(), + self.theme_label() ); let text = if let Some(note) = self.operator_note.as_ref() { format!(" {} |{}", truncate_for_dashboard(note, 96), text) @@ -559,7 +570,7 @@ impl Dashboard { .split(inner); frame.render_widget( - Paragraph::new(text).style(Style::default().fg(Color::DarkGray)), + Paragraph::new(text).style(Style::default().fg(self.theme_palette().muted)), chunks[0], ); frame.render_widget( @@ -586,6 +597,7 @@ impl Dashboard { " 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", @@ -610,7 +622,7 @@ impl Dashboard { Block::default() .borders(Borders::ALL) .title(" Help ") - .border_style(Style::default().fg(Color::Yellow)), + .border_style(Style::default().fg(self.theme_palette().help_border)), ); frame.render_widget(paragraph, area); } @@ -673,6 +685,34 @@ impl Dashboard { } } + pub fn toggle_theme(&mut self) { + let config_path = crate::config::Config::config_path(); + self.toggle_theme_with_save(&config_path, |cfg| cfg.save()); + } + + fn toggle_theme_with_save(&mut self, config_path: &std::path::Path, save: F) + where + F: FnOnce(&Config) -> anyhow::Result<()>, + { + let previous_theme = self.cfg.theme; + self.cfg.theme = match self.cfg.theme { + Theme::Dark => Theme::Light, + Theme::Light => Theme::Dark, + }; + + match save(&self.cfg) { + Ok(()) => self.set_operator_note(format!( + "theme set to {} | saved to {}", + self.theme_label(), + config_path.display() + )), + Err(error) => { + self.cfg.theme = previous_theme; + self.set_operator_note(format!("failed to persist theme: {error}")); + } + } + } + pub fn increase_pane_size(&mut self) { self.pane_size_percent = (self.pane_size_percent + PANE_RESIZE_STEP_PERCENT).min(MAX_PANE_SIZE_PERCENT); @@ -2371,7 +2411,7 @@ impl Dashboard { fn pane_border_style(&self, pane: Pane) -> Style { if self.selected_pane == pane { - Style::default().fg(Color::Cyan) + Style::default().fg(self.theme_palette().accent) } else { Style::default() } @@ -2385,6 +2425,30 @@ impl Dashboard { } } + fn theme_label(&self) -> &'static str { + match self.cfg.theme { + Theme::Dark => "dark", + Theme::Light => "light", + } + } + + fn theme_palette(&self) -> ThemePalette { + match self.cfg.theme { + Theme::Dark => ThemePalette { + accent: Color::Cyan, + row_highlight_bg: Color::DarkGray, + muted: Color::DarkGray, + help_border: Color::Yellow, + }, + Theme::Light => ThemePalette { + accent: Color::Blue, + row_highlight_bg: Color::Gray, + muted: Color::Black, + help_border: Color::Blue, + }, + } + } + fn log_field<'a>(&self, value: &'a str) -> &'a str { let trimmed = value.trim(); if trimmed.is_empty() { @@ -4270,6 +4334,41 @@ diff --git a/src/next.rs b/src/next.rs let _ = std::fs::remove_dir_all(tempdir); } + #[test] + fn toggle_theme_persists_config() { + let mut dashboard = test_dashboard(Vec::new(), 0); + let tempdir = std::env::temp_dir().join(format!("ecc2-theme-policy-{}", Uuid::new_v4())); + std::fs::create_dir_all(&tempdir).unwrap(); + let config_path = tempdir.join("ecc2.toml"); + + dashboard.toggle_theme_with_save(&config_path, |cfg| cfg.save_to_path(&config_path)); + + assert_eq!(dashboard.cfg.theme, Theme::Light); + let expected_note = format!("theme set to light | 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.theme, Theme::Light); + let _ = std::fs::remove_dir_all(tempdir); + } + + #[test] + fn light_theme_uses_light_palette_accent() { + let mut dashboard = test_dashboard(Vec::new(), 0); + dashboard.cfg.theme = Theme::Light; + dashboard.selected_pane = Pane::Sessions; + + assert_eq!( + dashboard.pane_border_style(Pane::Sessions), + Style::default().fg(Color::Blue) + ); + assert_eq!(dashboard.theme_palette().row_highlight_bg, Color::Gray); + } + fn test_dashboard(sessions: Vec, selected_session: usize) -> Dashboard { let selected_session = selected_session.min(sessions.len().saturating_sub(1)); let cfg = Config::default();