Files
Kigi-CLI/crates/codegen/kigi-tui/src/views/shortcuts_help.rs
T
ZacharyZhang-NY a02b555e66 docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment
guidelines: delete redundant restatements, decorative banners, change
narration, and end-of-line comments; keep and tighten the crucial ones
(invariants, bug rationale, SAFETY blocks, ported-source attribution).

No functional code changed. Every edit is proven comment-only against the
prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a
separate doctest-fence check. Where removing a comment made rustfmt or clippy
want to re-lay-out adjacent code, the minimal triggering comment is restored so
code tokens stay byte-identical.

Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy
--workspace --all-targets (0 warnings).

Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for
these guidelines (flags banners, end-of-line comments, change narration, and
commented-out code).
2026-07-23 16:55:39 -04:00

3505 lines
125 KiB
Rust

//! 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<ActionId>,
/// 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<usize> {
(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<ShortcutsHelpEntry> {
let mut entries: Vec<ShortcutsHelpEntry> = 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<KeyShortcut> =
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<KeyShortcut, usize> =
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<usize>,
) -> Vec<usize> {
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<usize> = Vec::new();
let mut pending_header: Option<usize> = 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::<Vec<_>>()
.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::<Vec<_>>()
.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<bool> {
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<ShortcutsHelpMode> {
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::<Vec<_>>()
.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<ShortcutsHelpMode> {
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<crate::views::modal_window::Shortcut<'static>> {
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<Line<'a>> = 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<Line<'static>> = 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<ExpandKey> {
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<crate::actions::ActionId> {
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<T: Eq + std::hash::Hash>(
set: &mut std::collections::HashSet<T>,
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<usize>,
expanded_ids: &std::collections::HashSet<ExpandKey>,
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<bool> = 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<bool> = 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<usize>,
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<bool> = 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<crate::views::modal_window::Shortcut<'static>> {
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<ExpandKey>,
},
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<String>,
kinds: Vec<CheatsheetRowKind>,
}
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<usize>,
) -> 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<ExpandKey>,
help: &'a [&'a str],
) -> Vec<crate::views::picker::PickerEntry<'a>> {
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<usize>,
expanded_ids: &std::collections::HashSet<ExpandKey>,
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<bool> = 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<usize>,
expanded_ids: &std::collections::HashSet<ExpandKey>,
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<usize> {
std::collections::HashSet::new()
}
fn no_expanded() -> std::collections::HashSet<ExpandKey> {
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], &registry, 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<When> {
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(), &registry, 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(), &registry, 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(), &registry, 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(), &registry, true);
let mut current_cat_keys: std::collections::HashSet<KeyShortcut> =
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(), &registry, 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(), &registry, 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(), &registry, 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(), &registry, 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<bool> {
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],
&registry,
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],
&registry,
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],
&registry,
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, &registry, 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, &registry, 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<bool> {
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], &registry, 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],
&registry,
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<ActionId> {
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<KeyShortcut> {
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], &registry, 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],
&registry,
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<ShortcutsHelpEntry>, 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(), &registry, 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<String> = (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::<String>()
.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(), &registry, 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(), &registry, 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<ShortcutsHelpEntry> = 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<ShortcutsHelpEntry> = 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<ShortcutsHelpEntry> = 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(), &registry, 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::<Vec<_>>()
);
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(), &registry, 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(), &registry, true);
let nav_keys: Vec<String> = 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<String> = 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(), &registry, 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(), &registry, 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(), &registry, 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(), &registry, 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(), &registry, 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(), &registry, 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(), &registry, 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(), &registry, 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(), &registry, 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<ExpandKey>| -> 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"
);
}
}