From 09c8c9d83e9a74a84240f5aca8dcd9ddc5bbc18f Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sat, 1 Nov 2025 22:57:25 +0100 Subject: [PATCH] feat(ui): add TUI with streaming agent integration and theming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new terminal UI crate (crates/app/ui) built with ratatui providing an interactive chat interface with real-time LLM streaming and tool visualization. Features: - Chat panel with horizontal padding for improved readability - Input box with cursor navigation and command history - Status bar with session statistics and uniform background styling - 7 theme presets: Tokyo Night (default), Dracula, Catppuccin, Nord, Synthwave, Rose Pine, and Midnight Ocean - Theme switching via /theme and /themes commands - Streaming LLM responses that accumulate into single messages - Real-time tool call visualization with success/error states - Session tracking (messages, tokens, tool calls, duration) - REPL commands: /help, /status, /cost, /checkpoint, /rewind, /clear, /exit Integration: - CLI automatically launches TUI mode when running interactively (no prompt) - Falls back to legacy text REPL with --no-tui flag - Uses existing agent loop with streaming support - Supports all existing tools (read, write, edit, glob, grep, bash) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Cargo.toml | 1 + crates/app/cli/Cargo.toml | 2 + crates/app/cli/src/main.rs | 16 +- crates/app/ui/Cargo.toml | 23 + crates/app/ui/src/app.rs | 561 ++++++++++++++++++ crates/app/ui/src/components/chat_panel.rs | 191 ++++++ crates/app/ui/src/components/input_box.rs | 144 +++++ crates/app/ui/src/components/mod.rs | 9 + .../app/ui/src/components/permission_popup.rs | 196 ++++++ crates/app/ui/src/components/status_bar.rs | 109 ++++ crates/app/ui/src/events.rs | 38 ++ crates/app/ui/src/layout.rs | 49 ++ crates/app/ui/src/lib.rs | 21 + crates/app/ui/src/theme.rs | 257 ++++++++ 14 files changed, 1614 insertions(+), 3 deletions(-) create mode 100644 crates/app/ui/Cargo.toml create mode 100644 crates/app/ui/src/app.rs create mode 100644 crates/app/ui/src/components/chat_panel.rs create mode 100644 crates/app/ui/src/components/input_box.rs create mode 100644 crates/app/ui/src/components/mod.rs create mode 100644 crates/app/ui/src/components/permission_popup.rs create mode 100644 crates/app/ui/src/components/status_bar.rs create mode 100644 crates/app/ui/src/events.rs create mode 100644 crates/app/ui/src/layout.rs create mode 100644 crates/app/ui/src/lib.rs create mode 100644 crates/app/ui/src/theme.rs diff --git a/Cargo.toml b/Cargo.toml index 8f6a593..26cc525 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/app/cli", + "crates/app/ui", "crates/core/agent", "crates/llm/ollama", "crates/platform/config", diff --git a/crates/app/cli/Cargo.toml b/crates/app/cli/Cargo.toml index 5c08fee..b1b9cce 100644 --- a/crates/app/cli/Cargo.toml +++ b/crates/app/cli/Cargo.toml @@ -19,6 +19,8 @@ tools-slash = { path = "../../tools/slash" } config-agent = { package = "config-agent", path = "../../platform/config" } permissions = { path = "../../platform/permissions" } hooks = { path = "../../platform/hooks" } +ui = { path = "../ui" } +atty = "0.2" futures-util = "0.3.31" [dev-dependencies] diff --git a/crates/app/cli/src/main.rs b/crates/app/cli/src/main.rs index 85287ec..f13fb66 100644 --- a/crates/app/cli/src/main.rs +++ b/crates/app/cli/src/main.rs @@ -149,6 +149,9 @@ struct Args { /// Output format (text, json, stream-json) #[arg(long, value_enum, default_value = "text")] output_format: OutputFormat, + /// Disable TUI and use legacy text-based REPL + #[arg(long)] + no_tui: bool, #[arg()] prompt: Vec, #[command(subcommand)] @@ -434,15 +437,15 @@ async fn main() -> Result<()> { } } - let model = args.model.unwrap_or(settings.model); - let api_key = args.api_key.or(settings.api_key); + let model = args.model.unwrap_or(settings.model.clone()); + let api_key = args.api_key.or(settings.api_key.clone()); // Use Ollama Cloud when model has "-cloud" suffix AND API key is set let use_cloud = model.ends_with("-cloud") && api_key.is_some(); let client = if use_cloud { OllamaClient::with_cloud().with_api_key(api_key.unwrap()) } else { - let base_url = args.ollama_url.unwrap_or(settings.ollama_url); + let base_url = args.ollama_url.unwrap_or(settings.ollama_url.clone()); let mut client = OllamaClient::new(base_url); if let Some(key) = api_key { client = client.with_api_key(key); @@ -456,6 +459,13 @@ async fn main() -> Result<()> { // Check if interactive mode (no prompt provided) if args.prompt.is_empty() { + // Use TUI mode unless --no-tui flag is set or not a TTY + if !args.no_tui && atty::is(atty::Stream::Stdout) { + // Launch TUI + return ui::run(client, opts, perms, settings).await; + } + + // Legacy text-based REPL println!("🤖 Owlen Interactive Mode"); println!("Model: {}", opts.model); println!("Mode: {:?}", settings.mode); diff --git a/crates/app/ui/Cargo.toml b/crates/app/ui/Cargo.toml new file mode 100644 index 0000000..3d8ab86 --- /dev/null +++ b/crates/app/ui/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "ui" +version = "0.1.0" +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +color-eyre = "0.6" +crossterm = { version = "0.28", features = ["event-stream"] } +ratatui = "0.28" +tokio = { version = "1", features = ["full"] } +futures = "0.3" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +unicode-width = "0.2" +textwrap = "0.16" + +# Internal dependencies +agent-core = { path = "../../core/agent" } +permissions = { path = "../../platform/permissions" } +llm-ollama = { path = "../../llm/ollama" } +config-agent = { path = "../../platform/config" } diff --git a/crates/app/ui/src/app.rs b/crates/app/ui/src/app.rs new file mode 100644 index 0000000..2da7e26 --- /dev/null +++ b/crates/app/ui/src/app.rs @@ -0,0 +1,561 @@ +use crate::{ + components::{ChatMessage, ChatPanel, InputBox, PermissionPopup, StatusBar}, + events::{handle_key_event, AppEvent}, + layout::AppLayout, + theme::Theme, +}; +use agent_core::{CheckpointManager, SessionHistory, SessionStats, execute_tool, get_tool_definitions}; +use color_eyre::eyre::Result; +use crossterm::{ + event::{Event, EventStream}, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + ExecutableCommand, +}; +use futures::{StreamExt, TryStreamExt}; +use llm_ollama::{ChatMessage as LLMChatMessage, OllamaClient, OllamaOptions}; +use permissions::PermissionManager; +use ratatui::{backend::CrosstermBackend, Terminal}; +use std::{io::stdout, path::PathBuf, time::SystemTime}; +use tokio::sync::mpsc; + +pub struct TuiApp { + // UI components + chat_panel: ChatPanel, + input_box: InputBox, + status_bar: StatusBar, + permission_popup: Option, + theme: Theme, + + // Session state + stats: SessionStats, + history: SessionHistory, + checkpoint_mgr: CheckpointManager, + + // System state + client: OllamaClient, + opts: OllamaOptions, + perms: PermissionManager, + #[allow(dead_code)] + settings: config_agent::Settings, + + // Runtime state + running: bool, + waiting_for_llm: bool, +} + +impl TuiApp { + pub fn new( + client: OllamaClient, + opts: OllamaOptions, + perms: PermissionManager, + settings: config_agent::Settings, + ) -> Result { + let theme = Theme::default(); + let mode = perms.mode(); + + Ok(Self { + chat_panel: ChatPanel::new(theme.clone()), + input_box: InputBox::new(theme.clone()), + status_bar: StatusBar::new(opts.model.clone(), mode, theme.clone()), + permission_popup: None, + theme, + stats: SessionStats::new(), + history: SessionHistory::new(), + checkpoint_mgr: CheckpointManager::new(PathBuf::from(".owlen/checkpoints")), + client, + opts, + perms, + settings, + running: true, + waiting_for_llm: false, + }) + } + + fn set_theme(&mut self, theme: Theme) { + self.theme = theme.clone(); + self.chat_panel = ChatPanel::new(theme.clone()); + self.input_box = InputBox::new(theme.clone()); + self.status_bar = StatusBar::new(self.opts.model.clone(), self.perms.mode(), theme); + } + + pub async fn run(&mut self) -> Result<()> { + // Setup terminal + enable_raw_mode()?; + stdout().execute(EnterAlternateScreen)?; + + let backend = CrosstermBackend::new(stdout()); + let mut terminal = Terminal::new(backend)?; + terminal.clear()?; + + // Create event channel + let (event_tx, mut event_rx) = mpsc::unbounded_channel(); + + // Spawn terminal event listener + let tx_clone = event_tx.clone(); + tokio::spawn(async move { + let mut reader = EventStream::new(); + while let Some(event) = reader.next().await { + if let Ok(Event::Key(key)) = event { + if let Some(app_event) = handle_key_event(key) { + let _ = tx_clone.send(app_event); + } + } else if let Ok(Event::Resize(w, h)) = event { + let _ = tx_clone.send(AppEvent::Resize { + width: w, + height: h, + }); + } + } + }); + + // Add welcome message + self.chat_panel.add_message(ChatMessage::System( + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".to_string() + )); + self.chat_panel.add_message(ChatMessage::System( + format!("Welcome to Owlen - Your AI Coding Assistant") + )); + self.chat_panel.add_message(ChatMessage::System( + format!("Model: {} │ Mode: {:?} │ Theme: Tokyo Night", self.opts.model, self.perms.mode()) + )); + self.chat_panel.add_message(ChatMessage::System( + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".to_string() + )); + self.chat_panel.add_message(ChatMessage::System( + "Type your message or use /help for available commands".to_string() + )); + self.chat_panel.add_message(ChatMessage::System( + "Press Ctrl+C to exit anytime".to_string() + )); + + // Main event loop + while self.running { + // Render + terminal.draw(|frame| { + let size = frame.area(); + let layout = AppLayout::calculate(size); + + // Render main components + self.chat_panel.render(frame, layout.chat_area); + self.input_box.render(frame, layout.input_area); + self.status_bar.render(frame, layout.status_area); + + // Render permission popup if active + if let Some(popup) = &self.permission_popup { + popup.render(frame, size); + } + })?; + + // Handle events + if let Ok(event) = event_rx.try_recv() { + self.handle_event(event, &event_tx).await?; + } + + // Small delay to prevent busy-waiting + tokio::time::sleep(tokio::time::Duration::from_millis(16)).await; + } + + // Cleanup terminal + disable_raw_mode()?; + stdout().execute(LeaveAlternateScreen)?; + + Ok(()) + } + + async fn handle_event( + &mut self, + event: AppEvent, + event_tx: &mpsc::UnboundedSender, + ) -> Result<()> { + match event { + AppEvent::Input(key) => { + // If permission popup is active, handle there first + if let Some(popup) = &mut self.permission_popup { + if let Some(option) = popup.handle_key(key) { + // TODO: Handle permission decision + self.chat_panel.add_message(ChatMessage::System( + format!("Permission: {:?}", option) + )); + self.permission_popup = None; + } + } else { + // Handle input box + if let Some(message) = self.input_box.handle_key(key) { + self.handle_user_message(message, event_tx).await?; + } + } + } + AppEvent::UserMessage(message) => { + self.chat_panel + .add_message(ChatMessage::User(message.clone())); + self.history.add_user_message(message); + } + AppEvent::LlmChunk(chunk) => { + // Add to last assistant message or create new one + self.chat_panel.add_message(ChatMessage::Assistant(chunk)); + } + AppEvent::ToolCall { name, args } => { + self.chat_panel.add_message(ChatMessage::ToolCall { + name: name.clone(), + args: args.to_string(), + }); + self.status_bar.set_last_tool(name); + self.stats.record_tool_call(); + } + AppEvent::ToolResult { success, output } => { + self.chat_panel + .add_message(ChatMessage::ToolResult { success, output }); + } + AppEvent::PermissionRequest { tool, context } => { + self.permission_popup = + Some(PermissionPopup::new(tool, context, self.theme.clone())); + } + AppEvent::StatusUpdate(stats) => { + self.stats = stats.clone(); + self.status_bar.update_stats(stats); + } + AppEvent::Resize { .. } => { + // Terminal will automatically re-layout on next draw + } + AppEvent::Quit => { + self.running = false; + } + } + + Ok(()) + } + + async fn handle_user_message( + &mut self, + message: String, + _event_tx: &mpsc::UnboundedSender, + ) -> Result<()> { + // Handle slash commands + if message.starts_with('/') { + self.handle_command(&message)?; + return Ok(()); + } + + // Add user message to chat + self.chat_panel + .add_message(ChatMessage::User(message.clone())); + self.history.add_user_message(message.clone()); + + // Run agent loop with tool calling + self.waiting_for_llm = true; + let start = SystemTime::now(); + + match self.run_streaming_agent_loop(&message).await { + Ok(response) => { + self.history.add_assistant_message(response.clone()); + + // Update stats + let duration = start.elapsed().unwrap_or_default(); + let tokens = (message.len() + response.len()) / 4; // Rough estimate + self.stats.record_message(tokens, duration); + } + Err(e) => { + self.chat_panel.add_message(ChatMessage::System( + format!("❌ Error: {}", e) + )); + } + } + + self.waiting_for_llm = false; + Ok(()) + } + + async fn run_streaming_agent_loop(&mut self, user_prompt: &str) -> Result { + let tools = get_tool_definitions(); + let mut messages = vec![LLMChatMessage { + role: "user".to_string(), + content: Some(user_prompt.to_string()), + tool_calls: None, + }]; + + let max_iterations = 10; + let mut iteration = 0; + let mut final_response = String::new(); + + loop { + iteration += 1; + if iteration > max_iterations { + self.chat_panel.add_message(ChatMessage::System( + "⚠️ Max iterations reached".to_string() + )); + break; + } + + // Call LLM with streaming + let mut stream = self.client.chat_stream(&messages, &self.opts, Some(&tools)).await?; + let mut response_content = String::new(); + let mut tool_calls = None; + + // Collect the streamed response + while let Some(chunk) = stream.try_next().await? { + if let Some(msg) = chunk.message { + if let Some(content) = msg.content { + response_content.push_str(&content); + // Stream chunks to UI - append to last assistant message + self.chat_panel.append_to_assistant(&content); + } + if let Some(calls) = msg.tool_calls { + tool_calls = Some(calls); + } + } + } + + drop(stream); + + // Save the response for final return + if !response_content.is_empty() { + final_response = response_content.clone(); + } + + // Check if LLM wants to call tools + if let Some(calls) = tool_calls { + // Add assistant message with tool calls to conversation + messages.push(LLMChatMessage { + role: "assistant".to_string(), + content: if response_content.is_empty() { + None + } else { + Some(response_content.clone()) + }, + tool_calls: Some(calls.clone()), + }); + + // Execute each tool call + for call in calls { + let tool_name = &call.function.name; + let arguments = &call.function.arguments; + + // Show tool call in UI + self.chat_panel.add_message(ChatMessage::ToolCall { + name: tool_name.clone(), + args: arguments.to_string(), + }); + self.stats.record_tool_call(); + + match execute_tool(tool_name, arguments, &self.perms).await { + Ok(result) => { + // Show success in UI + self.chat_panel.add_message(ChatMessage::ToolResult { + success: true, + output: result.clone(), + }); + + // Add tool result to conversation + messages.push(LLMChatMessage { + role: "tool".to_string(), + content: Some(result), + tool_calls: None, + }); + } + Err(e) => { + let error_msg = format!("Error: {}", e); + + // Show error in UI + self.chat_panel.add_message(ChatMessage::ToolResult { + success: false, + output: error_msg.clone(), + }); + + // Add error to conversation + messages.push(LLMChatMessage { + role: "tool".to_string(), + content: Some(error_msg), + tool_calls: None, + }); + } + } + } + + // Continue loop to get next response + continue; + } + + // No tool calls, we're done + break; + } + + Ok(final_response) + } + + fn handle_command(&mut self, command: &str) -> Result<()> { + match command { + "/help" => { + self.chat_panel.add_message(ChatMessage::System( + "Available commands: /help, /status, /permissions, /cost, /history, /checkpoint, /checkpoints, /rewind, /clear, /theme, /themes, /exit".to_string(), + )); + } + "/status" => { + let elapsed = self.stats.start_time.elapsed().unwrap_or_default(); + self.chat_panel.add_message(ChatMessage::System(format!( + "Model: {} | Mode: {:?} | Messages: {} | Tools: {} | Uptime: {}", + self.opts.model, + self.perms.mode(), + self.stats.total_messages, + self.stats.total_tool_calls, + SessionStats::format_duration(elapsed) + ))); + } + "/permissions" => { + self.chat_panel.add_message(ChatMessage::System(format!( + "Permission mode: {:?}", + self.perms.mode() + ))); + } + "/cost" => { + self.chat_panel.add_message(ChatMessage::System(format!( + "Estimated tokens: ~{} | Total time: {} | Note: Ollama is free!", + self.stats.estimated_tokens, + SessionStats::format_duration(self.stats.total_duration) + ))); + } + "/history" => { + let count = self.history.user_prompts.len(); + self.chat_panel.add_message(ChatMessage::System(format!( + "Conversation has {} messages", + count + ))); + } + "/checkpoint" => { + let checkpoint_id = format!( + "checkpoint-{}", + SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + ); + match self + .checkpoint_mgr + .save_checkpoint(checkpoint_id.clone(), self.stats.clone(), &self.history) + { + Ok(_) => { + self.chat_panel.add_message(ChatMessage::System(format!( + "💾 Checkpoint saved: {}", + checkpoint_id + ))); + } + Err(e) => { + self.chat_panel.add_message(ChatMessage::System(format!( + "❌ Failed to save checkpoint: {}", + e + ))); + } + } + } + "/checkpoints" => { + match self.checkpoint_mgr.list_checkpoints() { + Ok(checkpoints) => { + if checkpoints.is_empty() { + self.chat_panel + .add_message(ChatMessage::System("No checkpoints saved yet".to_string())); + } else { + self.chat_panel.add_message(ChatMessage::System(format!( + "Saved checkpoints: {}", + checkpoints.join(", ") + ))); + } + } + Err(e) => { + self.chat_panel.add_message(ChatMessage::System(format!( + "❌ Failed to list checkpoints: {}", + e + ))); + } + } + } + "/clear" => { + self.chat_panel.clear(); + self.history.clear(); + self.stats = SessionStats::new(); + self.chat_panel + .add_message(ChatMessage::System("🗑️ Session cleared!".to_string())); + } + "/themes" => { + self.chat_panel.add_message(ChatMessage::System( + "Available themes:".to_string() + )); + self.chat_panel.add_message(ChatMessage::System( + " • tokyo-night - Modern and vibrant (default)".to_string() + )); + self.chat_panel.add_message(ChatMessage::System( + " • dracula - Classic dark theme".to_string() + )); + self.chat_panel.add_message(ChatMessage::System( + " • catppuccin - Warm and cozy".to_string() + )); + self.chat_panel.add_message(ChatMessage::System( + " • nord - Minimal and clean".to_string() + )); + self.chat_panel.add_message(ChatMessage::System( + " • synthwave - Vibrant retro".to_string() + )); + self.chat_panel.add_message(ChatMessage::System( + " • rose-pine - Elegant and muted".to_string() + )); + self.chat_panel.add_message(ChatMessage::System( + " • midnight-ocean - Deep and serene".to_string() + )); + self.chat_panel.add_message(ChatMessage::System( + "Use '/theme ' to switch themes".to_string() + )); + } + "/exit" => { + self.running = false; + } + cmd if cmd.starts_with("/theme ") => { + let theme_name = cmd.strip_prefix("/theme ").unwrap().trim(); + let new_theme = match theme_name { + "tokyo-night" => Some(Theme::tokyo_night()), + "dracula" => Some(Theme::dracula()), + "catppuccin" => Some(Theme::catppuccin()), + "nord" => Some(Theme::nord()), + "synthwave" => Some(Theme::synthwave()), + "rose-pine" => Some(Theme::rose_pine()), + "midnight-ocean" => Some(Theme::midnight_ocean()), + _ => None, + }; + + if let Some(theme) = new_theme { + self.set_theme(theme); + self.chat_panel.add_message(ChatMessage::System( + format!("🎨 Theme changed to: {}", theme_name) + )); + } else { + self.chat_panel.add_message(ChatMessage::System( + format!("❌ Unknown theme: {}. Use '/themes' to see available themes.", theme_name) + )); + } + } + cmd if cmd.starts_with("/rewind ") => { + let checkpoint_id = cmd.strip_prefix("/rewind ").unwrap().trim(); + match self.checkpoint_mgr.rewind_to(checkpoint_id) { + Ok(restored_files) => { + self.chat_panel.add_message(ChatMessage::System(format!( + "⏪ Rewound to checkpoint: {} ({} files restored)", + checkpoint_id, + restored_files.len() + ))); + } + Err(e) => { + self.chat_panel.add_message(ChatMessage::System(format!( + "❌ Failed to rewind: {}", + e + ))); + } + } + } + _ => { + self.chat_panel.add_message(ChatMessage::System(format!( + "❌ Unknown command: {}", + command + ))); + } + } + + Ok(()) + } +} diff --git a/crates/app/ui/src/components/chat_panel.rs b/crates/app/ui/src/components/chat_panel.rs new file mode 100644 index 0000000..6d2ad87 --- /dev/null +++ b/crates/app/ui/src/components/chat_panel.rs @@ -0,0 +1,191 @@ +use crate::theme::Theme; +use ratatui::{ + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span, Text}, + widgets::{Block, Borders, Padding, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, + Frame, +}; + +#[derive(Debug, Clone)] +pub enum ChatMessage { + User(String), + Assistant(String), + ToolCall { name: String, args: String }, + ToolResult { success: bool, output: String }, + System(String), +} + +pub struct ChatPanel { + messages: Vec, + scroll_offset: usize, + theme: Theme, +} + +impl ChatPanel { + pub fn new(theme: Theme) -> Self { + Self { + messages: Vec::new(), + scroll_offset: 0, + theme, + } + } + + pub fn add_message(&mut self, message: ChatMessage) { + self.messages.push(message); + // Auto-scroll to bottom on new message + self.scroll_to_bottom(); + } + + /// Append content to the last assistant message, or create a new one if none exists + pub fn append_to_assistant(&mut self, content: &str) { + if let Some(ChatMessage::Assistant(last_content)) = self.messages.last_mut() { + last_content.push_str(content); + } else { + self.messages.push(ChatMessage::Assistant(content.to_string())); + } + // Auto-scroll to bottom on update + self.scroll_to_bottom(); + } + + pub fn scroll_up(&mut self) { + self.scroll_offset = self.scroll_offset.saturating_sub(1); + } + + pub fn scroll_down(&mut self) { + self.scroll_offset = self.scroll_offset.saturating_add(1); + } + + pub fn scroll_to_bottom(&mut self) { + self.scroll_offset = self.messages.len().saturating_sub(1); + } + + pub fn render(&self, frame: &mut Frame, area: Rect) { + let mut text_lines = Vec::new(); + + for message in &self.messages { + match message { + ChatMessage::User(content) => { + text_lines.push(Line::from(vec![ + Span::styled("❯ ", self.theme.user_message), + Span::styled(content, self.theme.user_message), + ])); + text_lines.push(Line::from("")); + } + ChatMessage::Assistant(content) => { + // Wrap long lines + let wrapped = textwrap::wrap(content, area.width.saturating_sub(6) as usize); + for (i, line) in wrapped.iter().enumerate() { + if i == 0 { + text_lines.push(Line::from(vec![ + Span::styled(" ", self.theme.assistant_message), + Span::styled(line.to_string(), self.theme.assistant_message), + ])); + } else { + text_lines.push(Line::styled( + format!(" {}", line), + self.theme.assistant_message, + )); + } + } + text_lines.push(Line::from("")); + } + ChatMessage::ToolCall { name, args } => { + text_lines.push(Line::from(vec![ + Span::styled(" ⚡ ", self.theme.tool_call), + Span::styled( + format!("{} ", name), + self.theme.tool_call, + ), + Span::styled( + args, + self.theme.tool_call.add_modifier(Modifier::DIM), + ), + ])); + } + ChatMessage::ToolResult { success, output } => { + let style = if *success { + self.theme.tool_result_success + } else { + self.theme.tool_result_error + }; + let icon = if *success { " ✓" } else { " ✗" }; + + // Truncate long output + let display_output = if output.len() > 200 { + format!("{}... [truncated]", &output[..200]) + } else { + output.clone() + }; + + text_lines.push(Line::from(vec![ + Span::styled(icon, style), + Span::raw(" "), + Span::styled(display_output, style.add_modifier(Modifier::DIM)), + ])); + text_lines.push(Line::from("")); + } + ChatMessage::System(content) => { + text_lines.push(Line::from(vec![ + Span::styled(" ○ ", Style::default().fg(self.theme.palette.info)), + Span::styled( + content, + Style::default().fg(self.theme.palette.fg_dim), + ), + ])); + text_lines.push(Line::from("")); + } + } + } + + let text = Text::from(text_lines); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(self.theme.border_active) + .padding(Padding::horizontal(1)) + .title(Line::from(vec![ + Span::raw(" "), + Span::styled("💬", self.theme.border_active), + Span::raw(" "), + Span::styled("Chat", self.theme.border_active), + Span::raw(" "), + ])); + + let paragraph = Paragraph::new(text) + .block(block) + .scroll((self.scroll_offset as u16, 0)); + + frame.render_widget(paragraph, area); + + // Render scrollbar if needed + if self.messages.len() > area.height as usize { + let scrollbar = Scrollbar::default() + .orientation(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("▲")) + .end_symbol(Some("▼")) + .track_symbol(Some("│")) + .thumb_symbol("█") + .style(self.theme.border); + + let mut scrollbar_state = ScrollbarState::default() + .content_length(self.messages.len()) + .position(self.scroll_offset); + + frame.render_stateful_widget( + scrollbar, + area, + &mut scrollbar_state, + ); + } + } + + pub fn messages(&self) -> &[ChatMessage] { + &self.messages + } + + pub fn clear(&mut self) { + self.messages.clear(); + self.scroll_offset = 0; + } +} diff --git a/crates/app/ui/src/components/input_box.rs b/crates/app/ui/src/components/input_box.rs new file mode 100644 index 0000000..12d665c --- /dev/null +++ b/crates/app/ui/src/components/input_box.rs @@ -0,0 +1,144 @@ +use crate::theme::Theme; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + layout::Rect, + style::Style, + text::{Line, Span}, + widgets::{Block, Borders, Padding, Paragraph}, + Frame, +}; + +pub struct InputBox { + input: String, + cursor_position: usize, + history: Vec, + history_index: usize, + theme: Theme, +} + +impl InputBox { + pub fn new(theme: Theme) -> Self { + Self { + input: String::new(), + cursor_position: 0, + history: Vec::new(), + history_index: 0, + theme, + } + } + + pub fn handle_key(&mut self, key: KeyEvent) -> Option { + match key.code { + KeyCode::Enter => { + let message = self.input.clone(); + if !message.trim().is_empty() { + self.history.push(message.clone()); + self.history_index = self.history.len(); + self.input.clear(); + self.cursor_position = 0; + return Some(message); + } + } + KeyCode::Char(c) => { + self.input.insert(self.cursor_position, c); + self.cursor_position += 1; + } + KeyCode::Backspace => { + if self.cursor_position > 0 { + self.input.remove(self.cursor_position - 1); + self.cursor_position -= 1; + } + } + KeyCode::Delete => { + if self.cursor_position < self.input.len() { + self.input.remove(self.cursor_position); + } + } + KeyCode::Left => { + self.cursor_position = self.cursor_position.saturating_sub(1); + } + KeyCode::Right => { + if self.cursor_position < self.input.len() { + self.cursor_position += 1; + } + } + KeyCode::Home => { + self.cursor_position = 0; + } + KeyCode::End => { + self.cursor_position = self.input.len(); + } + KeyCode::Up => { + if !self.history.is_empty() && self.history_index > 0 { + self.history_index -= 1; + self.input = self.history[self.history_index].clone(); + self.cursor_position = self.input.len(); + } + } + KeyCode::Down => { + if self.history_index < self.history.len() - 1 { + self.history_index += 1; + self.input = self.history[self.history_index].clone(); + self.cursor_position = self.input.len(); + } else if self.history_index < self.history.len() { + self.history_index = self.history.len(); + self.input.clear(); + self.cursor_position = 0; + } + } + _ => {} + } + + None + } + + pub fn render(&self, frame: &mut Frame, area: Rect) { + let is_empty = self.input.is_empty(); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(self.theme.border_active) + .padding(Padding::horizontal(1)) + .title(Line::from(vec![ + Span::raw(" "), + Span::styled("✍", self.theme.border_active), + Span::raw(" "), + Span::styled("Input", self.theme.border_active), + Span::raw(" "), + ])); + + // Display input with cursor + let (text_before, text_after) = if self.cursor_position < self.input.len() { + ( + &self.input[..self.cursor_position], + &self.input[self.cursor_position..], + ) + } else { + (&self.input[..], "") + }; + + let line = if is_empty { + Line::from(vec![ + Span::styled("❯ ", self.theme.input_box_active), + Span::styled("▊", self.theme.input_box_active), + Span::styled(" Type a message...", Style::default().fg(self.theme.palette.fg_dim)), + ]) + } else { + Line::from(vec![ + Span::styled("❯ ", self.theme.input_box_active), + Span::styled(text_before, self.theme.input_box), + Span::styled("▊", self.theme.input_box_active), + Span::styled(text_after, self.theme.input_box), + ]) + }; + + let paragraph = Paragraph::new(line).block(block); + + frame.render_widget(paragraph, area); + } + + pub fn clear(&mut self) { + self.input.clear(); + self.cursor_position = 0; + } +} diff --git a/crates/app/ui/src/components/mod.rs b/crates/app/ui/src/components/mod.rs new file mode 100644 index 0000000..5ac19c9 --- /dev/null +++ b/crates/app/ui/src/components/mod.rs @@ -0,0 +1,9 @@ +mod chat_panel; +mod input_box; +mod permission_popup; +mod status_bar; + +pub use chat_panel::{ChatMessage, ChatPanel}; +pub use input_box::InputBox; +pub use permission_popup::{PermissionOption, PermissionPopup}; +pub use status_bar::StatusBar; diff --git a/crates/app/ui/src/components/permission_popup.rs b/crates/app/ui/src/components/permission_popup.rs new file mode 100644 index 0000000..65e6da9 --- /dev/null +++ b/crates/app/ui/src/components/permission_popup.rs @@ -0,0 +1,196 @@ +use crate::theme::Theme; +use crossterm::event::{KeyCode, KeyEvent}; +use permissions::PermissionDecision; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +#[derive(Debug, Clone)] +pub enum PermissionOption { + AllowOnce, + AlwaysAllow, + Deny, + Explain, +} + +pub struct PermissionPopup { + tool: String, + context: Option, + selected: usize, + theme: Theme, +} + +impl PermissionPopup { + pub fn new(tool: String, context: Option, theme: Theme) -> Self { + Self { + tool, + context, + selected: 0, + theme, + } + } + + pub fn handle_key(&mut self, key: KeyEvent) -> Option { + match key.code { + KeyCode::Char('a') => Some(PermissionOption::AllowOnce), + KeyCode::Char('A') => Some(PermissionOption::AlwaysAllow), + KeyCode::Char('d') => Some(PermissionOption::Deny), + KeyCode::Char('?') => Some(PermissionOption::Explain), + KeyCode::Up => { + self.selected = self.selected.saturating_sub(1); + None + } + KeyCode::Down => { + if self.selected < 3 { + self.selected += 1; + } + None + } + KeyCode::Enter => match self.selected { + 0 => Some(PermissionOption::AllowOnce), + 1 => Some(PermissionOption::AlwaysAllow), + 2 => Some(PermissionOption::Deny), + 3 => Some(PermissionOption::Explain), + _ => None, + }, + KeyCode::Esc => Some(PermissionOption::Deny), + _ => None, + } + } + + pub fn render(&self, frame: &mut Frame, area: Rect) { + // Center the popup + let popup_area = crate::layout::AppLayout::center_popup(area, 64, 14); + + // Clear the area behind the popup + frame.render_widget(Clear, popup_area); + + // Render popup with styled border + let block = Block::default() + .borders(Borders::ALL) + .border_style(self.theme.popup_border) + .style(self.theme.popup_bg) + .title(Line::from(vec![ + Span::raw(" "), + Span::styled("🔒", self.theme.popup_title), + Span::raw(" "), + Span::styled("Permission Required", self.theme.popup_title), + Span::raw(" "), + ])); + + frame.render_widget(block, popup_area); + + // Split popup into sections + let inner = popup_area.inner(ratatui::layout::Margin { + vertical: 1, + horizontal: 2, + }); + + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), // Tool name with box + Constraint::Length(3), // Context (if any) + Constraint::Length(1), // Separator + Constraint::Length(1), // Option 1 + Constraint::Length(1), // Option 2 + Constraint::Length(1), // Option 3 + Constraint::Length(1), // Option 4 + Constraint::Length(1), // Help text + ]) + .split(inner); + + // Tool name with highlight + let tool_line = Line::from(vec![ + Span::styled("⚡ Tool: ", Style::default().fg(self.theme.palette.warning)), + Span::styled(&self.tool, self.theme.popup_title), + ]); + frame.render_widget(Paragraph::new(tool_line), sections[0]); + + // Context with wrapping + if let Some(ctx) = &self.context { + let context_text = if ctx.len() > 100 { + format!("{}...", &ctx[..100]) + } else { + ctx.clone() + }; + let context_lines = textwrap::wrap(&context_text, (sections[1].width - 2) as usize); + let mut lines = vec![ + Line::from(vec![ + Span::styled("📝 Context: ", Style::default().fg(self.theme.palette.info)), + ]) + ]; + for line in context_lines.iter().take(2) { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(line.to_string(), Style::default().fg(self.theme.palette.fg_dim)), + ])); + } + frame.render_widget(Paragraph::new(lines), sections[1]); + } + + // Separator + let separator = Line::styled( + "─".repeat(sections[2].width as usize), + Style::default().fg(self.theme.palette.border), + ); + frame.render_widget(Paragraph::new(separator), sections[2]); + + // Options with icons and colors + let options = [ + ("✓", " [a] Allow once", self.theme.palette.success, 0), + ("✓✓", " [A] Always allow", self.theme.palette.primary, 1), + ("✗", " [d] Deny", self.theme.palette.error, 2), + ("?", " [?] Explain", self.theme.palette.info, 3), + ]; + + for (icon, text, color, idx) in options.iter() { + let (style, prefix) = if self.selected == *idx { + ( + self.theme.selected, + "▶ " + ) + } else { + ( + Style::default().fg(*color), + " " + ) + }; + + let line = Line::from(vec![ + Span::styled(prefix, style), + Span::styled(*icon, style), + Span::styled(*text, style), + ]); + frame.render_widget(Paragraph::new(line), sections[3 + idx]); + } + + // Help text at bottom + let help_line = Line::from(vec![ + Span::styled( + "↑↓ Navigate Enter to select Esc to deny", + Style::default().fg(self.theme.palette.fg_dim).add_modifier(Modifier::ITALIC), + ), + ]); + frame.render_widget(Paragraph::new(help_line), sections[7]); + } +} + +impl PermissionOption { + pub fn to_decision(&self) -> Option { + match self { + PermissionOption::AllowOnce => Some(PermissionDecision::Allow), + PermissionOption::AlwaysAllow => Some(PermissionDecision::Allow), + PermissionOption::Deny => Some(PermissionDecision::Deny), + PermissionOption::Explain => None, // Special handling needed + } + } + + pub fn should_persist(&self) -> bool { + matches!(self, PermissionOption::AlwaysAllow) + } +} diff --git a/crates/app/ui/src/components/status_bar.rs b/crates/app/ui/src/components/status_bar.rs new file mode 100644 index 0000000..e03465c --- /dev/null +++ b/crates/app/ui/src/components/status_bar.rs @@ -0,0 +1,109 @@ +use crate::theme::Theme; +use agent_core::SessionStats; +use permissions::Mode; +use ratatui::{ + layout::Rect, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; + +pub struct StatusBar { + model: String, + mode: Mode, + stats: SessionStats, + last_tool: Option, + theme: Theme, +} + +impl StatusBar { + pub fn new(model: String, mode: Mode, theme: Theme) -> Self { + Self { + model, + mode, + stats: SessionStats::new(), + last_tool: None, + theme, + } + } + + pub fn update_stats(&mut self, stats: SessionStats) { + self.stats = stats; + } + + pub fn set_last_tool(&mut self, tool: String) { + self.last_tool = Some(tool); + } + + pub fn render(&self, frame: &mut Frame, area: Rect) { + let elapsed = self.stats.start_time.elapsed().unwrap_or_default(); + let elapsed_str = SessionStats::format_duration(elapsed); + + let (mode_str, mode_icon) = match self.mode { + Mode::Plan => ("Plan", "🔍"), + Mode::AcceptEdits => ("AcceptEdits", "✏️"), + Mode::Code => ("Code", "⚡"), + }; + + let last_tool_str = self + .last_tool + .as_ref() + .map(|t| format!("● {}", t)) + .unwrap_or_else(|| "○ idle".to_string()); + + // Build status line with colorful sections + let separator_style = self.theme.status_bar; + let mut spans = vec![ + Span::styled(" ", separator_style), + Span::styled(mode_icon, self.theme.status_bar), + Span::styled(" ", separator_style), + Span::styled(mode_str, self.theme.status_bar), + Span::styled(" │ ", separator_style), + Span::styled("⚙", self.theme.status_bar), + Span::styled(" ", separator_style), + Span::styled(&self.model, self.theme.status_bar), + Span::styled(" │ ", separator_style), + Span::styled( + format!("{} msgs", self.stats.total_messages), + self.theme.status_bar, + ), + Span::styled(" │ ", separator_style), + Span::styled( + format!("{} tools", self.stats.total_tool_calls), + self.theme.status_bar, + ), + Span::styled(" │ ", separator_style), + Span::styled( + format!("~{} tok", self.stats.estimated_tokens), + self.theme.status_bar, + ), + Span::styled(" │ ", separator_style), + Span::styled("⏱", self.theme.status_bar), + Span::styled(" ", separator_style), + Span::styled(elapsed_str, self.theme.status_bar), + Span::styled(" │ ", separator_style), + Span::styled(last_tool_str, self.theme.status_bar), + ]; + + // Add help text on the right + let help_text = " ? /help "; + + // Calculate current length + let current_len: usize = spans.iter() + .map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref())) + .sum(); + + // Add padding + let padding = area + .width + .saturating_sub((current_len + help_text.len()) as u16); + + spans.push(Span::styled(" ".repeat(padding as usize), separator_style)); + spans.push(Span::styled(help_text, self.theme.status_bar)); + + let line = Line::from(spans); + let paragraph = Paragraph::new(line); + + frame.render_widget(paragraph, area); + } +} diff --git a/crates/app/ui/src/events.rs b/crates/app/ui/src/events.rs new file mode 100644 index 0000000..06bb3ff --- /dev/null +++ b/crates/app/ui/src/events.rs @@ -0,0 +1,38 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use serde_json::Value; + +/// Application events that drive the TUI +#[derive(Debug, Clone)] +pub enum AppEvent { + /// User input from keyboard + Input(KeyEvent), + /// User submitted a message + UserMessage(String), + /// LLM response chunk + LlmChunk(String), + /// Tool call started + ToolCall { name: String, args: Value }, + /// Tool execution result + ToolResult { success: bool, output: String }, + /// Permission request from agent + PermissionRequest { + tool: String, + context: Option, + }, + /// Session statistics updated + StatusUpdate(agent_core::SessionStats), + /// Terminal was resized + Resize { width: u16, height: u16 }, + /// Application should quit + Quit, +} + +/// Process keyboard input into app events +pub fn handle_key_event(key: KeyEvent) -> Option { + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + Some(AppEvent::Quit) + } + _ => Some(AppEvent::Input(key)), + } +} diff --git a/crates/app/ui/src/layout.rs b/crates/app/ui/src/layout.rs new file mode 100644 index 0000000..fa66995 --- /dev/null +++ b/crates/app/ui/src/layout.rs @@ -0,0 +1,49 @@ +use ratatui::layout::{Constraint, Direction, Layout, Rect}; + +/// Calculate layout areas for the TUI +pub struct AppLayout { + pub chat_area: Rect, + pub input_area: Rect, + pub status_area: Rect, +} + +impl AppLayout { + /// Calculate layout from terminal size + pub fn calculate(area: Rect) -> Self { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(3), // Chat area (grows) + Constraint::Length(3), // Input area (fixed height) + Constraint::Length(1), // Status bar (fixed height) + ]) + .split(area); + + Self { + chat_area: chunks[0], + input_area: chunks[1], + status_area: chunks[2], + } + } + + /// Center a popup in the given area + pub fn center_popup(area: Rect, width: u16, height: u16) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length((area.height.saturating_sub(height)) / 2), + Constraint::Length(height), + Constraint::Length((area.height.saturating_sub(height)) / 2), + ]) + .split(area); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length((area.width.saturating_sub(width)) / 2), + Constraint::Length(width), + Constraint::Length((area.width.saturating_sub(width)) / 2), + ]) + .split(popup_layout[1])[1] + } +} diff --git a/crates/app/ui/src/lib.rs b/crates/app/ui/src/lib.rs new file mode 100644 index 0000000..a00f586 --- /dev/null +++ b/crates/app/ui/src/lib.rs @@ -0,0 +1,21 @@ +pub mod app; +pub mod components; +pub mod events; +pub mod layout; +pub mod theme; + +pub use app::TuiApp; +pub use events::AppEvent; + +use color_eyre::eyre::Result; + +/// Run the TUI application +pub async fn run( + client: llm_ollama::OllamaClient, + opts: llm_ollama::OllamaOptions, + perms: permissions::PermissionManager, + settings: config_agent::Settings, +) -> Result<()> { + let mut app = TuiApp::new(client, opts, perms, settings)?; + app.run().await +} diff --git a/crates/app/ui/src/theme.rs b/crates/app/ui/src/theme.rs new file mode 100644 index 0000000..f3332f4 --- /dev/null +++ b/crates/app/ui/src/theme.rs @@ -0,0 +1,257 @@ +use ratatui::style::{Color, Modifier, Style}; + +/// Modern color palette inspired by contemporary design systems +#[derive(Debug, Clone)] +pub struct ColorPalette { + pub primary: Color, + pub secondary: Color, + pub accent: Color, + pub success: Color, + pub warning: Color, + pub error: Color, + pub info: Color, + pub bg: Color, + pub fg: Color, + pub fg_dim: Color, + pub border: Color, + pub highlight: Color, +} + +impl ColorPalette { + /// Tokyo Night inspired palette - vibrant and modern + pub fn tokyo_night() -> Self { + Self { + primary: Color::Rgb(122, 162, 247), // Bright blue + secondary: Color::Rgb(187, 154, 247), // Purple + accent: Color::Rgb(255, 158, 100), // Orange + success: Color::Rgb(158, 206, 106), // Green + warning: Color::Rgb(224, 175, 104), // Yellow + error: Color::Rgb(247, 118, 142), // Pink/Red + info: Color::Rgb(125, 207, 255), // Cyan + bg: Color::Rgb(26, 27, 38), // Dark bg + fg: Color::Rgb(192, 202, 245), // Light text + fg_dim: Color::Rgb(86, 95, 137), // Dimmed text + border: Color::Rgb(77, 124, 254), // Blue border + highlight: Color::Rgb(56, 62, 90), // Selection bg + } + } + + /// Dracula inspired palette - classic and elegant + pub fn dracula() -> Self { + Self { + primary: Color::Rgb(139, 233, 253), // Cyan + secondary: Color::Rgb(189, 147, 249), // Purple + accent: Color::Rgb(255, 121, 198), // Pink + success: Color::Rgb(80, 250, 123), // Green + warning: Color::Rgb(241, 250, 140), // Yellow + error: Color::Rgb(255, 85, 85), // Red + info: Color::Rgb(139, 233, 253), // Cyan + bg: Color::Rgb(40, 42, 54), // Dark bg + fg: Color::Rgb(248, 248, 242), // Light text + fg_dim: Color::Rgb(98, 114, 164), // Comment + border: Color::Rgb(98, 114, 164), // Border + highlight: Color::Rgb(68, 71, 90), // Selection + } + } + + /// Catppuccin Mocha - warm and cozy + pub fn catppuccin() -> Self { + Self { + primary: Color::Rgb(137, 180, 250), // Blue + secondary: Color::Rgb(203, 166, 247), // Mauve + accent: Color::Rgb(245, 194, 231), // Pink + success: Color::Rgb(166, 227, 161), // Green + warning: Color::Rgb(249, 226, 175), // Yellow + error: Color::Rgb(243, 139, 168), // Red + info: Color::Rgb(148, 226, 213), // Teal + bg: Color::Rgb(30, 30, 46), // Base + fg: Color::Rgb(205, 214, 244), // Text + fg_dim: Color::Rgb(108, 112, 134), // Overlay + border: Color::Rgb(137, 180, 250), // Blue + highlight: Color::Rgb(49, 50, 68), // Surface + } + } + + /// Nord - minimal and clean + pub fn nord() -> Self { + Self { + primary: Color::Rgb(136, 192, 208), // Frost cyan + secondary: Color::Rgb(129, 161, 193), // Frost blue + accent: Color::Rgb(180, 142, 173), // Aurora purple + success: Color::Rgb(163, 190, 140), // Aurora green + warning: Color::Rgb(235, 203, 139), // Aurora yellow + error: Color::Rgb(191, 97, 106), // Aurora red + info: Color::Rgb(136, 192, 208), // Frost cyan + bg: Color::Rgb(46, 52, 64), // Polar night + fg: Color::Rgb(236, 239, 244), // Snow storm + fg_dim: Color::Rgb(76, 86, 106), // Polar night light + border: Color::Rgb(129, 161, 193), // Frost + highlight: Color::Rgb(59, 66, 82), // Selection + } + } + + /// Synthwave - vibrant and retro + pub fn synthwave() -> Self { + Self { + primary: Color::Rgb(255, 0, 128), // Hot pink + secondary: Color::Rgb(0, 229, 255), // Cyan + accent: Color::Rgb(255, 128, 0), // Orange + success: Color::Rgb(0, 255, 157), // Neon green + warning: Color::Rgb(255, 215, 0), // Gold + error: Color::Rgb(255, 64, 64), // Neon red + info: Color::Rgb(0, 229, 255), // Cyan + bg: Color::Rgb(20, 16, 32), // Dark purple + fg: Color::Rgb(242, 233, 255), // Light purple + fg_dim: Color::Rgb(127, 90, 180), // Mid purple + border: Color::Rgb(255, 0, 128), // Hot pink + highlight: Color::Rgb(72, 12, 168), // Deep purple + } + } + + /// Rose Pine - elegant and muted + pub fn rose_pine() -> Self { + Self { + primary: Color::Rgb(156, 207, 216), // Foam + secondary: Color::Rgb(235, 188, 186), // Rose + accent: Color::Rgb(234, 154, 151), // Love + success: Color::Rgb(49, 116, 143), // Pine + warning: Color::Rgb(246, 193, 119), // Gold + error: Color::Rgb(235, 111, 146), // Love (darker) + info: Color::Rgb(156, 207, 216), // Foam + bg: Color::Rgb(25, 23, 36), // Base + fg: Color::Rgb(224, 222, 244), // Text + fg_dim: Color::Rgb(110, 106, 134), // Muted + border: Color::Rgb(156, 207, 216), // Foam + highlight: Color::Rgb(42, 39, 63), // Highlight + } + } + + /// Midnight Ocean - deep and serene + pub fn midnight_ocean() -> Self { + Self { + primary: Color::Rgb(102, 217, 239), // Bright cyan + secondary: Color::Rgb(130, 170, 255), // Periwinkle + accent: Color::Rgb(199, 146, 234), // Purple + success: Color::Rgb(163, 190, 140), // Sea green + warning: Color::Rgb(229, 200, 144), // Sandy yellow + error: Color::Rgb(236, 95, 103), // Coral red + info: Color::Rgb(102, 217, 239), // Bright cyan + bg: Color::Rgb(1, 22, 39), // Deep ocean + fg: Color::Rgb(201, 211, 235), // Light blue-white + fg_dim: Color::Rgb(71, 103, 145), // Muted blue + border: Color::Rgb(102, 217, 239), // Bright cyan + highlight: Color::Rgb(13, 43, 69), // Deep blue + } + } +} + +/// Theme configuration for the TUI +#[derive(Debug, Clone)] +pub struct Theme { + pub palette: ColorPalette, + pub user_message: Style, + pub assistant_message: Style, + pub tool_call: Style, + pub tool_result_success: Style, + pub tool_result_error: Style, + pub status_bar: Style, + pub status_bar_highlight: Style, + pub input_box: Style, + pub input_box_active: Style, + pub popup_border: Style, + pub popup_bg: Style, + pub popup_title: Style, + pub selected: Style, + pub border: Style, + pub border_active: Style, +} + +impl Theme { + /// Create theme from color palette + pub fn from_palette(palette: ColorPalette) -> Self { + Self { + user_message: Style::default() + .fg(palette.primary) + .add_modifier(Modifier::BOLD), + assistant_message: Style::default().fg(palette.fg), + tool_call: Style::default() + .fg(palette.warning) + .add_modifier(Modifier::ITALIC), + tool_result_success: Style::default() + .fg(palette.success) + .add_modifier(Modifier::BOLD), + tool_result_error: Style::default() + .fg(palette.error) + .add_modifier(Modifier::BOLD), + status_bar: Style::default() + .fg(palette.bg) + .bg(palette.primary) + .add_modifier(Modifier::BOLD), + status_bar_highlight: Style::default() + .fg(palette.bg) + .bg(palette.accent) + .add_modifier(Modifier::BOLD), + input_box: Style::default().fg(palette.fg), + input_box_active: Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD), + popup_border: Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD), + popup_bg: Style::default().bg(palette.highlight), + popup_title: Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD), + selected: Style::default() + .fg(palette.bg) + .bg(palette.accent) + .add_modifier(Modifier::BOLD), + border: Style::default().fg(palette.border), + border_active: Style::default() + .fg(palette.primary) + .add_modifier(Modifier::BOLD), + palette, + } + } + + /// Tokyo Night theme (default) - modern and vibrant + pub fn tokyo_night() -> Self { + Self::from_palette(ColorPalette::tokyo_night()) + } + + /// Dracula theme - classic dark theme + pub fn dracula() -> Self { + Self::from_palette(ColorPalette::dracula()) + } + + /// Catppuccin Mocha - warm and cozy + pub fn catppuccin() -> Self { + Self::from_palette(ColorPalette::catppuccin()) + } + + /// Nord theme - minimal and clean + pub fn nord() -> Self { + Self::from_palette(ColorPalette::nord()) + } + + /// Synthwave theme - vibrant retro + pub fn synthwave() -> Self { + Self::from_palette(ColorPalette::synthwave()) + } + + /// Rose Pine theme - elegant and muted + pub fn rose_pine() -> Self { + Self::from_palette(ColorPalette::rose_pine()) + } + + /// Midnight Ocean theme - deep and serene + pub fn midnight_ocean() -> Self { + Self::from_palette(ColorPalette::midnight_ocean()) + } +} + +impl Default for Theme { + fn default() -> Self { + Self::tokyo_night() + } +}