//! All-shortcuts cheatsheet modal (Ctrl+. / Ctrl+X). //! //! Registry-driven: `build_entries(registry)` pulls every `ActionDef` from //! `ActionRegistry`, groups them by `Category` in onboarding-friendly order //! (Essentials → Panes → Scrollback Navigation → View → Prompt → Agent), //! and includes alt-key bindings inline. Search filters against key display, //! description, and label. //! //! Two ways to read a binding's help: pattern A expands an inline help line under //! the selected hint (e/Space/l/h/arrows); pattern B opens an in-modal man-style //! detail page on Enter, where Esc (or h/Left/Backspace) returns to the browse list. //! Section headers collapse/expand; close via Esc in browse or Ctrl+./Ctrl+X. //! Rendered via `ModalWindow` chrome (same appearance as the command palette). //! //! Entry points from `AgentView`: //! - `build_entries(registry)` + `build_initial_picker_state` → //! `ActiveModal::ShortcutsHelp` //! - `handle_input` / `handle_mouse` for key/mouse dispatch //! - Rendering is done inline in `AgentView` via `render_modal_window` + //! `render_picker_in_modal`. use std::borrow::Cow; use crate::actions::{ActionDef, ActionId, ActionRegistry, Category, When}; use crate::input::key::KeyShortcut; use crate::views::picker::{PickerConfig, PickerOutcome, PickerState, handle_picker_input}; use crate::views::shortcuts_bar::HintItem; /// Key for pattern-A inline expand state (`expanded_ids`). /// /// Registry rows use [`ExpandKey::Action`]; display-only rows that ship /// `long_help` (e.g. paste) use [`ExpandKey::Pseudo`] with a stable label. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ExpandKey { Action(ActionId), Pseudo(&'static str), } /// One row in the all-shortcuts cheatsheet. /// /// Headers are non-selectable section dividers; Hints are the actual key /// bindings and are selectable / dispatchable on Enter. pub enum ShortcutsHelpEntry { SectionHeader { label: &'static str, category_idx: usize, entry_count: usize, }, Hint { item: HintItem, dimmed: bool, /// Registry action for expand/detail; `None` for display-only pseudo-rows. action_id: Option, /// Man-style help shown under the expanded row; falls back to the description. long_help: Option<&'static str>, }, } impl ShortcutsHelpEntry { pub fn is_hint(&self) -> bool { matches!(self, Self::Hint { .. }) } pub fn is_section_header(&self) -> bool { matches!(self, Self::SectionHeader { .. }) } } /// Category display order and labels for the cheatsheet. const CATEGORY_ORDER: &[(Category, &str)] = &[ (Category::GettingStarted, "Essentials"), (Category::Input, "Input"), (Category::ConversationNav, "Conversation Navigation"), (Category::ConversationAction, "Conversation Actions"), (Category::Panels, "Panels"), (Category::Session, "Session"), (Category::Dashboard, "Dashboard"), ]; pub fn default_collapsed() -> std::collections::HashSet { (1..CATEGORY_ORDER.len()).collect() } // Man-page body for the paste pseudo-row (Enter detail). Keep claims that // hold on every host (agent + dashboard); non-image file paths are agent-only. #[cfg(target_os = "windows")] const PASTE_LONG_HELP: &str = "\ Pastes clipboard images into the prompt as chips, and plain text as typed.\n\ Prefer Ctrl+V. Use Alt+V as a fallback when Ctrl+V fails (some terminals or \ configs drop image clipboards; older Windows Terminal versions only pasted \ text).\n\ You can also drag an image file from Explorer into the prompt."; #[cfg(target_os = "macos")] const PASTE_LONG_HELP: &str = "\ Pastes clipboard images into the prompt as chips, and plain text as typed.\n\ Use Ctrl+V for screenshots, browser \"Copy Image\", and file-manager image \ copies (many terminals swallow Cmd+V and never deliver it to the TUI).\n\ You can also drag an image file into the prompt."; #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] const PASTE_LONG_HELP: &str = "\ Pastes clipboard images into the prompt as chips, and plain text as typed.\n\ Use Ctrl+V for screenshots, browser \"Copy Image\", and file-manager image \ copies.\n\ You can also drag an image file into the prompt."; /// Build the entries vector for the modal, grouped by category. /// /// All registered actions are included, grouped by category. Actions /// whose `When` context is not in `active_contexts` are dimmed. pub fn build_entries( active_contexts: &[When], registry: &ActionRegistry, vim_mode: bool, ) -> Vec { let mut entries: Vec = Vec::new(); // Keys the dashboard session-overlay claims while it is up. The // overlay intercept consults `When::DashboardOverlay` before // forwarding a key to the agent, so a lit row from another context // advertising one of these keys would be lying (e.g. the // cheatsheet's Ctrl+X alt is shadowed by the overlay stop). let overlay_claimed: std::collections::HashSet = if active_contexts.contains(&When::DashboardOverlay) { registry .all() .iter() .filter(|d| d.context == When::DashboardOverlay) .map(|d| d.default_key) .collect() } else { std::collections::HashSet::new() }; for (cat_idx, &(cat, label)) in CATEGORY_ORDER.iter().enumerate() { // Dedup per category on the default key, preferring the def // whose `When` context is active: `DashboardStop` (list) and // `DashboardOverlayStop` (overlay) share Ctrl+X and category, // and whichever matches the current surface must win // regardless of registration order. let mut seen_in_cat: std::collections::HashMap = std::collections::HashMap::new(); let defs: Vec<&ActionDef> = registry .all() .iter() .filter(|d| d.category == cat) .collect(); if defs.is_empty() { continue; } let header_idx = entries.len(); entries.push(ShortcutsHelpEntry::SectionHeader { label, category_idx: cat_idx, entry_count: 0, }); for def in defs { // Slash-only actions with no real keybinding don't belong in a // keyboard cheatsheet. if def.default_key == crate::key!(Null) && def.alt_keys.is_empty() { continue; } let mut item = def.hint(); if !def.alt_keys.is_empty() { item.keys.extend_from_slice(&def.alt_keys); // Alt keys can be terminal-encoding variants of the SAME // physical chord (Shift+Tab arrives as `BackTab`, // `BackTab`+SHIFT, or `Tab`+SHIFT depending on the // terminal). Collapse keys that render identically so the // row doesn't read "Shift+Tab / Shift+Tab / Shift+Tab". let mut seen_displays = std::collections::HashSet::new(); item.keys .retain(|k| seen_displays.insert(k.display_pretty())); item.custom_display = None; } // In non-vim mode, suppress bare-letter / Shift+letter keys // from any scrollback-context binding. If the row has at least // one non-vim key left (e.g. an arrow alt), show only those — // they still work, so don't dim. If every key was a vim key, // hide the row entirely (the binding is genuinely inert when // vim mode is off). if !vim_mode && def.context == When::ScrollbackFocused { let has_non_vim = item.keys.iter().any(|k| !k.is_letter_or_shift_letter()); if has_non_vim { item.keys.retain(|k| !k.is_letter_or_shift_letter()); // When we strip the default_key but keep an alt, the // custom_display string (e.g. "Shift+l/h") no longer // matches what's shown; drop it so the keys render // verbatim. item.custom_display = None; } else { continue; } } let dimmed = !active_contexts.contains(&def.context); // Strip overlay-claimed keys from lit rows of other // contexts (the overlay intercept shadows them). Dimmed // rows already say "not applicable here", so they keep // their keys for discoverability. if !dimmed && def.context != When::DashboardOverlay && item.keys.iter().any(|k| overlay_claimed.contains(k)) { item.keys.retain(|k| !overlay_claimed.contains(k)); if item.keys.is_empty() { // Every key is shadowed — the binding is // genuinely unreachable inside the overlay. continue; } // The custom display no longer matches the surviving // keys; render them verbatim. item.custom_display = None; } // Identical row for both arms; `item` moves in, `dimmed`/`def.id` are Copy. let hint = ShortcutsHelpEntry::Hint { item, dimmed, action_id: Some(def.id), long_help: def.long_help, }; match seen_in_cat.entry(def.default_key) { std::collections::hash_map::Entry::Vacant(slot) => { slot.insert(entries.len()); entries.push(hint); } std::collections::hash_map::Entry::Occupied(slot) => { // Same key already rendered in this category — // replace it only when the earlier row is dimmed // and this one is lit (active context wins). let prior = &mut entries[*slot.get()]; if !dimmed && matches!(prior, ShortcutsHelpEntry::Hint { dimmed: true, .. }) { *prior = hint; } } } } // Scrollback search (`/`) has no registered ActionDef yet — vim-only, // handled inline; surface it here for discoverability. if vim_mode && cat == Category::ConversationNav { let mut item = HintItem::new(crate::key!('/'), "search"); item.description = Some("Search scrollback".into()); let dimmed = !active_contexts.contains(&When::ScrollbackFocused); entries.push(ShortcutsHelpEntry::Hint { item, dimmed, action_id: None, long_help: None, }); } // Paste is handled by `is_paste_key`, not the registry. Ctrl+V always; // Windows also Alt+V as a fallback. Super/Cmd omitted — many terminals // swallow it. Lit on the agent prompt and the dashboard (both paste). if cat == Category::Input { let mut item = HintItem::new(crate::key!('v', CONTROL), "paste"); item.description = Some("Paste images (and text) from the clipboard".into()); #[cfg(target_os = "windows")] item.keys.push(crate::key!('v', ALT)); let dimmed = !active_contexts.contains(&When::PromptFocused) && !active_contexts.contains(&When::DashboardFocused); entries.push(ShortcutsHelpEntry::Hint { item, dimmed, action_id: None, long_help: Some(PASTE_LONG_HELP), }); } let count = entries.len() - header_idx - 1; if count == 0 { // Every action in this category got filtered out (e.g. all // scrollback vim-only bindings in non-vim mode); drop the // empty header rather than render a dead section. entries.pop(); } else if let Some(ShortcutsHelpEntry::SectionHeader { entry_count, .. }) = entries.get_mut(header_idx) { *entry_count = count; } } entries } /// Build the initial `PickerState` for the modal. Width/height are wider /// than the default Floating popup so the cheatsheet has room for the /// key + label columns. pub fn build_initial_picker_state(entries: &[ShortcutsHelpEntry]) -> PickerState { use crate::views::picker::{PickerMode, PopupConfig}; PickerState { selected: entries.iter().position(|e| e.is_hint()).unwrap_or(0), mode: PickerMode::Popup(PopupConfig { width_pct: 0.6, height_pct: 0.7, min_width: 60, min_height: 16, }), ..PickerState::default() } } /// Filter ShortcutsHelp entries by search query. /// /// Returns the original-index list of entries that pass the filter. /// Section headers are kept only when at least one hint in their section /// matches; this mirrors the palette's `filter_palette_entries` behavior. pub fn filter_entries( entries: &[ShortcutsHelpEntry], query: &str, hide_dimmed: bool, collapsed: &std::collections::HashSet, ) -> Vec { let searching = !query.is_empty(); if !searching && !hide_dimmed && collapsed.is_empty() { return (0..entries.len()).collect(); } let q = query.to_lowercase(); let mut result: Vec = Vec::new(); let mut pending_header: Option = None; let mut section_has_match = false; let mut current_section_collapsed = false; for (i, entry) in entries.iter().enumerate() { match entry { ShortcutsHelpEntry::SectionHeader { category_idx, .. } => { if let Some(h) = pending_header.take() && (section_has_match || current_section_collapsed) { result.push(h); } pending_header = Some(i); section_has_match = false; current_section_collapsed = !searching && collapsed.contains(category_idx); } ShortcutsHelpEntry::Hint { item: h, dimmed, .. } => { if current_section_collapsed { continue; } if hide_dimmed && *dimmed { continue; } let key_text = hint_key_display(h); let key_pretty = hint_key_pretty(h); let desc = hint_description(h); let q_matches = q.is_empty() || h.label.to_lowercase().contains(&q) || key_text.to_lowercase().contains(&q) || key_pretty.to_lowercase().contains(&q) || desc.to_lowercase().contains(&q); if q_matches { if let Some(idx) = pending_header.take() { result.push(idx); } section_has_match = true; result.push(i); } } } } if let Some(h) = pending_header && (section_has_match || current_section_collapsed) { result.push(h); } result } fn hint_key_display(h: &HintItem) -> String { if let Some(d) = h.custom_display { d.to_string() } else { h.keys .iter() .map(|k| k.display()) .collect::>() .join("/") } } /// Pretty key display for the cheatsheet modal. /// /// Uses `custom_display` when set (for special representations like /// "Esc Esc" that can't be derived from the key list), otherwise renders /// the actual keys with pretty formatting (e.g. "Ctrl+Q", "Tab / i / Space"). fn hint_key_pretty(h: &HintItem) -> String { if let Some(d) = h.custom_display { return d.to_string(); } h.keys .iter() .map(|k| k.display_pretty()) .collect::>() .join(" / ") } /// Get the long description for a hint, falling back to the short label. pub fn entry_display(entries: &[ShortcutsHelpEntry], idx: usize) -> (String, String) { match entries.get(idx) { Some(ShortcutsHelpEntry::Hint { item: h, .. }) => (hint_description(h), hint_key_pretty(h)), Some(ShortcutsHelpEntry::SectionHeader { label, .. }) => { ((*label).to_string(), String::new()) } None => (String::new(), String::new()), } } fn hint_description(h: &HintItem) -> String { h.description .as_ref() .map(|d| d.to_string()) .unwrap_or_else(|| { // Capitalize the short label as a fallback. let label = h.label.as_ref(); let mut chars = label.chars(); match chars.next() { None => String::new(), Some(c) => c.to_uppercase().to_string() + chars.as_str(), } }) } fn selected_original_entry<'a>( filtered: &[usize], entries: &'a [ShortcutsHelpEntry], selected: usize, ) -> Option<&'a ShortcutsHelpEntry> { filtered.get(selected).and_then(|&i| entries.get(i)) } fn non_selectable_mask(filtered: &[usize], _entries: &[ShortcutsHelpEntry]) -> Vec { filtered.iter().map(|_| false).collect() } fn picker_config(non_sel: &[bool]) -> PickerConfig<'_> { PickerConfig { title: None, show_search_hint: false, expandable: false, esc_clears_query: true, shortcuts: None, pending_hint: None, non_selectable: non_sel, non_selectable_clickable: &[], shortcuts_area: None, tabs: None, active_tab: 0, filter_label: None, filter_key_hint: None, filter_active: false, action_keys: &[], disable_search: false, compact_bottom_bar: false, search_only_on_slash: false, vim_normal_first: crate::appearance::cache::load_vim_mode(), } } /// Outcome of an input event delivered to the cheatsheet modal. /// /// The caller is responsible for mutating `AgentView` state — closing the /// modal, re-dispatching a synthesized key into `handle_input`, etc. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ShortcutsHelpOutcome { /// User asked to close the modal (Esc in browse, Ctrl+./Ctrl+X, [x] click). Close, /// Toggle the filter (show all vs hide dimmed). ToggleFilter, /// Toggle a section's collapsed state (by category index). ToggleSection(usize), /// Toggle inline help expand for a hint row (registry or long_help pseudo). ToggleExpand(ExpandKey), /// Visual state changed (selection, hover, or detail enter/scroll/back) — redraw. Changed, /// Nothing changed. Unchanged, } /// Browse list vs in-modal man-style detail (pattern B, same modal chrome). #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum ShortcutsHelpMode { #[default] Browse, Detail { title: String, keys_line: String, body: String, dimmed_note: bool, scroll: u16, }, } impl ShortcutsHelpMode { pub fn is_browse(&self) -> bool { matches!(self, Self::Browse) } pub fn is_detail(&self) -> bool { !self.is_browse() } } /// Build detail mode state from a cheatsheet entry (title/keys/body for the man page). /// /// Registry rows always open. Pseudo-rows (`action_id: None`) open only when they /// ship `long_help` so list-only rows like scrollback search stay browse-only. pub fn detail_from_entry(entry: &ShortcutsHelpEntry) -> Option { let ShortcutsHelpEntry::Hint { item, dimmed, action_id, long_help, } = entry else { return None; }; if action_id.is_none() && long_help.is_none() { return None; } let title = item .description .as_deref() .unwrap_or(item.label.as_ref()) .to_string(); let keys_line = item .custom_display .map(|s| s.to_string()) .unwrap_or_else(|| { item.keys .iter() .map(|k| k.display()) .collect::>() .join(" / ") }); // Body prefers long_help; falls back to the one-line description. let body = long_help .as_deref() .or(item.description.as_deref()) .unwrap_or(item.label.as_ref()) .to_string(); Some(ShortcutsHelpMode::Detail { title, keys_line, body, dimmed_note: *dimmed, scroll: 0, }) } /// Open the detail page for `entry`, dropping any committed search so Esc from /// detail returns to an unfiltered browse and closes with one more press. fn enter_detail(state: &mut PickerState, entry: &ShortcutsHelpEntry) -> Option { let detail = detail_from_entry(entry)?; state.query.clear(); state.query_cursor = 0; state.search_active = false; Some(detail) } /// Footer shortcuts while viewing a shortcut detail page. pub fn modal_footer_detail() -> Vec> { use crate::views::modal_window::Shortcut; vec![ Shortcut { label: "Esc back", clickable: false, id: 0, }, Shortcut { label: "\u{2191}/\u{2193} scroll", clickable: false, id: 0, }, Shortcut { label: "Ctrl+./X close", clickable: false, id: 0, }, ] } /// Paint the in-modal detail page (title, keys, body) into the content rect. #[allow(clippy::too_many_arguments)] pub fn render_detail_body<'a>( buf: &mut ratatui::buffer::Buffer, area: ratatui::layout::Rect, title: &'a str, keys_line: &'a str, body: &'a str, dimmed_note: bool, scroll: u16, theme: &crate::theme::Theme, ) { use crate::render::wrapping::word_wrap_lines; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Paragraph, Widget}; if area.width == 0 || area.height == 0 { return; } // Borrow from the owned detail payload — no allocation while building the rows. let mut lines: Vec> = Vec::new(); lines.push(Line::from(Span::styled( title, Style::default() .fg(theme.text_primary) .add_modifier(Modifier::BOLD), ))); if !keys_line.is_empty() { lines.push(Line::from(Span::styled( keys_line, Style::default().fg(theme.gray_bright), ))); } // Skip the body when it merely repeats the title (no long_help yet) to avoid a duplicate line. if !body.is_empty() && body != title { lines.push(Line::from("")); // Blank line between paragraphs so the detail page reads as spaced blocks; the inline expand (arrows) keeps them tight. for (i, para) in body.split('\n').enumerate() { if i > 0 { lines.push(Line::from("")); } lines.push(Line::from(Span::styled( para, Style::default().fg(theme.text_primary), ))); } } if dimmed_note { lines.push(Line::from("")); lines.push(Line::from(Span::styled( "(not active in current context)", Style::default().fg(theme.gray_dim), ))); } // Pre-wrap to the content width so scrolling counts wrapped rows, not logical lines. let wrapped = word_wrap_lines(lines, area.width as usize); // Clamp the displayed offset so over-scroll shows the last wrapped row, never a blank body. let max_scroll = wrapped.len().saturating_sub(area.height as usize); let skip = (scroll as usize).min(max_scroll); // Rows are already wrapped to width, so render verbatim (no second wrap pass). let visible: Vec> = wrapped.into_iter().skip(skip).collect(); Paragraph::new(visible).render(area, buf); } /// Render the detail page (pattern B) with its modal chrome + footer. Shared by /// both hosts so the chrome orchestration lives in one place (like `CheatsheetRows`). pub fn render_detail( buf: &mut ratatui::buffer::Buffer, area: ratatui::layout::Rect, window: &mut crate::views::modal_window::ModalWindowState, mode: &ShortcutsHelpMode, theme: &crate::theme::Theme, compact: bool, ) { use crate::views::modal_window as mw; let ShortcutsHelpMode::Detail { title, keys_line, body, dimmed_note, scroll, } = mode else { return; }; let footer = modal_footer_detail(); let modal_config = mw::ModalWindowConfig { title: "Keyboard Shortcuts", tabs: None, shortcuts: &footer, sizing: modal_sizing(compact), fold_info: None, }; if let Some(mca) = mw::render_modal_window(buf, area, window, &modal_config, theme) { render_detail_body( buf, mca.content, title, keys_line, body, *dimmed_note, *scroll, theme, ); } } /// Help line(s) shown under an expanded hint: prefers the action's `long_help`, /// falling back to the palette description. Callers split on `\n` for multi-line. pub fn hint_inline_help(entry: &ShortcutsHelpEntry) -> Option<&str> { match entry { ShortcutsHelpEntry::Hint { item, long_help, .. } => long_help.as_deref().or(item.description.as_deref()), _ => None, } } /// Expand key for pattern A (e/Space/l/→). Registry rows use their ActionId; /// pseudo-rows with `long_help` and a static label use [`ExpandKey::Pseudo`]. pub fn expand_key(entry: &ShortcutsHelpEntry) -> Option { match entry { ShortcutsHelpEntry::Hint { action_id: Some(id), .. } => Some(ExpandKey::Action(*id)), ShortcutsHelpEntry::Hint { action_id: None, long_help: Some(_), item, .. } => match item.label { Cow::Borrowed(s) => Some(ExpandKey::Pseudo(s)), Cow::Owned(_) => None, }, _ => None, } } /// Whether this hint can participate in inline expand (registry-backed rows only). /// Prefer [`expand_key`] for new code; kept for call sites that need an ActionId. pub fn hint_expand_action_id(entry: &ShortcutsHelpEntry) -> Option { match expand_key(entry) { Some(ExpandKey::Action(id)) => Some(id), _ => None, } } /// Flip `value`'s membership in `set`: insert when absent, remove when present. /// Shared by both modal hosts for the section-collapse and inline-expand toggles. pub fn toggle_membership( set: &mut std::collections::HashSet, value: T, ) { if !set.remove(&value) { set.insert(value); } } /// Dispatch a key event to the cheatsheet picker. Mutates `state`. /// /// When `mode` is `Detail`, keys scroll the man page or return to browse; global /// close chords still dismiss the whole modal. pub fn handle_input( key: &crossterm::event::KeyEvent, entries: &[ShortcutsHelpEntry], state: &mut PickerState, hide_dimmed: bool, collapsed: &std::collections::HashSet, expanded_ids: &std::collections::HashSet, mode: &mut ShortcutsHelpMode, ) -> ShortcutsHelpOutcome { use crossterm::event::{Event, KeyCode, KeyModifiers}; if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('.') | KeyCode::Char('x')) { return ShortcutsHelpOutcome::Close; } if mode.is_detail() { // Back-to-browse keys handled before borrowing `scroll` so we can replace `mode`. // Vim keys (h/j/k/g) are intentionally NOT bound here — vim modal bindings are owned separately. if matches!(key.code, KeyCode::Esc | KeyCode::Left | KeyCode::Backspace) { *mode = ShortcutsHelpMode::Browse; return ShortcutsHelpOutcome::Changed; } if let ShortcutsHelpMode::Detail { scroll, .. } = mode { return match key.code { KeyCode::Down | KeyCode::PageDown => { *scroll = scroll.saturating_add(1); ShortcutsHelpOutcome::Changed } KeyCode::Up | KeyCode::PageUp => { *scroll = scroll.saturating_sub(1); ShortcutsHelpOutcome::Changed } KeyCode::Home => { *scroll = 0; ShortcutsHelpOutcome::Changed } _ => ShortcutsHelpOutcome::Unchanged, }; } return ShortcutsHelpOutcome::Unchanged; } let searching = state.search_active || !state.query.is_empty(); let vim_mode = crate::appearance::cache::load_vim_mode(); if !searching { // `i` mirrors the vim-nav pickers' "press i to search" affordance. if key.code == KeyCode::Char('/') || (key.code == KeyCode::Char('i') && key.modifiers.is_empty()) { state.search_active = true; return ShortcutsHelpOutcome::Changed; } if key.code == KeyCode::Char('f') { return ShortcutsHelpOutcome::ToggleFilter; } let filtered = filter_entries(entries, &state.query, hide_dimmed, collapsed); if let Some(ShortcutsHelpEntry::SectionHeader { category_idx, .. }) = selected_original_entry(&filtered, entries, state.selected) { let is_collapsed = collapsed.contains(category_idx); let toggle = match key.code { KeyCode::Char('e') | KeyCode::Char(' ') | KeyCode::Enter => true, KeyCode::Right => is_collapsed, KeyCode::Char('l') if vim_mode && key.modifiers.is_empty() => is_collapsed, KeyCode::Char('E') | KeyCode::Left => !is_collapsed, KeyCode::Char('h') if vim_mode && key.modifiers.is_empty() => !is_collapsed, _ => false, }; if toggle { return ShortcutsHelpOutcome::ToggleSection(*category_idx); } } else if let Some(entry) = selected_original_entry(&filtered, entries, state.selected) && let Some(key_id) = expand_key(entry) { let is_expanded = expanded_ids.contains(&key_id); let toggle = match key.code { KeyCode::Char('e') | KeyCode::Char(' ') | KeyCode::Right => true, KeyCode::Char('l') if vim_mode && key.modifiers.is_empty() => true, KeyCode::Char('E') | KeyCode::Left => is_expanded, KeyCode::Char('h') if vim_mode && key.modifiers.is_empty() => is_expanded, _ => false, }; if toggle { return ShortcutsHelpOutcome::ToggleExpand(key_id); } } // Enter on a registry hint opens in-modal detail (pattern B); section handled above. if key.code == KeyCode::Enter { if let Some(entry) = selected_original_entry(&filtered, entries, state.selected) && let Some(detail) = detail_from_entry(entry) { *mode = detail; return ShortcutsHelpOutcome::Changed; } return ShortcutsHelpOutcome::Unchanged; } if matches!( key.code, KeyCode::Char('h') | KeyCode::Char('j') | KeyCode::Char('k') | KeyCode::Char('l') | KeyCode::Down | KeyCode::Up | KeyCode::Char('g') | KeyCode::Char('G') | KeyCode::PageUp | KeyCode::PageDown | KeyCode::Home | KeyCode::End | KeyCode::Esc | KeyCode::Tab ) { let non_sel: Vec = non_selectable_mask(&filtered, entries); let config = picker_config(&non_sel); let ev = Event::Key(*key); return match handle_picker_input(&ev, state, filtered.len(), &config) { PickerOutcome::Selected(_) | PickerOutcome::Closed => ShortcutsHelpOutcome::Close, PickerOutcome::Unchanged => ShortcutsHelpOutcome::Unchanged, _ => ShortcutsHelpOutcome::Changed, }; } return ShortcutsHelpOutcome::Unchanged; } if key.code == KeyCode::Esc { state.query.clear(); state.query_cursor = 0; state.search_active = false; state.selected = 0; return ShortcutsHelpOutcome::Changed; } let filtered = filter_entries(entries, &state.query, hide_dimmed, collapsed); let non_sel: Vec = non_selectable_mask(&filtered, entries); let config = picker_config(&non_sel); let ev = Event::Key(*key); match handle_picker_input(&ev, state, filtered.len(), &config) { PickerOutcome::Selected(idx) => { state.search_active = false; match selected_original_entry(&filtered, entries, idx) { Some(ShortcutsHelpEntry::SectionHeader { category_idx, .. }) => { ShortcutsHelpOutcome::ToggleSection(*category_idx) } Some(entry) => { if let Some(detail) = enter_detail(state, entry) { *mode = detail; ShortcutsHelpOutcome::Changed } else { ShortcutsHelpOutcome::Unchanged } } None => ShortcutsHelpOutcome::Unchanged, } } PickerOutcome::Closed => ShortcutsHelpOutcome::Close, PickerOutcome::Unchanged => ShortcutsHelpOutcome::Unchanged, _ => ShortcutsHelpOutcome::Changed, } } /// Dispatch a mouse event to the cheatsheet picker. Mutates `state`. pub fn handle_mouse( mouse: &crossterm::event::MouseEvent, entries: &[ShortcutsHelpEntry], state: &mut PickerState, hide_dimmed: bool, collapsed: &std::collections::HashSet, mode: &mut ShortcutsHelpMode, ) -> ShortcutsHelpOutcome { if mode.is_detail() { use crossterm::event::MouseEventKind; if let ShortcutsHelpMode::Detail { scroll, .. } = mode { return match mouse.kind { MouseEventKind::ScrollDown => { *scroll = scroll.saturating_add(1); ShortcutsHelpOutcome::Changed } MouseEventKind::ScrollUp => { *scroll = scroll.saturating_sub(1); ShortcutsHelpOutcome::Changed } _ => ShortcutsHelpOutcome::Unchanged, }; } } let filtered = filter_entries(entries, &state.query, hide_dimmed, collapsed); let non_sel: Vec = non_selectable_mask(&filtered, entries); let config = picker_config(&non_sel); let ev = crossterm::event::Event::Mouse(*mouse); match handle_picker_input(&ev, state, filtered.len(), &config) { PickerOutcome::Selected(idx) => { // Clicking a section header toggles it; hint opens detail (pattern B). if let Some(ShortcutsHelpEntry::SectionHeader { category_idx, .. }) = selected_original_entry(&filtered, entries, idx) { ShortcutsHelpOutcome::ToggleSection(*category_idx) } else if let Some(entry) = selected_original_entry(&filtered, entries, idx) { // enter_detail drops the committed search so click matches the keyboard path. if let Some(detail) = enter_detail(state, entry) { *mode = detail; ShortcutsHelpOutcome::Changed } else { ShortcutsHelpOutcome::Unchanged } } else { ShortcutsHelpOutcome::Unchanged } } PickerOutcome::Closed => ShortcutsHelpOutcome::Close, PickerOutcome::Unchanged => ShortcutsHelpOutcome::Unchanged, _ => ShortcutsHelpOutcome::Changed, } } /// Footer hints painted along the bottom border of the cheatsheet /// modal. Identical visual vocabulary for the agent view and the /// dashboard so muscle memory ports across surfaces. pub fn modal_footer(filter_active: bool) -> Vec> { use crate::views::modal_window::Shortcut; let mut shortcuts = vec![ Shortcut { label: "\u{2191}/\u{2193} nav", clickable: false, id: 0, }, Shortcut { label: if filter_active { "f show all" } else { "f filter" }, clickable: false, id: 0, }, Shortcut { label: "e/Space/\u{2192} expand", clickable: false, id: 0, }, Shortcut { label: "\u{2190} collapse", clickable: false, id: 0, }, Shortcut { label: "Enter details", clickable: false, id: 0, }, Shortcut { label: "/ search", clickable: false, id: 0, }, Shortcut { label: "Esc close", clickable: false, id: 0, }, ]; // Append the `i search` alias last for vim users (matching the other pickers). crate::views::modal_window::push_vim_nav_search_hint(&mut shortcuts, false); shortcuts } /// Modal-window sizing for the cheatsheet. The `compact` knob lets /// callers honour the user's compact-prompt setting (smaller /// margins + tighter padding) without re-deriving the sizing rules. pub fn modal_sizing(compact: bool) -> crate::views::modal_window::ModalSizing { crate::views::modal_window::ModalSizing { width_pct: 0.70, max_width: 80, min_width: 44, v_margin: 4, h_pad: 2, v_pad: 1, footer_lines: 2, } .with_compact(compact) } /// Per-row kind captured during [`CheatsheetRows::build`] so the borrowed /// picker rows need only the owned buffers, not the source `entries`. enum CheatsheetRowKind { Header { is_collapsed: bool, }, Hint { dimmed: bool, expand: Option, }, Other, } /// Owned per-frame buffers backing the cheatsheet picker rows, shared by both /// modal hosts (agent inline render + dashboard [`render_modal`]). The /// [`crate::views::picker::PickerEntry`] list from [`Self::picker_entries`] /// borrows these buffers, so this value must outlive the render call. pub struct CheatsheetRows { row_strs: Vec<(String, String)>, // Inline-help per row, newlines collapsed to spaces so the collapsible view renders one // wrap-flowed block; empty string when the row has no help. Owned (it's a transform of the source). help_text: Vec, kinds: Vec, } impl CheatsheetRows { /// Build the row buffers for the current filter/collapse state. Both hosts /// call this so the row/expand construction lives in exactly one place. pub fn build( entries: &[ShortcutsHelpEntry], query: &str, filter_active: bool, collapsed_sections: &std::collections::HashSet, ) -> Self { let filtered = filter_entries(entries, query, filter_active, collapsed_sections); let mut row_strs = Vec::with_capacity(filtered.len()); let mut help_text = Vec::with_capacity(filtered.len()); let mut kinds = Vec::with_capacity(filtered.len()); for &i in &filtered { match entries.get(i) { Some(ShortcutsHelpEntry::SectionHeader { label, entry_count, category_idx, }) => { let is_collapsed = collapsed_sections.contains(category_idx); let display = if is_collapsed { format!("{label} ({entry_count})") } else { (*label).to_string() }; row_strs.push((display, String::new())); help_text.push(String::new()); kinds.push(CheatsheetRowKind::Header { is_collapsed }); } Some(entry @ ShortcutsHelpEntry::Hint { dimmed, .. }) => { row_strs.push(entry_display(entries, i)); // Collapse newlines to spaces so the collapsible view shows one wrap-flowed block (no hard breaks). let help = hint_inline_help(entry) .map(|s| s.replace('\n', " ")) .unwrap_or_default(); help_text.push(help); kinds.push(CheatsheetRowKind::Hint { dimmed: *dimmed, expand: expand_key(entry), }); } _ => { row_strs.push(entry_display(entries, i)); help_text.push(String::new()); kinds.push(CheatsheetRowKind::Other); } } } Self { row_strs, help_text, kinds, } } /// Borrowed views of the per-row inline help, in row order. The caller holds /// these so the picker's description slices can borrow them across the render. pub fn help_refs(&self) -> Vec<&str> { self.help_text.iter().map(String::as_str).collect() } /// Build the borrowed picker rows, reading selection + expand state. The /// returned list borrows `self` and `help` (from [`Self::help_refs`]), so it /// lives only as long as both. pub fn picker_entries<'a>( &'a self, state: &PickerState, expanded_ids: &std::collections::HashSet, help: &'a [&'a str], ) -> Vec> { use crate::views::picker::{PickerEntry, PickerRow}; debug_assert_eq!( help.len(), self.kinds.len(), "help must be 1:1 with rows (pass CheatsheetRows::help_refs)" ); self.kinds .iter() .enumerate() .map(|(idx, kind)| { let selected = state.hovered == Some(idx) || (state.hovered.is_none() && idx == state.selected); match kind { CheatsheetRowKind::Header { is_collapsed } => PickerEntry::Row(PickerRow { label: self.row_strs[idx].0.as_str(), right_label: "", selected, expanded: !is_collapsed, fields: &[], description_lines: &[], summary_lines: &[], dimmed: false, indent: 0, badge: "", badge_color: None, collapsible: true, }), CheatsheetRowKind::Hint { dimmed, expand } => { let is_expanded = expand.map(|id| expanded_ids.contains(&id)).unwrap_or(false); let description_lines: &[&str] = if is_expanded && !help[idx].is_empty() { std::slice::from_ref(&help[idx]) } else { &[] }; PickerEntry::Row(PickerRow { label: self.row_strs[idx].0.as_str(), right_label: self.row_strs[idx].1.as_str(), selected, expanded: is_expanded, fields: &[], description_lines, summary_lines: &[], dimmed: *dimmed, indent: 1, badge: "", badge_color: None, collapsible: false, }) } CheatsheetRowKind::Other => PickerEntry::Row(PickerRow { label: self.row_strs[idx].0.as_str(), right_label: self.row_strs[idx].1.as_str(), selected: false, expanded: false, fields: &[], description_lines: &[], summary_lines: &[], dimmed: false, indent: 0, badge: "", badge_color: None, collapsible: false, }), } }) .collect() } } /// Render the cheatsheet modal in full (chrome + picker content). /// /// Pulled out of `AgentView::draw` so the dashboard can paint the /// exact same modal without re-plumbing `ModalWindowConfig` / /// picker-inner glue. The agent view continues to drive its own /// modal via `views::modal::ActiveModal::ShortcutsHelp`; this /// function consumes the same fields by reference. /// /// The signature mirrors the destructured `ActiveModal::ShortcutsHelp` /// fields one-to-one so callers can splat them directly — packing /// these into a wrapper struct would force every call site to /// build an intermediate just to take it apart again at the /// chrome / picker boundary. #[allow(clippy::too_many_arguments)] pub fn render_modal( buf: &mut ratatui::buffer::Buffer, area: ratatui::layout::Rect, entries: &[ShortcutsHelpEntry], state: &mut PickerState, window: &mut crate::views::modal_window::ModalWindowState, filter_active: bool, collapsed_sections: &std::collections::HashSet, expanded_ids: &std::collections::HashSet, mode: &ShortcutsHelpMode, theme: &crate::theme::Theme, compact: bool, ) { use crate::views::modal_window as mw; use crate::views::picker::{self, PickerHitAreas}; use ratatui::layout::Rect; // Detail screen reuses the same modal chrome with a different footer. if mode.is_detail() { render_detail(buf, area, window, mode, theme, compact); return; } let rows = CheatsheetRows::build(entries, &state.query, filter_active, collapsed_sections); let help_refs = rows.help_refs(); let picker_entries = rows.picker_entries(state, expanded_ids, &help_refs); let non_sel: Vec = vec![false; picker_entries.len()]; let footer = modal_footer(filter_active); let modal_config = mw::ModalWindowConfig { title: "Keyboard Shortcuts", tabs: None, shortcuts: &footer, sizing: modal_sizing(compact), fold_info: None, }; let Some(mca) = mw::render_modal_window(buf, area, window, &modal_config, theme) else { return; }; let content_area = mca.content; let inner_x = mca.inner_x; let inner_width = mca.inner_width; let searching = state.search_active || !state.query.is_empty(); let show_search_hint = !searching; picker::render_search_bar( buf, content_area.x, content_area.y, content_area.width, theme, &state.query, searching, show_search_hint, state.query_cursor, Some(theme.bg_base), ); let sep_y = content_area.y + 1; if sep_y < content_area.y + content_area.height { picker::render_divider(buf, inner_x, sep_y, inner_width, theme, Some(theme.bg_base)); } let entries_start_y = sep_y + 1; let search_bar_rect = Rect::new(content_area.x, content_area.y, content_area.width, 1); let entries_area = Rect { x: content_area.x, y: entries_start_y, width: content_area.width, height: content_area .height .saturating_sub(entries_start_y.saturating_sub(content_area.y)), }; let content_hit = picker::render_picker_content_with_scrollbar_x( buf, entries_area, theme, state, &picker_entries, &non_sel, &[], Some(theme.bg_base), false, inner_x + inner_width - 1, ); state.hit_areas = Some(PickerHitAreas { close_button: Rect::default(), search_bar: search_bar_rect, item_rects: content_hit.item_rects, entry_indices: content_hit.entry_indices, tab_rects: vec![], filter_rect: None, }); } /// Outcome of routing a key through the cheatsheet's /// chrome + picker pipeline. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ModalKeyOutcome { /// User asked to close the modal (Esc in browse, Ctrl+./Ctrl+X, /// or the close chrome button). Close, /// `f` was pressed — caller should flip `filter_active`. ToggleFilter, /// User toggled a section header (collapse / expand). ToggleSection(usize), /// Toggle inline help for a hint row (registry or long_help pseudo). ToggleExpand(ExpandKey), /// Visual state changed (cursor, query, scroll, or detail enter/back). Changed, /// Nothing changed. Unchanged, } /// Route a key through the cheatsheet's modal-window chrome + the /// picker `handle_input`. Mirrors the agent view's per-modal /// handler so the dashboard can reuse the exact same key /// semantics. Caller owns `filter_active` / `collapsed_sections` /// so the result mutations stay local to the wrapping struct. /// /// Args follow the same one-to-one shape as the field set behind /// `ActiveModal::ShortcutsHelp` so dashboards and agents can call /// it via plain destructuring instead of building / unpacking a /// wrapper struct. #[allow(clippy::too_many_arguments)] pub fn handle_modal_key( key: &crossterm::event::KeyEvent, entries: &[ShortcutsHelpEntry], state: &mut PickerState, window: &mut crate::views::modal_window::ModalWindowState, filter_active: bool, collapsed_sections: &std::collections::HashSet, expanded_ids: &std::collections::HashSet, mode: &mut ShortcutsHelpMode, compact: bool, ) -> ModalKeyOutcome { use crate::views::modal_window as mw; use crossterm::event::KeyCode; let searching = state.search_active || !state.query.is_empty(); if mode.is_browse() && searching && key.code == KeyCode::Esc { state.query.clear(); state.query_cursor = 0; state.search_active = false; state.selected = 0; return ModalKeyOutcome::Changed; } let footer = if mode.is_detail() { modal_footer_detail() } else { modal_footer(filter_active) }; let chrome_cfg = mw::ModalWindowConfig { title: "Keyboard Shortcuts", tabs: None, shortcuts: &footer, sizing: modal_sizing(compact), fold_info: None, }; // Detail owns Esc (back to browse); skip chrome so it doesn't close the modal. if mode.is_browse() { match mw::handle_modal_key(window, key, &chrome_cfg) { mw::ModalWindowOutcome::CloseRequested => return ModalKeyOutcome::Close, mw::ModalWindowOutcome::Unhandled => {} _ => return ModalKeyOutcome::Changed, } } match handle_input( key, entries, state, filter_active, collapsed_sections, expanded_ids, mode, ) { ShortcutsHelpOutcome::Close => ModalKeyOutcome::Close, ShortcutsHelpOutcome::ToggleFilter => ModalKeyOutcome::ToggleFilter, ShortcutsHelpOutcome::ToggleSection(idx) => ModalKeyOutcome::ToggleSection(idx), ShortcutsHelpOutcome::ToggleExpand(id) => ModalKeyOutcome::ToggleExpand(id), ShortcutsHelpOutcome::Changed => ModalKeyOutcome::Changed, ShortcutsHelpOutcome::Unchanged => ModalKeyOutcome::Unchanged, } } #[cfg(test)] mod tests { use super::*; use crate::key; struct VimModeGuard(bool); impl VimModeGuard { fn set(enabled: bool) -> Self { let previous = crate::appearance::cache::load_vim_mode(); crate::appearance::cache::set_vim_mode(enabled); Self(previous) } } impl Drop for VimModeGuard { fn drop(&mut self) { crate::appearance::cache::set_vim_mode(self.0); } } fn header(label: &'static str, idx: usize, count: usize) -> ShortcutsHelpEntry { ShortcutsHelpEntry::SectionHeader { label, category_idx: idx, entry_count: count, } } fn hint(label: &'static str, k: KeyShortcut) -> ShortcutsHelpEntry { ShortcutsHelpEntry::Hint { item: HintItem::new(k, label), dimmed: false, action_id: None, long_help: None, } } fn no_collapsed() -> std::collections::HashSet { std::collections::HashSet::new() } fn no_expanded() -> std::collections::HashSet { std::collections::HashSet::new() } fn browse_mode() -> ShortcutsHelpMode { ShortcutsHelpMode::Browse } fn hint_with_action( label: &'static str, k: KeyShortcut, action_id: crate::actions::ActionId, ) -> ShortcutsHelpEntry { let mut item = HintItem::new(k, label); item.description = Some(std::borrow::Cow::Borrowed(label)); ShortcutsHelpEntry::Hint { item, dimmed: false, action_id: Some(action_id), long_help: None, } } /// `DashboardCycleMode` carries Shift+Tab three times (the terminal /// encoding variants `BackTab` / `BackTab`+SHIFT / `Tab`+SHIFT). /// The cheatsheet must collapse identically-rendered keys instead /// of showing "Shift+Tab / Shift+Tab / Shift+Tab". #[test] fn build_entries_dedupes_identically_rendered_alt_keys() { let registry = crate::actions::ActionRegistry::defaults(); let entries = build_entries(&[When::DashboardFocused], ®istry, false); let item = entries .iter() .find_map(|e| match e { ShortcutsHelpEntry::Hint { item, .. } if item.description.as_deref() == Some("Cycle dispatch mode") => { Some(item) } _ => None, }) .expect("DashboardCycleMode must be listed"); assert_eq!( hint_key_pretty(item), "Shift+Tab", "encoding-variant alt keys must collapse to one display", ); } #[test] fn filter_empty_query_returns_all_indices() { let entries = vec![ header("Nav", 0, 2), hint("send", key!(Enter)), hint("nav", key!('j')), header("App", 1, 1), hint("quit", key!('q', CONTROL)), ]; assert_eq!( filter_entries(&entries, "", false, &no_collapsed()), vec![0, 1, 2, 3, 4] ); } #[test] fn filter_keeps_header_when_section_has_match() { let entries = vec![ header("Nav", 0, 2), hint("send", key!(Enter)), hint("nav", key!('j')), header("App", 1, 1), hint("quit", key!('q', CONTROL)), ]; assert_eq!( filter_entries(&entries, "send", false, &no_collapsed()), vec![0, 1] ); } #[test] fn filter_drops_header_when_section_empty() { let entries = vec![ header("Nav", 0, 1), hint("send", key!(Enter)), header("App", 1, 1), hint("quit", key!('q', CONTROL)), ]; assert_eq!( filter_entries(&entries, "quit", false, &no_collapsed()), vec![2, 3] ); } #[test] fn filter_matches_against_key_display() { let entries = vec![ header("Nav", 0, 2), hint("send", key!(Enter)), hint("nav", key!('j')), ]; assert_eq!( filter_entries(&entries, "enter", false, &no_collapsed()), vec![0, 1] ); } #[test] fn filter_keeps_both_headers_when_both_sections_match() { let entries = vec![ header("Nav", 0, 1), hint("nav", key!('j')), header("App", 1, 1), hint("new session", key!('n', CONTROL)), ]; let result = filter_entries(&entries, "n", false, &no_collapsed()); assert!(result.contains(&0)); assert!(result.contains(&1)); assert!(result.contains(&2)); assert!(result.contains(&3)); } #[test] fn collapsed_section_shows_header_only() { let entries = vec![ header("Nav", 0, 2), hint("send", key!(Enter)), hint("nav", key!('j')), header("App", 1, 1), hint("quit", key!('q', CONTROL)), ]; let mut collapsed = std::collections::HashSet::new(); collapsed.insert(0); let result = filter_entries(&entries, "", false, &collapsed); assert_eq!(result, vec![0, 3, 4]); } #[test] fn search_forces_collapsed_sections_open() { let entries = vec![ header("Nav", 0, 1), hint("nav", key!('j')), header("App", 1, 1), hint("quit", key!('q', CONTROL)), ]; let mut collapsed = std::collections::HashSet::new(); collapsed.insert(0); let result = filter_entries(&entries, "nav", false, &collapsed); assert!( result.contains(&1), "search should find nav in collapsed section" ); } fn all_contexts() -> Vec { vec![ When::ScrollbackFocused, When::PromptFocused, When::AgentScreen, When::Always, ] } #[test] fn build_entries_groups_by_category() { let registry = ActionRegistry::defaults(); let entries = build_entries(&all_contexts(), ®istry, true); let headers: Vec<&str> = entries .iter() .filter_map(|e| match e { ShortcutsHelpEntry::SectionHeader { label, .. } => Some(*label), _ => None, }) .collect(); assert!(headers.contains(&"Essentials")); assert!(headers.contains(&"Conversation Navigation")); assert!(headers.contains(&"Panels")); } #[test] fn mouse_reporting_shortcut_absent_by_default() { // Opt-in via config.toml; default registry must not advertise it. let registry = ActionRegistry::defaults(); assert!(registry.find(ActionId::ToggleMouseCapture).is_none()); let entries = build_entries(&all_contexts(), ®istry, true); let has_row = entries.iter().any(|e| { matches!( e, ShortcutsHelpEntry::Hint { item, .. } if item.label == "mouse reporting" ) }); assert!( !has_row, "mouse reporting must not appear when config-disabled" ); } #[test] fn mouse_reporting_shortcut_is_under_panels_when_enabled() { let registry = ActionRegistry::defaults_with_config(true); let def = registry .find(ActionId::ToggleMouseCapture) .expect("ToggleMouseCapture action must be registered when config-enabled"); assert_eq!(def.category, Category::Panels); assert_eq!(def.label, "mouse reporting"); assert_eq!( def.description, "Toggle mouse reporting (native copy/paste)", ); let entries = build_entries(&all_contexts(), ®istry, true); let mut in_panels = false; let mut in_essentials = false; let mut seen = false; for entry in &entries { match entry { ShortcutsHelpEntry::SectionHeader { label, .. } => { in_panels = *label == "Panels"; in_essentials = *label == "Essentials"; } ShortcutsHelpEntry::Hint { item, .. } => { if item.label == "mouse reporting" { assert!( in_panels, "mouse reporting row must be in Panels, not Essentials" ); assert!( !in_essentials, "mouse reporting must not appear under Essentials" ); assert_eq!( item.description.as_deref(), Some("Toggle mouse reporting (native copy/paste)"), ); let key_text = hint_key_pretty(item); assert!( key_text.contains("Ctrl+r") || key_text.contains("Ctrl+R"), "expected Ctrl+r in key display, got {key_text:?}" ); seen = true; } } } } assert!( seen, "mouse reporting row must be present in shortcuts help when enabled" ); } #[test] fn build_entries_deduplicates_within_category() { let registry = ActionRegistry::defaults(); let entries = build_entries(&all_contexts(), ®istry, true); let mut current_cat_keys: std::collections::HashSet = std::collections::HashSet::new(); for entry in &entries { match entry { ShortcutsHelpEntry::SectionHeader { .. } => { current_cat_keys.clear(); } ShortcutsHelpEntry::Hint { item: h, .. } => { if let Some(&k) = h.keys.first() { assert!( current_cat_keys.insert(k), "duplicate key {:?} within same category", k.display() ); } } } } } #[test] fn build_entries_includes_new_pane_actions() { let registry = ActionRegistry::defaults(); let entries = build_entries(&all_contexts(), ®istry, true); let has_todos = entries.iter().any(|e| { matches!( e, ShortcutsHelpEntry::Hint { item, .. } if item.label == "todos" ) }); let has_sessions = entries.iter().any(|e| { matches!( e, ShortcutsHelpEntry::Hint { item, .. } if item.label == "sessions" ) }); let has_queue = entries.iter().any(|e| { matches!( e, ShortcutsHelpEntry::Hint { item, .. } if item.label == "queue" ) }); assert!(has_todos, "should include toggle todos"); assert!(has_sessions, "should include open sessions"); assert!(has_queue, "should include toggle queue"); } fn has_scrollback_search(entries: &[ShortcutsHelpEntry]) -> bool { entries.iter().any(|e| { matches!( e, ShortcutsHelpEntry::Hint { item, .. } if item.label == "search" && item.keys.iter().any(|k| k.display() == "/") ) }) } #[test] fn build_entries_includes_scrollback_search_in_vim_mode() { let registry = ActionRegistry::defaults(); let entries = build_entries(&all_contexts(), ®istry, true); assert!( has_scrollback_search(&entries), "vim cheatsheet should list / search" ); } #[test] fn build_entries_omits_scrollback_search_in_simple_mode() { let registry = ActionRegistry::defaults(); let entries = build_entries(&all_contexts(), ®istry, false); assert!( !has_scrollback_search(&entries), "simple mode does not bind / to search, so it must not be listed" ); } #[test] fn build_entries_includes_paste() { let registry = ActionRegistry::defaults(); let entries = build_entries(&all_contexts(), ®istry, true); let entry = entries .iter() .find(|e| { matches!( e, ShortcutsHelpEntry::Hint { item, action_id: None, .. } if item.label == "paste" ) }) .expect("cheatsheet should list paste"); let ShortcutsHelpEntry::Hint { item, long_help, .. } = entry else { unreachable!(); }; assert!(item.keys.iter().any(|k| *k == key!('v', CONTROL))); assert!( item.description .as_deref() .is_some_and(|d| d.to_lowercase().contains("image")), "description should mention image for search" ); assert_eq!(*long_help, Some(PASTE_LONG_HELP)); assert!( PASTE_LONG_HELP.contains('\n'), "paste long_help should be multi-line man-style" ); #[cfg(target_os = "windows")] assert!(item.keys.iter().any(|k| *k == key!('v', ALT))); #[cfg(not(target_os = "windows"))] assert!(!item.keys.iter().any(|k| *k == key!('v', ALT))); } fn paste_is_dimmed(entries: &[ShortcutsHelpEntry]) -> Option { entries.iter().find_map(|e| match e { ShortcutsHelpEntry::Hint { item, dimmed, action_id: None, .. } if item.label == "paste" => Some(*dimmed), _ => None, }) } #[test] fn build_entries_dims_paste_outside_prompt_and_dashboard() { let registry = ActionRegistry::defaults(); assert_eq!( paste_is_dimmed(&build_entries( &[When::ScrollbackFocused, When::AgentScreen, When::Always], ®istry, true, )), Some(true), "paste dimmed when neither prompt nor dashboard is active" ); assert_eq!( paste_is_dimmed(&build_entries( &[When::PromptFocused, When::AgentScreen, When::Always], ®istry, true, )), Some(false), "paste lit when prompt is focused" ); // Dashboard host opens the cheatsheet with only DashboardFocused + Always // and handles paste itself — must not dim a working shortcut. assert_eq!( paste_is_dimmed(&build_entries( &[When::DashboardFocused, When::Always], ®istry, true, )), Some(false), "paste lit on the dashboard host" ); } #[test] fn build_entries_dims_out_of_context_actions() { let registry = ActionRegistry::defaults(); let prompt_contexts = vec![When::PromptFocused, When::AgentScreen, When::Always]; let entries = build_entries(&prompt_contexts, ®istry, true); let nav_dimmed = entries.iter().any(|e| { matches!( e, ShortcutsHelpEntry::Hint { item, dimmed: true, .. } if item.label == "nav" ) }); assert!( nav_dimmed, "scrollback nav should be dimmed when prompt is focused" ); let quit_bright = entries.iter().any(|e| { matches!( e, ShortcutsHelpEntry::Hint { item, dimmed: false, .. } if item.label == "quit" ) }); assert!(quit_bright, "quit should not be dimmed (When::Always)"); let cancel_bright = entries.iter().any(|e| { matches!( e, ShortcutsHelpEntry::Hint { item, dimmed: false, .. } if item.label == "cancel" ) }); assert!( cancel_bright, "cancel should not be dimmed (When::AgentScreen)" ); } #[test] fn build_entries_dims_both_pane_contexts_from_side_pane() { let registry = ActionRegistry::defaults(); let todo_contexts = vec![When::AgentScreen, When::Always]; let entries = build_entries(&todo_contexts, ®istry, true); let send_dimmed = entries.iter().any(|e| { matches!( e, ShortcutsHelpEntry::Hint { item, dimmed: true, .. } if item.label == "send" ) }); assert!( send_dimmed, "send should be dimmed from todo pane (PromptFocused)" ); let nav_dimmed = entries.iter().any(|e| { matches!( e, ShortcutsHelpEntry::Hint { item, dimmed: true, .. } if item.label == "nav" ) }); assert!( nav_dimmed, "nav should be dimmed from todo pane (ScrollbackFocused)" ); } /// The dashboard LIST and the session OVERLAY dim each other's shortcuts: /// on the list the overlay-scoped shortcuts (`When::DashboardOverlay`, /// e.g. "prev session") are dimmed while the list shortcuts /// (`When::DashboardFocused`, e.g. "pin") are lit; inside the overlay it's /// the inverse. (Dashboard actions are registered under `cfg(test)`.) #[test] fn build_entries_dims_dashboard_list_vs_overlay() { let registry = ActionRegistry::defaults(); let dimmed_of = |entries: &[ShortcutsHelpEntry], label: &str| -> Option { entries.iter().find_map(|e| match e { ShortcutsHelpEntry::Hint { item, dimmed, .. } if item.label == label => { Some(*dimmed) } _ => None, }) }; // Dashboard LIST: list shortcuts lit, overlay shortcuts dimmed. let list = build_entries(&[When::DashboardFocused, When::Always], ®istry, true); assert_eq!( dimmed_of(&list, "pin"), Some(false), "list `pin` must be lit on the dashboard list", ); assert_eq!( dimmed_of(&list, "prev session"), Some(true), "overlay `prev session` must be dimmed on the dashboard list", ); // Session OVERLAY (details): overlay shortcuts lit, list shortcuts dimmed. let overlay = build_entries( &[When::AgentScreen, When::Always, When::DashboardOverlay], ®istry, true, ); assert_eq!( dimmed_of(&overlay, "prev session"), Some(false), "overlay `prev session` must be lit inside the overlay", ); assert_eq!( dimmed_of(&overlay, "pin"), Some(true), "list `pin` must be dimmed inside the overlay", ); } /// `DashboardStop` (list) and `DashboardOverlayStop` (overlay) share /// Ctrl+X and the Dashboard category. The per-category dedup must keep /// whichever matches the active surface — lit — instead of always /// keeping the first-registered (list) def. And inside the overlay the /// `ShortcutsHelp` row must drop its shadowed Ctrl+X alt (the overlay /// stop owns the key there) while keeping its other binding. #[test] fn build_entries_overlay_stop_wins_dedup_and_shadows_cheatsheet_ctrl_x() { let registry = ActionRegistry::defaults(); let ctrl_x = crate::key!('x', CONTROL); let stop_rows = |entries: &[ShortcutsHelpEntry]| -> Vec<(String, bool)> { entries .iter() .filter_map(|e| match e { ShortcutsHelpEntry::Hint { item, dimmed, .. } if item.label == "stop" => { Some(( item.description.as_deref().unwrap_or_default().to_string(), *dimmed, )) } _ => None, }) .collect() }; let stop_id = |entries: &[ShortcutsHelpEntry]| -> Option { entries .iter() .find_map(|e| match e { ShortcutsHelpEntry::Hint { item, action_id, .. } if item.label == "stop" => Some(*action_id), _ => None, }) .flatten() }; let help_keys = |entries: &[ShortcutsHelpEntry]| -> Vec { entries .iter() .find_map(|e| match e { ShortcutsHelpEntry::Hint { item, .. } if item.label == "shortcuts" => { Some(item.keys.clone()) } _ => None, }) .expect("the ShortcutsHelp row must be present") }; // Dashboard LIST: the list stop survives, lit; the cheatsheet // row keeps Ctrl+X (no overlay up). let list = build_entries(&[When::DashboardFocused, When::Always], ®istry, true); assert_eq!( stop_rows(&list), vec![("Stop / Close agent".to_string(), false)], "the dashboard list must show exactly the list `stop`, lit", ); assert_eq!( stop_id(&list), Some(ActionId::DashboardStop), "the lit list `stop` is inserted first and never replaced — keeps DashboardStop", ); assert!( help_keys(&list).contains(&ctrl_x), "without an overlay the cheatsheet row keeps its Ctrl+X binding", ); // Session OVERLAY: the overlay stop survives, lit; the // cheatsheet row drops the shadowed Ctrl+X but keeps Ctrl+. let overlay = build_entries( &[When::AgentScreen, When::Always, When::DashboardOverlay], ®istry, true, ); assert_eq!( stop_rows(&overlay), vec![( "Stop agent, close session (back to dashboard)".to_string(), false )], "the overlay must show exactly the overlay `stop`, lit", ); assert_eq!( stop_id(&overlay), Some(ActionId::DashboardOverlayStop), "the lit overlay `stop` replaces the dimmed list row — carries DashboardOverlayStop", ); let keys = help_keys(&overlay); assert!( !keys.contains(&ctrl_x), "inside the overlay the cheatsheet row must drop the shadowed Ctrl+X", ); assert!( !keys.is_empty(), "the cheatsheet row must keep its non-shadowed binding (Ctrl+.)", ); } #[test] fn initial_state_selects_first_hint_not_header() { let entries = vec![ header("Nav", 0, 2), hint("send", key!(Enter)), hint("nav", key!('j')), ]; let state = build_initial_picker_state(&entries); assert_eq!(state.selected, 1, "selected should land on first Hint"); } fn make_key(code: crossterm::event::KeyCode) -> crossterm::event::KeyEvent { crossterm::event::KeyEvent::new(code, crossterm::event::KeyModifiers::NONE) } /// Helper: set up entries + state with selected on a section header. fn setup_on_header() -> (Vec, PickerState) { let entries = vec![ header("Nav", 0, 2), hint("send", key!(Enter)), hint("nav", key!('j')), ]; let mut state = build_initial_picker_state(&entries); state.selected = 0; (entries, state) } #[test] fn space_on_section_header_toggles() { let (entries, mut state) = setup_on_header(); let mut mode = browse_mode(); let result = handle_input( &make_key(crossterm::event::KeyCode::Char(' ')), &entries, &mut state, false, &no_collapsed(), &no_expanded(), &mut mode, ); assert_eq!(result, ShortcutsHelpOutcome::ToggleSection(0)); } #[test] fn enter_on_section_header_toggles() { let (entries, mut state) = setup_on_header(); let mut mode = browse_mode(); let result = handle_input( &make_key(crossterm::event::KeyCode::Enter), &entries, &mut state, false, &no_collapsed(), &no_expanded(), &mut mode, ); assert_eq!(result, ShortcutsHelpOutcome::ToggleSection(0)); } #[test] fn enter_on_hint_without_action_id_is_unchanged() { // Pseudo/legacy hints have no action_id — Enter does not close or open detail. let entries = vec![header("Nav", 0, 1), hint("send", key!(Enter))]; let mut state = build_initial_picker_state(&entries); state.selected = 1; let mut mode = browse_mode(); let result = handle_input( &make_key(crossterm::event::KeyCode::Enter), &entries, &mut state, false, &no_collapsed(), &no_expanded(), &mut mode, ); assert_eq!(result, ShortcutsHelpOutcome::Unchanged); assert!(mode.is_browse()); } #[test] fn enter_on_registry_hint_opens_detail() { use crate::actions::ActionId; let entries = vec![ header("Nav", 0, 1), hint_with_action("send", key!(Enter), ActionId::SendPrompt), ]; let mut state = build_initial_picker_state(&entries); state.selected = 1; let mut mode = browse_mode(); let result = handle_input( &make_key(crossterm::event::KeyCode::Enter), &entries, &mut state, false, &no_collapsed(), &no_expanded(), &mut mode, ); assert_eq!(result, ShortcutsHelpOutcome::Changed); assert!( mode.is_detail(), "Enter on a registry hint must switch mode to Detail" ); } /// Opening detail from an active search clears the query so a later Esc closes /// the modal directly (back -> close), not back -> clear-query -> close. #[test] fn enter_from_search_opens_detail_and_clears_query() { use crate::actions::ActionId; let entries = vec![ header("Nav", 0, 1), hint_with_action("send", key!(Enter), ActionId::SendPrompt), ]; let mut state = build_initial_picker_state(&entries); // Active search matching the hint, selection on the matching row. state.query = "send".to_string(); state.query_cursor = state.query.len(); state.search_active = true; state.selected = 1; let mut mode = browse_mode(); let result = handle_input( &make_key(crossterm::event::KeyCode::Enter), &entries, &mut state, false, &no_collapsed(), &no_expanded(), &mut mode, ); assert_eq!(result, ShortcutsHelpOutcome::Changed); assert!(mode.is_detail(), "Enter from search opens the detail page"); assert!( state.query.is_empty(), "opening detail clears the search query" ); assert!(!state.search_active, "opening detail clears search_active"); } /// Mouse parity with the keyboard path: clicking a hint while searching opens /// detail AND drops the committed query (so Esc from detail closes next press). #[test] fn click_from_search_opens_detail_and_clears_query() { use crate::actions::ActionId; use crate::views::picker::PickerHitAreas; use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; use ratatui::layout::Rect; let entries = vec![ header("Nav", 0, 1), hint_with_action("send", key!(Enter), ActionId::SendPrompt), ]; let mut state = build_initial_picker_state(&entries); // Active search that still matches the hint row. state.query = "send".to_string(); state.query_cursor = state.query.len(); state.search_active = true; // Map a click at row 2 to the hint's position in the filtered view. let filtered = filter_entries(&entries, &state.query, false, &no_collapsed()); let hint_pos = filtered .iter() .position(|&i| matches!(entries[i], ShortcutsHelpEntry::Hint { .. })) .expect("hint present in the filtered view"); state.hit_areas = Some(PickerHitAreas { close_button: Rect::default(), search_bar: Rect::default(), item_rects: vec![Rect::new(0, 2, 20, 1)], entry_indices: vec![hint_pos], tab_rects: vec![], filter_rect: None, }); let click = MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), column: 1, row: 2, modifiers: KeyModifiers::NONE, }; let mut mode = browse_mode(); let result = handle_mouse( &click, &entries, &mut state, false, &no_collapsed(), &mut mode, ); assert_eq!(result, ShortcutsHelpOutcome::Changed); assert!(mode.is_detail(), "clicking a hint from search opens detail"); assert!( state.query.is_empty(), "click-open detail clears the search query" ); assert!( !state.search_active, "click-open detail clears search_active" ); } /// The browse footer advertises the detail action so pattern B is discoverable. #[test] fn modal_footer_advertises_detail() { let footer = modal_footer(false); assert!( footer.iter().any(|s| s.label.contains("details")), "browse footer must advertise Enter details" ); } /// Wiring check: the cheatsheet footer carries the shared `i search` hint /// under vim and keeps `/ search` regardless. The gate is covered centrally /// by `modal_window::tests::vim_nav_search_hint_only_in_vim_nav_mode`. #[test] fn modal_footer_advertises_i_search_under_vim() { let _vim_mode = VimModeGuard::set(true); let footer = modal_footer(false); assert!( footer.iter().any(|s| s.label == "i search"), "vim-mode cheatsheet footer must advertise `i search`" ); assert!( footer.iter().any(|s| s.label == "/ search"), "`/ search` must remain regardless of vim-mode" ); } /// Host path: Enter on a registry hint enters Detail (not Close) via the /// chrome + picker pipeline both hosts share. #[test] fn handle_modal_key_enter_on_hint_enters_detail() { use crate::actions::ActionId; let entries = vec![ header("Nav", 0, 1), hint_with_action("send", key!(Enter), ActionId::SendPrompt), ]; let mut state = build_initial_picker_state(&entries); state.selected = 1; let mut window = crate::views::modal_window::ModalWindowState::default(); let mut mode = browse_mode(); let outcome = handle_modal_key( &make_key(crossterm::event::KeyCode::Enter), &entries, &mut state, &mut window, false, &no_collapsed(), &no_expanded(), &mut mode, false, ); assert_ne!( outcome, ModalKeyOutcome::Close, "Enter on a hint must not close" ); assert_eq!(outcome, ModalKeyOutcome::Changed); assert!(mode.is_detail(), "Enter enters the detail page"); } /// Over-scrolling a detail body clamps to the last lines instead of paging /// into an all-blank page. #[test] fn render_detail_body_clamps_overscroll() { use ratatui::buffer::Buffer; use ratatui::layout::Rect; let theme = crate::theme::Theme::current(); let area = Rect::new(0, 0, 40, 4); let mut buf = Buffer::empty(area); // Body taller than the 4-row viewport; a huge offset must still land on the end. let body = "L1\nL2\nL3\nL4\nL5\nZqxlast"; render_detail_body( &mut buf, area, "Title", "Enter", body, false, u16::MAX, &theme, ); let mut out = String::new(); for y in area.y..area.y + area.height { for x in area.x..area.x + area.width { if let Some(cell) = buf.cell((x, y)) { out.push_str(cell.symbol()); } } } assert!( out.contains("Zqxlast"), "over-scroll must clamp to show the last line, got: {out:?}" ); } /// When the body merely repeats the title (no long_help yet) it must render /// once; a distinct body (populated long_help) must still render below the title. #[test] fn render_detail_body_omits_body_equal_to_title() { use ratatui::buffer::Buffer; use ratatui::layout::Rect; let theme = crate::theme::Theme::current(); let collect = |title: &str, body: &str| -> String { let area = Rect::new(0, 0, 40, 10); let mut buf = Buffer::empty(area); render_detail_body(&mut buf, area, title, "Enter", body, false, 0, &theme); let mut out = String::new(); for y in area.y..area.y + area.height { for x in area.x..area.x + area.width { if let Some(cell) = buf.cell((x, y)) { out.push_str(cell.symbol()); } } } out }; assert_eq!( collect("Zqxtitle", "Zqxtitle").matches("Zqxtitle").count(), 1, "body equal to title must not be rendered twice" ); let distinct = collect("Zqxtitle", "Zqxbody"); assert_eq!(distinct.matches("Zqxtitle").count(), 1); assert!( distinct.contains("Zqxbody"), "a distinct body must still render below the title" ); } /// Every action that ships `long_help` carries man-style copy that is present /// and genuinely distinct from its one-line description. Iterating the whole /// registry catches a future description-echo on ANY populated action. #[test] fn populated_long_help_is_distinct_and_man_style() { let registry = ActionRegistry::defaults(); let populated: Vec<&crate::actions::ActionDef> = registry .all() .iter() .filter(|d| d.long_help.is_some()) .collect(); // Sanity floor so an accidental data wipe fails the test rather than passing vacuously. assert!( populated.len() >= 12, "expected the priority long_help set to stay populated, got {}", populated.len() ); for def in populated { let long = def.long_help.expect("filtered to Some above"); assert_ne!( long, def.description, "{:?} long_help must differ from its description (no echo)", def.id ); assert!( long.contains('\n'), "{:?} long_help should be multi-line man-style copy", def.id ); } } /// `detail_from_entry` surfaces the action's `long_help` as the detail body /// (not the description), proving the populated copy reaches the screen. #[test] fn detail_from_entry_uses_long_help_for_body() { let registry = ActionRegistry::defaults(); let def = registry .find(ActionId::ShortcutsHelp) .expect("ShortcutsHelp is registered"); let expected = def.long_help.expect("ShortcutsHelp has long_help"); let entries = build_entries(&all_contexts(), ®istry, true); let entry = entries .iter() .find(|e| hint_expand_action_id(e) == Some(ActionId::ShortcutsHelp)) .expect("ShortcutsHelp row is present"); let ShortcutsHelpMode::Detail { body, .. } = detail_from_entry(entry).expect("registry hint yields a detail") else { panic!("expected Detail mode"); }; assert_eq!( body, expected, "detail body must surface the action's long_help" ); assert_ne!( body, def.description, "detail body must be the long_help, not the description" ); } /// Scroll clamp counts WRAPPED rows: a body that wraps well past the viewport /// can scroll to its last wrapped row (a logical-line clamp could not reach it). #[test] fn render_detail_body_scroll_is_wrap_aware() { use ratatui::buffer::Buffer; use ratatui::layout::Rect; let theme = crate::theme::Theme::current(); // Narrow + short: one logical body line that wraps into many rows. let area = Rect::new(0, 0, 20, 4); let mut buf = Buffer::empty(area); let body = "alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo ZZEND"; render_detail_body( &mut buf, area, "Title", "Enter", body, false, u16::MAX, &theme, ); let mut out = String::new(); for y in area.y..area.y + area.height { for x in area.x..area.x + area.width { if let Some(cell) = buf.cell((x, y)) { out.push_str(cell.symbol()); } } } assert!( out.contains("ZZEND"), "wrap-aware clamp must scroll to the last wrapped row, got: {out:?}" ); } /// The detail page (Enter) paints a blank line between paragraphs so wrapped /// text reads as spaced blocks. The inline expand (arrows) is a separate path /// and stays tight. #[test] fn render_detail_body_spaces_paragraphs_with_blank_line() { use ratatui::buffer::Buffer; use ratatui::layout::Rect; let theme = crate::theme::Theme::current(); let area = Rect::new(0, 0, 40, 8); let mut buf = Buffer::empty(area); render_detail_body( &mut buf, area, "Title", "Enter", "First paragraph.\nSecond paragraph.", false, 0, &theme, ); let rows: Vec = (area.y..area.y + area.height) .map(|y| { (area.x..area.x + area.width) .filter_map(|x| buf.cell((x, y)).map(|c| c.symbol().to_string())) .collect::() .trim_end() .to_string() }) .collect(); let first = rows .iter() .position(|r| r.contains("First paragraph.")) .expect("first paragraph present"); let second = rows .iter() .position(|r| r.contains("Second paragraph.")) .expect("second paragraph present"); assert_eq!( second, first + 2, "paragraphs must be separated by exactly one blank row, rows: {rows:?}" ); assert!( rows[first + 1].is_empty(), "the row between paragraphs must be blank, got {:?}", rows[first + 1] ); } /// Search has no long_help — Enter stays in browse. #[test] fn enter_on_search_pseudo_row_does_not_open_detail() { let registry = ActionRegistry::defaults(); let entries = build_entries(&all_contexts(), ®istry, true); let idx = entries .iter() .position(|e| { matches!( e, ShortcutsHelpEntry::Hint { item, action_id: None, .. } if item.label == "search" ) }) .expect("vim-mode entries include the `/`-search pseudo-row"); let mut state = build_initial_picker_state(&entries); state.selected = idx; let mut mode = browse_mode(); let out = handle_input( &make_key(crossterm::event::KeyCode::Enter), &entries, &mut state, false, &no_collapsed(), &no_expanded(), &mut mode, ); assert_eq!(out, ShortcutsHelpOutcome::Unchanged); assert!( mode.is_browse(), "search pseudo-row Enter must not open detail" ); } /// Paste ships long_help — Enter opens the man-page detail view. #[test] fn enter_on_paste_pseudo_row_opens_detail() { let registry = ActionRegistry::defaults(); let entries = build_entries(&all_contexts(), ®istry, true); let idx = entries .iter() .position(|e| { matches!( e, ShortcutsHelpEntry::Hint { item, action_id: None, long_help: Some(_), .. } if item.label == "paste" ) }) .expect("paste pseudo-row with long_help"); assert_eq!( detail_from_entry(&entries[idx]) .and_then(|m| match m { ShortcutsHelpMode::Detail { body, .. } => Some(body), _ => None, }) .as_deref(), Some(PASTE_LONG_HELP) ); let mut state = build_initial_picker_state(&entries); state.selected = idx; let mut mode = browse_mode(); let out = handle_input( &make_key(crossterm::event::KeyCode::Enter), &entries, &mut state, false, &no_collapsed(), &no_expanded(), &mut mode, ); assert_eq!(out, ShortcutsHelpOutcome::Changed); match &mode { ShortcutsHelpMode::Detail { body, keys_line, .. } => { assert_eq!(body, PASTE_LONG_HELP); assert!( keys_line.to_ascii_lowercase().contains("ctrl+v"), "detail keys should list Ctrl+V, got {keys_line:?}" ); } ShortcutsHelpMode::Browse => panic!("paste Enter must open detail"), } } #[test] fn esc_in_detail_returns_to_browse() { let mut mode = ShortcutsHelpMode::Detail { title: "Send".into(), keys_line: "Enter".into(), body: "Send the message".into(), dimmed_note: false, scroll: 0, }; let entries: Vec = vec![]; let mut state = PickerState::default(); let result = handle_input( &make_key(crossterm::event::KeyCode::Esc), &entries, &mut state, false, &no_collapsed(), &no_expanded(), &mut mode, ); assert_eq!(result, ShortcutsHelpOutcome::Changed); assert!(mode.is_browse(), "Esc in detail must return to browse"); } /// Vim keys (h/j/k/g) are intentionally NOT bound in detail mode — vim modal /// bindings are owned separately. Arrows/Home scroll; Esc/Left/Backspace go back. #[test] fn detail_mode_ignores_vim_keys() { use crossterm::event::KeyCode; let entries: Vec = vec![]; let mut state = PickerState::default(); let scroll_of = |m: &ShortcutsHelpMode| match m { ShortcutsHelpMode::Detail { scroll, .. } => *scroll, _ => u16::MAX, }; let detail = || ShortcutsHelpMode::Detail { title: "Send".into(), keys_line: "Enter".into(), body: "line one\nline two".into(), dimmed_note: false, scroll: 0, }; // h/j/k/g are inert in detail: no scroll, no back. for code in [ KeyCode::Char('h'), KeyCode::Char('j'), KeyCode::Char('k'), KeyCode::Char('g'), ] { let mut mode = detail(); let out = handle_input( &make_key(code), &entries, &mut state, false, &no_collapsed(), &no_expanded(), &mut mode, ); assert!(mode.is_detail(), "{code:?} must not leave detail mode"); assert_eq!( scroll_of(&mode), 0, "{code:?} must not scroll the detail body" ); assert_eq!( out, ShortcutsHelpOutcome::Unchanged, "{code:?} must be inert in detail, got {out:?}" ); } // Non-vim keys still work: Down scrolls, Left returns to browse. let mut mode = detail(); let _ = handle_input( &make_key(KeyCode::Down), &entries, &mut state, false, &no_collapsed(), &no_expanded(), &mut mode, ); assert_eq!(scroll_of(&mode), 1, "Down scrolls the detail body"); let _ = handle_input( &make_key(KeyCode::Left), &entries, &mut state, false, &no_collapsed(), &no_expanded(), &mut mode, ); assert!(mode.is_browse(), "Left returns to browse"); } /// Host path: chrome must not intercept Esc while in detail (would close the /// modal); it returns to browse and keeps the modal open. #[test] fn handle_modal_key_esc_in_detail_is_back_not_close() { let entries = vec![header("Nav", 0, 1), hint("send", key!(Enter))]; let mut state = build_initial_picker_state(&entries); let mut window = crate::views::modal_window::ModalWindowState::default(); let collapsed = no_collapsed(); let mut mode = ShortcutsHelpMode::Detail { title: "Send".into(), keys_line: "Enter".into(), body: "Send the message".into(), dimmed_note: false, scroll: 0, }; let outcome = handle_modal_key( &make_key(crossterm::event::KeyCode::Esc), &entries, &mut state, &mut window, false, &collapsed, &no_expanded(), &mut mode, false, ); assert_ne!( outcome, ModalKeyOutcome::Close, "Esc in detail must not close" ); assert_eq!(outcome, ModalKeyOutcome::Changed); assert!(mode.is_browse(), "Esc in detail returns to browse"); } #[test] fn esc_in_browse_closes_via_picker() { let entries = vec![header("Nav", 0, 1), hint("send", key!(Enter))]; let mut state = build_initial_picker_state(&entries); let mut mode = browse_mode(); let result = handle_input( &make_key(crossterm::event::KeyCode::Esc), &entries, &mut state, false, &no_collapsed(), &no_expanded(), &mut mode, ); assert_eq!(result, ShortcutsHelpOutcome::Close); assert!(mode.is_browse()); } #[test] fn ctrl_dot_closes_from_detail_mode() { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let mut mode = ShortcutsHelpMode::Detail { title: "Send".into(), keys_line: "Enter".into(), body: "body".into(), dimmed_note: false, scroll: 3, }; let entries: Vec = vec![]; let mut state = PickerState::default(); let key = KeyEvent::new(KeyCode::Char('.'), KeyModifiers::CONTROL); let result = handle_input( &key, &entries, &mut state, false, &no_collapsed(), &no_expanded(), &mut mode, ); assert_eq!(result, ShortcutsHelpOutcome::Close); // mode is unchanged by handle_input; caller clears the modal. assert!(mode.is_detail()); } #[test] fn ctrl_x_closes_from_browse_mode() { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let entries = vec![header("Nav", 0, 1), hint("send", key!(Enter))]; let mut state = build_initial_picker_state(&entries); let mut mode = browse_mode(); let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL); let result = handle_input( &key, &entries, &mut state, false, &no_collapsed(), &no_expanded(), &mut mode, ); assert_eq!(result, ShortcutsHelpOutcome::Close); } #[test] fn vim_i_enters_search_and_printables_type_afterward() { let _vim_mode = VimModeGuard::set(true); let (entries, mut state) = setup_on_header(); assert!(!state.search_active); let mut mode = browse_mode(); let enter_search = handle_input( &make_key(crossterm::event::KeyCode::Char('i')), &entries, &mut state, false, &no_collapsed(), &no_expanded(), &mut mode, ); assert_eq!(enter_search, ShortcutsHelpOutcome::Changed); assert!(state.search_active, "`i` must activate cheatsheet search"); assert!(state.query.is_empty(), "`i` must not enter search text"); let type_j = handle_input( &make_key(crossterm::event::KeyCode::Char('j')), &entries, &mut state, false, &no_collapsed(), &no_expanded(), &mut mode, ); assert_eq!(type_j, ShortcutsHelpOutcome::Changed); assert_eq!(state.query, "j", "printables must type in active search"); } #[test] fn vim_mode_jk_navigate_without_starting_search() { let _vim_mode = VimModeGuard::set(true); let entries = vec![ header("Nav", 0, 3), hint("send", key!(Enter)), hint("next", key!('n')), hint("quit", key!('q', CONTROL)), ]; let mut state = build_initial_picker_state(&entries); let mut mode = browse_mode(); let down = handle_input( &make_key(crossterm::event::KeyCode::Char('j')), &entries, &mut state, false, &no_collapsed(), &no_expanded(), &mut mode, ); assert_eq!(down, ShortcutsHelpOutcome::Changed); assert_eq!(state.selected, 2, "`j` must select the next row"); assert!(state.query.is_empty(), "`j` must not enter search text"); assert!(!state.search_active, "`j` must leave search inactive"); let up = handle_input( &make_key(crossterm::event::KeyCode::Char('k')), &entries, &mut state, false, &no_collapsed(), &no_expanded(), &mut mode, ); assert_eq!(up, ShortcutsHelpOutcome::Changed); assert_eq!(state.selected, 1, "`k` must select the previous row"); assert!(state.query.is_empty(), "`k` must not enter search text"); assert!(!state.search_active, "`k` must leave search inactive"); } #[test] fn non_vim_hjkl_start_search() { let _vim_mode = VimModeGuard::set(false); let (entries, state) = setup_on_header(); for ch in ['h', 'j', 'k', 'l'] { let mut state = state.clone(); let collapsed = if ch == 'l' { std::collections::HashSet::from([0]) } else { no_collapsed() }; let mut mode = browse_mode(); let result = handle_input( &make_key(crossterm::event::KeyCode::Char(ch)), &entries, &mut state, false, &collapsed, &no_expanded(), &mut mode, ); assert_eq!( result, ShortcutsHelpOutcome::Changed, "non-vim `{ch}` must start search" ); assert_eq!(state.query, ch.to_string(), "non-vim `{ch}` must type"); } } /// In non-vim mode, `j/k` row should drop the `j` key and show only /// the `Down` alt — `Down` still works and the row should not be dimmed. #[test] fn build_entries_vim_off_keeps_arrow_alt_without_vim_key() { let registry = ActionRegistry::defaults(); let entries = build_entries(&all_contexts(), ®istry, false); let nav = entries .iter() .find_map(|e| match e { ShortcutsHelpEntry::Hint { item, dimmed, .. } if item.label == "nav" => { Some((item, *dimmed)) } _ => None, }) .expect("nav (SelectNext) row should be present in non-vim mode"); let (item, dimmed) = nav; assert!(!dimmed, "nav row with Down alt should not be dimmed"); assert!( item.keys.iter().all(|k| !k.is_letter_or_shift_letter()), "non-vim cheatsheet must not advertise letter keys; got {:?}", item.keys.iter().map(|k| k.display()).collect::>() ); assert!( !item.keys.is_empty(), "row must retain at least one (non-vim) key" ); } /// In non-vim mode, scrollback bindings that have NO non-vim alt /// (e.g. `g` GotoTop, `y` CopyBlockContent) should be hidden from the /// cheatsheet entirely. #[test] fn build_entries_vim_off_hides_vim_only_rows() { let registry = ActionRegistry::defaults(); let entries = build_entries(&all_contexts(), ®istry, false); for label in ["top", "btm", "copy", "copy cmd"] { let present = entries.iter().any(|e| { matches!( e, ShortcutsHelpEntry::Hint { item, .. } if item.label == label ) }); assert!( !present, "{label:?} (vim-only) should be hidden from cheatsheet when vim_mode=false" ); } } /// Vim mode ON: both vim key and arrow alt should be visible on the /// same row. #[test] fn build_entries_vim_on_shows_both_vim_and_arrow_keys() { let registry = ActionRegistry::defaults(); let entries = build_entries(&all_contexts(), ®istry, true); let nav_keys: Vec = entries .iter() .find_map(|e| match e { ShortcutsHelpEntry::Hint { item, .. } if item.label == "nav" => { Some(item.keys.iter().map(|k| k.display().to_string()).collect()) } _ => None, }) .expect("nav row should be present in vim mode"); let nav_keys_joined = nav_keys.join(" "); assert!( nav_keys_joined.contains('j') || nav_keys_joined.contains('J'), "vim mode should show `j` key for nav: {nav_keys:?}" ); assert!( nav_keys_joined.contains('↓') || nav_keys_joined.to_lowercase().contains("down"), "vim mode should also show arrow alt: {nav_keys:?}" ); } /// Asserts that the cheatsheet row for `label` advertises `expected_key` /// (primary or alt). Used by the Windows-fallback regressions below. fn assert_cheatsheet_row_has_key( entries: &[ShortcutsHelpEntry], label: &str, expected_key: &str, ) { let keys: Vec = entries .iter() .find_map(|e| match e { ShortcutsHelpEntry::Hint { item, .. } if item.label == label => { Some(item.keys.iter().map(|k| k.display()).collect()) } _ => None, }) .unwrap_or_else(|| panic!("{label:?} row not found in cheatsheet")); assert!( keys.iter().any(|k| k == expected_key), "{label} cheatsheet row missing {expected_key}; got {keys:?}" ); } #[test] fn build_entries_surfaces_interject_ctrl_i_fallback() { let registry = ActionRegistry::defaults(); let entries = build_entries(&all_contexts(), ®istry, true); // Action label is compact "send now" wording (interject under the hood). assert_cheatsheet_row_has_key(&entries, "send now", "Ctrl+i"); } #[test] fn build_entries_surfaces_queue_ctrl_apostrophe_fallback() { let registry = ActionRegistry::defaults(); let entries = build_entries(&all_contexts(), ®istry, true); assert_cheatsheet_row_has_key(&entries, "queue", "Ctrl+'"); } /// A section whose entries are all filtered out should have its /// header dropped, not rendered as a dead row. #[test] fn build_entries_vim_off_drops_empty_section_headers() { let registry = ActionRegistry::defaults(); let entries = build_entries(&all_contexts(), ®istry, false); for entry in &entries { if let ShortcutsHelpEntry::SectionHeader { entry_count, label, .. } = entry { assert!( *entry_count > 0, "section {label:?} has 0 entries — should have been dropped" ); } } } #[test] fn build_entries_sets_action_id_on_registry_hints() { let registry = ActionRegistry::defaults(); let entries = build_entries(&all_contexts(), ®istry, true); let shortcuts_id = entries.iter().find_map(|e| match e { ShortcutsHelpEntry::Hint { item, action_id: Some(id), .. } if item.description.as_deref() == Some("Keyboard shortcuts") => Some(*id), _ => None, }); assert_eq!( shortcuts_id, Some(ActionId::ShortcutsHelp), "registry-backed hints must carry their ActionId for expand/detail" ); // Registry rows carry ActionId; search + paste are display-only. let search_key = key!('/'); let paste_key = key!('v', CONTROL); for entry in &entries { let ShortcutsHelpEntry::Hint { item, action_id, .. } = entry else { continue; }; let is_pseudo = (item.label == "search" && item.keys.contains(&search_key)) || (item.label == "paste" && item.keys.contains(&paste_key)); if is_pseudo { assert!( action_id.is_none(), "pseudo-row {:?} must stay display-only", item.label ); } else { assert!( action_id.is_some(), "registry-backed hint {:?} lost its action_id", item.label ); } } } #[test] fn toggle_expand_outcome_for_hint_right_key() { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let registry = ActionRegistry::defaults(); let entries = build_entries(&all_contexts(), ®istry, true); let mut state = build_initial_picker_state(&entries); // Select first non-header row (Essentials section is first header at 0). state.selected = 1; let mut mode = browse_mode(); let key = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE); let out = handle_input( &key, &entries, &mut state, false, &no_collapsed(), &no_expanded(), &mut mode, ); assert!( matches!(out, ShortcutsHelpOutcome::ToggleExpand(_)), "Right on hint row should toggle inline expand, got {out:?}" ); let left = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE); let collapsed = handle_input( &left, &entries, &mut state, false, &no_collapsed(), &no_expanded(), &mut mode, ); assert!( !matches!(collapsed, ShortcutsHelpOutcome::ToggleExpand(_)), "Left on a collapsed hint must be inert, got {collapsed:?}" ); assert!(mode.is_browse(), "Left must not leave browse mode"); let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); let out_enter = handle_input( &enter, &entries, &mut state, false, &no_collapsed(), &no_expanded(), &mut mode, ); assert_eq!( out_enter, ShortcutsHelpOutcome::Changed, "Enter on a registry hint opens detail (pattern B), got {out_enter:?}" ); assert!(mode.is_detail(), "Enter switches mode to Detail directly"); } #[test] fn vim_h_collapses_only_expanded_action_hints() { use crate::actions::ActionId; let _vim_mode = VimModeGuard::set(true); let entries = vec![ header("Nav", 0, 1), hint_with_action("send", key!(Enter), ActionId::SendPrompt), ]; let mut state = build_initial_picker_state(&entries); state.selected = 1; let mut mode = browse_mode(); let collapsed = handle_input( &make_key(crossterm::event::KeyCode::Char('h')), &entries, &mut state, false, &no_collapsed(), &no_expanded(), &mut mode, ); assert_eq!( collapsed, ShortcutsHelpOutcome::Unchanged, "vim h on a collapsed action hint must be inert" ); assert!(state.query.is_empty(), "vim h must not enter search text"); let key_id = ExpandKey::Action(ActionId::SendPrompt); let expanded = std::collections::HashSet::from([key_id]); let collapse = handle_input( &make_key(crossterm::event::KeyCode::Char('h')), &entries, &mut state, false, &no_collapsed(), &expanded, &mut mode, ); assert_eq!( collapse, ShortcutsHelpOutcome::ToggleExpand(key_id), "vim h must collapse an expanded action hint" ); assert!(state.query.is_empty(), "vim h must not enter search text"); } #[test] fn search_pseudo_row_does_not_expand() { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let registry = ActionRegistry::defaults(); let entries = build_entries(&all_contexts(), ®istry, true); let search_idx = entries .iter() .position(|e| { matches!( e, ShortcutsHelpEntry::Hint { item, action_id: None, .. } if item.label == "search" ) }) .expect("vim-mode entries include the `/`-search pseudo-row"); for code in [KeyCode::Right, KeyCode::Char('e'), KeyCode::Char(' ')] { let mut state = build_initial_picker_state(&entries); state.selected = search_idx; let key = KeyEvent::new(code, KeyModifiers::NONE); let mut mode = ShortcutsHelpMode::Browse; let out = handle_input( &key, &entries, &mut state, false, &no_collapsed(), &no_expanded(), &mut mode, ); assert_eq!( out, ShortcutsHelpOutcome::Unchanged, "search pseudo-row must stay inert for {code:?}, got {out:?}" ); } } #[test] fn vim_l_expands_and_h_collapses_paste() { use crossterm::event::KeyCode; let _vim_mode = VimModeGuard::set(true); let registry = ActionRegistry::defaults(); let entries = build_entries(&all_contexts(), ®istry, true); let paste_idx = entries .iter() .position(|e| { matches!( e, ShortcutsHelpEntry::Hint { item, action_id: None, long_help: Some(_), .. } if item.label == "paste" ) }) .expect("paste pseudo-row with long_help"); let key_id = ExpandKey::Pseudo("paste"); assert_eq!(expand_key(&entries[paste_idx]), Some(key_id)); let mut state = build_initial_picker_state(&entries); state.selected = paste_idx; let mut mode = ShortcutsHelpMode::Browse; let expand = handle_input( &make_key(KeyCode::Char('l')), &entries, &mut state, false, &no_collapsed(), &no_expanded(), &mut mode, ); assert_eq!( expand, ShortcutsHelpOutcome::ToggleExpand(key_id), "vim l must expand the paste pseudo-row" ); assert!(state.query.is_empty(), "vim l must not enter search text"); let expanded = std::collections::HashSet::from([key_id]); let collapse = handle_input( &make_key(KeyCode::Char('h')), &entries, &mut state, false, &no_collapsed(), &expanded, &mut mode, ); assert_eq!( collapse, ShortcutsHelpOutcome::ToggleExpand(key_id), "vim h must collapse the expanded paste pseudo-row" ); assert!(state.query.is_empty(), "vim h must not enter search text"); } /// `handle_modal_key` (chrome + picker pipeline) maps the hint-row expand to /// `ModalKeyOutcome::ToggleExpand` so dashboards get identical semantics. #[test] fn handle_modal_key_maps_toggle_expand() { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let registry = ActionRegistry::defaults(); let entries = build_entries(&all_contexts(), ®istry, true); let mut state = build_initial_picker_state(&entries); state.selected = 1; let mut window = crate::views::modal_window::ModalWindowState::default(); let key = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE); let mut mode = ShortcutsHelpMode::Browse; let out = handle_modal_key( &key, &entries, &mut state, &mut window, false, &no_collapsed(), &no_expanded(), &mut mode, false, ); assert!( matches!(out, ModalKeyOutcome::ToggleExpand(_)), "Right on a hint row must map to ModalKeyOutcome::ToggleExpand, got {out:?}" ); } /// `handle_modal_key` forwards `expanded_ids` through the chrome pipeline so /// the dashboard host's Left-collapse works. A *populated* expanded set is /// required to exercise the wiring — the `→` test above passes regardless of /// the set, so it can't catch a dropped `expanded_ids` forward. #[test] fn handle_modal_key_left_collapses_expanded_hint() { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let registry = ActionRegistry::defaults(); let entries = build_entries(&all_contexts(), ®istry, true); let mut state = build_initial_picker_state(&entries); state.selected = 1; let key_id = expand_key(&entries[1]).expect("row 1 is expandable"); let expanded = std::collections::HashSet::from([key_id]); let mut window = crate::views::modal_window::ModalWindowState::default(); let key = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE); let mut mode = ShortcutsHelpMode::Browse; let out = handle_modal_key( &key, &entries, &mut state, &mut window, false, &no_collapsed(), &expanded, &mut mode, false, ); assert_eq!( out, ModalKeyOutcome::ToggleExpand(key_id), "Left on an expanded hint must map to ModalKeyOutcome::ToggleExpand (collapse), got {out:?}" ); } /// A row's `long_help` renders as an inline line only while its id is /// expanded, and is absent otherwise. #[test] fn render_modal_shows_long_help_only_when_expanded() { use crate::actions::ActionId; use ratatui::buffer::Buffer; use ratatui::layout::Rect; // long_help differs from label/description so the expanded line is detectable. let mut item = HintItem::new(key!('q', CONTROL), "quit"); item.description = Some("Quit the app".into()); let entries = vec![ ShortcutsHelpEntry::SectionHeader { label: "Essentials", category_idx: 0, entry_count: 1, }, ShortcutsHelpEntry::Hint { item, dimmed: false, action_id: Some(ActionId::Quit), long_help: Some("Zqxhelpline"), }, ]; let theme = crate::theme::Theme::current(); let area = Rect::new(0, 0, 100, 40); let render = |expanded: &std::collections::HashSet| -> String { let mut state = build_initial_picker_state(&entries); let mut window = crate::views::modal_window::ModalWindowState::default(); let mut buf = Buffer::empty(area); render_modal( &mut buf, area, &entries, &mut state, &mut window, false, &no_collapsed(), expanded, &ShortcutsHelpMode::Browse, &theme, false, ); let mut out = String::new(); for y in area.y..area.y + area.height { for x in area.x..area.x + area.width { if let Some(cell) = buf.cell((x, y)) { out.push_str(cell.symbol()); } } } out }; let mut expanded = std::collections::HashSet::new(); expanded.insert(ExpandKey::Action(ActionId::Quit)); assert!( render(&expanded).contains("Zqxhelpline"), "expanded hint must render its long_help line" ); assert!( !render(&std::collections::HashSet::new()).contains("Zqxhelpline"), "collapsed hint must not render the long_help line" ); } /// The collapsible (inline expand) view collapses newlines to spaces so the /// help renders as one wrap-flowed block with no hard breaks — unlike the /// detail page (Enter), which spaces paragraphs out with blank lines. #[test] fn cheatsheet_rows_inline_help_joins_newlines_with_spaces() { use crate::actions::ActionId; let mut item = HintItem::new(key!('q', CONTROL), "quit"); item.description = Some("Quit the app".into()); let entries = vec![ ShortcutsHelpEntry::SectionHeader { label: "Essentials", category_idx: 0, entry_count: 1, }, ShortcutsHelpEntry::Hint { item, dimmed: false, action_id: Some(ActionId::Quit), long_help: Some("First line.\nSecond line."), }, ]; let rows = CheatsheetRows::build(&entries, "", false, &no_collapsed()); let help = rows.help_refs(); assert_eq!( help[1], "First line. Second line.", "inline help must join newlines with spaces" ); assert!( !help[1].contains('\n'), "collapsible help must not contain newlines, got {:?}", help[1] ); } /// A hint with neither long_help nor description has empty inline help, so an /// expanded row must render no description line (no stray blank inline row). #[test] fn inline_expand_with_no_help_renders_no_description_line() { use crate::actions::ActionId; use crate::views::picker::PickerEntry; // HintItem::new leaves `description` unset; no long_help either. let item = HintItem::new(key!('q', CONTROL), "quit"); let entries = vec![ ShortcutsHelpEntry::SectionHeader { label: "Essentials", category_idx: 0, entry_count: 1, }, ShortcutsHelpEntry::Hint { item, dimmed: false, action_id: Some(ActionId::Quit), long_help: None, }, ]; let rows = CheatsheetRows::build(&entries, "", false, &no_collapsed()); let help = rows.help_refs(); assert_eq!( help[1], "", "a hint with no help source has empty inline help" ); let mut state = build_initial_picker_state(&entries); state.selected = 1; let expanded = std::collections::HashSet::from([ExpandKey::Action(ActionId::Quit)]); let picker_entries = rows.picker_entries(&state, &expanded, &help); let PickerEntry::Row(row) = &picker_entries[1] else { panic!("row 1 must be a hint row"); }; assert!(row.expanded, "row is expanded"); assert!( row.description_lines.is_empty(), "empty help must render no description line even when expanded" ); } }