diff --git a/crates/goose-tui/src/app.rs b/crates/goose-tui/src/app.rs index 39cfbddf02..e5d67faf97 100644 --- a/crates/goose-tui/src/app.rs +++ b/crates/goose-tui/src/app.rs @@ -6,6 +6,7 @@ use crate::components::popups::config::ConfigPopup; use crate::components::popups::help::HelpPopup; use crate::components::popups::message::MessagePopup; use crate::components::popups::session::SessionPopup; +use crate::components::popups::theme::ThemePopup; use crate::components::popups::todo::TodoPopup; use crate::components::status::StatusComponent; use crate::components::Component; @@ -29,6 +30,7 @@ pub struct App<'a> { builder_popup: BuilderPopup<'a>, message_popup: MessagePopup, config_popup: ConfigPopup, + theme_popup: ThemePopup, pub last_popup_close_time: Option, } @@ -52,6 +54,7 @@ impl<'a> App<'a> { builder_popup: BuilderPopup::new(), message_popup: MessagePopup::new(), config_popup: ConfigPopup::new(), + theme_popup: ThemePopup::new(), last_popup_close_time: None, } @@ -120,6 +123,15 @@ impl<'a> App<'a> { } return Ok(None); } + if state.showing_theme_picker { + if let Some(action) = self.theme_popup.handle_event(event, state)? { + if matches!(action, Action::ClosePopup | Action::ChangeTheme(_)) { + self.last_popup_close_time = Some(std::time::Instant::now()); + } + return Ok(Some(action)); + } + return Ok(None); + } Ok(None) } @@ -191,7 +203,8 @@ impl<'a> Component for App<'a> { || state.showing_session_picker || state.showing_command_builder || state.showing_message_info.is_some() - || state.showing_config; + || state.showing_config + || state.showing_theme_picker; if popup_active { if let Some(action) = self.handle_popups(event, state)? { @@ -238,6 +251,11 @@ impl<'a> Component for App<'a> { } fn render(&mut self, f: &mut Frame, area: Rect, state: &AppState) { + let theme = &state.config.theme; + let bg_block = ratatui::widgets::Block::default() + .style(ratatui::style::Style::default().bg(theme.base.background)); + f.render_widget(bg_block, f.area()); + let max_input_height = (f.area().height / 2).max(3); let input_height = self.input.height(max_input_height); @@ -264,5 +282,6 @@ impl<'a> Component for App<'a> { self.message_popup.render(f, f.area(), state); } self.config_popup.render(f, f.area(), state); + self.theme_popup.render(f, f.area(), state); } } diff --git a/crates/goose-tui/src/components/input.rs b/crates/goose-tui/src/components/input.rs index 4f7244c6de..2abf1b0470 100644 --- a/crates/goose-tui/src/components/input.rs +++ b/crates/goose-tui/src/components/input.rs @@ -79,9 +79,13 @@ impl<'a> InputComponent<'a> { .with_text(goose::agents::MANUAL_COMPACT_TRIGGER); Some(Action::SendMessage(message)) } - "/theme" => parts - .get(1) - .map(|theme_name| Action::ChangeTheme(theme_name.to_string())), + "/theme" => { + if let Some(theme_name) = parts.get(1) { + Some(Action::ChangeTheme(theme_name.to_string())) + } else { + Some(Action::OpenThemePicker) + } + } // Custom Commands _ => { let cmd_name = cmd.strip_prefix('/').unwrap_or(cmd); diff --git a/crates/goose-tui/src/components/popups/mod.rs b/crates/goose-tui/src/components/popups/mod.rs index 65671fa293..06efc44593 100644 --- a/crates/goose-tui/src/components/popups/mod.rs +++ b/crates/goose-tui/src/components/popups/mod.rs @@ -3,4 +3,5 @@ pub mod config; pub mod help; pub mod message; pub mod session; +pub mod theme; pub mod todo; diff --git a/crates/goose-tui/src/components/popups/theme.rs b/crates/goose-tui/src/components/popups/theme.rs new file mode 100644 index 0000000000..f3fc7a0d09 --- /dev/null +++ b/crates/goose-tui/src/components/popups/theme.rs @@ -0,0 +1,123 @@ +use crate::components::Component; +use crate::services::events::Event; +use crate::state::action::Action; +use crate::state::AppState; +use crate::utils::layout::centered_rect; +use crate::utils::styles::Theme; +use anyhow::Result; +use crossterm::event::{KeyCode, MouseEventKind}; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState}; +use ratatui::Frame; + +pub struct ThemePopup { + list_state: ListState, +} + +impl Default for ThemePopup { + fn default() -> Self { + Self::new() + } +} + +impl ThemePopup { + pub fn new() -> Self { + let mut list_state = ListState::default(); + list_state.select(Some(0)); + Self { list_state } + } +} + +impl Component for ThemePopup { + fn handle_event(&mut self, event: &Event, _state: &AppState) -> Result> { + let names = Theme::all_names(); + let len = names.len(); + + match event { + Event::Input(key) => match key.code { + KeyCode::Esc | KeyCode::Char('q') => return Ok(Some(Action::ClosePopup)), + KeyCode::Char('j') | KeyCode::Down => { + let cur = self.list_state.selected().unwrap_or(0); + self.list_state.select(Some((cur + 1) % len)); + } + KeyCode::Char('k') | KeyCode::Up => { + let cur = self.list_state.selected().unwrap_or(0); + self.list_state + .select(Some(cur.checked_sub(1).unwrap_or(len - 1))); + } + KeyCode::Enter => { + if let Some(idx) = self.list_state.selected() { + if let Some(&name) = names.get(idx) { + return Ok(Some(Action::ChangeTheme(name.to_string()))); + } + } + } + _ => {} + }, + Event::Mouse(m) => match m.kind { + MouseEventKind::ScrollDown => { + let cur = self.list_state.selected().unwrap_or(0); + self.list_state.select(Some((cur + 1) % len)); + } + MouseEventKind::ScrollUp => { + let cur = self.list_state.selected().unwrap_or(0); + self.list_state + .select(Some(cur.checked_sub(1).unwrap_or(len - 1))); + } + _ => {} + }, + _ => {} + } + Ok(None) + } + + fn render(&mut self, f: &mut Frame, area: Rect, state: &AppState) { + if !state.showing_theme_picker { + return; + } + + let theme = &state.config.theme; + let names = Theme::all_names(); + + let area = centered_rect(30, 50, area); + f.render_widget(Clear, area); + + let items: Vec = names + .iter() + .map(|&name| { + let is_current = name.eq_ignore_ascii_case(&theme.name); + let style = if is_current { + Style::default() + .fg(theme.status.success) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.base.foreground) + }; + let suffix = if is_current { " ✓" } else { "" }; + ListItem::new(Line::from(Span::styled( + format!("{name}{suffix}"), + style, + ))) + }) + .collect(); + + let block = Block::default() + .title("Select Theme (Enter to apply)") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(Style::default().bg(theme.base.background)); + + let list = List::new(items) + .block(block) + .highlight_style( + Style::default() + .bg(theme.base.selection) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▶ "); + + f.render_stateful_widget(list, area, &mut self.list_state); + } +} diff --git a/crates/goose-tui/src/state/action.rs b/crates/goose-tui/src/state/action.rs index b1f868c7d8..5759963271 100644 --- a/crates/goose-tui/src/state/action.rs +++ b/crates/goose-tui/src/state/action.rs @@ -36,6 +36,7 @@ pub enum Action { ResumeSession(String), CreateNewSession, ChangeTheme(String), + OpenThemePicker, ClearChat, ClosePopup, OpenMessageInfo(usize), diff --git a/crates/goose-tui/src/state/mod.rs b/crates/goose-tui/src/state/mod.rs index bae7f327ae..92f58b92bb 100644 --- a/crates/goose-tui/src/state/mod.rs +++ b/crates/goose-tui/src/state/mod.rs @@ -39,6 +39,7 @@ pub struct AppState { pub showing_command_builder: bool, pub showing_message_info: Option, pub showing_config: bool, + pub showing_theme_picker: bool, pub has_worked: bool, pub input_text_is_empty: bool, pub model_context_limit: usize, @@ -74,6 +75,7 @@ impl AppState { showing_command_builder: false, showing_message_info: None, showing_config: false, + showing_theme_picker: false, has_worked: false, input_text_is_empty: true, model_context_limit: 128_000, diff --git a/crates/goose-tui/src/state/reducer.rs b/crates/goose-tui/src/state/reducer.rs index 5f31d7b263..016b261c63 100644 --- a/crates/goose-tui/src/state/reducer.rs +++ b/crates/goose-tui/src/state/reducer.rs @@ -149,6 +149,11 @@ fn handle_ui(state: &mut AppState, action: &Action) -> bool { state.showing_command_builder = false; state.showing_message_info = None; state.showing_config = false; + state.showing_theme_picker = false; + true + } + Action::OpenThemePicker => { + state.showing_theme_picker = true; true } Action::OpenMessageInfo(idx) => { @@ -183,6 +188,8 @@ fn handle_misc(state: &mut AppState, action: &Action) -> bool { } Action::ChangeTheme(name) => { state.config.theme = crate::utils::styles::Theme::from_name(name); + state.showing_theme_picker = false; + state.needs_refresh = true; true } Action::SetInputEmpty(is_empty) => { diff --git a/crates/goose-tui/src/utils/styles.rs b/crates/goose-tui/src/utils/styles.rs index 10a5ec1bfc..4c8269d598 100644 --- a/crates/goose-tui/src/utils/styles.rs +++ b/crates/goose-tui/src/utils/styles.rs @@ -37,13 +37,34 @@ impl Default for Theme { } impl Theme { + pub fn all_names() -> Vec<&'static str> { + vec![ + "gemini", + "goose", + "light", + "dark", + "midnight", + "nord", + "dracula", + "matrix", + "tokyonight", + "solarized", + "retrowave", + ] + } + pub fn from_name(name: &str) -> Self { match name.to_lowercase().as_str() { "goose" => Theme::goose(), "light" => Theme::light(), "dark" => Theme::dark(), "midnight" => Theme::midnight(), + "nord" => Theme::nord(), + "dracula" => Theme::dracula(), "matrix" => Theme::matrix(), + "tokyonight" | "tokyo" => Theme::tokyonight(), + "solarized" | "solar" => Theme::solarized(), + "retrowave" | "retro" | "synthwave" => Theme::retrowave(), _ => Theme::gemini(), } } @@ -54,7 +75,7 @@ impl Theme { base: BaseColors { background: Color::Reset, foreground: Color::Rgb(205, 214, 244), - cursor: Color::Rgb(137, 220, 235), + cursor: Color::Rgb(166, 173, 200), selection: Color::Rgb(49, 50, 68), border: Color::Rgb(108, 112, 134), border_active: Color::Rgb(137, 180, 250), @@ -83,11 +104,11 @@ impl Theme { user_message_foreground: Color::Rgb(180, 175, 170), }, status: StatusColors { - info: Color::Blue, - success: Color::Green, - warning: Color::Yellow, - error: Color::Red, - thinking: Color::Magenta, + info: Color::Rgb(138, 173, 189), + success: Color::Rgb(169, 182, 101), + warning: Color::Rgb(234, 178, 102), + error: Color::Rgb(204, 102, 102), + thinking: Color::Rgb(212, 163, 115), }, } } @@ -96,20 +117,20 @@ impl Theme { Self { name: "Light".to_string(), base: BaseColors { - background: Color::White, - foreground: Color::Rgb(31, 41, 55), - cursor: Color::Black, - selection: Color::Rgb(219, 234, 254), - border: Color::DarkGray, - border_active: Color::Blue, - user_message_foreground: Color::Rgb(15, 20, 27), + background: Color::Rgb(255, 255, 255), + foreground: Color::Rgb(36, 41, 46), + cursor: Color::Rgb(3, 102, 214), + selection: Color::Rgb(225, 228, 232), + border: Color::Rgb(225, 228, 232), + border_active: Color::Rgb(3, 102, 214), + user_message_foreground: Color::Rgb(88, 96, 105), }, status: StatusColors { - info: Color::Blue, - success: Color::Green, - warning: Color::Yellow, - error: Color::Red, - thinking: Color::Magenta, + info: Color::Rgb(3, 102, 214), + success: Color::Rgb(34, 134, 58), + warning: Color::Rgb(227, 98, 9), + error: Color::Rgb(215, 58, 73), + thinking: Color::Rgb(111, 66, 193), }, } } @@ -118,20 +139,20 @@ impl Theme { Self { name: "Dark".to_string(), base: BaseColors { - background: Color::Rgb(30, 30, 30), - foreground: Color::Rgb(212, 212, 212), - cursor: Color::White, - selection: Color::Rgb(38, 79, 120), - border: Color::Gray, - border_active: Color::Blue, - user_message_foreground: Color::Rgb(170, 170, 170), + background: Color::Rgb(40, 44, 52), + foreground: Color::Rgb(171, 178, 191), + cursor: Color::Rgb(97, 175, 239), + selection: Color::Rgb(62, 68, 81), + border: Color::Rgb(62, 68, 81), + border_active: Color::Rgb(97, 175, 239), + user_message_foreground: Color::Rgb(130, 137, 151), }, status: StatusColors { - info: Color::Blue, - success: Color::Green, - warning: Color::Yellow, - error: Color::Red, - thinking: Color::Magenta, + info: Color::Rgb(97, 175, 239), + success: Color::Rgb(152, 195, 121), + warning: Color::Rgb(229, 192, 123), + error: Color::Rgb(224, 108, 117), + thinking: Color::Rgb(198, 120, 221), }, } } @@ -158,24 +179,134 @@ impl Theme { } } + pub fn nord() -> Self { + Self { + name: "Nord".to_string(), + base: BaseColors { + background: Color::Rgb(46, 52, 64), + foreground: Color::Rgb(216, 222, 233), + cursor: Color::Rgb(136, 192, 208), + selection: Color::Rgb(67, 76, 94), + border: Color::Rgb(67, 76, 94), + border_active: Color::Rgb(136, 192, 208), + user_message_foreground: Color::Rgb(176, 182, 196), + }, + status: StatusColors { + info: Color::Rgb(129, 161, 193), + success: Color::Rgb(163, 190, 140), + warning: Color::Rgb(235, 203, 139), + error: Color::Rgb(191, 97, 106), + thinking: Color::Rgb(180, 142, 173), + }, + } + } + + pub fn dracula() -> Self { + Self { + name: "Dracula".to_string(), + base: BaseColors { + background: Color::Rgb(40, 42, 54), + foreground: Color::Rgb(248, 248, 242), + cursor: Color::Rgb(255, 121, 198), + selection: Color::Rgb(68, 71, 90), + border: Color::Rgb(68, 71, 90), + border_active: Color::Rgb(189, 147, 249), + user_message_foreground: Color::Rgb(189, 189, 189), + }, + status: StatusColors { + info: Color::Rgb(139, 233, 253), + success: Color::Rgb(80, 250, 123), + warning: Color::Rgb(255, 184, 108), + error: Color::Rgb(255, 85, 85), + thinking: Color::Rgb(189, 147, 249), + }, + } + } + pub fn matrix() -> Self { Self { name: "Matrix".to_string(), base: BaseColors { - background: Color::Black, - foreground: Color::Green, - cursor: Color::Green, - selection: Color::DarkGray, - border: Color::DarkGray, - border_active: Color::Green, - user_message_foreground: Color::Rgb(0, 100, 0), + background: Color::Rgb(0, 10, 0), + foreground: Color::Rgb(0, 255, 65), + cursor: Color::Rgb(0, 255, 65), + selection: Color::Rgb(0, 60, 0), + border: Color::Rgb(0, 80, 0), + border_active: Color::Rgb(0, 255, 65), + user_message_foreground: Color::Rgb(0, 180, 45), }, status: StatusColors { - info: Color::Green, - success: Color::Green, - warning: Color::Yellow, - error: Color::Red, - thinking: Color::Green, + info: Color::Rgb(0, 200, 80), + success: Color::Rgb(0, 255, 65), + warning: Color::Rgb(180, 255, 0), + error: Color::Rgb(255, 50, 50), + thinking: Color::Rgb(0, 255, 130), + }, + } + } + + pub fn tokyonight() -> Self { + Self { + name: "TokyoNight".to_string(), + base: BaseColors { + background: Color::Rgb(26, 27, 38), + foreground: Color::Rgb(192, 202, 245), + cursor: Color::Rgb(125, 207, 255), + selection: Color::Rgb(43, 48, 74), + border: Color::Rgb(59, 66, 97), + border_active: Color::Rgb(125, 207, 255), + user_message_foreground: Color::Rgb(148, 156, 187), + }, + status: StatusColors { + info: Color::Rgb(125, 207, 255), + success: Color::Rgb(158, 206, 106), + warning: Color::Rgb(224, 175, 104), + error: Color::Rgb(247, 118, 142), + thinking: Color::Rgb(187, 154, 247), + }, + } + } + + pub fn solarized() -> Self { + Self { + name: "Solarized".to_string(), + base: BaseColors { + background: Color::Rgb(0, 43, 54), + foreground: Color::Rgb(131, 148, 150), + cursor: Color::Rgb(38, 139, 210), + selection: Color::Rgb(7, 54, 66), + border: Color::Rgb(88, 110, 117), + border_active: Color::Rgb(38, 139, 210), + user_message_foreground: Color::Rgb(101, 123, 131), + }, + status: StatusColors { + info: Color::Rgb(38, 139, 210), + success: Color::Rgb(133, 153, 0), + warning: Color::Rgb(181, 137, 0), + error: Color::Rgb(220, 50, 47), + thinking: Color::Rgb(108, 113, 196), + }, + } + } + + pub fn retrowave() -> Self { + Self { + name: "Retrowave".to_string(), + base: BaseColors { + background: Color::Rgb(22, 22, 30), + foreground: Color::Rgb(224, 222, 244), + cursor: Color::Rgb(255, 121, 198), + selection: Color::Rgb(52, 42, 72), + border: Color::Rgb(65, 55, 85), + border_active: Color::Rgb(255, 121, 198), + user_message_foreground: Color::Rgb(180, 175, 200), + }, + status: StatusColors { + info: Color::Rgb(125, 207, 255), + success: Color::Rgb(115, 218, 202), + warning: Color::Rgb(255, 158, 100), + error: Color::Rgb(247, 118, 142), + thinking: Color::Rgb(187, 154, 247), }, } }