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

TUI Enhancements:
- Add autocomplete dropdown with fuzzy filtering for slash commands
- Fix autocomplete: Tab confirms selection, Enter submits message
- Add command help overlay with scroll support (j/k, arrows, Page Up/Down)
- Brighten Tokyo Night theme colors for better readability
- Add todo panel component for task display
- Add rich command output formatting (tables, trees, lists)

Streaming Fixes:
- Refactor to non-blocking background streaming with channel events
- Add StreamStart/StreamEnd/StreamError events
- Fix LlmChunk to append instead of creating new messages
- Display user message immediately before LLM call

New Components:
- completions.rs: Command completion engine with fuzzy matching
- autocomplete.rs: Inline autocomplete dropdown
- command_help.rs: Modal help overlay with scrolling
- todo_panel.rs: Todo list display panel
- output.rs: Rich formatted output (tables, trees, code blocks)
- commands.rs: Built-in command implementations

Planning Mode Groundwork:
- Add EnterPlanMode/ExitPlanMode tools scaffolding
- Add Skill tool for plugin skill invocation
- Extend permissions with planning mode support
- Add compact.rs stub for context compaction

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

View File

@@ -15,6 +15,8 @@ members = [
"crates/tools/bash", "crates/tools/bash",
"crates/tools/fs", "crates/tools/fs",
"crates/tools/notebook", "crates/tools/notebook",
"crates/tools/plan",
"crates/tools/skill",
"crates/tools/slash", "crates/tools/slash",
"crates/tools/task", "crates/tools/task",
"crates/tools/todo", "crates/tools/todo",

View File

@@ -0,0 +1,382 @@
//! Built-in commands for CLI and TUI
//!
//! Provides handlers for /help, /mcp, /hooks, /clear, and other built-in commands.
use ui::{CommandInfo, CommandOutput, OutputFormat, TreeNode, ListItem};
use permissions::PermissionManager;
use hooks::HookManager;
use plugins::PluginManager;
use agent_core::SessionStats;
/// Result of executing a built-in command
pub enum CommandResult {
/// Command produced output to display
Output(CommandOutput),
/// Command was handled but produced no output (e.g., /clear)
Handled,
/// Command was not recognized
NotFound,
/// Command needs to exit the session
Exit,
}
/// Built-in command handler
pub struct BuiltinCommands<'a> {
plugin_manager: Option<&'a PluginManager>,
hook_manager: Option<&'a HookManager>,
permission_manager: Option<&'a PermissionManager>,
stats: Option<&'a SessionStats>,
}
impl<'a> BuiltinCommands<'a> {
pub fn new() -> Self {
Self {
plugin_manager: None,
hook_manager: None,
permission_manager: None,
stats: None,
}
}
pub fn with_plugins(mut self, pm: &'a PluginManager) -> Self {
self.plugin_manager = Some(pm);
self
}
pub fn with_hooks(mut self, hm: &'a HookManager) -> Self {
self.hook_manager = Some(hm);
self
}
pub fn with_permissions(mut self, perms: &'a PermissionManager) -> Self {
self.permission_manager = Some(perms);
self
}
pub fn with_stats(mut self, stats: &'a SessionStats) -> Self {
self.stats = Some(stats);
self
}
/// Execute a built-in command
pub fn execute(&self, command: &str) -> CommandResult {
let parts: Vec<&str> = command.split_whitespace().collect();
let cmd = parts.first().map(|s| s.trim_start_matches('/'));
match cmd {
Some("help") | Some("?") => CommandResult::Output(self.help()),
Some("mcp") => CommandResult::Output(self.mcp()),
Some("hooks") => CommandResult::Output(self.hooks()),
Some("plugins") => CommandResult::Output(self.plugins()),
Some("status") => CommandResult::Output(self.status()),
Some("permissions") | Some("perms") => CommandResult::Output(self.permissions()),
Some("clear") => CommandResult::Handled,
Some("exit") | Some("quit") | Some("q") => CommandResult::Exit,
_ => CommandResult::NotFound,
}
}
/// Generate help output
fn help(&self) -> CommandOutput {
let mut commands = vec![
// Built-in commands
CommandInfo::new("help", "Show available commands", "builtin"),
CommandInfo::new("clear", "Clear the screen", "builtin"),
CommandInfo::new("status", "Show session status", "builtin"),
CommandInfo::new("permissions", "Show permission settings", "builtin"),
CommandInfo::new("mcp", "List MCP servers and tools", "builtin"),
CommandInfo::new("hooks", "Show loaded hooks", "builtin"),
CommandInfo::new("plugins", "Show loaded plugins", "builtin"),
CommandInfo::new("checkpoint", "Save session state", "builtin"),
CommandInfo::new("checkpoints", "List saved checkpoints", "builtin"),
CommandInfo::new("rewind", "Restore from checkpoint", "builtin"),
CommandInfo::new("compact", "Compact conversation context", "builtin"),
CommandInfo::new("exit", "Exit the session", "builtin"),
];
// Add plugin commands
if let Some(pm) = self.plugin_manager {
for plugin in pm.plugins() {
for cmd_name in plugin.all_command_names() {
commands.push(CommandInfo::new(
&cmd_name,
&format!("Plugin command from {}", plugin.manifest.name),
&format!("plugin:{}", plugin.manifest.name),
));
}
}
}
CommandOutput::help_table(&commands)
}
/// Generate MCP servers output
fn mcp(&self) -> CommandOutput {
let mut servers: Vec<(String, Vec<String>)> = vec![];
// Get MCP servers from plugins
if let Some(pm) = self.plugin_manager {
for plugin in pm.plugins() {
// Check for .mcp.json in plugin directory
let mcp_path = plugin.base_path.join(".mcp.json");
if mcp_path.exists() {
if let Ok(content) = std::fs::read_to_string(&mcp_path) {
if let Ok(config) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(mcpservers) = config.get("mcpServers").and_then(|v| v.as_object()) {
for (name, _) in mcpservers {
servers.push((
format!("{} ({})", name, plugin.manifest.name),
vec!["(connect to discover tools)".to_string()],
));
}
}
}
}
}
}
}
if servers.is_empty() {
CommandOutput::new(OutputFormat::Text {
content: "No MCP servers configured.\n\nAdd MCP servers in plugin .mcp.json files.".to_string(),
})
} else {
CommandOutput::mcp_tree(&servers)
}
}
/// Generate hooks output
fn hooks(&self) -> CommandOutput {
let mut hooks_list: Vec<(String, String, bool)> = vec![];
// Check for file-based hooks in .owlen/hooks/
let hook_events = ["PreToolUse", "PostToolUse", "SessionStart", "SessionEnd",
"UserPromptSubmit", "PreCompact", "Stop", "SubagentStop"];
for event in hook_events {
let path = format!(".owlen/hooks/{}", event);
let exists = std::path::Path::new(&path).exists();
if exists {
hooks_list.push((event.to_string(), path, true));
}
}
// Get hooks from plugins
if let Some(pm) = self.plugin_manager {
for plugin in pm.plugins() {
if let Some(hooks_config) = plugin.load_hooks_config().ok().flatten() {
// hooks_config.hooks is HashMap<String, Vec<HookMatcher>>
for (event_name, matchers) in &hooks_config.hooks {
for matcher in matchers {
for hook_def in &matcher.hooks {
let cmd = hook_def.command.as_deref()
.or(hook_def.prompt.as_deref())
.unwrap_or("(no command)");
hooks_list.push((
event_name.clone(),
format!("{}: {}", plugin.manifest.name, cmd),
true,
));
}
}
}
}
}
}
if hooks_list.is_empty() {
CommandOutput::new(OutputFormat::Text {
content: "No hooks configured.\n\nAdd hooks in .owlen/hooks/ or plugin hooks.json files.".to_string(),
})
} else {
CommandOutput::hooks_list(&hooks_list)
}
}
/// Generate plugins output
fn plugins(&self) -> CommandOutput {
if let Some(pm) = self.plugin_manager {
let plugins = pm.plugins();
if plugins.is_empty() {
return CommandOutput::new(OutputFormat::Text {
content: "No plugins loaded.\n\nPlace plugins in:\n - ~/.config/owlen/plugins (user)\n - .owlen/plugins (project)".to_string(),
});
}
// Build tree of plugins and their components
let children: Vec<TreeNode> = plugins.iter().map(|p| {
let mut plugin_children = vec![];
let commands = p.all_command_names();
if !commands.is_empty() {
plugin_children.push(TreeNode::new("Commands").with_children(
commands.iter().map(|c| TreeNode::new(format!("/{}", c))).collect()
));
}
let agents = p.all_agent_names();
if !agents.is_empty() {
plugin_children.push(TreeNode::new("Agents").with_children(
agents.iter().map(|a| TreeNode::new(a)).collect()
));
}
let skills = p.all_skill_names();
if !skills.is_empty() {
plugin_children.push(TreeNode::new("Skills").with_children(
skills.iter().map(|s| TreeNode::new(s)).collect()
));
}
TreeNode::new(format!("{} v{}", p.manifest.name, p.manifest.version))
.with_children(plugin_children)
}).collect();
CommandOutput::new(OutputFormat::Tree {
root: TreeNode::new("Loaded Plugins").with_children(children),
})
} else {
CommandOutput::new(OutputFormat::Text {
content: "Plugin manager not available.".to_string(),
})
}
}
/// Generate status output
fn status(&self) -> CommandOutput {
let mut items = vec![];
if let Some(stats) = self.stats {
items.push(ListItem {
text: format!("Messages: {}", stats.total_messages),
marker: Some("📊".to_string()),
style: None,
});
items.push(ListItem {
text: format!("Tool Calls: {}", stats.total_tool_calls),
marker: Some("🔧".to_string()),
style: None,
});
items.push(ListItem {
text: format!("Est. Tokens: ~{}", stats.estimated_tokens),
marker: Some("📝".to_string()),
style: None,
});
let uptime = stats.start_time.elapsed().unwrap_or_default();
items.push(ListItem {
text: format!("Uptime: {}", SessionStats::format_duration(uptime)),
marker: Some("⏱️".to_string()),
style: None,
});
}
if let Some(perms) = self.permission_manager {
items.push(ListItem {
text: format!("Mode: {:?}", perms.mode()),
marker: Some("🔒".to_string()),
style: None,
});
}
if items.is_empty() {
CommandOutput::new(OutputFormat::Text {
content: "Session status not available.".to_string(),
})
} else {
CommandOutput::new(OutputFormat::List { items })
}
}
/// Generate permissions output
fn permissions(&self) -> CommandOutput {
if let Some(perms) = self.permission_manager {
let mode = perms.mode();
let mode_str = format!("{:?}", mode);
let mut items = vec![
ListItem {
text: format!("Current Mode: {}", mode_str),
marker: Some("🔒".to_string()),
style: None,
},
];
// Add tool permissions summary
let (read_status, write_status, bash_status) = match mode {
permissions::Mode::Plan => ("✅ Allowed", "❓ Ask", "❓ Ask"),
permissions::Mode::AcceptEdits => ("✅ Allowed", "✅ Allowed", "❓ Ask"),
permissions::Mode::Code => ("✅ Allowed", "✅ Allowed", "✅ Allowed"),
};
items.push(ListItem {
text: format!("Read/Grep/Glob: {}", read_status),
marker: None,
style: None,
});
items.push(ListItem {
text: format!("Write/Edit: {}", write_status),
marker: None,
style: None,
});
items.push(ListItem {
text: format!("Bash: {}", bash_status),
marker: None,
style: None,
});
CommandOutput::new(OutputFormat::List { items })
} else {
CommandOutput::new(OutputFormat::Text {
content: "Permission manager not available.".to_string(),
})
}
}
}
impl Default for BuiltinCommands<'_> {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_help_command() {
let handler = BuiltinCommands::new();
match handler.execute("/help") {
CommandResult::Output(output) => {
match output.format {
OutputFormat::Table { headers, rows } => {
assert!(!headers.is_empty());
assert!(!rows.is_empty());
}
_ => panic!("Expected Table format"),
}
}
_ => panic!("Expected Output result"),
}
}
#[test]
fn test_exit_command() {
let handler = BuiltinCommands::new();
assert!(matches!(handler.execute("/exit"), CommandResult::Exit));
assert!(matches!(handler.execute("/quit"), CommandResult::Exit));
assert!(matches!(handler.execute("/q"), CommandResult::Exit));
}
#[test]
fn test_clear_command() {
let handler = BuiltinCommands::new();
assert!(matches!(handler.execute("/clear"), CommandResult::Handled));
}
#[test]
fn test_unknown_command() {
let handler = BuiltinCommands::new();
assert!(matches!(handler.execute("/unknown"), CommandResult::NotFound));
}
}

View File

