From 7d6fd2d84fda77ad54ae43bdabe158d7bd20329d Mon Sep 17 00:00:00 2001 From: oiupoyt Date: Sat, 26 Sep 2026 17:48:45 +0530 Subject: [PATCH] feat(tui): OpenCode styling, start screen, suggestions popup, enlarged prompt card, and themes --- src/tui/app.rs | 147 ++++++++++- src/tui/mod.rs | 88 ++++++- src/tui/ui.rs | 700 +++++++++++++++++++++++++++++++++++-------------- 3 files changed, 732 insertions(+), 203 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index cf5e3ec..6c0f40b 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -40,8 +40,80 @@ pub enum TuiMode { PersonaSelect, PipelineSelect, SessionSelect, + ThemeSelect, } +#[derive(Debug, Clone)] +pub struct CommandSuggestion { + pub name: &'static str, + pub description: &'static str, +} + +pub const ALL_COMMANDS: &[CommandSuggestion] = &[ + CommandSuggestion { + name: "/settings", + description: "Open settings menu", + }, + CommandSuggestion { + name: "/session", + description: "Resume or manage saved sessions", + }, + CommandSuggestion { + name: "/theme", + description: "Select UI color theme", + }, + CommandSuggestion { + name: "/new", + description: "Start a fresh session", + }, + CommandSuggestion { + name: "/usage", + description: "View rate limit and budget usage counters", + }, + CommandSuggestion { + name: "/config", + description: "View active system configuration", + }, + CommandSuggestion { + name: "/stealth", + description: "Toggle stealth candidate models", + }, + CommandSuggestion { + name: "/escalate", + description: "Toggle complexity escalation", + }, + CommandSuggestion { + name: "/personas", + description: "Open persona selector modal", + }, + CommandSuggestion { + name: "/pipelines", + description: "Open pipeline selector modal", + }, + CommandSuggestion { + name: "/clear", + description: "Clear current conversation screen", + }, + CommandSuggestion { + name: "/help", + description: "Show command and shortcut help", + }, +]; + +pub const THEMES: &[(&str, &str)] = &[ + ( + "opencode", + "Default OpenCode Dark - Deep slate, cyan accent, forest tools", + ), + ("warm", "Warm Amber - Dark charcoal, golden amber accents"), + ("forest", "Forest Mint - Deep pine, emerald & mint accents"), + ( + "cyberpunk", + "Cyberpunk - Midnight, neon pink & electric blue", + ), + ("mono", "Monochrome - High contrast grayscale minimal"), +]; + #[derive(Debug, Clone)] pub struct PersonaItem { pub name: String, @@ -135,6 +207,8 @@ pub struct AppState { // UX & Theme pub expand_tool_outputs: bool, pub current_theme: String, + pub selected_suggestion_idx: usize, + pub selected_theme_idx: usize, } impl AppState { @@ -162,11 +236,7 @@ impl AppState { let session_summaries = Session::list(&config_dir).unwrap_or_default(); Self { - messages: vec![ChatMessage { - sender: MessageSender::System, - content: "brokecrew initialized. [Tab] persona | [Ctrl+P] pipeline | [Ctrl+S] sessions | [Ctrl+L] clear".to_string(), - fell_back: false, - }], + messages: Vec::new(), input: String::new(), cursor_pos: 0, mode: TuiMode::Normal, @@ -194,16 +264,34 @@ impl AppState { allow_stealth: false, escalate: false, expand_tool_outputs: false, - current_theme: "default".to_string(), + current_theme: "opencode".to_string(), + selected_suggestion_idx: 0, + selected_theme_idx: 0, } } + pub fn get_matching_suggestions(&self) -> Vec<&'static CommandSuggestion> { + if !self.input.starts_with('/') { + return Vec::new(); + } + let query = self.input.to_lowercase(); + ALL_COMMANDS + .iter() + .filter(|c| c.name.starts_with(&query)) + .collect() + } + + pub fn is_start_screen(&self) -> bool { + self.messages.is_empty() + } + pub fn cycle_theme(&mut self) { self.current_theme = match self.current_theme.as_str() { - "default" => "warm".to_string(), + "opencode" => "warm".to_string(), "warm" => "forest".to_string(), - "forest" => "mono".to_string(), - _ => "default".to_string(), + "forest" => "cyberpunk".to_string(), + "cyberpunk" => "mono".to_string(), + _ => "opencode".to_string(), }; } @@ -485,3 +573,44 @@ impl AppState { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_app_start_screen_and_suggestions() { + let mut app = AppState::new(vec![], vec![], 20, 50, PathBuf::from("/tmp/brokecrew_test")); + assert!(app.is_start_screen()); + + app.input = "/se".to_string(); + let sugs = app.get_matching_suggestions(); + assert!(!sugs.is_empty()); + assert!(sugs.iter().any(|s| s.name == "/session")); + assert!(sugs.iter().any(|s| s.name == "/settings")); + + app.input = "hello world".to_string(); + let sugs2 = app.get_matching_suggestions(); + assert!(sugs2.is_empty()); + + app.add_user_message("hello".to_string()); + assert!(!app.is_start_screen()); + } + + #[test] + fn test_app_theme_cycling() { + let mut app = AppState::new(vec![], vec![], 20, 50, PathBuf::from("/tmp/brokecrew_test")); + assert_eq!(app.current_theme, "opencode"); + app.cycle_theme(); + assert_eq!(app.current_theme, "warm"); + app.cycle_theme(); + assert_eq!(app.current_theme, "forest"); + app.cycle_theme(); + assert_eq!(app.current_theme, "cyberpunk"); + app.cycle_theme(); + assert_eq!(app.current_theme, "mono"); + app.cycle_theme(); + assert_eq!(app.current_theme, "opencode"); + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 613b913..cf98c13 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -455,6 +455,28 @@ fn handle_key_event( _ => {} }, + TuiMode::ThemeSelect => match key.code { + KeyCode::Esc => { + app.mode = TuiMode::Normal; + } + KeyCode::Up => { + app.selected_theme_idx = app.selected_theme_idx.saturating_sub(1); + } + KeyCode::Down => { + if app.selected_theme_idx + 1 < app::THEMES.len() { + app.selected_theme_idx += 1; + } + } + KeyCode::Enter => { + if let Some((name, _)) = app::THEMES.get(app.selected_theme_idx) { + app.current_theme = name.to_string(); + app.log_working_event(&format!("[theme] selected {}", name)); + } + app.mode = TuiMode::Normal; + } + _ => {} + }, + TuiMode::Normal => { // Mode switching and shortcuts if key.modifiers.contains(KeyModifiers::CONTROL) { @@ -509,6 +531,46 @@ fn handle_key_event( } } + // Command suggestions navigation when typing slash command + if app.input.starts_with('/') { + let suggestions = app.get_matching_suggestions(); + if !suggestions.is_empty() { + match key.code { + KeyCode::Up => { + app.selected_suggestion_idx = + app.selected_suggestion_idx.saturating_sub(1); + return false; + } + KeyCode::Down => { + if app.selected_suggestion_idx + 1 < suggestions.len() { + app.selected_suggestion_idx += 1; + } + return false; + } + KeyCode::Tab => { + if let Some(sug) = suggestions.get(app.selected_suggestion_idx) { + app.input = format!("{} ", sug.name); + app.cursor_pos = app.input.len(); + app.selected_suggestion_idx = 0; + } + return false; + } + KeyCode::Enter if !app.input.contains(' ') => { + // If input is purely a command without arguments, select the highlighted suggestion + if let Some(sug) = suggestions.get(app.selected_suggestion_idx) { + let cmd = sug.name.to_string(); + app.input.clear(); + app.cursor_pos = 0; + app.selected_suggestion_idx = 0; + handle_slash_command(app, config, &cmd); + return false; + } + } + _ => {} + } + } + } + match key.code { KeyCode::Esc => unreachable!(), KeyCode::Tab => { @@ -534,15 +596,18 @@ fn handle_key_event( } KeyCode::Backspace => { app.delete_char_before_cursor(); + app.selected_suggestion_idx = 0; } KeyCode::Delete => { app.delete_char_under_cursor(); + app.selected_suggestion_idx = 0; } KeyCode::Enter => { let prompt = app.input.trim().to_string(); if !prompt.is_empty() && !app.is_generating { app.input.clear(); app.cursor_pos = 0; + app.selected_suggestion_idx = 0; if prompt.starts_with('/') { handle_slash_command(app, config, &prompt); @@ -571,6 +636,7 @@ fn handle_key_event( } KeyCode::Char(c) => { app.insert_char(c); + app.selected_suggestion_idx = 0; } _ => {} } @@ -621,9 +687,25 @@ fn handle_slash_command(app: &mut AppState, config: &AppConfig, input: &str) { )); } "/theme" => { - app.cycle_theme(); - let theme_name = app.current_theme.clone(); - app.add_system_message(format!("Theme set to: {theme_name}")); + if let Some(arg) = parts.get(1) { + if *arg == "next" { + app.cycle_theme(); + let theme_name = app.current_theme.clone(); + app.add_system_message(format!("Theme set to: {theme_name}")); + return; + } else if let Some((name, _)) = app::THEMES.iter().find(|(n, _)| n == arg) { + app.current_theme = name.to_string(); + app.add_system_message(format!("Theme set to: {name}")); + return; + } + } + if let Some(pos) = app::THEMES + .iter() + .position(|(name, _)| *name == app.current_theme) + { + app.selected_theme_idx = pos; + } + app.mode = TuiMode::ThemeSelect; } "/settings" => { app.add_system_message(format!( diff --git a/src/tui/ui.rs b/src/tui/ui.rs index fb8b37c..a421796 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -1,4 +1,4 @@ -use super::app::{AppState, MessageSender, TuiMode}; +use super::app::{AppState, CommandSuggestion, MessageSender, TuiMode, THEMES}; use brokecrew::persona::Tier; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Position, Rect}, @@ -8,150 +8,281 @@ use ratatui::{ Frame, }; +#[derive(Debug, Clone)] +pub struct ThemePalette { + pub accent: Color, + pub user_bg: Color, + pub card_border: Color, + pub tool_bg: Color, + pub text_bright: Color, + pub text_muted: Color, +} + +pub fn get_theme_palette(theme: &str) -> ThemePalette { + match theme { + "warm" => ThemePalette { + accent: Color::Rgb(240, 160, 60), // warm amber + user_bg: Color::Rgb(48, 40, 36), // warm charcoal + card_border: Color::Rgb(100, 80, 60), + tool_bg: Color::Rgb(36, 32, 28), + text_bright: Color::Rgb(250, 245, 235), + text_muted: Color::Rgb(160, 145, 130), + }, + "forest" => ThemePalette { + accent: Color::Rgb(80, 200, 140), // emerald mint + user_bg: Color::Rgb(30, 46, 38), // deep pine + card_border: Color::Rgb(60, 95, 75), + tool_bg: Color::Rgb(24, 38, 30), + text_bright: Color::Rgb(235, 250, 240), + text_muted: Color::Rgb(130, 165, 145), + }, + "cyberpunk" => ThemePalette { + accent: Color::Rgb(255, 60, 150), // neon pink + user_bg: Color::Rgb(42, 25, 52), // midnight magenta + card_border: Color::Rgb(110, 50, 130), + tool_bg: Color::Rgb(30, 20, 40), + text_bright: Color::Rgb(255, 230, 250), + text_muted: Color::Rgb(160, 120, 180), + }, + "mono" => ThemePalette { + accent: Color::White, + user_bg: Color::Rgb(38, 38, 38), + card_border: Color::Rgb(85, 85, 85), + tool_bg: Color::Rgb(28, 28, 28), + text_bright: Color::White, + text_muted: Color::DarkGray, + }, + _ => ThemePalette { + // opencode (default) + accent: Color::Rgb(80, 210, 230), // cyan / sky + user_bg: Color::Rgb(46, 50, 66), // deep slate + card_border: Color::Rgb(70, 90, 115), + tool_bg: Color::Rgb(30, 38, 34), + text_bright: Color::White, + text_muted: Color::Rgb(140, 145, 160), + }, + } +} + pub fn render(frame: &mut Frame, app: &mut AppState) { let size = frame.area(); + let palette = get_theme_palette(&app.current_theme); - let root_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(2), // Minimal Header + divider - Constraint::Min(6), // Main workspace (Split: Chat left, Workings right) - Constraint::Length(1), // Minimal Bottom status / shortcuts - ]) - .split(size); + if app.is_start_screen() { + render_start_screen(frame, app, size, &palette); + } else { + let root_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Sleek 1-line top session tab + Constraint::Min(6), // Main workspace (Split: Chat left, Workings right) + Constraint::Length(1), // Minimal Bottom status + ]) + .split(size); - render_header(frame, app, root_chunks[0]); - render_workspace(frame, app, root_chunks[1]); - render_footer(frame, app, root_chunks[2]); + render_header(frame, app, root_chunks[0], &palette); + render_workspace(frame, app, root_chunks[1], &palette); + render_footer(frame, app, root_chunks[2], &palette); + } match app.mode { - TuiMode::PersonaSelect => render_persona_popup(frame, app), - TuiMode::PipelineSelect => render_pipeline_popup(frame, app), - TuiMode::SessionSelect => render_session_popup(frame, app), + TuiMode::PersonaSelect => render_persona_popup(frame, app, &palette), + TuiMode::PipelineSelect => render_pipeline_popup(frame, app, &palette), + TuiMode::SessionSelect => render_session_popup(frame, app, &palette), + TuiMode::ThemeSelect => render_theme_popup(frame, app, &palette), TuiMode::Normal => {} } } -fn render_header(frame: &mut Frame, app: &AppState, area: Rect) { - let active_str = if app.is_auto_route() { - "auto".to_string() +fn render_start_screen(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemePalette) { + let v_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(16), // Top spacer + Constraint::Length(6), // ASCII Logo + Constraint::Length(2), // Subtitle + Constraint::Length(1), // Spacer + Constraint::Length(3), // Centered input card + Constraint::Min(6), // Suggestions or shortcuts + Constraint::Length(1), // Footer + ]) + .split(area); + + // 1. ASCII Logo (Compact block) + let width = area.width; + let logo_lines = if width >= 80 { + vec![ + "██████╗ ██████╗ ██████╗ ██╗ ██╗███████╗ ██████╗██████╗ ███████╗██╗ ██╗", + "██╔══██╗██╔══██╗██╔═══██╗██║ ██╔╝██╔════╝██╔════╝██╔══██╗██╔════╝██║ ██║", + "██████╔╝██████╔╝██║ ██║█████╔╝ █████╗ ██║ ██████╔╝█████╗ ██║ █╗ ██║", + "██╔══██╗██╔══██╗██║ ██║██╔═██╗ ██╔══╝ ██║ ██╔══██╗██╔══╝ ██║███╗██║", + "██████╔╝██║ ██║╚██████╔╝██║ ██╗███████╗╚██████╗██║ ██║███████╗╚███╔███╔╝", + "╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═════╝╚═╝ ╚═╝╚══════╝ ╚══╝╚══╝ ", + ] } else { - app.current_persona_name().to_string() + vec![ + " ___ _ ___ ", + " / _ )_______ / /_____ / _ \\_______ _ __ ", + " / _ / __/ _ \\/ '_/ -_) // / __/ -_) |/|/ / ", + "/____/_/ \\___/_/\\_\\\\__/\\___/_/ \\__/|__,__/ ", + "", + "", + ] }; + let logo_spans: Vec = logo_lines + .into_iter() + .map(|l| { + Line::from(Span::styled(l, Style::default().fg(palette.accent).bold())) + .alignment(Alignment::Center) + }) + .collect(); + frame.render_widget(Paragraph::new(logo_spans), v_chunks[1]); + + // 2. Subtitle + let subtitle = Line::from(vec![Span::styled( + "/help to view commands • v0.1.1", + Style::default().fg(palette.text_muted), + )]) + .alignment(Alignment::Center); + frame.render_widget(Paragraph::new(subtitle), v_chunks[2]); + + // 3. Centered input card + let h_card_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(20), + Constraint::Percentage(60), + Constraint::Percentage(20), + ]) + .split(v_chunks[4]); + + let input_area = h_card_chunks[1]; + let input_block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette.card_border)) + .style(Style::default().bg(palette.user_bg)); + + let input_inner = input_block.inner(input_area); + frame.render_widget(input_block, input_area); + + let input_display = if app.input.is_empty() { + Line::from(vec![ + Span::styled("▌ ", Style::default().fg(palette.accent)), + Span::styled( + "Ask anything or type / for commands...", + Style::default().fg(palette.text_muted).italic(), + ), + ]) + } else { + Line::from(vec![ + Span::styled("▌ ", Style::default().fg(palette.accent)), + Span::styled(&app.input, Style::default().fg(palette.text_bright).bold()), + ]) + }; + frame.render_widget(Paragraph::new(input_display), input_inner); + + // Terminal cursor positioning on start screen + let cursor_x = input_inner.x + 2 + (app.cursor_pos as u16); + let cursor_y = input_inner.y; + if cursor_x < input_inner.x + input_inner.width { + frame.set_cursor_position(Position { + x: cursor_x, + y: cursor_y, + }); + } + + // 4. Suggestions dropdown OR shortcut hint pills + let suggestions = app.get_matching_suggestions(); + if !suggestions.is_empty() { + let sug_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(20), + Constraint::Percentage(60), + Constraint::Percentage(20), + ]) + .split(v_chunks[5]); + + render_suggestions_list(frame, app, sug_chunks[1], palette, &suggestions); + } else { + let hints = Line::from(vec![ + Span::styled("Tab ", Style::default().fg(palette.accent).bold()), + Span::styled("persona • ", Style::default().fg(palette.text_muted)), + Span::styled("Ctrl+P ", Style::default().fg(palette.accent).bold()), + Span::styled("pipeline • ", Style::default().fg(palette.text_muted)), + Span::styled("/theme ", Style::default().fg(palette.accent).bold()), + Span::styled("color • ", Style::default().fg(palette.text_muted)), + Span::styled("Esc ", Style::default().fg(palette.accent).bold()), + Span::styled("quit", Style::default().fg(palette.text_muted)), + ]) + .alignment(Alignment::Center); + + let hint_area = Rect { + x: v_chunks[5].x, + y: v_chunks[5].y + 1, + width: v_chunks[5].width, + height: 1, + }; + frame.render_widget(Paragraph::new(hints), hint_area); + } + + // 5. Footer + render_footer(frame, app, v_chunks[6], palette); +} + +fn render_header(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemePalette) { + let session_title = truncate_str(&app.active_session.title, 32); + let active_persona = app.current_persona_name(); + let pipe_tag = if let Some(ref p) = app.active_pipeline_for_prompt { - Span::styled(format!(" [pipe: {p}]"), Style::default().fg(Color::Yellow)) + format!(" [{p}]") } else { - Span::raw("") + String::new() }; - let status_indicator = if app.is_generating { - let spinner_frames = ["-", "\\", "|", "/"]; - let sp = spinner_frames[app.spinner_tick % spinner_frames.len()]; - Span::styled( - format!(" {sp} thinking..."), - Style::default().fg(Color::Yellow), - ) - } else if app.esc_pressed_at.is_some() { - Span::styled( - " [esc again to quit]", - Style::default().fg(Color::LightRed).bold(), - ) - } else { - Span::styled(" * ready", Style::default().fg(Color::Green)) - }; - - let session_badge = Span::styled( - format!(" [{}]", truncate_str(&app.active_session.title, 20)), - Style::default().fg(Color::DarkGray), - ); - let left_spans = vec![ + Span::styled("⠇ ", Style::default().fg(palette.accent).bold()), Span::styled( - ":: brokecrew", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), + session_title, + Style::default().fg(palette.text_bright).bold(), ), - Span::raw(" "), Span::styled( - format!("[{active_str}]"), - Style::default() - .fg(Color::LightGreen) - .add_modifier(Modifier::BOLD), - ), - pipe_tag, - session_badge, - Span::raw(" "), - status_indicator, - ]; - - let min_color = if app.minute_requests >= 18 { - Color::Red - } else if app.minute_requests >= 14 { - Color::Yellow - } else { - Color::Green - }; - - let day_color = if app.daily_requests >= 45 { - Color::Red - } else if app.daily_requests >= 35 { - Color::Yellow - } else { - Color::Green - }; - - let right_spans = vec![ - Span::styled( - format!("1m: {}/{}", app.minute_requests, app.minute_limit), - Style::default().fg(min_color), - ), - Span::raw(" "), - Span::styled( - format!("24h: {}/{}", app.daily_requests, app.daily_limit), - Style::default().fg(day_color), - ), - Span::raw(" "), - Span::styled( - format!("models: {}", app.models_cached), - Style::default().fg(Color::DarkGray), + format!(" ({active_persona}{pipe_tag})"), + Style::default().fg(palette.text_muted), ), ]; + let right_spans = if app.is_generating { + let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + let sp = spinner_frames[app.spinner_tick % spinner_frames.len()]; + vec![Span::styled( + format!("{sp} thinking..."), + Style::default().fg(Color::Yellow), + )] + } else if app.esc_pressed_at.is_some() { + vec![Span::styled( + "[esc again to quit]", + Style::default().fg(Color::LightRed).bold(), + )] + } else { + vec![] + }; + let header_layout = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) - .split(Rect { - x: area.x, - y: area.y, - width: area.width, - height: 1, - }); + .constraints([Constraint::Percentage(70), Constraint::Percentage(30)]) + .split(area); frame.render_widget(Paragraph::new(Line::from(left_spans)), header_layout[0]); frame.render_widget( Paragraph::new(Line::from(right_spans)).alignment(Alignment::Right), header_layout[1], ); - - // Subtle divider - let divider = Line::from(Span::styled( - "─".repeat(area.width as usize), - Style::default().fg(Color::DarkGray), - )); - frame.render_widget( - Paragraph::new(divider), - Rect { - x: area.x, - y: area.y + 1, - width: area.width, - height: 1, - }, - ); } -fn render_workspace(frame: &mut Frame, app: &mut AppState, area: Rect) { +fn render_workspace(frame: &mut Frame, app: &mut AppState, area: Rect, palette: &ThemePalette) { let workspace_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([ @@ -160,11 +291,16 @@ fn render_workspace(frame: &mut Frame, app: &mut AppState, area: Rect) { ]) .split(area); - render_conversation_pane(frame, app, workspace_chunks[0]); - render_workings_pane(frame, app, workspace_chunks[1]); + render_conversation_pane(frame, app, workspace_chunks[0], palette); + render_workings_pane(frame, app, workspace_chunks[1], palette); } -fn render_conversation_pane(frame: &mut Frame, app: &mut AppState, area: Rect) { +fn render_conversation_pane( + frame: &mut Frame, + app: &mut AppState, + area: Rect, + palette: &ThemePalette, +) { let chat_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -176,34 +312,115 @@ fn render_conversation_pane(frame: &mut Frame, app: &mut AppState, area: Rect) { ]) .split(area); - render_minimal_messages(frame, app, chat_chunks[0]); + render_minimal_messages(frame, app, chat_chunks[0], palette); // Top input border let top_border = Line::from(Span::styled( "─".repeat(chat_chunks[1].width as usize), - Style::default().fg(Color::Rgb(70, 90, 110)), + Style::default().fg(palette.card_border), )); frame.render_widget(Paragraph::new(top_border), chat_chunks[1]); // Input text line - render_minimal_input(frame, app, chat_chunks[2]); + render_minimal_input(frame, app, chat_chunks[2], palette); // Bottom input border let bot_border = Line::from(Span::styled( "─".repeat(chat_chunks[3].width as usize), - Style::default().fg(Color::Rgb(70, 90, 110)), + Style::default().fg(palette.card_border), )); frame.render_widget(Paragraph::new(bot_border), chat_chunks[3]); // ~ indicator line let tilde = Line::from(vec![ Span::raw(" "), - Span::styled("~", Style::default().fg(Color::DarkGray)), + Span::styled("~", Style::default().fg(palette.text_muted)), ]); frame.render_widget(Paragraph::new(tilde), chat_chunks[4]); + + // Suggestions popup when typing / + let suggestions = app.get_matching_suggestions(); + if !suggestions.is_empty() { + render_suggestions_overlay(frame, app, chat_chunks[1], palette, &suggestions); + } } -fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect) { +fn render_suggestions_overlay( + frame: &mut Frame, + app: &AppState, + anchor: Rect, + palette: &ThemePalette, + suggestions: &[&'static CommandSuggestion], +) { + let count = suggestions.len().min(8); + let box_height = (count as u16) + 2; + let box_y = anchor.y.saturating_sub(box_height); + let sug_rect = Rect { + x: anchor.x + 1, + y: box_y, + width: anchor.width.saturating_sub(2).min(64), + height: box_height, + }; + + render_suggestions_list(frame, app, sug_rect, palette, suggestions); +} + +fn render_suggestions_list( + frame: &mut Frame, + app: &AppState, + area: Rect, + palette: &ThemePalette, + suggestions: &[&'static CommandSuggestion], +) { + frame.render_widget(Clear, area); + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette.accent)) + .style(Style::default().bg(Color::Rgb(26, 30, 42))); + + let inner = block.inner(area); + frame.render_widget(block, area); + + let count = suggestions.len().min(inner.height as usize); + let items: Vec = suggestions + .iter() + .enumerate() + .take(count) + .map(|(idx, sug)| { + let is_selected = idx == app.selected_suggestion_idx; + let (prefix, prefix_style) = if is_selected { + ("→ ", Style::default().fg(palette.accent).bold()) + } else { + (" ", Style::default().fg(Color::DarkGray)) + }; + + let line = Line::from(vec![ + Span::styled(prefix, prefix_style), + Span::styled( + format!("{:<13} ", sug.name), + if is_selected { + Style::default().fg(palette.accent).bold() + } else { + Style::default().fg(Color::White) + }, + ), + Span::styled(sug.description, Style::default().fg(palette.text_muted)), + ]); + + if is_selected { + ListItem::new(line).style(Style::default().bg(Color::Rgb(38, 44, 62))) + } else { + ListItem::new(line) + } + }) + .collect(); + + let list = List::new(items); + frame.render_widget(list, inner); +} + +fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemePalette) { let mut lines: Vec = Vec::new(); let full_width = area.width.saturating_sub(1) as usize; @@ -211,21 +428,59 @@ fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect) { match &msg.sender { MessageSender::User => { lines.push(Line::from("")); - let user_bg = Color::Rgb(46, 50, 66); + + // Top padding line + lines.push( + Line::from(vec![ + Span::styled( + "▌ ", + Style::default().fg(palette.accent).bg(palette.user_bg), + ), + Span::styled( + " ".repeat(full_width.saturating_sub(2)), + Style::default().bg(palette.user_bg), + ), + ]) + .style(Style::default().bg(palette.user_bg)), + ); + + // Content lines for text_line in msg.content.lines() { - let pad_len = full_width.saturating_sub(text_line.len() + 1); + let pad_len = full_width.saturating_sub(text_line.len() + 3); lines.push( Line::from(vec![ - Span::raw(" ").style(Style::default().bg(user_bg)), + Span::styled( + "▌ ", + Style::default().fg(palette.accent).bg(palette.user_bg), + ), Span::styled( text_line.to_string(), - Style::default().fg(Color::White).bg(user_bg), + Style::default() + .fg(palette.text_bright) + .bold() + .bg(palette.user_bg), ), - Span::styled(" ".repeat(pad_len), Style::default().bg(user_bg)), + Span::styled(" ".repeat(pad_len), Style::default().bg(palette.user_bg)), ]) - .style(Style::default().bg(user_bg)), + .style(Style::default().bg(palette.user_bg)), ); } + + // Bottom padding line + lines.push( + Line::from(vec![ + Span::styled( + "▌ ", + Style::default().fg(palette.accent).bg(palette.user_bg), + ), + Span::styled( + " ".repeat(full_width.saturating_sub(2)), + Style::default().bg(palette.user_bg), + ), + ]) + .style(Style::default().bg(palette.user_bg)), + ); + lines.push(Line::from("")); } @@ -239,7 +494,7 @@ fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect) { exit_code, duration_ms, } => { - let tool_bg = Color::Rgb(30, 38, 34); + let tool_bg = palette.tool_bg; lines.push(Line::from("")); @@ -249,53 +504,48 @@ fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect) { let h_rem = full_width.saturating_sub(header_prefix.len() + timeout_tag.len()); lines.push( Line::from(vec![ - Span::styled(" $ ", Style::default().fg(Color::White).bold().bg(tool_bg)), Span::styled( - command.clone(), - Style::default().fg(Color::White).bold().bg(tool_bg), - ), - Span::raw(" ").style(Style::default().bg(tool_bg)), - Span::styled( - timeout_tag, - Style::default().fg(Color::Rgb(130, 140, 135)).bg(tool_bg), + header_prefix, + Style::default() + .fg(Color::Rgb(170, 220, 180)) + .bold() + .bg(tool_bg), ), Span::styled(" ".repeat(h_rem), Style::default().bg(tool_bg)), + Span::styled( + timeout_tag, + Style::default().fg(Color::Rgb(110, 130, 120)).bg(tool_bg), + ), ]) .style(Style::default().bg(tool_bg)), ); - // Blank padding inside tool block - lines.push( - Line::from(vec![Span::styled( - " ".repeat(full_width), - Style::default().bg(tool_bg), - )]) - .style(Style::default().bg(tool_bg)), - ); + // Body content lines + let raw_content = msg.content.trim(); + if !raw_content.is_empty() { + let content_lines: Vec<&str> = raw_content.lines().collect(); + let total_c_lines = content_lines.len(); - let content_lines: Vec<&str> = msg.content.lines().collect(); - if !content_lines.is_empty() { - if content_lines.len() > 6 && !app.expand_tool_outputs { - let earlier_count = content_lines.len() - 5; - let earlier_msg = format!(" ... ({earlier_count} earlier lines, "); - let e_rem = full_width.saturating_sub(earlier_msg.len() + 20); + // Blank line inside tool box + lines.push( + Line::from(vec![Span::styled( + " ".repeat(full_width), + Style::default().bg(tool_bg), + )]) + .style(Style::default().bg(tool_bg)), + ); + + if !app.expand_tool_outputs && total_c_lines > 6 { + let earlier_count = total_c_lines.saturating_sub(5); + let collapsed_msg = + format!(" ... ({earlier_count} earlier lines, ctrl+o to expand)"); + let e_rem = full_width.saturating_sub(collapsed_msg.len()); lines.push( Line::from(vec![ Span::styled( - earlier_msg, + collapsed_msg, Style::default() - .fg(Color::Rgb(130, 140, 135)) - .italic() - .bg(tool_bg), - ), - Span::styled( - "ctrl+o", - Style::default().fg(Color::LightGreen).bold().bg(tool_bg), - ), - Span::styled( - " to expand)", - Style::default() - .fg(Color::Rgb(130, 140, 135)) + .fg(Color::Rgb(140, 150, 145)) .italic() .bg(tool_bg), ), @@ -379,7 +629,7 @@ fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect) { Span::raw(" "), Span::styled( text_line.to_string(), - Style::default().fg(Color::Rgb(140, 140, 150)), + Style::default().fg(palette.text_muted), ), ])); } @@ -501,15 +751,25 @@ fn parse_inline_spans(text: &str, spans: &mut Vec>) { } } -fn render_minimal_input(frame: &mut Frame, app: &AppState, area: Rect) { - let para = Paragraph::new(Line::from(vec![ - Span::raw(" "), - Span::styled(&app.input, Style::default().fg(Color::White)), - ])); - frame.render_widget(para, area); +fn render_minimal_input(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemePalette) { + let input_line = if app.input.is_empty() { + Line::from(vec![ + Span::styled("▌ ", Style::default().fg(palette.accent)), + Span::styled( + "Ask anything or type / for commands...", + Style::default().fg(palette.text_muted).italic(), + ), + ]) + } else { + Line::from(vec![ + Span::styled("▌ ", Style::default().fg(palette.accent)), + Span::styled(&app.input, Style::default().fg(palette.text_bright)), + ]) + }; + frame.render_widget(Paragraph::new(input_line), area); // Terminal cursor positioning - let cursor_x = area.x + 1 + (app.cursor_pos as u16); + let cursor_x = area.x + 2 + (app.cursor_pos as u16); let cursor_y = area.y; if cursor_x < area.x + area.width { frame.set_cursor_position(Position { @@ -519,13 +779,13 @@ fn render_minimal_input(frame: &mut Frame, app: &AppState, area: Rect) { } } -fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect) { +fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemePalette) { let block = Block::default() .borders(Borders::LEFT) - .border_style(Style::default().fg(Color::DarkGray)) + .border_style(Style::default().fg(palette.card_border)) .title(Span::styled( " models routed ", - Style::default().fg(Color::DarkGray).bold(), + Style::default().fg(palette.text_muted).bold(), )); let inner = block.inner(area); @@ -538,7 +798,7 @@ fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect) { Span::raw(" "), Span::styled( "waiting for prompt...", - Style::default().fg(Color::DarkGray).italic(), + Style::default().fg(palette.text_muted).italic(), ), ])); } else { @@ -565,7 +825,7 @@ fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect) { model_display, Style::default() .fg(if att.is_success { - Color::White + palette.text_bright } else { Color::Gray }) @@ -591,7 +851,7 @@ fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect) { } else if att.is_success { Style::default().fg(Color::LightGreen) } else { - Style::default().fg(Color::DarkGray) + Style::default().fg(palette.text_muted) }, ), ])); @@ -603,7 +863,7 @@ fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect) { frame.render_widget(para, inner); } -fn render_footer(frame: &mut Frame, app: &AppState, area: Rect) { +fn render_footer(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemePalette) { let active_str = if app.is_auto_route() { "(auto)".to_string() } else { @@ -632,12 +892,12 @@ fn render_footer(frame: &mut Frame, app: &AppState, area: Rect) { "↑0.0k ↓0.0k 1m: {}/{} 24h: {}/{} {active_str}", app.minute_requests, app.minute_limit, app.daily_requests, app.daily_limit ), - Style::default().fg(Color::DarkGray), + Style::default().fg(palette.text_muted), )]; let right_spans = vec![Span::styled( format!("{active_model} • {stealth_str}"), - Style::default().fg(Color::DarkGray), + Style::default().fg(palette.text_muted), )]; let footer_layout = Layout::default() @@ -652,7 +912,7 @@ fn render_footer(frame: &mut Frame, app: &AppState, area: Rect) { ); } -fn render_persona_popup(frame: &mut Frame, app: &AppState) { +fn render_persona_popup(frame: &mut Frame, app: &AppState, palette: &ThemePalette) { let area = centered_rect(55, 55, frame.area()); frame.render_widget(Clear, area); @@ -669,7 +929,7 @@ fn render_persona_popup(frame: &mut Frame, app: &AppState) { format!("{prefix}{:<12} ", item.name), if is_selected { Style::default() - .fg(Color::Cyan) + .fg(palette.accent) .add_modifier(Modifier::BOLD) } else { Style::default().fg(get_persona_color(&item.name)) @@ -677,7 +937,7 @@ fn render_persona_popup(frame: &mut Frame, app: &AppState) { ), Span::styled( item.description.clone(), - Style::default().fg(Color::DarkGray), + Style::default().fg(palette.text_muted), ), ]); @@ -693,11 +953,11 @@ fn render_persona_popup(frame: &mut Frame, app: &AppState) { Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(Style::default().fg(Color::Cyan)) + .border_style(Style::default().fg(palette.accent)) .title(Span::styled( " select persona [up/down, enter select, esc cancel] ", Style::default() - .fg(Color::Cyan) + .fg(palette.accent) .add_modifier(Modifier::BOLD), )), ); @@ -705,7 +965,7 @@ fn render_persona_popup(frame: &mut Frame, app: &AppState) { frame.render_widget(list, area); } -fn render_pipeline_popup(frame: &mut Frame, app: &AppState) { +fn render_pipeline_popup(frame: &mut Frame, app: &AppState, palette: &ThemePalette) { let area = centered_rect(60, 50, frame.area()); frame.render_widget(Clear, area); @@ -735,7 +995,7 @@ fn render_pipeline_popup(frame: &mut Frame, app: &AppState) { ), Span::styled( format!("({steps_str})"), - Style::default().fg(Color::DarkGray), + Style::default().fg(palette.text_muted), ), ]); @@ -764,7 +1024,7 @@ fn render_pipeline_popup(frame: &mut Frame, app: &AppState) { frame.render_widget(list, area); } -fn render_session_popup(frame: &mut Frame, app: &AppState) { +fn render_session_popup(frame: &mut Frame, app: &AppState, palette: &ThemePalette) { let area = centered_rect(65, 55, frame.area()); frame.render_widget(Clear, area); @@ -783,7 +1043,7 @@ fn render_session_popup(frame: &mut Frame, app: &AppState) { format!("{prefix}{:<24} ", truncate_str(&s.title, 24)), if is_selected { Style::default() - .fg(Color::Cyan) + .fg(palette.accent) .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) @@ -796,7 +1056,7 @@ fn render_session_popup(frame: &mut Frame, app: &AppState) { s.message_count, truncate_str(&s.updated_at, 19) ), - Style::default().fg(Color::DarkGray), + Style::default().fg(palette.text_muted), ), ]); @@ -813,11 +1073,69 @@ fn render_session_popup(frame: &mut Frame, app: &AppState) { Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(Style::default().fg(Color::Cyan)) + .border_style(Style::default().fg(palette.accent)) .title(Span::styled( " saved sessions [up/down, enter resume, esc cancel] ", Style::default() - .fg(Color::Cyan) + .fg(palette.accent) + .add_modifier(Modifier::BOLD), + )), + ); + + frame.render_widget(list, area); +} + +fn render_theme_popup(frame: &mut Frame, app: &AppState, palette: &ThemePalette) { + let area = centered_rect(60, 48, frame.area()); + frame.render_widget(Clear, area); + + let items: Vec = THEMES + .iter() + .enumerate() + .map(|(idx, (name, desc))| { + let is_selected = idx == app.selected_theme_idx; + let is_active = *name == app.current_theme; + let prefix = if is_selected { "→ " } else { " " }; + + let mut spans = vec![ + Span::styled( + format!("{prefix}{:<12} ", name), + if is_selected { + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.text_bright) + }, + ), + Span::styled(*desc, Style::default().fg(palette.text_muted)), + ]; + + if is_active { + spans.push(Span::styled( + " [active]", + Style::default().fg(Color::LightGreen).bold(), + )); + } + + let line = Line::from(spans); + if is_selected { + ListItem::new(line).style(Style::default().bg(Color::Rgb(32, 38, 54))) + } else { + ListItem::new(line) + } + }) + .collect(); + + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette.accent)) + .title(Span::styled( + " select theme [up/down, enter apply, esc cancel] ", + Style::default() + .fg(palette.accent) .add_modifier(Modifier::BOLD), )), );