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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user