feat(ui): add autocomplete, command help, and streaming improvements

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 <noreply@anthropic.com>
This commit is contained in:
2025-12-02 19:03:33 +01:00
parent 10c8e2baae
commit 4a07b97eab
27 changed files with 4034 additions and 263 deletions

View File

@@ -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" }

View File

@@ -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<PermissionPopup>,
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<PendingToolCall>,
permission_tx: Option<tokio::sync::oneshot::Sender<bool>>,
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<AppEvent>,
) -> 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<AppEvent>,
event_tx: &mpsc::UnboundedSender<AppEvent>,
) -> 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<AppEvent>,
) -> Result<String> {
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 <name>' 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 <name>' 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(

View File

@@ -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<CommandInfo>,
}
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<CommandInfo> {
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<CommandInfo>) {
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<Completion> {
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<Completion> {
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<Completion> {
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"));
}
}

View File

@@ -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<AutocompleteOption> {
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<AutocompleteOption>,
filtered: Vec<usize>, // indices into options
selected: usize,
visible: bool,
theme: Theme,
}
impl Autocomplete {
pub fn new(theme: Theme) -> Self {
let options = default_options();
let filtered: Vec<usize> = (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<String> {
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<AutocompleteOption>) {
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<Line> = 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("/"));
}
}

View File

@@ -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),
]));
}

View File

@@ -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<Command> {
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<Command>,
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<Command>) {
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<Line> = 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);
}
}

View File

@@ -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;

View File

@@ -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<String>,
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");
}
}

View File

@@ -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<Line> = 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);
}
}

View File

@@ -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<AppEvent> {
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)),
}
}

View File

@@ -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],
}
}

View File

@@ -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,

388
crates/app/ui/src/output.rs Normal file
View File

