返回 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::collections::HashSet;
21 use std::path::Path;
22
23 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
24 use ratatui::{
25 buffer::Buffer,
26 layout::Rect,
27 style::{Modifier, Style},
28 text::{Line, Span},
29 widgets::{Paragraph, Widget},
30 };
31 use unicode_width::UnicodeWidthStr;
32
33 use crate::commands;
34 use crate::tui::keybindings::KEYBINDINGS;
35 use crate::tui::menu_style;
36 use crate::tui::views::{
37 ActionHint, ModalKind, ModalView, ViewAction, render_modal_footer, render_panel_scroll_rail,
38 render_underwater_surface,
39 };
40 use codewhale_localization::{Locale, MessageId, tr};
41 use codewhale_palette as palette;
42
43 /// Two top-level sections rendered in the overlay.
44 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
45 enum HelpSection {
46 Command,
47 UserCommand,
48 Skill,
49 Keybinding,
50 }
51
52 impl HelpSection {
53 fn label(self, locale: Locale) -> Cow<'static, str> {
54 match self {
55 Self::Command => tr(locale, MessageId::HelpSlashCommands),
56 Self::UserCommand => tr(locale, MessageId::HelpUserCommands),
57 Self::Skill => tr(locale, MessageId::HelpSkills),
58 Self::Keybinding => tr(locale, MessageId::HelpKeybindings),
59 }
60 }
61 }
62
63 /// Which reference surface owns the first visible section when Help opens.
64 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
65 pub enum HelpOrdering {
66 /// `/help` and context-menu Help are command discovery surfaces.
67 CommandsFirst,
68 /// F1 and its Ctrl+/ and Alt+? fallbacks open the keyboard reference.
69 KeybindingsFirst,
70 }
71
72 impl HelpOrdering {
73 fn section_rank(self, section: HelpSection) -> u8 {
74 // User commands and skills sit with the built-in commands: they are
75 // the same kind of thing to the user (#3912), so a keyboard-reference
76 // open still sorts every command surface below the chords.
77 match (self, section) {
78 (Self::CommandsFirst, HelpSection::Command) => 0,
79 (Self::CommandsFirst, HelpSection::UserCommand) => 1,
80 (Self::CommandsFirst, HelpSection::Skill) => 2,
81 (Self::CommandsFirst, HelpSection::Keybinding) => 3,
82 (Self::KeybindingsFirst, HelpSection::Keybinding) => 0,
83 (Self::KeybindingsFirst, HelpSection::Command) => 1,
84 (Self::KeybindingsFirst, HelpSection::UserCommand) => 2,
85 (Self::KeybindingsFirst, HelpSection::Skill) => 3,
86 }
87 }
88 }
89
90 #[derive(Debug, Clone)]
91 struct HelpEntry {
92 section: HelpSection,
93 /// Sort-within-section key — keybinding entries reuse their declared
94 /// section's rank so the help overlay groups Navigation, Editing, … in
95 /// the same order as `tui::keybindings`.
96 sub_rank: u8,
97 label: String,
98 description: String,
99 /// The command's argument shape, when it has one worth stating — the
100 /// registry `usage` string for built-ins, the front-matter usage for
101 /// workspace commands. `None` for rows that are already the whole shape
102 /// (`/copy`, `$skill`, a keybinding chord).
103 usage: Option<String>,
104 /// Lowercased haystack used for substring matching; pre-built so each
105 /// keystroke does not re-allocate per entry.
106 haystack: String,
107 }
108
109 #[derive(Debug, Clone, PartialEq, Eq)]
110 enum HelpRenderRow {
111 Group {
112 key: String,
113 label: String,
114 count: usize,
115 collapsed: bool,
116 },
117 Entry {
118 slot: usize,
119 entry_idx: usize,
120 },
121 }
122
123 #[derive(Debug, Clone, PartialEq, Eq)]
124 enum HelpHit {
125 Group(String),
126 Entry(usize),
127 }
128
129 pub struct HelpView {
130 locale: Locale,
131 ordering: HelpOrdering,
132 entries: Vec<HelpEntry>,
133 /// Indices into `entries`, in display order, after filtering.
134 filtered: Vec<usize>,
135 query: String,
136 /// Keyboard focus covers both group headers and entry rows. `selected`
137 /// remains the last focused entry slot so entry-oriented actions and
138 /// tests keep a stable target while a header owns focus.
139 focus: Option<HelpHit>,
140 selected: usize,
141 collapsed: HashSet<String>,
142 row_hitboxes: RefCell<Vec<(Rect, HelpHit)>>,
143 }
144
145 impl Default for HelpView {
146 fn default() -> Self {
147 Self::new()
148 }
149 }
150
151 impl HelpView {
152 pub fn new() -> Self {
153 Self::new_for_locale(Locale::En)
154 }
155
156 pub fn new_for_locale(locale: Locale) -> Self {
157 Self::new_with_ordering(locale, HelpOrdering::CommandsFirst)
158 }
159
160 /// Discoverability index over every user-invocable surface (#3912):
161 /// built-ins, workspace commands, and discovered skills. `skills` comes
162 /// from `App::cached_skills`; pass `&[]` only where none are discovered.
163 pub fn new_for_workspace(
164 locale: Locale,
165 workspace: &Path,
166 skills: &[(String, String)],
167 ) -> Self {
168 commands::user_registry::with_registry_for_workspace(Some(workspace), |registry| {
169 Self::new_with_registry(locale, HelpOrdering::CommandsFirst, registry, skills)
170 })
171 }
172
173 /// Open Help as the keyboard reference promised by shell shortcut hints.
174 pub fn new_for_shortcuts(
175 locale: Locale,
176 workspace: &Path,
177 skills: &[(String, String)],
178 ) -> Self {
179 commands::user_registry::with_registry_for_workspace(Some(workspace), |registry| {
180 Self::new_with_registry(locale, HelpOrdering::KeybindingsFirst, registry, skills)
181 })
182 }
183
184 fn new_with_ordering(locale: Locale, ordering: HelpOrdering) -> Self {
185 let registry = commands::user_registry::UserCommandRegistry::new();
186 Self::new_with_registry(locale, ordering, &registry, &[])
187 }
188
189 fn new_with_registry(
190 locale: Locale,
191 ordering: HelpOrdering,
192 registry: &commands::user_registry::UserCommandRegistry,
193 skills: &[(String, String)],
194 ) -> Self {
195 let entries = build_entries(locale, registry, skills);
196 let mut view = Self {
197 locale,
198 ordering,
199 entries,
200 filtered: Vec::new(),
201 query: String::new(),
202 focus: None,
203 selected: 0,
204 collapsed: default_collapsed(ordering),
205 row_hitboxes: RefCell::new(Vec::new()),
206 };
207 view.refilter();
208 view
209 }
210
211 /// Start with every Help/shortcuts group expanded. Default is the
212 /// Grok-like folded long tail; `/config help_expand_groups true` opts in.
213 #[must_use]
214 pub fn with_groups_expanded(mut self, expand: bool) -> Self {
215 if expand {
216 self.collapsed.clear();
217 self.clamp_focus_to_visible();
218 }
219 self
220 }
221
222 fn tr(&self, id: MessageId) -> Cow<'static, str> {
223 tr(self.locale, id)
224 }
225
226 fn refilter(&mut self) {
227 // Substring matching is intentional — fuzzy matchers can hide the
228 // exact-prefix hit a user is typing toward, which is the wrong
229 // failure mode for a *help* surface. We split on whitespace so
230 // multi-term queries (`apply mode`) act as an AND.
231 let query = self.query.trim().to_ascii_lowercase();
232 let terms: Vec<&str> = query
233 .split_whitespace()
234 .filter(|term| !term.is_empty())
235 .collect();
236
237 let mut filtered: Vec<usize> = self
238 .entries
239 .iter()
240 .enumerate()
241 .filter(|(_, entry)| terms.iter().all(|term| entry.haystack.contains(term)))
242 .map(|(idx, _)| idx)
243 .collect();
244
245 filtered.sort_by_key(|idx| {
246 let entry = &self.entries[*idx];
247 (
248 self.ordering.section_rank(entry.section),
249 entry.sub_rank,
250 entry.label.clone(),
251 )
252 });
253 self.filtered = filtered;
254 self.clamp_focus_to_visible();
255 }
256
257 fn clamp_focus_to_visible(&mut self) {
258 let visible = self.visible_entry_slots();
259 if !visible.is_empty() && !visible.contains(&self.selected) {
260 self.selected = visible[0];
261 }
262 let focusable = self.focusable_rows();
263 if !self
264 .focus
265 .as_ref()
266 .is_some_and(|focus| focusable.contains(focus))
267 {
268 // Prefer the first entry over the group header above it. A header
269 // has no description, and the detail row under the filter reads
270 // the focused entry — so falling back to a header left that row
271 // blank exactly when Help opens and while a query is being typed.
272 self.focus = focusable
273 .iter()
274 .find(|hit| matches!(hit, HelpHit::Entry(_)))
275 .or_else(|| focusable.first())
276 .cloned();
277 }
278 }
279
280 fn visible_entry_slots(&self) -> Vec<usize> {
281 self.filtered
282 .iter()
283 .copied()
284 .enumerate()
285 .filter_map(|(slot, entry_idx)| {
286 let key = group_key(&self.entries[entry_idx]);
287 if self.group_is_collapsed(&key) {
288 None
289 } else {
290 Some(slot)
291 }
292 })
293 .collect()
294 }
295
296 fn group_is_collapsed(&self, key: &str) -> bool {
297 self.query.trim().is_empty() && self.collapsed.contains(key)
298 }
299
300 fn toggle_group(&mut self, key: &str) {
301 if !self.collapsed.remove(key) {
302 self.collapsed.insert(key.to_string());
303 }
304 self.focus = Some(HelpHit::Group(key.to_string()));
305 self.clamp_focus_to_visible();
306 }
307
308 fn move_selection(&mut self, delta: isize) {
309 // #4755: help list wraps at both ends (same as other modal lists).
310 // Group headers participate so a keyboard user can open the same
311 // default-collapsed rows as a mouse user.
312 let focusable = self.focusable_rows();
313 if focusable.is_empty() {
314 return;
315 }
316 let pos = focusable
317 .iter()
318 .position(|candidate| self.focus.as_ref() == Some(candidate))
319 .unwrap_or(0);
320 let next = crate::tui::list_nav::wrap_index(pos, focusable.len(), delta);
321 self.set_focus(focusable[next].clone());
322 }
323
324 fn move_selection_wrapping(&mut self, delta: isize) {
325 self.move_selection(delta);
326 }
327
328 fn render_rows(&self) -> Vec<HelpRenderRow> {
329 let mut rows = Vec::new();
330 let mut active_group: Option<String> = None;
331
332 for (slot, entry_idx) in self.filtered.iter().copied().enumerate() {
333 let entry = &self.entries[entry_idx];
334 let key = group_key(entry);
335 if active_group.as_deref() != Some(key.as_str()) {
336 let count = self
337 .filtered
338 .iter()
339 .filter(|idx| group_key(&self.entries[**idx]) == key)
340 .count();
341 let collapsed = self.group_is_collapsed(&key);
342 rows.push(HelpRenderRow::Group {
343 key: key.clone(),
344 label: group_label(entry, self.locale),
345 count,
346 collapsed,
347 });
348 active_group = Some(key.clone());
349 }
350 if self.group_is_collapsed(&key) {
351 continue;
352 }
353 rows.push(HelpRenderRow::Entry { slot, entry_idx });
354 }
355
356 rows
357 }
358
359 /// Width of the label column for each group, measured from the labels
360 /// that group actually contains.
361 ///
362 /// The column used to be a flat 28 columns at every terminal size. At 60
363 /// columns that spent 28 of ~53 on a gutter — `/advisor` is eight cells
364 /// wide, so twenty blank columns sat between every command and a
365 /// description that had been cut to 21. Sizing per group keeps the block
366 /// under each header reading as one table while handing the slack back to
367 /// the descriptions; it is stable while scrolling because it does not
368 /// depend on which rows are on screen.
369 fn label_widths(&self, cap: usize) -> std::collections::HashMap<String, usize> {
370 let mut widths: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
371 for entry_idx in self.filtered.iter().copied() {
372 let entry = &self.entries[entry_idx];
373 let width = entry.label.width().min(cap);
374 let slot = widths.entry(group_key(entry)).or_default();
375 *slot = (*slot).max(width);
376 }
377 widths
378 }
379
380 /// What the focused row could not say for itself: the command's argument
381 /// shape, and its description when the row had to shed one.
382 ///
383 /// `/help` used to show `label + description` and keep `usage` in the
384 /// search haystack alone, so `/workspace [path|worktrees]` read as
385 /// `/workspace` and the worktree manager behind it was invisible (#5952).
386 /// The usage line is new information, so it is printed whenever the
387 /// registry has one; the description is only repeated when the row shed
388 /// it, because printing the same sentence twice on one screen is the
389 /// duplication this slot was built to avoid. The row stays reserved
390 /// either way so the list does not jump as focus moves.
391 fn focused_entry_detail(
392 &self,
393 inner_width: usize,
394 label_cap: usize,
395 label_widths: &std::collections::HashMap<String, usize>,
396 ) -> Option<String> {
397 let HelpHit::Entry(slot) = self.focus.as_ref()? else {
398 return None;
399 };
400 let entry_idx = *self.filtered.get(*slot)?;
401 let entry = &self.entries[entry_idx];
402 let label_width = label_widths
403 .get(&group_key(entry))
404 .copied()
405 .unwrap_or(label_cap);
406 let inline_capacity = inner_width.saturating_sub(label_width + 4);
407 let inline = shed_to_width(&entry.description, inline_capacity);
408 let full = shed_to_width(&entry.description, inner_width);
409 let repaired = (full != inline && !full.is_empty()).then(|| full.to_string());
410 match (entry.usage.as_deref(), repaired) {
411 (None, repaired) => repaired,
412 (Some(usage), None) => Some(shed_to_width(usage, inner_width).to_string()),
413 (Some(usage), Some(description)) => {
414 // They join at a joint `shed_to_width` already sheds on, and
415 // the description leads: repairing the shed is what this slot
416 // was built for, and a panel too narrow to hold both must not
417 // spend itself on the argument shape and drop the sentence
418 // the row could not print.
419 let joined = format!("{description} — {usage}");
420 Some(shed_to_width(&joined, inner_width).to_string())
421 }
422 }
423 }
424
425 fn focusable_rows(&self) -> Vec<HelpHit> {
426 self.render_rows()
427 .into_iter()
428 .map(|row| match row {
429 HelpRenderRow::Group { key, .. } => HelpHit::Group(key),
430 HelpRenderRow::Entry { slot, .. } => HelpHit::Entry(slot),
431 })
432 .collect()
433 }
434
435 fn set_focus(&mut self, focus: HelpHit) {
436 if let HelpHit::Entry(slot) = focus {
437 self.selected = slot;
438 self.focus = Some(HelpHit::Entry(slot));
439 } else {
440 self.focus = Some(focus);
441 }
442 }
443
444 fn focused_group_key(&self) -> Option<String> {
445 match self.focus.as_ref()? {
446 HelpHit::Group(key) => Some(key.clone()),
447 HelpHit::Entry(slot) => self
448 .filtered
449 .get(*slot)
450 .map(|entry_idx| group_key(&self.entries[*entry_idx])),
451 }
452 }
453
454 fn selected_render_row(rows: &[HelpRenderRow], focus: Option<&HelpHit>) -> usize {
455 rows.iter()
456 .position(|row| match (row, focus) {
457 (HelpRenderRow::Group { key, .. }, Some(HelpHit::Group(focused))) => key == focused,
458 (HelpRenderRow::Entry { slot, .. }, Some(HelpHit::Entry(focused))) => {
459 slot == focused
460 }
461 _ => false,
462 })
463 .unwrap_or(0)
464 }
465
466 fn visible_row_start(
467 rows: &[HelpRenderRow],
468 focus: Option<&HelpHit>,
469 visible_budget: usize,
470 ) -> usize {
471 if rows.len() <= visible_budget {
472 return 0;
473 }
474
475 let selected_row = Self::selected_render_row(rows, focus);
476 let half = visible_budget / 2;
477 if selected_row <= half {
478 0
479 } else if selected_row + half >= rows.len() {
480 rows.len().saturating_sub(visible_budget)
481 } else {
482 selected_row.saturating_sub(half)
483 }
484 }
485 }
486
487 fn build_entries(
488 locale: Locale,
489 registry: &commands::user_registry::UserCommandRegistry,
490 skills: &[(String, String)],
491 ) -> Vec<HelpEntry> {
492 let mut entries = Vec::new();
493
494 for command in commands::command_infos() {
495 if registry.get(command.name).is_some() {
496 continue;
497 }
498 let label = format!("/{}", command.name);
499 let localized = command.description_for(locale);
500 let visible_aliases = command
501 .aliases
502 .iter()
503 .copied()
504 .filter(|alias| registry.get(alias).is_none())
505 .collect::<Vec<_>>();
506 let description = if visible_aliases.is_empty() {
507 localized.to_string()
508 } else {
509 format!(
510 "{} (aliases: {})",
511 localized,
512 visible_aliases
513 .iter()
514 .map(|a| format!("/{a}"))
515 .collect::<Vec<_>>()
516 .join(", ")
517 )
518 };
519 let haystack = format!(
520 "{} {} {}",
521 label.to_ascii_lowercase(),
522 description.to_ascii_lowercase(),
523 command.usage.to_ascii_lowercase()
524 );
525 entries.push(HelpEntry {
526 section: HelpSection::Command,
527 // Curated commands first, then the catalog; alphabetical within
528 // each by leaning on `label.clone()` in the final sort_by_key tuple.
529 sub_rank: command_sub_rank(&label),
530 usage: stated_usage(command.usage, &label),
531 label,
532 description,
533 haystack,
534 });
535 }
536
537 // Workspace commands (#3912). The registry was already consulted above to
538 // suppress shadowed built-ins; until now it never contributed a row of its
539 // own, so `.codewhale/commands/*.md` authors could not find their own work
540 // in the surface that teaches the product. `hidden` entries stay out.
541 for command in registry.iter().filter(|command| !command.hidden) {
542 let label = format!("/{}", command.name);
543 let description = command
544 .description
545 .as_deref()
546 .map(str::trim)
547 .filter(|description| !description.is_empty())
548 .unwrap_or_default()
549 .to_string();
550 let usage = command
551 .display_usage()
552 .map(str::to_owned)
553 .unwrap_or_else(|| label.clone());
554 let haystack = format!(
555 "{} {} {}",
556 label.to_ascii_lowercase(),
557 description.to_ascii_lowercase(),
558 usage.to_ascii_lowercase()
559 );
560 entries.push(HelpEntry {
561 section: HelpSection::UserCommand,
562 sub_rank: 0,
563 usage: stated_usage(&usage, &label),
564 label,
565 description,
566 haystack,
567 });
568 }
569
570 // Skills dispatch as `$name` or `/skill name`; advertise the shape the
571 // user actually types.
572 for (name, description) in skills {
573 let label = format!("${name}");
574 let description = description.trim().to_string();
575 let haystack = format!(
576 "{} {} /skill {}",
577 label.to_ascii_lowercase(),
578 description.to_ascii_lowercase(),
579 name.to_ascii_lowercase()
580 );
581 entries.push(HelpEntry {
582 section: HelpSection::Skill,
583 sub_rank: 0,
584 usage: None,
585 label,
586 description,
587 haystack,
588 });
589 }
590
591 for binding in KEYBINDINGS {
592 // macOS renders Alt chords with the Option glyph (`⌥V`), never
593 // `Alt`/`Cmd` (TUI-DOG-002 acceptance).
594 let mut label = crate::tui::shell_key_routing::display_chord(binding.chord).into_owned();
595 // The newline row is the one chord whose availability depends on the
596 // terminal rather than the platform, so it is answered here instead
597 // of listing a key that may do nothing.
598 if label.contains("Shift+Enter")
599 && !crate::tui::composer_ui::terminal_can_report_shift_enter()
600 {
601 label = label.replace(" / Shift+Enter (enhanced terminals)", "");
602 }
603 let description = tr(locale, binding.description_id).into_owned();
604 let haystack = format!(
605 "{} {}",
606 label.to_ascii_lowercase(),
607 description.to_ascii_lowercase()
608 );
609 entries.push(HelpEntry {
610 section: HelpSection::Keybinding,
611 sub_rank: binding.section.rank(),
612 usage: None,
613 label,
614 description,
615 haystack,
616 });
617 }
618
619 entries
620 }
621
622 /// The usage line worth printing beside a row, or `None` when it only
623 /// restates the label.
624 ///
625 /// `/copy`'s usage is `/copy`; showing it teaches nothing and costs the row
626 /// that `/queue [list|send <n>|…]` needs. Workspace commands declare the
627 /// argument shape alone (`<environment>`), so the label is prepended to make
628 /// the same whole line a built-in already carries.
629 fn stated_usage(usage: &str, label: &str) -> Option<String> {
630 let usage = usage.trim();
631 if usage.is_empty() || usage == label {
632 return None;
633 }
634 Some(if usage.starts_with('/') {
635 usage.to_string()
636 } else {
637 format!("{label} {usage}")
638 })
639 }
640
641 /// The commands Help opens on. Everything else stays one keystroke away under
642 /// *All commands* — 103 rows sorted alphabetically is a catalog, not an answer,
643 /// and it buried the handful of commands people actually reach for.
644 ///
645 /// Membership is a product judgement, not a usage metric: these are the ones a
646 /// session needs to steer itself. The first five agree with the composer's own
647 /// curated slash menu on purpose, so the two surfaces teach the same thing.
648 const COMMON_COMMANDS: [&str; 12] = [
649 "/setup",
650 "/model",
651 "/settings",
652 "/resume",
653 "/clear",
654 "/compact",
655 "/context",
656 "/cost",
657 "/diff",
658 "/mcp",
659 "/theme",
660 "/exit",
661 ];
662
663 /// Sort rank that also selects the group: curated commands sort and group ahead
664 /// of the full catalog, because `filtered` is ordered by
665 /// `(section_rank, sub_rank, label)` and `group_key` reads the same field.
666 const COMMAND_RANK_COMMON: u8 = 0;
667 const COMMAND_RANK_ALL: u8 = 1;
668
669 fn command_sub_rank(label: &str) -> u8 {
670 if COMMON_COMMANDS.contains(&label) {
671 COMMAND_RANK_COMMON
672 } else {
673 COMMAND_RANK_ALL
674 }
675 }
676
677 fn group_key(entry: &HelpEntry) -> String {
678 match entry.section {
679 HelpSection::Command if entry.sub_rank == COMMAND_RANK_COMMON => "cmd:common".into(),
680 HelpSection::Command => "cmd:all".into(),
681 HelpSection::UserCommand => "usercmd".into(),
682 HelpSection::Skill => "skill".into(),
683 HelpSection::Keybinding => format!("kb:{}", entry.sub_rank),
684 }
685 }
686
687 fn group_label(entry: &HelpEntry, locale: Locale) -> String {
688 match entry.section {
689 HelpSection::Command if entry.sub_rank == COMMAND_RANK_COMMON => {
690 tr(locale, MessageId::HelpGroupCommonCommands).into_owned()
691 }
692 HelpSection::Command => tr(locale, MessageId::HelpGroupAllCommands).into_owned(),
693 HelpSection::Keybinding => keybinding_section_for_rank(entry.sub_rank)
694 .map(|section| section.label(locale).into_owned())
695 .unwrap_or_else(|| entry.section.label(locale).into_owned()),
696 other => other.label(locale).into_owned(),
697 }
698 }
699
700 fn keybinding_section_for_rank(rank: u8) -> Option<crate::tui::keybindings::KeybindingSection> {
701 crate::tui::keybindings::KeybindingSection::ALL
702 .into_iter()
703 .find(|section| section.rank() == rank)
704 }
705
706 fn default_collapsed(ordering: HelpOrdering) -> HashSet<String> {
707 use crate::tui::keybindings::KeybindingSection;
708 let kb_keys = KeybindingSection::ALL
709 .into_iter()
710 .map(|section| format!("kb:{}", section.rank()));
711
712 match ordering {
713 HelpOrdering::KeybindingsFirst => {
714 // Show Navigation only — the rest is a long tail the user
715 // expands or searches. Slash/skill catalogs stay folded.
716 let mut set: HashSet<String> = ["cmd:common", "cmd:all", "usercmd", "skill"]
717 .into_iter()
718 .map(str::to_string)
719 .collect();
720 set.extend(kb_keys.filter(|key| key != "kb:0"));
721 set
722 }
723 HelpOrdering::CommandsFirst => {
724 // Open on the curated commands with everything else folded. The
725 // catalogs are still one keystroke — or one keystroke of typing,
726 // since a query ignores collapse entirely — away.
727 let mut set: HashSet<String> = ["cmd:all", "usercmd", "skill"]
728 .into_iter()
729 .map(str::to_string)
730 .collect();
731 set.extend(kb_keys);
732 set
733 }
734 }
735 }
736
737 /// Joints a one-line description may shed at, longest-binding first. These
738 /// are the marks the descriptions already use: a trailing parenthetical (the
739 /// alias list), a semicolon or em-dash clause, then ordinary sentence and
740 /// comma boundaries.
741 const FIELD_JOINTS: [&str; 6] = [" (", "; ", " — ", ". ", ": ", ", "];
742
743 /// Fit a description into `max_width` by shedding whole fields, never by
744 /// cutting one.
745 ///
746 /// The overlay used to hand every label and description to a
747 /// `truncate_to_width` that appended `…`. In a list of two hundred rows an
748 /// ellipsis is the worst possible mark: it promises text the row has no way
749 /// to reveal, and it lands mid-token — `(aliases: /qin…` leaves an unclosed
750 /// parenthesis, and `deepseek-v4-…` names no model, because these strings
751 /// share prefixes. So the description sheds its alias parenthetical first,
752 /// then trailing clauses at its own joints, and finally itself. The label is
753 /// never shed at all: it is the string the user has to type.
754 fn shed_to_width(text: &str, max_width: usize) -> Cow<'_, str> {
755 let trimmed = text.trim_end();
756 if max_width == 0 {
757 return Cow::Borrowed("");
758 }
759 if trimmed.width() <= max_width {
760 return Cow::Borrowed(trimmed);
761 }
762 let mut best = "";
763 let mut oversize_clause = "";
764 let mut depth = 0usize;
765 for (idx, ch) in trimmed.char_indices() {
766 match ch {
767 '(' => depth += 1,
768 ')' => depth = depth.saturating_sub(1),
769 _ => {}
770 }
771 // Only cut where the parentheses balance. `(aliases: /image, /media)`
772 // holds a `: ` and a `, ` that are joints of the alias list, not of
773 // the sentence; cutting there left `(aliases: /image, /media` with the
774 // parenthesis hanging open — no ellipsis, and still a broken row.
775 if depth > 0 {
776 continue;
777 }
778 let rest = &trimmed[idx..];
779 if !FIELD_JOINTS.iter().any(|joint| rest.starts_with(joint)) {
780 continue;
781 }
782 let head = trimmed[..idx].trim_end_matches([' ', ',', ';', ':', '—', '-']);
783 if head.is_empty() {
784 continue;
785 }
786 let width = head.width();
787 if width <= max_width {
788 if width > best.width() {
789 best = head;
790 }
791 } else if oversize_clause.is_empty() {
792 // The main clause was one column over, so the joint itself did
793 // not fire. Word-shed that clause rather than the alias list
794 // hanging off it — otherwise `/automation` keeps the adjectives
795 // and loses `automations`. Heads grow left to right, so the first
796 // oversize one is the main clause; a later, wider head is that
797 // clause plus everything trailing it, which is the text this
798 // branch exists to shed.
799 oversize_clause = head;
800 }
801 }
802 if best.is_empty() {
803 // Roughly half of these descriptions are a single clause with no
804 // joint at all — "Toggle background advisor watcher on/off for this
805 // session". Shedding the whole field there left a bare `/advisor`
806 // beside rows that still had text, which reads as a broken renderer
807 // rather than as a decision. So the last resort is the sentence's
808 // own short form: whole words, no mark, and the same text restated
809 // at panel width one row up in the detail slot. What is never done
810 // is append `…`, which
811 // would claim text this overlay has no way to reveal.
812 let source = if oversize_clause.is_empty() {
813 trimmed
814 } else {
815 oversize_clause
816 };
817 shed_to_words(source, max_width)
818 } else {
819 Cow::Borrowed(best)
820 }
821 }
822
823 /// Longest prefix of `text` that fits `max_width` display columns, cut on a
824 /// character boundary. Used when there is no word boundary to cut on.
825 fn widest_char_prefix(text: &str, max_width: usize) -> &str {
826 let mut fitted = 0usize;
827 for (idx, ch) in text.char_indices() {
828 let next = idx + ch.len_utf8();
829 if text[..next].width() > max_width {
830 break;
831 }
832 fitted = next;
833 }
834 &text[..fitted]
835 }
836
837 /// Longest whole-word prefix of `text` that fits, with trailing short
838 /// function words dropped so the phrase does not end on `to an`.
839 ///
840 /// The scan used to stop on a space, so the last word was never included
841 /// even when it fitted, and the two-pass short-word trim then left a simple
842 /// verb + modifier + noun phrase without the noun — `/automation` read
843 /// `Manage durable scheduled`. If that prefix lost the head noun, intervening
844 /// modifiers are dropped so the noun survives.
845 fn shed_to_words(text: &str, max_width: usize) -> Cow<'_, str> {
846 let mut end = 0usize;
847 for (idx, ch) in text.char_indices() {
848 if ch == ' ' && text[..idx].width() <= max_width {
849 end = idx;
850 }
851 }
852 // Include the last word when the whole phrase fits. The loop above only
853 // fires on spaces, so without this the head noun was always eaten.
854 if text.width() <= max_width {
855 end = text.len();
856 }
857 if end == 0 {
858 // No usable space boundary. That is the normal case for Japanese,
859 // Chinese and Thai, which do not delimit words with spaces at all —
860 // the loop above can never fire, so this used to return "" and every
861 // description in those locales rendered blank. It also happens in
862 // English whenever the first space falls beyond `max_width`.
863 // Fall back to the widest whole-character prefix that fits.
864 return Cow::Borrowed(
865 widest_char_prefix(text, max_width).trim_end_matches([' ', ',', ';', ':', '—', '-']),
866 );
867 }
868 let mut head = &text[..end];
869 // Two passes at most: enough for `to an`, not enough to eat a real word.
870 for _ in 0..2 {
871 let Some(cut) = head.rfind(' ') else { break };
872 if head.len() - cut - 1 > 3 {
873 break;
874 }
875 head = &head[..cut];
876 }
877 let head = head.trim_end_matches([' ', ',', ';', ':', '—', '-']);
878 if let Some(kept) = keep_simple_head_noun(text, head, max_width) {
879 return kept;
880 }
881 Cow::Borrowed(head)
882 }
883
884 fn is_short_function_word(word: &str) -> bool {
885 !word.is_empty() && word.len() <= 3
886 }
887
888 fn is_plain_content_word(word: &str) -> bool {
889 !word.is_empty()
890 && word
891 .chars()
892 .all(|ch| ch.is_ascii_alphabetic() || matches!(ch, '-' | '/'))
893 }
894
895 /// Restore the head noun of a simple `verb modifier* noun` phrase when the
896 /// prefix trim dropped it. Phrases with a short function word after the verb
897 /// (`Toggle the background advisor for this session`) stay prefix-trimmed,
898 /// as do rows that carry punctuation (`(aliases: /image, /media)`).
899 fn keep_simple_head_noun<'a>(
900 text: &'a str,
901 prefix: &'a str,
902 max_width: usize,
903 ) -> Option<Cow<'a, str>> {
904 let words: Vec<&str> = text.split(' ').filter(|word| !word.is_empty()).collect();
905 if words.len() < 2 {
906 return None;
907 }
908 let noun = *words.last()?;
909 if is_short_function_word(noun) || prefix.ends_with(noun) {
910 return None;
911 }
912 if !words.iter().all(|word| is_plain_content_word(word)) {
913 return None;
914 }
915 if words[1..words.len() - 1]
916 .iter()
917 .any(|word| is_short_function_word(word))
918 {
919 return None;
920 }
921 if noun.width() > max_width {
922 return None;
923 }
924 let verb = words[0];
925 let modifiers = &words[1..words.len() - 1];
926 for skip in 0..=modifiers.len() {
927 let mut candidate = String::from(verb);
928 for modifier in &modifiers[skip..] {
929 candidate.push(' ');
930 candidate.push_str(modifier);
931 }
932 candidate.push(' ');
933 candidate.push_str(noun);
934 if candidate.width() <= max_width {
935 if text.starts_with(&candidate)
936 && text
937 .as_bytes()
938 .get(candidate.len())
939 .is_none_or(|byte| *byte == b' ')
940 {
941 return Some(Cow::Borrowed(&text[..candidate.len()]));
942 }
943 return Some(Cow::Owned(candidate));
944 }
945 }
946 Some(Cow::Borrowed(noun))
947 }
948
949 impl ModalView for HelpView {
950 fn kind(&self) -> ModalKind {
951 ModalKind::Help
952 }
953
954 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
955 self
956 }
957
958 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
959 // Scroll clamps at the ends (keyboard Up/Down wrap); wheel-wrapping
960 // reads as disorienting.
961 match mouse.kind {
962 MouseEventKind::ScrollUp => self.move_selection(-1),
963 MouseEventKind::ScrollDown => self.move_selection(1),
964 MouseEventKind::Down(MouseButton::Left) => {
965 let hit = self.row_hitboxes.borrow().iter().find_map(|(rect, hit)| {
966 rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
967 .then_some(hit.clone())
968 });
969 if let Some(hit) = hit {
970 match hit {
971 HelpHit::Group(key) => self.toggle_group(&key),
972 HelpHit::Entry(slot) => self.set_focus(HelpHit::Entry(slot)),
973 }
974 }
975 }
976 _ => {}
977 }
978 ViewAction::None
979 }
980
981 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
982 match key.code {
983 KeyCode::Esc => ViewAction::Close,
984 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
985 ViewAction::Close
986 }
987 KeyCode::Up => {
988 self.move_selection_wrapping(-1);
989 ViewAction::None
990 }
991 KeyCode::Down => {
992 self.move_selection_wrapping(1);
993 ViewAction::None
994 }
995 KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
996 self.move_selection_wrapping(-1);
997 ViewAction::None
998 }
999 KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1000 self.move_selection_wrapping(1);
1001 ViewAction::None
1002 }
1003 KeyCode::PageUp => {
1004 self.move_selection(-10);
1005 ViewAction::None
1006 }
1007 KeyCode::PageDown => {
1008 self.move_selection(10);
1009 ViewAction::None
1010 }
1011 KeyCode::Home => {
1012 if let Some(first) = self.focusable_rows().first().cloned() {
1013 self.set_focus(first);
1014 }
1015 ViewAction::None
1016 }
1017 KeyCode::End => {
1018 if let Some(last) = self.focusable_rows().last().cloned() {
1019 self.set_focus(last);
1020 }
1021 ViewAction::None
1022 }
1023 KeyCode::Enter => {
1024 if let Some(HelpHit::Group(key)) = self.focus.clone() {
1025 self.toggle_group(&key);
1026 }
1027 ViewAction::None
1028 }
1029 KeyCode::Right => {
1030 if let Some(HelpHit::Group(key)) = self.focus.clone()
1031 && self.group_is_collapsed(&key)
1032 {
1033 self.toggle_group(&key);
1034 }
1035 ViewAction::None
1036 }
1037 KeyCode::Left => {
1038 if let Some(key) = self.focused_group_key() {
1039 match self.focus.as_ref() {
1040 Some(HelpHit::Entry(_)) => self.set_focus(HelpHit::Group(key)),
1041 Some(HelpHit::Group(_)) if !self.group_is_collapsed(&key) => {
1042 self.collapsed.insert(key.clone());
1043 self.set_focus(HelpHit::Group(key));
1044 self.clamp_focus_to_visible();
1045 }
1046 _ => {}
1047 }
1048 }
1049 ViewAction::None
1050 }
1051 KeyCode::Backspace => {
1052 self.query.pop();
1053 self.refilter();
1054 ViewAction::None
1055 }
1056 // Terminals where stty erase == ^H send Ctrl+H instead of
1057 // Backspace (DEL). Treat it identically so the filter input
1058 // works across all platforms (#958).
1059 KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1060 self.query.pop();
1061 self.refilter();
1062 ViewAction::None
1063 }
1064 KeyCode::Char(c)
1065 if !c.is_control()
1066 && (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT) =>
1067 {
1068 self.query.push(c);
1069 self.refilter();
1070 ViewAction::None
1071 }
1072 _ => ViewAction::None,
1073 }
1074 }
1075
1076 fn render(&self, area: Rect, buf: &mut Buffer) {
1077 self.row_hitboxes.borrow_mut().clear();
1078 let inner = render_underwater_surface(
1079 area,
1080 buf,
1081 format!(
1082 "{} — {}",
1083 self.tr(MessageId::HelpTitle),
1084 self.tr(MessageId::HelpSubtitle)
1085 ),
1086 );
1087
1088 // The action footer wraps inside the modal body (#3732) rather than the
1089 // single-line border title that silently clipped hints at narrow
1090 // widths; the list renders into the content area above it. Empty hint
1091 // keys keep the existing localized footer phrases as plain labels.
1092 let content = render_modal_footer(
1093 inner,
1094 buf,
1095 &[
1096 // `Type to filter` is already printed in the filter row two
1097 // lines above; saying it twice on one screen cost the row the
1098 // footer wrapped onto at 60 columns.
1099 ActionHint::new("", self.tr(MessageId::HelpFooterMove)),
1100 ActionHint::new("", self.tr(MessageId::HelpFooterJump)),
1101 // Directional tree controls are self-describing and avoid
1102 // injecting an English-only phrase into localized Help.
1103 ActionHint::new("←/→", ""),
1104 ActionHint::new("", self.tr(MessageId::HelpFooterClose)),
1105 ],
1106 );
1107
1108 let mut lines: Vec<Line<'static>> = Vec::new();
1109
1110 // The filter and the size of what it selected are one fact, so they
1111 // share one row: the count used to own a row of its own, and a blank
1112 // spacer owned the row under it. At 60x20 that was two of the eight
1113 // rows this overlay had left for content.
1114 let query_label = if self.query.is_empty() {
1115 self.tr(MessageId::HelpFilterPlaceholder).to_string()
1116 } else {
1117 format!("{}{}", self.tr(MessageId::HelpFilterPrefix), self.query)
1118 };
1119 let match_count = if self.query.is_empty() {
1120 format!("{} entries", self.entries.len())
1121 } else {
1122 format!("{} / {} matches", self.filtered.len(), self.entries.len())
1123 };
1124 let rows = self.render_rows();
1125 // Two header rows: the filter with its count, and the detail row
1126 // that restates the focused entry's description at panel width.
1127 let visible_rows = content.height.saturating_sub(2) as usize;
1128 let row_start = Self::visible_row_start(&rows, self.focus.as_ref(), visible_rows.max(1));
1129 // Reserve the rail before calculating column widths. Otherwise the
1130 // description column writes beneath the rail on compact terminals.
1131 let content = render_panel_scroll_rail(
1132 content,
1133 buf,
1134 rows.len(),
1135 row_start,
1136 visible_rows.max(1),
1137 true,
1138 );
1139
1140 // Borders and padding eat 4 cells from each side (border 1 + padding
1141 // 1) × 2. The label column is measured from the labels each group
1142 // holds rather than fixed at 28, and the descriptions get everything
1143 // left over.
1144 let inner_width = content.width as usize;
1145 let label_cap = 28.min(inner_width.saturating_sub(8));
1146 let label_widths = self.label_widths(label_cap);
1147
1148 // Measured against the rail-adjusted width so the right-aligned count
1149 // lands inside the list, not under the scroll rail.
1150 let gap = (content.width as usize)
1151 .saturating_sub(query_label.width() + match_count.width())
1152 .max(2);
1153 lines.push(Line::from(vec![
1154 Span::styled(
1155 query_label,
1156 Style::default()
1157 .fg(palette::WHALE_ACTION)
1158 .add_modifier(Modifier::BOLD),
1159 ),
1160 Span::raw(" ".repeat(gap)),
1161 Span::styled(match_count, Style::default().fg(palette::TEXT_DIM)),
1162 ]));
1163
1164 // A row cannot hold a command and a sentence at sixty columns, so the
1165 // list sheds descriptions there rather than cutting them. That is only
1166 // honest if the shed text is still reachable, so the focused entry
1167 // states its description here at the full width of the panel — where
1168 // most of them fit whole, and the rest shed at their own joints
1169 // instead of at a column boundary. The slot keeps its row whether or
1170 // not it is filled, so the list below does not jump as focus moves.
1171 let detail = self
1172 .focused_entry_detail(inner_width, label_cap, &label_widths)
1173 .unwrap_or_default();
1174 lines.push(Line::from(Span::styled(
1175 detail,
1176 Style::default().fg(palette::TEXT_MUTED),
1177 )));
1178
1179 if self.filtered.is_empty() {
1180 lines.push(Line::from(Span::styled(
1181 self.tr(MessageId::HelpNoMatches),
1182 Style::default()
1183 .fg(palette::TEXT_MUTED)
1184 .add_modifier(Modifier::ITALIC),
1185 )));
1186 } else {
1187 // `content` is the body area above the wrapping footer (the block's
1188 // border, padding, and footer rows already removed), so budgeting
1189 // against its height keeps selected rows clear of the footer.
1190 let header_lines = lines.len();
1191 let visible_budget = (content.height as usize)
1192 .saturating_sub(header_lines)
1193 .max(1);
1194
1195 for row in rows.iter().skip(row_start).take(visible_budget) {
1196 match *row {
1197 HelpRenderRow::Group {
1198 ref key,
1199 ref label,
1200 count,
1201 collapsed,
1202 } => {
1203 let row_y = content.y.saturating_add(lines.len() as u16);
1204 self.row_hitboxes.borrow_mut().push((
1205 Rect::new(content.x, row_y, content.width, 1),
1206 HelpHit::Group(key.clone()),
1207 ));
1208 // The selection cursor is `▸` and the collapsed
1209 // chevron is `▸`. Printed side by side, a focused
1210 // collapsed group read `▸ ▸ Slash commands (97)` —
1211 // the same glyph twice for two different facts. The
1212 // chevron stays, because it is this row's own state;
1213 // focus is carried by the selection style, which is
1214 // what carries it on every other row here.
1215 let marker = if collapsed { "▸" } else { "▾" };
1216 let is_focused = self.focus.as_ref() == Some(&HelpHit::Group(key.clone()));
1217 let style = if is_focused {
1218 menu_style::selected_row_style()
1219 } else {
1220 Style::default()
1221 .fg(palette::WHALE_ACTION)
1222 .add_modifier(Modifier::BOLD)
1223 };
1224 lines.push(Line::from(Span::styled(
1225 format!("{marker} {label} ({count})"),
1226 style,
1227 )));
1228 }
1229 HelpRenderRow::Entry { slot, entry_idx } => {
1230 let row_y = content.y.saturating_add(lines.len() as u16);
1231 self.row_hitboxes.borrow_mut().push((
1232 Rect::new(content.x, row_y, content.width, 1),
1233 HelpHit::Entry(slot),
1234 ));
1235 let entry = &self.entries[entry_idx];
1236 let is_selected = self.focus.as_ref() == Some(&HelpHit::Entry(slot));
1237 let cursor =
1238 format!("{} ", crate::tui::glyphs::selection_marker(is_selected));
1239 let label_width = label_widths
1240 .get(&group_key(entry))
1241 .copied()
1242 .unwrap_or(label_cap);
1243 let pad = label_width.saturating_sub(entry.label.width());
1244 let desc_capacity =
1245 inner_width.saturating_sub(cursor.width() + label_width + 2);
1246 let desc = shed_to_width(&entry.description, desc_capacity);
1247 // The label is the string you type and the description
1248 // qualifies it. They were both TEXT_PRIMARY, so the
1249 // row said everything at one weight and the eye had
1250 // nothing to skim down.
1251 let (label_style, desc_style) = if is_selected {
1252 (
1253 menu_style::selected_row_style(),
1254 menu_style::selected_row_style(),
1255 )
1256 } else {
1257 (
1258 Style::default().fg(palette::TEXT_PRIMARY),
1259 Style::default().fg(palette::TEXT_DIM),
1260 )
1261 };
1262 let mut spans = vec![
1263 Span::styled(format!("{cursor}{}", entry.label), label_style),
1264 Span::styled(" ".repeat(pad + 2), label_style),
1265 ];
1266 if !desc.is_empty() {
1267 spans.push(Span::styled(desc.to_string(), desc_style));
1268 }
1269 lines.push(Line::from(spans));
1270 }
1271 }
1272 }
1273 }
1274
1275 Paragraph::new(lines).render(content, buf);
1276 }
1277 }
1278
1279 #[cfg(test)]
1280 mod tests {
1281 use super::*;
1282 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1283
1284 fn key(code: KeyCode) -> KeyEvent {
1285 KeyEvent::new(code, KeyModifiers::NONE)
1286 }
1287
1288 fn type_filter(view: &mut HelpView, text: &str) {
1289 for ch in text.chars() {
1290 view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
1291 }
1292 }
1293
1294 fn first_filtered_section(view: &HelpView) -> HelpSection {
1295 view.entries[*view
1296 .filtered
1297 .first()
1298 .expect("help should contain at least one entry")]
1299 .section
1300 }
1301
1302 #[test]
1303 fn empty_filter_lists_all_entries() {
1304 let view = HelpView::new();
1305 // Total = registered slash commands + catalogued keybindings.
1306 let expected = commands::command_infos().len() + KEYBINDINGS.len();
1307 assert_eq!(view.filtered.len(), expected);
1308 assert_eq!(view.entries.len(), expected);
1309 }
1310
1311 #[test]
1312 fn entry_points_choose_the_section_they_promise() {
1313 let commands = HelpView::new_for_locale(Locale::En);
1314 assert_eq!(commands.ordering, HelpOrdering::CommandsFirst);
1315 assert_eq!(first_filtered_section(&commands), HelpSection::Command);
1316
1317 let shortcuts = HelpView::new_with_ordering(Locale::En, HelpOrdering::KeybindingsFirst);
1318 assert_eq!(shortcuts.ordering, HelpOrdering::KeybindingsFirst);
1319 assert_eq!(first_filtered_section(&shortcuts), HelpSection::Keybinding);
1320 }
1321
1322 #[test]
1323 fn workspace_commands_and_skills_are_findable_with_provenance() {
1324 // #3912: both surfaces executed and autocompleted but were absent
1325 // from the surface that teaches the product.
1326 let tmp = tempfile::TempDir::new().unwrap();
1327 let commands_dir = tmp.path().join(".codewhale").join("commands");
1328 std::fs::create_dir_all(&commands_dir).unwrap();
1329 std::fs::write(
1330 commands_dir.join("shipit.md"),
1331 "---\ndescription: Cut a release candidate\n---\nbody",
1332 )
1333 .unwrap();
1334 std::fs::write(
1335 commands_dir.join("secret.md"),
1336 "---\ndescription: Internal only\nhidden: true\n---\nbody",
1337 )
1338 .unwrap();
1339
1340 let skills = vec![(
1341 "codereview".to_string(),
1342 "Review a diff for defects".to_string(),
1343 )];
1344 let mut view = HelpView::new_for_workspace(Locale::En, tmp.path(), &skills);
1345
1346 let user = view
1347 .entries
1348 .iter()
1349 .find(|entry| entry.label == "/shipit")
1350 .expect("workspace command should be listed");
1351 assert_eq!(user.section, HelpSection::UserCommand);
1352 assert!(user.description.contains("Cut a release candidate"));
1353
1354 let skill = view
1355 .entries
1356 .iter()
1357 .find(|entry| entry.label == "$codereview")
1358 .expect("discovered skill should be listed");
1359 assert_eq!(skill.section, HelpSection::Skill);
1360
1361 assert!(
1362 !view.entries.iter().any(|entry| entry.label == "/secret"),
1363 "hidden workspace commands stay out of the overlay"
1364 );
1365
1366 // Both are reachable through the existing substring filter.
1367 type_filter(&mut view, "shipit");
1368 assert!(
1369 view.filtered
1370 .iter()
1371 .any(|idx| view.entries[*idx].label == "/shipit")
1372 );
1373
1374 let mut view = HelpView::new_for_workspace(Locale::En, tmp.path(), &skills);
1375 type_filter(&mut view, "review a diff");
1376 assert!(
1377 view.filtered
1378 .iter()
1379 .any(|idx| view.entries[*idx].label == "$codereview"),
1380 "skills are findable by their description"
1381 );
1382 }
1383
1384 #[test]
1385 fn skill_rows_advertise_the_slash_skill_shape_too() {
1386 let tmp = tempfile::TempDir::new().unwrap();
1387 let skills = vec![("audit".to_string(), "Audit the tree".to_string())];
1388 let mut view = HelpView::new_for_workspace(Locale::En, tmp.path(), &skills);
1389 type_filter(&mut view, "/skill audit");
1390 assert!(
1391 view.filtered
1392 .iter()
1393 .any(|idx| view.entries[*idx].label == "$audit"),
1394 "searching the /skill form finds the skill"
1395 );
1396 }
1397
1398 #[test]
1399 fn help_hides_builtins_with_shadowed_canonical_names() {
1400 let registry = commands::user_registry::UserCommandRegistry::from_loaded(vec![(
1401 "help".to_string(),
1402 "---\ndescription: Custom help workflow\n---\ncustom help".to_string(),
1403 )]);
1404 let entries = build_entries(Locale::En, &registry, &[]);
1405
1406 // The built-in row is suppressed so the name is not advertised twice.
1407 assert!(
1408 !entries
1409 .iter()
1410 .any(|entry| entry.label == "/help" && entry.section == HelpSection::Command),
1411 "the shadowed built-in must not keep its own row"
1412 );
1413 // Since #3912 the shadowing workspace command supplies the row instead
1414 // of the name vanishing from help entirely.
1415 let user = entries
1416 .iter()
1417 .find(|entry| entry.label == "/help")
1418 .expect("the user command that shadows /help should be listed");
1419 assert_eq!(user.section, HelpSection::UserCommand);
1420 assert!(user.description.contains("Custom help workflow"));
1421 }
1422
1423 #[test]
1424 fn substring_filter_narrows_to_command() {
1425 let mut view = HelpView::new();
1426 type_filter(&mut view, "mode [act");
1427 assert!(!view.filtered.is_empty());
1428 // Every filtered entry should genuinely contain the query in its
1429 // searchable haystack — no false positives slipped past.
1430 for idx in &view.filtered {
1431 assert!(
1432 view.entries[*idx].haystack.contains("mode [act"),
1433 "entry {:?} leaked through `mode [act` filter",
1434 view.entries[*idx]
1435 );
1436 }
1437 // The unified `/mode` command must surface when filtering for a
1438 // concrete mode value from the visible vocabulary.
1439 assert!(
1440 view.filtered
1441 .iter()
1442 .any(|idx| view.entries[*idx].label == "/mode"),
1443 "/mode should match the `mode [act` filter"
1444 );
1445 }
1446
1447 #[test]
1448 fn substring_filter_finds_keybinding_by_chord() {
1449 let mut view = HelpView::new();
1450 type_filter(&mut view, "ctrl+r");
1451 assert!(!view.filtered.is_empty(), "Ctrl+R should match");
1452 assert!(
1453 view.filtered
1454 .iter()
1455 .any(|idx| view.entries[*idx].label.eq_ignore_ascii_case("ctrl+r")),
1456 "Ctrl+R chord must surface in the filtered set"
1457 );
1458 }
1459
1460 #[test]
1461 fn multiple_terms_act_as_and() {
1462 let mut view = HelpView::new();
1463 type_filter(&mut view, "session picker");
1464 assert!(
1465 !view.filtered.is_empty(),
1466 "expected at least one entry mentioning both `session` and `picker`"
1467 );
1468 for idx in &view.filtered {
1469 let haystack = &view.entries[*idx].haystack;
1470 assert!(
1471 haystack.contains("session") && haystack.contains("picker"),
1472 "entry {:?} leaked through `session picker` AND filter",
1473 view.entries[*idx]
1474 );
1475 }
1476 }
1477
1478 #[test]
1479 fn unknown_filter_yields_empty_set() {
1480 let mut view = HelpView::new();
1481 type_filter(&mut view, "zzzqqxxnope");
1482 assert!(view.filtered.is_empty());
1483 assert_eq!(view.selected, 0);
1484 }
1485
1486 #[test]
1487 fn backspace_widens_match_set() {
1488 let mut view = HelpView::new();
1489 // Near-miss against the still-visible mode vocabulary so the last
1490 // character removes a unique miss and broadens the match set.
1491 type_filter(&mut view, "modez");
1492 let narrow = view.filtered.len();
1493 view.handle_key(key(KeyCode::Backspace));
1494 let wider = view.filtered.len();
1495 assert!(
1496 wider > narrow,
1497 "backspace must broaden the matching set (was {narrow}, now {wider})"
1498 );
1499 }
1500
1501 #[test]
1502 fn ctrl_h_widens_match_set() {
1503 let mut view = HelpView::new();
1504 type_filter(&mut view, "modez");
1505 let narrow = view.filtered.len();
1506 view.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
1507 let wider = view.filtered.len();
1508 assert!(
1509 wider > narrow,
1510 "Ctrl+H must behave as Backspace, broadening the matching set (was {narrow}, now {wider})"
1511 );
1512 }
1513
1514 #[test]
1515 fn esc_closes_overlay() {
1516 let mut view = HelpView::new();
1517 let action = view.handle_key(key(KeyCode::Esc));
1518 assert!(matches!(action, ViewAction::Close));
1519 }
1520
1521 #[test]
1522 fn ctrl_c_closes_overlay() {
1523 let mut view = HelpView::new();
1524 let action = view.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
1525 assert!(matches!(action, ViewAction::Close));
1526 }
1527
1528 #[test]
1529 fn help_search_owns_initial_q() {
1530 for query in ["queue", "Queue", "q 队列é"] {
1531 let mut stack = crate::tui::views::ViewStack::new();
1532 stack.push(HelpView::new());
1533 for ch in query.chars() {
1534 let modifiers = if ch.is_uppercase() {
1535 KeyModifiers::SHIFT
1536 } else {
1537 KeyModifiers::NONE
1538 };
1539 assert!(
1540 stack
1541 .handle_key(KeyEvent::new(KeyCode::Char(ch), modifiers))
1542 .is_empty()
1543 );
1544 assert_eq!(stack.top_kind(), Some(ModalKind::Help), "{query:?}");
1545 }
1546 let mut modal = stack.pop().unwrap();
1547 let view = modal.as_any_mut().downcast_mut::<HelpView>().unwrap();
1548 assert_eq!(view.query, query);
1549 if query.eq_ignore_ascii_case("queue") {
1550 assert!(
1551 view.filtered
1552 .iter()
1553 .any(|&i| view.entries[i].label == "/queue")
1554 );
1555 }
1556 view.handle_key(key(KeyCode::Backspace));
1557 assert_eq!(
1558 view.query,
1559 query
1560 .chars()
1561 .take(query.chars().count() - 1)
1562 .collect::<String>()
1563 );
1564 assert!(matches!(
1565 view.handle_key(key(KeyCode::Esc)),
1566 ViewAction::Close
1567 ));
1568 }
1569 }
1570
1571 #[test]
1572 fn arrow_keys_move_selection_and_wrap_edges() {
1573 let mut view = HelpView::new();
1574 let focusable = view.focusable_rows();
1575 assert!(
1576 focusable.len() >= 3,
1577 "need at least three visible help rows"
1578 );
1579 // Help opens on the first entry, not the header above it: the detail
1580 // row under the filter reads the focused entry, and a header has no
1581 // description to put there.
1582 assert_eq!(view.focus.as_ref(), Some(&focusable[1]));
1583 // Up returns to its group; another Up wraps to the final visible row.
1584 view.handle_key(key(KeyCode::Up));
1585 assert_eq!(view.focus.as_ref(), focusable.first());
1586 view.handle_key(key(KeyCode::Up));
1587 assert_eq!(view.focus.as_ref(), focusable.last());
1588 // Down from last wraps to first; End still jumps to the last visible row.
1589 view.handle_key(key(KeyCode::Down));
1590 assert_eq!(view.focus.as_ref(), focusable.first());
1591 view.handle_key(key(KeyCode::Down));
1592 assert_eq!(view.focus.as_ref(), Some(&focusable[1]));
1593 view.handle_key(key(KeyCode::End));
1594 assert_eq!(view.focus.as_ref(), focusable.last());
1595 }
1596
1597 #[test]
1598 fn mouse_click_selects_visible_help_row() {
1599 let mut view = HelpView::new();
1600 let area = Rect::new(0, 0, 100, 30);
1601 let mut buf = Buffer::empty(area);
1602 view.render(area, &mut buf);
1603 let (rect, slot) = view
1604 .row_hitboxes
1605 .borrow()
1606 .iter()
1607 .find_map(|(rect, hit)| match hit {
1608 HelpHit::Entry(slot) => Some((*rect, *slot)),
1609 HelpHit::Group(_) => None,
1610 })
1611 .expect("at least one entry hitbox");
1612
1613 view.handle_mouse(MouseEvent {
1614 kind: MouseEventKind::Down(MouseButton::Left),
1615 column: rect.x,
1616 row: rect.y,
1617 modifiers: KeyModifiers::NONE,
1618 });
1619
1620 assert_eq!(view.selected, slot);
1621 assert_eq!(view.focus, Some(HelpHit::Entry(slot)));
1622 }
1623
1624 /// `/help` used to open on all 103 slash commands sorted alphabetically —
1625 /// a catalog, not an answer, and it buried the handful of commands a
1626 /// session actually steers itself with. It opens on the curated group now,
1627 /// with the catalog one keystroke below it.
1628 #[test]
1629 fn help_opens_on_the_curated_commands_with_the_catalog_folded() {
1630 let view = HelpView::new();
1631 assert!(
1632 !view.group_is_collapsed("cmd:common"),
1633 "the curated commands are the point of opening Help"
1634 );
1635 assert!(
1636 view.group_is_collapsed("cmd:all"),
1637 "the full catalog stays folded until asked for"
1638 );
1639
1640 let rows = view.render_rows();
1641 let entries = rows
1642 .iter()
1643 .filter(|row| matches!(row, HelpRenderRow::Entry { .. }))
1644 .count();
1645 assert!(
1646 entries <= COMMON_COMMANDS.len(),
1647 "Help opened with {entries} rows; only the curated set should be expanded: {:?}",
1648 rows.iter()
1649 .filter_map(|row| match row {
1650 HelpRenderRow::Entry { entry_idx, .. } =>
1651 Some(view.entries[*entry_idx].label.clone()),
1652 _ => None,
1653 })
1654 .collect::<Vec<_>>()
1655 );
1656
1657 // Every curated command is a real registered command, and each is on
1658 // screen. A typo here would silently shrink the opening view.
1659 // Distinct labels: a workspace command may share a built-in's name,
1660 // and that is a naming collision, not a missing curated entry.
1661 let shown: std::collections::BTreeSet<&str> = view
1662 .filtered
1663 .iter()
1664 .map(|idx| view.entries[*idx].label.as_str())
1665 .filter(|label| COMMON_COMMANDS.contains(label))
1666 .collect();
1667 assert_eq!(
1668 shown.len(),
1669 COMMON_COMMANDS.len(),
1670 "curated commands missing from the registry: {:?}",
1671 COMMON_COMMANDS
1672 .iter()
1673 .filter(|name| !shown.contains(*name))
1674 .collect::<Vec<_>>()
1675 );
1676
1677 // Typing reaches the folded catalog without expanding anything by hand.
1678 let mut view = view;
1679 type_filter(&mut view, "/advisor");
1680 assert!(
1681 view.filtered
1682 .iter()
1683 .any(|idx| view.entries[*idx].label == "/advisor"),
1684 "a query must reach commands inside the folded catalog"
1685 );
1686 }
1687
1688 /// Help opens with the full command catalog folded. Tests about layout,
1689 /// scrolling or a specific catalog command open it first — what they
1690 /// exercise is the rendering of those rows, not the default fold state,
1691 /// which `help_opens_on_the_curated_commands` covers on its own.
1692 fn view_with_catalog_open() -> HelpView {
1693 let mut view = HelpView::new();
1694 view.toggle_group("cmd:all");
1695 view.focus = None;
1696 view
1697 }
1698
1699 #[test]
1700 fn visible_window_keeps_selected_entry_visible_after_scroll() {
1701 let mut view = view_with_catalog_open();
1702 let selected = view
1703 .filtered
1704 .iter()
1705 .position(|idx| view.entries[*idx].label == "/home")
1706 .expect("/home command should be present");
1707 view.selected = selected;
1708 view.focus = Some(HelpHit::Entry(selected));
1709
1710 let rows = view.render_rows();
1711 let row_start = HelpView::visible_row_start(&rows, view.focus.as_ref(), 12);
1712 let visible = &rows[row_start..(row_start + 12).min(rows.len())];
1713
1714 assert!(
1715 visible
1716 .iter()
1717 .any(|row| matches!(row, HelpRenderRow::Entry { slot, .. } if *slot == selected)),
1718 "selected help entry should stay in the visible render window"
1719 );
1720 }
1721
1722 fn rows_at(view: &HelpView, width: u16, height: u16) -> Vec<String> {
1723 let area = Rect::new(0, 0, width, height);
1724 let mut buf = Buffer::empty(area);
1725 view.render(area, &mut buf);
1726 (area.top()..area.bottom())
1727 .map(|y| {
1728 (area.left()..area.right())
1729 .map(|x| buf[(x, y)].symbol())
1730 .collect::<String>()
1731 })
1732 .collect()
1733 }
1734
1735 /// House rule, and the thing the overlay broke worst. A trailing `…` in a
1736 /// list of two hundred rows promises text no keystroke can reveal, and it
1737 /// lands mid-token: `(aliases: /qin…` and `deepseek-v4-…` name nothing,
1738 /// because these strings share prefixes.
1739 #[test]
1740 fn no_row_advertises_truncation_at_any_width() {
1741 for width in [60u16, 80, 96, 120] {
1742 let view = HelpView::new();
1743 for row in rows_at(&view, width, 24) {
1744 assert!(
1745 !row.contains('…'),
1746 "help must shed, not truncate, at {width} columns: {row:?}"
1747 );
1748 }
1749 }
1750 }
1751
1752 /// A cut inside `(aliases: /image, /media)` leaves the parenthesis hanging
1753 /// open — no ellipsis, and still a broken row. Joints only count where the
1754 /// parentheses balance.
1755 #[test]
1756 fn shedding_never_leaves_a_parenthesis_open() {
1757 let text = "Attach media (aliases: /image, /media)";
1758 for width in 4..text.len() {
1759 let shed = shed_to_width(text, width);
1760 let opens = shed.matches('(').count();
1761 let closes = shed.matches(')').count();
1762 assert_eq!(opens, closes, "unbalanced at width {width}: {shed:?}");
1763 }
1764 }
1765
1766 /// A single-clause description has no joint to shed at. Shedding the whole
1767 /// field left a bare label beside rows that still had text, which reads as
1768 /// a broken renderer; the short form stops on a whole word instead, and
1769 /// does not end on a dangling `to an`.
1770 #[test]
1771 fn a_jointless_description_sheds_to_whole_words() {
1772 let text = "Move the active branch to an existing session entry";
1773 let shed = shed_to_width(text, 26);
1774 assert!(text.starts_with(&*shed), "{shed:?}");
1775 assert!(!shed.is_empty());
1776 assert!(!shed.ends_with(" an"), "{shed:?}");
1777 assert!(!shed.ends_with(" to"), "{shed:?}");
1778 assert!(!shed.ends_with('…'), "{shed:?}");
1779 }
1780
1781 /// The label column was a flat 28 columns at every terminal size, so at 60
1782 /// columns twenty blank cells sat between `/advisor` and a description cut
1783 /// to 21. It is measured from the labels each group holds instead — and
1784 /// measured in the rendered row, not just in the helper, because a helper
1785 /// the renderer ignores proves nothing.
1786 #[test]
1787 fn label_column_is_measured_from_the_group_not_fixed() {
1788 let view = view_with_catalog_open();
1789 let widest = view
1790 .entries
1791 .iter()
1792 .filter(|entry| {
1793 entry.section == HelpSection::Command && entry.sub_rank == COMMAND_RANK_ALL
1794 })
1795 .map(|entry| entry.label.width())
1796 .max()
1797 .expect("commands exist");
1798 assert!(
1799 widest < 28,
1800 "slash command labels are short; the fixture assumes it"
1801 );
1802 assert_eq!(view.label_widths(28).get("cmd:all").copied(), Some(widest));
1803
1804 // Tall enough to reach past the curated group into the catalog.
1805 let rows = rows_at(&view, 60, 60);
1806 let row = rows
1807 .iter()
1808 .find(|row| row.contains("/advisor"))
1809 .expect("advisor row");
1810 let label_at = row.find("/advisor").expect("label");
1811 let description_at = row[label_at..]
1812 .find("Toggle")
1813 .map(|offset| label_at + offset)
1814 .expect("description follows the label on the same row");
1815 assert!(
1816 description_at - label_at <= widest + 2,
1817 "description must start right after the widest label in the group, \
1818 not after a flat 28-column gutter: {row:?}"
1819 );
1820 }
1821
1822 /// At 60x20 the description slot is 35 columns. `/automation`'s
1823 /// "Manage durable scheduled automations" is 36, so the last-resort
1824 /// word shed printed "Manage durable scheduled" — the adjectives
1825 /// without the noun that says what is being managed.
1826 #[test]
1827 fn sixty_column_help_keeps_the_automation_noun() {
1828 let view = view_with_catalog_open();
1829 let rows = rows_at(&view, 60, 60);
1830 let row = rows
1831 .iter()
1832 .find(|row| row.contains("/automation"))
1833 .expect("/automation is a registered command");
1834 assert!(
1835 row.contains("automations"),
1836 "/automation lost the noun it manages: {row:?}"
1837 );
1838 }
1839
1840 /// The selection cursor and the collapsed chevron are both `▸`. Printed
1841 /// side by side, a focused collapsed group read `▸ ▸ Slash commands (97)`
1842 /// — one glyph, twice, for two different facts.
1843 #[test]
1844 fn a_group_header_spends_one_glyph_on_one_meaning() {
1845 let mut view = view_with_catalog_open();
1846 view.toggle_group("cmd:all");
1847 assert_eq!(view.focus, Some(HelpHit::Group("cmd:all".to_string())));
1848 let rows = rows_at(&view, 96, 60);
1849 let header = rows
1850 .iter()
1851 .find(|row| row.contains("All commands"))
1852 .expect("group header row");
1853 assert!(!header.contains("▸ ▸"), "{header:?}");
1854 assert!(
1855 header.contains('▸'),
1856 "collapsed state still shown: {header:?}"
1857 );
1858 }
1859
1860 /// The detail row repairs a shed; it never repeats one. On a wide terminal
1861 /// the inline description already fits, so the slot carries the usage
1862 /// line alone rather than printing the same sentence twice on one screen.
1863 #[test]
1864 fn the_detail_row_repairs_a_shed_and_never_repeats_one() {
1865 let mut view = view_with_catalog_open();
1866 let slot = view
1867 .filtered
1868 .iter()
1869 .position(|idx| view.entries[*idx].label == "/advisor")
1870 .expect("/advisor is a registered command");
1871 view.set_focus(HelpHit::Entry(slot));
1872 let entry = &view.entries[view.filtered[slot]];
1873 let description = entry.description.clone();
1874
1875 let wide = rows_at(&view, 140, 24);
1876 let occurrences = wide
1877 .iter()
1878 .filter(|row| row.contains(description.trim()))
1879 .count();
1880 assert_eq!(
1881 occurrences, 1,
1882 "wide terminal must not say it twice: {wide:#?}"
1883 );
1884
1885 let narrow = rows_at(&view, 60, 20);
1886 let detail_row = narrow
1887 .iter()
1888 .position(|row| row.contains("Type to filter"))
1889 .expect("filter row")
1890 + 1;
1891 // The scroll rail paints the last column of every row.
1892 let strip_rail = |row: &str| {
1893 row.trim_end_matches(['█', '│', '┃', ' '])
1894 .trim()
1895 .to_string()
1896 };
1897 let detail = strip_rail(narrow.get(detail_row).expect("detail row"));
1898 assert!(
1899 !detail.is_empty(),
1900 "narrow terminal must repair the shed: {narrow:#?}"
1901 );
1902 assert!(description.starts_with(&detail), "{detail:?}");
1903 let inline = strip_rail(
1904 narrow
1905 .iter()
1906 .find(|row| row.contains("/advisor"))
1907 .expect("advisor row"),
1908 );
1909 let inline_description = inline
1910 .split_once("/advisor")
1911 .map(|(_, rest)| rest.trim().to_string())
1912 .unwrap_or_default();
1913 assert!(
1914 detail.len() > inline_description.len(),
1915 "the detail row must carry more than the row could: {inline_description:?} / {detail:?}"
1916 );
1917 }
1918
1919 /// #5952: the usage string lived in the search haystack alone, so
1920 /// `/workspace [path|worktrees]` rendered as `/workspace` and the
1921 /// worktree manager behind it had no way of being seen.
1922 #[test]
1923 fn the_focused_command_states_its_usage() {
1924 let mut view = HelpView::new();
1925 let slot = view
1926 .filtered
1927 .iter()
1928 .position(|idx| view.entries[*idx].label == "/workspace")
1929 .expect("/workspace is a registered command");
1930 view.set_focus(HelpHit::Entry(slot));
1931
1932 let rows = rows_at(&view, 140, 24);
1933 assert!(
1934 rows.iter()
1935 .any(|row| row.contains("/workspace [path|worktrees]")),
1936 "the usage line must be on screen: {rows:#?}"
1937 );
1938 }
1939
1940 /// A row whose usage only restates its label spends no columns saying so.
1941 #[test]
1942 fn a_command_without_arguments_states_no_usage() {
1943 let view = HelpView::new();
1944 let entry = view
1945 .entries
1946 .iter()
1947 .find(|entry| entry.label == "/copy")
1948 .expect("/copy is a registered command");
1949 assert_eq!(entry.usage, None);
1950
1951 let workspace = view
1952 .entries
1953 .iter()
1954 .find(|entry| entry.label == "/workspace")
1955 .expect("/workspace is a registered command");
1956 assert_eq!(
1957 workspace.usage.as_deref(),
1958 Some("/workspace [path|worktrees]")
1959 );
1960 }
1961
1962 /// At 60 columns the detail slot cannot hold both, and the description it
1963 /// exists to repair wins — the usage sheds at its own joint rather than
1964 /// pushing the sentence off the panel.
1965 #[test]
1966 fn a_narrow_panel_keeps_the_repaired_description_over_the_usage() {
1967 let mut view = HelpView::new();
1968 let slot = view
1969 .filtered
1970 .iter()
1971 .position(|idx| view.entries[*idx].label == "/advisor")
1972 .expect("/advisor is a registered command");
1973 view.set_focus(HelpHit::Entry(slot));
1974 let description = view.entries[view.filtered[slot]].description.clone();
1975
1976 let narrow = rows_at(&view, 60, 20);
1977 let detail_row = narrow
1978 .iter()
1979 .position(|row| row.contains("Type to filter"))
1980 .expect("filter row")
1981 + 1;
1982 let detail = narrow
1983 .get(detail_row)
1984 .expect("detail row")
1985 .trim_end_matches(['█', '│', '┃', ' '])
1986 .trim()
1987 .to_string();
1988 assert!(
1989 description.starts_with(&detail) && !detail.is_empty(),
1990 "the narrow detail row must still be the description: {detail:?}"
1991 );
1992 }
1993
1994 /// Workspace commands declare their own usage in front matter; the row
1995 /// reads it from the same place the built-ins read theirs.
1996 #[test]
1997 fn a_workspace_command_states_its_declared_usage() {
1998 let tmp = tempfile::TempDir::new().unwrap();
1999 let commands_dir = tmp.path().join(".codewhale").join("commands");
2000 std::fs::create_dir_all(&commands_dir).unwrap();
2001 std::fs::write(
2002 commands_dir.join("shipit.md"),
2003 "---\ndescription: Ship the branch\nargument-hint: <environment>\n---\nbody",
2004 )
2005 .unwrap();
2006
2007 let view = HelpView::new_for_workspace(Locale::En, tmp.path(), &[]);
2008 let entry = view
2009 .entries
2010 .iter()
2011 .find(|entry| entry.label == "/shipit")
2012 .expect("workspace command row");
2013 assert_eq!(entry.usage.as_deref(), Some("/shipit <environment>"));
2014 }
2015
2016 #[test]
2017 fn render_keeps_next_row_after_help_visible() {
2018 let mut view = HelpView::new();
2019 let help_slot = view
2020 .filtered
2021 .iter()
2022 .position(|idx| view.entries[*idx].label == "/help")
2023 .expect("/help command should be present");
2024 view.selected = help_slot;
2025 view.focus = Some(HelpHit::Entry(help_slot));
2026 view.handle_key(key(KeyCode::Down));
2027 let selected_slot = match view.focus {
2028 Some(HelpHit::Entry(slot)) => slot,
2029 ref other => panic!("expected entry focus after /help, got {other:?}"),
2030 };
2031 let selected_idx = view.filtered[selected_slot];
2032 let selected_label = view.entries[selected_idx].label.clone();
2033
2034 let area = Rect::new(0, 0, 96, 32);
2035 let mut buf = Buffer::empty(area);
2036 view.render(area, &mut buf);
2037
2038 let mut highlighted_label = false;
2039 for y in area.top()..area.bottom() {
2040 let mut row = String::new();
2041 let mut row_has_highlight = false;
2042 for x in area.left()..area.right() {
2043 let cell = &buf[(x, y)];
2044 row.push_str(cell.symbol());
2045 row_has_highlight |=
2046 cell.bg == palette::SELECTION_BG && cell.fg == palette::SELECTION_TEXT;
2047 }
2048 if row_has_highlight && row.contains(&selected_label) {
2049 highlighted_label = true;
2050 break;
2051 }
2052 }
2053
2054 assert!(
2055 highlighted_label,
2056 "selected row after /help should stay visibly highlighted"
2057 );
2058 }
2059
2060 #[test]
2061 fn selected_help_row_uses_selection_highlight() {
2062 let view = HelpView::new();
2063 let area = Rect::new(0, 0, 96, 32);
2064 let mut buf = Buffer::empty(area);
2065 view.render(area, &mut buf);
2066
2067 let mut found_highlight = false;
2068 for y in area.top()..area.bottom() {
2069 for x in area.left()..area.right() {
2070 let cell = &buf[(x, y)];
2071 if cell.bg == palette::SELECTION_BG && cell.fg == palette::SELECTION_TEXT {
2072 found_highlight = true;
2073 break;
2074 }
2075 }
2076 }
2077
2078 assert!(
2079 found_highlight,
2080 "selected row should use the semantic selection highlight"
2081 );
2082 }
2083
2084 #[test]
2085 fn render_includes_help_chrome_for_empty_filter() {
2086 let view = view_with_catalog_open();
2087 let area = Rect::new(0, 0, 96, 32);
2088 let mut buf = Buffer::empty(area);
2089 view.render(area, &mut buf);
2090
2091 let dump = buffer_text(&buf, area);
2092 // Title border + section headings should always render.
2093 assert!(dump.contains("Help"), "missing help title:\n{dump}");
2094 assert!(
2095 dump.contains("Type to filter"),
2096 "missing filter prompt:\n{dump}"
2097 );
2098 // Help opens on the curated set with the catalog folded beneath it,
2099 // so both group headings are part of the chrome a user always sees.
2100 assert!(
2101 dump.contains("Common commands"),
2102 "missing curated-command heading:\n{dump}"
2103 );
2104 assert!(
2105 dump.contains("All commands"),
2106 "missing command-catalog heading:\n{dump}"
2107 );
2108 // Footer hint should advertise close key on the bottom border.
2109 assert!(
2110 dump.contains("Esc close"),
2111 "missing Esc close footer hint:\n{dump}"
2112 );
2113 }
2114
2115 #[test]
2116 fn render_with_filter_shows_only_matching_section_and_status() {
2117 let mut view = HelpView::new();
2118 type_filter(&mut view, "mode [act");
2119 let area = Rect::new(0, 0, 96, 24);
2120 let mut buf = Buffer::empty(area);
2121 view.render(area, &mut buf);
2122
2123 let dump = buffer_text(&buf, area);
2124 assert!(
2125 dump.contains("Filter: mode [act"),
2126 "filter echo missing:\n{dump}"
2127 );
2128 assert!(
2129 dump.contains("matches"),
2130 "match counter missing in dump:\n{dump}"
2131 );
2132 assert!(
2133 dump.contains("/mode"),
2134 "expected /mode command in filtered render:\n{dump}"
2135 );
2136 assert!(
2137 !dump.contains("/model"),
2138 "non-matching commands should not render under a `mode [act` filter:\n{dump}"
2139 );
2140 }
2141
2142 #[test]
2143 fn localized_help_chrome_renders_without_missing_markers() {
2144 let view = HelpView::new_for_locale(Locale::ZhHans);
2145 let area = Rect::new(0, 0, 48, 18);
2146 let mut buf = Buffer::empty(area);
2147 view.render(area, &mut buf);
2148
2149 let dump = buffer_text(&buf, area);
2150 assert!(
2151 dump.contains('帮') && dump.contains('助'),
2152 "missing localized title:\n{dump}"
2153 );
2154 assert!(
2155 !dump.contains("MISSING"),
2156 "missing-key marker leaked:\n{dump}"
2157 );
2158 }
2159
2160 #[test]
2161 fn localized_help_keybinding_descriptions_use_zh_hans() {
2162 let registry = commands::user_registry::UserCommandRegistry::new();
2163 let entries = build_entries(Locale::ZhHans, &registry, &[]);
2164 let kb_entries: Vec<_> = entries
2165 .iter()
2166 .filter(|e| e.section == HelpSection::Keybinding)
2167 .collect();
2168 assert!(!kb_entries.is_empty(), "no keybinding entries found");
2169
2170 for entry in &kb_entries {
2171 let group = group_label(entry, Locale::ZhHans);
2172 assert!(
2173 group
2174 .chars()
2175 .any(|c| { ('\u{4e00}'..='\u{9fff}').contains(&c) }),
2176 "keybinding group not localized: {group} ({})",
2177 entry.description
2178 );
2179 }
2180 }
2181
2182 /// The four terminal sizes the v0.8.66 modal blocker (#3732) requires
2183 /// every overlay to remain readable and fully operable at.
2184 const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
2185
2186 const SHORTCUT_HELP_SIZES: [(u16, u16); 5] =
2187 [(40, 12), (60, 16), (80, 24), (100, 32), (140, 40)];
2188
2189 #[test]
2190 fn shortcut_help_leads_with_keys_at_responsive_sizes() {
2191 use crate::tui::views::ViewStack;
2192
2193 let keybindings_heading = tr(Locale::En, MessageId::HelpSectionNavigation);
2194 let commands_heading = tr(Locale::En, MessageId::HelpSlashCommands);
2195
2196 for (w, h) in SHORTCUT_HELP_SIZES {
2197 let area = Rect::new(0, 0, w, h);
2198 let mut buf = Buffer::empty(area);
2199 for y in 0..h {
2200 for x in 0..w {
2201 buf[(x, y)].set_symbol("§");
2202 }
2203 }
2204
2205 let mut stack = ViewStack::new();
2206 stack.push(HelpView::new_with_ordering(
2207 Locale::En,
2208 HelpOrdering::KeybindingsFirst,
2209 ));
2210 stack.render(area, &mut buf);
2211
2212 let rows: Vec<String> = (0..h)
2213 .map(|y| {
2214 (0..w)
2215 .map(|x| buf[(x, y)].symbol().to_string())
2216 .collect::<String>()
2217 })
2218 .collect();
2219 let text = rows.join("\n");
2220 let keys_at = text.find(keybindings_heading.as_ref()).unwrap_or_else(|| {
2221 panic!("{w}x{h}: shortcut Help hid the keybindings heading:\n{text}")
2222 });
2223 if let Some(commands_at) = text.find(commands_heading.as_ref()) {
2224 assert!(
2225 keys_at < commands_at,
2226 "{w}x{h}: shortcut Help rendered commands before keybindings:\n{text}"
2227 );
2228 }
2229 assert!(
2230 !text.contains('§'),
2231 "{w}x{h}: background bleed-through into shortcut Help"
2232 );
2233 assert!(
2234 (0..h).any(|y| {
2235 (0..w).any(|x| {
2236 let cell = &buf[(x, y)];
2237 cell.bg == palette::SELECTION_BG && cell.fg == palette::SELECTION_TEXT
2238 })
2239 }),
2240 "{w}x{h}: first keybinding row lost its selection highlight"
2241 );
2242 for (y, row) in rows.iter().enumerate() {
2243 assert!(
2244 UnicodeWidthStr::width(row.trim_end()) <= w as usize,
2245 "{w}x{h}: row {y} overflows width: {row:?}"
2246 );
2247 }
2248 }
2249 }
2250
2251 #[test]
2252 fn help_is_usable_and_opaque_at_blocker_sizes() {
2253 use crate::tui::views::ViewStack;
2254 for (w, h) in BLOCKER_SIZES {
2255 let area = Rect::new(0, 0, w, h);
2256 let mut buf = Buffer::empty(area);
2257 for y in 0..h {
2258 for x in 0..w {
2259 buf[(x, y)].set_symbol("X");
2260 }
2261 }
2262 let mut stack = ViewStack::new();
2263 stack.push(HelpView::new_for_locale(Locale::En));
2264 stack.render(area, &mut buf);
2265
2266 let rows: Vec<String> = (0..h)
2267 .map(|y| {
2268 (0..w)
2269 .map(|x| buf[(x, y)].symbol().to_string())
2270 .collect::<String>()
2271 })
2272 .collect();
2273 let text = rows.join("\n");
2274
2275 // `type to filter` is deliberately absent: the filter row prints
2276 // `Type to filter` two lines above, and at 60 columns saying it
2277 // twice pushed the footer onto a second row.
2278 for label in [
2279 "Type to filter",
2280 "Up/Down move",
2281 "PgUp/PgDn jump",
2282 "Esc close",
2283 ] {
2284 assert!(text.contains(label), "{w}x{h}: missing footer '{label}'");
2285 }
2286 assert!(
2287 !text.contains('X'),
2288 "{w}x{h}: background bleed-through into modal surface"
2289 );
2290 assert_eq!(
2291 buf[(w / 2, h / 2)].bg,
2292 palette::WHALE_BG,
2293 "{w}x{h}: modal interior must be opaque"
2294 );
2295 for (y, row) in rows.iter().enumerate() {
2296 assert!(
2297 UnicodeWidthStr::width(row.trim_end()) <= w as usize,
2298 "{w}x{h}: row {y} overflows width: {row:?}"
2299 );
2300 }
2301 }
2302 }
2303
2304 #[test]
2305 fn shortcuts_open_folds_the_long_tail() {
2306 let view = HelpView::new_with_ordering(Locale::En, HelpOrdering::KeybindingsFirst);
2307 let rows = view.render_rows();
2308 let groups: Vec<&str> = rows
2309 .iter()
2310 .filter_map(|row| match row {
2311 HelpRenderRow::Group {
2312 label, collapsed, ..
2313 } => Some((*collapsed, label.as_str())),
2314 _ => None,
2315 })
2316 .map(|(collapsed, label)| {
2317 if collapsed {
2318 label
2319 } else {
2320 // keep expanded groups in a second pass
2321 label
2322 }
2323 })
2324 .collect();
2325 assert!(
2326 groups.contains(&"Navigation"),
2327 "shortcuts should surface Navigation: {groups:?}"
2328 );
2329 assert!(
2330 rows.iter().any(|row| matches!(
2331 row,
2332 HelpRenderRow::Group {
2333 collapsed: false,
2334 ..
2335 }
2336 )),
2337 "at least one group stays open"
2338 );
2339 assert!(
2340 rows.iter().any(|row| matches!(
2341 row,
2342 HelpRenderRow::Group {
2343 collapsed: true,
2344 ..
2345 }
2346 )),
2347 "the long tail should start collapsed"
2348 );
2349 assert!(
2350 !rows.iter().any(|row| matches!(
2351 row,
2352 HelpRenderRow::Entry { entry_idx, .. }
2353 if view.entries[*entry_idx].section == HelpSection::Command
2354 )),
2355 "slash commands stay folded until the user expands or searches"
2356 );
2357 }
2358
2359 #[test]
2360 fn enter_toggles_the_selected_group() {
2361 let mut view = HelpView::new_with_ordering(Locale::En, HelpOrdering::KeybindingsFirst);
2362 // Focus opens on the first entry, so step up onto its header first.
2363 view.handle_key(key(KeyCode::Up));
2364 assert!(matches!(view.focus, Some(HelpHit::Group(_))));
2365 let before = view.visible_entry_slots().len();
2366 view.handle_key(key(KeyCode::Enter));
2367 let after = view.visible_entry_slots().len();
2368 assert_ne!(
2369 before, after,
2370 "Enter should fold or unfold the selected group's members"
2371 );
2372 view.handle_key(key(KeyCode::Enter));
2373 assert_eq!(
2374 view.visible_entry_slots().len(),
2375 before,
2376 "a second Enter restores the previous fold"
2377 );
2378 }
2379
2380 #[test]
2381 fn right_expands_and_left_collapses_a_focused_header() {
2382 let mut view = HelpView::new_with_ordering(Locale::En, HelpOrdering::KeybindingsFirst);
2383 let group_key = "cmd:all".to_string();
2384 assert!(view.group_is_collapsed(&group_key));
2385 view.focus = Some(HelpHit::Group(group_key.clone()));
2386
2387 view.handle_key(key(KeyCode::Right));
2388 assert!(!view.group_is_collapsed(&group_key));
2389 assert_eq!(view.focus, Some(HelpHit::Group(group_key.clone())));
2390
2391 view.handle_key(key(KeyCode::Left));
2392 assert!(view.group_is_collapsed(&group_key));
2393 assert_eq!(view.focus, Some(HelpHit::Group(group_key)));
2394 }
2395
2396 #[test]
2397 fn mouse_click_on_group_header_matches_enter_toggle() {
2398 let mut view = HelpView::new_with_ordering(Locale::En, HelpOrdering::KeybindingsFirst);
2399 let area = Rect::new(0, 0, 100, 120);
2400 let mut buf = Buffer::empty(area);
2401 view.render(area, &mut buf);
2402 let (rect, group) = view
2403 .row_hitboxes
2404 .borrow()
2405 .iter()
2406 .find_map(|(rect, hit)| match hit {
2407 HelpHit::Group(key) if view.group_is_collapsed(key) => Some((*rect, key.clone())),
2408 _ => None,
2409 })
2410 .expect("at least one collapsed group header is visible");
2411
2412 view.handle_mouse(MouseEvent {
2413 kind: MouseEventKind::Down(MouseButton::Left),
2414 column: rect.x,
2415 row: rect.y,
2416 modifiers: KeyModifiers::NONE,
2417 });
2418
2419 assert!(!view.group_is_collapsed(&group));
2420 assert_eq!(view.focus, Some(HelpHit::Group(group)));
2421 }
2422
2423 #[test]
2424 fn search_unfolds_collapsed_groups() {
2425 let mut view = HelpView::new_with_ordering(Locale::En, HelpOrdering::KeybindingsFirst);
2426 assert!(
2427 view.group_is_collapsed("cmd:all"),
2428 "slash commands start collapsed on the shortcuts surface"
2429 );
2430 type_filter(&mut view, "/mode");
2431 assert!(
2432 !view.group_is_collapsed("cmd:all"),
2433 "a search query must reveal matching groups"
2434 );
2435 assert!(
2436 view.filtered
2437 .iter()
2438 .any(|idx| view.entries[*idx].label == "/mode")
2439 );
2440 }
2441
2442 #[test]
2443 fn help_expand_groups_starts_unfolded() {
2444 let view = HelpView::new_with_ordering(Locale::En, HelpOrdering::KeybindingsFirst)
2445 .with_groups_expanded(true);
2446 assert!(
2447 !view.group_is_collapsed("cmd:all"),
2448 "help_expand_groups must start with slash commands visible"
2449 );
2450 assert!(
2451 view.render_rows().iter().any(|row| matches!(
2452 row,
2453 HelpRenderRow::Entry { entry_idx, .. }
2454 if view.entries[*entry_idx].section == HelpSection::Command
2455 )),
2456 "expanded shortcuts include slash command rows"
2457 );
2458 }
2459
2460 fn buffer_text(buf: &Buffer, area: Rect) -> String {
2461 let mut out = String::new();
2462 for y in area.top()..area.bottom() {
2463 for x in area.left()..area.right() {
2464 out.push_str(buf[(x, y)].symbol());
2465 }
2466 out.push('\n');
2467 }
2468 out
2469 }
2470 }
2471
2472 #[cfg(test)]
2473 mod shed_to_words_script_tests {
2474 use super::{shed_to_words, widest_char_prefix};
2475 use unicode_width::UnicodeWidthStr;
2476
2477 /// Japanese, Chinese and Thai do not put spaces between words, so a
2478 /// word-boundary scan finds nothing and used to yield an empty string —
2479 /// every help description rendered blank in those locales.
2480 #[test]
2481 fn a_script_without_spaces_still_gets_a_description() {
2482 for text in [
2483 "バックグラウンドのアドバイザーを切り替える",
2484 "切换后台顾问",
2485 "切換背景顧問",
2486 ] {
2487 for width in [8usize, 12, 20, 30] {
2488 let shed = shed_to_words(text, width);
2489 assert!(
2490 !shed.is_empty(),
2491 "{text:?} at {width}: description rendered blank",
2492 );
2493 assert!(
2494 shed.width() <= width,
2495 "{text:?} at {width}: {shed:?} overflows ({} cols)",
2496 shed.width(),
2497 );
2498 assert!(
2499 text.starts_with(&*shed),
2500 "{shed:?} is not a prefix of {text:?}"
2501 );
2502 }
2503 }
2504 }
2505
2506 /// The same hole opens in English whenever the first space sits past the
2507 /// budget: the scan never fires and the row goes blank.
2508 #[test]
2509 fn an_overlong_first_word_sheds_to_characters_rather_than_nothing() {
2510 let text = "Internationalisation settings";
2511 let shed = shed_to_words(text, 10);
2512 assert!(!shed.is_empty(), "long first word rendered blank");
2513 assert!(shed.width() <= 10, "{shed:?}");
2514 }
2515
2516 /// Ordinary English is unchanged: still cut on a word boundary, still
2517 /// drops a trailing short function word.
2518 #[test]
2519 fn english_still_sheds_on_word_boundaries() {
2520 let text = "Toggle the background advisor for this session";
2521 let shed = shed_to_words(text, 24);
2522 assert!(shed.width() <= 24, "{shed:?}");
2523 assert!(!shed.ends_with(' '), "{shed:?}");
2524 assert!(
2525 shed.split(' ').count() > 1 && text.starts_with(&*shed),
2526 "{shed:?} should be a whole-word prefix",
2527 );
2528 }
2529
2530 #[test]
2531 fn widest_char_prefix_never_splits_a_character() {
2532 let text = "日本語テキスト";
2533 for width in 0..=14 {
2534 let prefix = widest_char_prefix(text, width);
2535 assert!(text.starts_with(prefix));
2536 assert!(prefix.width() <= width);
2537 }
2538 }
2539
2540 /// The last-resort word shed always ended on a space, so the last word
2541 /// of a simple verb + modifier + noun phrase was dropped even when it
2542 /// fitted, and when it overflowed by one column the two-pass short-word
2543 /// trim left the adjectives without the noun they qualify.
2544 #[test]
2545 fn a_simple_noun_phrase_keeps_the_head_noun() {
2546 let text = "Manage durable scheduled automations";
2547 // 24 fits "Manage durable scheduled" exactly and not the noun.
2548 // 35 is the description slot at 60 columns (measured).
2549 // 36 is the full phrase.
2550 for width in [24usize, 28, 32, 35, 36] {
2551 let shed = shed_to_words(text, width);
2552 assert!(
2553 shed.contains("automations"),
2554 "width {width} dropped the head noun: {shed:?}"
2555 );
2556 assert!(
2557 shed.width() <= width,
2558 "width {width} overflowed: {shed:?} ({} cols)",
2559 shed.width()
2560 );
2561 }
2562
2563 // Help appends ` (aliases: …)` onto the same row. The joint head is
2564 // one column over the 35-column slot, so last-resort word shed must
2565 // run on that clause, not on the alias list.
2566 let aliased = "Manage durable scheduled automations (aliases: /automations, /scheduled)";
2567 let shed = super::shed_to_width(aliased, 35);
2568 assert!(
2569 shed.contains("automations"),
2570 "aliased row dropped the head noun: {shed:?}"
2571 );
2572 assert!(
2573 !shed.contains("aliases"),
2574 "aliased row kept the alias list instead of the clause: {shed:?}"
2575 );
2576 assert!(shed.width() <= 35, "{shed:?}");
2577 }
2578 }
2579
2579 lines RUST