返回 CodeWhale
command_palette.rs
根目录 / crates / tui / src / tui / command_palette.rs
1 //! Command palette modal for quick command/skill insertion.
2 //!
3 //! Product job (#4276): **find and run one action** — not a dense manual.
4 //! Help owns concepts; Config owns settings; Fleet owns worker readiness.
5
6 use std::cell::{Cell, RefCell};
7 use std::path::Path;
8
9 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
10 use ratatui::{
11 buffer::Buffer,
12 layout::Rect,
13 style::{Modifier, Style},
14 text::{Line, Span},
15 widgets::{Block, Borders, Padding, Paragraph, Widget},
16 };
17
18 use crate::commands;
19 use crate::skills;
20 use crate::tools::spec::ApprovalRequirement;
21 use crate::tools::spec::ToolCapability;
22 use crate::tools::{ToolContext, ToolRegistryBuilder};
23 use crate::tui::menu_style;
24 use crate::tui::views::{
25 ActionHint, CommandPaletteAction, ModalKind, ModalView, ViewAction, ViewEvent,
26 centered_modal_area, render_modal_footer, render_modal_surface,
27 };
28 use codewhale_localization::{Locale, MessageId, tr};
29 use codewhale_palette as palette;
30
31 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
32 pub enum PaletteSection {
33 Action,
34 Command,
35 Skill,
36 Tool,
37 Mcp,
38 }
39
40 #[derive(Debug, Clone)]
41 pub struct CommandPaletteEntry {
42 section: PaletteSection,
43 pub label: String,
44 pub description: String,
45 pub command: String,
46 pub action: CommandPaletteAction,
47 show_on_empty_query: bool,
48 }
49
50 #[cfg(test)]
51 impl CommandPaletteEntry {
52 #[must_use]
53 pub fn section(&self) -> PaletteSection {
54 self.section
55 }
56 }
57
58 pub struct CommandPaletteView {
59 locale: Locale,
60 entries: Vec<CommandPaletteEntry>,
61 filtered: Vec<usize>,
62 query: String,
63 selected: usize,
64 /// Entry rows from the most recent render. Keeping the absolute filtered
65 /// index here makes mouse activation use the exact same action as Enter.
66 row_hitboxes: RefCell<Vec<(Rect, usize)>>,
67 /// Absolute filtered index under the pointer, tinted with the shared
68 /// hover style. Hover never moves the keyboard selection.
69 hovered: Cell<Option<usize>>,
70 }
71
72 #[cfg(test)]
73 pub fn build_entries(
74 locale: Locale,
75 skills_dir: &Path,
76 skills_discovery_mode: crate::skills::SkillDiscoveryMode,
77 workspace: &Path,
78 mcp_config_path: &Path,
79 mcp_snapshot: Option<&crate::mcp::McpManagerSnapshot>,
80 ) -> Vec<CommandPaletteEntry> {
81 build_entries_with_plugins(
82 locale,
83 skills_dir,
84 skills_discovery_mode,
85 workspace,
86 mcp_config_path,
87 mcp_snapshot,
88 &crate::plugins::PluginRegistry::empty(workspace),
89 )
90 }
91
92 pub fn build_entries_with_plugins(
93 locale: Locale,
94 skills_dir: &Path,
95 skills_discovery_mode: crate::skills::SkillDiscoveryMode,
96 workspace: &Path,
97 mcp_config_path: &Path,
98 mcp_snapshot: Option<&crate::mcp::McpManagerSnapshot>,
99 plugins: &crate::plugins::PluginRegistry,
100 ) -> Vec<CommandPaletteEntry> {
101 let mut entries = Vec::new();
102 commands::user_registry::with_registry_for_plugins(plugins, |user_registry| {
103 let all_user_commands = user_registry.iter().collect::<Vec<_>>();
104 for command in commands::command_infos() {
105 if command.is_unlisted() {
106 continue;
107 }
108 if commands::discovery::user_command_shadows_builtin_canonical(
109 command,
110 &all_user_commands,
111 ) {
112 continue;
113 }
114 let mut description =
115 palette_description_for_unshadowed_aliases(command, locale, &all_user_commands);
116 if command.requires_argument() {
117 description.push_str(" ");
118 description.push_str(command.usage);
119 }
120 let action = if command.palette_runs_directly() {
121 CommandPaletteAction::ExecuteCommand {
122 command: format!("/{}", command.name),
123 }
124 } else {
125 CommandPaletteAction::InsertText {
126 text: command.palette_command(),
127 }
128 };
129 entries.push(CommandPaletteEntry {
130 section: PaletteSection::Command,
131 label: format!("/{}", command.name),
132 description,
133 command: command.palette_command(),
134 action,
135 show_on_empty_query: command.show_in_empty_discovery(),
136 });
137 }
138
139 for command in user_registry.iter().filter(|command| !command.hidden) {
140 let mut description = command
141 .description
142 .clone()
143 .unwrap_or_else(|| "User-defined command".to_string());
144 if let Some(hint) = command.display_usage() {
145 description.push_str(" ");
146 description.push_str(hint);
147 }
148 let slash_command = format!("/{}", command.name);
149 let action = if command.takes_arguments() {
150 CommandPaletteAction::InsertText {
151 text: format!("{slash_command} "),
152 }
153 } else {
154 CommandPaletteAction::ExecuteCommand {
155 command: slash_command.clone(),
156 }
157 };
158 entries.push(CommandPaletteEntry {
159 section: PaletteSection::Command,
160 label: slash_command.clone(),
161 description,
162 command: slash_command,
163 action,
164 show_on_empty_query: true,
165 });
166 }
167 });
168
169 let skills = skills::discover_for_workspace_and_dir_with_mode_and_plugins(
170 workspace,
171 skills_dir,
172 skills_discovery_mode,
173 Some(plugins),
174 )
175 .into_enabled();
176 for skill in skills
177 .list()
178 .iter()
179 .filter(|skill| skill.invocation.user_invocable())
180 {
181 entries.push(CommandPaletteEntry {
182 section: PaletteSection::Skill,
183 label: format!("${}", skill.name),
184 description: skill.user_menu_description(),
185 command: format!("${}", skill.name),
186 action: CommandPaletteAction::ExecuteCommand {
187 command: format!("${}", skill.name),
188 },
189 show_on_empty_query: true,
190 });
191 }
192
193 let context = ToolContext::new(workspace);
194 let registry = ToolRegistryBuilder::new()
195 .with_file_tools()
196 .with_search_tools()
197 .with_shell_tools()
198 .with_web_tools()
199 .with_git_tools()
200 .with_user_input_tool(crate::tools::user_input::UserInputLimits::default())
201 .with_patch_tools()
202 .with_note_tool()
203 .with_diagnostics_tool()
204 .with_project_tools()
205 .with_test_runner_tool()
206 .build(context);
207
208 let mut tool_entries = registry
209 .all()
210 .into_iter()
211 .filter_map(|tool| {
212 let name = tool.name().to_string();
213 let capabilities = tool.capabilities();
214
215 let mut tags = Vec::new();
216 if tool.is_read_only() {
217 tags.push("read-only");
218 }
219 if capabilities.contains(&ToolCapability::WritesFiles) {
220 tags.push("writes");
221 }
222 if capabilities.contains(&ToolCapability::ExecutesCode) {
223 tags.push("shell");
224 }
225 if capabilities.contains(&ToolCapability::Network) {
226 tags.push("network");
227 }
228 if tool.supports_parallel() {
229 tags.push("parallel");
230 }
231 match tool.approval_requirement() {
232 ApprovalRequirement::Required => tags.push("requires approval"),
233 ApprovalRequirement::Suggest => tags.push("suggest approval"),
234 ApprovalRequirement::Auto => {}
235 }
236
237 let mut description = tool.description().to_string();
238 if !tags.is_empty() {
239 description.push_str(" [");
240 description.push_str(&tags.join(", "));
241 description.push(']');
242 }
243
244 if name.trim().is_empty() {
245 return None;
246 }
247 Some(CommandPaletteEntry {
248 section: PaletteSection::Tool,
249 label: format!("tool:{name}"),
250 description: description.clone(),
251 command: name,
252 action: CommandPaletteAction::OpenTextPager {
253 title: format!("Tool: {}", tool.name()),
254 content: format_tool_details(tool.name(), tool.description(), &tags),
255 },
256 show_on_empty_query: true,
257 })
258 })
259 .collect::<Vec<_>>();
260 tool_entries.sort_by(|a, b| a.label.cmp(&b.label));
261 entries.extend(tool_entries);
262
263 entries.extend(build_mcp_entries(
264 workspace,
265 mcp_config_path,
266 mcp_snapshot,
267 plugins,
268 ));
269
270 entries.sort_by(|a, b| a.label.cmp(&b.label));
271 entries.sort_by_key(|entry| entry.section);
272 entries
273 }
274
275 fn palette_description_for_unshadowed_aliases(
276 command: &commands::CommandInfo,
277 locale: Locale,
278 all_user_commands: &[&commands::user_registry::UserCommandMetadata],
279 ) -> String {
280 let desc = command.description_for(locale);
281 let aliases = commands::discovery::unshadowed_builtin_aliases(command, all_user_commands);
282 if aliases.len() == command.aliases.len() {
283 return command.palette_description_for(locale);
284 }
285 if aliases.is_empty() {
286 desc.to_string()
287 } else {
288 format!("{} aliases: {}", desc, aliases.join(", "))
289 }
290 }
291
292 fn build_mcp_entries(
293 workspace: &Path,
294 mcp_config_path: &Path,
295 mcp_snapshot: Option<&crate::mcp::McpManagerSnapshot>,
296 plugins: &crate::plugins::PluginRegistry,
297 ) -> Vec<CommandPaletteEntry> {
298 let owned_snapshot = if mcp_snapshot.is_none() {
299 crate::mcp::manager_snapshot_from_config_with_workspace_and_plugins(
300 mcp_config_path,
301 workspace,
302 false,
303 plugins,
304 )
305 .ok()
306 } else {
307 None
308 };
309 let snapshot = mcp_snapshot.or(owned_snapshot.as_ref());
310 let mut entries = vec![CommandPaletteEntry {
311 section: PaletteSection::Mcp,
312 label: "mcp:manager".to_string(),
313 description: format!("Open MCP manager ({})", mcp_config_path.display()),
314 command: "/mcp".to_string(),
315 action: CommandPaletteAction::ExecuteCommand {
316 command: "/mcp".to_string(),
317 },
318 show_on_empty_query: true,
319 }];
320
321 let Some(snapshot) = snapshot else {
322 return entries;
323 };
324
325 for server in &snapshot.servers {
326 let state = if server.enabled {
327 if server.connected {
328 "connected"
329 } else if server.error.is_some() {
330 "failed"
331 } else {
332 "enabled"
333 }
334 } else {
335 "disabled"
336 };
337 entries.push(CommandPaletteEntry {
338 section: PaletteSection::Mcp,
339 label: format!("mcp:{}", server.name),
340 description: format!(
341 "{} {} [{}] tools={} resources={} prompts={}",
342 server.transport,
343 crate::mcp::mcp_display_target(&server.transport, &server.command_or_url),
344 state,
345 server.tools.len(),
346 server.resources.len(),
347 server.prompts.len()
348 ),
349 command: format!("/mcp show {}", server.name),
350 action: CommandPaletteAction::OpenTextPager {
351 title: format!("MCP Server: {}", server.name),
352 content: format_mcp_server_details(snapshot, server),
353 },
354 show_on_empty_query: true,
355 });
356
357 for tool in &server.tools {
358 entries.push(CommandPaletteEntry {
359 section: PaletteSection::Mcp,
360 label: format!("mcp:{}:tool:{}", server.name, tool.name),
361 description: format!(
362 "{}{}",
363 tool.model_name,
364 tool.description
365 .as_ref()
366 .map_or(String::new(), |desc| format!(" - {desc}"))
367 ),
368 command: tool.model_name.clone(),
369 action: CommandPaletteAction::OpenTextPager {
370 title: format!("MCP Tool: {}", tool.model_name),
371 content: format!(
372 "Server: {}\nRuntime name: {}\nKind: tool\n\n{}",
373 server.name,
374 tool.model_name,
375 tool.description.as_deref().unwrap_or("(no description)")
376 ),
377 },
378 show_on_empty_query: true,
379 });
380 // Add a "use" entry that inserts the tool's model_name into the input
381 // so users can quickly reference the tool in their message to the AI.
382 if !tool.model_name.trim().is_empty() {
383 entries.push(CommandPaletteEntry {
384 section: PaletteSection::Mcp,
385 label: format!("mcp:{}:tool:{} > use", server.name, tool.name),
386 description: format!(
387 "Insert {} into input — type args then send{}",
388 tool.model_name,
389 tool.description
390 .as_ref()
391 .map_or(String::new(), |desc| format!(" ({desc})"))
392 ),
393 command: tool.model_name.clone(),
394 action: CommandPaletteAction::InsertText {
395 text: tool.model_name.clone(),
396 },
397 show_on_empty_query: true,
398 });
399 }
400 }
401
402 for resource in &server.resources {
403 entries.push(CommandPaletteEntry {
404 section: PaletteSection::Mcp,
405 label: format!("mcp:{}:resource:{}", server.name, resource.name),
406 description: resource
407 .description
408 .clone()
409 .unwrap_or_else(|| "MCP resource".to_string()),
410 command: resource.name.clone(),
411 action: CommandPaletteAction::OpenTextPager {
412 title: format!("MCP Resource: {}", resource.name),
413 content: format!(
414 "Server: {}\nResource: {}\nModel helper: list_mcp_resources / read_mcp_resource",
415 server.name, resource.name
416 ),
417 },
418 show_on_empty_query: true,
419 });
420 }
421
422 for prompt in &server.prompts {
423 entries.push(CommandPaletteEntry {
424 section: PaletteSection::Mcp,
425 label: format!("mcp:{}:prompt:{}", server.name, prompt.name),
426 description: format!(
427 "{}{}",
428 prompt.model_name,
429 prompt
430 .description
431 .as_ref()
432 .map_or(String::new(), |desc| format!(" - {desc}"))
433 ),
434 command: prompt.model_name.clone(),
435 action: CommandPaletteAction::OpenTextPager {
436 title: format!("MCP Prompt: {}", prompt.model_name),
437 content: format!(
438 "Server: {}\nRuntime name: {}\nKind: prompt",
439 server.name, prompt.model_name
440 ),
441 },
442 show_on_empty_query: true,
443 });
444 }
445 }
446
447 entries
448 }
449
450 fn format_mcp_server_details(
451 snapshot: &crate::mcp::McpManagerSnapshot,
452 server: &crate::mcp::McpServerSnapshot,
453 ) -> String {
454 let mut lines = vec![
455 format!("Config: {}", snapshot.config_path.display()),
456 format!("Server: {}", server.name),
457 format!("Enabled: {}", server.enabled),
458 format!("Connected: {}", server.connected),
459 format!("Transport: {}", server.transport),
460 format!(
461 "Target: {}",
462 crate::mcp::mcp_display_target(&server.transport, &server.command_or_url)
463 ),
464 format!(
465 "Timeouts: connect={}s execute={}s read={}s",
466 server.connect_timeout, server.execute_timeout, server.read_timeout
467 ),
468 ];
469 if let Some(error) = server.error.as_ref() {
470 lines.push(format!("Error: {error}"));
471 }
472 lines.push(String::new());
473 lines.push(format!("Tools ({})", server.tools.len()));
474 for tool in &server.tools {
475 lines.push(format!(" - {}", tool.model_name));
476 }
477 lines.push(format!("Resources ({})", server.resources.len()));
478 for resource in &server.resources {
479 lines.push(format!(" - {}", resource.name));
480 }
481 lines.push(format!("Prompts ({})", server.prompts.len()));
482 for prompt in &server.prompts {
483 lines.push(format!(" - {}", prompt.model_name));
484 }
485 lines.join("\n")
486 }
487
488 fn modal_block() -> Block<'static> {
489 Block::default()
490 .borders(Borders::ALL)
491 .border_style(Style::default().fg(palette::BORDER_COLOR))
492 .style(Style::default().bg(palette::WHALE_BG))
493 .padding(Padding::uniform(1))
494 }
495
496 fn parse_section_term(term: &str) -> Option<(PaletteSection, String)> {
497 let (section, query) = term.split_once(':')?;
498
499 if section.is_empty() || query.is_empty() {
500 return None;
501 }
502
503 let query = query.to_ascii_lowercase();
504 let section = match section {
505 "a" | "action" | "actions" => PaletteSection::Action,
506 "c" | "cmd" | "command" | "commands" => PaletteSection::Command,
507 "s" | "skill" | "skills" => PaletteSection::Skill,
508 "t" | "tool" | "tools" => PaletteSection::Tool,
509 "m" | "mcp" => PaletteSection::Mcp,
510 _ => return None,
511 };
512
513 Some((section, query))
514 }
515
516 fn section_tag(section: PaletteSection) -> &'static str {
517 match section {
518 PaletteSection::Action => "action",
519 PaletteSection::Command => "command",
520 PaletteSection::Skill => "skill",
521 PaletteSection::Tool => "tool",
522 PaletteSection::Mcp => "mcp",
523 }
524 }
525
526 fn section_rank(section: PaletteSection) -> usize {
527 match section {
528 PaletteSection::Action => 0,
529 PaletteSection::Command => 1,
530 PaletteSection::Skill => 2,
531 PaletteSection::Tool => 3,
532 PaletteSection::Mcp => 4,
533 }
534 }
535
536 fn format_tool_details(name: &str, description: &str, tags: &[&str]) -> String {
537 let mut lines = vec![
538 format!("Tool: {name}"),
539 String::new(),
540 description.to_string(),
541 ];
542 if !tags.is_empty() {
543 lines.push(String::new());
544 lines.push(format!("Capabilities: {}", tags.join(", ")));
545 }
546 lines.push(String::new());
547 lines.push(
548 "Use slash commands and skills here for direct actions; use tool entries to inspect what the agent can call."
549 .to_string(),
550 );
551 lines.join("\n")
552 }
553
554 fn term_score(term: &str, label: &str, description: &str, command: &str, haystack: &str) -> usize {
555 if term.is_empty() {
556 return 0;
557 }
558
559 if label == term || command == term || description == term {
560 return 0;
561 }
562
563 if label.starts_with(term) {
564 return 8;
565 }
566
567 if command.starts_with(term) {
568 return 16;
569 }
570
571 if description.contains(term) {
572 return 64;
573 }
574
575 if label.contains(term) {
576 return 32;
577 }
578
579 if command.contains(term) {
580 return 48;
581 }
582
583 if haystack.contains(term) {
584 return 96;
585 }
586
587 128
588 }
589
590 fn entry_match_score(entry: &CommandPaletteEntry, terms: &[&str]) -> Option<usize> {
591 if terms.is_empty() {
592 return Some(0);
593 }
594
595 let section = section_tag(entry.section);
596 let label = entry.label.to_ascii_lowercase();
597 let description = entry.description.to_ascii_lowercase();
598 let command = entry.command.to_ascii_lowercase();
599 let entry_text = format!("{section} {label} {description} {command}");
600
601 let mut total_score = 0usize;
602
603 for term in terms {
604 if let Some((required_section, scoped_query)) = parse_section_term(term) {
605 if entry.section != required_section {
606 return None;
607 }
608 if !entry_text.contains(&scoped_query) {
609 return None;
610 }
611 total_score += term_score(&scoped_query, &label, &description, &command, &entry_text);
612 continue;
613 }
614
615 if !entry_text.contains(term) {
616 return None;
617 }
618 total_score += term_score(term, &label, &description, &command, &entry_text);
619 }
620
621 Some(total_score)
622 }
623
624 /// Number of rendered rows the entry loop consumes for the window
625 /// `sections[start..end]`: one row per entry, plus one section-label row each
626 /// time the section changes, plus a separator blank before every section group
627 /// after the first.
628 fn rendered_entry_rows(sections: &[PaletteSection], start: usize, end: usize) -> usize {
629 let end = end.min(sections.len());
630 if start >= end {
631 return 0;
632 }
633 let mut rows = 0usize;
634 let mut active: Option<PaletteSection> = None;
635 for (slot, sec) in sections[start..end].iter().enumerate() {
636 if active != Some(*sec) {
637 if slot > 0 {
638 rows += 1; // separator blank
639 }
640 rows += 1; // section label
641 active = Some(*sec);
642 }
643 rows += 1; // the entry itself
644 }
645 rows
646 }
647
648 /// Compute the `[start, end)` window of filtered entries to render so that the
649 /// selected entry is always visible and the rendered rows — entries plus the
650 /// per-section labels and separators inserted between them — fit within
651 /// `available` rows.
652 ///
653 /// The previous logic sized the window purely by entry count (`popup_height -
654 /// 7`) while the same fixed-height area also held the header, section labels,
655 /// and separators. Those uncounted rows pushed the selection past the bottom
656 /// clip line, so it vanished and the list appeared frozen until the index
657 /// finally exceeded the (overlarge) entry budget (#2590).
658 fn visible_entry_window(
659 sections: &[PaletteSection],
660 selected: usize,
661 available: usize,
662 ) -> (usize, usize) {
663 let total = sections.len();
664 if total == 0 || available == 0 {
665 return (0, 0);
666 }
667 let selected = selected.min(total - 1);
668 // Always include the selected row, then greedily grow downward and upward
669 // while the fully-rendered window still fits. Growth only ever adds rows,
670 // so the greedy expansion terminates at the largest fitting window.
671 let mut start = selected;
672 let mut end = selected + 1;
673 loop {
674 let mut progressed = false;
675 if end < total && rendered_entry_rows(sections, start, end + 1) <= available {
676 end += 1;
677 progressed = true;
678 }
679 if start > 0 && rendered_entry_rows(sections, start - 1, end) <= available {
680 start -= 1;
681 progressed = true;
682 }
683 if !progressed {
684 break;
685 }
686 }
687 (start, end)
688 }
689
690 impl CommandPaletteView {
691 #[cfg(test)]
692 pub fn new(entries: Vec<CommandPaletteEntry>) -> Self {
693 Self::new_for_locale(Locale::En, entries)
694 }
695
696 pub fn new_for_locale(locale: Locale, entries: Vec<CommandPaletteEntry>) -> Self {
697 let mut view = Self {
698 locale,
699 entries,
700 filtered: Vec::new(),
701 query: String::new(),
702 selected: 0,
703 row_hitboxes: RefCell::new(Vec::new()),
704 hovered: Cell::new(None),
705 };
706 view.refilter();
707 view
708 }
709
710 fn refilter(&mut self) {
711 let query = self.query.trim().to_ascii_lowercase();
712 let terms: Vec<&str> = query
713 .split_whitespace()
714 .filter(|term| !term.is_empty())
715 .collect();
716
717 let mut filtered = self
718 .entries
719 .iter()
720 .enumerate()
721 .filter_map(|(idx, entry)| {
722 if terms.is_empty() && !entry.show_on_empty_query {
723 return None;
724 }
725 entry_match_score(entry, &terms).map(|score| (idx, score))
726 })
727 .collect::<Vec<_>>();
728
729 filtered.sort_by_key(|(idx, score)| {
730 let entry = &self.entries[*idx];
731 (section_rank(entry.section), *score, &entry.label)
732 });
733 // Follow the highlighted entry across the refilter instead of leaving a
734 // raw index pointing into a freshly re-sorted list. Every keystroke
735 // refilters, so a clamp alone silently slides the highlight onto an
736 // unrelated row — and Enter runs whatever it landed on. `filtered` holds
737 // indices into the stable `entries`, so the entry is its own identity.
738 let keep = self.filtered.get(self.selected).copied();
739 self.filtered = filtered.into_iter().map(|(idx, _)| idx).collect();
740 self.selected = keep
741 .and_then(|entry| self.filtered.iter().position(|idx| *idx == entry))
742 .unwrap_or(0);
743 self.hovered.set(None);
744 }
745
746 fn scope_hint_lines() -> Line<'static> {
747 let hint = "scope: c:cmd · s:skill · t:tool · m:mcp";
748 Line::from(Span::styled(
749 hint,
750 Style::default()
751 .fg(palette::TEXT_DIM)
752 .add_modifier(Modifier::ITALIC),
753 ))
754 }
755
756 fn format_section_label(section: PaletteSection, count: usize) -> Line<'static> {
757 let title = match section {
758 PaletteSection::Action => "Actions",
759 PaletteSection::Command => "Commands",
760 PaletteSection::Skill => "Skills",
761 PaletteSection::Tool => "Tools",
762 PaletteSection::Mcp => "MCP",
763 };
764 Line::from(vec![Span::styled(
765 format!(" {title} ({count}) "),
766 Style::default()
767 .fg(palette::WHALE_ACTION)
768 .add_modifier(Modifier::BOLD),
769 )])
770 }
771
772 fn move_selection(&mut self, delta: isize) {
773 self.selected = crate::tui::list_nav::wrap_index(self.selected, self.filtered.len(), delta);
774 self.hovered.set(None);
775 }
776
777 fn selected_entry(&self) -> Option<&CommandPaletteEntry> {
778 self.filtered
779 .get(self.selected)
780 .and_then(|idx| self.entries.get(*idx))
781 }
782 }
783
784 impl ModalView for CommandPaletteView {
785 fn kind(&self) -> ModalKind {
786 ModalKind::CommandPalette
787 }
788
789 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
790 self
791 }
792
793 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
794 match mouse.kind {
795 MouseEventKind::Moved => {
796 let hovered = self.row_hitboxes.borrow().iter().find_map(|(rect, index)| {
797 rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
798 .then_some(*index)
799 });
800 self.hovered.set(hovered);
801 }
802 MouseEventKind::ScrollUp => self.move_selection(-1),
803 MouseEventKind::ScrollDown => self.move_selection(1),
804 MouseEventKind::Down(MouseButton::Left) => {
805 let clicked = self.row_hitboxes.borrow().iter().find_map(|(rect, index)| {
806 rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
807 .then_some(*index)
808 });
809 if let Some(index) = clicked {
810 if self.selected == index {
811 if let Some(entry) = self.selected_entry() {
812 return ViewAction::EmitAndClose(ViewEvent::CommandPaletteSelected {
813 action: entry.action.clone(),
814 });
815 }
816 } else {
817 self.selected = index;
818 }
819 }
820 }
821 _ => {}
822 }
823 ViewAction::None
824 }
825
826 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
827 match key.code {
828 KeyCode::Esc => ViewAction::Close,
829 KeyCode::Enter => {
830 if let Some(entry) = self.selected_entry() {
831 ViewAction::EmitAndClose(ViewEvent::CommandPaletteSelected {
832 action: entry.action.clone(),
833 })
834 } else {
835 ViewAction::None
836 }
837 }
838 KeyCode::Up => {
839 self.move_selection(-1);
840 ViewAction::None
841 }
842 KeyCode::Down => {
843 self.move_selection(1);
844 ViewAction::None
845 }
846 KeyCode::PageUp => {
847 self.move_selection(-8);
848 ViewAction::None
849 }
850 KeyCode::PageDown => {
851 self.move_selection(8);
852 ViewAction::None
853 }
854 KeyCode::Backspace => {
855 self.query.pop();
856 self.refilter();
857 ViewAction::None
858 }
859 // Ctrl+H is the legacy ASCII backspace many terminals emit.
860 KeyCode::Char('h')
861 if key.modifiers.contains(KeyModifiers::CONTROL)
862 && !key.modifiers.contains(KeyModifiers::ALT) =>
863 {
864 self.query.pop();
865 self.refilter();
866 ViewAction::None
867 }
868 KeyCode::Char(c)
869 if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
870 {
871 self.query.push(c);
872 self.refilter();
873 ViewAction::None
874 }
875 _ => ViewAction::None,
876 }
877 }
878
879 fn render(&self, area: Rect, buf: &mut Buffer) {
880 self.row_hitboxes.borrow_mut().clear();
881 let popup_area = centered_modal_area(area, 90, 22, 44, 8);
882 let popup_width = popup_area.width;
883
884 render_modal_surface(area, popup_area, buf);
885
886 let title = format!(
887 " {} — {} ",
888 tr(self.locale, MessageId::CommandPaletteTitle),
889 tr(self.locale, MessageId::CommandPaletteSubtitle)
890 );
891 let block = modal_block().title(Line::from(Span::styled(
892 title,
893 Style::default()
894 .fg(palette::WHALE_ACTION)
895 .add_modifier(Modifier::BOLD),
896 )));
897 let inner = block.inner(popup_area);
898 block.render(popup_area, buf);
899
900 let content = render_modal_footer(
901 inner,
902 buf,
903 &[
904 ActionHint::new("↑/↓", "move"),
905 ActionHint::new("Enter", "select"),
906 ActionHint::new("Esc", "cancel"),
907 ],
908 );
909
910 let mut lines = Vec::new();
911 let mut entry_line_indices = Vec::new();
912 let query_label = if self.query.is_empty() {
913 "Type to filter".to_string()
914 } else {
915 format!("Filter: {}", self.query)
916 };
917 lines.push(Line::from(Span::styled(
918 query_label,
919 Style::default().fg(palette::TEXT_MUTED),
920 )));
921 let match_count = if self.query.is_empty() {
922 format!(
923 "{} shown / {} entries",
924 self.filtered.len(),
925 self.entries.len()
926 )
927 } else {
928 format!("{} / {} matches", self.filtered.len(), self.entries.len())
929 };
930 lines.push(Line::from(Span::styled(
931 match_count,
932 Style::default().fg(palette::TEXT_DIM).italic(),
933 )));
934 lines.push(Self::scope_hint_lines());
935 lines.push(Line::from(""));
936
937 // Rows the bordered popup can show for the list, minus the header that
938 // was already pushed above. The entry loop additionally emits section
939 // labels and separators, so the scroll window is sized against the real
940 // rendered cost rather than a flat entry count (#2590).
941 let header_lines = lines.len();
942 let available = (content.height as usize).saturating_sub(header_lines);
943 let mut action_count = 0usize;
944 let mut command_count = 0usize;
945 let mut skill_count = 0usize;
946 let mut tool_count = 0usize;
947 let mut mcp_count = 0usize;
948 for idx in &self.filtered {
949 match self.entries[*idx].section {
950 PaletteSection::Action => action_count += 1,
951 PaletteSection::Command => command_count += 1,
952 PaletteSection::Skill => skill_count += 1,
953 PaletteSection::Tool => tool_count += 1,
954 PaletteSection::Mcp => mcp_count += 1,
955 }
956 }
957 if self.filtered.is_empty() {
958 lines.push(Line::from(Span::styled(
959 "No matches",
960 Style::default().fg(palette::TEXT_MUTED).italic(),
961 )));
962 } else {
963 let label_width = 24.min(popup_width.saturating_sub(26) as usize);
964 let sections: Vec<PaletteSection> = self
965 .filtered
966 .iter()
967 .map(|idx| self.entries[*idx].section)
968 .collect();
969 let (start, end) = visible_entry_window(&sections, self.selected, available);
970 let mut active_section = None;
971 for (slot, idx) in self.filtered[start..end].iter().enumerate() {
972 let absolute = start + slot;
973 let is_selected = absolute == self.selected;
974 let entry = &self.entries[*idx];
975
976 if active_section != Some(entry.section) {
977 if slot > 0 {
978 lines.push(Line::from(""));
979 }
980 let count = match entry.section {
981 PaletteSection::Action => action_count,
982 PaletteSection::Command => command_count,
983 PaletteSection::Skill => skill_count,
984 PaletteSection::Tool => tool_count,
985 PaletteSection::Mcp => mcp_count,
986 };
987 lines.push(Self::format_section_label(entry.section, count));
988 active_section = Some(entry.section);
989 }
990
991 // Hover tints but never steals the keyboard selection.
992 let hovered = !is_selected && self.hovered.get() == Some(absolute);
993 let style = if is_selected {
994 menu_style::selected_row_style()
995 } else if hovered {
996 Style::default()
997 .fg(palette::TEXT_PRIMARY)
998 .patch(menu_style::hovered_row_style())
999 } else {
1000 Style::default().fg(palette::TEXT_PRIMARY)
1001 };
1002
1003 let pointer = crate::tui::glyphs::selection_marker(is_selected);
1004 // `{:<width$}` pads but never truncates, so a long label — every
1005 // `mcp:server:tool` row — ran past the column and pushed the
1006 // description off the card entirely. Truncate first, then pad, so
1007 // the description column stays on one axis.
1008 let label = crate::tui::ui_text::truncate_line_to_width(&entry.label, label_width);
1009 let mut line = format!("{pointer} {label:<label_width$}");
1010 // The rows are drawn into `content`, which is the popup less its
1011 // borders and padding — measuring against `popup_width` overstated
1012 // the room by four columns.
1013 let content_width = (popup_width as usize).saturating_sub(4);
1014 let desc_capacity = content_width.saturating_sub(label_width + 4);
1015 let desc =
1016 crate::tui::ui_text::truncate_line_to_width(&entry.description, desc_capacity);
1017 line.push_str(" ");
1018 line.push_str(&desc);
1019 entry_line_indices.push((lines.len(), absolute));
1020 lines.push(Line::from(Span::styled(line, style)));
1021 }
1022 }
1023
1024 // The palette's row-budget logic intentionally treats each logical
1025 // line as one terminal row. Do not wrap here: wrapping a long query or
1026 // label would both hide later entries and make mouse hitboxes lie.
1027 Paragraph::new(lines).render(content, buf);
1028 *self.row_hitboxes.borrow_mut() = entry_line_indices
1029 .into_iter()
1030 .filter_map(|(line, index)| {
1031 let row = content.y.saturating_add(line as u16);
1032 (row < content.bottom())
1033 .then_some((Rect::new(content.x, row, content.width, 1), index))
1034 })
1035 .collect();
1036 }
1037 }
1038
1039 #[cfg(test)]
1040 mod tests {
1041 use super::*;
1042 use std::path::Path;
1043 use tempfile::TempDir;
1044 use unicode_width::UnicodeWidthStr;
1045
1046 #[test]
1047 fn refilter_keeps_the_highlight_on_the_entry_the_user_was_looking_at() {
1048 // Every keystroke refilters and re-sorts. The index used to be clamped
1049 // but never re-anchored, so refining a query could slide the highlight
1050 // onto an unrelated row — and Enter runs whatever is highlighted.
1051 let entries = vec![
1052 palette_entry(PaletteSection::Tool, "tool:one", "alpha", "one"),
1053 palette_entry(PaletteSection::Tool, "tool:two", "shared", "two"),
1054 palette_entry(PaletteSection::Tool, "tool:three", "shared", "three"),
1055 ];
1056 let mut view = CommandPaletteView::new(entries);
1057
1058 view.query = "tool".to_string();
1059 view.refilter();
1060 view.selected = view
1061 .filtered
1062 .iter()
1063 .position(|idx| view.entries[*idx].label == "tool:three")
1064 .expect("tool:three is listed");
1065
1066 // Narrowing to a query `tool:three` still matches. It moves to a lower
1067 // index in the shorter list, which is exactly the case a clamp gets
1068 // wrong: the old code reset to 0 and highlighted `tool:two`.
1069 view.query = "shared".to_string();
1070 view.refilter();
1071 assert_eq!(
1072 view.selected_entry().map(|entry| entry.label.as_str()),
1073 Some("tool:three"),
1074 "the highlight jumped to another row: {:?}",
1075 view.selected_entry().map(|entry| entry.label.clone())
1076 );
1077
1078 // When the highlighted entry filters out entirely, fall back to the top
1079 // rather than to a stale index.
1080 view.query = "alpha".to_string();
1081 view.refilter();
1082 assert_eq!(
1083 view.selected_entry().map(|entry| entry.label.as_str()),
1084 Some("tool:one")
1085 );
1086 }
1087
1088 #[test]
1089 fn visible_window_keeps_selection_in_view_and_fits() {
1090 // Single large section, small budget: every selection must stay visible
1091 // and the rendered window must fit the available rows (#2590).
1092 let sections = vec![PaletteSection::Command; 30];
1093 let available = 10;
1094 for selected in 0..sections.len() {
1095 let (start, end) = visible_entry_window(&sections, selected, available);
1096 assert!(
1097 start <= selected && selected < end,
1098 "selected {selected} must lie within [{start}, {end})"
1099 );
1100 assert!(
1101 rendered_entry_rows(&sections, start, end) <= available,
1102 "window [{start}, {end}) must fit within {available} rows"
1103 );
1104 }
1105 }
1106
1107 #[test]
1108 fn visible_window_scrolls_as_selection_advances() {
1109 let sections = vec![PaletteSection::Command; 30];
1110 let available = 8;
1111 let (start_near, _) = visible_entry_window(&sections, 0, available);
1112 assert_eq!(start_near, 0);
1113 // A far-down selection must advance the window start — the old code
1114 // left it pinned at 0 so the selection scrolled off-screen.
1115 let (start_far, end_far) = visible_entry_window(&sections, 25, available);
1116 assert!(start_far > 0, "window should scroll for a far selection");
1117 assert!(start_far <= 25 && 25 < end_far);
1118 }
1119
1120 #[test]
1121 fn visible_window_accounts_for_section_overhead() {
1122 // Each entry is its own section, so each costs a label (plus a
1123 // separator after the first) on top of the entry row. Far fewer than
1124 // `available` entries fit, and the window must still respect the budget.
1125 let sections = vec![
1126 PaletteSection::Action,
1127 PaletteSection::Command,
1128 PaletteSection::Skill,
1129 PaletteSection::Tool,
1130 PaletteSection::Mcp,
1131 ];
1132 let available = 6;
1133 let (start, end) = visible_entry_window(&sections, 0, available);
1134 assert_eq!(start, 0);
1135 assert!(end >= 1, "at least the selected entry must render");
1136 assert!(rendered_entry_rows(&sections, start, end) <= available);
1137 }
1138
1139 #[test]
1140 fn visible_window_handles_empty_and_zero_budget() {
1141 assert_eq!(visible_entry_window(&[], 0, 10), (0, 0));
1142 let sections = vec![PaletteSection::Command; 5];
1143 assert_eq!(visible_entry_window(&sections, 2, 0), (0, 0));
1144 }
1145
1146 fn palette_entry(
1147 section: PaletteSection,
1148 label: &str,
1149 description: &str,
1150 command: &str,
1151 ) -> CommandPaletteEntry {
1152 CommandPaletteEntry {
1153 section,
1154 label: label.to_string(),
1155 description: description.to_string(),
1156 command: command.to_string(),
1157 action: CommandPaletteAction::InsertText {
1158 text: command.to_string(),
1159 },
1160 show_on_empty_query: true,
1161 }
1162 }
1163
1164 fn assert_palette_search_owns_text(query: &str) {
1165 let entries = ["json", "key", "队列é"]
1166 .map(|text| palette_entry(PaletteSection::Command, text, "", text))
1167 .to_vec();
1168 let mut stack = crate::tui::views::ViewStack::new();
1169 stack.push(CommandPaletteView::new(entries));
1170 for ch in query.chars() {
1171 assert!(
1172 stack
1173 .handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE))
1174 .is_empty()
1175 );
1176 assert_eq!(stack.top_kind(), Some(ModalKind::CommandPalette));
1177 }
1178 let mut modal = stack.pop().unwrap();
1179 let view = modal
1180 .as_any_mut()
1181 .downcast_mut::<CommandPaletteView>()
1182 .unwrap();
1183 assert_eq!(view.query, query);
1184 assert_eq!(view.filtered.len(), 1);
1185 for code in [
1186 KeyCode::Up,
1187 KeyCode::Down,
1188 KeyCode::PageUp,
1189 KeyCode::PageDown,
1190 ] {
1191 assert!(matches!(
1192 view.handle_key(KeyEvent::new(code, KeyModifiers::NONE)),
1193 ViewAction::None
1194 ));
1195 assert_eq!(view.query, query);
1196 }
1197 assert!(matches!(
1198 view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
1199 ViewAction::EmitAndClose(ViewEvent::CommandPaletteSelected {
1200 action: CommandPaletteAction::InsertText { text }
1201 }) if text == query
1202 ));
1203 assert!(matches!(
1204 view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
1205 ViewAction::Close
1206 ));
1207 }
1208
1209 #[test]
1210 fn palette_search_owns_initial_j() {
1211 assert_palette_search_owns_text("json");
1212 }
1213
1214 #[test]
1215 fn palette_search_owns_initial_k() {
1216 assert_palette_search_owns_text("key");
1217 }
1218
1219 #[test]
1220 fn palette_search_owns_unicode() {
1221 assert_palette_search_owns_text("队列é");
1222 }
1223
1224 #[test]
1225 fn command_palette_filters_with_section_shortcuts() {
1226 let entries = vec![
1227 palette_entry(PaletteSection::Command, "/mode", "mode command", "/mode"),
1228 palette_entry(
1229 PaletteSection::Skill,
1230 "skill:search",
1231 "search skill",
1232 "/skill search",
1233 ),
1234 palette_entry(PaletteSection::Tool, "tool:git", "git tool", "git"),
1235 palette_entry(
1236 PaletteSection::Tool,
1237 "tool:search",
1238 "search utility",
1239 "search",
1240 ),
1241 palette_entry(PaletteSection::Mcp, "mcp:fs", "filesystem", "mcp_fs_read"),
1242 ];
1243 let mut view = CommandPaletteView::new(entries);
1244
1245 view.query = "c:mode".to_string();
1246 view.refilter();
1247 assert_eq!(view.filtered, vec![0]);
1248
1249 view.query = "s:search".to_string();
1250 view.refilter();
1251 assert_eq!(view.filtered, vec![1]);
1252
1253 view.query = "t:search".to_string();
1254 view.refilter();
1255 assert_eq!(view.filtered, vec![3]);
1256
1257 view.query = "m:fs".to_string();
1258 view.refilter();
1259 assert_eq!(view.filtered, vec![4]);
1260 }
1261
1262 #[test]
1263 fn command_palette_ranks_label_matches_before_description_matches() {
1264 let entries = vec![
1265 palette_entry(
1266 PaletteSection::Command,
1267 "/git",
1268 "status summary for repository",
1269 "git",
1270 ),
1271 palette_entry(
1272 PaletteSection::Command,
1273 "/config",
1274 "configure git settings",
1275 "config",
1276 ),
1277 palette_entry(
1278 PaletteSection::Command,
1279 "/sync",
1280 "sync repository state",
1281 "sync",
1282 ),
1283 ];
1284 let mut view = CommandPaletteView::new(entries);
1285
1286 view.query = "git".to_string();
1287 view.refilter();
1288
1289 assert_eq!(view.entries[view.filtered[0]].label, "/git");
1290 assert_eq!(view.entries[view.filtered[1]].label, "/config");
1291 }
1292
1293 #[test]
1294 fn command_palette_supports_multiple_terms() {
1295 let entries = vec![
1296 palette_entry(
1297 PaletteSection::Command,
1298 "/search-code",
1299 "search with ripgrep",
1300 "search code",
1301 ),
1302 palette_entry(
1303 PaletteSection::Tool,
1304 "tool:search",
1305 "search web and files",
1306 "search",
1307 ),
1308 palette_entry(
1309 PaletteSection::Skill,
1310 "skill:search",
1311 "search files and docs",
1312 "/skill search",
1313 ),
1314 ];
1315 let mut view = CommandPaletteView::new(entries);
1316
1317 view.query = "search code".to_string();
1318 view.refilter();
1319 assert_eq!(view.filtered.len(), 1);
1320 assert_eq!(view.entries[view.filtered[0]].label, "/search-code");
1321
1322 view.query = "s:search".to_string();
1323 view.refilter();
1324 assert_eq!(view.filtered.len(), 1);
1325 assert_eq!(view.entries[view.filtered[0]].label, "skill:search");
1326 }
1327
1328 #[test]
1329 fn command_palette_skills_use_workspace_and_configured_directories() {
1330 let tmp = TempDir::new().expect("tempdir");
1331 let workspace = tmp.path().join("workspace");
1332 crate::test_support::trust_workspace(&workspace);
1333 let workspace_skill_dir = workspace
1334 .join(".agents")
1335 .join("skills")
1336 .join("workspace-skill");
1337 std::fs::create_dir_all(&workspace_skill_dir).expect("create workspace skill dir");
1338 std::fs::write(
1339 workspace_skill_dir.join("SKILL.md"),
1340 "---\nname: workspace-skill\ndescription: Workspace skill\ngithub: https://example.com\n---\nbody",
1341 )
1342 .expect("write workspace skill");
1343
1344 let configured_dir = tmp.path().join("configured-skills");
1345 let configured_skill_dir = configured_dir.join("configured-skill");
1346 std::fs::create_dir_all(&configured_skill_dir).expect("create configured skill dir");
1347 std::fs::write(
1348 configured_skill_dir.join("SKILL.md"),
1349 "---\nname: configured-skill\ndescription: Configured skill\n---\nbody",
1350 )
1351 .expect("write configured skill");
1352
1353 let entries = build_entries(
1354 Locale::En,
1355 configured_dir.as_path(),
1356 crate::skills::SkillDiscoveryMode::Compatible,
1357 workspace.as_path(),
1358 Path::new("mcp.json"),
1359 None,
1360 );
1361 let skill_labels = entries
1362 .iter()
1363 .filter(|entry| entry.section == PaletteSection::Skill)
1364 .map(|entry| entry.label.as_str())
1365 .collect::<Vec<_>>();
1366
1367 assert!(skill_labels.contains(&"$workspace-skill"));
1368 assert!(skill_labels.contains(&"$configured-skill"));
1369 }
1370
1371 #[test]
1372 fn command_palette_skills_respect_codewhale_only_scan() {
1373 let tmp = TempDir::new().expect("tempdir");
1374 let workspace = tmp.path().join("workspace");
1375 crate::test_support::trust_workspace(&workspace);
1376 let claude_skill_dir = workspace
1377 .join(".claude")
1378 .join("skills")
1379 .join("claude-skill");
1380 std::fs::create_dir_all(&claude_skill_dir).expect("create claude skill dir");
1381 std::fs::write(
1382 claude_skill_dir.join("SKILL.md"),
1383 "---\nname: claude-skill\ndescription: Claude skill\n---\nbody",
1384 )
1385 .expect("write claude skill");
1386 let codewhale_skill_dir = workspace
1387 .join(".codewhale")
1388 .join("skills")
1389 .join("codewhale-skill");
1390 std::fs::create_dir_all(&codewhale_skill_dir).expect("create codewhale skill dir");
1391 std::fs::write(
1392 codewhale_skill_dir.join("SKILL.md"),
1393 "---\nname: codewhale-skill\ndescription: CodeWhale skill\n---\nbody",
1394 )
1395 .expect("write codewhale skill");
1396
1397 let entries = build_entries(
1398 Locale::En,
1399 workspace.join(".codewhale").join("skills").as_path(),
1400 crate::skills::SkillDiscoveryMode::CodeWhaleOnly,
1401 workspace.as_path(),
1402 Path::new("mcp.json"),
1403 None,
1404 );
1405 let skill_labels: Vec<&str> = entries
1406 .iter()
1407 .filter(|entry| entry.section == PaletteSection::Skill)
1408 .map(|entry| entry.label.as_str())
1409 .collect();
1410
1411 assert!(skill_labels.contains(&"$codewhale-skill"));
1412 assert!(!skill_labels.contains(&"$claude-skill"));
1413 }
1414
1415 #[test]
1416 fn command_palette_includes_only_active_reviewed_plugin_skills() {
1417 let _env = crate::test_support::lock_test_env();
1418 let tmp = TempDir::new().expect("tempdir");
1419 let _home =
1420 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join("home"));
1421 let workspace = tmp.path().join("workspace");
1422 let plugin_root = tmp.path().join("plugins/demo");
1423 std::fs::create_dir_all(plugin_root.join("skills/review")).expect("plugin Skill dir");
1424 std::fs::write(
1425 plugin_root.join("plugin.toml"),
1426 "schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n[skills]\npath = \"skills\"\n",
1427 )
1428 .expect("plugin manifest");
1429 std::fs::write(
1430 plugin_root.join("skills/review/SKILL.md"),
1431 "---\nname: review\ndescription: reviewed plugin Skill\n---\nbody\n",
1432 )
1433 .expect("plugin Skill");
1434 let config = crate::plugins::discovery::DiscoveryConfig {
1435 workspace: workspace.clone(),
1436 user_plugins_dir: tmp.path().join("plugins"),
1437 workspace_plugins_dir: workspace.join(".codewhale/plugins"),
1438 builtin_plugin_dirs: Vec::new(),
1439 state_path: tmp.path().join("plugin-state/state.json"),
1440 };
1441 let mut plugins = crate::plugins::discovery::discover_with_config(&config);
1442 let entries_before = build_entries_with_plugins(
1443 Locale::En,
1444 tmp.path().join("skills").as_path(),
1445 crate::skills::SkillDiscoveryMode::Compatible,
1446 &workspace,
1447 Path::new("mcp.json"),
1448 None,
1449 &plugins,
1450 );
1451 assert!(
1452 !entries_before
1453 .iter()
1454 .any(|entry| entry.label == "$demo:review")
1455 );
1456
1457 plugins.trust("demo").expect("trust plugin");
1458 plugins.enable("demo").expect("enable plugin");
1459 let entries_after = build_entries_with_plugins(
1460 Locale::En,
1461 tmp.path().join("skills").as_path(),
1462 crate::skills::SkillDiscoveryMode::Compatible,
1463 &workspace,
1464 Path::new("mcp.json"),
1465 None,
1466 &plugins,
1467 );
1468 let skill = entries_after
1469 .iter()
1470 .find(|entry| entry.label == "$demo:review")
1471 .expect("active reviewed plugin Skill should reach the palette");
1472 assert!(matches!(
1473 &skill.action,
1474 CommandPaletteAction::ExecuteCommand { command } if command == "$demo:review"
1475 ));
1476 }
1477
1478 #[test]
1479 fn command_palette_command_entries_include_links_and_config_but_not_removed_commands() {
1480 let entries = build_entries(
1481 Locale::En,
1482 Path::new("."),
1483 crate::skills::SkillDiscoveryMode::Compatible,
1484 Path::new("."),
1485 Path::new("mcp.json"),
1486 None,
1487 );
1488 let command_labels = entries
1489 .iter()
1490 .filter(|entry| entry.section == PaletteSection::Command)
1491 .map(|entry| entry.label.as_str())
1492 .collect::<Vec<_>>();
1493
1494 assert!(command_labels.contains(&"/config"));
1495 assert!(command_labels.contains(&"/links"));
1496 assert!(command_labels.contains(&"/voice"));
1497 assert!(!command_labels.contains(&"/set"));
1498 assert!(!command_labels.contains(&"/deepseek"));
1499 }
1500
1501 #[test]
1502 fn command_palette_includes_workspace_user_commands() {
1503 let tmp = TempDir::new().expect("tempdir");
1504 let workspace = tmp.path().join("workspace");
1505 let commands_dir = workspace.join(".codewhale").join("commands");
1506 std::fs::create_dir_all(&commands_dir).expect("create commands dir");
1507 crate::config::save_workspace_trust(&workspace).expect("trust workspace");
1508 std::fs::write(
1509 commands_dir.join("review.md"),
1510 "---\ndescription: Review with context\nargument-hint: <path>\n---\nReview $ARGUMENTS",
1511 )
1512 .expect("write user command");
1513
1514 let entries = build_entries(
1515 Locale::En,
1516 tmp.path().join("skills").as_path(),
1517 crate::skills::SkillDiscoveryMode::Compatible,
1518 workspace.as_path(),
1519 tmp.path().join("mcp.json").as_path(),
1520 None,
1521 );
1522 let user_entry = entries
1523 .iter()
1524 .find(|entry| entry.section == PaletteSection::Command && entry.label == "/review")
1525 .expect("user command should appear in command palette");
1526
1527 assert!(user_entry.description.contains("Review with context"));
1528 assert!(user_entry.description.contains("<path>"));
1529 assert!(matches!(
1530 &user_entry.action,
1531 CommandPaletteAction::InsertText { text } if text == "/review "
1532 ));
1533 }
1534
1535 #[test]
1536 fn command_palette_uses_frontmatter_name_usage_and_arguments() {
1537 let tmp = TempDir::new().expect("tempdir");
1538 let workspace = tmp.path().join("workspace");
1539 crate::test_support::trust_workspace(&workspace);
1540 let commands_dir = workspace.join(".codewhale").join("commands");
1541 std::fs::create_dir_all(&commands_dir).expect("create commands dir");
1542 std::fs::write(
1543 commands_dir.join("workflow-file.md"),
1544 "---\nname: inspect\ndescription: Inspect a target\nusage: /inspect <path>\narguments: <path>\nargument-hint: <legacy>\n---\nInspect $ARGUMENTS",
1545 )
1546 .expect("write user command");
1547
1548 let entries = build_entries(
1549 Locale::En,
1550 tmp.path().join("skills").as_path(),
1551 crate::skills::SkillDiscoveryMode::Compatible,
1552 workspace.as_path(),
1553 tmp.path().join("mcp.json").as_path(),
1554 None,
1555 );
1556 let user_entry = entries
1557 .iter()
1558 .find(|entry| entry.section == PaletteSection::Command && entry.label == "/inspect")
1559 .expect("frontmatter name should be the palette command");
1560
1561 assert_eq!(user_entry.description, "Inspect a target /inspect <path>");
1562 assert!(matches!(
1563 &user_entry.action,
1564 CommandPaletteAction::InsertText { text } if text == "/inspect "
1565 ));
1566 assert!(!entries.iter().any(|entry| {
1567 entry.section == PaletteSection::Command && entry.label == "/workflow-file"
1568 }));
1569 }
1570
1571 #[test]
1572 fn command_palette_excludes_hidden_user_commands() {
1573 let tmp = TempDir::new().expect("tempdir");
1574 let workspace = tmp.path().join("workspace");
1575 let commands_dir = workspace.join(".codewhale").join("commands");
1576 std::fs::create_dir_all(&commands_dir).expect("create commands dir");
1577 std::fs::write(
1578 commands_dir.join("secret.md"),
1579 "---\ndescription: Internal workflow\nhidden: true\n---\nsecret",
1580 )
1581 .expect("write hidden user command");
1582
1583 let entries = build_entries(
1584 Locale::En,
1585 tmp.path().join("skills").as_path(),
1586 crate::skills::SkillDiscoveryMode::Compatible,
1587 workspace.as_path(),
1588 tmp.path().join("mcp.json").as_path(),
1589 None,
1590 );
1591
1592 assert!(
1593 !entries
1594 .iter()
1595 .any(|entry| entry.section == PaletteSection::Command && entry.label == "/secret")
1596 );
1597 }
1598
1599 #[test]
1600 fn hidden_frontmatter_name_override_suppresses_shadowed_builtin() {
1601 let tmp = TempDir::new().expect("tempdir");
1602 let workspace = tmp.path().join("workspace");
1603 crate::test_support::trust_workspace(&workspace);
1604 let commands_dir = workspace.join(".codewhale").join("commands");
1605 std::fs::create_dir_all(&commands_dir).expect("create commands dir");
1606 std::fs::write(
1607 commands_dir.join("private-help.md"),
1608 "---\nname: help\nhidden: true\n---\nprivate help",
1609 )
1610 .expect("write hidden user command");
1611
1612 let entries = build_entries(
1613 Locale::En,
1614 tmp.path().join("skills").as_path(),
1615 crate::skills::SkillDiscoveryMode::Compatible,
1616 workspace.as_path(),
1617 tmp.path().join("mcp.json").as_path(),
1618 None,
1619 );
1620
1621 assert!(!entries.iter().any(|entry| {
1622 entry.section == PaletteSection::Command
1623 && matches!(entry.label.as_str(), "/help" | "/private-help")
1624 }));
1625 }
1626
1627 #[test]
1628 fn command_palette_filters_shadowed_builtin_aliases_from_description() {
1629 let tmp = TempDir::new().expect("tempdir");
1630 let workspace = tmp.path().join("workspace");
1631 crate::test_support::trust_workspace(&workspace);
1632 let commands_dir = workspace.join(".codewhale").join("commands");
1633 std::fs::create_dir_all(&commands_dir).expect("create commands dir");
1634 std::fs::write(
1635 commands_dir.join("image-review.md"),
1636 "---\ndescription: Review an image\nalias: image\n---\nreview image",
1637 )
1638 .expect("write user command");
1639
1640 let entries = build_entries(
1641 Locale::En,
1642 tmp.path().join("skills").as_path(),
1643 crate::skills::SkillDiscoveryMode::Compatible,
1644 workspace.as_path(),
1645 tmp.path().join("mcp.json").as_path(),
1646 None,
1647 );
1648 let attach = entries
1649 .iter()
1650 .find(|entry| entry.section == PaletteSection::Command && entry.label == "/attach")
1651 .expect("built-in canonical command should remain visible");
1652
1653 assert!(
1654 !attach.description.contains("aliases: image")
1655 && !attach.description.contains(", image")
1656 && !attach.description.contains("image,"),
1657 "shadowed /image alias must not be advertised by /attach: {}",
1658 attach.description
1659 );
1660 assert!(
1661 entries
1662 .iter()
1663 .any(|entry| entry.section == PaletteSection::Command
1664 && entry.label == "/image-review"),
1665 "user command that owns the /image alias should be visible"
1666 );
1667 }
1668
1669 #[test]
1670 fn command_palette_visible_canonical_shadow_has_exactly_one_user_row() {
1671 // Deep-Dive Q1: a visible user command whose canonical name equals a
1672 // built-in must produce exactly one palette row owned by the user
1673 // command (its metadata and action), never the built-in row.
1674 let tmp = TempDir::new().expect("tempdir");
1675 let workspace = tmp.path().join("workspace");
1676 crate::test_support::trust_workspace(&workspace);
1677 let commands_dir = workspace.join(".codewhale").join("commands");
1678 std::fs::create_dir_all(&commands_dir).expect("create commands dir");
1679 std::fs::write(
1680 commands_dir.join("my-help.md"),
1681 "---\nname: help\ndescription: My private help\nusage: /help <topic>\n---\nhelp $ARGUMENTS",
1682 )
1683 .expect("write user command");
1684
1685 let entries = build_entries(
1686 Locale::En,
1687 tmp.path().join("skills").as_path(),
1688 crate::skills::SkillDiscoveryMode::Compatible,
1689 workspace.as_path(),
1690 tmp.path().join("mcp.json").as_path(),
1691 None,
1692 );
1693 let rows = entries
1694 .iter()
1695 .filter(|entry| entry.section == PaletteSection::Command && entry.label == "/help")
1696 .collect::<Vec<_>>();
1697
1698 assert_eq!(rows.len(), 1, "exactly one /help row must exist");
1699 assert!(
1700 rows[0].description.contains("My private help"),
1701 "row must carry the user command metadata: {}",
1702 rows[0].description
1703 );
1704 assert!(
1705 rows[0].description.contains("/help <topic>"),
1706 "row must carry the user command usage: {}",
1707 rows[0].description
1708 );
1709 assert!(
1710 matches!(&rows[0].action, CommandPaletteAction::InsertText { text } if text == "/help "),
1711 "row must carry the user command action"
1712 );
1713 }
1714
1715 #[test]
1716 fn command_palette_accepted_alias_suppresses_builtin_canonical_row() {
1717 // A visible user command whose accepted alias equals a built-in
1718 // canonical token must suppress the built-in row in the palette,
1719 // matching the shared alias-aware contract.
1720 let tmp = TempDir::new().expect("tempdir");
1721 let workspace = tmp.path().join("workspace");
1722 crate::test_support::trust_workspace(&workspace);
1723 let commands_dir = workspace.join(".codewhale").join("commands");
1724 std::fs::create_dir_all(&commands_dir).expect("create commands dir");
1725 std::fs::write(
1726 commands_dir.join("assistant.md"),
1727 "---\ndescription: My assistant\nalias: help\n---\nassistant",
1728 )
1729 .expect("write user command");
1730
1731 let entries = build_entries(
1732 Locale::En,
1733 tmp.path().join("skills").as_path(),
1734 crate::skills::SkillDiscoveryMode::Compatible,
1735 workspace.as_path(),
1736 tmp.path().join("mcp.json").as_path(),
1737 None,
1738 );
1739
1740 assert!(
1741 !entries
1742 .iter()
1743 .any(|entry| entry.section == PaletteSection::Command && entry.label == "/help"),
1744 "built-in canonical row must be suppressed when a user alias claims /help"
1745 );
1746 assert!(
1747 entries.iter().any(
1748 |entry| entry.section == PaletteSection::Command && entry.label == "/assistant"
1749 ),
1750 "the user command must appear under its own canonical name"
1751 );
1752 }
1753
1754 #[test]
1755 fn command_palette_hidden_canonical_shadow_exposes_no_discovery_row() {
1756 // A hidden user command claiming a built-in canonical token must not
1757 // surface either row: the hidden command is excluded from output while
1758 // still owning the token (AT-008 boundary in the palette).
1759 let tmp = TempDir::new().expect("tempdir");
1760 let workspace = tmp.path().join("workspace");
1761 crate::test_support::trust_workspace(&workspace);
1762 let commands_dir = workspace.join(".codewhale").join("commands");
1763 std::fs::create_dir_all(&commands_dir).expect("create commands dir");
1764 std::fs::write(
1765 commands_dir.join("private-help.md"),
1766 "---\nname: help\nhidden: true\n---\nprivate help",
1767 )
1768 .expect("write hidden user command");
1769
1770 let entries = build_entries(
1771 Locale::En,
1772 tmp.path().join("skills").as_path(),
1773 crate::skills::SkillDiscoveryMode::Compatible,
1774 workspace.as_path(),
1775 tmp.path().join("mcp.json").as_path(),
1776 None,
1777 );
1778
1779 assert!(
1780 !entries
1781 .iter()
1782 .any(|entry| entry.section == PaletteSection::Command && entry.label == "/help"),
1783 "neither the hidden user command nor the shadowed built-in may appear"
1784 );
1785 }
1786
1787 #[test]
1788 fn command_palette_alias_shadow_preserves_canonical_row_without_claimed_alias() {
1789 // A user command claiming only one built-in alias must leave the
1790 // built-in canonical row visible and remove only the claimed alias
1791 // from its description.
1792 let tmp = TempDir::new().expect("tempdir");
1793 let workspace = tmp.path().join("workspace");
1794 crate::test_support::trust_workspace(&workspace);
1795 let commands_dir = workspace.join(".codewhale").join("commands");
1796 std::fs::create_dir_all(&commands_dir).expect("create commands dir");
1797 std::fs::write(
1798 commands_dir.join("image-review.md"),
1799 "---\ndescription: Review an image\nalias: image\n---\nreview image",
1800 )
1801 .expect("write user command");
1802
1803 let entries = build_entries(
1804 Locale::En,
1805 tmp.path().join("skills").as_path(),
1806 crate::skills::SkillDiscoveryMode::Compatible,
1807 workspace.as_path(),
1808 tmp.path().join("mcp.json").as_path(),
1809 None,
1810 );
1811 let attach_rows = entries
1812 .iter()
1813 .filter(|entry| entry.section == PaletteSection::Command && entry.label == "/attach")
1814 .collect::<Vec<_>>();
1815
1816 assert_eq!(
1817 attach_rows.len(),
1818 1,
1819 "built-in canonical row must appear once"
1820 );
1821 assert!(
1822 !attach_rows[0].description.contains("aliases: image")
1823 && !attach_rows[0].description.contains(", image")
1824 && !attach_rows[0].description.contains("image,"),
1825 "claimed /image alias must be absent from /attach description: {}",
1826 attach_rows[0].description
1827 );
1828 }
1829
1830 #[test]
1831 fn command_palette_has_one_entry_for_every_registered_command() {
1832 let tmp = TempDir::new().expect("tempdir");
1833 let skills_dir = tmp.path().join("skills");
1834 let mcp_config_path = tmp.path().join("mcp.json");
1835 let entries = build_entries(
1836 Locale::En,
1837 skills_dir.as_path(),
1838 crate::skills::SkillDiscoveryMode::Compatible,
1839 tmp.path(),
1840 mcp_config_path.as_path(),
1841 None,
1842 );
1843
1844 let command_entries = entries
1845 .iter()
1846 .filter(|entry| entry.section == PaletteSection::Command)
1847 .collect::<Vec<_>>();
1848 let user_registry = commands::user_registry::registry_for_workspace(Some(tmp.path()));
1849 let visible_user_commands = user_registry
1850 .iter()
1851 .filter(|command| !command.hidden)
1852 .count();
1853 let shadowed_builtins = commands::command_infos()
1854 .iter()
1855 .filter(|command| user_registry.get(command.name).is_some())
1856 .count();
1857 // Unlisted commands run when typed but are never advertised — see
1858 // `commands::traits::UNLISTED_COMMANDS`.
1859 let unlisted = commands::command_infos()
1860 .iter()
1861 .filter(|command| command.is_unlisted() && user_registry.get(command.name).is_none())
1862 .count();
1863 assert_eq!(
1864 command_entries.len(),
1865 commands::command_infos().len() - shadowed_builtins - unlisted + visible_user_commands
1866 );
1867
1868 for command in commands::command_infos() {
1869 if user_registry.get(command.name).is_some() || command.is_unlisted() {
1870 continue;
1871 }
1872 let label = format!("/{}", command.name);
1873 let matching = command_entries
1874 .iter()
1875 .filter(|entry| entry.label == label)
1876 .collect::<Vec<_>>();
1877 assert_eq!(
1878 matching.len(),
1879 1,
1880 "expected one palette entry for /{}",
1881 command.name
1882 );
1883
1884 let entry = matching[0];
1885 assert_eq!(entry.command, command.palette_command());
1886 assert!(
1887 entry
1888 .description
1889 .contains(&*command.description_for(Locale::En)),
1890 "/{} palette description should include command help text",
1891 command.name
1892 );
1893 if command.requires_argument() {
1894 assert!(
1895 entry.description.contains(command.usage),
1896 "/{} palette description should include usage {:?}",
1897 command.name,
1898 command.usage
1899 );
1900 }
1901 }
1902 }
1903
1904 #[test]
1905 fn command_palette_hides_toolbox_commands_until_searched() {
1906 let entries = build_entries(
1907 Locale::En,
1908 Path::new("."),
1909 crate::skills::SkillDiscoveryMode::Compatible,
1910 Path::new("."),
1911 Path::new("mcp.json"),
1912 None,
1913 );
1914 let mut view = CommandPaletteView::new(entries);
1915 let root_labels = view
1916 .filtered
1917 .iter()
1918 .map(|idx| view.entries[*idx].label.as_str())
1919 .collect::<Vec<_>>();
1920
1921 assert!(root_labels.contains(&"/provider"));
1922 assert!(root_labels.contains(&"/model"));
1923 assert!(root_labels.contains(&"/fleet"));
1924 assert!(!root_labels.contains(&"/pod"));
1925 assert!(root_labels.contains(&"/config"));
1926 assert!(root_labels.contains(&"/statusline"));
1927 assert!(!root_labels.contains(&"/rlm"));
1928 assert!(!root_labels.contains(&"/modeldb"));
1929 assert!(!root_labels.contains(&"/models"));
1930 assert!(!root_labels.contains(&"/subagents"));
1931
1932 view.query = "rlm".to_string();
1933 view.refilter();
1934 assert!(
1935 view.filtered
1936 .iter()
1937 .any(|idx| view.entries[*idx].label == "/rlm"),
1938 "advanced /rlm should still be searchable"
1939 );
1940 }
1941
1942 #[test]
1943 fn command_palette_runs_model_command_to_open_picker() {
1944 let entries = build_entries(
1945 Locale::En,
1946 Path::new("."),
1947 crate::skills::SkillDiscoveryMode::Compatible,
1948 Path::new("."),
1949 Path::new("mcp.json"),
1950 None,
1951 );
1952 let model = entries
1953 .iter()
1954 .find(|entry| entry.section == PaletteSection::Command && entry.label == "/model")
1955 .expect("model command entry");
1956
1957 assert_eq!(model.command, "/model ");
1958 assert!(matches!(
1959 &model.action,
1960 CommandPaletteAction::ExecuteCommand { command } if command == "/model"
1961 ));
1962 }
1963
1964 #[test]
1965 fn command_palette_runs_change_without_requiring_version() {
1966 let entries = build_entries(
1967 Locale::En,
1968 Path::new("."),
1969 crate::skills::SkillDiscoveryMode::Compatible,
1970 Path::new("."),
1971 Path::new("mcp.json"),
1972 None,
1973 );
1974 let change = entries
1975 .iter()
1976 .find(|entry| entry.section == PaletteSection::Command && entry.label == "/change")
1977 .expect("change command entry");
1978
1979 assert!(matches!(
1980 &change.action,
1981 CommandPaletteAction::ExecuteCommand { command } if command == "/change"
1982 ));
1983 }
1984
1985 #[test]
1986 fn palette_paste_only_names_are_registered_canonical_commands() {
1987 let registered: std::collections::HashSet<&str> = commands::command_infos()
1988 .iter()
1989 .map(|info| info.name)
1990 .collect();
1991 for name in commands::traits::PALETTE_PASTE_ONLY {
1992 assert!(
1993 registered.contains(name),
1994 "PALETTE_PASTE_ONLY entry `{name}` is not a registered command"
1995 );
1996 }
1997 }
1998
1999 #[test]
2000 fn command_palette_direct_execute_follows_command_metadata() {
2001 let tmp = TempDir::new().expect("tempdir");
2002 let skills_dir = tmp.path().join("skills");
2003 let mcp_config_path = tmp.path().join("mcp.json");
2004 let entries = build_entries(
2005 Locale::En,
2006 skills_dir.as_path(),
2007 crate::skills::SkillDiscoveryMode::Compatible,
2008 tmp.path(),
2009 mcp_config_path.as_path(),
2010 None,
2011 );
2012 let user_registry = commands::user_registry::registry_for_workspace(Some(tmp.path()));
2013
2014 for command in commands::command_infos() {
2015 if user_registry.get(command.name).is_some() || command.is_unlisted() {
2016 continue;
2017 }
2018 let label = format!("/{}", command.name);
2019 let entry = entries
2020 .iter()
2021 .find(|entry| entry.section == PaletteSection::Command && entry.label == label)
2022 .unwrap_or_else(|| panic!("missing palette entry for {label}"));
2023
2024 if command.palette_runs_directly() {
2025 assert!(
2026 matches!(
2027 &entry.action,
2028 CommandPaletteAction::ExecuteCommand { command: c }
2029 if c == &format!("/{}", command.name)
2030 ),
2031 "/{} should execute directly from the palette (no required arg)",
2032 command.name
2033 );
2034 } else {
2035 assert!(
2036 matches!(
2037 &entry.action,
2038 CommandPaletteAction::InsertText { text }
2039 if text == &command.palette_command()
2040 ),
2041 "/{} should paste for required arguments (usage: {})",
2042 command.name,
2043 command.usage
2044 );
2045 }
2046 }
2047
2048 // Drift traps from #3911: no-arg rows must not silently paste, and
2049 // dead mode-arg names must never reappear as palette allowlist entries.
2050 for no_arg in [
2051 "cost",
2052 "diff",
2053 "edit",
2054 "purge",
2055 "setup",
2056 "hotbar",
2057 "translate",
2058 ] {
2059 let label = format!("/{no_arg}");
2060 let entry = entries
2061 .iter()
2062 .find(|entry| entry.section == PaletteSection::Command && entry.label == label)
2063 .unwrap_or_else(|| panic!("missing palette entry for {label}"));
2064 assert!(
2065 matches!(&entry.action, CommandPaletteAction::ExecuteCommand { .. }),
2066 "/{no_arg} is no-arg and must run directly"
2067 );
2068 }
2069 for required in ["rename", "attach", "profile", "review"] {
2070 let label = format!("/{required}");
2071 let entry = entries
2072 .iter()
2073 .find(|entry| entry.section == PaletteSection::Command && entry.label == label)
2074 .unwrap_or_else(|| panic!("missing palette entry for {label}"));
2075 assert!(
2076 matches!(&entry.action, CommandPaletteAction::InsertText { .. }),
2077 "/{required} requires an argument and must paste"
2078 );
2079 }
2080 }
2081
2082 #[test]
2083 fn command_palette_includes_mcp_discovery_and_failed_servers() {
2084 let snapshot = crate::mcp::McpManagerSnapshot {
2085 config_path: Path::new("mcp.json").to_path_buf(),
2086 config_exists: true,
2087 reload_required: false,
2088 servers: vec![
2089 crate::mcp::McpServerSnapshot {
2090 name: "fs".to_string(),
2091 enabled: true,
2092 required: false,
2093 transport: "stdio".to_string(),
2094 command_or_url: "node server.js".to_string(),
2095 connect_timeout: 10,
2096 execute_timeout: 60,
2097 read_timeout: 120,
2098 connected: true,
2099 error: None,
2100 auth_required: false,
2101 capability_metadata: crate::mcp::McpServerCapabilityMetadata::LegacyFallback,
2102 tools: vec![crate::mcp::McpDiscoveredItem {
2103 name: "read".to_string(),
2104 model_name: "mcp_fs_read".to_string(),
2105 description: Some("Read files".to_string()),
2106 }],
2107 resources: Vec::new(),
2108 prompts: Vec::new(),
2109 },
2110 crate::mcp::McpServerSnapshot {
2111 name: "broken".to_string(),
2112 enabled: true,
2113 required: false,
2114 transport: "http/sse".to_string(),
2115 command_or_url: "https://example.invalid/mcp".to_string(),
2116 connect_timeout: 10,
2117 execute_timeout: 60,
2118 read_timeout: 120,
2119 connected: false,
2120 error: Some("connect failed".to_string()),
2121 auth_required: false,
2122 capability_metadata: crate::mcp::McpServerCapabilityMetadata::NotObserved,
2123 tools: Vec::new(),
2124 resources: Vec::new(),
2125 prompts: Vec::new(),
2126 },
2127 ],
2128 };
2129 let entries = build_entries(
2130 Locale::En,
2131 Path::new("."),
2132 crate::skills::SkillDiscoveryMode::Compatible,
2133 Path::new("."),
2134 Path::new("mcp.json"),
2135 Some(&snapshot),
2136 );
2137
2138 assert!(entries.iter().any(|entry| entry.label == "mcp:manager"));
2139 assert!(entries.iter().any(|entry| entry.command == "mcp_fs_read"));
2140 let failed = entries
2141 .iter()
2142 .find(|entry| entry.label == "mcp:broken")
2143 .expect("failed server visible");
2144 assert!(failed.description.contains("failed"));
2145
2146 // Verify the "use" insert entry for MCP tools
2147 let use_entry = entries
2148 .iter()
2149 .find(|entry| entry.label == "mcp:fs:tool:read > use")
2150 .expect("MCP tool use entry should exist");
2151 assert!(matches!(
2152 &use_entry.action,
2153 CommandPaletteAction::InsertText { text } if text == "mcp_fs_read"
2154 ));
2155 assert_eq!(use_entry.command, "mcp_fs_read");
2156 }
2157
2158 #[test]
2159 fn command_palette_marks_disabled_servers_visibly() {
2160 // The healthy/failed cases are covered above; disabled was the
2161 // remaining gap from #197's acceptance list. Disabled servers must
2162 // appear in the palette with a `[disabled]` state tag so users can
2163 // see them without opening the MCP manager.
2164 let snapshot = crate::mcp::McpManagerSnapshot {
2165 config_path: Path::new("mcp.json").to_path_buf(),
2166 config_exists: true,
2167 reload_required: false,
2168 servers: vec![crate::mcp::McpServerSnapshot {
2169 name: "muted".to_string(),
2170 enabled: false,
2171 required: false,
2172 transport: "stdio".to_string(),
2173 command_or_url: "node disabled.js".to_string(),
2174 connect_timeout: 10,
2175 execute_timeout: 60,
2176 read_timeout: 120,
2177 connected: false,
2178 error: None,
2179 auth_required: false,
2180 capability_metadata: crate::mcp::McpServerCapabilityMetadata::NotObserved,
2181 tools: Vec::new(),
2182 resources: Vec::new(),
2183 prompts: Vec::new(),
2184 }],
2185 };
2186 let entries = build_entries(
2187 Locale::En,
2188 Path::new("."),
2189 crate::skills::SkillDiscoveryMode::Compatible,
2190 Path::new("."),
2191 Path::new("mcp.json"),
2192 Some(&snapshot),
2193 );
2194
2195 let muted = entries
2196 .iter()
2197 .find(|entry| entry.label == "mcp:muted")
2198 .expect("disabled server should still appear in the palette");
2199 assert!(
2200 muted.description.contains("[disabled]"),
2201 "expected `[disabled]` state tag in description, got: {}",
2202 muted.description
2203 );
2204 }
2205
2206 #[test]
2207 fn command_palette_emits_actions_not_raw_insertions() {
2208 let entries = vec![CommandPaletteEntry {
2209 section: PaletteSection::Command,
2210 label: "/config".to_string(),
2211 description: "open config".to_string(),
2212 command: "/config".to_string(),
2213 action: CommandPaletteAction::ExecuteCommand {
2214 command: "/config".to_string(),
2215 },
2216 show_on_empty_query: true,
2217 }];
2218 let mut view = CommandPaletteView::new(entries);
2219
2220 let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()));
2221 assert!(matches!(
2222 action,
2223 ViewAction::EmitAndClose(ViewEvent::CommandPaletteSelected {
2224 action: CommandPaletteAction::ExecuteCommand { .. }
2225 })
2226 ));
2227 }
2228
2229 #[test]
2230 fn command_palette_mouse_runs_the_same_skill_action_as_enter() {
2231 let entries = vec![
2232 palette_entry(PaletteSection::Command, "/config", "open config", "/config"),
2233 CommandPaletteEntry {
2234 section: PaletteSection::Skill,
2235 label: "$plugin:review".to_string(),
2236 description: "review from an enabled plugin".to_string(),
2237 command: "$plugin:review".to_string(),
2238 action: CommandPaletteAction::ExecuteCommand {
2239 command: "$plugin:review".to_string(),
2240 },
2241 show_on_empty_query: true,
2242 },
2243 ];
2244 let mut keyboard = CommandPaletteView::new(entries.clone());
2245 keyboard.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::empty()));
2246 let keyboard_action =
2247 keyboard.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()));
2248
2249 let mut mouse = CommandPaletteView::new(entries);
2250 let area = Rect::new(0, 0, 100, 30);
2251 let mut buf = Buffer::empty(area);
2252 mouse.render(area, &mut buf);
2253 let (rect, _) = mouse
2254 .row_hitboxes
2255 .borrow()
2256 .iter()
2257 .find(|(_, index)| *index == 1)
2258 .copied()
2259 .expect("plugin Skill row should have a mouse hitbox");
2260 let click = MouseEvent {
2261 kind: MouseEventKind::Down(MouseButton::Left),
2262 column: rect.x,
2263 row: rect.y,
2264 modifiers: KeyModifiers::empty(),
2265 };
2266 assert!(matches!(mouse.handle_mouse(click), ViewAction::None));
2267 let mouse_action = mouse.handle_mouse(click);
2268
2269 for action in [keyboard_action, mouse_action] {
2270 assert!(matches!(
2271 action,
2272 ViewAction::EmitAndClose(ViewEvent::CommandPaletteSelected {
2273 action: CommandPaletteAction::ExecuteCommand { command }
2274 }) if command == "$plugin:review"
2275 ));
2276 }
2277 }
2278
2279 #[test]
2280 fn command_palette_hover_tints_entry_without_moving_selection() {
2281 let mut view = sample_palette_view();
2282 let area = Rect::new(0, 0, 100, 30);
2283 let mut buf = Buffer::empty(area);
2284 view.render(area, &mut buf);
2285 assert_eq!(view.selected, 0);
2286 let (rect, _) = view
2287 .row_hitboxes
2288 .borrow()
2289 .iter()
2290 .find(|(_, index)| *index == 1)
2291 .copied()
2292 .expect("second entry should have a mouse hitbox");
2293 let hover = MouseEvent {
2294 kind: MouseEventKind::Moved,
2295 column: rect.x,
2296 row: rect.y,
2297 modifiers: KeyModifiers::empty(),
2298 };
2299 assert!(matches!(view.handle_mouse(hover), ViewAction::None));
2300 assert_eq!(view.hovered.get(), Some(1));
2301 assert_eq!(view.selected, 0);
2302
2303 let mut hovered_buf = Buffer::empty(area);
2304 view.render(area, &mut hovered_buf);
2305 assert_eq!(
2306 hovered_buf[(rect.x, rect.y)].bg,
2307 codewhale_palette::SURFACE_ELEVATED,
2308 "hovered palette entry must show the shared hover band"
2309 );
2310 }
2311
2312 /// The four terminal sizes the v0.8.66 modal blocker (#3732) requires every
2313 /// overlay to remain readable and fully operable at.
2314 const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
2315
2316 fn sample_palette_view() -> CommandPaletteView {
2317 let entries = vec![
2318 palette_entry(PaletteSection::Command, "/config", "open config", "/config"),
2319 palette_entry(PaletteSection::Command, "/model", "choose model", "/model"),
2320 palette_entry(PaletteSection::Skill, "$search", "search skill", "$search"),
2321 palette_entry(PaletteSection::Tool, "tool:git", "git tool", "git"),
2322 palette_entry(PaletteSection::Mcp, "mcp:fs", "filesystem", "mcp_fs_read"),
2323 ];
2324 CommandPaletteView::new(entries)
2325 }
2326
2327 #[test]
2328 fn command_palette_is_usable_and_opaque_at_blocker_sizes() {
2329 use crate::tui::views::ViewStack;
2330 for (w, h) in BLOCKER_SIZES {
2331 let area = Rect::new(0, 0, w, h);
2332 let mut buf = Buffer::empty(area);
2333 for y in 0..h {
2334 for x in 0..w {
2335 buf[(x, y)].set_symbol("X");
2336 }
2337 }
2338 let mut stack = ViewStack::new();
2339 stack.push(sample_palette_view());
2340 stack.render(area, &mut buf);
2341
2342 let rows: Vec<String> = (0..h)
2343 .map(|y| (0..w).map(|x| buf[(x, y)].symbol().to_string()).collect())
2344 .collect();
2345 let text = rows.join("\n");
2346
2347 // Footer keeps every action.
2348 assert!(text.contains("move"), "{w}x{h}: missing 'move' hint");
2349 assert!(text.contains("select"), "{w}x{h}: missing 'select' hint");
2350 assert!(text.contains("cancel"), "{w}x{h}: missing 'cancel' hint");
2351
2352 // The selected row carries the charter pointer glyph.
2353 assert!(
2354 text.contains(crate::tui::glyphs::SELECTION),
2355 "{w}x{h}: selected row missing charter pointer"
2356 );
2357
2358 // Header stays compact: scope help is a single line, with no
2359 // multi-line "Try:" example block crowding out entries.
2360 assert!(text.contains("Type to filter"), "{w}x{h}: missing prompt");
2361 assert!(
2362 !text.contains("Try:"),
2363 "{w}x{h}: scope example block should stay collapsed"
2364 );
2365
2366 // Composited frame is fully opaque.
2367 assert!(!text.contains('X'), "{w}x{h}: background bleed-through");
2368 assert_eq!(
2369 buf[(w / 2, h / 2)].bg,
2370 palette::WHALE_BG,
2371 "{w}x{h}: modal interior must be opaque"
2372 );
2373
2374 // No horizontal overflow.
2375 for (y, row) in rows.iter().enumerate() {
2376 assert!(
2377 UnicodeWidthStr::width(row.trim_end()) <= w as usize,
2378 "{w}x{h}: row {y} overflows width: {row:?}"
2379 );
2380 }
2381 }
2382 }
2383
2384 #[test]
2385 fn command_palette_selected_row_uses_shared_selection_style_at_blocker_sizes() {
2386 use crate::tui::views::ViewStack;
2387 for (w, h) in BLOCKER_SIZES {
2388 let area = Rect::new(0, 0, w, h);
2389 let mut buf = Buffer::empty(area);
2390 let mut stack = ViewStack::new();
2391 stack.push(sample_palette_view());
2392 stack.render(area, &mut buf);
2393
2394 // The first entry ("/config") is selected by default; find its row.
2395 let selected_y = (0..h)
2396 .find(|&y| {
2397 let row: String = (0..w).map(|x| buf[(x, y)].symbol()).collect();
2398 row.contains("/config")
2399 })
2400 .unwrap_or_else(|| panic!("{w}x{h}: selected entry should render"));
2401 let selected_cells = (0..w)
2402 .filter(|&x| {
2403 let cell = &buf[(x, selected_y)];
2404 !cell.symbol().trim().is_empty()
2405 && cell.bg == palette::SELECTION_BG
2406 && cell.fg == palette::SELECTION_TEXT
2407 })
2408 .count();
2409 assert!(
2410 selected_cells >= "/config".len(),
2411 "{w}x{h}: selected row must render with the shared selection style \
2412 (palette::SELECTION_TEXT on palette::SELECTION_BG)"
2413 );
2414 }
2415 }
2416 }
2417
2417 lines RUST