From 4a07b97eab577c607c03807322380fd06966e3a1 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Tue, 2 Dec 2025 19:03:33 +0100 Subject: [PATCH] feat(ui): add autocomplete, command help, and streaming improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TUI Enhancements: - Add autocomplete dropdown with fuzzy filtering for slash commands - Fix autocomplete: Tab confirms selection, Enter submits message - Add command help overlay with scroll support (j/k, arrows, Page Up/Down) - Brighten Tokyo Night theme colors for better readability - Add todo panel component for task display - Add rich command output formatting (tables, trees, lists) Streaming Fixes: - Refactor to non-blocking background streaming with channel events - Add StreamStart/StreamEnd/StreamError events - Fix LlmChunk to append instead of creating new messages - Display user message immediately before LLM call New Components: - completions.rs: Command completion engine with fuzzy matching - autocomplete.rs: Inline autocomplete dropdown - command_help.rs: Modal help overlay with scrolling - todo_panel.rs: Todo list display panel - output.rs: Rich formatted output (tables, trees, code blocks) - commands.rs: Built-in command implementations Planning Mode Groundwork: - Add EnterPlanMode/ExitPlanMode tools scaffolding - Add Skill tool for plugin skill invocation - Extend permissions with planning mode support - Add compact.rs stub for context compaction 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Cargo.toml | 2 + crates/app/cli/src/commands.rs | 382 ++++++++++++++++ crates/app/cli/src/main.rs | 4 + crates/app/ui/Cargo.toml | 1 + crates/app/ui/src/app.rs | 448 +++++++++++++++---- crates/app/ui/src/completions.rs | 226 ++++++++++ crates/app/ui/src/components/autocomplete.rs | 377 ++++++++++++++++ crates/app/ui/src/components/chat_panel.rs | 61 ++- crates/app/ui/src/components/command_help.rs | 322 +++++++++++++ crates/app/ui/src/components/mod.rs | 6 + crates/app/ui/src/components/status_bar.rs | 135 ++---- crates/app/ui/src/components/todo_panel.rs | 200 +++++++++ crates/app/ui/src/events.rs | 13 +- crates/app/ui/src/layout.rs | 79 +++- crates/app/ui/src/lib.rs | 4 + crates/app/ui/src/output.rs | 388 ++++++++++++++++ crates/app/ui/src/theme.rs | 148 ++++-- crates/core/agent/Cargo.toml | 2 + crates/core/agent/src/compact.rs | 218 +++++++++ crates/core/agent/src/lib.rs | 152 ++++++- crates/platform/config/src/lib.rs | 32 +- crates/platform/hooks/src/lib.rs | 313 +++++++++++++ crates/platform/permissions/src/lib.rs | 179 ++++++++ crates/tools/plan/Cargo.toml | 18 + crates/tools/plan/src/lib.rs | 296 ++++++++++++ crates/tools/skill/Cargo.toml | 16 + crates/tools/skill/src/lib.rs | 275 ++++++++++++ 27 files changed, 4034 insertions(+), 263 deletions(-) create mode 100644 crates/app/cli/src/commands.rs create mode 100644 crates/app/ui/src/completions.rs create mode 100644 crates/app/ui/src/components/autocomplete.rs create mode 100644 crates/app/ui/src/components/command_help.rs create mode 100644 crates/app/ui/src/components/todo_panel.rs create mode 100644 crates/app/ui/src/output.rs create mode 100644 crates/core/agent/src/compact.rs create mode 100644 crates/tools/plan/Cargo.toml create mode 100644 crates/tools/plan/src/lib.rs create mode 100644 crates/tools/skill/Cargo.toml create mode 100644 crates/tools/skill/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 95bf247..a41feab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,8 @@ members = [ "crates/tools/bash", "crates/tools/fs", "crates/tools/notebook", + "crates/tools/plan", + "crates/tools/skill", "crates/tools/slash", "crates/tools/task", "crates/tools/todo", diff --git a/crates/app/cli/src/commands.rs b/crates/app/cli/src/commands.rs new file mode 100644 index 0000000..46afb1a --- /dev/null +++ b/crates/app/cli/src/commands.rs @@ -0,0 +1,382 @@ +//! Built-in commands for CLI and TUI +//! +//! Provides handlers for /help, /mcp, /hooks, /clear, and other built-in commands. + +use ui::{CommandInfo, CommandOutput, OutputFormat, TreeNode, ListItem}; +use permissions::PermissionManager; +use hooks::HookManager; +use plugins::PluginManager; +use agent_core::SessionStats; + +/// Result of executing a built-in command +pub enum CommandResult { + /// Command produced output to display + Output(CommandOutput), + /// Command was handled but produced no output (e.g., /clear) + Handled, + /// Command was not recognized + NotFound, + /// Command needs to exit the session + Exit, +} + +/// Built-in command handler +pub struct BuiltinCommands<'a> { + plugin_manager: Option<&'a PluginManager>, + hook_manager: Option<&'a HookManager>, + permission_manager: Option<&'a PermissionManager>, + stats: Option<&'a SessionStats>, +} + +impl<'a> BuiltinCommands<'a> { + pub fn new() -> Self { + Self { + plugin_manager: None, + hook_manager: None, + permission_manager: None, + stats: None, + } + } + + pub fn with_plugins(mut self, pm: &'a PluginManager) -> Self { + self.plugin_manager = Some(pm); + self + } + + pub fn with_hooks(mut self, hm: &'a HookManager) -> Self { + self.hook_manager = Some(hm); + self + } + + pub fn with_permissions(mut self, perms: &'a PermissionManager) -> Self { + self.permission_manager = Some(perms); + self + } + + pub fn with_stats(mut self, stats: &'a SessionStats) -> Self { + self.stats = Some(stats); + self + } + + /// Execute a built-in command + pub fn execute(&self, command: &str) -> CommandResult { + let parts: Vec<&str> = command.split_whitespace().collect(); + let cmd = parts.first().map(|s| s.trim_start_matches('/')); + + match cmd { + Some("help") | Some("?") => CommandResult::Output(self.help()), + Some("mcp") => CommandResult::Output(self.mcp()), + Some("hooks") => CommandResult::Output(self.hooks()), + Some("plugins") => CommandResult::Output(self.plugins()), + Some("status") => CommandResult::Output(self.status()), + Some("permissions") | Some("perms") => CommandResult::Output(self.permissions()), + Some("clear") => CommandResult::Handled, + Some("exit") | Some("quit") | Some("q") => CommandResult::Exit, + _ => CommandResult::NotFound, + } + } + + /// Generate help output + fn help(&self) -> CommandOutput { + let mut commands = vec![ + // Built-in commands + CommandInfo::new("help", "Show available commands", "builtin"), + CommandInfo::new("clear", "Clear the screen", "builtin"), + CommandInfo::new("status", "Show session status", "builtin"), + CommandInfo::new("permissions", "Show permission settings", "builtin"), + CommandInfo::new("mcp", "List MCP servers and tools", "builtin"), + CommandInfo::new("hooks", "Show loaded hooks", "builtin"), + CommandInfo::new("plugins", "Show loaded plugins", "builtin"), + CommandInfo::new("checkpoint", "Save session state", "builtin"), + CommandInfo::new("checkpoints", "List saved checkpoints", "builtin"), + CommandInfo::new("rewind", "Restore from checkpoint", "builtin"), + CommandInfo::new("compact", "Compact conversation context", "builtin"), + CommandInfo::new("exit", "Exit the session", "builtin"), + ]; + + // Add plugin commands + if let Some(pm) = self.plugin_manager { + for plugin in pm.plugins() { + for cmd_name in plugin.all_command_names() { + commands.push(CommandInfo::new( + &cmd_name, + &format!("Plugin command from {}", plugin.manifest.name), + &format!("plugin:{}", plugin.manifest.name), + )); + } + } + } + + CommandOutput::help_table(&commands) + } + + /// Generate MCP servers output + fn mcp(&self) -> CommandOutput { + let mut servers: Vec<(String, Vec)> = vec![]; + + // Get MCP servers from plugins + if let Some(pm) = self.plugin_manager { + for plugin in pm.plugins() { + // Check for .mcp.json in plugin directory + let mcp_path = plugin.base_path.join(".mcp.json"); + if mcp_path.exists() { + if let Ok(content) = std::fs::read_to_string(&mcp_path) { + if let Ok(config) = serde_json::from_str::(&content) { + if let Some(mcpservers) = config.get("mcpServers").and_then(|v| v.as_object()) { + for (name, _) in mcpservers { + servers.push(( + format!("{} ({})", name, plugin.manifest.name), + vec!["(connect to discover tools)".to_string()], + )); + } + } + } + } + } + } + } + + if servers.is_empty() { + CommandOutput::new(OutputFormat::Text { + content: "No MCP servers configured.\n\nAdd MCP servers in plugin .mcp.json files.".to_string(), + }) + } else { + CommandOutput::mcp_tree(&servers) + } + } + + /// Generate hooks output + fn hooks(&self) -> CommandOutput { + let mut hooks_list: Vec<(String, String, bool)> = vec![]; + + // Check for file-based hooks in .owlen/hooks/ + let hook_events = ["PreToolUse", "PostToolUse", "SessionStart", "SessionEnd", + "UserPromptSubmit", "PreCompact", "Stop", "SubagentStop"]; + + for event in hook_events { + let path = format!(".owlen/hooks/{}", event); + let exists = std::path::Path::new(&path).exists(); + if exists { + hooks_list.push((event.to_string(), path, true)); + } + } + + // Get hooks from plugins + if let Some(pm) = self.plugin_manager { + for plugin in pm.plugins() { + if let Some(hooks_config) = plugin.load_hooks_config().ok().flatten() { + // hooks_config.hooks is HashMap> + for (event_name, matchers) in &hooks_config.hooks { + for matcher in matchers { + for hook_def in &matcher.hooks { + let cmd = hook_def.command.as_deref() + .or(hook_def.prompt.as_deref()) + .unwrap_or("(no command)"); + hooks_list.push(( + event_name.clone(), + format!("{}: {}", plugin.manifest.name, cmd), + true, + )); + } + } + } + } + } + } + + if hooks_list.is_empty() { + CommandOutput::new(OutputFormat::Text { + content: "No hooks configured.\n\nAdd hooks in .owlen/hooks/ or plugin hooks.json files.".to_string(), + }) + } else { + CommandOutput::hooks_list(&hooks_list) + } + } + + /// Generate plugins output + fn plugins(&self) -> CommandOutput { + if let Some(pm) = self.plugin_manager { + let plugins = pm.plugins(); + if plugins.is_empty() { + return CommandOutput::new(OutputFormat::Text { + content: "No plugins loaded.\n\nPlace plugins in:\n - ~/.config/owlen/plugins (user)\n - .owlen/plugins (project)".to_string(), + }); + } + + // Build tree of plugins and their components + let children: Vec = plugins.iter().map(|p| { + let mut plugin_children = vec![]; + + let commands = p.all_command_names(); + if !commands.is_empty() { + plugin_children.push(TreeNode::new("Commands").with_children( + commands.iter().map(|c| TreeNode::new(format!("/{}", c))).collect() + )); + } + + let agents = p.all_agent_names(); + if !agents.is_empty() { + plugin_children.push(TreeNode::new("Agents").with_children( + agents.iter().map(|a| TreeNode::new(a)).collect() + )); + } + + let skills = p.all_skill_names(); + if !skills.is_empty() { + plugin_children.push(TreeNode::new("Skills").with_children( + skills.iter().map(|s| TreeNode::new(s)).collect() + )); + } + + TreeNode::new(format!("{} v{}", p.manifest.name, p.manifest.version)) + .with_children(plugin_children) + }).collect(); + + CommandOutput::new(OutputFormat::Tree { + root: TreeNode::new("Loaded Plugins").with_children(children), + }) + } else { + CommandOutput::new(OutputFormat::Text { + content: "Plugin manager not available.".to_string(), + }) + } + } + + /// Generate status output + fn status(&self) -> CommandOutput { + let mut items = vec![]; + + if let Some(stats) = self.stats { + items.push(ListItem { + text: format!("Messages: {}", stats.total_messages), + marker: Some("📊".to_string()), + style: None, + }); + items.push(ListItem { + text: format!("Tool Calls: {}", stats.total_tool_calls), + marker: Some("🔧".to_string()), + style: None, + }); + items.push(ListItem { + text: format!("Est. Tokens: ~{}", stats.estimated_tokens), + marker: Some("📝".to_string()), + style: None, + }); + let uptime = stats.start_time.elapsed().unwrap_or_default(); + items.push(ListItem { + text: format!("Uptime: {}", SessionStats::format_duration(uptime)), + marker: Some("⏱️".to_string()), + style: None, + }); + } + + if let Some(perms) = self.permission_manager { + items.push(ListItem { + text: format!("Mode: {:?}", perms.mode()), + marker: Some("🔒".to_string()), + style: None, + }); + } + + if items.is_empty() { + CommandOutput::new(OutputFormat::Text { + content: "Session status not available.".to_string(), + }) + } else { + CommandOutput::new(OutputFormat::List { items }) + } + } + + /// Generate permissions output + fn permissions(&self) -> CommandOutput { + if let Some(perms) = self.permission_manager { + let mode = perms.mode(); + let mode_str = format!("{:?}", mode); + + let mut items = vec![ + ListItem { + text: format!("Current Mode: {}", mode_str), + marker: Some("🔒".to_string()), + style: None, + }, + ]; + + // Add tool permissions summary + let (read_status, write_status, bash_status) = match mode { + permissions::Mode::Plan => ("✅ Allowed", "❓ Ask", "❓ Ask"), + permissions::Mode::AcceptEdits => ("✅ Allowed", "✅ Allowed", "❓ Ask"), + permissions::Mode::Code => ("✅ Allowed", "✅ Allowed", "✅ Allowed"), + }; + + items.push(ListItem { + text: format!("Read/Grep/Glob: {}", read_status), + marker: None, + style: None, + }); + items.push(ListItem { + text: format!("Write/Edit: {}", write_status), + marker: None, + style: None, + }); + items.push(ListItem { + text: format!("Bash: {}", bash_status), + marker: None, + style: None, + }); + + CommandOutput::new(OutputFormat::List { items }) + } else { + CommandOutput::new(OutputFormat::Text { + content: "Permission manager not available.".to_string(), + }) + } + } +} + +impl Default for BuiltinCommands<'_> { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_help_command() { + let handler = BuiltinCommands::new(); + match handler.execute("/help") { + CommandResult::Output(output) => { + match output.format { + OutputFormat::Table { headers, rows } => { + assert!(!headers.is_empty()); + assert!(!rows.is_empty()); + } + _ => panic!("Expected Table format"), + } + } + _ => panic!("Expected Output result"), + } + } + + #[test] + fn test_exit_command() { + let handler = BuiltinCommands::new(); + assert!(matches!(handler.execute("/exit"), CommandResult::Exit)); + assert!(matches!(handler.execute("/quit"), CommandResult::Exit)); + assert!(matches!(handler.execute("/q"), CommandResult::Exit)); + } + + #[test] + fn test_clear_command() { + let handler = BuiltinCommands::new(); + assert!(matches!(handler.execute("/clear"), CommandResult::Handled)); + } + + #[test] + fn test_unknown_command() { + let handler = BuiltinCommands::new(); + assert!(matches!(handler.execute("/unknown"), CommandResult::NotFound)); + } +} diff --git a/crates/app/cli/src/main.rs b/crates/app/cli/src/main.rs index 802dbe7..9a35f69 100644 --- a/crates/app/cli/src/main.rs +++ b/crates/app/cli/src/main.rs @@ -1,3 +1,5 @@ +mod commands; + use clap::{Parser, ValueEnum}; use color_eyre::eyre::{Result, eyre}; use config_agent::load_settings; @@ -10,6 +12,8 @@ use serde::Serialize; use std::io::Write; use std::time::{SystemTime, UNIX_EPOCH}; +pub use commands::{BuiltinCommands, CommandResult}; + #[derive(Debug, Clone, Copy, ValueEnum)] enum OutputFormat { Text, diff --git a/crates/app/ui/Cargo.toml b/crates/app/ui/Cargo.toml index 97b499a..ff82f5c 100644 --- a/crates/app/ui/Cargo.toml +++ b/crates/app/ui/Cargo.toml @@ -24,3 +24,4 @@ permissions = { path = "../../platform/permissions" } llm-core = { path = "../../llm/core" } llm-ollama = { path = "../../llm/ollama" } config-agent = { path = "../../platform/config" } +tools-todo = { path = "../../tools/todo" } diff --git a/crates/app/ui/src/app.rs b/crates/app/ui/src/app.rs index 896689d..db59280 100644 --- a/crates/app/ui/src/app.rs +++ b/crates/app/ui/src/app.rs @@ -1,9 +1,13 @@ use crate::{ - components::{ChatMessage, ChatPanel, InputBox, PermissionPopup, StatusBar}, + components::{ + Autocomplete, AutocompleteResult, ChatMessage, ChatPanel, CommandHelp, InputBox, + PermissionPopup, StatusBar, TodoPanel, + }, events::{handle_key_event, AppEvent}, layout::AppLayout, - theme::Theme, + theme::{Theme, VimMode}, }; +use tools_todo::TodoList; use agent_core::{CheckpointManager, SessionHistory, SessionStats, ToolContext, execute_tool, get_tool_definitions}; use color_eyre::eyre::Result; use crossterm::{ @@ -15,7 +19,14 @@ use futures::StreamExt; use llm_core::{ChatMessage as LLMChatMessage, ChatOptions}; use llm_ollama::OllamaClient; use permissions::{Action, PermissionDecision, PermissionManager, Tool as PermTool}; -use ratatui::{backend::CrosstermBackend, Terminal}; +use ratatui::{ + backend::CrosstermBackend, + layout::Rect, + style::Style, + text::{Line, Span}, + widgets::Paragraph, + Terminal, +}; use serde_json::Value; use std::{io::stdout, path::PathBuf, time::SystemTime}; use tokio::sync::mpsc; @@ -33,13 +44,17 @@ pub struct TuiApp { chat_panel: ChatPanel, input_box: InputBox, status_bar: StatusBar, + todo_panel: TodoPanel, permission_popup: Option, + autocomplete: Autocomplete, + command_help: CommandHelp, theme: Theme, // Session state stats: SessionStats, history: SessionHistory, checkpoint_mgr: CheckpointManager, + todo_list: TodoList, // System state client: OllamaClient, @@ -54,6 +69,7 @@ pub struct TuiApp { waiting_for_llm: bool, pending_tool: Option, permission_tx: Option>, + vim_mode: VimMode, } impl TuiApp { @@ -70,11 +86,15 @@ impl TuiApp { chat_panel: ChatPanel::new(theme.clone()), input_box: InputBox::new(theme.clone()), status_bar: StatusBar::new(opts.model.clone(), mode, theme.clone()), + todo_panel: TodoPanel::new(theme.clone()), permission_popup: None, + autocomplete: Autocomplete::new(theme.clone()), + command_help: CommandHelp::new(theme.clone()), theme, stats: SessionStats::new(), history: SessionHistory::new(), checkpoint_mgr: CheckpointManager::new(PathBuf::from(".owlen/checkpoints")), + todo_list: TodoList::new(), client, opts, perms, @@ -84,6 +104,7 @@ impl TuiApp { waiting_for_llm: false, pending_tool: None, permission_tx: None, + vim_mode: VimMode::Insert, }) } @@ -91,7 +112,77 @@ impl TuiApp { 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); + self.status_bar = StatusBar::new(self.opts.model.clone(), self.perms.mode(), theme.clone()); + self.todo_panel.set_theme(theme.clone()); + self.autocomplete.set_theme(theme.clone()); + self.command_help.set_theme(theme); + } + + /// Get the public todo list for external updates + pub fn todo_list(&self) -> &TodoList { + &self.todo_list + } + + /// Render the header line: OWLEN left, model + vim mode right + fn render_header(&self, frame: &mut ratatui::Frame, area: Rect) { + let vim_indicator = self.vim_mode.indicator(&self.theme.symbols); + + // Calculate right side content + let right_content = format!("{} {}", self.opts.model, vim_indicator); + let right_len = right_content.len(); + + // Calculate padding + let name = "OWLEN"; + let padding = area.width.saturating_sub(name.len() as u16 + right_len as u16 + 2); + + let line = Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(name, self.theme.header_accent), + Span::raw(" ".repeat(padding as usize)), + Span::styled(&self.opts.model, self.theme.status_dim), + Span::styled(" ", Style::default()), + Span::styled(vim_indicator, self.theme.header_accent), + Span::styled(" ", Style::default()), + ]); + + let paragraph = Paragraph::new(line); + frame.render_widget(paragraph, area); + } + + /// Render a horizontal rule divider + fn render_divider(&self, frame: &mut ratatui::Frame, area: Rect) { + let rule = self.theme.symbols.horizontal_rule.repeat(area.width as usize); + let line = Line::from(Span::styled(rule, Style::default().fg(self.theme.palette.border))); + let paragraph = Paragraph::new(line); + frame.render_widget(paragraph, area); + } + + /// Render empty state placeholder (centered) + fn render_empty_state(&self, frame: &mut ratatui::Frame, area: Rect) { + let message = "Start a conversation..."; + let padding = area.width.saturating_sub(message.len() as u16) / 2; + let vertical_center = area.height / 2; + + // Create centered area + let centered_area = Rect { + x: area.x, + y: area.y + vertical_center, + width: area.width, + height: 1, + }; + + let line = Line::from(vec![ + Span::raw(" ".repeat(padding as usize)), + Span::styled(message, self.theme.status_dim), + ]); + + let paragraph = Paragraph::new(line); + frame.render_widget(paragraph, centered_area); + } + + /// Check if the chat panel is empty (no real messages) + fn is_chat_empty(&self) -> bool { + self.chat_panel.messages().is_empty() } pub async fn run(&mut self) -> Result<()> { @@ -142,42 +233,58 @@ impl TuiApp { } }); - // 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() - )); + // No welcome messages added - empty state shows "Start a conversation..." // Main event loop while self.running { // Render terminal.draw(|frame| { let size = frame.area(); - let layout = AppLayout::calculate(size); + let todo_height = self.todo_panel.min_height(); + let layout = AppLayout::calculate_with_todo(size, todo_height); + + // Render header: OWLEN left, model + vim mode right + self.render_header(frame, layout.header_area); + + // Render top divider (horizontal rule) + self.render_divider(frame, layout.top_divider); // Update scroll position before rendering self.chat_panel.update_scroll(layout.chat_area); - // Render main components - self.chat_panel.render(frame, layout.chat_area); + // Render chat area or empty state + if self.is_chat_empty() { + self.render_empty_state(frame, layout.chat_area); + } else { + self.chat_panel.render(frame, layout.chat_area); + } + + // Render todo panel if visible + if todo_height > 0 { + self.todo_panel.render(frame, layout.todo_area, &self.todo_list); + } + + // Render bottom divider + self.render_divider(frame, layout.bottom_divider); + + // Render input area self.input_box.render(frame, layout.input_area); + + // Render status bar self.status_bar.render(frame, layout.status_area); - // Render permission popup if active + // Render overlays (in order of priority) + // 1. Autocomplete dropdown (above input) + if self.autocomplete.is_visible() { + self.autocomplete.render(frame, layout.input_area); + } + + // 2. Command help overlay (centered modal) + if self.command_help.is_visible() { + self.command_help.render(frame, size); + } + + // 3. Permission popup (highest priority) if let Some(popup) = &self.permission_popup { popup.render(frame, size); } @@ -208,7 +315,9 @@ impl TuiApp { ) -> Result<()> { match event { AppEvent::Input(key) => { - // If permission popup is active, handle there first + // Handle overlays in priority order (highest first) + + // 1. Permission popup (highest priority) if let Some(popup) = &mut self.permission_popup { if let Some(option) = popup.handle_key(key) { use crate::components::PermissionOption; @@ -216,7 +325,7 @@ impl TuiApp { match option { PermissionOption::AllowOnce => { self.chat_panel.add_message(ChatMessage::System( - "✓ Permission granted once".to_string() + "Permission granted once".to_string() )); if let Some(tx) = self.permission_tx.take() { let _ = tx.send(true); @@ -231,7 +340,7 @@ impl TuiApp { Action::Allow, ); self.chat_panel.add_message(ChatMessage::System( - format!("✓ Always allowed: {}", pending.tool_name) + format!("Always allowed: {}", pending.tool_name) )); } if let Some(tx) = self.permission_tx.take() { @@ -240,7 +349,7 @@ impl TuiApp { } PermissionOption::Deny => { self.chat_panel.add_message(ChatMessage::System( - "✗ Permission denied".to_string() + "Permission denied".to_string() )); if let Some(tx) = self.permission_tx.take() { let _ = tx.send(false); @@ -271,31 +380,39 @@ impl TuiApp { self.permission_popup = None; self.pending_tool = None; } - } else { - // Handle input box with vim-modal events - use crate::components::InputEvent; - if let Some(event) = self.input_box.handle_key(key) { - match event { - InputEvent::Message(message) => { - self.handle_user_message(message, event_tx).await?; - } - InputEvent::Command(cmd) => { - // Commands from command mode (without /) - self.handle_command(&format!("/{}", cmd))?; - } - InputEvent::ModeChange(mode) => { - self.status_bar.set_vim_mode(mode); - } - InputEvent::Cancel => { - // Cancel current operation - self.waiting_for_llm = false; - } - InputEvent::Expand => { - // TODO: Expand to multiline input - } + return Ok(()); + } + + // 2. Command help overlay + if self.command_help.is_visible() { + self.command_help.handle_key(key); + return Ok(()); + } + + // 3. Autocomplete dropdown + if self.autocomplete.is_visible() { + match self.autocomplete.handle_key(key) { + AutocompleteResult::Confirmed(cmd) => { + // Insert confirmed command into input box + self.input_box.set_text(cmd); + self.autocomplete.hide(); + } + AutocompleteResult::Cancelled => { + self.autocomplete.hide(); + } + AutocompleteResult::Handled => { + // Key was handled (navigation), do nothing + } + AutocompleteResult::NotHandled => { + // Pass through to input box + self.handle_input_key(key, event_tx).await?; } } + return Ok(()); } + + // 4. Normal input handling + self.handle_input_key(key, event_tx).await?; } AppEvent::ScrollUp => { self.chat_panel.scroll_up(3); @@ -308,9 +425,31 @@ impl TuiApp { .add_message(ChatMessage::User(message.clone())); self.history.add_user_message(message); } + AppEvent::StreamStart => { + // Streaming started - indicator will show in chat panel + self.waiting_for_llm = true; + self.chat_panel.set_streaming(true); + } AppEvent::LlmChunk(chunk) => { - // Add to last assistant message or create new one - self.chat_panel.add_message(ChatMessage::Assistant(chunk)); + // APPEND to last assistant message (don't create new one each time) + self.chat_panel.append_to_assistant(&chunk); + } + AppEvent::StreamEnd { response } => { + // Streaming finished + self.waiting_for_llm = false; + self.chat_panel.set_streaming(false); + self.history.add_assistant_message(response.clone()); + // Update stats (rough estimate) + let tokens = response.len() / 4; + self.stats.record_message(tokens, std::time::Duration::from_secs(1)); + } + AppEvent::StreamError(error) => { + // Streaming error + self.waiting_for_llm = false; + self.chat_panel.set_streaming(false); + self.chat_panel.add_message(ChatMessage::System( + format!("Error: {}", error) + )); } AppEvent::ToolCall { name, args } => { self.chat_panel.add_message(ChatMessage::ToolCall { @@ -335,6 +474,9 @@ impl TuiApp { AppEvent::Resize { .. } => { // Terminal will automatically re-layout on next draw } + AppEvent::ToggleTodo => { + self.todo_panel.toggle(); + } AppEvent::Quit => { self.running = false; } @@ -343,10 +485,63 @@ impl TuiApp { Ok(()) } + /// Handle input keys with autocomplete integration + async fn handle_input_key( + &mut self, + key: crossterm::event::KeyEvent, + event_tx: &mpsc::UnboundedSender, + ) -> Result<()> { + use crate::components::InputEvent; + + // Handle the key in input box + if let Some(event) = self.input_box.handle_key(key) { + match event { + InputEvent::Message(message) => { + // Hide autocomplete before processing + self.autocomplete.hide(); + self.handle_user_message(message, event_tx).await?; + } + InputEvent::Command(cmd) => { + // Commands from vim command mode (without /) + self.autocomplete.hide(); + self.handle_command(&format!("/{}", cmd))?; + } + InputEvent::ModeChange(mode) => { + self.vim_mode = mode; + self.status_bar.set_vim_mode(mode); + } + InputEvent::Cancel => { + self.autocomplete.hide(); + self.waiting_for_llm = false; + } + InputEvent::Expand => { + // TODO: Expand to multiline input + } + } + } + + // Check if we should show/update autocomplete + let input = self.input_box.text(); + if input.starts_with('/') { + let query = &input[1..]; // Text after / + if !self.autocomplete.is_visible() { + self.autocomplete.show(); + } + self.autocomplete.update_filter(query); + } else { + // Hide autocomplete if input doesn't start with / + if self.autocomplete.is_visible() { + self.autocomplete.hide(); + } + } + + Ok(()) + } + async fn handle_user_message( &mut self, message: String, - _event_tx: &mpsc::UnboundedSender, + event_tx: &mpsc::UnboundedSender, ) -> Result<()> { // Handle slash commands if message.starts_with('/') { @@ -354,33 +549,68 @@ impl TuiApp { return Ok(()); } - // Add user message to chat + // Add user message to chat IMMEDIATELY so it shows before AI response self.chat_panel .add_message(ChatMessage::User(message.clone())); self.history.add_user_message(message.clone()); - // Run agent loop with tool calling + // Start streaming indicator self.waiting_for_llm = true; - let start = SystemTime::now(); + self.chat_panel.set_streaming(true); + let _ = event_tx.send(AppEvent::StreamStart); - match self.run_streaming_agent_loop(&message).await { - Ok(response) => { - self.history.add_assistant_message(response.clone()); + // Spawn streaming in background task + let client = self.client.clone(); + let opts = self.opts.clone(); + let tx = event_tx.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); + tokio::spawn(async move { + match Self::run_background_stream(&client, &opts, &message, &tx).await { + Ok(response) => { + let _ = tx.send(AppEvent::StreamEnd { response }); + } + Err(e) => { + let _ = tx.send(AppEvent::StreamError(e.to_string())); + } } - Err(e) => { - self.chat_panel.add_message(ChatMessage::System( - format!("❌ Error: {}", e) - )); + }); + + Ok(()) + } + + /// Run streaming in background, sending chunks through channel + async fn run_background_stream( + client: &OllamaClient, + opts: &ChatOptions, + prompt: &str, + tx: &mpsc::UnboundedSender, + ) -> Result { + use llm_core::LlmProvider; + + let messages = vec![LLMChatMessage::user(prompt)]; + let tools = get_tool_definitions(); + + let mut stream = client + .chat_stream(&messages, opts, Some(&tools)) + .await + .map_err(|e| color_eyre::eyre::eyre!("LLM provider error: {}", e))?; + + let mut response_content = String::new(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| color_eyre::eyre::eyre!("Stream error: {}", e))?; + + if let Some(content) = chunk.content { + response_content.push_str(&content); + // Send chunk to UI for immediate display + let _ = tx.send(AppEvent::LlmChunk(content)); } + + // TODO: Handle tool calls in background streaming + // For now, tool calls are ignored in background mode } - self.waiting_for_llm = false; - Ok(()) + Ok(response_content) } /// Execute a tool with permission handling @@ -630,10 +860,9 @@ impl TuiApp { 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(), - )); + "/help" | "/?" => { + // Show command help overlay + self.command_help.show(); } "/status" => { let elapsed = self.stats.start_time.elapsed().unwrap_or_default(); @@ -718,7 +947,72 @@ impl TuiApp { self.history.clear(); self.stats = SessionStats::new(); self.chat_panel - .add_message(ChatMessage::System("🗑️ Session cleared!".to_string())); + .add_message(ChatMessage::System("Session cleared".to_string())); + } + "/compact" => { + self.chat_panel.add_message(ChatMessage::System( + "Context compaction not yet implemented".to_string() + )); + } + "/provider" => { + // Show available providers + self.chat_panel.add_message(ChatMessage::System( + "Available providers:".to_string() + )); + self.chat_panel.add_message(ChatMessage::System( + " • ollama - Local LLM (default)".to_string() + )); + self.chat_panel.add_message(ChatMessage::System( + " • anthropic - Claude API (requires ANTHROPIC_API_KEY)".to_string() + )); + self.chat_panel.add_message(ChatMessage::System( + " • openai - OpenAI API (requires OPENAI_API_KEY)".to_string() + )); + self.chat_panel.add_message(ChatMessage::System( + "Use '/provider ' to switch".to_string() + )); + } + cmd if cmd.starts_with("/provider ") => { + let provider_name = cmd.strip_prefix("/provider ").unwrap().trim(); + match provider_name { + "ollama" | "anthropic" | "openai" => { + self.chat_panel.add_message(ChatMessage::System(format!( + "Provider switching requires restart. Set OWLEN_PROVIDER={}", provider_name + ))); + } + _ => { + self.chat_panel.add_message(ChatMessage::System(format!( + "Unknown provider: {}. Available: ollama, anthropic, openai", provider_name + ))); + } + } + } + "/model" => { + // Show current model + self.chat_panel.add_message(ChatMessage::System(format!( + "Current model: {}", self.opts.model + ))); + self.chat_panel.add_message(ChatMessage::System( + "Use '/model ' to switch (e.g., /model llama3.2, /model qwen3:8b)".to_string() + )); + } + cmd if cmd.starts_with("/model ") => { + let model_name = cmd.strip_prefix("/model ").unwrap().trim(); + if model_name.is_empty() { + self.chat_panel.add_message(ChatMessage::System(format!( + "Current model: {}", self.opts.model + ))); + } else { + self.opts.model = model_name.to_string(); + self.status_bar = StatusBar::new( + self.opts.model.clone(), + self.perms.mode(), + self.theme.clone(), + ); + self.chat_panel.add_message(ChatMessage::System(format!( + "Model switched to: {}", model_name + ))); + } } "/themes" => { self.chat_panel.add_message(ChatMessage::System( diff --git a/crates/app/ui/src/completions.rs b/crates/app/ui/src/completions.rs new file mode 100644 index 0000000..d38f137 --- /dev/null +++ b/crates/app/ui/src/completions.rs @@ -0,0 +1,226 @@ +//! Command completion engine for the TUI +//! +//! Provides Tab-completion for slash commands, file paths, and tool names. + +use std::path::Path; + +/// A single completion suggestion +#[derive(Debug, Clone)] +pub struct Completion { + /// The text to insert + pub text: String, + /// Description of what this completion does + pub description: String, + /// Source of the completion (e.g., "builtin", "plugin:name") + pub source: String, +} + +/// Information about a command for completion purposes +#[derive(Debug, Clone)] +pub struct CommandInfo { + /// Command name (without leading /) + pub name: String, + /// Command description + pub description: String, + /// Source of the command + pub source: String, +} + +impl CommandInfo { + pub fn new(name: &str, description: &str, source: &str) -> Self { + Self { + name: name.to_string(), + description: description.to_string(), + source: source.to_string(), + } + } +} + +/// Completion engine for the TUI +pub struct CompletionEngine { + /// Available commands + commands: Vec, +} + +impl Default for CompletionEngine { + fn default() -> Self { + Self::new() + } +} + +impl CompletionEngine { + pub fn new() -> Self { + Self { + commands: Self::builtin_commands(), + } + } + + /// Get built-in commands + fn builtin_commands() -> Vec { + vec![ + CommandInfo::new("help", "Show available commands and help", "builtin"), + CommandInfo::new("clear", "Clear the screen", "builtin"), + CommandInfo::new("mcp", "List MCP servers and their tools", "builtin"), + CommandInfo::new("hooks", "Show loaded hooks", "builtin"), + CommandInfo::new("compact", "Compact conversation context", "builtin"), + CommandInfo::new("mode", "Switch permission mode (plan/edit/code)", "builtin"), + CommandInfo::new("provider", "Switch LLM provider", "builtin"), + CommandInfo::new("model", "Switch LLM model", "builtin"), + CommandInfo::new("checkpoint", "Create a checkpoint", "builtin"), + CommandInfo::new("rewind", "Rewind to a checkpoint", "builtin"), + ] + } + + /// Add commands from plugins + pub fn add_plugin_commands(&mut self, plugin_name: &str, commands: Vec) { + for mut cmd in commands { + cmd.source = format!("plugin:{}", plugin_name); + self.commands.push(cmd); + } + } + + /// Add a single command + pub fn add_command(&mut self, command: CommandInfo) { + self.commands.push(command); + } + + /// Get completions for the given input + pub fn complete(&self, input: &str) -> Vec { + if input.starts_with('/') { + self.complete_command(&input[1..]) + } else if input.starts_with('@') { + self.complete_file_path(&input[1..]) + } else { + vec![] + } + } + + /// Complete a slash command + fn complete_command(&self, partial: &str) -> Vec { + let partial_lower = partial.to_lowercase(); + + self.commands + .iter() + .filter(|cmd| { + // Match if name starts with partial, or contains partial (fuzzy) + cmd.name.to_lowercase().starts_with(&partial_lower) + || (partial.len() >= 2 && cmd.name.to_lowercase().contains(&partial_lower)) + }) + .map(|cmd| Completion { + text: format!("/{}", cmd.name), + description: cmd.description.clone(), + source: cmd.source.clone(), + }) + .collect() + } + + /// Complete a file path + fn complete_file_path(&self, partial: &str) -> Vec { + let path = Path::new(partial); + + // Get the directory to search and the prefix to match + let (dir, prefix) = if partial.ends_with('/') || partial.is_empty() { + (partial, "") + } else { + let parent = path.parent().map(|p| p.to_str().unwrap_or("")).unwrap_or(""); + let file_name = path.file_name().and_then(|f| f.to_str()).unwrap_or(""); + (parent, file_name) + }; + + // Search directory + let search_dir = if dir.is_empty() { "." } else { dir }; + + match std::fs::read_dir(search_dir) { + Ok(entries) => { + entries + .filter_map(|entry| entry.ok()) + .filter(|entry| { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + // Skip hidden files unless user started typing with . + if !prefix.starts_with('.') && name_str.starts_with('.') { + return false; + } + name_str.to_lowercase().starts_with(&prefix.to_lowercase()) + }) + .map(|entry| { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); + + let full_path = if dir.is_empty() { + name_str.to_string() + } else if dir.ends_with('/') { + format!("{}{}", dir, name_str) + } else { + format!("{}/{}", dir, name_str) + }; + + Completion { + text: format!("@{}{}", full_path, if is_dir { "/" } else { "" }), + description: if is_dir { "Directory".to_string() } else { "File".to_string() }, + source: "filesystem".to_string(), + } + }) + .collect() + } + Err(_) => vec![], + } + } + + /// Get all commands (for /help display) + pub fn all_commands(&self) -> &[CommandInfo] { + &self.commands + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_command_completion_exact() { + let engine = CompletionEngine::new(); + let completions = engine.complete("/help"); + assert!(!completions.is_empty()); + assert!(completions.iter().any(|c| c.text == "/help")); + } + + #[test] + fn test_command_completion_partial() { + let engine = CompletionEngine::new(); + let completions = engine.complete("/hel"); + assert!(!completions.is_empty()); + assert!(completions.iter().any(|c| c.text == "/help")); + } + + #[test] + fn test_command_completion_fuzzy() { + let engine = CompletionEngine::new(); + // "cle" should match "clear" + let completions = engine.complete("/cle"); + assert!(!completions.is_empty()); + assert!(completions.iter().any(|c| c.text == "/clear")); + } + + #[test] + fn test_command_info() { + let info = CommandInfo::new("test", "A test command", "builtin"); + assert_eq!(info.name, "test"); + assert_eq!(info.description, "A test command"); + assert_eq!(info.source, "builtin"); + } + + #[test] + fn test_add_plugin_commands() { + let mut engine = CompletionEngine::new(); + let plugin_cmds = vec![ + CommandInfo::new("custom", "A custom command", ""), + ]; + engine.add_plugin_commands("my-plugin", plugin_cmds); + + let completions = engine.complete("/custom"); + assert!(!completions.is_empty()); + assert!(completions.iter().any(|c| c.source == "plugin:my-plugin")); + } +} diff --git a/crates/app/ui/src/components/autocomplete.rs b/crates/app/ui/src/components/autocomplete.rs new file mode 100644 index 0000000..6958bd1 --- /dev/null +++ b/crates/app/ui/src/components/autocomplete.rs @@ -0,0 +1,377 @@ +//! Command autocomplete dropdown component +//! +//! Displays inline autocomplete suggestions when user types `/`. +//! Supports fuzzy filtering as user types. + +use crate::theme::Theme; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + layout::Rect, + style::Style, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +/// An autocomplete option +#[derive(Debug, Clone)] +pub struct AutocompleteOption { + /// The trigger text (command name without /) + pub trigger: String, + /// Display text (e.g., "/model [name]") + pub display: String, + /// Short description + pub description: String, + /// Has submenu/subcommands + pub has_submenu: bool, +} + +impl AutocompleteOption { + pub fn new(trigger: &str, description: &str) -> Self { + Self { + trigger: trigger.to_string(), + display: format!("/{}", trigger), + description: description.to_string(), + has_submenu: false, + } + } + + pub fn with_args(trigger: &str, args: &str, description: &str) -> Self { + Self { + trigger: trigger.to_string(), + display: format!("/{} {}", trigger, args), + description: description.to_string(), + has_submenu: false, + } + } + + pub fn with_submenu(trigger: &str, description: &str) -> Self { + Self { + trigger: trigger.to_string(), + display: format!("/{}", trigger), + description: description.to_string(), + has_submenu: true, + } + } +} + +/// Default command options +fn default_options() -> Vec { + vec![ + AutocompleteOption::new("help", "Show help"), + AutocompleteOption::new("status", "Session info"), + AutocompleteOption::with_args("model", "[name]", "Switch model"), + AutocompleteOption::with_args("provider", "[name]", "Switch provider"), + AutocompleteOption::new("history", "View history"), + AutocompleteOption::new("checkpoint", "Save state"), + AutocompleteOption::new("checkpoints", "List checkpoints"), + AutocompleteOption::with_args("rewind", "[id]", "Restore"), + AutocompleteOption::new("cost", "Token usage"), + AutocompleteOption::new("clear", "Clear chat"), + AutocompleteOption::new("compact", "Compact context"), + AutocompleteOption::new("permissions", "Permission mode"), + AutocompleteOption::new("themes", "List themes"), + AutocompleteOption::with_args("theme", "[name]", "Switch theme"), + AutocompleteOption::new("exit", "Exit"), + ] +} + +/// Autocomplete dropdown component +pub struct Autocomplete { + options: Vec, + filtered: Vec, // indices into options + selected: usize, + visible: bool, + theme: Theme, +} + +impl Autocomplete { + pub fn new(theme: Theme) -> Self { + let options = default_options(); + let filtered: Vec = (0..options.len()).collect(); + + Self { + options, + filtered, + selected: 0, + visible: false, + theme, + } + } + + /// Show autocomplete and reset filter + pub fn show(&mut self) { + self.visible = true; + self.filtered = (0..self.options.len()).collect(); + self.selected = 0; + } + + /// Hide autocomplete + pub fn hide(&mut self) { + self.visible = false; + } + + /// Check if visible + pub fn is_visible(&self) -> bool { + self.visible + } + + /// Update filter based on current input (text after /) + pub fn update_filter(&mut self, query: &str) { + if query.is_empty() { + self.filtered = (0..self.options.len()).collect(); + } else { + let query_lower = query.to_lowercase(); + self.filtered = self.options + .iter() + .enumerate() + .filter(|(_, opt)| { + // Fuzzy match: check if query chars appear in order + fuzzy_match(&opt.trigger.to_lowercase(), &query_lower) + }) + .map(|(i, _)| i) + .collect(); + } + + // Reset selection if it's out of bounds + if self.selected >= self.filtered.len() { + self.selected = 0; + } + } + + /// Select next option + pub fn select_next(&mut self) { + if !self.filtered.is_empty() { + self.selected = (self.selected + 1) % self.filtered.len(); + } + } + + /// Select previous option + pub fn select_prev(&mut self) { + if !self.filtered.is_empty() { + self.selected = if self.selected == 0 { + self.filtered.len() - 1 + } else { + self.selected - 1 + }; + } + } + + /// Get the currently selected option's trigger + pub fn confirm(&self) -> Option { + if self.filtered.is_empty() { + return None; + } + + let idx = self.filtered[self.selected]; + Some(format!("/{}", self.options[idx].trigger)) + } + + /// Handle key input, returns Some(command) if confirmed + /// + /// Key behavior: + /// - Tab: Confirm selection and insert into input + /// - Down/Up: Navigate options + /// - Enter: Pass through to submit (NotHandled) + /// - Esc: Cancel autocomplete + pub fn handle_key(&mut self, key: KeyEvent) -> AutocompleteResult { + if !self.visible { + return AutocompleteResult::NotHandled; + } + + match key.code { + KeyCode::Tab => { + // Tab confirms and inserts the selected command + if let Some(cmd) = self.confirm() { + self.hide(); + AutocompleteResult::Confirmed(cmd) + } else { + AutocompleteResult::Handled + } + } + KeyCode::Down => { + self.select_next(); + AutocompleteResult::Handled + } + KeyCode::BackTab | KeyCode::Up => { + self.select_prev(); + AutocompleteResult::Handled + } + KeyCode::Enter => { + // Enter should submit the message, not confirm autocomplete + // Hide autocomplete and let Enter pass through + self.hide(); + AutocompleteResult::NotHandled + } + KeyCode::Esc => { + self.hide(); + AutocompleteResult::Cancelled + } + _ => AutocompleteResult::NotHandled, + } + } + + /// Update theme + pub fn set_theme(&mut self, theme: Theme) { + self.theme = theme; + } + + /// Add custom options (from plugins) + pub fn add_options(&mut self, options: Vec) { + self.options.extend(options); + // Re-filter with all options + self.filtered = (0..self.options.len()).collect(); + } + + /// Render the autocomplete dropdown above the input line + pub fn render(&self, frame: &mut Frame, input_area: Rect) { + if !self.visible || self.filtered.is_empty() { + return; + } + + // Calculate dropdown dimensions + let max_visible = 8.min(self.filtered.len()); + let width = 40.min(input_area.width.saturating_sub(4)); + let height = (max_visible + 2) as u16; // +2 for borders + + // Position above input, left-aligned with some padding + let x = input_area.x + 2; + let y = input_area.y.saturating_sub(height); + + let dropdown_area = Rect::new(x, y, width, height); + + // Clear area behind dropdown + frame.render_widget(Clear, dropdown_area); + + // Build option lines + let mut lines: Vec = Vec::new(); + + for (display_idx, &opt_idx) in self.filtered.iter().take(max_visible).enumerate() { + let opt = &self.options[opt_idx]; + let is_selected = display_idx == self.selected; + + let style = if is_selected { + self.theme.selected + } else { + Style::default() + }; + + let mut spans = vec![ + Span::styled(" ", style), + Span::styled("/", if is_selected { style } else { self.theme.cmd_slash }), + Span::styled(&opt.trigger, if is_selected { style } else { self.theme.cmd_name }), + ]; + + // Submenu indicator + if opt.has_submenu { + spans.push(Span::styled(" >", if is_selected { style } else { self.theme.cmd_desc })); + } + + // Pad to fixed width for consistent selection highlighting + let current_len: usize = spans.iter().map(|s| s.content.len()).sum(); + let padding = (width as usize).saturating_sub(current_len + 1); + spans.push(Span::styled(" ".repeat(padding), style)); + + lines.push(Line::from(spans)); + } + + // Show overflow indicator if needed + if self.filtered.len() > max_visible { + lines.push(Line::from(Span::styled( + format!(" ... +{} more", self.filtered.len() - max_visible), + self.theme.cmd_desc, + ))); + } + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(self.theme.palette.border)) + .style(self.theme.overlay_bg); + + let paragraph = Paragraph::new(lines).block(block); + + frame.render_widget(paragraph, dropdown_area); + } +} + +/// Result of handling autocomplete key +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AutocompleteResult { + /// Key was not handled by autocomplete + NotHandled, + /// Key was handled, no action needed + Handled, + /// User confirmed selection, returns command string + Confirmed(String), + /// User cancelled autocomplete + Cancelled, +} + +/// Simple fuzzy match: check if query chars appear in order in text +fn fuzzy_match(text: &str, query: &str) -> bool { + let mut text_chars = text.chars().peekable(); + + for query_char in query.chars() { + loop { + match text_chars.next() { + Some(c) if c == query_char => break, + Some(_) => continue, + None => return false, + } + } + } + + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fuzzy_match() { + assert!(fuzzy_match("help", "h")); + assert!(fuzzy_match("help", "he")); + assert!(fuzzy_match("help", "hel")); + assert!(fuzzy_match("help", "help")); + assert!(fuzzy_match("help", "hp")); // fuzzy: h...p + assert!(!fuzzy_match("help", "x")); + assert!(!fuzzy_match("help", "helping")); // query longer than text + } + + #[test] + fn test_autocomplete_filter() { + let theme = Theme::default(); + let mut ac = Autocomplete::new(theme); + + ac.update_filter("he"); + assert!(ac.filtered.len() < ac.options.len()); + + // Should match "help" + assert!(ac.filtered.iter().any(|&i| ac.options[i].trigger == "help")); + } + + #[test] + fn test_autocomplete_navigation() { + let theme = Theme::default(); + let mut ac = Autocomplete::new(theme); + ac.show(); + + assert_eq!(ac.selected, 0); + ac.select_next(); + assert_eq!(ac.selected, 1); + ac.select_prev(); + assert_eq!(ac.selected, 0); + } + + #[test] + fn test_autocomplete_confirm() { + let theme = Theme::default(); + let mut ac = Autocomplete::new(theme); + ac.show(); + + let cmd = ac.confirm(); + assert!(cmd.is_some()); + assert!(cmd.unwrap().starts_with("/")); + } +} diff --git a/crates/app/ui/src/components/chat_panel.rs b/crates/app/ui/src/components/chat_panel.rs index 2bb1484..4639217 100644 --- a/crates/app/ui/src/components/chat_panel.rs +++ b/crates/app/ui/src/components/chat_panel.rs @@ -224,10 +224,15 @@ impl ChatPanel { } /// Render the borderless chat panel + /// + /// Message display format (no symbols, clean typography): + /// - Role: bold, appropriate color + /// - Timestamp: dim, same line as role + /// - Content: 2-space indent, normal weight + /// - Blank line between messages pub fn render(&self, frame: &mut Frame, area: Rect) { let mut text_lines = Vec::new(); let wrap_width = area.width.saturating_sub(4) as usize; - let symbols = &self.theme.symbols; for (idx, display_msg) in self.messages.iter().enumerate() { let is_focused = self.focused_index == Some(idx); @@ -235,22 +240,15 @@ impl ChatPanel { match &display_msg.message { ChatMessage::User(content) => { - // User message: bright, with prefix - let mut role_spans = vec![ + // Role line: "You" bold + timestamp dim + text_lines.push(Line::from(vec![ Span::styled(" ", Style::default()), + Span::styled("You", self.theme.user_message), Span::styled( - format!("{} You", symbols.user_prefix), - self.theme.user_message, + format!(" {}", display_msg.timestamp), + self.theme.timestamp, ), - ]; - - // Timestamp right-aligned (we'll simplify for now) - role_spans.push(Span::styled( - format!(" {}", display_msg.timestamp), - self.theme.timestamp, - )); - - text_lines.push(Line::from(role_spans)); + ])); // Message content with 2-space indent let wrapped = textwrap::wrap(content, wrap_width); @@ -278,19 +276,19 @@ impl ChatPanel { } ChatMessage::Assistant(content) => { - // Assistant message: accent color + // Role line: streaming indicator (if active) + "Assistant" bold + timestamp let mut role_spans = vec![Span::styled(" ", Style::default())]; - // Streaming indicator + // Streaming indicator (subtle, no symbol) if is_last && self.is_streaming { role_spans.push(Span::styled( - format!("{} ", symbols.streaming), + "... ", Style::default().fg(self.theme.palette.success), )); } role_spans.push(Span::styled( - format!("{} Assistant", symbols.assistant_prefix), + "Assistant", self.theme.assistant_message.add_modifier(Modifier::BOLD), )); @@ -327,12 +325,9 @@ impl ChatPanel { } ChatMessage::ToolCall { name, args } => { + // Tool calls: name in tool color, args dimmed text_lines.push(Line::from(vec![ Span::styled(" ", Style::default()), - Span::styled( - format!("{} ", symbols.tool_prefix), - self.theme.tool_call, - ), Span::styled(format!("{} ", name), self.theme.tool_call), Span::styled( truncate_str(args, 60), @@ -343,34 +338,28 @@ impl ChatPanel { } ChatMessage::ToolResult { success, output } => { - let style = if *success { - self.theme.tool_result_success + // Tool results: status prefix + output + let (prefix, style) = if *success { + ("ok ", self.theme.tool_result_success) } else { - self.theme.tool_result_error - }; - let icon = if *success { - symbols.check - } else { - symbols.cross + ("err ", self.theme.tool_result_error) }; text_lines.push(Line::from(vec![ - Span::styled(format!(" {} ", icon), style), + Span::styled(" ", Style::default()), + Span::styled(prefix, style), Span::styled( truncate_str(output, 100), - style.add_modifier(Modifier::DIM), + style.remove_modifier(Modifier::BOLD), ), ])); text_lines.push(Line::from("")); } ChatMessage::System(content) => { + // System messages: just dim text, no prefix text_lines.push(Line::from(vec![ Span::styled(" ", Style::default()), - Span::styled( - format!("{} ", symbols.system_prefix), - self.theme.system_message, - ), Span::styled(content.to_string(), self.theme.system_message), ])); } diff --git a/crates/app/ui/src/components/command_help.rs b/crates/app/ui/src/components/command_help.rs new file mode 100644 index 0000000..3924f93 --- /dev/null +++ b/crates/app/ui/src/components/command_help.rs @@ -0,0 +1,322 @@ +//! Command help overlay component +//! +//! Modal overlay that displays available commands in a structured format. +//! Shown when user types `/help` or `?`. Supports scrolling with j/k or arrows. + +use crate::theme::Theme; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + layout::Rect, + style::Style, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, + Frame, +}; + +/// A single command definition +#[derive(Debug, Clone)] +pub struct Command { + pub name: &'static str, + pub args: Option<&'static str>, + pub description: &'static str, +} + +impl Command { + pub const fn new(name: &'static str, description: &'static str) -> Self { + Self { + name, + args: None, + description, + } + } + + pub const fn with_args(name: &'static str, args: &'static str, description: &'static str) -> Self { + Self { + name, + args: Some(args), + description, + } + } +} + +/// Built-in commands +pub fn builtin_commands() -> Vec { + vec![ + Command::new("help", "Show this help"), + Command::new("status", "Current session info"), + Command::with_args("model", "[name]", "Switch model"), + Command::with_args("provider", "[name]", "Switch provider (ollama, anthropic, openai)"), + Command::new("history", "Browse conversation history"), + Command::new("checkpoint", "Save conversation state"), + Command::new("checkpoints", "List saved checkpoints"), + Command::with_args("rewind", "[id]", "Restore checkpoint"), + Command::new("cost", "Show token usage"), + Command::new("clear", "Clear conversation"), + Command::new("compact", "Compact conversation context"), + Command::new("permissions", "Show permission mode"), + Command::new("themes", "List available themes"), + Command::with_args("theme", "[name]", "Switch theme"), + Command::new("exit", "Exit OWLEN"), + ] +} + +/// Command help overlay +pub struct CommandHelp { + commands: Vec, + visible: bool, + scroll_offset: usize, + theme: Theme, +} + +impl CommandHelp { + pub fn new(theme: Theme) -> Self { + Self { + commands: builtin_commands(), + visible: false, + scroll_offset: 0, + theme, + } + } + + /// Show the help overlay + pub fn show(&mut self) { + self.visible = true; + self.scroll_offset = 0; // Reset scroll when showing + } + + /// Hide the help overlay + pub fn hide(&mut self) { + self.visible = false; + } + + /// Check if visible + pub fn is_visible(&self) -> bool { + self.visible + } + + /// Toggle visibility + pub fn toggle(&mut self) { + self.visible = !self.visible; + if self.visible { + self.scroll_offset = 0; + } + } + + /// Scroll up by amount + fn scroll_up(&mut self, amount: usize) { + self.scroll_offset = self.scroll_offset.saturating_sub(amount); + } + + /// Scroll down by amount, respecting max + fn scroll_down(&mut self, amount: usize, max_scroll: usize) { + self.scroll_offset = (self.scroll_offset + amount).min(max_scroll); + } + + /// Handle key input, returns true if overlay handled the key + pub fn handle_key(&mut self, key: KeyEvent) -> bool { + if !self.visible { + return false; + } + + // Calculate max scroll (commands + padding lines - visible area) + let total_lines = self.commands.len() + 3; // +3 for padding and footer + let max_scroll = total_lines.saturating_sub(10); // Assume ~10 visible lines + + match key.code { + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') => { + self.hide(); + true + } + // Scroll navigation + KeyCode::Up | KeyCode::Char('k') => { + self.scroll_up(1); + true + } + KeyCode::Down | KeyCode::Char('j') => { + self.scroll_down(1, max_scroll); + true + } + KeyCode::PageUp | KeyCode::Char('u') => { + self.scroll_up(5); + true + } + KeyCode::PageDown | KeyCode::Char('d') => { + self.scroll_down(5, max_scroll); + true + } + KeyCode::Home | KeyCode::Char('g') => { + self.scroll_offset = 0; + true + } + KeyCode::End | KeyCode::Char('G') => { + self.scroll_offset = max_scroll; + true + } + _ => true, // Consume all other keys while visible + } + } + + /// Update theme + pub fn set_theme(&mut self, theme: Theme) { + self.theme = theme; + } + + /// Add plugin commands + pub fn add_commands(&mut self, commands: Vec) { + self.commands.extend(commands); + } + + /// Render the help overlay + pub fn render(&self, frame: &mut Frame, area: Rect) { + if !self.visible { + return; + } + + // Calculate overlay dimensions + let width = (area.width as f32 * 0.7).min(65.0) as u16; + let max_height = area.height.saturating_sub(4); + let content_height = self.commands.len() as u16 + 4; // +4 for padding and footer + let height = content_height.min(max_height).max(8); + + // Center the overlay + let x = (area.width.saturating_sub(width)) / 2; + let y = (area.height.saturating_sub(height)) / 2; + + let overlay_area = Rect::new(x, y, width, height); + + // Clear the area behind the overlay + frame.render_widget(Clear, overlay_area); + + // Build content lines + let mut lines: Vec = Vec::new(); + + // Empty line for padding + lines.push(Line::from("")); + + // Command list + for cmd in &self.commands { + let name_with_args = if let Some(args) = cmd.args { + format!("/{} {}", cmd.name, args) + } else { + format!("/{}", cmd.name) + }; + + // Calculate padding for alignment + let name_width: usize = 22; + let padding = name_width.saturating_sub(name_with_args.len()); + + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled("/", self.theme.cmd_slash), + Span::styled( + if let Some(args) = cmd.args { + format!("{} {}", cmd.name, args) + } else { + cmd.name.to_string() + }, + self.theme.cmd_name, + ), + Span::raw(" ".repeat(padding)), + Span::styled(cmd.description, self.theme.cmd_desc), + ])); + } + + // Empty line for padding + lines.push(Line::from("")); + + // Footer hint with scroll info + let scroll_hint = if self.commands.len() > (height as usize - 4) { + format!(" (scroll: j/k or ↑/↓)") + } else { + String::new() + }; + + lines.push(Line::from(vec![ + Span::styled(" Press ", self.theme.cmd_desc), + Span::styled("Esc", self.theme.cmd_name), + Span::styled(" to close", self.theme.cmd_desc), + Span::styled(scroll_hint, self.theme.cmd_desc), + ])); + + // Create the block with border + let block = Block::default() + .title(" Commands ") + .title_style(self.theme.popup_title) + .borders(Borders::ALL) + .border_style(self.theme.popup_border) + .style(self.theme.overlay_bg); + + let paragraph = Paragraph::new(lines) + .block(block) + .scroll((self.scroll_offset as u16, 0)); + + frame.render_widget(paragraph, overlay_area); + + // Render scrollbar if content exceeds visible area + let visible_height = height.saturating_sub(2) as usize; // -2 for borders + let total_lines = self.commands.len() + 3; + if total_lines > visible_height { + let scrollbar = Scrollbar::default() + .orientation(ScrollbarOrientation::VerticalRight) + .begin_symbol(None) + .end_symbol(None) + .track_symbol(Some(" ")) + .thumb_symbol("│") + .style(self.theme.status_dim); + + let mut scrollbar_state = ScrollbarState::default() + .content_length(total_lines) + .position(self.scroll_offset); + + // Adjust scrollbar area to be inside the border + let scrollbar_area = Rect::new( + overlay_area.x + overlay_area.width - 2, + overlay_area.y + 1, + 1, + overlay_area.height.saturating_sub(2), + ); + + frame.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_command_help_visibility() { + let theme = Theme::default(); + let mut help = CommandHelp::new(theme); + + assert!(!help.is_visible()); + help.show(); + assert!(help.is_visible()); + help.hide(); + assert!(!help.is_visible()); + } + + #[test] + fn test_builtin_commands() { + let commands = builtin_commands(); + assert!(!commands.is_empty()); + assert!(commands.iter().any(|c| c.name == "help")); + assert!(commands.iter().any(|c| c.name == "provider")); + } + + #[test] + fn test_scroll_navigation() { + let theme = Theme::default(); + let mut help = CommandHelp::new(theme); + help.show(); + + assert_eq!(help.scroll_offset, 0); + help.scroll_down(3, 10); + assert_eq!(help.scroll_offset, 3); + help.scroll_up(1); + assert_eq!(help.scroll_offset, 2); + help.scroll_up(10); // Should clamp to 0 + assert_eq!(help.scroll_offset, 0); + } +} diff --git a/crates/app/ui/src/components/mod.rs b/crates/app/ui/src/components/mod.rs index 7d04cb3..9f49c2d 100644 --- a/crates/app/ui/src/components/mod.rs +++ b/crates/app/ui/src/components/mod.rs @@ -1,13 +1,19 @@ //! TUI components for the borderless multi-provider design +mod autocomplete; mod chat_panel; +mod command_help; mod input_box; mod permission_popup; mod provider_tabs; mod status_bar; +mod todo_panel; +pub use autocomplete::{Autocomplete, AutocompleteOption, AutocompleteResult}; pub use chat_panel::{ChatMessage, ChatPanel, DisplayMessage}; +pub use command_help::{Command, CommandHelp}; pub use input_box::{InputBox, InputEvent}; pub use permission_popup::{PermissionOption, PermissionPopup}; pub use provider_tabs::ProviderTabs; pub use status_bar::{AppState, StatusBar}; +pub use todo_panel::TodoPanel; diff --git a/crates/app/ui/src/components/status_bar.rs b/crates/app/ui/src/components/status_bar.rs index 3aa213c..c903085 100644 --- a/crates/app/ui/src/components/status_bar.rs +++ b/crates/app/ui/src/components/status_bar.rs @@ -1,13 +1,14 @@ -//! Multi-provider status bar component +//! Minimal status bar component //! -//! Borderless status bar showing provider, model, mode, stats, and state. -//! Format: 󰚩 model │ Mode │ N msgs │ 󱐋 N │ ~Nk │ $0.00 │ ● status +//! Clean, readable status bar with essential info only. +//! Format: ` Mode │ N msgs │ ~Nk tok │ state` use crate::theme::{Provider, Theme, VimMode}; use agent_core::SessionStats; use permissions::Mode; use ratatui::{ layout::Rect, + style::Style, text::{Line, Span}, widgets::Paragraph, Frame, @@ -23,19 +24,10 @@ pub enum AppState { } impl AppState { - pub fn icon(&self) -> &'static str { - match self { - AppState::Idle => "○", - AppState::Streaming => "●", - AppState::WaitingPermission => "◐", - AppState::Error => "✗", - } - } - pub fn label(&self) -> &'static str { match self { AppState::Idle => "idle", - AppState::Streaming => "streaming", + AppState::Streaming => "streaming...", AppState::WaitingPermission => "waiting", AppState::Error => "error", } @@ -51,6 +43,7 @@ pub struct StatusBar { last_tool: Option, state: AppState, estimated_cost: f64, + planning_mode: bool, theme: Theme, } @@ -65,6 +58,7 @@ impl StatusBar { last_tool: None, state: AppState::Idle, estimated_cost: 0.0, + planning_mode: false, theme, } } @@ -114,99 +108,60 @@ impl StatusBar { self.theme = theme; } - /// Render the status bar - pub fn render(&self, frame: &mut Frame, area: Rect) { - let symbols = &self.theme.symbols; - let sep = symbols.vertical_separator; + /// Set planning mode status + pub fn set_planning_mode(&mut self, active: bool) { + self.planning_mode = active; + } - // Provider icon and model - let provider_icon = self.theme.provider_icon(self.provider); - let provider_style = ratatui::style::Style::default() - .fg(self.theme.provider_color(self.provider)); + /// Render the minimal status bar + /// + /// Format: ` Mode │ N msgs │ ~Nk tok │ state` + pub fn render(&self, frame: &mut Frame, area: Rect) { + let sep = self.theme.symbols.vertical_separator; + let sep_style = Style::default().fg(self.theme.palette.border); // Permission mode - let mode_str = match self.mode { - Mode::Plan => "Plan", - Mode::AcceptEdits => "Edit", - Mode::Code => "Code", + let mode_str = if self.planning_mode { + "PLAN" + } else { + match self.mode { + Mode::Plan => "Plan", + Mode::AcceptEdits => "Edit", + Mode::Code => "Code", + } }; // Format token count let tokens_str = if self.stats.estimated_tokens >= 1000 { - format!("~{}k", self.stats.estimated_tokens / 1000) + format!("~{}k tok", self.stats.estimated_tokens / 1000) } else { - format!("~{}", self.stats.estimated_tokens) + format!("~{} tok", self.stats.estimated_tokens) }; - // Cost display (only for paid providers) - let cost_str = if self.provider != Provider::Ollama && self.estimated_cost > 0.0 { - format!("${:.2}", self.estimated_cost) - } else { - String::new() - }; - - // State indicator + // State style - only highlight non-idle states let state_style = match self.state { AppState::Idle => self.theme.status_dim, - AppState::Streaming => ratatui::style::Style::default() - .fg(self.theme.palette.success), - AppState::WaitingPermission => ratatui::style::Style::default() - .fg(self.theme.palette.warning), - AppState::Error => ratatui::style::Style::default() - .fg(self.theme.palette.error), + AppState::Streaming => Style::default().fg(self.theme.palette.success), + AppState::WaitingPermission => Style::default().fg(self.theme.palette.warning), + AppState::Error => Style::default().fg(self.theme.palette.error), }; - // Build status line - let mut spans = vec![ - Span::styled(" ", self.theme.status_bar), - // Provider icon and model - Span::styled(format!("{} ", provider_icon), provider_style), - Span::styled(&self.model, self.theme.status_bar), - Span::styled(format!(" {} ", sep), self.theme.status_dim), - // Permission mode - Span::styled(mode_str, self.theme.status_bar), - Span::styled(format!(" {} ", sep), self.theme.status_dim), + // Build minimal status line + let spans = vec![ + Span::styled(" ", self.theme.status_dim), + // Mode + Span::styled(mode_str, self.theme.status_dim), + Span::styled(format!(" {} ", sep), sep_style), // Message count - Span::styled(format!("{} msgs", self.stats.total_messages), self.theme.status_bar), - Span::styled(format!(" {} ", sep), self.theme.status_dim), - // Tool count - Span::styled(format!("{} {}", symbols.tool_prefix, self.stats.total_tool_calls), self.theme.status_bar), - Span::styled(format!(" {} ", sep), self.theme.status_dim), + Span::styled(format!("{} msgs", self.stats.total_messages), self.theme.status_dim), + Span::styled(format!(" {} ", sep), sep_style), // Token count - Span::styled(tokens_str, self.theme.status_bar), + Span::styled(&tokens_str, self.theme.status_dim), + Span::styled(format!(" {} ", sep), sep_style), + // State + Span::styled(self.state.label(), state_style), ]; - // Add cost if applicable - if !cost_str.is_empty() { - spans.push(Span::styled(format!(" {} ", sep), self.theme.status_dim)); - spans.push(Span::styled(cost_str, self.theme.status_accent)); - } - - // State indicator - spans.push(Span::styled(format!(" {} ", sep), self.theme.status_dim)); - spans.push(Span::styled( - format!("{} {}", self.state.icon(), self.state.label()), - state_style, - )); - - // Calculate current width - let current_width: usize = spans - .iter() - .map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref())) - .sum(); - - // Add help hint on the right - let vim_indicator = self.vim_mode.indicator(&self.theme.symbols); - let help_hint = format!("{} ?", vim_indicator); - let help_width = unicode_width::UnicodeWidthStr::width(help_hint.as_str()) + 2; - - // Padding - let available = area.width as usize; - let padding = available.saturating_sub(current_width + help_width); - spans.push(Span::raw(" ".repeat(padding))); - spans.push(Span::styled(help_hint, self.theme.status_dim)); - spans.push(Span::raw(" ")); - let line = Line::from(spans); let paragraph = Paragraph::new(line); frame.render_widget(paragraph, area); @@ -227,7 +182,7 @@ mod tests { #[test] fn test_app_state_display() { assert_eq!(AppState::Idle.label(), "idle"); - assert_eq!(AppState::Streaming.label(), "streaming"); - assert_eq!(AppState::Error.icon(), "✗"); + assert_eq!(AppState::Streaming.label(), "streaming..."); + assert_eq!(AppState::Error.label(), "error"); } } diff --git a/crates/app/ui/src/components/todo_panel.rs b/crates/app/ui/src/components/todo_panel.rs new file mode 100644 index 0000000..1260206 --- /dev/null +++ b/crates/app/ui/src/components/todo_panel.rs @@ -0,0 +1,200 @@ +//! Todo panel component for displaying task list +//! +//! Shows the current todo list with status indicators and progress. + +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; +use tools_todo::{Todo, TodoList, TodoStatus}; + +use crate::theme::Theme; + +/// Todo panel component +pub struct TodoPanel { + theme: Theme, + collapsed: bool, +} + +impl TodoPanel { + pub fn new(theme: Theme) -> Self { + Self { + theme, + collapsed: false, + } + } + + /// Toggle collapsed state + pub fn toggle(&mut self) { + self.collapsed = !self.collapsed; + } + + /// Check if collapsed + pub fn is_collapsed(&self) -> bool { + self.collapsed + } + + /// Update theme + pub fn set_theme(&mut self, theme: Theme) { + self.theme = theme; + } + + /// Get the minimum height needed for the panel + pub fn min_height(&self) -> u16 { + if self.collapsed { + 1 + } else { + 5 + } + } + + /// Render the todo panel + pub fn render(&self, frame: &mut Frame, area: Rect, todos: &TodoList) { + if self.collapsed { + self.render_collapsed(frame, area, todos); + } else { + self.render_expanded(frame, area, todos); + } + } + + /// Render collapsed view (single line summary) + fn render_collapsed(&self, frame: &mut Frame, area: Rect, todos: &TodoList) { + let items = todos.read(); + let completed = items.iter().filter(|t| t.status == TodoStatus::Completed).count(); + let in_progress = items.iter().filter(|t| t.status == TodoStatus::InProgress).count(); + let pending = items.iter().filter(|t| t.status == TodoStatus::Pending).count(); + + let summary = if items.is_empty() { + "No tasks".to_string() + } else { + format!( + "{} {} / {} {} / {} {}", + self.theme.symbols.check, completed, + self.theme.symbols.streaming, in_progress, + self.theme.symbols.bullet, pending + ) + }; + + let line = Line::from(vec![ + Span::styled("Tasks: ", self.theme.status_bar), + Span::styled(summary, self.theme.status_dim), + Span::styled(" [t to expand]", self.theme.status_dim), + ]); + + let paragraph = Paragraph::new(line); + frame.render_widget(paragraph, area); + } + + /// Render expanded view with task list + fn render_expanded(&self, frame: &mut Frame, area: Rect, todos: &TodoList) { + let items = todos.read(); + + let mut lines: Vec = Vec::new(); + + // Header + lines.push(Line::from(vec![ + Span::styled("Tasks", Style::default().add_modifier(Modifier::BOLD)), + Span::styled(" [t to collapse]", self.theme.status_dim), + ])); + + if items.is_empty() { + lines.push(Line::from(Span::styled( + " No active tasks", + self.theme.status_dim, + ))); + } else { + // Show tasks (limit to available space) + let max_items = (area.height as usize).saturating_sub(2); + let display_items: Vec<&Todo> = items.iter().take(max_items).collect(); + + for item in display_items { + let (icon, style) = match item.status { + TodoStatus::Completed => ( + self.theme.symbols.check, + Style::default().fg(Color::Green), + ), + TodoStatus::InProgress => ( + self.theme.symbols.streaming, + Style::default().fg(Color::Yellow), + ), + TodoStatus::Pending => ( + self.theme.symbols.bullet, + self.theme.status_dim, + ), + }; + + // Use active form for in-progress, content for others + let text = if item.status == TodoStatus::InProgress { + &item.active_form + } else { + &item.content + }; + + // Truncate if too long + let max_width = area.width.saturating_sub(6) as usize; + let display_text = if text.len() > max_width { + format!("{}...", &text[..max_width.saturating_sub(3)]) + } else { + text.clone() + }; + + lines.push(Line::from(vec![ + Span::styled(format!(" {} ", icon), style), + Span::styled(display_text, style), + ])); + } + + // Show overflow indicator if needed + if items.len() > max_items { + lines.push(Line::from(Span::styled( + format!(" ... and {} more", items.len() - max_items), + self.theme.status_dim, + ))); + } + } + + let block = Block::default() + .borders(Borders::TOP) + .border_style(self.theme.status_dim); + + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, area); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_todo_panel_creation() { + let theme = Theme::default(); + let panel = TodoPanel::new(theme); + assert!(!panel.is_collapsed()); + } + + #[test] + fn test_todo_panel_toggle() { + let theme = Theme::default(); + let mut panel = TodoPanel::new(theme); + + assert!(!panel.is_collapsed()); + panel.toggle(); + assert!(panel.is_collapsed()); + panel.toggle(); + assert!(!panel.is_collapsed()); + } + + #[test] + fn test_min_height() { + let theme = Theme::default(); + let mut panel = TodoPanel::new(theme); + + assert_eq!(panel.min_height(), 5); + panel.toggle(); + assert_eq!(panel.min_height(), 1); + } +} diff --git a/crates/app/ui/src/events.rs b/crates/app/ui/src/events.rs index eb76e24..ba491b8 100644 --- a/crates/app/ui/src/events.rs +++ b/crates/app/ui/src/events.rs @@ -8,8 +8,14 @@ pub enum AppEvent { Input(KeyEvent), /// User submitted a message UserMessage(String), - /// LLM response chunk + /// LLM streaming started + StreamStart, + /// LLM response chunk (streaming) LlmChunk(String), + /// LLM streaming completed + StreamEnd { response: String }, + /// LLM streaming error + StreamError(String), /// Tool call started ToolCall { name: String, args: Value }, /// Tool execution result @@ -27,6 +33,8 @@ pub enum AppEvent { ScrollUp, /// Mouse scroll down ScrollDown, + /// Toggle the todo panel + ToggleTodo, /// Application should quit Quit, } @@ -37,6 +45,9 @@ pub fn handle_key_event(key: KeyEvent) -> Option { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { Some(AppEvent::Quit) } + KeyCode::Char('t') if key.modifiers.contains(KeyModifiers::CONTROL) => { + Some(AppEvent::ToggleTodo) + } _ => Some(AppEvent::Input(key)), } } diff --git a/crates/app/ui/src/layout.rs b/crates/app/ui/src/layout.rs index 855dbf2..fbe1353 100644 --- a/crates/app/ui/src/layout.rs +++ b/crates/app/ui/src/layout.rs @@ -22,6 +22,8 @@ pub struct AppLayout { pub top_divider: Rect, /// Main chat/message area pub chat_area: Rect, + /// Todo panel area (optional, between chat and input) + pub todo_area: Rect, /// Bottom divider (horizontal rule) pub bottom_divider: Rect, /// Input area for user text @@ -33,24 +35,54 @@ pub struct AppLayout { impl AppLayout { /// Calculate layout for the given terminal size pub fn calculate(area: Rect) -> Self { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), // Header - Constraint::Length(1), // Provider tabs - Constraint::Length(1), // Top divider - Constraint::Min(5), // Chat area (flexible) - Constraint::Length(1), // Bottom divider - Constraint::Length(1), // Input - Constraint::Length(1), // Status bar - ]) - .split(area); + Self::calculate_with_todo(area, 0) + } + + /// Calculate layout with todo panel of specified height + /// + /// Simplified layout without provider tabs: + /// - Header (1 line) + /// - Top divider (1 line) + /// - Chat area (flexible) + /// - Todo panel (optional) + /// - Bottom divider (1 line) + /// - Input (1 line) + /// - Status bar (1 line) + pub fn calculate_with_todo(area: Rect, todo_height: u16) -> Self { + let chunks = if todo_height > 0 { + Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Header + Constraint::Length(1), // Top divider + Constraint::Min(5), // Chat area (flexible) + Constraint::Length(todo_height), // Todo panel + Constraint::Length(1), // Bottom divider + Constraint::Length(1), // Input + Constraint::Length(1), // Status bar + ]) + .split(area) + } else { + Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Header + Constraint::Length(1), // Top divider + Constraint::Min(5), // Chat area (flexible) + Constraint::Length(0), // No todo panel + Constraint::Length(1), // Bottom divider + Constraint::Length(1), // Input + Constraint::Length(1), // Status bar + ]) + .split(area) + }; Self { header_area: chunks[0], - tabs_area: chunks[1], - top_divider: chunks[2], - chat_area: chunks[3], + tabs_area: Rect::default(), // Not used in simplified layout + top_divider: chunks[1], + chat_area: chunks[2], + todo_area: chunks[3], bottom_divider: chunks[4], input_area: chunks[5], status_area: chunks[6], @@ -65,9 +97,9 @@ impl AppLayout { .direction(Direction::Vertical) .constraints([ Constraint::Length(1), // Header - Constraint::Length(1), // Provider tabs Constraint::Length(1), // Top divider Constraint::Min(5), // Chat area (flexible) + Constraint::Length(0), // No todo panel Constraint::Length(1), // Bottom divider Constraint::Length(input_height), // Expanded input Constraint::Length(1), // Status bar @@ -76,9 +108,10 @@ impl AppLayout { Self { header_area: chunks[0], - tabs_area: chunks[1], - top_divider: chunks[2], - chat_area: chunks[3], + tabs_area: Rect::default(), + top_divider: chunks[1], + chat_area: chunks[2], + todo_area: chunks[3], bottom_divider: chunks[4], input_area: chunks[5], status_area: chunks[6], @@ -93,6 +126,7 @@ impl AppLayout { Constraint::Length(1), // Header (includes compact provider indicator) Constraint::Length(1), // Top divider Constraint::Min(5), // Chat area (flexible) + Constraint::Length(0), // No todo panel Constraint::Length(1), // Bottom divider Constraint::Length(1), // Input Constraint::Length(1), // Status bar @@ -104,9 +138,10 @@ impl AppLayout { tabs_area: Rect::default(), // No tabs area in compact mode top_divider: chunks[1], chat_area: chunks[2], - bottom_divider: chunks[3], - input_area: chunks[4], - status_area: chunks[5], + todo_area: chunks[3], + bottom_divider: chunks[4], + input_area: chunks[5], + status_area: chunks[6], } } diff --git a/crates/app/ui/src/lib.rs b/crates/app/ui/src/lib.rs index 773b919..27939b6 100644 --- a/crates/app/ui/src/lib.rs +++ b/crates/app/ui/src/lib.rs @@ -1,12 +1,16 @@ pub mod app; +pub mod completions; pub mod components; pub mod events; pub mod formatting; pub mod layout; +pub mod output; pub mod theme; pub use app::TuiApp; +pub use completions::{CompletionEngine, Completion, CommandInfo}; pub use events::AppEvent; +pub use output::{CommandOutput, OutputFormat, TreeNode, ListItem}; pub use formatting::{ FormattedContent, MarkdownRenderer, SyntaxHighlighter, format_file_path, format_tool_name, format_error, format_success, format_warning, format_info, diff --git a/crates/app/ui/src/output.rs b/crates/app/ui/src/output.rs new file mode 100644 index 0000000..7963262 --- /dev/null +++ b/crates/app/ui/src/output.rs @@ -0,0 +1,388 @@ +//! Rich command output formatting +//! +//! Provides formatted output for commands like /help, /mcp, /hooks +//! with tables, trees, and syntax highlighting. + +use ratatui::text::{Line, Span}; +use ratatui::style::{Color, Modifier, Style}; + +use crate::completions::CommandInfo; +use crate::theme::Theme; + +/// A tree node for hierarchical display +#[derive(Debug, Clone)] +pub struct TreeNode { + pub label: String, + pub children: Vec, +} + +impl TreeNode { + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + children: vec![], + } + } + + pub fn with_children(mut self, children: Vec) -> Self { + self.children = children; + self + } +} + +/// A list item with optional icon/marker +#[derive(Debug, Clone)] +pub struct ListItem { + pub text: String, + pub marker: Option, + pub style: Option