@@ -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<TreeNode>,
}
impl TreeNode {
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
children: vec![],
}
}
pub fn with_children(mut self, children: Vec<TreeNode>) -> Self {
self.children = children;
self
}
}
/// A list item with optional icon/marker
#[derive(Debug, Clone)]
pub struct ListItem {
pub text: String,
pub marker: Option<String>,
pub style: Option<Style>,
}
/// Different output formats
#[derive(Debug, Clone)]
pub enum OutputFormat {
/// Formatted table with headers and rows
Table {
headers: Vec<String>,
rows: Vec<Vec<String>>,
},
/// Hierarchical tree view
Tree {
root: TreeNode,
},
/// Syntax-highlighted code block
Code {
language: String,
content: String,
},
/// Side-by-side diff view
Diff {
old: String,
new: String,
},
/// Simple list with markers
List {
items: Vec<ListItem>,
},
/// Plain text
Text {
content: String,
},
}
/// Rich command output renderer
pub struct CommandOutput {
pub format: OutputFormat,
}
impl CommandOutput {
pub fn new(format: OutputFormat) -> Self {
Self { format }
}
/// Create a help table output
pub fn help_table(commands: &[CommandInfo]) -> Self {
let headers = vec![
"Command".to_string(),
"Description".to_string(),
"Source".to_string(),
];
let rows: Vec<Vec<String>> = commands
.iter()
.map(|c| vec![
format!("/{}", c.name),
c.description.clone(),
c.source.clone(),
])
.collect();
Self {
format: OutputFormat::Table { headers, rows },
}
}
/// Create an MCP servers tree view
pub fn mcp_tree(servers: &[(String, Vec<String>)]) -> Self {
let children: Vec<TreeNode> = servers
.iter()
.map(|(name, tools)| {
TreeNode {
label: name.clone(),
children: tools.iter().map(|t| TreeNode::new(t)).collect(),
}
})
.collect();
Self {
format: OutputFormat::Tree {
root: TreeNode {
label: "MCP Servers".to_string(),
children,
},
},
}
}
/// Create a hooks list output
pub fn hooks_list(hooks: &[(String, String, bool)]) -> Self {
let items: Vec<ListItem> = hooks
.iter()
.map(|(event, path, enabled)| {
let marker = if *enabled { "" } else { "" };
let style = if *enabled {
Some(Style::default().fg(Color::Green))
} else {
Some(Style::default().fg(Color::Red))
};
ListItem {
text: format!("{}: {}", event, path),
marker: Some(marker.to_string()),
style,
}
})
.collect();
Self {
format: OutputFormat::List { items },
}
}
/// Render to TUI Lines
pub fn render(&self, theme: &Theme) -> Vec<Line<'static>> {
match &self.format {
OutputFormat::Table { headers, rows } => {
self.render_table(headers, rows, theme)
}
OutputFormat::Tree { root } => {
self.render_tree(root, 0, theme)
}
OutputFormat::List { items } => {
self.render_list(items, theme)
}
OutputFormat::Code { content, .. } => {
content.lines()
.map(|line| Line::from(Span::styled(line.to_string(), theme.tool_call)))
.collect()
}
OutputFormat::Diff { old, new } => {
self.render_diff(old, new, theme)
}
OutputFormat::Text { content } => {
content.lines()
.map(|line| Line::from(line.to_string()))
.collect()
}
}
}
fn render_table(&self, headers: &[String], rows: &[Vec<String>], theme: &Theme) -> Vec<Line<'static>> {
let mut lines = Vec::new();
// Calculate column widths
let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
for row in rows {
for (i, cell) in row.iter().enumerate() {
if i < widths.len() {
widths[i] = widths[i].max(cell.len());
}
}
}
// Header line
let header_spans: Vec<Span> = headers
.iter()
.enumerate()
.flat_map(|(i, h)| {
let padded = format!("{:width$}", h, width = widths.get(i).copied().unwrap_or(h.len()));
vec![
Span::styled(padded, Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" "),
]
})
.collect();
lines.push(Line::from(header_spans));
// Separator
let sep: String = widths.iter().map(|w| "".repeat(*w)).collect::<Vec<_>>().join("──");
lines.push(Line::from(Span::styled(sep, theme.status_dim)));
// Rows
for row in rows {
let row_spans: Vec<Span> = row
.iter()
.enumerate()
.flat_map(|(i, cell)| {
let padded = format!("{:width$}", cell, width = widths.get(i).copied().unwrap_or(cell.len()));
let style = if i == 0 {
theme.status_accent // Command names in accent color
} else {
theme.status_bar
};
vec![
Span::styled(padded, style),
Span::raw(" "),
]
})
.collect();
lines.push(Line::from(row_spans));
}
lines
}
fn render_tree(&self, node: &TreeNode, depth: usize, theme: &Theme) -> Vec<Line<'static>> {
let mut lines = Vec::new();
// Render current node
let prefix = if depth == 0 {
"".to_string()
} else {
format!("{}├─ ", "".repeat(depth - 1))
};
let style = if depth == 0 {
Style::default().add_modifier(Modifier::BOLD)
} else if node.children.is_empty() {
theme.status_bar
} else {
theme.status_accent
};
lines.push(Line::from(vec![
Span::styled(prefix, theme.status_dim),
Span::styled(node.label.clone(), style),
]));
// Render children
for child in &node.children {
lines.extend(self.render_tree(child, depth + 1, theme));
}
lines
}
fn render_list(&self, items: &[ListItem], theme: &Theme) -> Vec<Line<'static>> {
items
.iter()
.map(|item| {
let marker_span = if let Some(marker) = &item.marker {
Span::styled(
format!("{} ", marker),
item.style.unwrap_or(theme.status_bar),
)
} else {
Span::raw("")
};
Line::from(vec![
marker_span,
Span::styled(
item.text.clone(),
item.style.unwrap_or(theme.status_bar),
),
])
})
.collect()
}
fn render_diff(&self, old: &str, new: &str, _theme: &Theme) -> Vec<Line<'static>> {
let mut lines = Vec::new();
// Simple line-by-line diff
let old_lines: Vec<&str> = old.lines().collect();
let new_lines: Vec<&str> = new.lines().collect();
let max_len = old_lines.len().max(new_lines.len());
for i in 0..max_len {
let old_line = old_lines.get(i).copied().unwrap_or("");
let new_line = new_lines.get(i).copied().unwrap_or("");
if old_line != new_line {
if !old_line.is_empty() {
lines.push(Line::from(Span::styled(
format!("- {}", old_line),
Style::default().fg(Color::Red),
)));
}
if !new_line.is_empty() {
lines.push(Line::from(Span::styled(
format!("+ {}", new_line),
Style::default().fg(Color::Green),
)));
}
} else {
lines.push(Line::from(format!(" {}", old_line)));
}
}
lines
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_help_table() {
let commands = vec![
CommandInfo::new("help", "Show help", "builtin"),
CommandInfo::new("clear", "Clear screen", "builtin"),
];
let output = CommandOutput::help_table(&commands);
match output.format {
OutputFormat::Table { headers, rows } => {
assert_eq!(headers.len(), 3);
assert_eq!(rows.len(), 2);
}
_ => panic!("Expected Table format"),
}
}
#[test]
fn test_mcp_tree() {
let servers = vec![
("filesystem".to_string(), vec!["read".to_string(), "write".to_string()]),
("database".to_string(), vec!["query".to_string()]),
];
let output = CommandOutput::mcp_tree(&servers);
match output.format {
OutputFormat::Tree { root } => {
assert_eq!(root.label, "MCP Servers");
assert_eq!(root.children.len(), 2);
}
_ => panic!("Expected Tree format"),
}
}
#[test]
fn test_hooks_list() {
let hooks = vec![
("PreToolUse".to_string(), "./hooks/pre".to_string(), true),
("PostToolUse".to_string(), "./hooks/post".to_string(), false),
];
let output = CommandOutput::hooks_list(&hooks);
match output.format {
OutputFormat::List { items } => {
assert_eq!(items.len(), 2);
}
_ => panic!("Expected List format"),
}
}
#[test]
fn test_tree_node() {
let node = TreeNode::new("root")
.with_children(vec![
TreeNode::new("child1"),
TreeNode::new("child2"),
]);
assert_eq!(node.label, "root");
assert_eq!(node.children.len(), 2);
}
}

