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:
@@ -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",
|
||||||
|
|||||||
382
crates/app/cli/src/commands.rs
Normal file
382
crates/app/cli/src/commands.rs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
226
crates/app/ui/src/completions.rs
Normal file
226
crates/app/ui/src/completions.rs
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
//! Command completion engine for the TUI
|
||||||
|
//!
|
||||||
|
//! Provides Tab-completion for slash commands, file paths, and tool names.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// A single completion suggestion
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Completion {
|
||||||
|
/// The text to insert
|
||||||
|
pub text: String,
|
||||||
|
/// Description of what this completion does
|
||||||
|
pub description: String,
|
||||||
|
/// Source of the completion (e.g., "builtin", "plugin:name")
|
||||||
|
pub source: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Information about a command for completion purposes
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CommandInfo {
|
||||||
|
/// Command name (without leading /)
|
||||||
|
pub name: String,
|
||||||
|
/// Command description
|
||||||
|
pub description: String,
|
||||||
|
/// Source of the command
|
||||||
|
pub source: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandInfo {
|
||||||
|
pub fn new(name: &str, description: &str, source: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.to_string(),
|
||||||
|
description: description.to_string(),
|
||||||
|
source: source.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Completion engine for the TUI
|
||||||
|
pub struct CompletionEngine {
|
||||||
|
/// Available commands
|
||||||
|
commands: Vec<CommandInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CompletionEngine {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CompletionEngine {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
commands: Self::builtin_commands(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get built-in commands
|
||||||
|
fn builtin_commands() -> Vec<CommandInfo> {
|
||||||
|
vec![
|
||||||
|
CommandInfo::new("help", "Show available commands and help", "builtin"),
|
||||||
|
CommandInfo::new("clear", "Clear the screen", "builtin"),
|
||||||
|
CommandInfo::new("mcp", "List MCP servers and their tools", "builtin"),
|
||||||
|
CommandInfo::new("hooks", "Show loaded hooks", "builtin"),
|
||||||
|
CommandInfo::new("compact", "Compact conversation context", "builtin"),
|
||||||
|
CommandInfo::new("mode", "Switch permission mode (plan/edit/code)", "builtin"),
|
||||||
|
CommandInfo::new("provider", "Switch LLM provider", "builtin"),
|
||||||
|
CommandInfo::new("model", "Switch LLM model", "builtin"),
|
||||||
|
CommandInfo::new("checkpoint", "Create a checkpoint", "builtin"),
|
||||||
|
CommandInfo::new("rewind", "Rewind to a checkpoint", "builtin"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add commands from plugins
|
||||||
|
pub fn add_plugin_commands(&mut self, plugin_name: &str, commands: Vec<CommandInfo>) {
|
||||||
|
for mut cmd in commands {
|
||||||
|
cmd.source = format!("plugin:{}", plugin_name);
|
||||||
|
self.commands.push(cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a single command
|
||||||
|
pub fn add_command(&mut self, command: CommandInfo) {
|
||||||
|
self.commands.push(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get completions for the given input
|
||||||
|
pub fn complete(&self, input: &str) -> Vec<Completion> {
|
||||||
|
if input.starts_with('/') {
|
||||||
|
self.complete_command(&input[1..])
|
||||||
|
} else if input.starts_with('@') {
|
||||||
|
self.complete_file_path(&input[1..])
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete a slash command
|
||||||
|
fn complete_command(&self, partial: &str) -> Vec<Completion> {
|
||||||
|
let partial_lower = partial.to_lowercase();
|
||||||
|
|
||||||
|
self.commands
|
||||||
|
.iter()
|
||||||
|
.filter(|cmd| {
|
||||||
|
// Match if name starts with partial, or contains partial (fuzzy)
|
||||||
|
cmd.name.to_lowercase().starts_with(&partial_lower)
|
||||||
|
|| (partial.len() >= 2 && cmd.name.to_lowercase().contains(&partial_lower))
|
||||||
|
})
|
||||||
|
.map(|cmd| Completion {
|
||||||
|
text: format!("/{}", cmd.name),
|
||||||
|
description: cmd.description.clone(),
|
||||||
|
source: cmd.source.clone(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete a file path
|
||||||
|
fn complete_file_path(&self, partial: &str) -> Vec<Completion> {
|
||||||
|
let path = Path::new(partial);
|
||||||
|
|
||||||
|
// Get the directory to search and the prefix to match
|
||||||
|
let (dir, prefix) = if partial.ends_with('/') || partial.is_empty() {
|
||||||
|
(partial, "")
|
||||||
|
} else {
|
||||||
|
let parent = path.parent().map(|p| p.to_str().unwrap_or("")).unwrap_or("");
|
||||||
|
let file_name = path.file_name().and_then(|f| f.to_str()).unwrap_or("");
|
||||||
|
(parent, file_name)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Search directory
|
||||||
|
let search_dir = if dir.is_empty() { "." } else { dir };
|
||||||
|
|
||||||
|
match std::fs::read_dir(search_dir) {
|
||||||
|
Ok(entries) => {
|
||||||
|
entries
|
||||||
|
.filter_map(|entry| entry.ok())
|
||||||
|
.filter(|entry| {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name_str = name.to_string_lossy();
|
||||||
|
// Skip hidden files unless user started typing with .
|
||||||
|
if !prefix.starts_with('.') && name_str.starts_with('.') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
name_str.to_lowercase().starts_with(&prefix.to_lowercase())
|
||||||
|
})
|
||||||
|
.map(|entry| {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name_str = name.to_string_lossy();
|
||||||
|
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
|
||||||
|
|
||||||
|
let full_path = if dir.is_empty() {
|
||||||
|
name_str.to_string()
|
||||||
|
} else if dir.ends_with('/') {
|
||||||
|
format!("{}{}", dir, name_str)
|
||||||
|
} else {
|
||||||
|
format!("{}/{}", dir, name_str)
|
||||||
|
};
|
||||||
|
|
||||||
|
Completion {
|
||||||
|
text: format!("@{}{}", full_path, if is_dir { "/" } else { "" }),
|
||||||
|
description: if is_dir { "Directory".to_string() } else { "File".to_string() },
|
||||||
|
source: "filesystem".to_string(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
Err(_) => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all commands (for /help display)
|
||||||
|
pub fn all_commands(&self) -> &[CommandInfo] {
|
||||||
|
&self.commands
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_completion_exact() {
|
||||||
|
let engine = CompletionEngine::new();
|
||||||
|
let completions = engine.complete("/help");
|
||||||
|
assert!(!completions.is_empty());
|
||||||
|
assert!(completions.iter().any(|c| c.text == "/help"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_completion_partial() {
|
||||||
|
let engine = CompletionEngine::new();
|
||||||
|
let completions = engine.complete("/hel");
|
||||||
|
assert!(!completions.is_empty());
|
||||||
|
assert!(completions.iter().any(|c| c.text == "/help"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_completion_fuzzy() {
|
||||||
|
let engine = CompletionEngine::new();
|
||||||
|
// "cle" should match "clear"
|
||||||
|
let completions = engine.complete("/cle");
|
||||||
|
assert!(!completions.is_empty());
|
||||||
|
assert!(completions.iter().any(|c| c.text == "/clear"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_info() {
|
||||||
|
let info = CommandInfo::new("test", "A test command", "builtin");
|
||||||
|
assert_eq!(info.name, "test");
|
||||||
|
assert_eq!(info.description, "A test command");
|
||||||
|
assert_eq!(info.source, "builtin");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_add_plugin_commands() {
|
||||||
|
let mut engine = CompletionEngine::new();
|
||||||
|
let plugin_cmds = vec![
|
||||||
|
CommandInfo::new("custom", "A custom command", ""),
|
||||||
|
];
|
||||||
|
engine.add_plugin_commands("my-plugin", plugin_cmds);
|
||||||
|
|
||||||
|
let completions = engine.complete("/custom");
|
||||||
|
assert!(!completions.is_empty());
|
||||||
|
assert!(completions.iter().any(|c| c.source == "plugin:my-plugin"));
|
||||||
|
}
|
||||||
|
}
|
||||||
377
crates/app/ui/src/components/autocomplete.rs
Normal file
377
crates/app/ui/src/components/autocomplete.rs
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
//! Command autocomplete dropdown component
|
||||||
|
//!
|
||||||
|
//! Displays inline autocomplete suggestions when user types `/`.
|
||||||
|
//! Supports fuzzy filtering as user types.
|
||||||
|
|
||||||
|
use crate::theme::Theme;
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent};
|
||||||
|
use ratatui::{
|
||||||
|
layout::Rect,
|
||||||
|
style::Style,
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Clear, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// An autocomplete option
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AutocompleteOption {
|
||||||
|
/// The trigger text (command name without /)
|
||||||
|
pub trigger: String,
|
||||||
|
/// Display text (e.g., "/model [name]")
|
||||||
|
pub display: String,
|
||||||
|
/// Short description
|
||||||
|
pub description: String,
|
||||||
|
/// Has submenu/subcommands
|
||||||
|
pub has_submenu: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AutocompleteOption {
|
||||||
|
pub fn new(trigger: &str, description: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
trigger: trigger.to_string(),
|
||||||
|
display: format!("/{}", trigger),
|
||||||
|
description: description.to_string(),
|
||||||
|
has_submenu: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_args(trigger: &str, args: &str, description: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
trigger: trigger.to_string(),
|
||||||
|
display: format!("/{} {}", trigger, args),
|
||||||
|
description: description.to_string(),
|
||||||
|
has_submenu: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_submenu(trigger: &str, description: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
trigger: trigger.to_string(),
|
||||||
|
display: format!("/{}", trigger),
|
||||||
|
description: description.to_string(),
|
||||||
|
has_submenu: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default command options
|
||||||
|
fn default_options() -> Vec<AutocompleteOption> {
|
||||||
|
vec![
|
||||||
|
AutocompleteOption::new("help", "Show help"),
|
||||||
|
AutocompleteOption::new("status", "Session info"),
|
||||||
|
AutocompleteOption::with_args("model", "[name]", "Switch model"),
|
||||||
|
AutocompleteOption::with_args("provider", "[name]", "Switch provider"),
|
||||||
|
AutocompleteOption::new("history", "View history"),
|
||||||
|
AutocompleteOption::new("checkpoint", "Save state"),
|
||||||
|
AutocompleteOption::new("checkpoints", "List checkpoints"),
|
||||||
|
AutocompleteOption::with_args("rewind", "[id]", "Restore"),
|
||||||
|
AutocompleteOption::new("cost", "Token usage"),
|
||||||
|
AutocompleteOption::new("clear", "Clear chat"),
|
||||||
|
AutocompleteOption::new("compact", "Compact context"),
|
||||||
|
AutocompleteOption::new("permissions", "Permission mode"),
|
||||||
|
AutocompleteOption::new("themes", "List themes"),
|
||||||
|
AutocompleteOption::with_args("theme", "[name]", "Switch theme"),
|
||||||
|
AutocompleteOption::new("exit", "Exit"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Autocomplete dropdown component
|
||||||
|
pub struct Autocomplete {
|
||||||
|
options: Vec<AutocompleteOption>,
|
||||||
|
filtered: Vec<usize>, // indices into options
|
||||||
|
selected: usize,
|
||||||
|
visible: bool,
|
||||||
|
theme: Theme,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Autocomplete {
|
||||||
|
pub fn new(theme: Theme) -> Self {
|
||||||
|
let options = default_options();
|
||||||
|
let filtered: Vec<usize> = (0..options.len()).collect();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
options,
|
||||||
|
filtered,
|
||||||
|
selected: 0,
|
||||||
|
visible: false,
|
||||||
|
theme,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show autocomplete and reset filter
|
||||||
|
pub fn show(&mut self) {
|
||||||
|
self.visible = true;
|
||||||
|
self.filtered = (0..self.options.len()).collect();
|
||||||
|
self.selected = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hide autocomplete
|
||||||
|
pub fn hide(&mut self) {
|
||||||
|
self.visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if visible
|
||||||
|
pub fn is_visible(&self) -> bool {
|
||||||
|
self.visible
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update filter based on current input (text after /)
|
||||||
|
pub fn update_filter(&mut self, query: &str) {
|
||||||
|
if query.is_empty() {
|
||||||
|
self.filtered = (0..self.options.len()).collect();
|
||||||
|
} else {
|
||||||
|
let query_lower = query.to_lowercase();
|
||||||
|
self.filtered = self.options
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, opt)| {
|
||||||
|
// Fuzzy match: check if query chars appear in order
|
||||||
|
fuzzy_match(&opt.trigger.to_lowercase(), &query_lower)
|
||||||
|
})
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset selection if it's out of bounds
|
||||||
|
if self.selected >= self.filtered.len() {
|
||||||
|
self.selected = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select next option
|
||||||
|
pub fn select_next(&mut self) {
|
||||||
|
if !self.filtered.is_empty() {
|
||||||
|
self.selected = (self.selected + 1) % self.filtered.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select previous option
|
||||||
|
pub fn select_prev(&mut self) {
|
||||||
|
if !self.filtered.is_empty() {
|
||||||
|
self.selected = if self.selected == 0 {
|
||||||
|
self.filtered.len() - 1
|
||||||
|
} else {
|
||||||
|
self.selected - 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the currently selected option's trigger
|
||||||
|
pub fn confirm(&self) -> Option<String> {
|
||||||
|
if self.filtered.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let idx = self.filtered[self.selected];
|
||||||
|
Some(format!("/{}", self.options[idx].trigger))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle key input, returns Some(command) if confirmed
|
||||||
|
///
|
||||||
|
/// Key behavior:
|
||||||
|
/// - Tab: Confirm selection and insert into input
|
||||||
|
/// - Down/Up: Navigate options
|
||||||
|
/// - Enter: Pass through to submit (NotHandled)
|
||||||
|
/// - Esc: Cancel autocomplete
|
||||||
|
pub fn handle_key(&mut self, key: KeyEvent) -> AutocompleteResult {
|
||||||
|
if !self.visible {
|
||||||
|
return AutocompleteResult::NotHandled;
|
||||||
|
}
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Tab => {
|
||||||
|
// Tab confirms and inserts the selected command
|
||||||
|
if let Some(cmd) = self.confirm() {
|
||||||
|
self.hide();
|
||||||
|
AutocompleteResult::Confirmed(cmd)
|
||||||
|
} else {
|
||||||
|
AutocompleteResult::Handled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
self.select_next();
|
||||||
|
AutocompleteResult::Handled
|
||||||
|
}
|
||||||
|
KeyCode::BackTab | KeyCode::Up => {
|
||||||
|
self.select_prev();
|
||||||
|
AutocompleteResult::Handled
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
// Enter should submit the message, not confirm autocomplete
|
||||||
|
// Hide autocomplete and let Enter pass through
|
||||||
|
self.hide();
|
||||||
|
AutocompleteResult::NotHandled
|
||||||
|
}
|
||||||
|
KeyCode::Esc => {
|
||||||
|
self.hide();
|
||||||
|
AutocompleteResult::Cancelled
|
||||||
|
}
|
||||||
|
_ => AutocompleteResult::NotHandled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update theme
|
||||||
|
pub fn set_theme(&mut self, theme: Theme) {
|
||||||
|
self.theme = theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add custom options (from plugins)
|
||||||
|
pub fn add_options(&mut self, options: Vec<AutocompleteOption>) {
|
||||||
|
self.options.extend(options);
|
||||||
|
// Re-filter with all options
|
||||||
|
self.filtered = (0..self.options.len()).collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the autocomplete dropdown above the input line
|
||||||
|
pub fn render(&self, frame: &mut Frame, input_area: Rect) {
|
||||||
|
if !self.visible || self.filtered.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate dropdown dimensions
|
||||||
|
let max_visible = 8.min(self.filtered.len());
|
||||||
|
let width = 40.min(input_area.width.saturating_sub(4));
|
||||||
|
let height = (max_visible + 2) as u16; // +2 for borders
|
||||||
|
|
||||||
|
// Position above input, left-aligned with some padding
|
||||||
|
let x = input_area.x + 2;
|
||||||
|
let y = input_area.y.saturating_sub(height);
|
||||||
|
|
||||||
|
let dropdown_area = Rect::new(x, y, width, height);
|
||||||
|
|
||||||
|
// Clear area behind dropdown
|
||||||
|
frame.render_widget(Clear, dropdown_area);
|
||||||
|
|
||||||
|
// Build option lines
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
|
||||||
|
for (display_idx, &opt_idx) in self.filtered.iter().take(max_visible).enumerate() {
|
||||||
|
let opt = &self.options[opt_idx];
|
||||||
|
let is_selected = display_idx == self.selected;
|
||||||
|
|
||||||
|
let style = if is_selected {
|
||||||
|
self.theme.selected
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut spans = vec![
|
||||||
|
Span::styled(" ", style),
|
||||||
|
Span::styled("/", if is_selected { style } else { self.theme.cmd_slash }),
|
||||||
|
Span::styled(&opt.trigger, if is_selected { style } else { self.theme.cmd_name }),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Submenu indicator
|
||||||
|
if opt.has_submenu {
|
||||||
|
spans.push(Span::styled(" >", if is_selected { style } else { self.theme.cmd_desc }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pad to fixed width for consistent selection highlighting
|
||||||
|
let current_len: usize = spans.iter().map(|s| s.content.len()).sum();
|
||||||
|
let padding = (width as usize).saturating_sub(current_len + 1);
|
||||||
|
spans.push(Span::styled(" ".repeat(padding), style));
|
||||||
|
|
||||||
|
lines.push(Line::from(spans));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show overflow indicator if needed
|
||||||
|
if self.filtered.len() > max_visible {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!(" ... +{} more", self.filtered.len() - max_visible),
|
||||||
|
self.theme.cmd_desc,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(self.theme.palette.border))
|
||||||
|
.style(self.theme.overlay_bg);
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(lines).block(block);
|
||||||
|
|
||||||
|
frame.render_widget(paragraph, dropdown_area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of handling autocomplete key
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum AutocompleteResult {
|
||||||
|
/// Key was not handled by autocomplete
|
||||||
|
NotHandled,
|
||||||
|
/// Key was handled, no action needed
|
||||||
|
Handled,
|
||||||
|
/// User confirmed selection, returns command string
|
||||||
|
Confirmed(String),
|
||||||
|
/// User cancelled autocomplete
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple fuzzy match: check if query chars appear in order in text
|
||||||
|
fn fuzzy_match(text: &str, query: &str) -> bool {
|
||||||
|
let mut text_chars = text.chars().peekable();
|
||||||
|
|
||||||
|
for query_char in query.chars() {
|
||||||
|
loop {
|
||||||
|
match text_chars.next() {
|
||||||
|
Some(c) if c == query_char => break,
|
||||||
|
Some(_) => continue,
|
||||||
|
None => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fuzzy_match() {
|
||||||
|
assert!(fuzzy_match("help", "h"));
|
||||||
|
assert!(fuzzy_match("help", "he"));
|
||||||
|
assert!(fuzzy_match("help", "hel"));
|
||||||
|
assert!(fuzzy_match("help", "help"));
|
||||||
|
assert!(fuzzy_match("help", "hp")); // fuzzy: h...p
|
||||||
|
assert!(!fuzzy_match("help", "x"));
|
||||||
|
assert!(!fuzzy_match("help", "helping")); // query longer than text
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_autocomplete_filter() {
|
||||||
|
let theme = Theme::default();
|
||||||
|
let mut ac = Autocomplete::new(theme);
|
||||||
|
|
||||||
|
ac.update_filter("he");
|
||||||
|
assert!(ac.filtered.len() < ac.options.len());
|
||||||
|
|
||||||
|
// Should match "help"
|
||||||
|
assert!(ac.filtered.iter().any(|&i| ac.options[i].trigger == "help"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_autocomplete_navigation() {
|
||||||
|
let theme = Theme::default();
|
||||||
|
let mut ac = Autocomplete::new(theme);
|
||||||
|
ac.show();
|
||||||
|
|
||||||
|
assert_eq!(ac.selected, 0);
|
||||||
|
ac.select_next();
|
||||||
|
assert_eq!(ac.selected, 1);
|
||||||
|
ac.select_prev();
|
||||||
|
assert_eq!(ac.selected, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_autocomplete_confirm() {
|
||||||
|
let theme = Theme::default();
|
||||||
|
let mut ac = Autocomplete::new(theme);
|
||||||
|
ac.show();
|
||||||
|
|
||||||
|
let cmd = ac.confirm();
|
||||||
|
assert!(cmd.is_some());
|
||||||
|
assert!(cmd.unwrap().starts_with("/"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -224,10 +224,15 @@ impl ChatPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Render the borderless chat panel
|
/// 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),
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|||||||
322
crates/app/ui/src/components/command_help.rs
Normal file
322
crates/app/ui/src/components/command_help.rs
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
//! Command help overlay component
|
||||||
|
//!
|
||||||
|
//! Modal overlay that displays available commands in a structured format.
|
||||||
|
//! Shown when user types `/help` or `?`. Supports scrolling with j/k or arrows.
|
||||||
|
|
||||||
|
use crate::theme::Theme;
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent};
|
||||||
|
use ratatui::{
|
||||||
|
layout::Rect,
|
||||||
|
style::Style,
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A single command definition
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Command {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub args: Option<&'static str>,
|
||||||
|
pub description: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Command {
|
||||||
|
pub const fn new(name: &'static str, description: &'static str) -> Self {
|
||||||
|
Self {
|
||||||
|
name,
|
||||||
|
args: None,
|
||||||
|
description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn with_args(name: &'static str, args: &'static str, description: &'static str) -> Self {
|
||||||
|
Self {
|
||||||
|
name,
|
||||||
|
args: Some(args),
|
||||||
|
description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Built-in commands
|
||||||
|
pub fn builtin_commands() -> Vec<Command> {
|
||||||
|
vec![
|
||||||
|
Command::new("help", "Show this help"),
|
||||||
|
Command::new("status", "Current session info"),
|
||||||
|
Command::with_args("model", "[name]", "Switch model"),
|
||||||
|
Command::with_args("provider", "[name]", "Switch provider (ollama, anthropic, openai)"),
|
||||||
|
Command::new("history", "Browse conversation history"),
|
||||||
|
Command::new("checkpoint", "Save conversation state"),
|
||||||
|
Command::new("checkpoints", "List saved checkpoints"),
|
||||||
|
Command::with_args("rewind", "[id]", "Restore checkpoint"),
|
||||||
|
Command::new("cost", "Show token usage"),
|
||||||
|
Command::new("clear", "Clear conversation"),
|
||||||
|
Command::new("compact", "Compact conversation context"),
|
||||||
|
Command::new("permissions", "Show permission mode"),
|
||||||
|
Command::new("themes", "List available themes"),
|
||||||
|
Command::with_args("theme", "[name]", "Switch theme"),
|
||||||
|
Command::new("exit", "Exit OWLEN"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Command help overlay
|
||||||
|
pub struct CommandHelp {
|
||||||
|
commands: Vec<Command>,
|
||||||
|
visible: bool,
|
||||||
|
scroll_offset: usize,
|
||||||
|
theme: Theme,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandHelp {
|
||||||
|
pub fn new(theme: Theme) -> Self {
|
||||||
|
Self {
|
||||||
|
commands: builtin_commands(),
|
||||||
|
visible: false,
|
||||||
|
scroll_offset: 0,
|
||||||
|
theme,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the help overlay
|
||||||
|
pub fn show(&mut self) {
|
||||||
|
self.visible = true;
|
||||||
|
self.scroll_offset = 0; // Reset scroll when showing
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hide the help overlay
|
||||||
|
pub fn hide(&mut self) {
|
||||||
|
self.visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if visible
|
||||||
|
pub fn is_visible(&self) -> bool {
|
||||||
|
self.visible
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle visibility
|
||||||
|
pub fn toggle(&mut self) {
|
||||||
|
self.visible = !self.visible;
|
||||||
|
if self.visible {
|
||||||
|
self.scroll_offset = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scroll up by amount
|
||||||
|
fn scroll_up(&mut self, amount: usize) {
|
||||||
|
self.scroll_offset = self.scroll_offset.saturating_sub(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scroll down by amount, respecting max
|
||||||
|
fn scroll_down(&mut self, amount: usize, max_scroll: usize) {
|
||||||
|
self.scroll_offset = (self.scroll_offset + amount).min(max_scroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle key input, returns true if overlay handled the key
|
||||||
|
pub fn handle_key(&mut self, key: KeyEvent) -> bool {
|
||||||
|
if !self.visible {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate max scroll (commands + padding lines - visible area)
|
||||||
|
let total_lines = self.commands.len() + 3; // +3 for padding and footer
|
||||||
|
let max_scroll = total_lines.saturating_sub(10); // Assume ~10 visible lines
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') => {
|
||||||
|
self.hide();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// Scroll navigation
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
self.scroll_up(1);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
self.scroll_down(1, max_scroll);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
KeyCode::PageUp | KeyCode::Char('u') => {
|
||||||
|
self.scroll_up(5);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
KeyCode::PageDown | KeyCode::Char('d') => {
|
||||||
|
self.scroll_down(5, max_scroll);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
KeyCode::Home | KeyCode::Char('g') => {
|
||||||
|
self.scroll_offset = 0;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
KeyCode::End | KeyCode::Char('G') => {
|
||||||
|
self.scroll_offset = max_scroll;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => true, // Consume all other keys while visible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update theme
|
||||||
|
pub fn set_theme(&mut self, theme: Theme) {
|
||||||
|
self.theme = theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add plugin commands
|
||||||
|
pub fn add_commands(&mut self, commands: Vec<Command>) {
|
||||||
|
self.commands.extend(commands);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the help overlay
|
||||||
|
pub fn render(&self, frame: &mut Frame, area: Rect) {
|
||||||
|
if !self.visible {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate overlay dimensions
|
||||||
|
let width = (area.width as f32 * 0.7).min(65.0) as u16;
|
||||||
|
let max_height = area.height.saturating_sub(4);
|
||||||
|
let content_height = self.commands.len() as u16 + 4; // +4 for padding and footer
|
||||||
|
let height = content_height.min(max_height).max(8);
|
||||||
|
|
||||||
|
// Center the overlay
|
||||||
|
let x = (area.width.saturating_sub(width)) / 2;
|
||||||
|
let y = (area.height.saturating_sub(height)) / 2;
|
||||||
|
|
||||||
|
let overlay_area = Rect::new(x, y, width, height);
|
||||||
|
|
||||||
|
// Clear the area behind the overlay
|
||||||
|
frame.render_widget(Clear, overlay_area);
|
||||||
|
|
||||||
|
// Build content lines
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
|
||||||
|
// Empty line for padding
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
|
||||||
|
// Command list
|
||||||
|
for cmd in &self.commands {
|
||||||
|
let name_with_args = if let Some(args) = cmd.args {
|
||||||
|
format!("/{} {}", cmd.name, args)
|
||||||
|
} else {
|
||||||
|
format!("/{}", cmd.name)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate padding for alignment
|
||||||
|
let name_width: usize = 22;
|
||||||
|
let padding = name_width.saturating_sub(name_with_args.len());
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(" ", Style::default()),
|
||||||
|
Span::styled("/", self.theme.cmd_slash),
|
||||||
|
Span::styled(
|
||||||
|
if let Some(args) = cmd.args {
|
||||||
|
format!("{} {}", cmd.name, args)
|
||||||
|
} else {
|
||||||
|
cmd.name.to_string()
|
||||||
|
},
|
||||||
|
self.theme.cmd_name,
|
||||||
|
),
|
||||||
|
Span::raw(" ".repeat(padding)),
|
||||||
|
Span::styled(cmd.description, self.theme.cmd_desc),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty line for padding
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
|
||||||
|
// Footer hint with scroll info
|
||||||
|
let scroll_hint = if self.commands.len() > (height as usize - 4) {
|
||||||
|
format!(" (scroll: j/k or ↑/↓)")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(" Press ", self.theme.cmd_desc),
|
||||||
|
Span::styled("Esc", self.theme.cmd_name),
|
||||||
|
Span::styled(" to close", self.theme.cmd_desc),
|
||||||
|
Span::styled(scroll_hint, self.theme.cmd_desc),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Create the block with border
|
||||||
|
let block = Block::default()
|
||||||
|
.title(" Commands ")
|
||||||
|
.title_style(self.theme.popup_title)
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(self.theme.popup_border)
|
||||||
|
.style(self.theme.overlay_bg);
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(lines)
|
||||||
|
.block(block)
|
||||||
|
.scroll((self.scroll_offset as u16, 0));
|
||||||
|
|
||||||
|
frame.render_widget(paragraph, overlay_area);
|
||||||
|
|
||||||
|
// Render scrollbar if content exceeds visible area
|
||||||
|
let visible_height = height.saturating_sub(2) as usize; // -2 for borders
|
||||||
|
let total_lines = self.commands.len() + 3;
|
||||||
|
if total_lines > visible_height {
|
||||||
|
let scrollbar = Scrollbar::default()
|
||||||
|
.orientation(ScrollbarOrientation::VerticalRight)
|
||||||
|
.begin_symbol(None)
|
||||||
|
.end_symbol(None)
|
||||||
|
.track_symbol(Some(" "))
|
||||||
|
.thumb_symbol("│")
|
||||||
|
.style(self.theme.status_dim);
|
||||||
|
|
||||||
|
let mut scrollbar_state = ScrollbarState::default()
|
||||||
|
.content_length(total_lines)
|
||||||
|
.position(self.scroll_offset);
|
||||||
|
|
||||||
|
// Adjust scrollbar area to be inside the border
|
||||||
|
let scrollbar_area = Rect::new(
|
||||||
|
overlay_area.x + overlay_area.width - 2,
|
||||||
|
overlay_area.y + 1,
|
||||||
|
1,
|
||||||
|
overlay_area.height.saturating_sub(2),
|
||||||
|
);
|
||||||
|
|
||||||
|
frame.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_help_visibility() {
|
||||||
|
let theme = Theme::default();
|
||||||
|
let mut help = CommandHelp::new(theme);
|
||||||
|
|
||||||
|
assert!(!help.is_visible());
|
||||||
|
help.show();
|
||||||
|
assert!(help.is_visible());
|
||||||
|
help.hide();
|
||||||
|
assert!(!help.is_visible());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_builtin_commands() {
|
||||||
|
let commands = builtin_commands();
|
||||||
|
assert!(!commands.is_empty());
|
||||||
|
assert!(commands.iter().any(|c| c.name == "help"));
|
||||||
|
assert!(commands.iter().any(|c| c.name == "provider"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scroll_navigation() {
|
||||||
|
let theme = Theme::default();
|
||||||
|
let mut help = CommandHelp::new(theme);
|
||||||
|
help.show();
|
||||||
|
|
||||||
|
assert_eq!(help.scroll_offset, 0);
|
||||||
|
help.scroll_down(3, 10);
|
||||||
|
assert_eq!(help.scroll_offset, 3);
|
||||||
|
help.scroll_up(1);
|
||||||
|
assert_eq!(help.scroll_offset, 2);
|
||||||
|
help.scroll_up(10); // Should clamp to 0
|
||||||
|
assert_eq!(help.scroll_offset, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,19 @@
|
|||||||
//! TUI components for the borderless multi-provider design
|
//! 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;
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
200
crates/app/ui/src/components/todo_panel.rs
Normal file
200
crates/app/ui/src/components/todo_panel.rs
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
//! Todo panel component for displaying task list
|
||||||
|
//!
|
||||||
|
//! Shows the current todo list with status indicators and progress.
|
||||||
|
|
||||||
|
use ratatui::{
|
||||||
|
layout::Rect,
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
use tools_todo::{Todo, TodoList, TodoStatus};
|
||||||
|
|
||||||
|
use crate::theme::Theme;
|
||||||
|
|
||||||
|
/// Todo panel component
|
||||||
|
pub struct TodoPanel {
|
||||||
|
theme: Theme,
|
||||||
|
collapsed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TodoPanel {
|
||||||
|
pub fn new(theme: Theme) -> Self {
|
||||||
|
Self {
|
||||||
|
theme,
|
||||||
|
collapsed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle collapsed state
|
||||||
|
pub fn toggle(&mut self) {
|
||||||
|
self.collapsed = !self.collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if collapsed
|
||||||
|
pub fn is_collapsed(&self) -> bool {
|
||||||
|
self.collapsed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update theme
|
||||||
|
pub fn set_theme(&mut self, theme: Theme) {
|
||||||
|
self.theme = theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the minimum height needed for the panel
|
||||||
|
pub fn min_height(&self) -> u16 {
|
||||||
|
if self.collapsed {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the todo panel
|
||||||
|
pub fn render(&self, frame: &mut Frame, area: Rect, todos: &TodoList) {
|
||||||
|
if self.collapsed {
|
||||||
|
self.render_collapsed(frame, area, todos);
|
||||||
|
} else {
|
||||||
|
self.render_expanded(frame, area, todos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render collapsed view (single line summary)
|
||||||
|
fn render_collapsed(&self, frame: &mut Frame, area: Rect, todos: &TodoList) {
|
||||||
|
let items = todos.read();
|
||||||
|
let completed = items.iter().filter(|t| t.status == TodoStatus::Completed).count();
|
||||||
|
let in_progress = items.iter().filter(|t| t.status == TodoStatus::InProgress).count();
|
||||||
|
let pending = items.iter().filter(|t| t.status == TodoStatus::Pending).count();
|
||||||
|
|
||||||
|
let summary = if items.is_empty() {
|
||||||
|
"No tasks".to_string()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{} {} / {} {} / {} {}",
|
||||||
|
self.theme.symbols.check, completed,
|
||||||
|
self.theme.symbols.streaming, in_progress,
|
||||||
|
self.theme.symbols.bullet, pending
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let line = Line::from(vec![
|
||||||
|
Span::styled("Tasks: ", self.theme.status_bar),
|
||||||
|
Span::styled(summary, self.theme.status_dim),
|
||||||
|
Span::styled(" [t to expand]", self.theme.status_dim),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(line);
|
||||||
|
frame.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render expanded view with task list
|
||||||
|
fn render_expanded(&self, frame: &mut Frame, area: Rect, todos: &TodoList) {
|
||||||
|
let items = todos.read();
|
||||||
|
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
|
||||||
|
// Header
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled("Tasks", Style::default().add_modifier(Modifier::BOLD)),
|
||||||
|
Span::styled(" [t to collapse]", self.theme.status_dim),
|
||||||
|
]));
|
||||||
|
|
||||||
|
if items.is_empty() {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
" No active tasks",
|
||||||
|
self.theme.status_dim,
|
||||||
|
)));
|
||||||
|
} else {
|
||||||
|
// Show tasks (limit to available space)
|
||||||
|
let max_items = (area.height as usize).saturating_sub(2);
|
||||||
|
let display_items: Vec<&Todo> = items.iter().take(max_items).collect();
|
||||||
|
|
||||||
|
for item in display_items {
|
||||||
|
let (icon, style) = match item.status {
|
||||||
|
TodoStatus::Completed => (
|
||||||
|
self.theme.symbols.check,
|
||||||
|
Style::default().fg(Color::Green),
|
||||||
|
),
|
||||||
|
TodoStatus::InProgress => (
|
||||||
|
self.theme.symbols.streaming,
|
||||||
|
Style::default().fg(Color::Yellow),
|
||||||
|
),
|
||||||
|
TodoStatus::Pending => (
|
||||||
|
self.theme.symbols.bullet,
|
||||||
|
self.theme.status_dim,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use active form for in-progress, content for others
|
||||||
|
let text = if item.status == TodoStatus::InProgress {
|
||||||
|
&item.active_form
|
||||||
|
} else {
|
||||||
|
&item.content
|
||||||
|
};
|
||||||
|
|
||||||
|
// Truncate if too long
|
||||||
|
let max_width = area.width.saturating_sub(6) as usize;
|
||||||
|
let display_text = if text.len() > max_width {
|
||||||
|
format!("{}...", &text[..max_width.saturating_sub(3)])
|
||||||
|
} else {
|
||||||
|
text.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(format!(" {} ", icon), style),
|
||||||
|
Span::styled(display_text, style),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show overflow indicator if needed
|
||||||
|
if items.len() > max_items {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!(" ... and {} more", items.len() - max_items),
|
||||||
|
self.theme.status_dim,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::TOP)
|
||||||
|
.border_style(self.theme.status_dim);
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(lines).block(block);
|
||||||
|
frame.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_todo_panel_creation() {
|
||||||
|
let theme = Theme::default();
|
||||||
|
let panel = TodoPanel::new(theme);
|
||||||
|
assert!(!panel.is_collapsed());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_todo_panel_toggle() {
|
||||||
|
let theme = Theme::default();
|
||||||
|
let mut panel = TodoPanel::new(theme);
|
||||||
|
|
||||||
|
assert!(!panel.is_collapsed());
|
||||||
|
panel.toggle();
|
||||||
|
assert!(panel.is_collapsed());
|
||||||
|
panel.toggle();
|
||||||
|
assert!(!panel.is_collapsed());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_min_height() {
|
||||||
|
let theme = Theme::default();
|
||||||
|
let mut panel = TodoPanel::new(theme);
|
||||||
|
|
||||||
|
assert_eq!(panel.min_height(), 5);
|
||||||
|
panel.toggle();
|
||||||
|
assert_eq!(panel.min_height(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,14 @@ pub enum AppEvent {
|
|||||||
Input(KeyEvent),
|
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)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
388
crates/app/ui/src/output.rs
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
//! Rich command output formatting
|
||||||
|
//!
|
||||||
|
//! Provides formatted output for commands like /help, /mcp, /hooks
|
||||||
|
//! with tables, trees, and syntax highlighting.
|
||||||
|
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
|
||||||
|
use crate::completions::CommandInfo;
|
||||||
|
use crate::theme::Theme;
|
||||||
|
|
||||||
|
/// A tree node for hierarchical display
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TreeNode {
|
||||||
|
pub label: String,
|
||||||
|
pub children: Vec<TreeNode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TreeNode {
|
||||||
|
pub fn new(label: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
label: label.into(),
|
||||||
|
children: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_children(mut self, children: Vec<TreeNode>) -> Self {
|
||||||
|
self.children = children;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A list item with optional icon/marker
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ListItem {
|
||||||
|
pub text: String,
|
||||||
|
pub marker: Option<String>,
|
||||||
|
pub style: Option<Style>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Different output formats
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum OutputFormat {
|
||||||
|
/// Formatted table with headers and rows
|
||||||
|
Table {
|
||||||
|
headers: Vec<String>,
|
||||||
|
rows: Vec<Vec<String>>,
|
||||||
|
},
|
||||||
|
/// Hierarchical tree view
|
||||||
|
Tree {
|
||||||
|
root: TreeNode,
|
||||||
|
},
|
||||||
|
/// Syntax-highlighted code block
|
||||||
|
Code {
|
||||||
|
language: String,
|
||||||
|
content: String,
|
||||||
|
},
|
||||||
|
/// Side-by-side diff view
|
||||||
|
Diff {
|
||||||
|
old: String,
|
||||||
|
new: String,
|
||||||
|
},
|
||||||
|
/// Simple list with markers
|
||||||
|
List {
|
||||||
|
items: Vec<ListItem>,
|
||||||
|
},
|
||||||
|
/// Plain text
|
||||||
|
Text {
|
||||||
|
content: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rich command output renderer
|
||||||
|
pub struct CommandOutput {
|
||||||
|
pub format: OutputFormat,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandOutput {
|
||||||
|
pub fn new(format: OutputFormat) -> Self {
|
||||||
|
Self { format }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a help table output
|
||||||
|
pub fn help_table(commands: &[CommandInfo]) -> Self {
|
||||||
|
let headers = vec![
|
||||||
|
"Command".to_string(),
|
||||||
|
"Description".to_string(),
|
||||||
|
"Source".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let rows: Vec<Vec<String>> = commands
|
||||||
|
.iter()
|
||||||
|
.map(|c| vec![
|
||||||
|
format!("/{}", c.name),
|
||||||
|
c.description.clone(),
|
||||||
|
c.source.clone(),
|
||||||
|
])
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
format: OutputFormat::Table { headers, rows },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an MCP servers tree view
|
||||||
|
pub fn mcp_tree(servers: &[(String, Vec<String>)]) -> Self {
|
||||||
|
let children: Vec<TreeNode> = servers
|
||||||
|
.iter()
|
||||||
|
.map(|(name, tools)| {
|
||||||
|
TreeNode {
|
||||||
|
label: name.clone(),
|
||||||
|
children: tools.iter().map(|t| TreeNode::new(t)).collect(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
format: OutputFormat::Tree {
|
||||||
|
root: TreeNode {
|
||||||
|
label: "MCP Servers".to_string(),
|
||||||
|
children,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a hooks list output
|
||||||
|
pub fn hooks_list(hooks: &[(String, String, bool)]) -> Self {
|
||||||
|
let items: Vec<ListItem> = hooks
|
||||||
|
.iter()
|
||||||
|
.map(|(event, path, enabled)| {
|
||||||
|
let marker = if *enabled { "✓" } else { "✗" };
|
||||||
|
let style = if *enabled {
|
||||||
|
Some(Style::default().fg(Color::Green))
|
||||||
|
} else {
|
||||||
|
Some(Style::default().fg(Color::Red))
|
||||||
|
};
|
||||||
|
ListItem {
|
||||||
|
text: format!("{}: {}", event, path),
|
||||||
|
marker: Some(marker.to_string()),
|
||||||
|
style,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
format: OutputFormat::List { items },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render to TUI Lines
|
||||||
|
pub fn render(&self, theme: &Theme) -> Vec<Line<'static>> {
|
||||||
|
match &self.format {
|
||||||
|
OutputFormat::Table { headers, rows } => {
|
||||||
|
self.render_table(headers, rows, theme)
|
||||||
|
}
|
||||||
|
OutputFormat::Tree { root } => {
|
||||||
|
self.render_tree(root, 0, theme)
|
||||||
|
}
|
||||||
|
OutputFormat::List { items } => {
|
||||||
|
self.render_list(items, theme)
|
||||||
|
}
|
||||||
|
OutputFormat::Code { content, .. } => {
|
||||||
|
content.lines()
|
||||||
|
.map(|line| Line::from(Span::styled(line.to_string(), theme.tool_call)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
OutputFormat::Diff { old, new } => {
|
||||||
|
self.render_diff(old, new, theme)
|
||||||
|
}
|
||||||
|
OutputFormat::Text { content } => {
|
||||||
|
content.lines()
|
||||||
|
.map(|line| Line::from(line.to_string()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_table(&self, headers: &[String], rows: &[Vec<String>], theme: &Theme) -> Vec<Line<'static>> {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
// Calculate column widths
|
||||||
|
let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
|
||||||
|
for row in rows {
|
||||||
|
for (i, cell) in row.iter().enumerate() {
|
||||||
|
if i < widths.len() {
|
||||||
|
widths[i] = widths[i].max(cell.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header line
|
||||||
|
let header_spans: Vec<Span> = headers
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.flat_map(|(i, h)| {
|
||||||
|
let padded = format!("{:width$}", h, width = widths.get(i).copied().unwrap_or(h.len()));
|
||||||
|
vec![
|
||||||
|
Span::styled(padded, Style::default().add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw(" "),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
lines.push(Line::from(header_spans));
|
||||||
|
|
||||||
|
// Separator
|
||||||
|
let sep: String = widths.iter().map(|w| "─".repeat(*w)).collect::<Vec<_>>().join("──");
|
||||||
|
lines.push(Line::from(Span::styled(sep, theme.status_dim)));
|
||||||
|
|
||||||
|
// Rows
|
||||||
|
for row in rows {
|
||||||
|
let row_spans: Vec<Span> = row
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.flat_map(|(i, cell)| {
|
||||||
|
let padded = format!("{:width$}", cell, width = widths.get(i).copied().unwrap_or(cell.len()));
|
||||||
|
let style = if i == 0 {
|
||||||
|
theme.status_accent // Command names in accent color
|
||||||
|
} else {
|
||||||
|
theme.status_bar
|
||||||
|
};
|
||||||
|
vec![
|
||||||
|
Span::styled(padded, style),
|
||||||
|
Span::raw(" "),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
lines.push(Line::from(row_spans));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_tree(&self, node: &TreeNode, depth: usize, theme: &Theme) -> Vec<Line<'static>> {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
// Render current node
|
||||||
|
let prefix = if depth == 0 {
|
||||||
|
"".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}├─ ", "│ ".repeat(depth - 1))
|
||||||
|
};
|
||||||
|
|
||||||
|
let style = if depth == 0 {
|
||||||
|
Style::default().add_modifier(Modifier::BOLD)
|
||||||
|
} else if node.children.is_empty() {
|
||||||
|
theme.status_bar
|
||||||
|
} else {
|
||||||
|
theme.status_accent
|
||||||
|
};
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(prefix, theme.status_dim),
|
||||||
|
Span::styled(node.label.clone(), style),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Render children
|
||||||
|
for child in &node.children {
|
||||||
|
lines.extend(self.render_tree(child, depth + 1, theme));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_list(&self, items: &[ListItem], theme: &Theme) -> Vec<Line<'static>> {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.map(|item| {
|
||||||
|
let marker_span = if let Some(marker) = &item.marker {
|
||||||
|
Span::styled(
|
||||||
|
format!("{} ", marker),
|
||||||
|
item.style.unwrap_or(theme.status_bar),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Span::raw("• ")
|
||||||
|
};
|
||||||
|
|
||||||
|
Line::from(vec![
|
||||||
|
marker_span,
|
||||||
|
Span::styled(
|
||||||
|
item.text.clone(),
|
||||||
|
item.style.unwrap_or(theme.status_bar),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_diff(&self, old: &str, new: &str, _theme: &Theme) -> Vec<Line<'static>> {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
// Simple line-by-line diff
|
||||||
|
let old_lines: Vec<&str> = old.lines().collect();
|
||||||
|
let new_lines: Vec<&str> = new.lines().collect();
|
||||||
|
|
||||||
|
let max_len = old_lines.len().max(new_lines.len());
|
||||||
|
|
||||||
|
for i in 0..max_len {
|
||||||
|
let old_line = old_lines.get(i).copied().unwrap_or("");
|
||||||
|
let new_line = new_lines.get(i).copied().unwrap_or("");
|
||||||
|
|
||||||
|
if old_line != new_line {
|
||||||
|
if !old_line.is_empty() {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!("- {}", old_line),
|
||||||
|
Style::default().fg(Color::Red),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !new_line.is_empty() {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!("+ {}", new_line),
|
||||||
|
Style::default().fg(Color::Green),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push(Line::from(format!(" {}", old_line)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_help_table() {
|
||||||
|
let commands = vec![
|
||||||
|
CommandInfo::new("help", "Show help", "builtin"),
|
||||||
|
CommandInfo::new("clear", "Clear screen", "builtin"),
|
||||||
|
];
|
||||||
|
let output = CommandOutput::help_table(&commands);
|
||||||
|
|
||||||
|
match output.format {
|
||||||
|
OutputFormat::Table { headers, rows } => {
|
||||||
|
assert_eq!(headers.len(), 3);
|
||||||
|
assert_eq!(rows.len(), 2);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Table format"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mcp_tree() {
|
||||||
|
let servers = vec![
|
||||||
|
("filesystem".to_string(), vec!["read".to_string(), "write".to_string()]),
|
||||||
|
("database".to_string(), vec!["query".to_string()]),
|
||||||
|
];
|
||||||
|
let output = CommandOutput::mcp_tree(&servers);
|
||||||
|
|
||||||
|
match output.format {
|
||||||
|
OutputFormat::Tree { root } => {
|
||||||
|
assert_eq!(root.label, "MCP Servers");
|
||||||
|
assert_eq!(root.children.len(), 2);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Tree format"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hooks_list() {
|
||||||
|
let hooks = vec![
|
||||||
|
("PreToolUse".to_string(), "./hooks/pre".to_string(), true),
|
||||||
|
("PostToolUse".to_string(), "./hooks/post".to_string(), false),
|
||||||
|
];
|
||||||
|
let output = CommandOutput::hooks_list(&hooks);
|
||||||
|
|
||||||
|
match output.format {
|
||||||
|
OutputFormat::List { items } => {
|
||||||
|
assert_eq!(items.len(), 2);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected List format"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tree_node() {
|
||||||
|
let node = TreeNode::new("root")
|
||||||
|
.with_children(vec![
|
||||||
|
TreeNode::new("child1"),
|
||||||
|
TreeNode::new("child2"),
|
||||||
|
]);
|
||||||
|
assert_eq!(node.label, "root");
|
||||||
|
assert_eq!(node.children.len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,6 +141,14 @@ impl Symbols {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Modern color palette inspired by contemporary design systems
|
/// 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),
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
218
crates/core/agent/src/compact.rs
Normal file
218
crates/core/agent/src/compact.rs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
crates/tools/plan/Cargo.toml
Normal file
18
crates/tools/plan/Cargo.toml
Normal 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"] }
|
||||||
296
crates/tools/plan/src/lib.rs
Normal file
296
crates/tools/plan/src/lib.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
16
crates/tools/skill/Cargo.toml
Normal file
16
crates/tools/skill/Cargo.toml
Normal 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"
|
||||||
275
crates/tools/skill/src/lib.rs
Normal file
275
crates/tools/skill/src/lib.rs
Normal 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(¶ms.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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user