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