返回 CodeWhale
help.rs
根目录 / crates / tui / src / tui / views / help.rs
1 //! Searchable help overlay for `Alt+?`, `F1`, and `Ctrl+/`.
2 //!
3 //! Renders two stacked sections — *Slash commands* and *Keybindings* — with
4 //! a live substring filter applied as the user types in the search box. The
5 //! entry point decides which section comes first: `/help` and context-menu
6 //! Help lead with commands, while keyboard shortcuts lead with the key
7 //! reference that the footer promises. The command list is sourced from
8 //! [`crate::commands::command_infos()`] and the keybinding list from
9 //! [`crate::tui::keybindings::KEYBINDINGS`] so neither can drift from the
10 //! wired-up handlers.
11 //!
12 //! Keys: any printable character extends the filter, `Backspace` (or `Ctrl+H`)
13 //! shrinks it,
14 //! `↑`/`↓` (or `Ctrl+P`/`Ctrl+N`) move the selection, `PgUp`/`PgDn` jump by
15 //! ten rows, `Home`/`End` jump to ends, and `Esc` closes. Pressing `?` again
16 //! at the call-site (`tui::ui`) also toggles the overlay closed.
17
18 use std::borrow::Cow;
19 use std::cell::RefCell;
20 use std::path::Path;
21
22 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
23 use ratatui::{
24 buffer::Buffer,
25 layout::Rect,
26 style::{Modifier, Style},
27 text::{Line, Span},
28 widgets::{Paragraph, Widget},
29 };
30 use unicode_width::UnicodeWidthStr;
31
32 use crate::commands;
33 use crate::localization::{Locale, MessageId, tr};
34 use crate::palette;
35 use crate::tui::keybindings::KEYBINDINGS;
36 use crate::tui::menu_style;
37 use crate::tui::views::{
38 ActionHint, ModalKind, ModalView, ViewAction, render_modal_footer, render_panel_scroll_rail,
39 render_underwater_surface,
40 };
41
42 /// Two top-level sections rendered in the overlay.
43 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
44 enum HelpSection {
45 Command,
46 UserCommand,
47 Skill,
48 Keybinding,
49 }
50
51 impl HelpSection {
52 fn label(self, locale: Locale) -> Cow<'static, str> {
53 match self {
54 Self::Command => tr(locale, MessageId::HelpSlashCommands),
55 Self::UserCommand => tr(locale, MessageId::HelpUserCommands),
56 Self::Skill => tr(locale, MessageId::HelpSkills),
57 Self::Keybinding => tr(locale, MessageId::HelpKeybindings),
58 }
59 }
60 }
61
62 /// Which reference surface owns the first visible section when Help opens.
63 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
64 pub enum HelpOrdering {
65 /// `/help` and context-menu Help are command discovery surfaces.
66 CommandsFirst,
67 /// F1 and its Ctrl+/ and Alt+? fallbacks open the keyboard reference.
68 KeybindingsFirst,
69 }
70
71 impl HelpOrdering {
72 fn section_rank(self, section: HelpSection) -> u8 {
73 // User commands and skills sit with the built-in commands: they are
74 // the same kind of thing to the user (#3912), so a keyboard-reference
75 // open still sorts every command surface below the chords.
76 match (self, section) {
77 (Self::CommandsFirst, HelpSection::Command) => 0,
78 (Self::CommandsFirst, HelpSection::UserCommand) => 1,
79 (Self::CommandsFirst, HelpSection::Skill) => 2,
80 (Self::CommandsFirst, HelpSection::Keybinding) => 3,
81 (Self::KeybindingsFirst, HelpSection::Keybinding) => 0,
82 (Self::KeybindingsFirst, HelpSection::Command) => 1,
83 (Self::KeybindingsFirst, HelpSection::UserCommand) => 2,
84 (Self::KeybindingsFirst, HelpSection::Skill) => 3,
85 }
86 }
87 }
88
89 #[derive(Debug, Clone)]
90 struct HelpEntry {
91 section: HelpSection,
92 /// Sort-within-section key — keybinding entries reuse their declared
93 /// section's rank so the help overlay groups Navigation, Editing, … in
94 /// the same order as `tui::keybindings`.
95 sub_rank: u8,
96 label: String,
97 description: String,
98 /// Lowercased haystack used for substring matching; pre-built so each
99 /// keystroke does not re-allocate per entry.
100 haystack: String,
101 }
102
103 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
104 enum HelpRenderRow {
105 Section(HelpSection),
106 Entry { slot: usize, entry_idx: usize },
107 }
108
109 pub struct HelpView {
110 locale: Locale,
111 ordering: HelpOrdering,
112 entries: Vec<HelpEntry>,
113 /// Indices into `entries`, in display order, after filtering.
114 filtered: Vec<usize>,
115 query: String,
116 selected: usize,
117 row_hitboxes: RefCell<Vec<(Rect, usize)>>,
118 }
119
120 impl Default for HelpView {
121 fn default() -> Self {
122 Self::new()
123 }
124 }
125
126 impl HelpView {
127 pub fn new() -> Self {
128 Self::new_for_locale(Locale::En)
129 }
130
131 pub fn new_for_locale(locale: Locale) -> Self {
132 Self::new_with_ordering(locale, HelpOrdering::CommandsFirst)
133 }
134
135 /// Discoverability index over every user-invocable surface (#3912):
136 /// built-ins, workspace commands, and discovered skills. `skills` comes
137 /// from `App::cached_skills`; pass `&[]` only where none are discovered.
138 pub fn new_for_workspace(
139 locale: Locale,
140 workspace: &Path,
141 skills: &[(String, String)],
142 ) -> Self {
143 commands::user_registry::with_registry_for_workspace(Some(workspace), |registry| {
144 Self::new_with_registry(locale, HelpOrdering::CommandsFirst, registry, skills)
145 })
146 }
147
148 /// Open Help as the keyboard reference promised by shell shortcut hints.
149 pub fn new_for_shortcuts(
150 locale: Locale,
151 workspace: &Path,
152 skills: &[(String, String)],
153 ) -> Self {
154 commands::user_registry::with_registry_for_workspace(Some(workspace), |registry| {
155 Self::new_with_registry(locale, HelpOrdering::KeybindingsFirst, registry, skills)
156 })
157 }
158
159 fn new_with_ordering(locale: Locale, ordering: HelpOrdering) -> Self {
160 let registry = commands::user_registry::UserCommandRegistry::new();
161 Self::new_with_registry(locale, ordering, &registry, &[])
162 }
163
164 fn new_with_registry(
165 locale: Locale,
166 ordering: HelpOrdering,
167 registry: &commands::user_registry::UserCommandRegistry,
168 skills: &[(String, String)],
169 ) -> Self {
170 let entries = build_entries(locale, registry, skills);
171 let mut view = Self {
172 locale,
173 ordering,
174 entries,
175 filtered: Vec::new(),
176 query: String::new(),
177 selected: 0,
178 row_hitboxes: RefCell::new(Vec::new()),
179 };
180 view.refilter();
181 view
182 }
183
184 fn tr(&self, id: MessageId) -> Cow<'static, str> {
185 tr(self.locale, id)
186 }
187
188 fn refilter(&mut self) {
189 // Substring matching is intentional — fuzzy matchers can hide the
190 // exact-prefix hit a user is typing toward, which is the wrong
191 // failure mode for a *help* surface. We split on whitespace so
192 // multi-term queries (`apply mode`) act as an AND.
193 let query = self.query.trim().to_ascii_lowercase();
194 let terms: Vec<&str> = query
195 .split_whitespace()
196 .filter(|term| !term.is_empty())
197 .collect();
198
199 let mut filtered: Vec<usize> = self
200 .entries
201 .iter()
202 .enumerate()
203 .filter(|(_, entry)| terms.iter().all(|term| entry.haystack.contains(term)))
204 .map(|(idx, _)| idx)
205 .collect();
206
207 filtered.sort_by_key(|idx| {
208 let entry = &self.entries[*idx];
209 (
210 self.ordering.section_rank(entry.section),
211 entry.sub_rank,
212 entry.label.clone(),
213 )
214 });
215 self.filtered = filtered;
216 if self.selected >= self.filtered.len() {
217 self.selected = self.filtered.len().saturating_sub(1);
218 }
219 }
220
221 fn move_selection(&mut self, delta: isize) {
222 // #4755: help list wraps at both ends (same as other modal lists).
223 self.selected = crate::tui::list_nav::wrap_index(self.selected, self.filtered.len(), delta);
224 }
225
226 fn move_selection_wrapping(&mut self, delta: isize) {
227 self.move_selection(delta);
228 }
229
230 fn render_rows(&self) -> Vec<HelpRenderRow> {
231 let mut rows = Vec::new();
232 let mut active_section: Option<HelpSection> = None;
233
234 for (slot, entry_idx) in self.filtered.iter().copied().enumerate() {
235 let entry = &self.entries[entry_idx];
236 if active_section != Some(entry.section) {
237 rows.push(HelpRenderRow::Section(entry.section));
238 active_section = Some(entry.section);
239 }
240 rows.push(HelpRenderRow::Entry { slot, entry_idx });
241 }
242
243 rows
244 }
245
246 fn selected_render_row(rows: &[HelpRenderRow], selected: usize) -> usize {
247 rows.iter()
248 .position(|row| matches!(row, HelpRenderRow::Entry { slot, .. } if *slot == selected))
249 .unwrap_or(0)
250 }
251
252 fn visible_row_start(rows: &[HelpRenderRow], selected: usize, visible_budget: usize) -> usize {
253 if rows.len() <= visible_budget {
254 return 0;
255 }
256
257 let selected_row = Self::selected_render_row(rows, selected);
258 let half = visible_budget / 2;
259 if selected_row <= half {
260 0
261 } else if selected_row + half >= rows.len() {
262 rows.len().saturating_sub(visible_budget)
263 } else {
264 selected_row.saturating_sub(half)
265 }
266 }
267 }
268
269 fn build_entries(
270 locale: Locale,
271 registry: &commands::user_registry::UserCommandRegistry,
272 skills: &[(String, String)],
273 ) -> Vec<HelpEntry> {
274 let mut entries = Vec::new();
275
276 for command in commands::command_infos() {
277 if registry.get(command.name).is_some() {
278 continue;
279 }
280 let label = format!("/{}", command.name);
281 let localized = command.description_for(locale);
282 let visible_aliases = command
283 .aliases
284 .iter()
285 .copied()
286 .filter(|alias| registry.get(alias).is_none())
287 .collect::<Vec<_>>();
288 let description = if visible_aliases.is_empty() {
289 localized.to_string()
290 } else {
291 format!(
292 "{} (aliases: {})",
293 localized,
294 visible_aliases
295 .iter()
296 .map(|a| format!("/{a}"))
297 .collect::<Vec<_>>()
298 .join(", ")
299 )
300 };
301 let haystack = format!(
302 "{} {} {}",
303 label.to_ascii_lowercase(),
304 description.to_ascii_lowercase(),
305 command.usage.to_ascii_lowercase()
306 );
307 entries.push(HelpEntry {
308 section: HelpSection::Command,
309 // Commands have no inherent ordering — fall back to alphabetical
310 // by leaning on `label.clone()` in the final sort_by_key tuple.
311 sub_rank: 0,
312 label,
313 description,
314 haystack,
315 });
316 }
317
318 // Workspace commands (#3912). The registry was already consulted above to
319 // suppress shadowed built-ins; until now it never contributed a row of its
320 // own, so `.codewhale/commands/*.md` authors could not find their own work
321 // in the surface that teaches the product. `hidden` entries stay out.
322 for command in registry.iter().filter(|command| !command.hidden) {
323 let label = format!("/{}", command.name);
324 let description = command
325 .description
326 .as_deref()
327 .map(str::trim)
328 .filter(|description| !description.is_empty())
329 .unwrap_or_default()
330 .to_string();
331 let usage = command
332 .display_usage()
333 .map(str::to_owned)
334 .unwrap_or_else(|| label.clone());
335 let haystack = format!(
336 "{} {} {}",
337 label.to_ascii_lowercase(),
338 description.to_ascii_lowercase(),
339 usage.to_ascii_lowercase()
340 );
341 entries.push(HelpEntry {
342 section: HelpSection::UserCommand,
343 sub_rank: 0,
344 label,
345 description,
346 haystack,
347 });
348 }
349
350 // Skills dispatch as `$name` or `/skill name`; advertise the shape the
351 // user actually types.
352 for (name, description) in skills {
353 let label = format!("${name}");
354 let description = description.trim().to_string();
355 let haystack = format!(
356 "{} {} /skill {}",
357 label.to_ascii_lowercase(),
358 description.to_ascii_lowercase(),
359 name.to_ascii_lowercase()
360 );
361 entries.push(HelpEntry {
362 section: HelpSection::Skill,
363 sub_rank: 0,
364 label,
365 description,
366 haystack,
367 });
368 }
369
370 for binding in KEYBINDINGS {
371 // macOS renders Alt chords with the Option glyph (`⌥V`), never
372 // `Alt`/`Cmd` (TUI-DOG-002 acceptance).
373 let label = crate::tui::shell_key_routing::display_chord(binding.chord).into_owned();
374 let description = format!(
375 "[{}] {}",
376 binding.section.label(locale),
377 tr(locale, binding.description_id)
378 );
379 let haystack = format!(
380 "{} {}",
381 label.to_ascii_lowercase(),
382 description.to_ascii_lowercase()
383 );
384 entries.push(HelpEntry {
385 section: HelpSection::Keybinding,
386 sub_rank: binding.section.rank(),
387 label,
388 description,
389 haystack,
390 });
391 }
392
393 entries
394 }
395
396 fn truncate_to_width(text: &str, max_width: usize) -> String {
397 if max_width == 0 {
398 return String::new();
399 }
400 if text.width() <= max_width {
401 return text.to_string();
402 }
403 let mut out = String::new();
404 let limit = max_width.saturating_sub(1);
405 for ch in text.chars() {
406 let next_width = out.width() + ch.to_string().width();
407 if next_width > limit {
408 break;
409 }
410 out.push(ch);
411 }
412 out.push('…');
413 out
414 }
415
416 impl ModalView for HelpView {
417 fn kind(&self) -> ModalKind {
418 ModalKind::Help
419 }
420
421 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
422 self
423 }
424
425 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
426 // Scroll clamps at the ends (keyboard Up/Down wrap); wheel-wrapping
427 // reads as disorienting.
428 match mouse.kind {
429 MouseEventKind::ScrollUp => self.move_selection(-1),
430 MouseEventKind::ScrollDown => self.move_selection(1),
431 MouseEventKind::Down(MouseButton::Left) => {
432 if let Some(slot) = self.row_hitboxes.borrow().iter().find_map(|(rect, slot)| {
433 rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
434 .then_some(*slot)
435 }) {
436 self.selected = slot;
437 }
438 }
439 _ => {}
440 }
441 ViewAction::None
442 }
443
444 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
445 match key.code {
446 KeyCode::Esc => ViewAction::Close,
447 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
448 ViewAction::Close
449 }
450 KeyCode::Char('q') | KeyCode::Char('Q') if self.query.is_empty() => ViewAction::Close,
451 KeyCode::Up => {
452 self.move_selection_wrapping(-1);
453 ViewAction::None
454 }
455 KeyCode::Down => {
456 self.move_selection_wrapping(1);
457 ViewAction::None
458 }
459 KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
460 self.move_selection_wrapping(-1);
461 ViewAction::None
462 }
463 KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
464 self.move_selection_wrapping(1);
465 ViewAction::None
466 }
467 KeyCode::PageUp => {
468 self.move_selection(-10);
469 ViewAction::None
470 }
471 KeyCode::PageDown => {
472 self.move_selection(10);
473 ViewAction::None
474 }
475 KeyCode::Home => {
476 self.selected = 0;
477 ViewAction::None
478 }
479 KeyCode::End => {
480 if !self.filtered.is_empty() {
481 self.selected = self.filtered.len() - 1;
482 }
483 ViewAction::None
484 }
485 KeyCode::Backspace => {
486 self.query.pop();
487 self.refilter();
488 ViewAction::None
489 }
490 // Terminals where stty erase == ^H send Ctrl+H instead of
491 // Backspace (DEL). Treat it identically so the filter input
492 // works across all platforms (#958).
493 KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
494 self.query.pop();
495 self.refilter();
496 ViewAction::None
497 }
498 KeyCode::Char(c)
499 if !c.is_control()
500 && (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT) =>
501 {
502 self.query.push(c);
503 self.refilter();
504 ViewAction::None
505 }
506 _ => ViewAction::None,
507 }
508 }
509
510 fn render(&self, area: Rect, buf: &mut Buffer) {
511 self.row_hitboxes.borrow_mut().clear();
512 let inner = render_underwater_surface(
513 area,
514 buf,
515 format!(
516 "{} — {}",
517 self.tr(MessageId::HelpTitle),
518 self.tr(MessageId::HelpSubtitle)
519 ),
520 );
521
522 // The action footer wraps inside the modal body (#3732) rather than the
523 // single-line border title that silently clipped hints at narrow
524 // widths; the list renders into the content area above it. Empty hint
525 // keys keep the existing localized footer phrases as plain labels.
526 let content = render_modal_footer(
527 inner,
528 buf,
529 &[
530 ActionHint::new("", self.tr(MessageId::HelpFooterTypeFilter)),
531 ActionHint::new("", self.tr(MessageId::HelpFooterMove)),
532 ActionHint::new("", self.tr(MessageId::HelpFooterJump)),
533 ActionHint::new("", self.tr(MessageId::HelpFooterClose)),
534 ],
535 );
536
537 let mut lines: Vec<Line<'static>> = Vec::new();
538
539 let query_label = if self.query.is_empty() {
540 self.tr(MessageId::HelpFilterPlaceholder).to_string()
541 } else {
542 format!("{}{}", self.tr(MessageId::HelpFilterPrefix), self.query)
543 };
544 lines.push(Line::from(Span::styled(
545 query_label,
546 Style::default()
547 .fg(palette::WHALE_INFO)
548 .add_modifier(Modifier::BOLD),
549 )));
550
551 let match_count = if self.query.is_empty() {
552 format!("{} entries", self.entries.len())
553 } else {
554 format!("{} / {} matches", self.filtered.len(), self.entries.len())
555 };
556 lines.push(Line::from(Span::styled(
557 match_count,
558 Style::default()
559 .fg(palette::TEXT_DIM)
560 .add_modifier(Modifier::ITALIC),
561 )));
562 lines.push(Line::from(""));
563
564 let rows = self.render_rows();
565 let visible_rows = content.height.saturating_sub(lines.len() as u16) as usize;
566 let row_start = Self::visible_row_start(&rows, self.selected, visible_rows.max(1));
567 // Reserve the rail before calculating column widths. Otherwise the
568 // description column writes beneath the rail on compact terminals.
569 let content = render_panel_scroll_rail(
570 content,
571 buf,
572 rows.len(),
573 row_start,
574 visible_rows.max(1),
575 true,
576 );
577
578 if self.filtered.is_empty() {
579 lines.push(Line::from(Span::styled(
580 self.tr(MessageId::HelpNoMatches),
581 Style::default()
582 .fg(palette::TEXT_MUTED)
583 .add_modifier(Modifier::ITALIC),
584 )));
585 } else {
586 // The chord/label column takes up to 28 cols on wide screens;
587 // descriptions fill the remainder. Borders and padding eat 4
588 // cells from each side (border 1 + padding 1) × 2.
589 let inner_width = content.width as usize;
590 let label_width = 28.min(inner_width.saturating_sub(8));
591 let desc_capacity = inner_width.saturating_sub(label_width + 4);
592
593 // `content` is the body area above the wrapping footer (the block's
594 // border, padding, and footer rows already removed), so budgeting
595 // against its height keeps selected rows clear of the footer.
596 let header_lines = lines.len();
597 let visible_budget = (content.height as usize)
598 .saturating_sub(header_lines)
599 .max(1);
600
601 for row in rows.iter().skip(row_start).take(visible_budget) {
602 match *row {
603 HelpRenderRow::Section(section) => {
604 let count = self
605 .filtered
606 .iter()
607 .filter(|idx| self.entries[**idx].section == section)
608 .count();
609 lines.push(Line::from(Span::styled(
610 format!(" {} ({})", section.label(self.locale), count),
611 Style::default()
612 .fg(palette::WHALE_ACTION)
613 .add_modifier(Modifier::BOLD),
614 )));
615 }
616 HelpRenderRow::Entry { slot, entry_idx } => {
617 let row_y = content.y.saturating_add(lines.len() as u16);
618 self.row_hitboxes
619 .borrow_mut()
620 .push((Rect::new(content.x, row_y, content.width, 1), slot));
621 let entry = &self.entries[entry_idx];
622 let is_selected = slot == self.selected;
623 let style = if is_selected {
624 menu_style::selected_row_style()
625 } else {
626 Style::default().fg(palette::TEXT_PRIMARY)
627 };
628 let cursor =
629 format!("{} ", crate::tui::glyphs::selection_marker(is_selected));
630 let label = truncate_to_width(&entry.label, label_width);
631 let desc = truncate_to_width(&entry.description, desc_capacity);
632 let line_text = format!("{cursor}{label:<label_width$} {desc}",);
633 lines.push(Line::from(Span::styled(line_text, style)));
634 }
635 }
636 }
637 }
638
639 Paragraph::new(lines).render(content, buf);
640 }
641 }
642
643 #[cfg(test)]
644 mod tests {
645 use super::*;
646 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
647
648 fn key(code: KeyCode) -> KeyEvent {
649 KeyEvent::new(code, KeyModifiers::NONE)
650 }
651
652 fn type_filter(view: &mut HelpView, text: &str) {
653 for ch in text.chars() {
654 view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
655 }
656 }
657
658 fn first_filtered_section(view: &HelpView) -> HelpSection {
659 view.entries[*view
660 .filtered
661 .first()
662 .expect("help should contain at least one entry")]
663 .section
664 }
665
666 #[test]
667 fn empty_filter_lists_all_entries() {
668 let view = HelpView::new();
669 // Total = registered slash commands + catalogued keybindings.
670 let expected = commands::command_infos().len() + KEYBINDINGS.len();
671 assert_eq!(view.filtered.len(), expected);
672 assert_eq!(view.entries.len(), expected);
673 }
674
675 #[test]
676 fn entry_points_choose_the_section_they_promise() {
677 let commands = HelpView::new_for_locale(Locale::En);
678 assert_eq!(commands.ordering, HelpOrdering::CommandsFirst);
679 assert_eq!(first_filtered_section(&commands), HelpSection::Command);
680
681 let shortcuts = HelpView::new_with_ordering(Locale::En, HelpOrdering::KeybindingsFirst);
682 assert_eq!(shortcuts.ordering, HelpOrdering::KeybindingsFirst);
683 assert_eq!(first_filtered_section(&shortcuts), HelpSection::Keybinding);
684 }
685
686 #[test]
687 fn workspace_commands_and_skills_are_findable_with_provenance() {
688 // #3912: both surfaces executed and autocompleted but were absent
689 // from the surface that teaches the product.
690 let tmp = tempfile::TempDir::new().unwrap();
691 let commands_dir = tmp.path().join(".codewhale").join("commands");
692 std::fs::create_dir_all(&commands_dir).unwrap();
693 std::fs::write(
694 commands_dir.join("shipit.md"),
695 "---\ndescription: Cut a release candidate\n---\nbody",
696 )
697 .unwrap();
698 std::fs::write(
699 commands_dir.join("secret.md"),
700 "---\ndescription: Internal only\nhidden: true\n---\nbody",
701 )
702 .unwrap();
703
704 let skills = vec![(
705 "codereview".to_string(),
706 "Review a diff for defects".to_string(),
707 )];
708 let mut view = HelpView::new_for_workspace(Locale::En, tmp.path(), &skills);
709
710 let user = view
711 .entries
712 .iter()
713 .find(|entry| entry.label == "/shipit")
714 .expect("workspace command should be listed");
715 assert_eq!(user.section, HelpSection::UserCommand);
716 assert!(user.description.contains("Cut a release candidate"));
717
718 let skill = view
719 .entries
720 .iter()
721 .find(|entry| entry.label == "$codereview")
722 .expect("discovered skill should be listed");
723 assert_eq!(skill.section, HelpSection::Skill);
724
725 assert!(
726 !view.entries.iter().any(|entry| entry.label == "/secret"),
727 "hidden workspace commands stay out of the overlay"
728 );
729
730 // Both are reachable through the existing substring filter.
731 type_filter(&mut view, "shipit");
732 assert!(
733 view.filtered
734 .iter()
735 .any(|idx| view.entries[*idx].label == "/shipit")
736 );
737
738 let mut view = HelpView::new_for_workspace(Locale::En, tmp.path(), &skills);
739 type_filter(&mut view, "review a diff");
740 assert!(
741 view.filtered
742 .iter()
743 .any(|idx| view.entries[*idx].label == "$codereview"),
744 "skills are findable by their description"
745 );
746 }
747
748 #[test]
749 fn skill_rows_advertise_the_slash_skill_shape_too() {
750 let tmp = tempfile::TempDir::new().unwrap();
751 let skills = vec![("audit".to_string(), "Audit the tree".to_string())];
752 let mut view = HelpView::new_for_workspace(Locale::En, tmp.path(), &skills);
753 type_filter(&mut view, "/skill audit");
754 assert!(
755 view.filtered
756 .iter()
757 .any(|idx| view.entries[*idx].label == "$audit"),
758 "searching the /skill form finds the skill"
759 );
760 }
761
762 #[test]
763 fn help_hides_builtins_with_shadowed_canonical_names() {
764 let registry = commands::user_registry::UserCommandRegistry::from_loaded(vec![(
765 "help".to_string(),
766 "---\ndescription: Custom help workflow\n---\ncustom help".to_string(),
767 )]);
768 let entries = build_entries(Locale::En, &registry, &[]);
769
770 // The built-in row is suppressed so the name is not advertised twice.
771 assert!(
772 !entries
773 .iter()
774 .any(|entry| entry.label == "/help" && entry.section == HelpSection::Command),
775 "the shadowed built-in must not keep its own row"
776 );
777 // Since #3912 the shadowing workspace command supplies the row instead
778 // of the name vanishing from help entirely.
779 let user = entries
780 .iter()
781 .find(|entry| entry.label == "/help")
782 .expect("the user command that shadows /help should be listed");
783 assert_eq!(user.section, HelpSection::UserCommand);
784 assert!(user.description.contains("Custom help workflow"));
785 }
786
787 #[test]
788 fn substring_filter_narrows_to_command() {
789 let mut view = HelpView::new();
790 type_filter(&mut view, "mode [act");
791 assert!(!view.filtered.is_empty());
792 // Every filtered entry should genuinely contain the query in its
793 // searchable haystack — no false positives slipped past.
794 for idx in &view.filtered {
795 assert!(
796 view.entries[*idx].haystack.contains("mode [act"),
797 "entry {:?} leaked through `mode [act` filter",
798 view.entries[*idx]
799 );
800 }
801 // The unified `/mode` command must surface when filtering for a
802 // concrete mode value from the visible vocabulary.
803 assert!(
804 view.filtered
805 .iter()
806 .any(|idx| view.entries[*idx].label == "/mode"),
807 "/mode should match the `mode [act` filter"
808 );
809 }
810
811 #[test]
812 fn substring_filter_finds_keybinding_by_chord() {
813 let mut view = HelpView::new();
814 type_filter(&mut view, "ctrl+r");
815 assert!(!view.filtered.is_empty(), "Ctrl+R should match");
816 assert!(
817 view.filtered
818 .iter()
819 .any(|idx| view.entries[*idx].label.eq_ignore_ascii_case("ctrl+r")),
820 "Ctrl+R chord must surface in the filtered set"
821 );
822 }
823
824 #[test]
825 fn multiple_terms_act_as_and() {
826 let mut view = HelpView::new();
827 type_filter(&mut view, "session picker");
828 assert!(
829 !view.filtered.is_empty(),
830 "expected at least one entry mentioning both `session` and `picker`"
831 );
832 for idx in &view.filtered {
833 let haystack = &view.entries[*idx].haystack;
834 assert!(
835 haystack.contains("session") && haystack.contains("picker"),
836 "entry {:?} leaked through `session picker` AND filter",
837 view.entries[*idx]
838 );
839 }
840 }
841
842 #[test]
843 fn unknown_filter_yields_empty_set() {
844 let mut view = HelpView::new();
845 type_filter(&mut view, "zzzqqxxnope");
846 assert!(view.filtered.is_empty());
847 assert_eq!(view.selected, 0);
848 }
849
850 #[test]
851 fn backspace_widens_match_set() {
852 let mut view = HelpView::new();
853 // Near-miss against the still-visible mode vocabulary so the last
854 // character removes a unique miss and broadens the match set.
855 type_filter(&mut view, "modez");
856 let narrow = view.filtered.len();
857 view.handle_key(key(KeyCode::Backspace));
858 let wider = view.filtered.len();
859 assert!(
860 wider > narrow,
861 "backspace must broaden the matching set (was {narrow}, now {wider})"
862 );
863 }
864
865 #[test]
866 fn ctrl_h_widens_match_set() {
867 let mut view = HelpView::new();
868 type_filter(&mut view, "modez");
869 let narrow = view.filtered.len();
870 view.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
871 let wider = view.filtered.len();
872 assert!(
873 wider > narrow,
874 "Ctrl+H must behave as Backspace, broadening the matching set (was {narrow}, now {wider})"
875 );
876 }
877
878 #[test]
879 fn esc_closes_overlay() {
880 let mut view = HelpView::new();
881 let action = view.handle_key(key(KeyCode::Esc));
882 assert!(matches!(action, ViewAction::Close));
883 }
884
885 #[test]
886 fn ctrl_c_closes_overlay() {
887 let mut view = HelpView::new();
888 let action = view.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
889 assert!(matches!(action, ViewAction::Close));
890 }
891
892 #[test]
893 fn q_closes_empty_filter_but_types_when_filtering() {
894 let mut view = HelpView::new();
895 let action = view.handle_key(key(KeyCode::Char('q')));
896 assert!(matches!(action, ViewAction::Close));
897
898 let mut view = HelpView::new();
899 type_filter(&mut view, "mod");
900 let action = view.handle_key(key(KeyCode::Char('q')));
901 assert!(matches!(action, ViewAction::None));
902 assert_eq!(view.query, "modq");
903 }
904
905 #[test]
906 fn arrow_keys_move_selection_and_wrap_edges() {
907 let mut view = HelpView::new();
908 // Down once → row 1; Up twice wraps from the first row to the last.
909 view.handle_key(key(KeyCode::Down));
910 assert_eq!(view.selected, 1);
911 view.handle_key(key(KeyCode::Up));
912 view.handle_key(key(KeyCode::Up));
913 assert_eq!(view.selected, view.filtered.len() - 1);
914 // Down from last wraps to first; End still jumps to the last row.
915 view.handle_key(key(KeyCode::Down));
916 assert_eq!(view.selected, 0);
917 view.handle_key(key(KeyCode::End));
918 assert_eq!(view.selected, view.filtered.len() - 1);
919 }
920
921 #[test]
922 fn mouse_click_selects_visible_help_row() {
923 let mut view = HelpView::new();
924 let area = Rect::new(0, 0, 100, 30);
925 let mut buf = Buffer::empty(area);
926 view.render(area, &mut buf);
927 let (rect, slot) = view.row_hitboxes.borrow()[1];
928
929 view.handle_mouse(MouseEvent {
930 kind: MouseEventKind::Down(MouseButton::Left),
931 column: rect.x,
932 row: rect.y,
933 modifiers: KeyModifiers::NONE,
934 });
935
936 assert_eq!(view.selected, slot);
937 }
938
939 #[test]
940 fn visible_window_keeps_selected_entry_visible_after_scroll() {
941 let mut view = HelpView::new();
942 let selected = view
943 .filtered
944 .iter()
945 .position(|idx| view.entries[*idx].label == "/home")
946 .expect("/home command should be present");
947 view.selected = selected;
948
949 let rows = view.render_rows();
950 let row_start = HelpView::visible_row_start(&rows, view.selected, 12);
951 let visible = &rows[row_start..(row_start + 12).min(rows.len())];
952
953 assert!(
954 visible
955 .iter()
956 .any(|row| matches!(row, HelpRenderRow::Entry { slot, .. } if *slot == selected)),
957 "selected help entry should stay in the visible render window"
958 );
959 }
960
961 #[test]
962 fn render_keeps_next_row_after_help_visible() {
963 let mut view = HelpView::new();
964 let help_slot = view
965 .filtered
966 .iter()
967 .position(|idx| view.entries[*idx].label == "/help")
968 .expect("/help command should be present");
969 view.selected = help_slot;
970 view.handle_key(key(KeyCode::Down));
971 let selected_idx = view.filtered[view.selected];
972 let selected_label = view.entries[selected_idx].label.clone();
973
974 let area = Rect::new(0, 0, 96, 32);
975 let mut buf = Buffer::empty(area);
976 view.render(area, &mut buf);
977
978 let mut highlighted_label = false;
979 for y in area.top()..area.bottom() {
980 let mut row = String::new();
981 let mut row_has_highlight = false;
982 for x in area.left()..area.right() {
983 let cell = &buf[(x, y)];
984 row.push_str(cell.symbol());
985 row_has_highlight |=
986 cell.bg == palette::SELECTION_BG && cell.fg == palette::SELECTION_TEXT;
987 }
988 if row_has_highlight && row.contains(&selected_label) {
989 highlighted_label = true;
990 break;
991 }
992 }
993
994 assert!(
995 highlighted_label,
996 "selected row after /help should stay visibly highlighted"
997 );
998 }
999
1000 #[test]
1001 fn selected_help_row_uses_selection_highlight() {
1002 let view = HelpView::new();
1003 let area = Rect::new(0, 0, 96, 32);
1004 let mut buf = Buffer::empty(area);
1005 view.render(area, &mut buf);
1006
1007 let mut found_highlight = false;
1008 for y in area.top()..area.bottom() {
1009 for x in area.left()..area.right() {
1010 let cell = &buf[(x, y)];
1011 if cell.bg == palette::SELECTION_BG && cell.fg == palette::SELECTION_TEXT {
1012 found_highlight = true;
1013 break;
1014 }
1015 }
1016 }
1017
1018 assert!(
1019 found_highlight,
1020 "selected row should use the semantic selection highlight"
1021 );
1022 }
1023
1024 #[test]
1025 fn render_includes_help_chrome_for_empty_filter() {
1026 let view = HelpView::new();
1027 let area = Rect::new(0, 0, 96, 32);
1028 let mut buf = Buffer::empty(area);
1029 view.render(area, &mut buf);
1030
1031 let dump = buffer_text(&buf, area);
1032 // Title border + section headings should always render.
1033 assert!(dump.contains("Help"), "missing help title:\n{dump}");
1034 assert!(
1035 dump.contains("Type to filter"),
1036 "missing filter prompt:\n{dump}"
1037 );
1038 assert!(
1039 dump.contains("Slash commands"),
1040 "missing slash-command section heading:\n{dump}"
1041 );
1042 // Footer hint should advertise close key on the bottom border.
1043 assert!(
1044 dump.contains("Esc close"),
1045 "missing Esc close footer hint:\n{dump}"
1046 );
1047 }
1048
1049 #[test]
1050 fn render_with_filter_shows_only_matching_section_and_status() {
1051 let mut view = HelpView::new();
1052 type_filter(&mut view, "mode [act");
1053 let area = Rect::new(0, 0, 96, 24);
1054 let mut buf = Buffer::empty(area);
1055 view.render(area, &mut buf);
1056
1057 let dump = buffer_text(&buf, area);
1058 assert!(
1059 dump.contains("Filter: mode [act"),
1060 "filter echo missing:\n{dump}"
1061 );
1062 assert!(
1063 dump.contains("matches"),
1064 "match counter missing in dump:\n{dump}"
1065 );
1066 assert!(
1067 dump.contains("/mode"),
1068 "expected /mode command in filtered render:\n{dump}"
1069 );
1070 assert!(
1071 !dump.contains("/model"),
1072 "non-matching commands should not render under a `mode [act` filter:\n{dump}"
1073 );
1074 }
1075
1076 #[test]
1077 fn localized_help_chrome_renders_without_missing_markers() {
1078 let view = HelpView::new_for_locale(Locale::ZhHans);
1079 let area = Rect::new(0, 0, 48, 18);
1080 let mut buf = Buffer::empty(area);
1081 view.render(area, &mut buf);
1082
1083 let dump = buffer_text(&buf, area);
1084 assert!(
1085 dump.contains('帮') && dump.contains('助'),
1086 "missing localized title:\n{dump}"
1087 );
1088 assert!(
1089 !dump.contains("MISSING"),
1090 "missing-key marker leaked:\n{dump}"
1091 );
1092 }
1093
1094 #[test]
1095 fn localized_help_keybinding_descriptions_use_zh_hans() {
1096 let registry = commands::user_registry::UserCommandRegistry::new();
1097 let entries = build_entries(Locale::ZhHans, &registry, &[]);
1098 let kb_entries: Vec<_> = entries
1099 .iter()
1100 .filter(|e| e.section == HelpSection::Keybinding)
1101 .collect();
1102 assert!(!kb_entries.is_empty(), "no keybinding entries found");
1103
1104 for entry in &kb_entries {
1105 assert!(
1106 entry
1107 .description
1108 .chars()
1109 .any(|c| { ('\u{4e00}'..='\u{9fff}').contains(&c) }),
1110 "keybinding description not localized: {}",
1111 entry.description
1112 );
1113 }
1114 }
1115
1116 /// The four terminal sizes the v0.8.66 modal blocker (#3732) requires
1117 /// every overlay to remain readable and fully operable at.
1118 const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
1119
1120 const SHORTCUT_HELP_SIZES: [(u16, u16); 5] =
1121 [(40, 12), (60, 16), (80, 24), (100, 32), (140, 40)];
1122
1123 #[test]
1124 fn shortcut_help_leads_with_keys_at_responsive_sizes() {
1125 use crate::tui::views::ViewStack;
1126
1127 let keybindings_heading = tr(Locale::En, MessageId::HelpKeybindings);
1128 let commands_heading = tr(Locale::En, MessageId::HelpSlashCommands);
1129
1130 for (w, h) in SHORTCUT_HELP_SIZES {
1131 let area = Rect::new(0, 0, w, h);
1132 let mut buf = Buffer::empty(area);
1133 for y in 0..h {
1134 for x in 0..w {
1135 buf[(x, y)].set_symbol("§");
1136 }
1137 }
1138
1139 let mut stack = ViewStack::new();
1140 stack.push(HelpView::new_with_ordering(
1141 Locale::En,
1142 HelpOrdering::KeybindingsFirst,
1143 ));
1144 stack.render(area, &mut buf);
1145
1146 let rows: Vec<String> = (0..h)
1147 .map(|y| {
1148 (0..w)
1149 .map(|x| buf[(x, y)].symbol().to_string())
1150 .collect::<String>()
1151 })
1152 .collect();
1153 let text = rows.join("\n");
1154 let keys_at = text.find(keybindings_heading.as_ref()).unwrap_or_else(|| {
1155 panic!("{w}x{h}: shortcut Help hid the keybindings heading:\n{text}")
1156 });
1157 if let Some(commands_at) = text.find(commands_heading.as_ref()) {
1158 assert!(
1159 keys_at < commands_at,
1160 "{w}x{h}: shortcut Help rendered commands before keybindings:\n{text}"
1161 );
1162 }
1163 assert!(
1164 !text.contains('§'),
1165 "{w}x{h}: background bleed-through into shortcut Help"
1166 );
1167 assert!(
1168 (0..h).any(|y| {
1169 (0..w).any(|x| {
1170 let cell = &buf[(x, y)];
1171 cell.bg == palette::SELECTION_BG && cell.fg == palette::SELECTION_TEXT
1172 })
1173 }),
1174 "{w}x{h}: first keybinding row lost its selection highlight"
1175 );
1176 for (y, row) in rows.iter().enumerate() {
1177 assert!(
1178 UnicodeWidthStr::width(row.trim_end()) <= w as usize,
1179 "{w}x{h}: row {y} overflows width: {row:?}"
1180 );
1181 }
1182 }
1183 }
1184
1185 #[test]
1186 fn help_is_usable_and_opaque_at_blocker_sizes() {
1187 use crate::tui::views::ViewStack;
1188 for (w, h) in BLOCKER_SIZES {
1189 let area = Rect::new(0, 0, w, h);
1190 let mut buf = Buffer::empty(area);
1191 for y in 0..h {
1192 for x in 0..w {
1193 buf[(x, y)].set_symbol("X");
1194 }
1195 }
1196 let mut stack = ViewStack::new();
1197 stack.push(HelpView::new_for_locale(Locale::En));
1198 stack.render(area, &mut buf);
1199
1200 let rows: Vec<String> = (0..h)
1201 .map(|y| {
1202 (0..w)
1203 .map(|x| buf[(x, y)].symbol().to_string())
1204 .collect::<String>()
1205 })
1206 .collect();
1207 let text = rows.join("\n");
1208
1209 for label in [
1210 "type to filter",
1211 "Up/Down move",
1212 "PgUp/PgDn jump",
1213 "Esc close",
1214 ] {
1215 assert!(text.contains(label), "{w}x{h}: missing footer '{label}'");
1216 }
1217 assert!(
1218 !text.contains('X'),
1219 "{w}x{h}: background bleed-through into modal surface"
1220 );
1221 assert_eq!(
1222 buf[(w / 2, h / 2)].bg,
1223 palette::WHALE_BG,
1224 "{w}x{h}: modal interior must be opaque"
1225 );
1226 for (y, row) in rows.iter().enumerate() {
1227 assert!(
1228 UnicodeWidthStr::width(row.trim_end()) <= w as usize,
1229 "{w}x{h}: row {y} overflows width: {row:?}"
1230 );
1231 }
1232 }
1233 }
1234
1235 fn buffer_text(buf: &Buffer, area: Rect) -> String {
1236 let mut out = String::new();
1237 for y in area.top()..area.bottom() {
1238 for x in area.left()..area.right() {
1239 out.push_str(buf[(x, y)].symbol());
1240 }
1241 out.push('\n');
1242 }
1243 out
1244 }
1245 }
1246
1246 lines RUST