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