返回 DeepSeek-TUI-2026
command_palette.rs
根目录 / crates / tui / src / tui / command_palette.rs
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
1103 lines RUST