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