View File

@@ -141,6 +141,14 @@ impl Symbols {
}
/// Modern color palette inspired by contemporary design systems
///
/// Color assignment principles:
/// - fg (#c0caf5): PRIMARY text - user messages, command names
/// - assistant (#9aa5ce): Soft gray-blue for AI responses (distinct from user)
/// - accent (#7aa2f7): Interactive elements ONLY (mode, prompt symbol)
/// - cmd_slash (#bb9af7): Purple for / prefix (signals "command")
/// - fg_dim (#565f89): Timestamps, hints, inactive elements
/// - selection (#283457): Highlighted row background
#[derive(Debug, Clone)]
pub struct ColorPalette {
pub primary: Color,
@@ -155,44 +163,68 @@ pub struct ColorPalette {
pub fg_dim: Color,
pub fg_muted: Color,
pub highlight: Color,
pub border: Color, // For horizontal rules (subtle)
pub selection: Color, // Highlighted row background
// Provider-specific colors
pub claude: Color,
pub ollama: Color,
pub openai: Color,
// Semantic colors for borderless design
pub user_fg: Color,
pub assistant_fg: Color,
// Semantic colors for messages
pub user_fg: Color, // User message text (bright, fg)
pub assistant_fg: Color, // Assistant message text (soft gray-blue)
pub tool_fg: Color,
pub timestamp_fg: Color,
pub divider_fg: Color,
// Command colors
pub cmd_slash: Color, // Purple for / prefix
pub cmd_name: Color, // Command name (same as fg)
pub cmd_desc: Color, // Command description (dim)
// Overlay/modal colors
pub overlay_bg: Color, // Slightly lighter than main bg
}
impl ColorPalette {
/// Tokyo Night inspired palette - vibrant and modern
/// Tokyo Night inspired palette - high contrast, readable
///
/// Key principles:
/// - fg (#c0caf5) for user messages and command names
/// - assistant (#a9b1d6) brighter gray-blue for AI responses (readable)
/// - accent (#7aa2f7) only for interactive elements (mode indicator, prompt symbol)
/// - cmd_slash (#bb9af7) purple for / prefix (signals "command")
/// - fg_dim (#737aa2) for timestamps, hints, descriptions (brighter than before)
/// - border (#3b4261) for horizontal rules
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
fg_muted: Color::Rgb(65, 72, 104), // Very dim
highlight: Color::Rgb(56, 62, 90), // Selection bg
primary: Color::Rgb(122, 162, 247), // #7aa2f7 - Blue accent
secondary: Color::Rgb(187, 154, 247), // #bb9af7 - Purple
accent: Color::Rgb(122, 162, 247), // #7aa2f7 - Interactive elements ONLY
success: Color::Rgb(158, 206, 106), // #9ece6a - Green
warning: Color::Rgb(224, 175, 104), // #e0af68 - Yellow
error: Color::Rgb(247, 118, 142), // #f7768e - Pink/Red
info: Color::Rgb(125, 207, 255), // Cyan (rarely used)
bg: Color::Rgb(26, 27, 38), // #1a1b26 - Dark bg
fg: Color::Rgb(192, 202, 245), // #c0caf5 - Primary text (HIGH CONTRAST)
fg_dim: Color::Rgb(115, 122, 162), // #737aa2 - Secondary text (BRIGHTER)
fg_muted: Color::Rgb(86, 95, 137), // #565f89 - Very dim
highlight: Color::Rgb(56, 62, 90), // Selection bg (legacy)
border: Color::Rgb(73, 82, 115), // #495273 - Horizontal rules (BRIGHTER)
selection: Color::Rgb(40, 52, 87), // #283457 - Highlighted row bg
// Provider colors
claude: Color::Rgb(217, 119, 87), // Claude orange
ollama: Color::Rgb(122, 162, 247), // Blue
openai: Color::Rgb(16, 163, 127), // OpenAI green
// Semantic
user_fg: Color::Rgb(255, 255, 255), // Bright white for user
assistant_fg: Color::Rgb(125, 207, 255), // Cyan for AI
tool_fg: Color::Rgb(224, 175, 104), // Yellow for tools
timestamp_fg: Color::Rgb(65, 72, 104), // Very dim
divider_fg: Color::Rgb(56, 62, 90), // Subtle divider
// Message colors - user bright, assistant readable
user_fg: Color::Rgb(192, 202, 245), // #c0caf5 - Same as fg (bright)
assistant_fg: Color::Rgb(169, 177, 214), // #a9b1d6 - Brighter gray-blue (READABLE)
tool_fg: Color::Rgb(224, 175, 104), // #e0af68 - Yellow for tools
timestamp_fg: Color::Rgb(115, 122, 162), // #737aa2 - Brighter dim
divider_fg: Color::Rgb(73, 82, 115), // #495273 - Border color (BRIGHTER)
// Command colors
cmd_slash: Color::Rgb(187, 154, 247), // #bb9af7 - Purple for / prefix
cmd_name: Color::Rgb(192, 202, 245), // #c0caf5 - White for command name
cmd_desc: Color::Rgb(115, 122, 162), // #737aa2 - Brighter description
// Overlay colors
overlay_bg: Color::Rgb(36, 40, 59), // #24283b - Slightly lighter than bg
}
}
@@ -211,14 +243,20 @@ impl ColorPalette {
fg_dim: Color::Rgb(98, 114, 164), // Comment
fg_muted: Color::Rgb(68, 71, 90), // Very dim
highlight: Color::Rgb(68, 71, 90), // Selection
border: Color::Rgb(68, 71, 90),
selection: Color::Rgb(68, 71, 90),
claude: Color::Rgb(255, 121, 198),
ollama: Color::Rgb(139, 233, 253),
openai: Color::Rgb(80, 250, 123),
user_fg: Color::Rgb(248, 248, 242),
assistant_fg: Color::Rgb(139, 233, 253),
assistant_fg: Color::Rgb(189, 186, 220), // Softer purple-gray
tool_fg: Color::Rgb(241, 250, 140),
timestamp_fg: Color::Rgb(68, 71, 90),
divider_fg: Color::Rgb(68, 71, 90),
cmd_slash: Color::Rgb(189, 147, 249), // Purple
cmd_name: Color::Rgb(248, 248, 242),
cmd_desc: Color::Rgb(98, 114, 164),
overlay_bg: Color::Rgb(50, 52, 64),
}
}
@@ -237,14 +275,20 @@ impl ColorPalette {
fg_dim: Color::Rgb(108, 112, 134), // Overlay
fg_muted: Color::Rgb(69, 71, 90), // Surface
highlight: Color::Rgb(49, 50, 68), // Surface
border: Color::Rgb(69, 71, 90),
selection: Color::Rgb(49, 50, 68),
claude: Color::Rgb(245, 194, 231),
ollama: Color::Rgb(137, 180, 250),
openai: Color::Rgb(166, 227, 161),
user_fg: Color::Rgb(205, 214, 244),
assistant_fg: Color::Rgb(148, 226, 213),
assistant_fg: Color::Rgb(166, 187, 213), // Softer blue-gray
tool_fg: Color::Rgb(249, 226, 175),
timestamp_fg: Color::Rgb(69, 71, 90),
divider_fg: Color::Rgb(69, 71, 90),
cmd_slash: Color::Rgb(203, 166, 247), // Mauve
cmd_name: Color::Rgb(205, 214, 244),
cmd_desc: Color::Rgb(108, 112, 134),
overlay_bg: Color::Rgb(40, 40, 56),
}
}
@@ -263,14 +307,20 @@ impl ColorPalette {
fg_dim: Color::Rgb(76, 86, 106), // Polar night light
fg_muted: Color::Rgb(59, 66, 82),
highlight: Color::Rgb(59, 66, 82), // Selection
border: Color::Rgb(59, 66, 82),
selection: Color::Rgb(59, 66, 82),
claude: Color::Rgb(180, 142, 173),
ollama: Color::Rgb(136, 192, 208),
openai: Color::Rgb(163, 190, 140),
user_fg: Color::Rgb(236, 239, 244),
assistant_fg: Color::Rgb(136, 192, 208),
assistant_fg: Color::Rgb(180, 195, 210), // Softer blue-gray
tool_fg: Color::Rgb(235, 203, 139),
timestamp_fg: Color::Rgb(59, 66, 82),
divider_fg: Color::Rgb(59, 66, 82),
cmd_slash: Color::Rgb(180, 142, 173), // Aurora purple
cmd_name: Color::Rgb(236, 239, 244),
cmd_desc: Color::Rgb(76, 86, 106),
overlay_bg: Color::Rgb(56, 62, 74),
}
}
@@ -289,14 +339,20 @@ impl ColorPalette {
fg_dim: Color::Rgb(127, 90, 180), // Mid purple
fg_muted: Color::Rgb(72, 12, 168),
highlight: Color::Rgb(72, 12, 168), // Deep purple
border: Color::Rgb(72, 12, 168),
selection: Color::Rgb(72, 12, 168),
claude: Color::Rgb(255, 128, 0),
ollama: Color::Rgb(0, 229, 255),
openai: Color::Rgb(0, 255, 157),
user_fg: Color::Rgb(242, 233, 255),
assistant_fg: Color::Rgb(0, 229, 255),
assistant_fg: Color::Rgb(180, 170, 220), // Softer purple
tool_fg: Color::Rgb(255, 215, 0),
timestamp_fg: Color::Rgb(72, 12, 168),
divider_fg: Color::Rgb(72, 12, 168),
cmd_slash: Color::Rgb(255, 0, 128), // Hot pink
cmd_name: Color::Rgb(242, 233, 255),
cmd_desc: Color::Rgb(127, 90, 180),
overlay_bg: Color::Rgb(30, 26, 42),
}
}
@@ -315,14 +371,20 @@ impl ColorPalette {
fg_dim: Color::Rgb(110, 106, 134), // Muted
fg_muted: Color::Rgb(42, 39, 63),
highlight: Color::Rgb(42, 39, 63), // Highlight
border: Color::Rgb(42, 39, 63),
selection: Color::Rgb(42, 39, 63),
claude: Color::Rgb(234, 154, 151),
ollama: Color::Rgb(156, 207, 216),
openai: Color::Rgb(49, 116, 143),
user_fg: Color::Rgb(224, 222, 244),
assistant_fg: Color::Rgb(156, 207, 216),
assistant_fg: Color::Rgb(180, 185, 210), // Softer lavender-gray
tool_fg: Color::Rgb(246, 193, 119),
timestamp_fg: Color::Rgb(42, 39, 63),
divider_fg: Color::Rgb(42, 39, 63),
cmd_slash: Color::Rgb(235, 188, 186), // Rose
cmd_name: Color::Rgb(224, 222, 244),
cmd_desc: Color::Rgb(110, 106, 134),
overlay_bg: Color::Rgb(35, 33, 46),
}
}
@@ -341,14 +403,20 @@ impl ColorPalette {
fg_dim: Color::Rgb(71, 103, 145), // Muted blue
fg_muted: Color::Rgb(13, 43, 69),
highlight: Color::Rgb(13, 43, 69), // Deep blue
border: Color::Rgb(13, 43, 69),
selection: Color::Rgb(13, 43, 69),
claude: Color::Rgb(199, 146, 234),
ollama: Color::Rgb(102, 217, 239),
openai: Color::Rgb(163, 190, 140),
user_fg: Color::Rgb(201, 211, 235),
assistant_fg: Color::Rgb(102, 217, 239),
assistant_fg: Color::Rgb(150, 175, 200), // Softer blue-gray
tool_fg: Color::Rgb(229, 200, 144),
timestamp_fg: Color::Rgb(13, 43, 69),
divider_fg: Color::Rgb(13, 43, 69),
cmd_slash: Color::Rgb(199, 146, 234), // Purple
cmd_name: Color::Rgb(201, 211, 235),
cmd_desc: Color::Rgb(71, 103, 145),
overlay_bg: Color::Rgb(11, 32, 49),
}
}
}
@@ -422,6 +490,13 @@ pub struct Theme {
pub status_bar: Style,
pub status_accent: Style,
pub status_dim: Style,
// Command styles
pub cmd_slash: Style, // Purple for / prefix
pub cmd_name: Style, // White for command name
pub cmd_desc: Style, // Dim for description
// Overlay/modal styles
pub overlay_bg: Style, // Modal background
pub selection_bg: Style, // Selected row background
// Popup styles (for permission dialogs)
pub popup_border: Style,
pub popup_bg: Style,
@@ -483,17 +558,24 @@ impl Theme {
status_bar: Style::default().fg(palette.fg_dim),
status_accent: Style::default().fg(palette.accent),
status_dim: Style::default().fg(palette.fg_muted),
// Command styles
cmd_slash: Style::default().fg(palette.cmd_slash),
cmd_name: Style::default().fg(palette.cmd_name),
cmd_desc: Style::default().fg(palette.cmd_desc),
// Overlay/modal styles
overlay_bg: Style::default().bg(palette.overlay_bg),
selection_bg: Style::default().bg(palette.selection),
// Popup styles
popup_border: Style::default()
.fg(palette.accent)
.fg(palette.border)
.add_modifier(Modifier::BOLD),
popup_bg: Style::default().bg(palette.highlight),
popup_bg: Style::default().bg(palette.overlay_bg),
popup_title: Style::default()
.fg(palette.accent)
.fg(palette.fg)
.add_modifier(Modifier::BOLD),
selected: Style::default()
.fg(palette.bg)
.bg(palette.accent)
.fg(palette.fg)
.bg(palette.selection)
.add_modifier(Modifier::BOLD),
// Legacy compatibility
border: Style::default().fg(palette.fg_dim),