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:
@@ -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" }
|
||||
|
||||
@@ -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(
|
||||
|
||||
226
crates/app/ui/src/completions.rs
Normal file
226
crates/app/ui/src/completions.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
377
crates/app/ui/src/components/autocomplete.rs
Normal file
377
crates/app/ui/src/components/autocomplete.rs
Normal 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("/"));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]));
|
||||
}
|
||||
|
||||
322
crates/app/ui/src/components/command_help.rs
Normal file
322
crates/app/ui/src/components/command_help.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
200
crates/app/ui/src/components/todo_panel.rs
Normal file
200
crates/app/ui/src/components/todo_panel.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
388
crates/app/ui/src/output.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user