@@ -1,3 +1,5 @@
mod commands;
use clap::{Parser, ValueEnum}; use clap::{Parser, ValueEnum};
use color_eyre::eyre::{Result, eyre}; use color_eyre::eyre::{Result, eyre};
use config_agent::load_settings; use config_agent::load_settings;
@@ -10,6 +12,8 @@ use serde::Serialize;
use std::io::Write; use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
pub use commands::{BuiltinCommands, CommandResult};
#[derive(Debug, Clone, Copy, ValueEnum)] #[derive(Debug, Clone, Copy, ValueEnum)]
enum OutputFormat { enum OutputFormat {
Text, Text,

View File

@@ -24,3 +24,4 @@ permissions = { path = "../../platform/permissions" }
llm-core = { path = "../../llm/core" } llm-core = { path = "../../llm/core" }
llm-ollama = { path = "../../llm/ollama" } llm-ollama = { path = "../../llm/ollama" }
config-agent = { path = "../../platform/config" } config-agent = { path = "../../platform/config" }
tools-todo = { path = "../../tools/todo" }

View File

@@ -1,9 +1,13 @@
use crate::{ use crate::{
components::{ChatMessage, ChatPanel, InputBox, PermissionPopup, StatusBar}, components::{
Autocomplete, AutocompleteResult, ChatMessage, ChatPanel, CommandHelp, InputBox,
PermissionPopup, StatusBar, TodoPanel,
},
events::{handle_key_event, AppEvent}, events::{handle_key_event, AppEvent},
layout::AppLayout, layout::AppLayout,
theme::Theme, theme::{Theme, VimMode},
}; };
use tools_todo::TodoList;
use agent_core::{CheckpointManager, SessionHistory, SessionStats, ToolContext, execute_tool, get_tool_definitions}; use agent_core::{CheckpointManager, SessionHistory, SessionStats, ToolContext, execute_tool, get_tool_definitions};
use color_eyre::eyre::Result; use color_eyre::eyre::Result;
use crossterm::{ use crossterm::{
@@ -15,7 +19,14 @@ use futures::StreamExt;
use llm_core::{ChatMessage as LLMChatMessage, ChatOptions}; use llm_core::{ChatMessage as LLMChatMessage, ChatOptions};
use llm_ollama::OllamaClient; use llm_ollama::OllamaClient;
use permissions::{Action, PermissionDecision, PermissionManager, Tool as PermTool}; 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 serde_json::Value;
use std::{io::stdout, path::PathBuf, time::SystemTime}; use std::{io::stdout, path::PathBuf, time::SystemTime};
use tokio::sync::mpsc; use tokio::sync::mpsc;
@@ -33,13 +44,17 @@ pub struct TuiApp {
chat_panel: ChatPanel, chat_panel: ChatPanel,
input_box: InputBox, input_box: InputBox,
status_bar: StatusBar, status_bar: StatusBar,
todo_panel: TodoPanel,
permission_popup: Option<PermissionPopup>, permission_popup: Option<PermissionPopup>,
autocomplete: Autocomplete,
command_help: CommandHelp,
theme: Theme, theme: Theme,
// Session state // Session state
stats: SessionStats, stats: SessionStats,
history: SessionHistory, history: SessionHistory,
checkpoint_mgr: CheckpointManager, checkpoint_mgr: CheckpointManager,
todo_list: TodoList,
// System state // System state
client: OllamaClient, client: OllamaClient,
@@ -54,6 +69,7 @@ pub struct TuiApp {
waiting_for_llm: bool, waiting_for_llm: bool,
pending_tool: Option<PendingToolCall>, pending_tool: Option<PendingToolCall>,
permission_tx: Option<tokio::sync::oneshot::Sender<bool>>, permission_tx: Option<tokio::sync::oneshot::Sender<bool>>,
vim_mode: VimMode,
} }
impl TuiApp { impl TuiApp {
@@ -70,11 +86,15 @@ impl TuiApp {
chat_panel: ChatPanel::new(theme.clone()), chat_panel: ChatPanel::new(theme.clone()),
input_box: InputBox::new(theme.clone()), input_box: InputBox::new(theme.clone()),
status_bar: StatusBar::new(opts.model.clone(), mode, theme.clone()), status_bar: StatusBar::new(opts.model.clone(), mode, theme.clone()),
todo_panel: TodoPanel::new(theme.clone()),
permission_popup: None, permission_popup: None,
autocomplete: Autocomplete::new(theme.clone()),
command_help: CommandHelp::new(theme.clone()),
theme, theme,
stats: SessionStats::new(), stats: SessionStats::new(),
history: SessionHistory::new(), history: SessionHistory::new(),
checkpoint_mgr: CheckpointManager::new(PathBuf::from(".owlen/checkpoints")), checkpoint_mgr: CheckpointManager::new(PathBuf::from(".owlen/checkpoints")),
todo_list: TodoList::new(),
client, client,
opts, opts,
perms, perms,
@@ -84,6 +104,7 @@ impl TuiApp {
waiting_for_llm: false, waiting_for_llm: false,
pending_tool: None, pending_tool: None,
permission_tx: None, permission_tx: None,
vim_mode: VimMode::Insert,
}) })
} }
@@ -91,7 +112,77 @@ impl TuiApp {
self.theme = theme.clone(); self.theme = theme.clone();
self.chat_panel = ChatPanel::new(theme.clone()); self.chat_panel = ChatPanel::new(theme.clone());
self.input_box = InputBox::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<()> { pub async fn run(&mut self) -> Result<()> {
@@ -142,42 +233,58 @@ impl TuiApp {
} }
}); });
// Add welcome message // No welcome messages added - empty state shows "Start a conversation..."
self.chat_panel.add_message(ChatMessage::System(
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".to_string()
));
self.chat_panel.add_message(ChatMessage::System(
format!("Welcome to Owlen - Your AI Coding Assistant")
));
self.chat_panel.add_message(ChatMessage::System(
format!("Model: {} │ Mode: {:?} │ Theme: Tokyo Night", self.opts.model, self.perms.mode())
));
self.chat_panel.add_message(ChatMessage::System(
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".to_string()
));
self.chat_panel.add_message(ChatMessage::System(
"Type your message or use /help for available commands".to_string()
));
self.chat_panel.add_message(ChatMessage::System(
"Press Ctrl+C to exit anytime".to_string()
));
// Main event loop // Main event loop
while self.running { while self.running {
// Render // Render
terminal.draw(|frame| { terminal.draw(|frame| {
let size = frame.area(); 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 // Update scroll position before rendering
self.chat_panel.update_scroll(layout.chat_area); self.chat_panel.update_scroll(layout.chat_area);
// Render main components // 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); 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); self.input_box.render(frame, layout.input_area);
// Render status bar
self.status_bar.render(frame, layout.status_area); 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 { if let Some(popup) = &self.permission_popup {
popup.render(frame, size); popup.render(frame, size);
} }
@@ -208,7 +315,9 @@ impl TuiApp {
) -> Result<()> { ) -> Result<()> {
match event { match event {
AppEvent::Input(key) => { 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(popup) = &mut self.permission_popup {
if let Some(option) = popup.handle_key(key) { if let Some(option) = popup.handle_key(key) {
use crate::components::PermissionOption; use crate::components::PermissionOption;
@@ -216,7 +325,7 @@ impl TuiApp {
match option { match option {
PermissionOption::AllowOnce => { PermissionOption::AllowOnce => {
self.chat_panel.add_message(ChatMessage::System( 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() { if let Some(tx) = self.permission_tx.take() {
let _ = tx.send(true); let _ = tx.send(true);
@@ -231,7 +340,7 @@ impl TuiApp {
Action::Allow, Action::Allow,
); );
self.chat_panel.add_message(ChatMessage::System( 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() { if let Some(tx) = self.permission_tx.take() {
@@ -240,7 +349,7 @@ impl TuiApp {
} }
PermissionOption::Deny => { PermissionOption::Deny => {
self.chat_panel.add_message(ChatMessage::System( self.chat_panel.add_message(ChatMessage::System(
"Permission denied".to_string() "Permission denied".to_string()
)); ));
if let Some(tx) = self.permission_tx.take() { if let Some(tx) = self.permission_tx.take() {
let _ = tx.send(false); let _ = tx.send(false);
@@ -271,31 +380,39 @@ impl TuiApp {
self.permission_popup = None; self.permission_popup = None;
self.pending_tool = None; self.pending_tool = None;
} }
} else { return Ok(());
// 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 /) // 2. Command help overlay
self.handle_command(&format!("/{}", cmd))?; if self.command_help.is_visible() {
self.command_help.handle_key(key);
return Ok(());
} }
InputEvent::ModeChange(mode) => {
self.status_bar.set_vim_mode(mode); // 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();
} }
InputEvent::Cancel => { AutocompleteResult::Cancelled => {
// Cancel current operation self.autocomplete.hide();
self.waiting_for_llm = false;
}
InputEvent::Expand => {
// TODO: Expand to multiline input
} }
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 => { AppEvent::ScrollUp => {
self.chat_panel.scroll_up(3); self.chat_panel.scroll_up(3);
@@ -308,9 +425,31 @@ impl TuiApp {
.add_message(ChatMessage::User(message.clone())); .add_message(ChatMessage::User(message.clone()));
self.history.add_user_message(message); 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) => { AppEvent::LlmChunk(chunk) => {
// Add to last assistant message or create new one // APPEND to last assistant message (don't create new one each time)
self.chat_panel.add_message(ChatMessage::Assistant(chunk)); 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 } => { AppEvent::ToolCall { name, args } => {
self.chat_panel.add_message(ChatMessage::ToolCall { self.chat_panel.add_message(ChatMessage::ToolCall {
@@ -335,6 +474,9 @@ impl TuiApp {
AppEvent::Resize { .. } => { AppEvent::Resize { .. } => {
// Terminal will automatically re-layout on next draw // Terminal will automatically re-layout on next draw
} }
AppEvent::ToggleTodo => {
self.todo_panel.toggle();
}
AppEvent::Quit => { AppEvent::Quit => {
self.running = false; self.running = false;
} }
@@ -343,10 +485,63 @@ impl TuiApp {
Ok(()) 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( async fn handle_user_message(
&mut self, &mut self,
message: String, message: String,
_event_tx: &mpsc::UnboundedSender<AppEvent>, event_tx: &mpsc::UnboundedSender<AppEvent>,
) -> Result<()> { ) -> Result<()> {
// Handle slash commands // Handle slash commands
if message.starts_with('/') { if message.starts_with('/') {
@@ -354,33 +549,68 @@ impl TuiApp {
return Ok(()); return Ok(());
} }
// Add user message to chat // Add user message to chat IMMEDIATELY so it shows before AI response
self.chat_panel self.chat_panel
.add_message(ChatMessage::User(message.clone())); .add_message(ChatMessage::User(message.clone()));
self.history.add_user_message(message.clone()); self.history.add_user_message(message.clone());
// Run agent loop with tool calling // Start streaming indicator
self.waiting_for_llm = true; 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 { // Spawn streaming in background task
let client = self.client.clone();
let opts = self.opts.clone();
let tx = event_tx.clone();
tokio::spawn(async move {
match Self::run_background_stream(&client, &opts, &message, &tx).await {
Ok(response) => { Ok(response) => {
self.history.add_assistant_message(response.clone()); let _ = tx.send(AppEvent::StreamEnd { response });
// Update stats
let duration = start.elapsed().unwrap_or_default();
let tokens = (message.len() + response.len()) / 4; // Rough estimate
self.stats.record_message(tokens, duration);
} }
Err(e) => { Err(e) => {
self.chat_panel.add_message(ChatMessage::System( let _ = tx.send(AppEvent::StreamError(e.to_string()));
format!("❌ Error: {}", e)
));
} }
} }
});
Ok(())
}
self.waiting_for_llm = false; /// Run streaming in background, sending chunks through channel
Ok(()) 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
}
Ok(response_content)
} }
/// Execute a tool with permission handling /// Execute a tool with permission handling
@@ -630,10 +860,9 @@ impl TuiApp {
fn handle_command(&mut self, command: &str) -> Result<()> { fn handle_command(&mut self, command: &str) -> Result<()> {
match command { match command {
"/help" => { "/help" | "/?" => {
self.chat_panel.add_message(ChatMessage::System( // Show command help overlay
"Available commands: /help, /status, /permissions, /cost, /history, /checkpoint, /checkpoints, /rewind, /clear, /theme, /themes, /exit".to_string(), self.command_help.show();
));
} }
"/status" => { "/status" => {
let elapsed = self.stats.start_time.elapsed().unwrap_or_default(); let elapsed = self.stats.start_time.elapsed().unwrap_or_default();
@@ -718,7 +947,72 @@ impl TuiApp {
self.history.clear(); self.history.clear();
self.stats = SessionStats::new(); self.stats = SessionStats::new();
self.chat_panel 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" => { "/themes" => {
self.chat_panel.add_message(ChatMessage::System( self.chat_panel.add_message(ChatMessage::System(

View File

@@ -0,0 +1,226 @@
//! Command completion engine for the TUI
//!
//! Provides Tab-completion for slash commands, file paths, and tool names.
use std::path::Path;
/// A single completion suggestion
#[derive(Debug, Clone)]
pub struct Completion {
/// The text to insert
pub text: String,
/// Description of what this completion does
pub description: String,
/// Source of the completion (e.g., "builtin", "plugin:name")
pub source: String,
}
/// Information about a command for completion purposes
#[derive(Debug, Clone)]
pub struct CommandInfo {
/// Command name (without leading /)
pub name: String,
/// Command description
pub description: String,
/// Source of the command
pub source: String,
}
impl CommandInfo {
pub fn new(name: &str, description: &str, source: &str) -> Self {
Self {
name: name.to_string(),
description: description.to_string(),
source: source.to_string(),
}
}
}
/// Completion engine for the TUI
pub struct CompletionEngine {
/// Available commands
commands: Vec<CommandInfo>,
}
impl Default for CompletionEngine {
fn default() -> Self {
Self::new()
}
}
impl CompletionEngine {
pub fn new() -> Self {
Self {
commands: Self::builtin_commands(),
}
}
/// Get built-in commands
fn builtin_commands() -> Vec<CommandInfo> {
vec![
CommandInfo::new("help", "Show available commands and help", "builtin"),
CommandInfo::new("clear", "Clear the screen", "builtin"),
CommandInfo::new("mcp", "List MCP servers and their tools", "builtin"),
CommandInfo::new("hooks", "Show loaded hooks", "builtin"),
CommandInfo::new("compact", "Compact conversation context", "builtin"),
CommandInfo::new("mode", "Switch permission mode (plan/edit/code)", "builtin"),
CommandInfo::new("provider", "Switch LLM provider", "builtin"),
CommandInfo::new("model", "Switch LLM model", "builtin"),
CommandInfo::new("checkpoint", "Create a checkpoint", "builtin"),
CommandInfo::new("rewind", "Rewind to a checkpoint", "builtin"),
]
}
/// Add commands from plugins
pub fn add_plugin_commands(&mut self, plugin_name: &str, commands: Vec<CommandInfo>) {
for mut cmd in commands {
cmd.source = format!("plugin:{}", plugin_name);
self.commands.push(cmd);
}
}
/// Add a single command
pub fn add_command(&mut self, command: CommandInfo) {
self.commands.push(command);
}
/// Get completions for the given input
pub fn complete(&self, input: &str) -> Vec<Completion> {
if input.starts_with('/') {
self.complete_command(&input[1..])
} else if input.starts_with('@') {
self.complete_file_path(&input[1..])
} else {
vec![]
}
}
/// Complete a slash command
fn complete_command(&self, partial: &str) -> Vec<Completion> {
let partial_lower = partial.to_lowercase();
self.commands
.iter()
.filter(|cmd| {
// Match if name starts with partial, or contains partial (fuzzy)
cmd.name.to_lowercase().starts_with(&partial_lower)
|| (partial.len() >= 2 && cmd.name.to_lowercase().contains(&partial_lower))
})
.map(|cmd| Completion {
text: format!("/{}", cmd.name),
description: cmd.description.clone(),
source: cmd.source.clone(),
})
.collect()
}
/// Complete a file path
fn complete_file_path(&self, partial: &str) -> Vec<Completion> {
let path = Path::new(partial);
// Get the directory to search and the prefix to match
let (dir, prefix) = if partial.ends_with('/') || partial.is_empty() {
(partial, "")
} else {
let parent = path.parent().map(|p| p.to_str().unwrap_or("")).unwrap_or("");
let file_name = path.file_name().and_then(|f| f.to_str()).unwrap_or("");
(parent, file_name)
};
// Search directory
let search_dir = if dir.is_empty() { "." } else { dir };
match std::fs::read_dir(search_dir) {
Ok(entries) => {
entries
.filter_map(|entry| entry.ok())
.filter(|entry| {
let name = entry.file_name();
let name_str = name.to_string_lossy();
// Skip hidden files unless user started typing with .
if !prefix.starts_with('.') && name_str.starts_with('.') {
return false;
}
name_str.to_lowercase().starts_with(&prefix.to_lowercase())
})
.map(|entry| {
let name = entry.file_name();
let name_str = name.to_string_lossy();
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
let full_path = if dir.is_empty() {
name_str.to_string()
} else if dir.ends_with('/') {
format!("{}{}", dir, name_str)
} else {
format!("{}/{}", dir, name_str)
};
Completion {
text: format!("@{}{}", full_path, if is_dir { "/" } else { "" }),
description: if is_dir { "Directory".to_string() } else { "File".to_string() },
source: "filesystem".to_string(),
}
})
.collect()
}
Err(_) => vec![],
}
}
/// Get all commands (for /help display)
pub fn all_commands(&self) -> &[CommandInfo] {
&self.commands
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_command_completion_exact() {
let engine = CompletionEngine::new();
let completions = engine.complete("/help");
assert!(!completions.is_empty());
assert!(completions.iter().any(|c| c.text == "/help"));
}
#[test]
fn test_command_completion_partial() {
let engine = CompletionEngine::new();
let completions = engine.complete("/hel");
assert!(!completions.is_empty());
assert!(completions.iter().any(|c| c.text == "/help"));
}
#[test]
fn test_command_completion_fuzzy() {
let engine = CompletionEngine::new();
// "cle" should match "clear"
let completions = engine.complete("/cle");
assert!(!completions.is_empty());
assert!(completions.iter().any(|c| c.text == "/clear"));
}
#[test]
fn test_command_info() {
let info = CommandInfo::new("test", "A test command", "builtin");
assert_eq!(info.name, "test");
assert_eq!(info.description, "A test command");
assert_eq!(info.source, "builtin");
}
#[test]
fn test_add_plugin_commands() {
let mut engine = CompletionEngine::new();
let plugin_cmds = vec![
CommandInfo::new("custom", "A custom command", ""),
];
engine.add_plugin_commands("my-plugin", plugin_cmds);
let completions = engine.complete("/custom");
assert!(!completions.is_empty());
assert!(completions.iter().any(|c| c.source == "plugin:my-plugin"));
}
}

View File

@@ -0,0 +1,377 @@
//! Command autocomplete dropdown component
//!
//! Displays inline autocomplete suggestions when user types `/`.
//! Supports fuzzy filtering as user types.
use crate::theme::Theme;
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
layout::Rect,
style::Style,
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
/// An autocomplete option
#[derive(Debug, Clone)]
pub struct AutocompleteOption {
/// The trigger text (command name without /)
pub trigger: String,
/// Display text (e.g., "/model [name]")
pub display: String,
/// Short description
pub description: String,
/// Has submenu/subcommands
pub has_submenu: bool,
}
impl AutocompleteOption {
pub fn new(trigger: &str, description: &str) -> Self {
Self {
trigger: trigger.to_string(),
display: format!("/{}", trigger),
description: description.to_string(),
has_submenu: false,
}
}
pub fn with_args(trigger: &str, args: &str, description: &str) -> Self {
Self {
trigger: trigger.to_string(),
display: format!("/{} {}", trigger, args),
description: description.to_string(),
has_submenu: false,
}
}
pub fn with_submenu(trigger: &str, description: &str) -> Self {
Self {
trigger: trigger.to_string(),
display: format!("/{}", trigger),
description: description.to_string(),
has_submenu: true,
}
}
}
/// Default command options
fn default_options() -> Vec<AutocompleteOption> {
vec![
AutocompleteOption::new("help", "Show help"),
AutocompleteOption::new("status", "Session info"),
AutocompleteOption::with_args("model", "[name]", "Switch model"),
AutocompleteOption::with_args("provider", "[name]", "Switch provider"),
AutocompleteOption::new("history", "View history"),
AutocompleteOption::new("checkpoint", "Save state"),
AutocompleteOption::new("checkpoints", "List checkpoints"),
AutocompleteOption::with_args("rewind", "[id]", "Restore"),
AutocompleteOption::new("cost", "Token usage"),
AutocompleteOption::new("clear", "Clear chat"),
AutocompleteOption::new("compact", "Compact context"),
AutocompleteOption::new("permissions", "Permission mode"),
AutocompleteOption::new("themes", "List themes"),
AutocompleteOption::with_args("theme", "[name]", "Switch theme"),
AutocompleteOption::new("exit", "Exit"),
]
}
/// Autocomplete dropdown component
pub struct Autocomplete {
options: Vec<AutocompleteOption>,
filtered: Vec<usize>, // indices into options
selected: usize,
visible: bool,
theme: Theme,
}
impl Autocomplete {
pub fn new(theme: Theme) -> Self {
let options = default_options();
let filtered: Vec<usize> = (0..options.len()).collect();
Self {
options,
filtered,
selected: 0,
visible: false,
theme,
}
}
/// Show autocomplete and reset filter
pub fn show(&mut self) {
self.visible = true;
self.filtered = (0..self.options.len()).collect();
self.selected = 0;
}
/// Hide autocomplete
pub fn hide(&mut self) {
self.visible = false;
}
/// Check if visible
pub fn is_visible(&self) -> bool {
self.visible
}
/// Update filter based on current input (text after /)
pub fn update_filter(&mut self, query: &str) {
if query.is_empty() {
self.filtered = (0..self.options.len()).collect();
} else {
let query_lower = query.to_lowercase();
self.filtered = self.options
.iter()
.enumerate()
.filter(|(_, opt)| {
// Fuzzy match: check if query chars appear in order
fuzzy_match(&opt.trigger.to_lowercase(), &query_lower)
})
.map(|(i, _)| i)
.collect();
}
// Reset selection if it's out of bounds
if self.selected >= self.filtered.len() {
self.selected = 0;
}
}
/// Select next option
pub fn select_next(&mut self) {
if !self.filtered.is_empty() {
self.selected = (self.selected + 1) % self.filtered.len();
}
}
/// Select previous option
pub fn select_prev(&mut self) {
if !self.filtered.is_empty() {
self.selected = if self.selected == 0 {
self.filtered.len() - 1
} else {
self.selected - 1
};
}
}
/// Get the currently selected option's trigger
pub fn confirm(&self) -> Option<String> {
if self.filtered.is_empty() {
return None;
}
let idx = self.filtered[self.selected];
Some(format!("/{}", self.options[idx].trigger))
}
/// Handle key input, returns Some(command) if confirmed
///
/// Key behavior:
/// - Tab: Confirm selection and insert into input
/// - Down/Up: Navigate options
/// - Enter: Pass through to submit (NotHandled)
/// - Esc: Cancel autocomplete
pub fn handle_key(&mut self, key: KeyEvent) -> AutocompleteResult {
if !self.visible {
return AutocompleteResult::NotHandled;
}
match key.code {
KeyCode::Tab => {
// Tab confirms and inserts the selected command
if let Some(cmd) = self.confirm() {
self.hide();
AutocompleteResult::Confirmed(cmd)
} else {
AutocompleteResult::Handled
}
}
KeyCode::Down => {
self.select_next();
AutocompleteResult::Handled
}
KeyCode::BackTab | KeyCode::Up => {
self.select_prev();
AutocompleteResult::Handled
}
KeyCode::Enter => {
// Enter should submit the message, not confirm autocomplete
// Hide autocomplete and let Enter pass through
self.hide();
AutocompleteResult::NotHandled
}
KeyCode::Esc => {
self.hide();
AutocompleteResult::Cancelled
}
_ => AutocompleteResult::NotHandled,
}
}
/// Update theme
pub fn set_theme(&mut self, theme: Theme) {
self.theme = theme;
}
/// Add custom options (from plugins)
pub fn add_options(&mut self, options: Vec<AutocompleteOption>) {
self.options.extend(options);
// Re-filter with all options
self.filtered = (0..self.options.len()).collect();
}
/// Render the autocomplete dropdown above the input line
pub fn render(&self, frame: &mut Frame, input_area: Rect) {
if !self.visible || self.filtered.is_empty() {
return;
}
// Calculate dropdown dimensions
let max_visible = 8.min(self.filtered.len());
let width = 40.min(input_area.width.saturating_sub(4));
let height = (max_visible + 2) as u16; // +2 for borders
// Position above input, left-aligned with some padding
let x = input_area.x + 2;
let y = input_area.y.saturating_sub(height);
let dropdown_area = Rect::new(x, y, width, height);
// Clear area behind dropdown
frame.render_widget(Clear, dropdown_area);
// Build option lines
let mut lines: Vec<Line> = Vec::new();
for (display_idx, &opt_idx) in self.filtered.iter().take(max_visible).enumerate() {
let opt = &self.options[opt_idx];
let is_selected = display_idx == self.selected;
let style = if is_selected {
self.theme.selected
} else {
Style::default()
};
let mut spans = vec![
Span::styled(" ", style),
Span::styled("/", if is_selected { style } else { self.theme.cmd_slash }),
Span::styled(&opt.trigger, if is_selected { style } else { self.theme.cmd_name }),
];
// Submenu indicator
if opt.has_submenu {
spans.push(Span::styled(" >", if is_selected { style } else { self.theme.cmd_desc }));
}
// Pad to fixed width for consistent selection highlighting
let current_len: usize = spans.iter().map(|s| s.content.len()).sum();
let padding = (width as usize).saturating_sub(current_len + 1);
spans.push(Span::styled(" ".repeat(padding), style));
lines.push(Line::from(spans));
}
// Show overflow indicator if needed
if self.filtered.len() > max_visible {
lines.push(Line::from(Span::styled(
format!(" ... +{} more", self.filtered.len() - max_visible),
self.theme.cmd_desc,
)));
}
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(self.theme.palette.border))
.style(self.theme.overlay_bg);
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, dropdown_area);
}
}
/// Result of handling autocomplete key
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AutocompleteResult {
/// Key was not handled by autocomplete
NotHandled,
/// Key was handled, no action needed
Handled,
/// User confirmed selection, returns command string
Confirmed(String),
/// User cancelled autocomplete
Cancelled,
}
/// Simple fuzzy match: check if query chars appear in order in text
fn fuzzy_match(text: &str, query: &str) -> bool {
let mut text_chars = text.chars().peekable();
for query_char in query.chars() {
loop {
match text_chars.next() {
Some(c) if c == query_char => break,
Some(_) => continue,
None => return false,
}
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fuzzy_match() {
assert!(fuzzy_match("help", "h"));
assert!(fuzzy_match("help", "he"));
assert!(fuzzy_match("help", "hel"));
assert!(fuzzy_match("help", "help"));
assert!(fuzzy_match("help", "hp")); // fuzzy: h...p
assert!(!fuzzy_match("help", "x"));
assert!(!fuzzy_match("help", "helping")); // query longer than text
}
#[test]
fn test_autocomplete_filter() {
let theme = Theme::default();
let mut ac = Autocomplete::new(theme);
ac.update_filter("he");
assert!(ac.filtered.len() < ac.options.len());
// Should match "help"
assert!(ac.filtered.iter().any(|&i| ac.options[i].trigger == "help"));
}
#[test]
fn test_autocomplete_navigation() {
let theme = Theme::default();
let mut ac = Autocomplete::new(theme);
ac.show();
assert_eq!(ac.selected, 0);
ac.select_next();
assert_eq!(ac.selected, 1);
ac.select_prev();
assert_eq!(ac.selected, 0);
}
#[test]
fn test_autocomplete_confirm() {
let theme = Theme::default();
let mut ac = Autocomplete::new(theme);
ac.show();
let cmd = ac.confirm();
assert!(cmd.is_some());
assert!(cmd.unwrap().starts_with("/"));
}
}

View File

@@ -224,10 +224,15 @@ impl ChatPanel {
} }
/// Render the borderless chat panel /// 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) { pub fn render(&self, frame: &mut Frame, area: Rect) {
let mut text_lines = Vec::new(); let mut text_lines = Vec::new();
let wrap_width = area.width.saturating_sub(4) as usize; let wrap_width = area.width.saturating_sub(4) as usize;
let symbols = &self.theme.symbols;
for (idx, display_msg) in self.messages.iter().enumerate() { for (idx, display_msg) in self.messages.iter().enumerate() {
let is_focused = self.focused_index == Some(idx); let is_focused = self.focused_index == Some(idx);
@@ -235,22 +240,15 @@ impl ChatPanel {
match &display_msg.message { match &display_msg.message {
ChatMessage::User(content) => { ChatMessage::User(content) => {
// User message: bright, with prefix // Role line: "You" bold + timestamp dim
let mut role_spans = vec![ text_lines.push(Line::from(vec![
Span::styled(" ", Style::default()), Span::styled(" ", Style::default()),
Span::styled("You", self.theme.user_message),
Span::styled( Span::styled(
format!("{} You", symbols.user_prefix),
self.theme.user_message,
),
];
// Timestamp right-aligned (we'll simplify for now)
role_spans.push(Span::styled(
format!(" {}", display_msg.timestamp), format!(" {}", display_msg.timestamp),
self.theme.timestamp, self.theme.timestamp,
)); ),
]));
text_lines.push(Line::from(role_spans));
// Message content with 2-space indent // Message content with 2-space indent
let wrapped = textwrap::wrap(content, wrap_width); let wrapped = textwrap::wrap(content, wrap_width);
@@ -278,19 +276,19 @@ impl ChatPanel {
} }
ChatMessage::Assistant(content) => { 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())]; let mut role_spans = vec![Span::styled(" ", Style::default())];
// Streaming indicator // Streaming indicator (subtle, no symbol)
if is_last && self.is_streaming { if is_last && self.is_streaming {
role_spans.push(Span::styled( role_spans.push(Span::styled(
format!("{} ", symbols.streaming), "... ",
Style::default().fg(self.theme.palette.success), Style::default().fg(self.theme.palette.success),
)); ));
} }
role_spans.push(Span::styled( role_spans.push(Span::styled(
format!("{} Assistant", symbols.assistant_prefix), "Assistant",
self.theme.assistant_message.add_modifier(Modifier::BOLD), self.theme.assistant_message.add_modifier(Modifier::BOLD),
)); ));
@@ -327,12 +325,9 @@ impl ChatPanel {
} }
ChatMessage::ToolCall { name, args } => { ChatMessage::ToolCall { name, args } => {
// Tool calls: name in tool color, args dimmed
text_lines.push(Line::from(vec![ text_lines.push(Line::from(vec![
Span::styled(" ", Style::default()), Span::styled(" ", Style::default()),
Span::styled(
format!("{} ", symbols.tool_prefix),
self.theme.tool_call,
),
Span::styled(format!("{} ", name), self.theme.tool_call), Span::styled(format!("{} ", name), self.theme.tool_call),
Span::styled( Span::styled(
truncate_str(args, 60), truncate_str(args, 60),
@@ -343,34 +338,28 @@ impl ChatPanel {
} }
ChatMessage::ToolResult { success, output } => { ChatMessage::ToolResult { success, output } => {
let style = if *success { // Tool results: status prefix + output
self.theme.tool_result_success let (prefix, style) = if *success {
("ok ", self.theme.tool_result_success)
} else { } else {
self.theme.tool_result_error ("err ", self.theme.tool_result_error)
};
let icon = if *success {
symbols.check
} else {
symbols.cross
}; };
text_lines.push(Line::from(vec![ text_lines.push(Line::from(vec![
Span::styled(format!(" {} ", icon), style), Span::styled(" ", Style::default()),
Span::styled(prefix, style),
Span::styled( Span::styled(
truncate_str(output, 100), truncate_str(output, 100),
style.add_modifier(Modifier::DIM), style.remove_modifier(Modifier::BOLD),
), ),
])); ]));
text_lines.push(Line::from("")); text_lines.push(Line::from(""));
} }
ChatMessage::System(content) => { ChatMessage::System(content) => {
// System messages: just dim text, no prefix
text_lines.push(Line::from(vec![ text_lines.push(Line::from(vec![
Span::styled(" ", Style::default()), Span::styled(" ", Style::default()),
Span::styled(
format!("{} ", symbols.system_prefix),
self.theme.system_message,
),
Span::styled(content.to_string(), self.theme.system_message), Span::styled(content.to_string(), self.theme.system_message),
])); ]));
} }

View File

@@ -0,0 +1,322 @@
//! Command help overlay component
//!
//! Modal overlay that displays available commands in a structured format.
//! Shown when user types `/help` or `?`. Supports scrolling with j/k or arrows.
use crate::theme::Theme;
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
layout::Rect,
style::Style,
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
Frame,
};
/// A single command definition
#[derive(Debug, Clone)]
pub struct Command {
pub name: &'static str,
pub args: Option<&'static str>,
pub description: &'static str,
}
impl Command {
pub const fn new(name: &'static str, description: &'static str) -> Self {
Self {
name,
args: None,
description,
}
}
pub const fn with_args(name: &'static str, args: &'static str, description: &'static str) -> Self {
Self {
name,
args: Some(args),
description,
}
}
}
/// Built-in commands
pub fn builtin_commands() -> Vec<Command> {
vec![
Command::new("help", "Show this help"),
Command::new("status", "Current session info"),
Command::with_args("model", "[name]", "Switch model"),
Command::with_args("provider", "[name]", "Switch provider (ollama, anthropic, openai)"),
Command::new("history", "Browse conversation history"),
Command::new("checkpoint", "Save conversation state"),
Command::new("checkpoints", "List saved checkpoints"),
Command::with_args("rewind", "[id]", "Restore checkpoint"),
Command::new("cost", "Show token usage"),
Command::new("clear", "Clear conversation"),
Command::new("compact", "Compact conversation context"),
Command::new("permissions", "Show permission mode"),
Command::new("themes", "List available themes"),
Command::with_args("theme", "[name]", "Switch theme"),
Command::new("exit", "Exit OWLEN"),
]
}
/// Command help overlay
pub struct CommandHelp {
commands: Vec<Command>,
visible: bool,
scroll_offset: usize,
theme: Theme,
}
impl CommandHelp {
pub fn new(theme: Theme) -> Self {
Self {
commands: builtin_commands(),
visible: false,
scroll_offset: 0,
theme,
}
}
/// Show the help overlay
pub fn show(&mut self) {
self.visible = true;
self.scroll_offset = 0; // Reset scroll when showing
}
/// Hide the help overlay
pub fn hide(&mut self) {
self.visible = false;
}
/// Check if visible
pub fn is_visible(&self) -> bool {
self.visible
}
/// Toggle visibility
pub fn toggle(&mut self) {
self.visible = !self.visible;
if self.visible {
self.scroll_offset = 0;
}
}
/// Scroll up by amount
fn scroll_up(&mut self, amount: usize) {
self.scroll_offset = self.scroll_offset.saturating_sub(amount);
}
/// Scroll down by amount, respecting max
fn scroll_down(&mut self, amount: usize, max_scroll: usize) {
self.scroll_offset = (self.scroll_offset + amount).min(max_scroll);
}
/// Handle key input, returns true if overlay handled the key
pub fn handle_key(&mut self, key: KeyEvent) -> bool {
if !self.visible {
return false;
}
// Calculate max scroll (commands + padding lines - visible area)
let total_lines = self.commands.len() + 3; // +3 for padding and footer
let max_scroll = total_lines.saturating_sub(10); // Assume ~10 visible lines
match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') => {
self.hide();
true
}
// Scroll navigation
KeyCode::Up | KeyCode::Char('k') => {
self.scroll_up(1);
true
}
KeyCode::Down | KeyCode::Char('j') => {
self.scroll_down(1, max_scroll);
true
}
KeyCode::PageUp | KeyCode::Char('u') => {
self.scroll_up(5);
true
}
KeyCode::PageDown | KeyCode::Char('d') => {
self.scroll_down(5, max_scroll);
true
}
KeyCode::Home | KeyCode::Char('g') => {
self.scroll_offset = 0;
true
}
KeyCode::End | KeyCode::Char('G') => {
self.scroll_offset = max_scroll;
true
}
_ => true, // Consume all other keys while visible
}
}
/// Update theme
pub fn set_theme(&mut self, theme: Theme) {
self.theme = theme;
}
/// Add plugin commands
pub fn add_commands(&mut self, commands: Vec<Command>) {
self.commands.extend(commands);
}
/// Render the help overlay
pub fn render(&self, frame: &mut Frame, area: Rect) {
if !self.visible {
return;
}
// Calculate overlay dimensions
let width = (area.width as f32 * 0.7).min(65.0) as u16;
let max_height = area.height.saturating_sub(4);
let content_height = self.commands.len() as u16 + 4; // +4 for padding and footer
let height = content_height.min(max_height).max(8);
// Center the overlay
let x = (area.width.saturating_sub(width)) / 2;
let y = (area.height.saturating_sub(height)) / 2;
let overlay_area = Rect::new(x, y, width, height);
// Clear the area behind the overlay
frame.render_widget(Clear, overlay_area);
// Build content lines
let mut lines: Vec<Line> = Vec::new();
// Empty line for padding
lines.push(Line::from(""));
// Command list
for cmd in &self.commands {
let name_with_args = if let Some(args) = cmd.args {
format!("/{} {}", cmd.name, args)
} else {
format!("/{}", cmd.name)
};
// Calculate padding for alignment
let name_width: usize = 22;
let padding = name_width.saturating_sub(name_with_args.len());
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled("/", self.theme.cmd_slash),
Span::styled(
if let Some(args) = cmd.args {
format!("{} {}", cmd.name, args)
} else {
cmd.name.to_string()
},
self.theme.cmd_name,
),
Span::raw(" ".repeat(padding)),
Span::styled(cmd.description, self.theme.cmd_desc),
]));
}
// Empty line for padding
lines.push(Line::from(""));
// Footer hint with scroll info
let scroll_hint = if self.commands.len() > (height as usize - 4) {
format!(" (scroll: j/k or ↑/↓)")
} else {
String::new()
};
lines.push(Line::from(vec![
Span::styled(" Press ", self.theme.cmd_desc),
Span::styled("Esc", self.theme.cmd_name),
Span::styled(" to close", self.theme.cmd_desc),
Span::styled(scroll_hint, self.theme.cmd_desc),
]));
// Create the block with border
let block = Block::default()
.title(" Commands ")
.title_style(self.theme.popup_title)
.borders(Borders::ALL)
.border_style(self.theme.popup_border)
.style(self.theme.overlay_bg);
let paragraph = Paragraph::new(lines)
.block(block)
.scroll((self.scroll_offset as u16, 0));
frame.render_widget(paragraph, overlay_area);
// Render scrollbar if content exceeds visible area
let visible_height = height.saturating_sub(2) as usize; // -2 for borders
let total_lines = self.commands.len() + 3;
if total_lines > visible_height {
let scrollbar = Scrollbar::default()
.orientation(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.track_symbol(Some(" "))
.thumb_symbol("")
.style(self.theme.status_dim);
let mut scrollbar_state = ScrollbarState::default()
.content_length(total_lines)
.position(self.scroll_offset);
// Adjust scrollbar area to be inside the border
let scrollbar_area = Rect::new(
overlay_area.x + overlay_area.width - 2,
overlay_area.y + 1,
1,
overlay_area.height.saturating_sub(2),
);
frame.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_command_help_visibility() {
let theme = Theme::default();
let mut help = CommandHelp::new(theme);
assert!(!help.is_visible());
help.show();
assert!(help.is_visible());
help.hide();
assert!(!help.is_visible());
}
#[test]
fn test_builtin_commands() {
let commands = builtin_commands();
assert!(!commands.is_empty());
assert!(commands.iter().any(|c| c.name == "help"));
assert!(commands.iter().any(|c| c.name == "provider"));
}
#[test]
fn test_scroll_navigation() {
let theme = Theme::default();
let mut help = CommandHelp::new(theme);
help.show();
assert_eq!(help.scroll_offset, 0);
help.scroll_down(3, 10);
assert_eq!(help.scroll_offset, 3);
help.scroll_up(1);
assert_eq!(help.scroll_offset, 2);
help.scroll_up(10); // Should clamp to 0
assert_eq!(help.scroll_offset, 0);
}
}

View File

@@ -1,13 +1,19 @@
//! TUI components for the borderless multi-provider design //! TUI components for the borderless multi-provider design
mod autocomplete;
mod chat_panel; mod chat_panel;
mod command_help;
mod input_box; mod input_box;
mod permission_popup; mod permission_popup;
mod provider_tabs; mod provider_tabs;
mod status_bar; mod status_bar;
mod todo_panel;
pub use autocomplete::{Autocomplete, AutocompleteOption, AutocompleteResult};
pub use chat_panel::{ChatMessage, ChatPanel, DisplayMessage}; pub use chat_panel::{ChatMessage, ChatPanel, DisplayMessage};
pub use command_help::{Command, CommandHelp};
pub use input_box::{InputBox, InputEvent}; pub use input_box::{InputBox, InputEvent};
pub use permission_popup::{PermissionOption, PermissionPopup}; pub use permission_popup::{PermissionOption, PermissionPopup};
pub use provider_tabs::ProviderTabs; pub use provider_tabs::ProviderTabs;
pub use status_bar::{AppState, StatusBar}; pub use status_bar::{AppState, StatusBar};
pub use todo_panel::TodoPanel;

View File

@@ -1,13 +1,14 @@
//! Multi-provider status bar component //! Minimal status bar component
//! //!
//! Borderless status bar showing provider, model, mode, stats, and state. //! Clean, readable status bar with essential info only.
//! Format: 󰚩 model │ Mode │ N msgs │ 󱐋 N │ ~Nk │ $0.00 │ ● status //! Format: ` Mode │ N msgs │ ~Nk tok │ state`
use crate::theme::{Provider, Theme, VimMode}; use crate::theme::{Provider, Theme, VimMode};
use agent_core::SessionStats; use agent_core::SessionStats;
use permissions::Mode; use permissions::Mode;
use ratatui::{ use ratatui::{
layout::Rect, layout::Rect,
style::Style,
text::{Line, Span}, text::{Line, Span},
widgets::Paragraph, widgets::Paragraph,
Frame, Frame,
@@ -23,19 +24,10 @@ pub enum AppState {
} }
impl 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 { pub fn label(&self) -> &'static str {
match self { match self {
AppState::Idle => "idle", AppState::Idle => "idle",
AppState::Streaming => "streaming", AppState::Streaming => "streaming...",
AppState::WaitingPermission => "waiting", AppState::WaitingPermission => "waiting",
AppState::Error => "error", AppState::Error => "error",
} }
@@ -51,6 +43,7 @@ pub struct StatusBar {
last_tool: Option<String>, last_tool: Option<String>,
state: AppState, state: AppState,
estimated_cost: f64, estimated_cost: f64,
planning_mode: bool,
theme: Theme, theme: Theme,
} }
@@ -65,6 +58,7 @@ impl StatusBar {
last_tool: None, last_tool: None,
state: AppState::Idle, state: AppState::Idle,
estimated_cost: 0.0, estimated_cost: 0.0,
planning_mode: false,
theme, theme,
} }
} }
@@ -114,99 +108,60 @@ impl StatusBar {
self.theme = theme; self.theme = theme;
} }
/// Render the status bar /// Set planning mode status
pub fn render(&self, frame: &mut Frame, area: Rect) { pub fn set_planning_mode(&mut self, active: bool) {
let symbols = &self.theme.symbols; self.planning_mode = active;
let sep = symbols.vertical_separator; }
// Provider icon and model /// Render the minimal status bar
let provider_icon = self.theme.provider_icon(self.provider); ///
let provider_style = ratatui::style::Style::default() /// Format: ` Mode │ N msgs │ ~Nk tok │ state`
.fg(self.theme.provider_color(self.provider)); 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 // Permission mode
let mode_str = match self.mode { let mode_str = if self.planning_mode {
"PLAN"
} else {
match self.mode {
Mode::Plan => "Plan", Mode::Plan => "Plan",
Mode::AcceptEdits => "Edit", Mode::AcceptEdits => "Edit",
Mode::Code => "Code", Mode::Code => "Code",
}
}; };
// Format token count // Format token count
let tokens_str = if self.stats.estimated_tokens >= 1000 { 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 { } else {
format!("~{}", self.stats.estimated_tokens) format!("~{} tok", self.stats.estimated_tokens)
}; };
// Cost display (only for paid providers) // State style - only highlight non-idle states
let cost_str = if self.provider != Provider::Ollama && self.estimated_cost > 0.0 {
format!("${:.2}", self.estimated_cost)
} else {
String::new()
};
// State indicator
let state_style = match self.state { let state_style = match self.state {
AppState::Idle => self.theme.status_dim, AppState::Idle => self.theme.status_dim,
AppState::Streaming => ratatui::style::Style::default() AppState::Streaming => Style::default().fg(self.theme.palette.success),
.fg(self.theme.palette.success), AppState::WaitingPermission => Style::default().fg(self.theme.palette.warning),
AppState::WaitingPermission => ratatui::style::Style::default() AppState::Error => Style::default().fg(self.theme.palette.error),
.fg(self.theme.palette.warning),
AppState::Error => ratatui::style::Style::default()
.fg(self.theme.palette.error),
}; };
// Build status line // Build minimal status line
let mut spans = vec![ let spans = vec![
Span::styled(" ", self.theme.status_bar), Span::styled(" ", self.theme.status_dim),
// Provider icon and model // Mode
Span::styled(format!("{} ", provider_icon), provider_style), Span::styled(mode_str, self.theme.status_dim),
Span::styled(&self.model, self.theme.status_bar), Span::styled(format!(" {} ", sep), sep_style),
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),
// Message count // Message count
Span::styled(format!("{} msgs", self.stats.total_messages), self.theme.status_bar), Span::styled(format!("{} msgs", self.stats.total_messages), self.theme.status_dim),
Span::styled(format!(" {} ", sep), self.theme.status_dim), Span::styled(format!(" {} ", sep), sep_style),
// 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),
// Token count // 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 line = Line::from(spans);
let paragraph = Paragraph::new(line); let paragraph = Paragraph::new(line);
frame.render_widget(paragraph, area); frame.render_widget(paragraph, area);
@@ -227,7 +182,7 @@ mod tests {
#[test] #[test]
fn test_app_state_display() { fn test_app_state_display() {
assert_eq!(AppState::Idle.label(), "idle"); assert_eq!(AppState::Idle.label(), "idle");
assert_eq!(AppState::Streaming.label(), "streaming"); assert_eq!(AppState::Streaming.label(), "streaming...");
assert_eq!(AppState::Error.icon(), ""); assert_eq!(AppState::Error.label(), "error");
} }
} }

View File

@@ -0,0 +1,200 @@
//! Todo panel component for displaying task list
//!
//! Shows the current todo list with status indicators and progress.
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
use tools_todo::{Todo, TodoList, TodoStatus};
use crate::theme::Theme;
/// Todo panel component
pub struct TodoPanel {
theme: Theme,
collapsed: bool,
}
impl TodoPanel {
pub fn new(theme: Theme) -> Self {
Self {
theme,
collapsed: false,
}
}
/// Toggle collapsed state
pub fn toggle(&mut self) {
self.collapsed = !self.collapsed;
}
/// Check if collapsed
pub fn is_collapsed(&self) -> bool {
self.collapsed
}
/// Update theme
pub fn set_theme(&mut self, theme: Theme) {
self.theme = theme;
}
/// Get the minimum height needed for the panel
pub fn min_height(&self) -> u16 {
if self.collapsed {
1
} else {
5
}
}
/// Render the todo panel
pub fn render(&self, frame: &mut Frame, area: Rect, todos: &TodoList) {
if self.collapsed {
self.render_collapsed(frame, area, todos);
} else {
self.render_expanded(frame, area, todos);
}
}
/// Render collapsed view (single line summary)
fn render_collapsed(&self, frame: &mut Frame, area: Rect, todos: &TodoList) {
let items = todos.read();
let completed = items.iter().filter(|t| t.status == TodoStatus::Completed).count();
let in_progress = items.iter().filter(|t| t.status == TodoStatus::InProgress).count();
let pending = items.iter().filter(|t| t.status == TodoStatus::Pending).count();
let summary = if items.is_empty() {
"No tasks".to_string()
} else {
format!(
"{} {} / {} {} / {} {}",
self.theme.symbols.check, completed,
self.theme.symbols.streaming, in_progress,
self.theme.symbols.bullet, pending
)
};
let line = Line::from(vec![
Span::styled("Tasks: ", self.theme.status_bar),
Span::styled(summary, self.theme.status_dim),
Span::styled(" [t to expand]", self.theme.status_dim),
]);
let paragraph = Paragraph::new(line);
frame.render_widget(paragraph, area);
}
/// Render expanded view with task list
fn render_expanded(&self, frame: &mut Frame, area: Rect, todos: &TodoList) {
let items = todos.read();
let mut lines: Vec<Line> = Vec::new();
// Header
lines.push(Line::from(vec![
Span::styled("Tasks", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(" [t to collapse]", self.theme.status_dim),
]));
if items.is_empty() {
lines.push(Line::from(Span::styled(
" No active tasks",
self.theme.status_dim,
)));
} else {
// Show tasks (limit to available space)
let max_items = (area.height as usize).saturating_sub(2);
let display_items: Vec<&Todo> = items.iter().take(max_items).collect();
for item in display_items {
let (icon, style) = match item.status {
TodoStatus::Completed => (
self.theme.symbols.check,
Style::default().fg(Color::Green),
),
TodoStatus::InProgress => (
self.theme.symbols.streaming,
Style::default().fg(Color::Yellow),
),
TodoStatus::Pending => (
self.theme.symbols.bullet,
self.theme.status_dim,
),
};
// Use active form for in-progress, content for others
let text = if item.status == TodoStatus::InProgress {
&item.active_form
} else {
&item.content
};
// Truncate if too long
let max_width = area.width.saturating_sub(6) as usize;
let display_text = if text.len() > max_width {
format!("{}...", &text[..max_width.saturating_sub(3)])
} else {
text.clone()
};
lines.push(Line::from(vec![
Span::styled(format!(" {} ", icon), style),
Span::styled(display_text, style),
]));
}
// Show overflow indicator if needed
if items.len() > max_items {
lines.push(Line::from(Span::styled(
format!(" ... and {} more", items.len() - max_items),
self.theme.status_dim,
)));
}
}
let block = Block::default()
.borders(Borders::TOP)
.border_style(self.theme.status_dim);
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_todo_panel_creation() {
let theme = Theme::default();
let panel = TodoPanel::new(theme);
assert!(!panel.is_collapsed());
}
#[test]
fn test_todo_panel_toggle() {
let theme = Theme::default();
let mut panel = TodoPanel::new(theme);
assert!(!panel.is_collapsed());
panel.toggle();
assert!(panel.is_collapsed());
panel.toggle();
assert!(!panel.is_collapsed());
}
#[test]
fn test_min_height() {
let theme = Theme::default();
let mut panel = TodoPanel::new(theme);
assert_eq!(panel.min_height(), 5);
panel.toggle();
assert_eq!(panel.min_height(), 1);
}
}

View File

@@ -8,8 +8,14 @@ pub enum AppEvent {
Input(KeyEvent), Input(KeyEvent),
/// User submitted a message /// User submitted a message
UserMessage(String), UserMessage(String),
/// LLM response chunk /// LLM streaming started
StreamStart,
/// LLM response chunk (streaming)
LlmChunk(String), LlmChunk(String),
/// LLM streaming completed
StreamEnd { response: String },
/// LLM streaming error
StreamError(String),
/// Tool call started /// Tool call started
ToolCall { name: String, args: Value }, ToolCall { name: String, args: Value },
/// Tool execution result /// Tool execution result
@@ -27,6 +33,8 @@ pub enum AppEvent {
ScrollUp, ScrollUp,
/// Mouse scroll down /// Mouse scroll down
ScrollDown, ScrollDown,
/// Toggle the todo panel
ToggleTodo,
/// Application should quit /// Application should quit
Quit, Quit,
} }
@@ -37,6 +45,9 @@ pub fn handle_key_event(key: KeyEvent) -> Option<AppEvent> {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(AppEvent::Quit) Some(AppEvent::Quit)
} }
KeyCode::Char('t') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(AppEvent::ToggleTodo)
}
_ => Some(AppEvent::Input(key)), _ => Some(AppEvent::Input(key)),
} }
} }

View File

@@ -22,6 +22,8 @@ pub struct AppLayout {
pub top_divider: Rect, pub top_divider: Rect,
/// Main chat/message area /// Main chat/message area
pub chat_area: Rect, pub chat_area: Rect,
/// Todo panel area (optional, between chat and input)
pub todo_area: Rect,
/// Bottom divider (horizontal rule) /// Bottom divider (horizontal rule)
pub bottom_divider: Rect, pub bottom_divider: Rect,
/// Input area for user text /// Input area for user text
@@ -33,24 +35,54 @@ pub struct AppLayout {
impl AppLayout { impl AppLayout {
/// Calculate layout for the given terminal size /// Calculate layout for the given terminal size
pub fn calculate(area: Rect) -> Self { pub fn calculate(area: Rect) -> Self {
let chunks = Layout::default() 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) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Length(1), // Header Constraint::Length(1), // Header
Constraint::Length(1), // Provider tabs
Constraint::Length(1), // Top divider Constraint::Length(1), // Top divider
Constraint::Min(5), // Chat area (flexible) Constraint::Min(5), // Chat area (flexible)
Constraint::Length(todo_height), // Todo panel
Constraint::Length(1), // Bottom divider Constraint::Length(1), // Bottom divider
Constraint::Length(1), // Input Constraint::Length(1), // Input
Constraint::Length(1), // Status bar Constraint::Length(1), // Status bar
]) ])
.split(area); .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 { Self {
header_area: chunks[0], header_area: chunks[0],
tabs_area: chunks[1], tabs_area: Rect::default(), // Not used in simplified layout
top_divider: chunks[2], top_divider: chunks[1],
chat_area: chunks[3], chat_area: chunks[2],
todo_area: chunks[3],
bottom_divider: chunks[4], bottom_divider: chunks[4],
input_area: chunks[5], input_area: chunks[5],
status_area: chunks[6], status_area: chunks[6],
@@ -65,9 +97,9 @@ impl AppLayout {
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Length(1), // Header Constraint::Length(1), // Header
Constraint::Length(1), // Provider tabs
Constraint::Length(1), // Top divider Constraint::Length(1), // Top divider
Constraint::Min(5), // Chat area (flexible) Constraint::Min(5), // Chat area (flexible)
Constraint::Length(0), // No todo panel
Constraint::Length(1), // Bottom divider Constraint::Length(1), // Bottom divider
Constraint::Length(input_height), // Expanded input Constraint::Length(input_height), // Expanded input
Constraint::Length(1), // Status bar Constraint::Length(1), // Status bar
@@ -76,9 +108,10 @@ impl AppLayout {
Self { Self {
header_area: chunks[0], header_area: chunks[0],
tabs_area: chunks[1], tabs_area: Rect::default(),
top_divider: chunks[2], top_divider: chunks[1],
chat_area: chunks[3], chat_area: chunks[2],
todo_area: chunks[3],
bottom_divider: chunks[4], bottom_divider: chunks[4],
input_area: chunks[5], input_area: chunks[5],
status_area: chunks[6], status_area: chunks[6],
@@ -93,6 +126,7 @@ impl AppLayout {
Constraint::Length(1), // Header (includes compact provider indicator) Constraint::Length(1), // Header (includes compact provider indicator)
Constraint::Length(1), // Top divider Constraint::Length(1), // Top divider
Constraint::Min(5), // Chat area (flexible) Constraint::Min(5), // Chat area (flexible)
Constraint::Length(0), // No todo panel
Constraint::Length(1), // Bottom divider Constraint::Length(1), // Bottom divider
Constraint::Length(1), // Input Constraint::Length(1), // Input
Constraint::Length(1), // Status bar Constraint::Length(1), // Status bar
@@ -104,9 +138,10 @@ impl AppLayout {
tabs_area: Rect::default(), // No tabs area in compact mode tabs_area: Rect::default(), // No tabs area in compact mode
top_divider: chunks[1], top_divider: chunks[1],
chat_area: chunks[2], chat_area: chunks[2],
bottom_divider: chunks[3], todo_area: chunks[3],
input_area: chunks[4], bottom_divider: chunks[4],
status_area: chunks[5], input_area: chunks[5],
status_area: chunks[6],
} }
} }

View File

@@ -1,12 +1,16 @@
pub mod app; pub mod app;
pub mod completions;
pub mod components; pub mod components;
pub mod events; pub mod events;
pub mod formatting; pub mod formatting;
pub mod layout; pub mod layout;
pub mod output;
pub mod theme; pub mod theme;
pub use app::TuiApp; pub use app::TuiApp;
pub use completions::{CompletionEngine, Completion, CommandInfo};
pub use events::AppEvent; pub use events::AppEvent;
pub use output::{CommandOutput, OutputFormat, TreeNode, ListItem};
pub use formatting::{ pub use formatting::{
FormattedContent, MarkdownRenderer, SyntaxHighlighter, FormattedContent, MarkdownRenderer, SyntaxHighlighter,
format_file_path, format_tool_name, format_error, format_success, format_warning, format_info, format_file_path, format_tool_name, format_error, format_success, format_warning, format_info,

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

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

View File

@@ -141,6 +141,14 @@ impl Symbols {
} }
/// Modern color palette inspired by contemporary design systems /// 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)] #[derive(Debug, Clone)]
pub struct ColorPalette { pub struct ColorPalette {
pub primary: Color, pub primary: Color,
@@ -155,44 +163,68 @@ pub struct ColorPalette {
pub fg_dim: Color, pub fg_dim: Color,
pub fg_muted: Color, pub fg_muted: Color,
pub highlight: Color, pub highlight: Color,
pub border: Color, // For horizontal rules (subtle)
pub selection: Color, // Highlighted row background
// Provider-specific colors // Provider-specific colors
pub claude: Color, pub claude: Color,
pub ollama: Color, pub ollama: Color,
pub openai: Color, pub openai: Color,
// Semantic colors for borderless design // Semantic colors for messages
pub user_fg: Color, pub user_fg: Color, // User message text (bright, fg)
pub assistant_fg: Color, pub assistant_fg: Color, // Assistant message text (soft gray-blue)
pub tool_fg: Color, pub tool_fg: Color,
pub timestamp_fg: Color, pub timestamp_fg: Color,
pub divider_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 { 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 { pub fn tokyo_night() -> Self {
Self { Self {
primary: Color::Rgb(122, 162, 247), // Bright blue primary: Color::Rgb(122, 162, 247), // #7aa2f7 - Blue accent
secondary: Color::Rgb(187, 154, 247), // Purple secondary: Color::Rgb(187, 154, 247), // #bb9af7 - Purple
accent: Color::Rgb(255, 158, 100), // Orange accent: Color::Rgb(122, 162, 247), // #7aa2f7 - Interactive elements ONLY
success: Color::Rgb(158, 206, 106), // Green success: Color::Rgb(158, 206, 106), // #9ece6a - Green
warning: Color::Rgb(224, 175, 104), // Yellow warning: Color::Rgb(224, 175, 104), // #e0af68 - Yellow
error: Color::Rgb(247, 118, 142), // Pink/Red error: Color::Rgb(247, 118, 142), // #f7768e - Pink/Red
info: Color::Rgb(125, 207, 255), // Cyan info: Color::Rgb(125, 207, 255), // Cyan (rarely used)
bg: Color::Rgb(26, 27, 38), // Dark bg bg: Color::Rgb(26, 27, 38), // #1a1b26 - Dark bg
fg: Color::Rgb(192, 202, 245), // Light text fg: Color::Rgb(192, 202, 245), // #c0caf5 - Primary text (HIGH CONTRAST)
fg_dim: Color::Rgb(86, 95, 137), // Dimmed text fg_dim: Color::Rgb(115, 122, 162), // #737aa2 - Secondary text (BRIGHTER)
fg_muted: Color::Rgb(65, 72, 104), // Very dim fg_muted: Color::Rgb(86, 95, 137), // #565f89 - Very dim
highlight: Color::Rgb(56, 62, 90), // Selection bg 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 // Provider colors
claude: Color::Rgb(217, 119, 87), // Claude orange claude: Color::Rgb(217, 119, 87), // Claude orange
ollama: Color::Rgb(122, 162, 247), // Blue ollama: Color::Rgb(122, 162, 247), // Blue
openai: Color::Rgb(16, 163, 127), // OpenAI green openai: Color::Rgb(16, 163, 127), // OpenAI green
// Semantic // Message colors - user bright, assistant readable
user_fg: Color::Rgb(255, 255, 255), // Bright white for user user_fg: Color::Rgb(192, 202, 245), // #c0caf5 - Same as fg (bright)
assistant_fg: Color::Rgb(125, 207, 255), // Cyan for AI assistant_fg: Color::Rgb(169, 177, 214), // #a9b1d6 - Brighter gray-blue (READABLE)
tool_fg: Color::Rgb(224, 175, 104), // Yellow for tools tool_fg: Color::Rgb(224, 175, 104), // #e0af68 - Yellow for tools
timestamp_fg: Color::Rgb(65, 72, 104), // Very dim timestamp_fg: Color::Rgb(115, 122, 162), // #737aa2 - Brighter dim
divider_fg: Color::Rgb(56, 62, 90), // Subtle divider 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_dim: Color::Rgb(98, 114, 164), // Comment
fg_muted: Color::Rgb(68, 71, 90), // Very dim fg_muted: Color::Rgb(68, 71, 90), // Very dim
highlight: Color::Rgb(68, 71, 90), // Selection 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), claude: Color::Rgb(255, 121, 198),
ollama: Color::Rgb(139, 233, 253), ollama: Color::Rgb(139, 233, 253),
openai: Color::Rgb(80, 250, 123), openai: Color::Rgb(80, 250, 123),
user_fg: Color::Rgb(248, 248, 242), 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), tool_fg: Color::Rgb(241, 250, 140),
timestamp_fg: Color::Rgb(68, 71, 90), timestamp_fg: Color::Rgb(68, 71, 90),
divider_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_dim: Color::Rgb(108, 112, 134), // Overlay
fg_muted: Color::Rgb(69, 71, 90), // Surface fg_muted: Color::Rgb(69, 71, 90), // Surface
highlight: Color::Rgb(49, 50, 68), // 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), claude: Color::Rgb(245, 194, 231),
ollama: Color::Rgb(137, 180, 250), ollama: Color::Rgb(137, 180, 250),
openai: Color::Rgb(166, 227, 161), openai: Color::Rgb(166, 227, 161),
user_fg: Color::Rgb(205, 214, 244), 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), tool_fg: Color::Rgb(249, 226, 175),
timestamp_fg: Color::Rgb(69, 71, 90), timestamp_fg: Color::Rgb(69, 71, 90),
divider_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_dim: Color::Rgb(76, 86, 106), // Polar night light
fg_muted: Color::Rgb(59, 66, 82), fg_muted: Color::Rgb(59, 66, 82),
highlight: Color::Rgb(59, 66, 82), // Selection 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), claude: Color::Rgb(180, 142, 173),
ollama: Color::Rgb(136, 192, 208), ollama: Color::Rgb(136, 192, 208),
openai: Color::Rgb(163, 190, 140), openai: Color::Rgb(163, 190, 140),
user_fg: Color::Rgb(236, 239, 244), 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), tool_fg: Color::Rgb(235, 203, 139),
timestamp_fg: Color::Rgb(59, 66, 82), timestamp_fg: Color::Rgb(59, 66, 82),
divider_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_dim: Color::Rgb(127, 90, 180), // Mid purple
fg_muted: Color::Rgb(72, 12, 168), fg_muted: Color::Rgb(72, 12, 168),
highlight: Color::Rgb(72, 12, 168), // Deep purple 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), claude: Color::Rgb(255, 128, 0),
ollama: Color::Rgb(0, 229, 255), ollama: Color::Rgb(0, 229, 255),
openai: Color::Rgb(0, 255, 157), openai: Color::Rgb(0, 255, 157),
user_fg: Color::Rgb(242, 233, 255), 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), tool_fg: Color::Rgb(255, 215, 0),
timestamp_fg: Color::Rgb(72, 12, 168), timestamp_fg: Color::Rgb(72, 12, 168),
divider_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_dim: Color::Rgb(110, 106, 134), // Muted
fg_muted: Color::Rgb(42, 39, 63), fg_muted: Color::Rgb(42, 39, 63),
highlight: Color::Rgb(42, 39, 63), // Highlight 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), claude: Color::Rgb(234, 154, 151),
ollama: Color::Rgb(156, 207, 216), ollama: Color::Rgb(156, 207, 216),
openai: Color::Rgb(49, 116, 143), openai: Color::Rgb(49, 116, 143),
user_fg: Color::Rgb(224, 222, 244), 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), tool_fg: Color::Rgb(246, 193, 119),
timestamp_fg: Color::Rgb(42, 39, 63), timestamp_fg: Color::Rgb(42, 39, 63),
divider_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_dim: Color::Rgb(71, 103, 145), // Muted blue
fg_muted: Color::Rgb(13, 43, 69), fg_muted: Color::Rgb(13, 43, 69),
highlight: Color::Rgb(13, 43, 69), // Deep blue 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), claude: Color::Rgb(199, 146, 234),
ollama: Color::Rgb(102, 217, 239), ollama: Color::Rgb(102, 217, 239),
openai: Color::Rgb(163, 190, 140), openai: Color::Rgb(163, 190, 140),
user_fg: Color::Rgb(201, 211, 235), 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), tool_fg: Color::Rgb(229, 200, 144),
timestamp_fg: Color::Rgb(13, 43, 69), timestamp_fg: Color::Rgb(13, 43, 69),
divider_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_bar: Style,
pub status_accent: Style, pub status_accent: Style,
pub status_dim: 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) // Popup styles (for permission dialogs)
pub popup_border: Style, pub popup_border: Style,
pub popup_bg: Style, pub popup_bg: Style,
@@ -483,17 +558,24 @@ impl Theme {
status_bar: Style::default().fg(palette.fg_dim), status_bar: Style::default().fg(palette.fg_dim),
status_accent: Style::default().fg(palette.accent), status_accent: Style::default().fg(palette.accent),
status_dim: Style::default().fg(palette.fg_muted), 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 styles
popup_border: Style::default() popup_border: Style::default()
.fg(palette.accent) .fg(palette.border)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
popup_bg: Style::default().bg(palette.highlight), popup_bg: Style::default().bg(palette.overlay_bg),
popup_title: Style::default() popup_title: Style::default()
.fg(palette.accent) .fg(palette.fg)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
selected: Style::default() selected: Style::default()
.fg(palette.bg) .fg(palette.fg)
.bg(palette.accent) .bg(palette.selection)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
// Legacy compatibility // Legacy compatibility
border: Style::default().fg(palette.fg_dim), border: Style::default().fg(palette.fg_dim),

View File

@@ -13,6 +13,7 @@ tokio = { version = "1", features = ["full"] }
futures-util = "0.3" futures-util = "0.3"
tracing = "0.1" tracing = "0.1"
async-trait = "0.1" async-trait = "0.1"
chrono = "0.4"
# Internal dependencies # Internal dependencies
llm-core = { path = "../../llm/core" } llm-core = { path = "../../llm/core" }
@@ -22,6 +23,7 @@ tools-bash = { path = "../../tools/bash" }
tools-ask = { path = "../../tools/ask" } tools-ask = { path = "../../tools/ask" }
tools-todo = { path = "../../tools/todo" } tools-todo = { path = "../../tools/todo" }
tools-web = { path = "../../tools/web" } tools-web = { path = "../../tools/web" }
tools-plan = { path = "../../tools/plan" }
[dev-dependencies] [dev-dependencies]
tempfile = "3.13" tempfile = "3.13"

View File

@@ -0,0 +1,218 @@
//! Context compaction for long conversations
//!
//! When the conversation context grows too large, this module compacts
//! earlier messages into a summary while preserving recent context.
use color_eyre::eyre::Result;
use llm_core::{ChatMessage, ChatOptions, LlmProvider};
/// Token limit threshold for triggering compaction
const CONTEXT_LIMIT: usize = 180_000;
/// Threshold ratio at which to trigger compaction (90% of limit)
const COMPACTION_THRESHOLD: f64 = 0.9;
/// Number of recent messages to preserve during compaction
const PRESERVE_RECENT: usize = 10;
/// Token counter for estimating context size
pub struct TokenCounter {
chars_per_token: f64,
}
impl Default for TokenCounter {
fn default() -> Self {
Self::new()
}
}
impl TokenCounter {
pub fn new() -> Self {
// Rough estimate: ~4 chars per token for English text
Self { chars_per_token: 4.0 }
}
/// Estimate token count for a message
pub fn count_message(&self, message: &ChatMessage) -> usize {
let content_len = message.content.as_ref().map(|c| c.len()).unwrap_or(0);
// Add overhead for role, metadata
let overhead = 10;
((content_len as f64 / self.chars_per_token) as usize) + overhead
}
/// Estimate total token count for all messages
pub fn count_messages(&self, messages: &[ChatMessage]) -> usize {
messages.iter().map(|m| self.count_message(m)).sum()
}
/// Check if context should be compacted
pub fn should_compact(&self, messages: &[ChatMessage]) -> bool {
let count = self.count_messages(messages);
count > (CONTEXT_LIMIT as f64 * COMPACTION_THRESHOLD) as usize
}
}
/// Context compactor that summarizes conversation history
pub struct Compactor {
token_counter: TokenCounter,
}
impl Default for Compactor {
fn default() -> Self {
Self::new()
}
}
impl Compactor {
pub fn new() -> Self {
Self {
token_counter: TokenCounter::new(),
}
}
/// Check if messages need compaction
pub fn needs_compaction(&self, messages: &[ChatMessage]) -> bool {
self.token_counter.should_compact(messages)
}
/// Compact messages by summarizing earlier conversation
///
/// Returns compacted messages with:
/// - A system message containing the summary of earlier context
/// - The most recent N messages preserved in full
pub async fn compact<P: LlmProvider>(
&self,
provider: &P,
messages: &[ChatMessage],
options: &ChatOptions,
) -> Result<Vec<ChatMessage>> {
// If not enough messages to compact, return as-is
if messages.len() <= PRESERVE_RECENT + 1 {
return Ok(messages.to_vec());
}
// Split into messages to summarize and messages to preserve
let split_point = messages.len().saturating_sub(PRESERVE_RECENT);
let to_summarize = &messages[..split_point];
let to_preserve = &messages[split_point..];
// Generate summary of earlier messages
let summary = self.summarize_messages(provider, to_summarize, options).await?;
// Build compacted message list
let mut compacted = Vec::with_capacity(PRESERVE_RECENT + 1);
// Add system message with summary
compacted.push(ChatMessage::system(format!(
"## Earlier Conversation Summary\n\n{}\n\n---\n\n\
The above summarizes the earlier part of this conversation. \
Continue from the recent messages below.",
summary
)));
// Add preserved recent messages
compacted.extend(to_preserve.iter().cloned());
Ok(compacted)
}
/// Generate a summary of messages using the LLM
async fn summarize_messages<P: LlmProvider>(
&self,
provider: &P,
messages: &[ChatMessage],
options: &ChatOptions,
) -> Result<String> {
// Format messages for summarization
let mut context = String::new();
for msg in messages {
let role = &msg.role;
let content = msg.content.as_deref().unwrap_or("");
context.push_str(&format!("[{:?}]: {}\n\n", role, content));
}
// Create summarization prompt
let summary_prompt = format!(
"Please provide a concise summary of the following conversation. \
Focus on:\n\
1. Key decisions made\n\
2. Important files or code mentioned\n\
3. Tasks completed and their outcomes\n\
4. Any pending items or next steps discussed\n\n\
Keep the summary informative but brief (under 500 words).\n\n\
Conversation:\n{}\n\n\
Summary:",
context
);
// Call LLM to generate summary
let summary_options = ChatOptions {
model: options.model.clone(),
max_tokens: Some(1000),
temperature: Some(0.3), // Lower temperature for more focused summary
..Default::default()
};
let summary_messages = vec![ChatMessage::user(&summary_prompt)];
let mut stream = provider.chat_stream(&summary_messages, &summary_options, None).await?;
let mut summary = String::new();
use futures_util::StreamExt;
while let Some(chunk_result) = stream.next().await {
if let Ok(chunk) = chunk_result {
if let Some(content) = &chunk.content {
summary.push_str(content);
}
}
}
Ok(summary.trim().to_string())
}
/// Get token counter for external use
pub fn token_counter(&self) -> &TokenCounter {
&self.token_counter
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_token_counter_estimate() {
let counter = TokenCounter::new();
let msg = ChatMessage::user("Hello, world!");
let count = counter.count_message(&msg);
// Should be approximately 13/4 + 10 overhead = 13
assert!(count > 10);
assert!(count < 20);
}
#[test]
fn test_should_compact() {
let counter = TokenCounter::new();
// Small message list shouldn't compact
let small_messages: Vec<ChatMessage> = (0..10)
.map(|i| ChatMessage::user(&format!("Message {}", i)))
.collect();
assert!(!counter.should_compact(&small_messages));
// Large message list should compact
// Need ~162,000 tokens = ~648,000 chars (at 4 chars per token)
let large_content = "x".repeat(700_000);
let large_messages = vec![ChatMessage::user(&large_content)];
assert!(counter.should_compact(&large_messages));
}
#[test]
fn test_compactor_needs_compaction() {
let compactor = Compactor::new();
let small: Vec<ChatMessage> = (0..5)
.map(|i| ChatMessage::user(&format!("Short message {}", i)))
.collect();
assert!(!compactor.needs_compaction(&small));
}
}

View File

@@ -1,13 +1,16 @@
pub mod session; pub mod session;
pub mod system_prompt; pub mod system_prompt;
pub mod git; pub mod git;
pub mod compact;
use color_eyre::eyre::{Result, eyre}; use color_eyre::eyre::{Result, eyre};
use futures_util::StreamExt; use futures_util::StreamExt;
use llm_core::{ChatMessage, ChatOptions, LlmProvider, Tool, ToolParameters}; use llm_core::{ChatMessage, ChatOptions, LlmProvider, Tool, ToolParameters};
use permissions::{PermissionDecision, PermissionManager, Tool as PermTool}; use permissions::{PermissionDecision, PermissionManager, Tool as PermTool};
use serde_json::{json, Value}; use serde_json::{json, Value};
use tokio::sync::mpsc; use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::{mpsc, RwLock};
use tools_ask::AskSender; use tools_ask::AskSender;
use tools_bash::ShellManager; use tools_bash::ShellManager;
use tools_todo::TodoList; use tools_todo::TodoList;
@@ -25,6 +28,12 @@ pub use git::{
format_git_status, format_git_status,
}; };
// Re-export planning mode types
pub use tools_plan::{AgentMode, PlanManager, PlanStatus};
// Re-export compaction types
pub use compact::{Compactor, TokenCounter};
/// Events emitted during agent loop execution /// Events emitted during agent loop execution
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum AgentEvent { pub enum AgentEvent {
@@ -68,7 +77,7 @@ pub fn create_event_channel() -> (AgentEventSender, AgentEventReceiver) {
} }
/// Optional context for tools that need external dependencies /// Optional context for tools that need external dependencies
#[derive(Clone, Default)] #[derive(Clone)]
pub struct ToolContext { pub struct ToolContext {
/// Todo list for TodoWrite tool /// Todo list for TodoWrite tool
pub todo_list: Option<TodoList>, pub todo_list: Option<TodoList>,
@@ -78,6 +87,24 @@ pub struct ToolContext {
/// Shell manager for background shells /// Shell manager for background shells
pub shell_manager: Option<ShellManager>, pub shell_manager: Option<ShellManager>,
/// Plan manager for planning mode
pub plan_manager: Option<Arc<PlanManager>>,
/// Current agent mode (normal or planning)
pub agent_mode: Arc<RwLock<AgentMode>>,
}
impl Default for ToolContext {
fn default() -> Self {
Self {
todo_list: None,
ask_sender: None,
shell_manager: None,
plan_manager: None,
agent_mode: Arc::new(RwLock::new(AgentMode::Normal)),
}
}
} }
impl ToolContext { impl ToolContext {
@@ -99,6 +126,31 @@ impl ToolContext {
self.shell_manager = Some(manager); self.shell_manager = Some(manager);
self self
} }
pub fn with_plan_manager(mut self, manager: PlanManager) -> Self {
self.plan_manager = Some(Arc::new(manager));
self
}
pub fn with_project_root(mut self, project_root: PathBuf) -> Self {
self.plan_manager = Some(Arc::new(PlanManager::new(project_root)));
self
}
/// Check if agent is in planning mode
pub async fn is_planning(&self) -> bool {
self.agent_mode.read().await.is_planning()
}
/// Get current agent mode
pub async fn get_mode(&self) -> AgentMode {
self.agent_mode.read().await.clone()
}
/// Set agent mode
pub async fn set_mode(&self, mode: AgentMode) {
*self.agent_mode.write().await = mode;
}
} }
/// Define all available tools for the LLM /// Define all available tools for the LLM
@@ -339,6 +391,22 @@ pub fn get_tool_definitions() -> Vec<Tool> {
vec!["shell_id".to_string()], vec!["shell_id".to_string()],
), ),
), ),
Tool::function(
"enter_plan_mode",
"Enter planning mode for complex tasks that require careful planning before implementation. In planning mode, only read-only tools are available.",
ToolParameters::object(
json!({}),
vec![],
),
),
Tool::function(
"exit_plan_mode",
"Exit planning mode after presenting the implementation plan. The plan will be shown to the user for approval.",
ToolParameters::object(
json!({}),
vec![],
),
),
] ]
} }
@@ -706,6 +774,86 @@ pub async fn execute_tool(
} }
} }
} }
"enter_plan_mode" => {
let plan_manager = ctx.plan_manager.as_ref()
.ok_or_else(|| eyre!("PlanManager not available - cannot enter planning mode"))?;
// Check permission
match perms.check(PermTool::EnterPlanMode, None) {
PermissionDecision::Allow => {
// Check if already in planning mode
if ctx.is_planning().await {
return Err(eyre!("Already in planning mode"));
}
// Create plan file
let plan_path = plan_manager.create_plan().await?;
// Enter planning mode
ctx.set_mode(tools_plan::enter_plan_mode(plan_path.clone())).await;
Ok(format!(
"Entered planning mode. Plan file created at: {}\n\n\
In planning mode, only read-only tools are available. \
Use `exit_plan_mode` when you have finished writing your plan.",
plan_path.display()
))
}
PermissionDecision::Ask => {
Err(eyre!("Permission required: EnterPlanMode operation needs approval"))
}
PermissionDecision::Deny => {
Err(eyre!("Permission denied: EnterPlanMode operation is blocked"))
}
}
}
"exit_plan_mode" => {
// Check permission
match perms.check(PermTool::ExitPlanMode, None) {
PermissionDecision::Allow => {
// Check if in planning mode
let mode = ctx.get_mode().await;
match mode {
AgentMode::Planning { plan_file, started_at } => {
let plan_manager = ctx.plan_manager.as_ref()
.ok_or_else(|| eyre!("PlanManager not available"))?;
// Read the plan content
let plan_content = plan_manager.read_plan(&plan_file).await
.unwrap_or_else(|_| "No plan content written.".to_string());
// Update plan status to pending approval
let _ = plan_manager.set_status(&plan_file, tools_plan::PlanStatus::PendingApproval).await;
// Exit planning mode
ctx.set_mode(tools_plan::exit_plan_mode()).await;
let duration = chrono::Utc::now().signed_duration_since(started_at);
let minutes = duration.num_minutes();
Ok(format!(
"Exited planning mode after {} minutes.\n\n\
Plan file: {}\n\n\
## Plan Content:\n\n{}\n\n\
The plan is now awaiting your approval.",
minutes,
plan_file.display(),
plan_content
))
}
AgentMode::Normal => {
Err(eyre!("Not in planning mode"))
}
}
}
PermissionDecision::Ask => {
Err(eyre!("Permission required: ExitPlanMode operation needs approval"))
}
PermissionDecision::Deny => {
Err(eyre!("Permission denied: ExitPlanMode operation is blocked"))
}
}
}
_ => Err(eyre!("Unknown tool: {}", tool_name)), _ => Err(eyre!("Unknown tool: {}", tool_name)),
} }
} }

View File

@@ -35,6 +35,19 @@ pub struct Settings {
// Permission mode // Permission mode
#[serde(default = "default_mode")] #[serde(default = "default_mode")]
pub mode: String, // "plan" | "acceptEdits" | "code" pub mode: String, // "plan" | "acceptEdits" | "code"
// Tool permission lists
/// Tools that are always allowed without prompting
/// Format: "tool_name" or "tool_name:pattern"
/// Example: ["bash:npm test:*", "bash:cargo test:*", "mcp:filesystem__*"]
#[serde(default)]
pub allowed_tools: Vec<String>,
/// Tools that are always denied (blocked)
/// Format: "tool_name" or "tool_name:pattern"
/// Example: ["bash:rm -rf*", "bash:sudo*"]
#[serde(default)]
pub disallowed_tools: Vec<String>,
} }
fn default_provider() -> String { fn default_provider() -> String {
@@ -65,15 +78,30 @@ impl Default for Settings {
anthropic_api_key: None, anthropic_api_key: None,
openai_api_key: None, openai_api_key: None,
mode: default_mode(), mode: default_mode(),
allowed_tools: Vec::new(),
disallowed_tools: Vec::new(),
} }
} }
} }
impl Settings { impl Settings {
/// Create a PermissionManager based on the configured mode /// Create a PermissionManager based on the configured mode and tool lists
///
/// Tool lists are applied in order:
/// 1. Disallowed tools (highest priority - blocked first)
/// 2. Allowed tools
/// 3. Mode-based defaults
pub fn create_permission_manager(&self) -> PermissionManager { pub fn create_permission_manager(&self) -> PermissionManager {
let mode = Mode::from_str(&self.mode).unwrap_or(Mode::Plan); let mode = Mode::from_str(&self.mode).unwrap_or(Mode::Plan);
PermissionManager::new(mode) let mut pm = PermissionManager::new(mode);
// Add disallowed tools first (deny rules take precedence)
pm.add_disallowed_tools(&self.disallowed_tools);
// Then add allowed tools
pm.add_allowed_tools(&self.allowed_tools);
pm
} }
/// Get the Mode enum from the mode string /// Get the Mode enum from the mode string

View File

@@ -34,6 +34,34 @@ pub enum HookEvent {
prompt: String, prompt: String,
}, },
PreCompact, PreCompact,
/// Called before the agent stops - allows validation of completion
#[serde(rename_all = "camelCase")]
Stop {
/// Reason for stopping (e.g., "task_complete", "max_iterations", "user_interrupt")
reason: String,
/// Number of messages in conversation
num_messages: usize,
/// Number of tool calls made
num_tool_calls: usize,
},
/// Called before a subagent stops
#[serde(rename_all = "camelCase")]
SubagentStop {
/// Unique identifier for the subagent
agent_id: String,
/// Type of subagent (e.g., "explore", "code-reviewer")
agent_type: String,
/// Reason for stopping
reason: String,
},
/// Called when a notification is sent to the user
#[serde(rename_all = "camelCase")]
Notification {
/// Notification message
message: String,
/// Notification type (e.g., "info", "warning", "error")
notification_type: String,
},
} }
impl HookEvent { impl HookEvent {
@@ -46,16 +74,105 @@ impl HookEvent {
HookEvent::SessionEnd { .. } => "SessionEnd", HookEvent::SessionEnd { .. } => "SessionEnd",
HookEvent::UserPromptSubmit { .. } => "UserPromptSubmit", HookEvent::UserPromptSubmit { .. } => "UserPromptSubmit",
HookEvent::PreCompact => "PreCompact", HookEvent::PreCompact => "PreCompact",
HookEvent::Stop { .. } => "Stop",
HookEvent::SubagentStop { .. } => "SubagentStop",
HookEvent::Notification { .. } => "Notification",
} }
} }
} }
/// Simple hook result for backwards compatibility
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookResult { pub enum HookResult {
Allow, Allow,
Deny, Deny,
} }
/// Extended hook output with additional control options
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HookOutput {
/// Whether to continue execution (default: true if exit code 0)
#[serde(default = "default_continue")]
pub continue_execution: bool,
/// Whether to suppress showing the result to the user
#[serde(default)]
pub suppress_output: bool,
/// System message to inject into the conversation
#[serde(default)]
pub system_message: Option<String>,
/// Permission decision override
#[serde(default)]
pub permission_decision: Option<HookPermission>,
/// Modified input/args for the tool (PreToolUse only)
#[serde(default)]
pub updated_input: Option<Value>,
}
impl Default for HookOutput {
fn default() -> Self {
Self {
continue_execution: true,
suppress_output: false,
system_message: None,
permission_decision: None,
updated_input: None,
}
}
}
fn default_continue() -> bool {
true
}
/// Permission decision from a hook
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HookPermission {
Allow,
Deny,
Ask,
}
impl HookOutput {
pub fn new() -> Self {
Self::default()
}
pub fn allow() -> Self {
Self {
continue_execution: true,
..Default::default()
}
}
pub fn deny() -> Self {
Self {
continue_execution: false,
..Default::default()
}
}
pub fn with_system_message(mut self, message: impl Into<String>) -> Self {
self.system_message = Some(message.into());
self
}
pub fn with_permission(mut self, permission: HookPermission) -> Self {
self.permission_decision = Some(permission);
self
}
/// Convert to simple HookResult for backwards compatibility
pub fn to_result(&self) -> HookResult {
if self.continue_execution {
HookResult::Allow
} else {
HookResult::Deny
}
}
}
/// A registered hook that can be executed /// A registered hook that can be executed
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct Hook { struct Hook {
@@ -195,6 +312,131 @@ impl HookManager {
} }
} }
/// Execute a hook and return extended output
///
/// This method parses JSON output from stdout if the hook provides it,
/// otherwise falls back to exit code interpretation.
pub async fn execute_extended(&self, event: &HookEvent, timeout_ms: Option<u64>) -> Result<HookOutput> {
// First check for legacy file-based hooks
let hook_path = self.get_hook_path(event);
let has_file_hook = hook_path.exists();
// Get registered hooks for this event
let event_name = event.hook_name();
let mut matching_hooks: Vec<&Hook> = self.hooks.iter()
.filter(|h| h.event == event_name)
.collect();
// If we need to filter by pattern (for PreToolUse events)
if let HookEvent::PreToolUse { tool, .. } = event {
matching_hooks.retain(|h| {
if let Some(pattern) = &h.pattern {
if let Ok(re) = regex::Regex::new(pattern) {
re.is_match(tool)
} else {
false
}
} else {
true
}
});
}
// If no hooks at all, allow by default
if !has_file_hook && matching_hooks.is_empty() {
return Ok(HookOutput::allow());
}
let mut combined_output = HookOutput::allow();
// Execute file-based hook first (if exists)
if has_file_hook {
let output = self.execute_hook_extended(&hook_path.to_string_lossy(), event, timeout_ms).await?;
combined_output = Self::merge_outputs(combined_output, output);
if !combined_output.continue_execution {
return Ok(combined_output);
}
}
// Execute registered hooks
for hook in matching_hooks {
let hook_timeout = hook.timeout.or(timeout_ms);
let output = self.execute_hook_extended(&hook.command, event, hook_timeout).await?;
combined_output = Self::merge_outputs(combined_output, output);
if !combined_output.continue_execution {
return Ok(combined_output);
}
}
Ok(combined_output)
}
/// Execute a single hook command and return extended output
async fn execute_hook_extended(&self, command: &str, event: &HookEvent, timeout_ms: Option<u64>) -> Result<HookOutput> {
let input_json = serde_json::to_string(event)?;
let mut child = Command::new("sh")
.arg("-c")
.arg(command)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.current_dir(&self.project_root)
.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(input_json.as_bytes()).await?;
stdin.flush().await?;
drop(stdin);
}
let result = if let Some(ms) = timeout_ms {
timeout(Duration::from_millis(ms), child.wait_with_output()).await
} else {
Ok(child.wait_with_output().await)
};
match result {
Ok(Ok(output)) => {
let exit_code = output.status.code();
let stdout = String::from_utf8_lossy(&output.stdout);
// Try to parse JSON output from stdout
if !stdout.trim().is_empty() {
if let Ok(hook_output) = serde_json::from_str::<HookOutput>(stdout.trim()) {
return Ok(hook_output);
}
}
// Fall back to exit code interpretation
match exit_code {
Some(0) => Ok(HookOutput::allow()),
Some(2) => Ok(HookOutput::deny()),
Some(code) => Err(eyre!(
"Hook {} failed with exit code {}: {}",
event.hook_name(),
code,
String::from_utf8_lossy(&output.stderr)
)),
None => Err(eyre!("Hook {} terminated by signal", event.hook_name())),
}
}
Ok(Err(e)) => Err(eyre!("Failed to execute hook {}: {}", event.hook_name(), e)),
Err(_) => Err(eyre!("Hook {} timed out", event.hook_name())),
}
}
/// Merge two hook outputs, with the second taking precedence
fn merge_outputs(base: HookOutput, new: HookOutput) -> HookOutput {
HookOutput {
continue_execution: base.continue_execution && new.continue_execution,
suppress_output: base.suppress_output || new.suppress_output,
system_message: new.system_message.or(base.system_message),
permission_decision: new.permission_decision.or(base.permission_decision),
updated_input: new.updated_input.or(base.updated_input),
}
}
fn get_hook_path(&self, event: &HookEvent) -> PathBuf { fn get_hook_path(&self, event: &HookEvent) -> PathBuf {
self.project_root self.project_root
.join(".owlen") .join(".owlen")
@@ -236,5 +478,76 @@ mod tests {
.hook_name(), .hook_name(),
"SessionStart" "SessionStart"
); );
assert_eq!(
HookEvent::Stop {
reason: "task_complete".to_string(),
num_messages: 10,
num_tool_calls: 5,
}
.hook_name(),
"Stop"
);
assert_eq!(
HookEvent::SubagentStop {
agent_id: "abc123".to_string(),
agent_type: "explore".to_string(),
reason: "completed".to_string(),
}
.hook_name(),
"SubagentStop"
);
}
#[test]
fn stop_event_serializes_correctly() {
let event = HookEvent::Stop {
reason: "task_complete".to_string(),
num_messages: 10,
num_tool_calls: 5,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"event\":\"stop\""));
assert!(json.contains("\"reason\":\"task_complete\""));
assert!(json.contains("\"numMessages\":10"));
assert!(json.contains("\"numToolCalls\":5"));
}
#[test]
fn hook_output_defaults() {
let output = HookOutput::default();
assert!(output.continue_execution);
assert!(!output.suppress_output);
assert!(output.system_message.is_none());
assert!(output.permission_decision.is_none());
}
#[test]
fn hook_output_builders() {
let output = HookOutput::allow()
.with_system_message("Test message")
.with_permission(HookPermission::Allow);
assert!(output.continue_execution);
assert_eq!(output.system_message, Some("Test message".to_string()));
assert_eq!(output.permission_decision, Some(HookPermission::Allow));
let deny = HookOutput::deny();
assert!(!deny.continue_execution);
}
#[test]
fn hook_output_deserializes() {
let json = r#"{"continueExecution": true, "suppressOutput": false, "systemMessage": "Hello"}"#;
let output: HookOutput = serde_json::from_str(json).unwrap();
assert!(output.continue_execution);
assert!(!output.suppress_output);
assert_eq!(output.system_message, Some("Hello".to_string()));
}
#[test]
fn hook_output_to_result() {
assert_eq!(HookOutput::allow().to_result(), HookResult::Allow);
assert_eq!(HookOutput::deny().to_result(), HookResult::Deny);
} }
} }

View File

@@ -22,6 +22,69 @@ pub enum Tool {
AskUserQuestion, AskUserQuestion,
BashOutput, BashOutput,
KillShell, KillShell,
// Planning mode tools
EnterPlanMode,
ExitPlanMode,
Skill,
}
impl Tool {
/// Parse a tool name from string (case-insensitive)
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"read" => Some(Tool::Read),
"write" => Some(Tool::Write),
"edit" => Some(Tool::Edit),
"bash" => Some(Tool::Bash),
"grep" => Some(Tool::Grep),
"glob" => Some(Tool::Glob),
"webfetch" | "web_fetch" => Some(Tool::WebFetch),
"websearch" | "web_search" => Some(Tool::WebSearch),
"notebookread" | "notebook_read" => Some(Tool::NotebookRead),
"notebookedit" | "notebook_edit" => Some(Tool::NotebookEdit),
"slashcommand" | "slash_command" => Some(Tool::SlashCommand),
"task" => Some(Tool::Task),
"todowrite" | "todo_write" | "todo" => Some(Tool::TodoWrite),
"mcp" => Some(Tool::Mcp),
"multiedit" | "multi_edit" => Some(Tool::MultiEdit),
"ls" => Some(Tool::LS),
"askuserquestion" | "ask_user_question" | "ask" => Some(Tool::AskUserQuestion),
"bashoutput" | "bash_output" => Some(Tool::BashOutput),
"killshell" | "kill_shell" => Some(Tool::KillShell),
"enterplanmode" | "enter_plan_mode" => Some(Tool::EnterPlanMode),
"exitplanmode" | "exit_plan_mode" => Some(Tool::ExitPlanMode),
"skill" => Some(Tool::Skill),
_ => None,
}
}
/// Get the string name of this tool
pub fn name(&self) -> &'static str {
match self {
Tool::Read => "read",
Tool::Write => "write",
Tool::Edit => "edit",
Tool::Bash => "bash",
Tool::Grep => "grep",
Tool::Glob => "glob",
Tool::WebFetch => "web_fetch",
Tool::WebSearch => "web_search",
Tool::NotebookRead => "notebook_read",
Tool::NotebookEdit => "notebook_edit",
Tool::SlashCommand => "slash_command",
Tool::Task => "task",
Tool::TodoWrite => "todo_write",
Tool::Mcp => "mcp",
Tool::MultiEdit => "multi_edit",
Tool::LS => "ls",
Tool::AskUserQuestion => "ask_user_question",
Tool::BashOutput => "bash_output",
Tool::KillShell => "kill_shell",
Tool::EnterPlanMode => "enter_plan_mode",
Tool::ExitPlanMode => "exit_plan_mode",
Tool::Skill => "skill",
}
}
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -134,6 +197,11 @@ impl PermissionManager {
} }
// User interaction and session state tools allowed // User interaction and session state tools allowed
Tool::AskUserQuestion | Tool::TodoWrite => PermissionDecision::Allow, Tool::AskUserQuestion | Tool::TodoWrite => PermissionDecision::Allow,
// Planning mode tools - EnterPlanMode asks, ExitPlanMode allowed
Tool::EnterPlanMode => PermissionDecision::Ask,
Tool::ExitPlanMode => PermissionDecision::Allow,
// Skill tool allowed (read-only skill injection)
Tool::Skill => PermissionDecision::Allow,
// Everything else requires asking // Everything else requires asking
_ => PermissionDecision::Ask, _ => PermissionDecision::Ask,
}, },
@@ -150,6 +218,8 @@ impl PermissionManager {
Tool::BashOutput | Tool::KillShell => PermissionDecision::Ask, Tool::BashOutput | Tool::KillShell => PermissionDecision::Ask,
// Utility tools allowed // Utility tools allowed
Tool::TodoWrite | Tool::SlashCommand | Tool::Task | Tool::AskUserQuestion => PermissionDecision::Allow, Tool::TodoWrite | Tool::SlashCommand | Tool::Task | Tool::AskUserQuestion => PermissionDecision::Allow,
// Planning mode tools allowed
Tool::EnterPlanMode | Tool::ExitPlanMode | Tool::Skill => PermissionDecision::Allow,
}, },
Mode::Code => { Mode::Code => {
// Everything allowed in code mode // Everything allowed in code mode
@@ -165,6 +235,41 @@ impl PermissionManager {
pub fn mode(&self) -> Mode { pub fn mode(&self) -> Mode {
self.mode self.mode
} }
/// Add allowed tools from a list of tool names (with optional patterns)
///
/// Format: "tool_name" or "tool_name:pattern"
/// Example: "bash", "bash:npm test:*", "mcp:filesystem__*"
pub fn add_allowed_tools(&mut self, tools: &[String]) {
for spec in tools {
if let Some((tool, pattern)) = Self::parse_tool_spec(spec) {
self.add_rule(tool, pattern, Action::Allow);
}
}
}
/// Add disallowed tools from a list of tool names (with optional patterns)
///
/// Format: "tool_name" or "tool_name:pattern"
/// Example: "bash", "bash:rm -rf*"
pub fn add_disallowed_tools(&mut self, tools: &[String]) {
for spec in tools {
if let Some((tool, pattern)) = Self::parse_tool_spec(spec) {
self.add_rule(tool, pattern, Action::Deny);
}
}
}
/// Parse a tool specification into (Tool, Option<pattern>)
///
/// Format: "tool_name" or "tool_name:pattern"
fn parse_tool_spec(spec: &str) -> Option<(Tool, Option<String>)> {
let parts: Vec<&str> = spec.splitn(2, ':').collect();
let tool_name = parts[0].trim();
let pattern = parts.get(1).map(|s| s.trim().to_string());
Tool::from_str(tool_name).map(|tool| (tool, pattern))
}
} }
#[cfg(test)] #[cfg(test)]
@@ -247,4 +352,78 @@ mod tests {
assert!(rule.matches(Tool::Mcp, Some("filesystem__read_file"))); assert!(rule.matches(Tool::Mcp, Some("filesystem__read_file")));
assert!(!rule.matches(Tool::Mcp, Some("filesystem__write_file"))); assert!(!rule.matches(Tool::Mcp, Some("filesystem__write_file")));
} }
#[test]
fn tool_from_str() {
assert_eq!(Tool::from_str("bash"), Some(Tool::Bash));
assert_eq!(Tool::from_str("BASH"), Some(Tool::Bash));
assert_eq!(Tool::from_str("Bash"), Some(Tool::Bash));
assert_eq!(Tool::from_str("web_fetch"), Some(Tool::WebFetch));
assert_eq!(Tool::from_str("webfetch"), Some(Tool::WebFetch));
assert_eq!(Tool::from_str("unknown"), None);
}
#[test]
fn parse_tool_spec() {
let (tool, pattern) = PermissionManager::parse_tool_spec("bash").unwrap();
assert_eq!(tool, Tool::Bash);
assert_eq!(pattern, None);
let (tool, pattern) = PermissionManager::parse_tool_spec("bash:npm test*").unwrap();
assert_eq!(tool, Tool::Bash);
assert_eq!(pattern, Some("npm test*".to_string()));
let (tool, pattern) = PermissionManager::parse_tool_spec("mcp:filesystem__*").unwrap();
assert_eq!(tool, Tool::Mcp);
assert_eq!(pattern, Some("filesystem__*".to_string()));
assert!(PermissionManager::parse_tool_spec("invalid_tool").is_none());
}
#[test]
fn allowed_tools_list() {
let mut pm = PermissionManager::new(Mode::Plan);
pm.add_allowed_tools(&[
"bash:npm test:*".to_string(),
"bash:cargo test".to_string(),
]);
// Allowed by rule
assert_eq!(pm.check(Tool::Bash, Some("npm test:unit")), PermissionDecision::Allow);
assert_eq!(pm.check(Tool::Bash, Some("cargo test")), PermissionDecision::Allow);
// Not matched by any rule, falls back to mode default (Ask for bash in plan mode)
assert_eq!(pm.check(Tool::Bash, Some("rm -rf")), PermissionDecision::Ask);
}
#[test]
fn disallowed_tools_list() {
let mut pm = PermissionManager::new(Mode::Code);
pm.add_disallowed_tools(&[
"bash:rm -rf*".to_string(),
"bash:sudo*".to_string(),
]);
// Denied by rule
assert_eq!(pm.check(Tool::Bash, Some("rm -rf /")), PermissionDecision::Deny);
assert_eq!(pm.check(Tool::Bash, Some("sudo apt install")), PermissionDecision::Deny);
// Not matched by deny rule, allowed by Code mode
assert_eq!(pm.check(Tool::Bash, Some("npm test")), PermissionDecision::Allow);
}
#[test]
fn deny_takes_precedence() {
let mut pm = PermissionManager::new(Mode::Code);
// Add both allow and deny for similar patterns
pm.add_disallowed_tools(&["bash:rm*".to_string()]);
pm.add_allowed_tools(&["bash".to_string()]);
// Deny rule was added first, so it takes precedence when matched
assert_eq!(pm.check(Tool::Bash, Some("rm -rf")), PermissionDecision::Deny);
assert_eq!(pm.check(Tool::Bash, Some("ls -la")), PermissionDecision::Allow);
}
} }

View File

@@ -0,0 +1,18 @@
[package]
name = "tools-plan"
version = "0.1.0"
edition = "2024"
license = "AGPL-3.0"
description = "Planning mode tools for the Owlen agent"
[dependencies]
color-eyre = "0.6"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.0", features = ["v4"] }
tokio = { version = "1", features = ["fs"] }
[dev-dependencies]
tempfile = "3.13"
tokio = { version = "1", features = ["rt", "macros"] }

View File

@@ -0,0 +1,296 @@
//! Planning mode tools for the Owlen agent
//!
//! Provides EnterPlanMode and ExitPlanMode tools that allow the agent
//! to enter a planning phase where only read-only operations are allowed,
//! and then present a plan for user approval.
use color_eyre::eyre::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use uuid::Uuid;
/// Agent mode - normal execution or planning
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AgentMode {
/// Normal mode - all tools available per permission settings
Normal,
/// Planning mode - only read-only tools allowed
Planning {
/// Path to the plan file being written
plan_file: PathBuf,
/// When planning mode was entered
started_at: DateTime<Utc>,
},
}
impl Default for AgentMode {
fn default() -> Self {
Self::Normal
}
}
impl AgentMode {
/// Check if we're in planning mode
pub fn is_planning(&self) -> bool {
matches!(self, AgentMode::Planning { .. })
}
/// Get the plan file path if in planning mode
pub fn plan_file(&self) -> Option<&PathBuf> {
match self {
AgentMode::Planning { plan_file, .. } => Some(plan_file),
AgentMode::Normal => None,
}
}
}
/// Plan file metadata
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanMetadata {
pub id: String,
pub created_at: DateTime<Utc>,
pub status: PlanStatus,
pub title: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PlanStatus {
/// Plan is being written
Draft,
/// Plan is awaiting user approval
PendingApproval,
/// Plan was approved by user
Approved,
/// Plan was rejected by user
Rejected,
}
/// Manager for plan files
pub struct PlanManager {
plans_dir: PathBuf,
}
impl PlanManager {
/// Create a new plan manager
pub fn new(project_root: PathBuf) -> Self {
let plans_dir = project_root.join(".owlen").join("plans");
Self { plans_dir }
}
/// Create a new plan manager with custom directory
pub fn with_dir(plans_dir: PathBuf) -> Self {
Self { plans_dir }
}
/// Get the plans directory
pub fn plans_dir(&self) -> &PathBuf {
&self.plans_dir
}
/// Ensure the plans directory exists
pub async fn ensure_dir(&self) -> Result<()> {
tokio::fs::create_dir_all(&self.plans_dir).await?;
Ok(())
}
/// Generate a unique plan file name
/// Uses a format like: <adjective>-<verb>-<noun>.md
pub fn generate_plan_name(&self) -> String {
// Simple word lists for readable names
let adjectives = ["cozy", "swift", "clever", "bright", "calm", "eager", "gentle", "happy"];
let verbs = ["dancing", "jumping", "running", "flying", "singing", "coding", "building", "thinking"];
let nouns = ["owl", "fox", "bear", "wolf", "hawk", "deer", "lion", "tiger"];
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let uuid = Uuid::new_v4();
let mut hasher = DefaultHasher::new();
uuid.hash(&mut hasher);
let hash = hasher.finish();
let adj = adjectives[(hash % adjectives.len() as u64) as usize];
let verb = verbs[((hash >> 8) % verbs.len() as u64) as usize];
let noun = nouns[((hash >> 16) % nouns.len() as u64) as usize];
format!("{}-{}-{}.md", adj, verb, noun)
}
/// Create a new plan file and return the path
pub async fn create_plan(&self) -> Result<PathBuf> {
self.ensure_dir().await?;
let filename = self.generate_plan_name();
let plan_path = self.plans_dir.join(&filename);
// Create initial plan file with metadata
let metadata = PlanMetadata {
id: Uuid::new_v4().to_string(),
created_at: Utc::now(),
status: PlanStatus::Draft,
title: None,
};
let initial_content = format!(
"<!-- plan-id: {} -->\n<!-- status: draft -->\n\n# Implementation Plan\n\n",
metadata.id
);
tokio::fs::write(&plan_path, initial_content).await?;
Ok(plan_path)
}
/// Write content to a plan file
pub async fn write_plan(&self, path: &PathBuf, content: &str) -> Result<()> {
// Preserve the metadata header if it exists
let existing = tokio::fs::read_to_string(path).await.unwrap_or_default();
// Extract metadata lines (lines starting with <!--)
let metadata_lines: Vec<&str> = existing
.lines()
.take_while(|line| line.starts_with("<!--"))
.collect();
// Update status to pending approval
let mut new_content = String::new();
for line in &metadata_lines {
if line.contains("status:") {
new_content.push_str("<!-- status: pending_approval -->\n");
} else {
new_content.push_str(line);
new_content.push('\n');
}
}
new_content.push('\n');
new_content.push_str(content);
tokio::fs::write(path, new_content).await?;
Ok(())
}
/// Read a plan file
pub async fn read_plan(&self, path: &PathBuf) -> Result<String> {
let content = tokio::fs::read_to_string(path).await?;
Ok(content)
}
/// Update plan status
pub async fn set_status(&self, path: &PathBuf, status: PlanStatus) -> Result<()> {
let content = tokio::fs::read_to_string(path).await?;
let status_str = match status {
PlanStatus::Draft => "draft",
PlanStatus::PendingApproval => "pending_approval",
PlanStatus::Approved => "approved",
PlanStatus::Rejected => "rejected",
};
// Replace status line
let updated: String = content
.lines()
.map(|line| {
if line.contains("<!-- status:") {
format!("<!-- status: {} -->", status_str)
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
tokio::fs::write(path, updated).await?;
Ok(())
}
/// List all plan files
pub async fn list_plans(&self) -> Result<Vec<PathBuf>> {
let mut plans = Vec::new();
if !self.plans_dir.exists() {
return Ok(plans);
}
let mut entries = tokio::fs::read_dir(&self.plans_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().map_or(false, |ext| ext == "md") {
plans.push(path);
}
}
plans.sort();
Ok(plans)
}
}
/// Enter planning mode
pub fn enter_plan_mode(plan_file: PathBuf) -> AgentMode {
AgentMode::Planning {
plan_file,
started_at: Utc::now(),
}
}
/// Exit planning mode and return to normal
pub fn exit_plan_mode() -> AgentMode {
AgentMode::Normal
}
/// Check if a tool is allowed in planning mode
/// Only read-only tools are allowed
pub fn is_tool_allowed_in_plan_mode(tool_name: &str) -> bool {
matches!(
tool_name,
"read" | "glob" | "grep" | "ls" | "web_fetch" | "web_search" |
"todo_write" | "ask_user" | "exit_plan_mode"
)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_create_plan() {
let temp_dir = TempDir::new().unwrap();
let manager = PlanManager::new(temp_dir.path().to_path_buf());
let plan_path = manager.create_plan().await.unwrap();
assert!(plan_path.exists());
assert!(plan_path.extension().map_or(false, |ext| ext == "md"));
}
#[tokio::test]
async fn test_write_and_read_plan() {
let temp_dir = TempDir::new().unwrap();
let manager = PlanManager::new(temp_dir.path().to_path_buf());
let plan_path = manager.create_plan().await.unwrap();
manager.write_plan(&plan_path, "# My Plan\n\nStep 1: Do something").await.unwrap();
let content = manager.read_plan(&plan_path).await.unwrap();
assert!(content.contains("My Plan"));
assert!(content.contains("pending_approval"));
}
#[test]
fn test_plan_mode_check() {
assert!(is_tool_allowed_in_plan_mode("read"));
assert!(is_tool_allowed_in_plan_mode("glob"));
assert!(is_tool_allowed_in_plan_mode("grep"));
assert!(!is_tool_allowed_in_plan_mode("write"));
assert!(!is_tool_allowed_in_plan_mode("bash"));
assert!(!is_tool_allowed_in_plan_mode("edit"));
}
#[test]
fn test_agent_mode_default() {
let mode = AgentMode::default();
assert!(!mode.is_planning());
assert!(mode.plan_file().is_none());
}
}

View File

@@ -0,0 +1,16 @@
[package]
name = "tools-skill"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "Skill invocation tool for the Owlen agent"
[dependencies]
color-eyre = "0.6"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
plugins = { path = "../../platform/plugins" }
[dev-dependencies]
tempfile = "3.13"

View File

@@ -0,0 +1,275 @@
//! Skill invocation tool for the Owlen agent
//!
//! Provides the Skill tool that allows the agent to invoke skills
//! from plugins programmatically during a conversation.
use color_eyre::eyre::{Result, eyre};
use plugins::PluginManager;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
/// Parameters for the Skill tool
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillParams {
/// Name of the skill to invoke (e.g., "pdf", "xlsx", or "plugin:skill")
pub skill: String,
}
/// Result of skill invocation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillResult {
/// The skill name that was invoked
pub skill_name: String,
/// The skill content (instructions)
pub content: String,
/// Source of the skill (plugin name)
pub source: String,
}
/// Skill registry for looking up and invoking skills
pub struct SkillRegistry {
/// Local skills directory (e.g., .owlen/skills/)
local_skills_dir: PathBuf,
/// Plugin manager for finding plugin skills
plugin_manager: Option<PluginManager>,
}
impl SkillRegistry {
/// Create a new skill registry
pub fn new() -> Self {
Self {
local_skills_dir: PathBuf::from(".owlen/skills"),
plugin_manager: None,
}
}
/// Create with custom local skills directory
pub fn with_local_dir(mut self, dir: PathBuf) -> Self {
self.local_skills_dir = dir;
self
}
/// Set the plugin manager for discovering plugin skills
pub fn with_plugin_manager(mut self, pm: PluginManager) -> Self {
self.plugin_manager = Some(pm);
self
}
/// Find and load a skill by name
///
/// Skill names can be:
/// - Simple name: "pdf" (searches local, then plugins)
/// - Fully qualified: "plugin-name:skill-name"
pub fn invoke(&self, skill_name: &str) -> Result<SkillResult> {
// Check for fully qualified name (plugin:skill)
if let Some((plugin_name, skill_id)) = skill_name.split_once(':') {
return self.load_plugin_skill(plugin_name, skill_id);
}
// Try local skills first
if let Ok(result) = self.load_local_skill(skill_name) {
return Ok(result);
}
// Try plugins
if let Some(pm) = &self.plugin_manager {
for plugin in pm.plugins() {
if let Ok(result) = self.load_skill_from_plugin(plugin, skill_name) {
return Ok(result);
}
}
}
Err(eyre!(
"Skill '{}' not found.\n\nAvailable skills:\n{}",
skill_name,
self.list_available_skills().join("\n")
))
}
/// Load a local skill from .owlen/skills/
fn load_local_skill(&self, skill_name: &str) -> Result<SkillResult> {
// Try with and without .md extension
let skill_file = self.local_skills_dir.join(format!("{}.md", skill_name));
let skill_dir = self.local_skills_dir.join(skill_name).join("SKILL.md");
let content = if skill_file.exists() {
fs::read_to_string(&skill_file)?
} else if skill_dir.exists() {
fs::read_to_string(&skill_dir)?
} else {
return Err(eyre!("Local skill '{}' not found", skill_name));
};
Ok(SkillResult {
skill_name: skill_name.to_string(),
content: parse_skill_content(&content),
source: "local".to_string(),
})
}
/// Load a skill from a specific plugin
fn load_plugin_skill(&self, plugin_name: &str, skill_name: &str) -> Result<SkillResult> {
let pm = self.plugin_manager.as_ref()
.ok_or_else(|| eyre!("Plugin manager not available"))?;
for plugin in pm.plugins() {
if plugin.manifest.name == plugin_name {
return self.load_skill_from_plugin(plugin, skill_name);
}
}
Err(eyre!("Plugin '{}' not found", plugin_name))
}
/// Load a skill from a plugin
fn load_skill_from_plugin(&self, plugin: &plugins::Plugin, skill_name: &str) -> Result<SkillResult> {
let skill_names = plugin.all_skill_names();
if !skill_names.contains(&skill_name.to_string()) {
return Err(eyre!("Skill '{}' not found in plugin '{}'", skill_name, plugin.manifest.name));
}
// Skills are in skills/<name>/SKILL.md
let skill_path = plugin.base_path.join("skills").join(skill_name).join("SKILL.md");
if !skill_path.exists() {
return Err(eyre!("Skill file not found: {:?}", skill_path));
}
let content = fs::read_to_string(&skill_path)?;
Ok(SkillResult {
skill_name: skill_name.to_string(),
content: parse_skill_content(&content),
source: format!("plugin:{}", plugin.manifest.name),
})
}
/// List all available skills
pub fn list_available_skills(&self) -> Vec<String> {
let mut skills = Vec::new();
// Local skills
if self.local_skills_dir.exists() {
if let Ok(entries) = fs::read_dir(&self.local_skills_dir) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.is_file() && path.extension().map_or(false, |e| e == "md") {
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
skills.push(format!(" - {} (local)", name));
}
} else if path.is_dir() && path.join("SKILL.md").exists() {
if let Some(name) = path.file_name().and_then(|s| s.to_str()) {
skills.push(format!(" - {} (local)", name));
}
}
}
}
}
// Plugin skills
if let Some(pm) = &self.plugin_manager {
for plugin in pm.plugins() {
for skill_name in plugin.all_skill_names() {
skills.push(format!(" - {} (plugin:{})", skill_name, plugin.manifest.name));
}
}
}
if skills.is_empty() {
skills.push(" (no skills available)".to_string());
}
skills
}
}
impl Default for SkillRegistry {
fn default() -> Self {
Self::new()
}
}
/// Parse skill content, extracting the body (stripping YAML frontmatter)
fn parse_skill_content(content: &str) -> String {
// Check for YAML frontmatter
if content.starts_with("---") {
// Find the end of frontmatter
if let Some(end_idx) = content[3..].find("---") {
let body_start = end_idx + 6; // Skip past the closing ---
if body_start < content.len() {
return content[body_start..].trim().to_string();
}
}
}
content.trim().to_string()
}
/// Execute the Skill tool
pub fn execute_skill(params: &SkillParams, registry: &SkillRegistry) -> Result<String> {
let result = registry.invoke(&params.skill)?;
// Format output for injection into conversation
Ok(format!(
"## Skill: {} ({})\n\n{}",
result.skill_name,
result.source,
result.content
))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_parse_skill_content_with_frontmatter() {
let content = r#"---
name: test-skill
description: A test skill
---
# Test Skill
This is the skill content."#;
let parsed = parse_skill_content(content);
assert!(parsed.starts_with("# Test Skill"));
assert!(!parsed.contains("name: test-skill"));
}
#[test]
fn test_parse_skill_content_without_frontmatter() {
let content = "# Just Content\n\nNo frontmatter here.";
let parsed = parse_skill_content(content);
assert_eq!(parsed, content.trim());
}
#[test]
fn test_skill_registry_local() {
let temp_dir = TempDir::new().unwrap();
let skills_dir = temp_dir.path().join(".owlen/skills");
fs::create_dir_all(&skills_dir).unwrap();
// Create a test skill
fs::write(skills_dir.join("test.md"), "# Test Skill\n\nTest content.").unwrap();
let registry = SkillRegistry::new().with_local_dir(skills_dir);
let result = registry.invoke("test").unwrap();
assert_eq!(result.skill_name, "test");
assert_eq!(result.source, "local");
assert!(result.content.contains("Test Skill"));
}
#[test]
fn test_skill_not_found() {
let registry = SkillRegistry::new();
assert!(registry.invoke("nonexistent").is_err());
}
}