| 1 | //! Command palette modal for quick command/skill insertion. |
| 2 | |
| 3 | use std::path::Path; |
| 4 | |
| 5 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; |
| 6 | use ratatui::{ |
| 7 | buffer::Buffer, |
| 8 | layout::Rect, |
| 9 | prelude::Stylize, |
| 10 | style::{Modifier, Style}, |
| 11 | text::{Line, Span}, |
| 12 | widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap}, |
| 13 | }; |
| 14 | use unicode_width::UnicodeWidthStr; |
| 15 | |
| 16 | use crate::commands; |
| 17 | use crate::localization::Locale; |
| 18 | use crate::palette; |
| 19 | use crate::skills::SkillRegistry; |
| 20 | use crate::tools::spec::ApprovalRequirement; |
| 21 | use crate::tools::spec::ToolCapability; |
| 22 | use crate::tools::{ToolContext, ToolRegistryBuilder}; |
| 23 | use crate::tui::views::{CommandPaletteAction, ModalKind, ModalView, ViewAction, ViewEvent}; |
| 24 | |
| 25 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] |
| 26 | enum PaletteSection { |
| 27 | Command, |
| 28 | Skill, |
| 29 | Tool, |
| 30 | Mcp, |
| 31 | } |
| 32 | |
| 33 | #[derive(Debug, Clone)] |
| 34 | pub struct CommandPaletteEntry { |
| 35 | section: PaletteSection, |
| 36 | pub label: String, |
| 37 | pub description: String, |
| 38 | pub command: String, |
| 39 | pub action: CommandPaletteAction, |
| 40 | } |
| 41 | |
| 42 | pub struct CommandPaletteView { |
| 43 | entries: Vec<CommandPaletteEntry>, |
| 44 | filtered: Vec<usize>, |
| 45 | query: String, |
| 46 | selected: usize, |
| 47 | } |
| 48 | |
| 49 | pub fn build_entries( |
| 50 | locale: Locale, |
| 51 | skills_dir: &Path, |
| 52 | workspace: &Path, |
| 53 | mcp_config_path: &Path, |
| 54 | mcp_snapshot: Option<&crate::mcp::McpManagerSnapshot>, |
| 55 | ) -> Vec<CommandPaletteEntry> { |
| 56 | let mut entries = Vec::new(); |
| 57 | |
| 58 | for command in commands::COMMANDS { |
| 59 | let mut description = command.palette_description_for(locale); |
| 60 | if command.requires_argument() { |
| 61 | description.push_str(" "); |
| 62 | description.push_str(command.usage); |
| 63 | } |
| 64 | let action = if command_runs_directly(command.name) { |
| 65 | CommandPaletteAction::ExecuteCommand { |
| 66 | command: format!("/{}", command.name), |
| 67 | } |
| 68 | } else { |
| 69 | CommandPaletteAction::InsertText { |
| 70 | text: command.palette_command(), |
| 71 | } |
| 72 | }; |
| 73 | entries.push(CommandPaletteEntry { |
| 74 | section: PaletteSection::Command, |
| 75 | label: format!("/{}", command.name), |
| 76 | description, |
| 77 | command: command.palette_command(), |
| 78 | action, |
| 79 | }); |
| 80 | } |
| 81 | |
| 82 | let skills = SkillRegistry::discover(skills_dir); |
| 83 | for skill in skills.list() { |
| 84 | entries.push(CommandPaletteEntry { |
| 85 | section: PaletteSection::Skill, |
| 86 | label: format!("skill:{}", skill.name), |
| 87 | description: skill.description.clone(), |
| 88 | command: format!("/skill {}", skill.name), |
| 89 | action: CommandPaletteAction::ExecuteCommand { |
| 90 | command: format!("/skill {}", skill.name), |
| 91 | }, |
| 92 | }); |
| 93 | } |
| 94 | |
| 95 | let context = ToolContext::new(workspace); |
| 96 | let registry = ToolRegistryBuilder::new() |
| 97 | .with_file_tools() |
| 98 | .with_search_tools() |
| 99 | .with_shell_tools() |
| 100 | .with_web_tools() |
| 101 | .with_git_tools() |
| 102 | .with_user_input_tool() |
| 103 | .with_parallel_tool() |
| 104 | .with_patch_tools() |
| 105 | .with_note_tool() |
| 106 | .with_diagnostics_tool() |
| 107 | .with_project_tools() |
| 108 | .with_test_runner_tool() |
| 109 | .build(context); |
| 110 | |
| 111 | let mut tool_entries = registry |
| 112 | .all() |
| 113 | .into_iter() |
| 114 | .filter_map(|tool| { |
| 115 | let name = tool.name().to_string(); |
| 116 | let capabilities = tool.capabilities(); |
| 117 | |
| 118 | let mut tags = Vec::new(); |
| 119 | if tool.is_read_only() { |
| 120 | tags.push("read-only"); |
| 121 | } |
| 122 | if capabilities.contains(&ToolCapability::WritesFiles) { |
| 123 | tags.push("writes"); |
| 124 | } |
| 125 | if capabilities.contains(&ToolCapability::ExecutesCode) { |
| 126 | tags.push("shell"); |
| 127 | } |
| 128 | if capabilities.contains(&ToolCapability::Network) { |
| 129 | tags.push("network"); |
| 130 | } |
| 131 | if tool.supports_parallel() { |
| 132 | tags.push("parallel"); |
| 133 | } |
| 134 | match tool.approval_requirement() { |
| 135 | ApprovalRequirement::Required => tags.push("requires approval"), |
| 136 | ApprovalRequirement::Suggest => tags.push("suggest approval"), |
| 137 | ApprovalRequirement::Auto => {} |
| 138 | } |
| 139 | |
| 140 | let mut description = tool.description().to_string(); |
| 141 | if !tags.is_empty() { |
| 142 | description.push_str(" ["); |
| 143 | description.push_str(&tags.join(", ")); |
| 144 | description.push(']'); |
| 145 | } |
| 146 | |
| 147 | if name.trim().is_empty() { |
| 148 | return None; |
| 149 | } |
| 150 | Some(CommandPaletteEntry { |
| 151 | section: PaletteSection::Tool, |
| 152 | label: format!("tool:{name}"), |
| 153 | description: description.clone(), |
| 154 | command: name, |
| 155 | action: CommandPaletteAction::OpenTextPager { |
| 156 | title: format!("Tool: {}", tool.name()), |
| 157 | content: format_tool_details(tool.name(), tool.description(), &tags), |
| 158 | }, |
| 159 | }) |
| 160 | }) |
| 161 | .collect::<Vec<_>>(); |
| 162 | tool_entries.sort_by(|a, b| a.label.cmp(&b.label)); |
| 163 | entries.extend(tool_entries); |
| 164 | |
| 165 | entries.extend(build_mcp_entries(mcp_config_path, mcp_snapshot)); |
| 166 | |
| 167 | entries.sort_by(|a, b| a.label.cmp(&b.label)); |
| 168 | entries.sort_by_key(|entry| entry.section); |
| 169 | entries |
| 170 | } |
| 171 | |
| 172 | fn build_mcp_entries( |
| 173 | mcp_config_path: &Path, |
| 174 | mcp_snapshot: Option<&crate::mcp::McpManagerSnapshot>, |
| 175 | ) -> Vec<CommandPaletteEntry> { |
| 176 | let owned_snapshot = if mcp_snapshot.is_none() { |
| 177 | crate::mcp::manager_snapshot_from_config(mcp_config_path, false).ok() |
| 178 | } else { |
| 179 | None |
| 180 | }; |
| 181 | let snapshot = mcp_snapshot.or(owned_snapshot.as_ref()); |
| 182 | let mut entries = vec![CommandPaletteEntry { |
| 183 | section: PaletteSection::Mcp, |
| 184 | label: "mcp:manager".to_string(), |
| 185 | description: format!("Open MCP manager ({})", mcp_config_path.display()), |
| 186 | command: "/mcp".to_string(), |
| 187 | action: CommandPaletteAction::ExecuteCommand { |
| 188 | command: "/mcp".to_string(), |
| 189 | }, |
| 190 | }]; |
| 191 | |
| 192 | let Some(snapshot) = snapshot else { |
| 193 | return entries; |
| 194 | }; |
| 195 | |
| 196 | for server in &snapshot.servers { |
| 197 | let state = if server.enabled { |
| 198 | if server.connected { |
| 199 | "connected" |
| 200 | } else if server.error.is_some() { |
| 201 | "failed" |
| 202 | } else { |
| 203 | "enabled" |
| 204 | } |
| 205 | } else { |
| 206 | "disabled" |
| 207 | }; |
| 208 | entries.push(CommandPaletteEntry { |
| 209 | section: PaletteSection::Mcp, |
| 210 | label: format!("mcp:{}", server.name), |
| 211 | description: format!( |
| 212 | "{} {} [{}] tools={} resources={} prompts={}", |
| 213 | server.transport, |
| 214 | server.command_or_url, |
| 215 | state, |
| 216 | server.tools.len(), |
| 217 | server.resources.len(), |
| 218 | server.prompts.len() |
| 219 | ), |
| 220 | command: format!("/mcp show {}", server.name), |
| 221 | action: CommandPaletteAction::OpenTextPager { |
| 222 | title: format!("MCP Server: {}", server.name), |
| 223 | content: format_mcp_server_details(snapshot, server), |
| 224 | }, |
| 225 | }); |
| 226 | |
| 227 | for tool in &server.tools { |
| 228 | entries.push(CommandPaletteEntry { |
| 229 | section: PaletteSection::Mcp, |
| 230 | label: format!("mcp:{}:tool:{}", server.name, tool.name), |
| 231 | description: format!( |
| 232 | "{}{}", |
| 233 | tool.model_name, |
| 234 | tool.description |
| 235 | .as_ref() |
| 236 | .map_or(String::new(), |desc| format!(" - {desc}")) |
| 237 | ), |
| 238 | command: tool.model_name.clone(), |
| 239 | action: CommandPaletteAction::OpenTextPager { |
| 240 | title: format!("MCP Tool: {}", tool.model_name), |
| 241 | content: format!( |
| 242 | "Server: {}\nRuntime name: {}\nKind: tool\n\n{}", |
| 243 | server.name, |
| 244 | tool.model_name, |
| 245 | tool.description.as_deref().unwrap_or("(no description)") |
| 246 | ), |
| 247 | }, |
| 248 | }); |
| 249 | // Add a "use" entry that inserts the tool's model_name into the input |
| 250 | // so users can quickly reference the tool in their message to the AI. |
| 251 | if !tool.model_name.trim().is_empty() { |
| 252 | entries.push(CommandPaletteEntry { |
| 253 | section: PaletteSection::Mcp, |
| 254 | label: format!("mcp:{}:tool:{} > use", server.name, tool.name), |
| 255 | description: format!( |
| 256 | "Insert {} into input — type args then send{}", |
| 257 | tool.model_name, |
| 258 | tool.description |
| 259 | .as_ref() |
| 260 | .map_or(String::new(), |desc| format!(" ({})", desc)) |
| 261 | ), |
| 262 | command: tool.model_name.clone(), |
| 263 | action: CommandPaletteAction::InsertText { |
| 264 | text: tool.model_name.clone(), |
| 265 | }, |
| 266 | }); |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | for resource in &server.resources { |
| 271 | entries.push(CommandPaletteEntry { |
| 272 | section: PaletteSection::Mcp, |
| 273 | label: format!("mcp:{}:resource:{}", server.name, resource.name), |
| 274 | description: resource |
| 275 | .description |
| 276 | .clone() |
| 277 | .unwrap_or_else(|| "MCP resource".to_string()), |
| 278 | command: resource.name.clone(), |
| 279 | action: CommandPaletteAction::OpenTextPager { |
| 280 | title: format!("MCP Resource: {}", resource.name), |
| 281 | content: format!( |
| 282 | "Server: {}\nResource: {}\nModel helper: list_mcp_resources / read_mcp_resource", |
| 283 | server.name, resource.name |
| 284 | ), |
| 285 | }, |
| 286 | }); |
| 287 | } |
| 288 | |
| 289 | for prompt in &server.prompts { |
| 290 | entries.push(CommandPaletteEntry { |
| 291 | section: PaletteSection::Mcp, |
| 292 | label: format!("mcp:{}:prompt:{}", server.name, prompt.name), |
| 293 | description: format!( |
| 294 | "{}{}", |
| 295 | prompt.model_name, |
| 296 | prompt |
| 297 | .description |
| 298 | .as_ref() |
| 299 | .map_or(String::new(), |desc| format!(" - {desc}")) |
| 300 | ), |
| 301 | command: prompt.model_name.clone(), |
| 302 | action: CommandPaletteAction::OpenTextPager { |
| 303 | title: format!("MCP Prompt: {}", prompt.model_name), |
| 304 | content: format!( |
| 305 | "Server: {}\nRuntime name: {}\nKind: prompt", |
| 306 | server.name, prompt.model_name |
| 307 | ), |
| 308 | }, |
| 309 | }); |
| 310 | } |
| 311 | } |
| 312 | |
| 313 | entries |
| 314 | } |
| 315 | |
| 316 | fn format_mcp_server_details( |
| 317 | snapshot: &crate::mcp::McpManagerSnapshot, |
| 318 | server: &crate::mcp::McpServerSnapshot, |
| 319 | ) -> String { |
| 320 | let mut lines = vec![ |
| 321 | format!("Config: {}", snapshot.config_path.display()), |
| 322 | format!("Server: {}", server.name), |
| 323 | format!("Enabled: {}", server.enabled), |
| 324 | format!("Connected: {}", server.connected), |
| 325 | format!("Transport: {}", server.transport), |
| 326 | format!("Target: {}", server.command_or_url), |
| 327 | format!( |
| 328 | "Timeouts: connect={}s execute={}s read={}s", |
| 329 | server.connect_timeout, server.execute_timeout, server.read_timeout |
| 330 | ), |
| 331 | ]; |
| 332 | if let Some(error) = server.error.as_ref() { |
| 333 | lines.push(format!("Error: {error}")); |
| 334 | } |
| 335 | lines.push(String::new()); |
| 336 | lines.push(format!("Tools ({})", server.tools.len())); |
| 337 | for tool in &server.tools { |
| 338 | lines.push(format!(" - {}", tool.model_name)); |
| 339 | } |
| 340 | lines.push(format!("Resources ({})", server.resources.len())); |
| 341 | for resource in &server.resources { |
| 342 | lines.push(format!(" - {}", resource.name)); |
| 343 | } |
| 344 | lines.push(format!("Prompts ({})", server.prompts.len())); |
| 345 | for prompt in &server.prompts { |
| 346 | lines.push(format!(" - {}", prompt.model_name)); |
| 347 | } |
| 348 | lines.join("\n") |
| 349 | } |
| 350 | |
| 351 | fn modal_block() -> Block<'static> { |
| 352 | Block::default() |
| 353 | .borders(Borders::ALL) |
| 354 | .border_style(Style::default().fg(palette::BORDER_COLOR)) |
| 355 | .padding(Padding::uniform(1)) |
| 356 | } |
| 357 | |
| 358 | fn parse_section_term(term: &str) -> Option<(PaletteSection, String)> { |
| 359 | let (section, query) = term.split_once(':')?; |
| 360 | |
| 361 | if section.is_empty() || query.is_empty() { |
| 362 | return None; |
| 363 | } |
| 364 | |
| 365 | let query = query.to_ascii_lowercase(); |
| 366 | let section = match section { |
| 367 | "c" | "cmd" | "command" | "commands" => PaletteSection::Command, |
| 368 | "s" | "skill" | "skills" => PaletteSection::Skill, |
| 369 | "t" | "tool" | "tools" => PaletteSection::Tool, |
| 370 | "m" | "mcp" => PaletteSection::Mcp, |
| 371 | _ => return None, |
| 372 | }; |
| 373 | |
| 374 | Some((section, query)) |
| 375 | } |
| 376 | |
| 377 | fn section_tag(section: PaletteSection) -> &'static str { |
| 378 | match section { |
| 379 | PaletteSection::Command => "command", |
| 380 | PaletteSection::Skill => "skill", |
| 381 | PaletteSection::Tool => "tool", |
| 382 | PaletteSection::Mcp => "mcp", |
| 383 | } |
| 384 | } |
| 385 | |
| 386 | fn section_rank(section: PaletteSection) -> usize { |
| 387 | match section { |
| 388 | PaletteSection::Command => 0, |
| 389 | PaletteSection::Skill => 1, |
| 390 | PaletteSection::Tool => 2, |
| 391 | PaletteSection::Mcp => 3, |
| 392 | } |
| 393 | } |
| 394 | |
| 395 | fn command_runs_directly(name: &str) -> bool { |
| 396 | matches!( |
| 397 | name, |
| 398 | "help" |
| 399 | | "clear" |
| 400 | | "exit" |
| 401 | | "models" |
| 402 | | "queue" |
| 403 | | "stash" |
| 404 | | "hooks" |
| 405 | | "subagents" |
| 406 | | "links" |
| 407 | | "home" |
| 408 | | "save" |
| 409 | | "sessions" |
| 410 | | "compact" |
| 411 | | "export" |
| 412 | | "config" |
| 413 | | "yolo" |
| 414 | | "agent" |
| 415 | | "plan" |
| 416 | | "trust" |
| 417 | | "logout" |
| 418 | | "tokens" |
| 419 | | "system" |
| 420 | | "context" |
| 421 | | "undo" |
| 422 | | "retry" |
| 423 | | "init" |
| 424 | | "settings" |
| 425 | | "skills" |
| 426 | | "cost" |
| 427 | | "jobs" |
| 428 | | "mcp" |
| 429 | | "task" |
| 430 | ) |
| 431 | } |
| 432 | |
| 433 | fn format_tool_details(name: &str, description: &str, tags: &[&str]) -> String { |
| 434 | let mut lines = vec![ |
| 435 | format!("Tool: {name}"), |
| 436 | String::new(), |
| 437 | description.to_string(), |
| 438 | ]; |
| 439 | if !tags.is_empty() { |
| 440 | lines.push(String::new()); |
| 441 | lines.push(format!("Capabilities: {}", tags.join(", "))); |
| 442 | } |
| 443 | lines.push(String::new()); |
| 444 | lines.push( |
| 445 | "Use slash commands and skills here for direct actions; use tool entries to inspect what the agent can call." |
| 446 | .to_string(), |
| 447 | ); |
| 448 | lines.join("\n") |
| 449 | } |
| 450 | |
| 451 | fn term_score(term: &str, label: &str, description: &str, command: &str, haystack: &str) -> usize { |
| 452 | if term.is_empty() { |
| 453 | return 0; |
| 454 | } |
| 455 | |
| 456 | if label == term || command == term || description == term { |
| 457 | return 0; |
| 458 | } |
| 459 | |
| 460 | if label.starts_with(term) { |
| 461 | return 8; |
| 462 | } |
| 463 | |
| 464 | if command.starts_with(term) { |
| 465 | return 16; |
| 466 | } |
| 467 | |
| 468 | if description.contains(term) { |
| 469 | return 64; |
| 470 | } |
| 471 | |
| 472 | if label.contains(term) { |
| 473 | return 32; |
| 474 | } |
| 475 | |
| 476 | if command.contains(term) { |
| 477 | return 48; |
| 478 | } |
| 479 | |
| 480 | if haystack.contains(term) { |
| 481 | return 96; |
| 482 | } |
| 483 | |
| 484 | 128 |
| 485 | } |
| 486 | |
| 487 | fn entry_match_score(entry: &CommandPaletteEntry, terms: &[&str]) -> Option<usize> { |
| 488 | if terms.is_empty() { |
| 489 | return Some(0); |
| 490 | } |
| 491 | |
| 492 | let section = section_tag(entry.section); |
| 493 | let label = entry.label.to_ascii_lowercase(); |
| 494 | let description = entry.description.to_ascii_lowercase(); |
| 495 | let command = entry.command.to_ascii_lowercase(); |
| 496 | let entry_text = format!("{section} {label} {description} {command}"); |
| 497 | |
| 498 | let mut total_score = 0usize; |
| 499 | |
| 500 | for term in terms { |
| 501 | if let Some((required_section, scoped_query)) = parse_section_term(term) { |
| 502 | if entry.section != required_section { |
| 503 | return None; |
| 504 | } |
| 505 | if !entry_text.contains(&scoped_query) { |
| 506 | return None; |
| 507 | } |
| 508 | total_score += term_score(&scoped_query, &label, &description, &command, &entry_text); |
| 509 | continue; |
| 510 | } |
| 511 | |
| 512 | if !entry_text.contains(term) { |
| 513 | return None; |
| 514 | } |
| 515 | total_score += term_score(term, &label, &description, &command, &entry_text); |
| 516 | } |
| 517 | |
| 518 | Some(total_score) |
| 519 | } |
| 520 | |
| 521 | impl CommandPaletteView { |
| 522 | pub fn new(entries: Vec<CommandPaletteEntry>) -> Self { |
| 523 | let mut view = Self { |
| 524 | entries, |
| 525 | filtered: Vec::new(), |
| 526 | query: String::new(), |
| 527 | selected: 0, |
| 528 | }; |
| 529 | view.refilter(); |
| 530 | view |
| 531 | } |
| 532 | |
| 533 | fn refilter(&mut self) { |
| 534 | let query = self.query.trim().to_ascii_lowercase(); |
| 535 | let terms: Vec<&str> = query |
| 536 | .split_whitespace() |
| 537 | .filter(|term| !term.is_empty()) |
| 538 | .collect(); |
| 539 | |
| 540 | let mut filtered = self |
| 541 | .entries |
| 542 | .iter() |
| 543 | .enumerate() |
| 544 | .filter_map(|(idx, entry)| entry_match_score(entry, &terms).map(|score| (idx, score))) |
| 545 | .collect::<Vec<_>>(); |
| 546 | |
| 547 | filtered.sort_by_key(|(idx, score)| { |
| 548 | let entry = &self.entries[*idx]; |
| 549 | (section_rank(entry.section), *score, &entry.label) |
| 550 | }); |
| 551 | self.filtered = filtered.into_iter().map(|(idx, _)| idx).collect(); |
| 552 | if self.selected >= self.filtered.len() { |
| 553 | self.selected = 0; |
| 554 | } |
| 555 | } |
| 556 | |
| 557 | fn scope_hint_lines() -> Line<'static> { |
| 558 | let hint = "scope: c:/cmd: , s:/skill: , t:/tool: , m:/mcp:"; |
| 559 | Line::from(Span::styled( |
| 560 | hint, |
| 561 | Style::default() |
| 562 | .fg(palette::TEXT_DIM) |
| 563 | .add_modifier(Modifier::ITALIC), |
| 564 | )) |
| 565 | } |
| 566 | |
| 567 | fn format_section_label(section: PaletteSection, count: usize) -> Line<'static> { |
| 568 | let title = match section { |
| 569 | PaletteSection::Command => "Commands", |
| 570 | PaletteSection::Skill => "Skills", |
| 571 | PaletteSection::Tool => "Tools", |
| 572 | PaletteSection::Mcp => "MCP", |
| 573 | }; |
| 574 | Line::from(vec![Span::styled( |
| 575 | format!(" {title} ({count}) "), |
| 576 | Style::default() |
| 577 | .fg(palette::DEEPSEEK_SKY) |
| 578 | .add_modifier(Modifier::BOLD), |
| 579 | )]) |
| 580 | } |
| 581 | |
| 582 | fn scope_examples() -> Vec<Line<'static>> { |
| 583 | vec![ |
| 584 | Line::from(Span::styled("Try:", Style::default().fg(palette::TEXT_DIM))), |
| 585 | Line::from(Span::styled( |
| 586 | " c:<term> Command-only e.g. c:agent", |
| 587 | Style::default().fg(palette::TEXT_MUTED), |
| 588 | )), |
| 589 | Line::from(Span::styled( |
| 590 | " s:<term> Skill-only e.g. s:search", |
| 591 | Style::default().fg(palette::TEXT_MUTED), |
| 592 | )), |
| 593 | Line::from(Span::styled( |
| 594 | " t:<term> Tool-only e.g. t:git", |
| 595 | Style::default().fg(palette::TEXT_MUTED), |
| 596 | )), |
| 597 | Line::from(Span::styled( |
| 598 | " m:<term> MCP-only e.g. m:filesystem", |
| 599 | Style::default().fg(palette::TEXT_MUTED), |
| 600 | )), |
| 601 | ] |
| 602 | } |
| 603 | |
| 604 | fn move_selection(&mut self, delta: isize) { |
| 605 | if self.filtered.is_empty() { |
| 606 | self.selected = 0; |
| 607 | return; |
| 608 | } |
| 609 | let len = self.filtered.len() as isize; |
| 610 | let next = (self.selected as isize + delta).clamp(0, len - 1) as usize; |
| 611 | self.selected = next; |
| 612 | } |
| 613 | |
| 614 | fn selected_entry(&self) -> Option<&CommandPaletteEntry> { |
| 615 | self.filtered |
| 616 | .get(self.selected) |
| 617 | .and_then(|idx| self.entries.get(*idx)) |
| 618 | } |
| 619 | } |
| 620 | |
| 621 | impl ModalView for CommandPaletteView { |
| 622 | fn kind(&self) -> ModalKind { |
| 623 | ModalKind::CommandPalette |
| 624 | } |
| 625 | |
| 626 | fn as_any_mut(&mut self) -> &mut dyn std::any::Any { |
| 627 | self |
| 628 | } |
| 629 | |
| 630 | fn handle_key(&mut self, key: KeyEvent) -> ViewAction { |
| 631 | match key.code { |
| 632 | KeyCode::Esc => ViewAction::Close, |
| 633 | KeyCode::Enter => { |
| 634 | if let Some(entry) = self.selected_entry() { |
| 635 | ViewAction::EmitAndClose(ViewEvent::CommandPaletteSelected { |
| 636 | action: entry.action.clone(), |
| 637 | }) |
| 638 | } else { |
| 639 | ViewAction::None |
| 640 | } |
| 641 | } |
| 642 | KeyCode::Up | KeyCode::Char('k') => { |
| 643 | self.move_selection(-1); |
| 644 | ViewAction::None |
| 645 | } |
| 646 | KeyCode::Down | KeyCode::Char('j') => { |
| 647 | self.move_selection(1); |
| 648 | ViewAction::None |
| 649 | } |
| 650 | KeyCode::PageUp => { |
| 651 | self.move_selection(-8); |
| 652 | ViewAction::None |
| 653 | } |
| 654 | KeyCode::PageDown => { |
| 655 | self.move_selection(8); |
| 656 | ViewAction::None |
| 657 | } |
| 658 | KeyCode::Backspace => { |
| 659 | self.query.pop(); |
| 660 | self.refilter(); |
| 661 | ViewAction::None |
| 662 | } |
| 663 | KeyCode::Char(c) |
| 664 | if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => |
| 665 | { |
| 666 | self.query.push(c); |
| 667 | self.refilter(); |
| 668 | ViewAction::None |
| 669 | } |
| 670 | _ => ViewAction::None, |
| 671 | } |
| 672 | } |
| 673 | |
| 674 | fn render(&self, area: Rect, buf: &mut Buffer) { |
| 675 | let popup_width = 90.min(area.width.saturating_sub(4)); |
| 676 | let popup_height = 22.min(area.height.saturating_sub(4)); |
| 677 | let popup_area = Rect { |
| 678 | x: (area.width.saturating_sub(popup_width)) / 2, |
| 679 | y: (area.height.saturating_sub(popup_height)) / 2, |
| 680 | width: popup_width, |
| 681 | height: popup_height, |
| 682 | }; |
| 683 | |
| 684 | Clear.render(popup_area, buf); |
| 685 | |
| 686 | let mut lines = Vec::new(); |
| 687 | let query_label = if self.query.is_empty() { |
| 688 | "Type to filter".to_string() |
| 689 | } else { |
| 690 | format!("Filter: {}", self.query) |
| 691 | }; |
| 692 | lines.push(Line::from(Span::styled( |
| 693 | query_label, |
| 694 | Style::default().fg(palette::TEXT_MUTED), |
| 695 | ))); |
| 696 | let match_count = if self.query.is_empty() { |
| 697 | format!("{} entries", self.entries.len()) |
| 698 | } else { |
| 699 | format!("{} / {} matches", self.filtered.len(), self.entries.len()) |
| 700 | }; |
| 701 | lines.push(Line::from(Span::styled( |
| 702 | match_count, |
| 703 | Style::default().fg(palette::TEXT_DIM).italic(), |
| 704 | ))); |
| 705 | lines.push(Self::scope_hint_lines()); |
| 706 | lines.extend(Self::scope_examples()); |
| 707 | lines.push(Line::from("")); |
| 708 | |
| 709 | let visible = popup_height.saturating_sub(7) as usize; |
| 710 | let mut command_count = 0usize; |
| 711 | let mut skill_count = 0usize; |
| 712 | let mut tool_count = 0usize; |
| 713 | let mut mcp_count = 0usize; |
| 714 | for idx in &self.filtered { |
| 715 | match self.entries[*idx].section { |
| 716 | PaletteSection::Command => command_count += 1, |
| 717 | PaletteSection::Skill => skill_count += 1, |
| 718 | PaletteSection::Tool => tool_count += 1, |
| 719 | PaletteSection::Mcp => mcp_count += 1, |
| 720 | } |
| 721 | } |
| 722 | if self.filtered.is_empty() { |
| 723 | lines.push(Line::from(Span::styled( |
| 724 | "No matches.", |
| 725 | Style::default().fg(palette::TEXT_MUTED).italic(), |
| 726 | ))); |
| 727 | } else { |
| 728 | let label_width = 24.min(popup_width.saturating_sub(26) as usize); |
| 729 | let start = self.selected.saturating_sub(visible.saturating_sub(1)); |
| 730 | let end = (start + visible).min(self.filtered.len()); |
| 731 | let mut active_section = None; |
| 732 | for (slot, idx) in self.filtered[start..end].iter().enumerate() { |
| 733 | let absolute = start + slot; |
| 734 | let is_selected = absolute == self.selected; |
| 735 | let entry = &self.entries[*idx]; |
| 736 | |
| 737 | if active_section != Some(entry.section) { |
| 738 | if slot > 0 { |
| 739 | lines.push(Line::from("")); |
| 740 | } |
| 741 | let count = match entry.section { |
| 742 | PaletteSection::Command => command_count, |
| 743 | PaletteSection::Skill => skill_count, |
| 744 | PaletteSection::Tool => tool_count, |
| 745 | PaletteSection::Mcp => mcp_count, |
| 746 | }; |
| 747 | lines.push(Self::format_section_label(entry.section, count)); |
| 748 | active_section = Some(entry.section); |
| 749 | } |
| 750 | |
| 751 | let style = if is_selected { |
| 752 | Style::default() |
| 753 | .fg(palette::SELECTION_TEXT) |
| 754 | .bg(palette::SELECTION_BG) |
| 755 | } else { |
| 756 | Style::default().fg(palette::TEXT_PRIMARY) |
| 757 | }; |
| 758 | |
| 759 | let mut line = format!(" {:<label_width$}", entry.label); |
| 760 | let desc_capacity = popup_width as usize - (label_width + 4); |
| 761 | let desc = if entry.description.width() > desc_capacity { |
| 762 | let mut shortened = String::new(); |
| 763 | for ch in entry.description.chars() { |
| 764 | if shortened.width() >= desc_capacity.saturating_sub(3) { |
| 765 | break; |
| 766 | } |
| 767 | shortened.push(ch); |
| 768 | } |
| 769 | format!("{shortened}...") |
| 770 | } else { |
| 771 | entry.description.clone() |
| 772 | }; |
| 773 | if is_selected { |
| 774 | line = format!("> {:<label_width$}", entry.label); |
| 775 | } |
| 776 | line.push_str(" "); |
| 777 | line.push_str(&desc); |
| 778 | lines.push(Line::from(Span::styled(line, style))); |
| 779 | } |
| 780 | } |
| 781 | |
| 782 | let block = modal_block() |
| 783 | .title(" Command Palette ") |
| 784 | .title_bottom(Line::from(vec![ |
| 785 | Span::styled(" ↑/↓/j/k move ", Style::default().fg(palette::TEXT_MUTED)), |
| 786 | Span::styled("Enter run/open ", Style::default().fg(palette::TEXT_MUTED)), |
| 787 | Span::styled("Esc close", Style::default().fg(palette::TEXT_MUTED)), |
| 788 | ])); |
| 789 | |
| 790 | Paragraph::new(lines) |
| 791 | .block(block) |
| 792 | .wrap(Wrap { trim: false }) |
| 793 | .render(popup_area, buf); |
| 794 | } |
| 795 | } |
| 796 | |
| 797 | #[cfg(test)] |
| 798 | mod tests { |
| 799 | use super::*; |
| 800 | use std::path::Path; |
| 801 | |
| 802 | fn palette_entry( |
| 803 | section: PaletteSection, |
| 804 | label: &str, |
| 805 | description: &str, |
| 806 | command: &str, |
| 807 | ) -> CommandPaletteEntry { |
| 808 | CommandPaletteEntry { |
| 809 | section, |
| 810 | label: label.to_string(), |
| 811 | description: description.to_string(), |
| 812 | command: command.to_string(), |
| 813 | action: CommandPaletteAction::InsertText { |
| 814 | text: command.to_string(), |
| 815 | }, |
| 816 | } |
| 817 | } |
| 818 | |
| 819 | #[test] |
| 820 | fn command_palette_filters_with_section_shortcuts() { |
| 821 | let entries = vec![ |
| 822 | palette_entry(PaletteSection::Command, "/agent", "agent command", "/agent"), |
| 823 | palette_entry( |
| 824 | PaletteSection::Skill, |
| 825 | "skill:search", |
| 826 | "search skill", |
| 827 | "/skill search", |
| 828 | ), |
| 829 | palette_entry(PaletteSection::Tool, "tool:git", "git tool", "git"), |
| 830 | palette_entry( |
| 831 | PaletteSection::Tool, |
| 832 | "tool:search", |
| 833 | "search utility", |
| 834 | "search", |
| 835 | ), |
| 836 | palette_entry(PaletteSection::Mcp, "mcp:fs", "filesystem", "mcp_fs_read"), |
| 837 | ]; |
| 838 | let mut view = CommandPaletteView::new(entries); |
| 839 | |
| 840 | view.query = "c:agent".to_string(); |
| 841 | view.refilter(); |
| 842 | assert_eq!(view.filtered, vec![0]); |
| 843 | |
| 844 | view.query = "s:search".to_string(); |
| 845 | view.refilter(); |
| 846 | assert_eq!(view.filtered, vec![1]); |
| 847 | |
| 848 | view.query = "t:search".to_string(); |
| 849 | view.refilter(); |
| 850 | assert_eq!(view.filtered, vec![3]); |
| 851 | |
| 852 | view.query = "m:fs".to_string(); |
| 853 | view.refilter(); |
| 854 | assert_eq!(view.filtered, vec![4]); |
| 855 | } |
| 856 | |
| 857 | #[test] |
| 858 | fn command_palette_ranks_label_matches_before_description_matches() { |
| 859 | let entries = vec![ |
| 860 | palette_entry( |
| 861 | PaletteSection::Command, |
| 862 | "/git", |
| 863 | "status summary for repository", |
| 864 | "git", |
| 865 | ), |
| 866 | palette_entry( |
| 867 | PaletteSection::Command, |
| 868 | "/config", |
| 869 | "configure git settings", |
| 870 | "config", |
| 871 | ), |
| 872 | palette_entry( |
| 873 | PaletteSection::Command, |
| 874 | "/sync", |
| 875 | "sync repository state", |
| 876 | "sync", |
| 877 | ), |
| 878 | ]; |
| 879 | let mut view = CommandPaletteView::new(entries); |
| 880 | |
| 881 | view.query = "git".to_string(); |
| 882 | view.refilter(); |
| 883 | |
| 884 | assert_eq!(view.entries[view.filtered[0]].label, "/git"); |
| 885 | assert_eq!(view.entries[view.filtered[1]].label, "/config"); |
| 886 | } |
| 887 | |
| 888 | #[test] |
| 889 | fn command_palette_supports_multiple_terms() { |
| 890 | let entries = vec![ |
| 891 | palette_entry( |
| 892 | PaletteSection::Command, |
| 893 | "/search-code", |
| 894 | "search with ripgrep", |
| 895 | "search code", |
| 896 | ), |
| 897 | palette_entry( |
| 898 | PaletteSection::Tool, |
| 899 | "tool:search", |
| 900 | "search web and files", |
| 901 | "search", |
| 902 | ), |
| 903 | palette_entry( |
| 904 | PaletteSection::Skill, |
| 905 | "skill:search", |
| 906 | "search files and docs", |
| 907 | "/skill search", |
| 908 | ), |
| 909 | ]; |
| 910 | let mut view = CommandPaletteView::new(entries); |
| 911 | |
| 912 | view.query = "search code".to_string(); |
| 913 | view.refilter(); |
| 914 | assert_eq!(view.filtered.len(), 1); |
| 915 | assert_eq!(view.entries[view.filtered[0]].label, "/search-code"); |
| 916 | |
| 917 | view.query = "s:search".to_string(); |
| 918 | view.refilter(); |
| 919 | assert_eq!(view.filtered.len(), 1); |
| 920 | assert_eq!(view.entries[view.filtered[0]].label, "skill:search"); |
| 921 | } |
| 922 | |
| 923 | #[test] |
| 924 | fn command_palette_command_entries_include_links_and_config_but_not_removed_commands() { |
| 925 | let entries = build_entries( |
| 926 | Locale::En, |
| 927 | Path::new("."), |
| 928 | Path::new("."), |
| 929 | Path::new("mcp.json"), |
| 930 | None, |
| 931 | ); |
| 932 | let command_labels = entries |
| 933 | .iter() |
| 934 | .filter(|entry| entry.section == PaletteSection::Command) |
| 935 | .map(|entry| entry.label.as_str()) |
| 936 | .collect::<Vec<_>>(); |
| 937 | |
| 938 | assert!(command_labels.contains(&"/config")); |
| 939 | assert!(command_labels.contains(&"/links")); |
| 940 | assert!(!command_labels.contains(&"/set")); |
| 941 | assert!(!command_labels.contains(&"/deepseek")); |
| 942 | } |
| 943 | |
| 944 | #[test] |
| 945 | fn command_palette_inserts_model_command_for_argument_entry() { |
| 946 | let entries = build_entries( |
| 947 | Locale::En, |
| 948 | Path::new("."), |
| 949 | Path::new("."), |
| 950 | Path::new("mcp.json"), |
| 951 | None, |
| 952 | ); |
| 953 | let model = entries |
| 954 | .iter() |
| 955 | .find(|entry| entry.section == PaletteSection::Command && entry.label == "/model") |
| 956 | .expect("model command entry"); |
| 957 | |
| 958 | assert_eq!(model.command, "/model "); |
| 959 | assert!(matches!( |
| 960 | &model.action, |
| 961 | CommandPaletteAction::InsertText { text } if text == "/model " |
| 962 | )); |
| 963 | } |
| 964 | |
| 965 | #[test] |
| 966 | fn command_palette_includes_mcp_discovery_and_failed_servers() { |
| 967 | let snapshot = crate::mcp::McpManagerSnapshot { |
| 968 | config_path: Path::new("mcp.json").to_path_buf(), |
| 969 | config_exists: true, |
| 970 | restart_required: false, |
| 971 | servers: vec![ |
| 972 | crate::mcp::McpServerSnapshot { |
| 973 | name: "fs".to_string(), |
| 974 | enabled: true, |
| 975 | required: false, |
| 976 | transport: "stdio".to_string(), |
| 977 | command_or_url: "node server.js".to_string(), |
| 978 | connect_timeout: 10, |
| 979 | execute_timeout: 60, |
| 980 | read_timeout: 120, |
| 981 | connected: true, |
| 982 | error: None, |
| 983 | tools: vec![crate::mcp::McpDiscoveredItem { |
| 984 | name: "read".to_string(), |
| 985 | model_name: "mcp_fs_read".to_string(), |
| 986 | description: Some("Read files".to_string()), |
| 987 | }], |
| 988 | resources: Vec::new(), |
| 989 | prompts: Vec::new(), |
| 990 | }, |
| 991 | crate::mcp::McpServerSnapshot { |
| 992 | name: "broken".to_string(), |
| 993 | enabled: true, |
| 994 | required: false, |
| 995 | transport: "http/sse".to_string(), |
| 996 | command_or_url: "https://example.invalid/mcp".to_string(), |
| 997 | connect_timeout: 10, |
| 998 | execute_timeout: 60, |
| 999 | read_timeout: 120, |
| 1000 | connected: false, |
| 1001 | error: Some("connect failed".to_string()), |
| 1002 | tools: Vec::new(), |
| 1003 | resources: Vec::new(), |
| 1004 | prompts: Vec::new(), |
| 1005 | }, |
| 1006 | ], |
| 1007 | }; |
| 1008 | let entries = build_entries( |
| 1009 | Locale::En, |
| 1010 | Path::new("."), |
| 1011 | Path::new("."), |
| 1012 | Path::new("mcp.json"), |
| 1013 | Some(&snapshot), |
| 1014 | ); |
| 1015 | |
| 1016 | assert!(entries.iter().any(|entry| entry.label == "mcp:manager")); |
| 1017 | assert!(entries.iter().any(|entry| entry.command == "mcp_fs_read")); |
| 1018 | let failed = entries |
| 1019 | .iter() |
| 1020 | .find(|entry| entry.label == "mcp:broken") |
| 1021 | .expect("failed server visible"); |
| 1022 | assert!(failed.description.contains("failed")); |
| 1023 | |
| 1024 | // Verify the "use" insert entry for MCP tools |
| 1025 | let use_entry = entries |
| 1026 | .iter() |
| 1027 | .find(|entry| entry.label == "mcp:fs:tool:read > use") |
| 1028 | .expect("MCP tool use entry should exist"); |
| 1029 | assert!(matches!( |
| 1030 | &use_entry.action, |
| 1031 | CommandPaletteAction::InsertText { text } if text == "mcp_fs_read" |
| 1032 | )); |
| 1033 | assert_eq!(use_entry.command, "mcp_fs_read"); |
| 1034 | } |
| 1035 | |
| 1036 | #[test] |
| 1037 | fn command_palette_marks_disabled_servers_visibly() { |
| 1038 | // The healthy/failed cases are covered above; disabled was the |
| 1039 | // remaining gap from #197's acceptance list. Disabled servers must |
| 1040 | // appear in the palette with a `[disabled]` state tag so users can |
| 1041 | // see them without opening the MCP manager. |
| 1042 | let snapshot = crate::mcp::McpManagerSnapshot { |
| 1043 | config_path: Path::new("mcp.json").to_path_buf(), |
| 1044 | config_exists: true, |
| 1045 | restart_required: false, |
| 1046 | servers: vec![crate::mcp::McpServerSnapshot { |
| 1047 | name: "muted".to_string(), |
| 1048 | enabled: false, |
| 1049 | required: false, |
| 1050 | transport: "stdio".to_string(), |
| 1051 | command_or_url: "node disabled.js".to_string(), |
| 1052 | connect_timeout: 10, |
| 1053 | execute_timeout: 60, |
| 1054 | read_timeout: 120, |
| 1055 | connected: false, |
| 1056 | error: None, |
| 1057 | tools: Vec::new(), |
| 1058 | resources: Vec::new(), |
| 1059 | prompts: Vec::new(), |
| 1060 | }], |
| 1061 | }; |
| 1062 | let entries = build_entries( |
| 1063 | Locale::En, |
| 1064 | Path::new("."), |
| 1065 | Path::new("."), |
| 1066 | Path::new("mcp.json"), |
| 1067 | Some(&snapshot), |
| 1068 | ); |
| 1069 | |
| 1070 | let muted = entries |
| 1071 | .iter() |
| 1072 | .find(|entry| entry.label == "mcp:muted") |
| 1073 | .expect("disabled server should still appear in the palette"); |
| 1074 | assert!( |
| 1075 | muted.description.contains("[disabled]"), |
| 1076 | "expected `[disabled]` state tag in description, got: {}", |
| 1077 | muted.description |
| 1078 | ); |
| 1079 | } |
| 1080 | |
| 1081 | #[test] |
| 1082 | fn command_palette_emits_actions_not_raw_insertions() { |
| 1083 | let entries = vec![CommandPaletteEntry { |
| 1084 | section: PaletteSection::Command, |
| 1085 | label: "/config".to_string(), |
| 1086 | description: "open config".to_string(), |
| 1087 | command: "/config".to_string(), |
| 1088 | action: CommandPaletteAction::ExecuteCommand { |
| 1089 | command: "/config".to_string(), |
| 1090 | }, |
| 1091 | }]; |
| 1092 | let mut view = CommandPaletteView::new(entries); |
| 1093 | |
| 1094 | let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); |
| 1095 | assert!(matches!( |
| 1096 | action, |
| 1097 | ViewAction::EmitAndClose(ViewEvent::CommandPaletteSelected { |
| 1098 | action: CommandPaletteAction::ExecuteCommand { .. } |
| 1099 | }) |
| 1100 | )); |
| 1101 | } |
| 1102 | } |
| 1103 |