Skip to content

Commit 684397e

Browse files
feat(tui): add theme system with 3 built-in themes (#299)
* feat: add theme TOML files and hex color parser - Create 3 built-in themes: phosphor, tokyo-night, catppuccin-latte - Add parse_hex_color() utility for #RRGGBB and #RGB formats - All themes have 17 required color fields in TOML format - phosphor.toml matches existing Theme::phosphor() RGB values Tasks: #2 (TOML files) + #3 (hex parser) from theme-system plan * feat(tui): add ThemeColor newtype with Serde support - Add ThemeColor(Color) newtype wrapper for serialization - Implement Serialize/Deserialize for hex color format - Update all 17 Theme struct fields to use ThemeColor - Fix all downstream usage sites with explicit derefs (*) - Enable TOML-based theme loading (Task 1 complete) All 399 type errors resolved. Build passes with zero errors. Part of theme system implementation (Issue #295). * feat(tui): apply theme changes immediately in settings view Wire immediate theme application when user changes theme in settings: - Add pending_theme_change tracking in SettingsView - Add take_pending_theme_change() accessor in SettingsView - Check for pending theme in home/input.rs SettingsAction::Continue - Return Action::SetTheme(name) to apply immediately via app.rs - Add Action::SetTheme handler to handle_mouse() for consistency Theme now updates visually in real-time instead of on settings close. Verified: - OPENSSL_NO_VENDOR=1 cargo check: 0 errors - cargo clippy: 0 warnings - OPENSSL_NO_VENDOR=1 cargo test --lib: 641 passed * fix(tui): add Theme category to settings view Discovered during F3 TUI QA: SettingsCategory::Theme was defined but not included in the categories vector. Theme now appears as first category in Settings. * test: add merge_configs tests for theme override Adds two tests to match the pattern used for other config types: - test_merge_configs_with_theme_override: verifies profile override applies - test_merge_configs_theme_inherits_when_not_overridden: verifies global inherits * feat(themes): add tokyo-night-storm theme Add darker Tokyo Night variant with official Storm palette colors: - Background: #24283b (vs #1a1b26 in regular tokyo-night) - Border: #414868 (Terminal Black) - Selection: #364a82 All semantic colors (running, waiting, error, idle) match tokyo-night. * fix: dereference ThemeColor in dialog styles * chore: remove .sisyphus from tracking * docs: add theme config section * chore: remove .sisyphus from tracking * fix: address PR review feedback - Use let-else pattern for ThemeColor::Serialize invariant - Read theme directly from field value instead of config - Remove comments from tokyo-night-storm.toml for consistency * refactor: remove ThemeColor wrapper, use native ratatui Color serde - Enable ratatui 'serde' feature for native Color deserialization - Delete ThemeColor newtype and color.rs module - Add semantic color fields to Theme (diff_*, branch, sandbox, worktree_*) - Update all 4 theme TOML files with official palette colors - Verify all TUI primitives use theme colors (no hardcoded Color::) * feat(themes): add diff_modified and help_key semantic colors - Add diff_modified for FileStatus::Modified (yellow) - Add help_key for keyboard shortcuts in help panels (yellow) - Preserves original phosphor Color::Yellow mapping - Uses palette-correct yellow for tokyo-night/catppuccin * cleanup --------- Co-authored-by: njbrake <njbrake@gmail.com>
1 parent dd9bbad commit 684397e

13 files changed

Lines changed: 351 additions & 28 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Thumbs.db
3939

4040
# AI coding tools
4141
.opencode/
42+
.sisyphus/
4243

4344
# Per-repo aoe config (created by `aoe init`, should be committed per-repo by users)
4445
# Ignored here since this is the aoe tool repo itself

docs/guides/configuration.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,17 @@ All settings below can also be edited from the TUI settings screen (press `s` or
3737
| `AGENT_OF_EMPIRES_PROFILE` | Default profile to use |
3838
| `AGENT_OF_EMPIRES_DEBUG` | Enable debug logging (`1` to enable) |
3939

40+
## Theme
41+
42+
```toml
43+
[theme]
44+
name = "phosphor" # phosphor, tokyo-night-storm, catppuccin-latte
45+
```
46+
47+
| Option | Default | Description |
48+
|--------|---------|-------------|
49+
| `name` | `"phosphor"` | TUI color theme. Available: `phosphor` (default green), `tokyo-night-storm` (dark blue/purple), `catppuccin-latte` (light pastel). |
50+
4051
## Session
4152

4253
```toml

src/session/profile_config.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -703,4 +703,27 @@ mod tests {
703703
Some(TmuxMouseMode::Enabled)
704704
);
705705
}
706+
707+
#[test]
708+
fn test_merge_configs_with_theme_override() {
709+
let global = Config::default();
710+
let profile = ProfileConfig {
711+
theme: Some(ThemeConfigOverride {
712+
name: Some("tokyo-night".to_string()),
713+
}),
714+
..Default::default()
715+
};
716+
let merged = merge_configs(global, &profile);
717+
assert_eq!(merged.theme.name, "tokyo-night");
718+
}
719+
720+
#[test]
721+
fn test_merge_configs_theme_inherits_when_not_overridden() {
722+
let mut global = Config::default();
723+
global.theme.name = "catppuccin-latte".to_string();
724+
725+
let profile = ProfileConfig::default();
726+
let merged = merge_configs(global, &profile);
727+
assert_eq!(merged.theme.name, "catppuccin-latte");
728+
}
706729
}

src/tui/app.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use std::path::PathBuf;
77
use std::time::Duration;
88

99
use super::home::{HomeView, TerminalMode};
10+
use super::styles::load_theme;
1011
use super::styles::Theme;
1112
use crate::session::{get_update_settings, load_config, save_config, Storage};
1213
use crate::tmux::AvailableTools;
@@ -78,10 +79,17 @@ impl App {
7879
pub fn new(profile: &str, available_tools: AvailableTools) -> Result<Self> {
7980
let storage = Storage::new(profile)?;
8081
let mut home = HomeView::new(storage, available_tools)?;
81-
let theme = Theme::default();
8282

8383
// Check if we need to show welcome or changelog dialogs
8484
let mut config = load_config()?.unwrap_or_default();
85+
86+
// Load theme from config, defaulting to phosphor if empty
87+
let theme_name = if config.theme.name.is_empty() {
88+
"phosphor"
89+
} else {
90+
&config.theme.name
91+
};
92+
let theme = load_theme(theme_name);
8593
let current_version = env!("CARGO_PKG_VERSION").to_string();
8694

8795
if !config.app_state.has_seen_welcome {
@@ -106,6 +114,11 @@ impl App {
106114
})
107115
}
108116

117+
pub fn set_theme(&mut self, name: &str) {
118+
self.theme = load_theme(name);
119+
self.needs_redraw = true;
120+
}
121+
109122
pub async fn run(
110123
&mut self,
111124
terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
@@ -304,6 +317,9 @@ impl App {
304317
Action::EditFile(path) => {
305318
self.edit_file(&path, terminal)?;
306319
}
320+
Action::SetTheme(name) => {
321+
self.set_theme(&name);
322+
}
307323
}
308324
}
309325

@@ -333,6 +349,9 @@ impl App {
333349
Action::EditFile(path) => {
334350
self.edit_file(&path, terminal)?;
335351
}
352+
Action::SetTheme(name) => {
353+
self.set_theme(&name);
354+
}
336355
}
337356
}
338357

@@ -542,6 +561,7 @@ pub enum Action {
542561
AttachTerminal(String, TerminalMode),
543562
SwitchProfile(String),
544563
EditFile(PathBuf),
564+
SetTheme(String),
545565
}
546566

547567
#[cfg(test)]

src/tui/components/preview.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ impl Preview {
6363
if sandbox.enabled {
6464
info_lines.push(Line::from(vec![
6565
Span::styled("Sandbox: ", Style::default().fg(theme.dimmed)),
66-
Span::styled(&sandbox.container_name, Style::default().fg(Color::Magenta)),
66+
Span::styled(&sandbox.container_name, Style::default().fg(theme.sandbox)),
6767
]));
6868
}
6969
}
@@ -193,7 +193,7 @@ impl Preview {
193193
]));
194194
info_lines.push(Line::from(vec![
195195
Span::styled("Branch: ", Style::default().fg(theme.dimmed)),
196-
Span::styled(&wt_info.branch, Style::default().fg(Color::Cyan)),
196+
Span::styled(&wt_info.branch, Style::default().fg(theme.branch)),
197197
]));
198198
info_lines.push(Line::from(vec![
199199
Span::styled("Main: ", Style::default().fg(theme.dimmed)),
@@ -213,9 +213,9 @@ impl Preview {
213213
Span::styled(
214214
managed_text,
215215
Style::default().fg(if wt_info.managed_by_aoe {
216-
Color::Green
216+
theme.worktree_managed
217217
} else {
218-
Color::Yellow
218+
theme.worktree_manual
219219
}),
220220
),
221221
]));

src/tui/dialogs/new_session/render.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ impl NewSessionDialog {
357357
if let Some(error) = &self.error_message {
358358
let error_text = format!("✗ Error: {}", error);
359359
let error_paragraph = Paragraph::new(error_text)
360-
.style(Style::default().fg(Color::Red))
360+
.style(Style::default().fg(theme.error))
361361
.wrap(Wrap { trim: true });
362362
frame.render_widget(error_paragraph, chunks[hint_chunk]);
363363
} else {

src/tui/diff/render.rs

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
33
use ratatui::{
44
layout::{Constraint, Direction, Layout, Rect},
5-
style::{Color, Modifier, Style},
5+
style::{Modifier, Style},
66
text::{Line, Span},
77
widgets::{
88
Block, Borders, Clear, List, ListItem, Padding, Paragraph, Scrollbar, ScrollbarOrientation,
@@ -98,9 +98,15 @@ impl DiffView {
9898
Style::default().fg(theme.dimmed),
9999
),
100100
Span::styled(" ", Style::default()),
101-
Span::styled(format!("+{}", additions), Style::default().fg(Color::Green)),
101+
Span::styled(
102+
format!("+{}", additions),
103+
Style::default().fg(theme.diff_add),
104+
),
102105
Span::styled(" ", Style::default()),
103-
Span::styled(format!("-{}", deletions), Style::default().fg(Color::Red)),
106+
Span::styled(
107+
format!("-{}", deletions),
108+
Style::default().fg(theme.diff_delete),
109+
),
104110
]);
105111

106112
frame.render_widget(Paragraph::new(header), inner);
@@ -147,12 +153,12 @@ impl DiffView {
147153
let is_selected = i == self.selected_file;
148154

149155
let status_color = match file.status {
150-
FileStatus::Added => Color::Green,
151-
FileStatus::Modified => Color::Yellow,
152-
FileStatus::Deleted => Color::Red,
153-
FileStatus::Renamed => Color::Cyan,
154-
FileStatus::Copied => Color::Cyan,
155-
FileStatus::Untracked => Color::Gray,
156+
FileStatus::Added => theme.diff_add,
157+
FileStatus::Modified => theme.diff_modified,
158+
FileStatus::Deleted => theme.diff_delete,
159+
FileStatus::Renamed => theme.diff_header,
160+
FileStatus::Copied => theme.diff_header,
161+
FileStatus::Untracked => theme.dimmed,
156162
};
157163

158164
let style = if is_selected {
@@ -239,13 +245,13 @@ impl DiffView {
239245
);
240246
lines.push(Line::from(Span::styled(
241247
header,
242-
Style::default().fg(Color::Cyan),
248+
Style::default().fg(theme.diff_header),
243249
)));
244250

245251
for line in &hunk.lines {
246252
let (prefix, style) = match line.tag {
247-
ChangeTag::Delete => ("-", Style::default().fg(Color::Red)),
248-
ChangeTag::Insert => ("+", Style::default().fg(Color::Green)),
253+
ChangeTag::Delete => ("-", Style::default().fg(theme.diff_delete)),
254+
ChangeTag::Insert => ("+", Style::default().fg(theme.diff_add)),
249255
ChangeTag::Equal => (" ", Style::default().fg(theme.dimmed)),
250256
};
251257

@@ -330,7 +336,7 @@ impl DiffView {
330336
let content = if let Some(ref error) = self.error_message {
331337
Line::from(Span::styled(error, Style::default().fg(theme.error)))
332338
} else if let Some(ref success) = self.success_message {
333-
Line::from(Span::styled(success, Style::default().fg(Color::Green)))
339+
Line::from(Span::styled(success, Style::default().fg(theme.diff_add)))
334340
} else {
335341
Line::from(vec![
336342
Span::styled("j/k", Style::default().fg(theme.accent)),
@@ -495,7 +501,7 @@ impl DiffView {
495501
)));
496502
for (key, desc) in keys {
497503
lines.push(Line::from(vec![
498-
Span::styled(format!(" {:14}", key), Style::default().fg(Color::Yellow)),
504+
Span::styled(format!(" {:14}", key), Style::default().fg(theme.help_key)),
499505
Span::styled(desc, Style::default().fg(theme.text)),
500506
]));
501507
}

src/tui/home/input.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ impl HomeView {
3535
self.settings_view = None;
3636
self.confirm_dialog = None;
3737
self.settings_close_confirm = false;
38+
// Revert theme to saved config (undo any preview)
39+
if let Ok(config) = resolve_config(self.storage.profile()) {
40+
let theme_name = if config.theme.name.is_empty() {
41+
"phosphor".to_string()
42+
} else {
43+
config.theme.name
44+
};
45+
return Some(Action::SetTheme(theme_name));
46+
}
3847
return None;
3948
}
4049
}
@@ -44,11 +53,22 @@ impl HomeView {
4453
// Handle settings view (full-screen takeover)
4554
if let Some(ref mut settings) = self.settings_view {
4655
match settings.handle_key(key) {
47-
SettingsAction::Continue => return None,
56+
SettingsAction::Continue => {
57+
return None;
58+
}
4859
SettingsAction::Close => {
4960
self.settings_view = None;
5061
// Refresh config-dependent state in case settings changed
5162
self.refresh_from_config();
63+
// Reload theme from saved config
64+
if let Ok(config) = resolve_config(self.storage.profile()) {
65+
let theme_name = if config.theme.name.is_empty() {
66+
"phosphor".to_string()
67+
} else {
68+
config.theme.name
69+
};
70+
return Some(Action::SetTheme(theme_name));
71+
}
5272
return None;
5373
}
5474
SettingsAction::UnsavedChangesWarning => {
@@ -61,6 +81,9 @@ impl HomeView {
6181
self.settings_close_confirm = true;
6282
return None;
6383
}
84+
SettingsAction::PreviewTheme(name) => {
85+
return Some(Action::SetTheme(name));
86+
}
6487
}
6588
}
6689

src/tui/home/render.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -295,15 +295,15 @@ impl HomeView {
295295
if let Some(wt_info) = &inst.worktree_info {
296296
line_spans.push(Span::styled(
297297
format!(" {}", wt_info.branch),
298-
Style::default().fg(Color::Cyan),
298+
Style::default().fg(theme.branch),
299299
));
300300
}
301301
if inst.is_sandboxed() {
302302
match self.view_mode {
303303
ViewMode::Agent => {
304304
line_spans.push(Span::styled(
305305
" [sandbox]",
306-
Style::default().fg(Color::Magenta),
306+
Style::default().fg(theme.sandbox),
307307
));
308308
}
309309
ViewMode::Terminal => {
@@ -313,7 +313,7 @@ impl HomeView {
313313
TerminalMode::Host => " [host]",
314314
};
315315
line_spans
316-
.push(Span::styled(mode_text, Style::default().fg(Color::Magenta)));
316+
.push(Span::styled(mode_text, Style::default().fg(theme.sandbox)));
317317
}
318318
}
319319
}

0 commit comments

Comments
 (0)