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

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

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

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

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

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

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

View File

@@ -22,6 +22,69 @@ pub enum Tool {
AskUserQuestion,
BashOutput,
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)]
@@ -134,6 +197,11 @@ impl PermissionManager {
}
// User interaction and session state tools allowed
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
_ => PermissionDecision::Ask,
},
@@ -150,6 +218,8 @@ impl PermissionManager {
Tool::BashOutput | Tool::KillShell => PermissionDecision::Ask,
// Utility tools allowed
Tool::TodoWrite | Tool::SlashCommand | Tool::Task | Tool::AskUserQuestion => PermissionDecision::Allow,
// Planning mode tools allowed
Tool::EnterPlanMode | Tool::ExitPlanMode | Tool::Skill => PermissionDecision::Allow,
},
Mode::Code => {
// Everything allowed in code mode
@@ -165,6 +235,41 @@ impl PermissionManager {
pub fn 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)]
@@ -247,4 +352,78 @@ mod tests {
assert!(rule.matches(Tool::Mcp, Some("filesystem__read_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);
}
}