diff --git a/crates/tui/src/tui/keybindings.rs b/crates/tui/src/tui/keybindings.rs new file mode 100644 index 00000000..e67cb4da --- /dev/null +++ b/crates/tui/src/tui/keybindings.rs @@ -0,0 +1,307 @@ +//! Documentation-only catalog of every user-facing keybinding. +//! +//! This module is the *single source of truth* for what shortcuts the help +//! overlay renders. The actual key handlers live in `tui/ui.rs` (and a few +//! sibling modules); they read keys directly off the crossterm event stream +//! and intentionally do **not** consult this catalog. The catalog exists so +//! that: +//! +//! 1. The help overlay (`tui/views/help.rs`) does not have to maintain a +//! parallel list that silently rots when a handler is added or moved. +//! 2. New contributors have one place to look when answering "which keys are +//! bound, and where do they go?" +//! +//! When you add or change a binding in `ui.rs`, **add or update the matching +//! entry here**. The compile-only side-effect of forgetting is a stale help +//! screen; there is no runtime crash, so the discipline lives in code review. +//! +//! Entries are grouped by `KeybindingSection`. The `chord` field is a +//! human-readable string formatted exactly the way it should appear in help — +//! we avoid storing `KeyBinding` values directly because many shortcuts are +//! pairs (`↑/↓`) or families (`Alt+1/2/3`) that don't map cleanly to a single +//! chord. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeybindingSection { + Navigation, + Editing, + Submission, + Modes, + Sessions, + Clipboard, + Help, +} + +impl KeybindingSection { + pub fn label(self) -> &'static str { + match self { + Self::Navigation => "Navigation", + Self::Editing => "Input editing", + Self::Submission => "Actions", + Self::Modes => "Modes", + Self::Sessions => "Sessions", + Self::Clipboard => "Clipboard", + Self::Help => "Help", + } + } + + /// Stable ordering for help rendering — matches the variant declaration + /// order; explicit so adding a section forces a deliberate placement. + pub fn rank(self) -> u8 { + match self { + Self::Navigation => 0, + Self::Editing => 1, + Self::Submission => 2, + Self::Modes => 3, + Self::Sessions => 4, + Self::Clipboard => 5, + Self::Help => 6, + } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct KeybindingEntry { + pub chord: &'static str, + pub description: &'static str, + pub section: KeybindingSection, +} + +/// Canonical list of keybindings shown in the help overlay. +/// +/// Strings are written in the same notation the existing help screen uses so +/// readers can cross-reference with documentation: `Ctrl+X`, `Alt+X`, +/// `Shift+X`, `↑/↓`, `PgUp/PgDn`, etc. Help renderers may apply per-platform +/// substitutions (e.g. `⌥` for Alt on macOS) at render time, but the catalog +/// itself stores the portable form. +pub const KEYBINDINGS: &[KeybindingEntry] = &[ + // --- Navigation --- + KeybindingEntry { + chord: "↑ / ↓", + description: "Scroll transcript or navigate input history", + section: KeybindingSection::Navigation, + }, + KeybindingEntry { + chord: "Ctrl+↑ / Ctrl+↓", + description: "Navigate input history", + section: KeybindingSection::Navigation, + }, + KeybindingEntry { + chord: "Alt+↑ / Alt+↓", + description: "Scroll transcript", + section: KeybindingSection::Navigation, + }, + KeybindingEntry { + chord: "PgUp / PgDn", + description: "Scroll transcript by page", + section: KeybindingSection::Navigation, + }, + KeybindingEntry { + chord: "Home / End", + description: "Jump to top / bottom of transcript", + section: KeybindingSection::Navigation, + }, + KeybindingEntry { + chord: "g / G", + description: "Jump to top / bottom (when input is empty)", + section: KeybindingSection::Navigation, + }, + KeybindingEntry { + chord: "[ / ]", + description: "Jump between tool output blocks", + section: KeybindingSection::Navigation, + }, + // --- Editing --- + KeybindingEntry { + chord: "← / →", + description: "Move cursor in composer", + section: KeybindingSection::Editing, + }, + KeybindingEntry { + chord: "Ctrl+A / Ctrl+E", + description: "Jump to start / end of line", + section: KeybindingSection::Editing, + }, + KeybindingEntry { + chord: "Backspace / Delete", + description: "Delete character before / after the cursor", + section: KeybindingSection::Editing, + }, + KeybindingEntry { + chord: "Ctrl+U", + description: "Clear the current draft", + section: KeybindingSection::Editing, + }, + KeybindingEntry { + chord: "Ctrl+J / Alt+Enter", + description: "Insert a newline in the composer", + section: KeybindingSection::Editing, + }, + // --- Submission / actions --- + KeybindingEntry { + chord: "Enter", + description: "Send the current draft", + section: KeybindingSection::Submission, + }, + KeybindingEntry { + chord: "Esc", + description: "Close menu, cancel request, discard draft, or clear input", + section: KeybindingSection::Submission, + }, + KeybindingEntry { + chord: "Ctrl+C", + description: "Cancel request, or exit when nothing is running", + section: KeybindingSection::Submission, + }, + KeybindingEntry { + chord: "Ctrl+D", + description: "Exit when input is empty", + section: KeybindingSection::Submission, + }, + KeybindingEntry { + chord: "Ctrl+K", + description: "Open the command palette", + section: KeybindingSection::Submission, + }, + KeybindingEntry { + chord: "Ctrl+P", + description: "Open the fuzzy file picker (insert @path on Enter)", + section: KeybindingSection::Submission, + }, + KeybindingEntry { + chord: "l", + description: "Open pager for the last message (when input is empty)", + section: KeybindingSection::Submission, + }, + KeybindingEntry { + chord: "v", + description: "Open details for the selected tool or message (when input is empty)", + section: KeybindingSection::Submission, + }, + KeybindingEntry { + chord: "Ctrl+O", + description: "Open thinking pager", + section: KeybindingSection::Submission, + }, + // --- Modes --- + KeybindingEntry { + chord: "Tab / Shift+Tab", + description: "Complete /command or cycle modes (Shift+Tab cycles reasoning effort)", + section: KeybindingSection::Modes, + }, + KeybindingEntry { + chord: "Alt+1 / Alt+2 / Alt+3", + description: "Jump directly to Plan / Agent / YOLO mode", + section: KeybindingSection::Modes, + }, + KeybindingEntry { + chord: "Alt+P / Alt+A / Alt+Y", + description: "Alternative jump to Plan / Agent / YOLO mode", + section: KeybindingSection::Modes, + }, + KeybindingEntry { + chord: "Alt+! / Alt+@ / Alt+# / Alt+$ / Alt+)", + description: "Focus Plan / Todos / Tasks / Agents / Auto sidebar", + section: KeybindingSection::Modes, + }, + KeybindingEntry { + chord: "Ctrl+X", + description: "Toggle between Plan and Agent modes", + section: KeybindingSection::Modes, + }, + // --- Sessions --- + KeybindingEntry { + chord: "Ctrl+R", + description: "Open the session picker", + section: KeybindingSection::Sessions, + }, + // --- Clipboard --- + KeybindingEntry { + chord: "Ctrl+V", + description: "Paste text or attach a clipboard image", + section: KeybindingSection::Clipboard, + }, + KeybindingEntry { + chord: "Ctrl+Shift+C", + description: "Copy the current selection (Cmd+C on macOS)", + section: KeybindingSection::Clipboard, + }, + KeybindingEntry { + chord: "@path", + description: "Add a local text file or directory to context", + section: KeybindingSection::Clipboard, + }, + // --- Help --- + KeybindingEntry { + chord: "?", + description: "Open this help overlay (when input is empty)", + section: KeybindingSection::Help, + }, + KeybindingEntry { + chord: "F1", + description: "Toggle help overlay", + section: KeybindingSection::Help, + }, + KeybindingEntry { + chord: "Ctrl+/", + description: "Toggle help overlay", + section: KeybindingSection::Help, + }, +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_is_non_empty_and_sections_have_entries() { + assert!(!KEYBINDINGS.is_empty()); + // Every declared section should appear in the catalog at least once, + // otherwise the help overlay would render an empty heading. + let sections = [ + KeybindingSection::Navigation, + KeybindingSection::Editing, + KeybindingSection::Submission, + KeybindingSection::Modes, + KeybindingSection::Sessions, + KeybindingSection::Clipboard, + KeybindingSection::Help, + ]; + for section in sections { + assert!( + KEYBINDINGS.iter().any(|entry| entry.section == section), + "no entries for section {:?}", + section + ); + } + } + + #[test] + fn help_section_documents_question_mark() { + // The whole point of #93 is that `?` opens this overlay; if the entry + // ever disappears the user-facing discoverability promise breaks. + assert!( + KEYBINDINGS + .iter() + .any(|entry| entry.chord.contains('?') && entry.section == KeybindingSection::Help), + "`?` must remain documented as the help-toggle chord" + ); + } + + #[test] + fn section_rank_is_a_total_order() { + let sections = [ + KeybindingSection::Navigation, + KeybindingSection::Editing, + KeybindingSection::Submission, + KeybindingSection::Modes, + KeybindingSection::Sessions, + KeybindingSection::Clipboard, + KeybindingSection::Help, + ]; + let mut ranks: Vec = sections.iter().map(|s| s.rank()).collect(); + ranks.sort_unstable(); + ranks.dedup(); + assert_eq!(ranks.len(), sections.len(), "ranks must be unique"); + } +} diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index fc6855db..d6efe66a 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -14,6 +14,7 @@ pub mod file_mention; pub mod file_picker; pub mod frame_rate_limiter; pub mod history; +pub mod keybindings; pub mod markdown_render; pub mod model_picker; pub mod onboarding; diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 1df3248f..8e70df74 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1237,8 +1237,7 @@ async fn run_event_loop( if app.view_stack.top_kind() == Some(ModalKind::Help) { app.view_stack.pop(); } else { - app.view_stack - .push(HelpView::new_for_workspace(app.workspace.clone())); + app.view_stack.push(HelpView::new()); } continue; } @@ -1247,8 +1246,7 @@ async fn run_event_loop( if app.view_stack.top_kind() == Some(ModalKind::Help) { app.view_stack.pop(); } else { - app.view_stack - .push(HelpView::new_for_workspace(app.workspace.clone())); + app.view_stack.push(HelpView::new()); } continue; } @@ -1606,6 +1604,21 @@ async fn run_event_loop( { app.status_message = Some("No next tool output".to_string()); } + // `?` opens the searchable help overlay (#93). Gated on the + // composer being empty so typing `?` mid-question is treated + // as text. `Shift` is permitted because US layouts produce + // `?` as `Shift+/`. Help-modal toggling lives next to the + // F1 / Ctrl+/ branch above; here we only open. + KeyCode::Char('?') + if (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT) + && app.input.is_empty() + && !slash_menu_open => + { + if app.view_stack.top_kind() != Some(ModalKind::Help) { + app.view_stack.push(HelpView::new()); + } + continue; + } // Input handling KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => { app.insert_char('\n'); diff --git a/crates/tui/src/tui/views/help.rs b/crates/tui/src/tui/views/help.rs new file mode 100644 index 00000000..1693440d --- /dev/null +++ b/crates/tui/src/tui/views/help.rs @@ -0,0 +1,601 @@ +//! Searchable help overlay for `?`, `F1`, and `Ctrl+/`. +//! +//! Renders two stacked sections — *Slash commands* and *Keybindings* — with +//! a live substring filter applied as the user types in the search box. The +//! command list is sourced from [`crate::commands::COMMANDS`] and the +//! keybinding list from [`crate::tui::keybindings::KEYBINDINGS`] so neither +//! can drift from the wired-up handlers. +//! +//! Keys: any printable character extends the filter, `Backspace` shrinks it, +//! `↑`/`↓` (or `Ctrl+P`/`Ctrl+N`) move the selection, `PgUp`/`PgDn` jump by +//! ten rows, `Home`/`End` jump to ends, and `Esc` closes. Pressing `?` again +//! at the call-site (`tui::ui`) also toggles the overlay closed. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Padding, Paragraph, Widget}, +}; +use unicode_width::UnicodeWidthStr; + +use crate::commands; +use crate::palette; +use crate::tui::keybindings::KEYBINDINGS; +use crate::tui::views::{ModalKind, ModalView, ViewAction}; + +/// Two top-level sections rendered in the overlay. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HelpSection { + Command, + Keybinding, +} + +impl HelpSection { + fn label(self) -> &'static str { + match self { + Self::Command => "Slash commands", + Self::Keybinding => "Keybindings", + } + } + + /// Sort key — commands before keybindings keeps the most-used surface up + /// top so an unfiltered overlay opens with the user's likely target in + /// view without scrolling. + fn rank(self) -> u8 { + match self { + Self::Command => 0, + Self::Keybinding => 1, + } + } +} + +#[derive(Debug, Clone)] +struct HelpEntry { + section: HelpSection, + /// Sort-within-section key — keybinding entries reuse their declared + /// section's rank so the help overlay groups Navigation, Editing, … in + /// the same order as `tui::keybindings`. + sub_rank: u8, + label: String, + description: String, + /// Lowercased haystack used for substring matching; pre-built so each + /// keystroke does not re-allocate per entry. + haystack: String, +} + +pub struct HelpView { + entries: Vec, + /// Indices into `entries`, in display order, after filtering. + filtered: Vec, + query: String, + selected: usize, +} + +impl Default for HelpView { + fn default() -> Self { + Self::new() + } +} + +impl HelpView { + pub fn new() -> Self { + let entries = build_entries(); + let mut view = Self { + entries, + filtered: Vec::new(), + query: String::new(), + selected: 0, + }; + view.refilter(); + view + } + + fn refilter(&mut self) { + // Substring matching is intentional — fuzzy matchers can hide the + // exact-prefix hit a user is typing toward, which is the wrong + // failure mode for a *help* surface. We split on whitespace so + // multi-term queries (`apply mode`) act as an AND. + let query = self.query.trim().to_ascii_lowercase(); + let terms: Vec<&str> = query + .split_whitespace() + .filter(|term| !term.is_empty()) + .collect(); + + let mut filtered: Vec = self + .entries + .iter() + .enumerate() + .filter(|(_, entry)| terms.iter().all(|term| entry.haystack.contains(term))) + .map(|(idx, _)| idx) + .collect(); + + filtered.sort_by_key(|idx| { + let entry = &self.entries[*idx]; + (entry.section.rank(), entry.sub_rank, entry.label.clone()) + }); + self.filtered = filtered; + if self.selected >= self.filtered.len() { + self.selected = self.filtered.len().saturating_sub(1); + } + } + + fn move_selection(&mut self, delta: isize) { + if self.filtered.is_empty() { + self.selected = 0; + return; + } + let len = self.filtered.len() as isize; + let next = (self.selected as isize + delta).clamp(0, len - 1) as usize; + self.selected = next; + } +} + +fn build_entries() -> Vec { + let mut entries = Vec::new(); + + for command in commands::COMMANDS { + let label = format!("/{}", command.name); + let description = if command.aliases.is_empty() { + command.description.to_string() + } else { + format!( + "{} (aliases: {})", + command.description, + command + .aliases + .iter() + .map(|a| format!("/{a}")) + .collect::>() + .join(", ") + ) + }; + let haystack = format!( + "{} {} {}", + label.to_ascii_lowercase(), + description.to_ascii_lowercase(), + command.usage.to_ascii_lowercase() + ); + entries.push(HelpEntry { + section: HelpSection::Command, + // Commands have no inherent ordering — fall back to alphabetical + // by leaning on `label.clone()` in the final sort_by_key tuple. + sub_rank: 0, + label, + description, + haystack, + }); + } + + for binding in KEYBINDINGS { + let label = binding.chord.to_string(); + let description = format!("[{}] {}", binding.section.label(), binding.description); + let haystack = format!( + "{} {}", + label.to_ascii_lowercase(), + description.to_ascii_lowercase() + ); + entries.push(HelpEntry { + section: HelpSection::Keybinding, + sub_rank: binding.section.rank(), + label, + description, + haystack, + }); + } + + entries +} + +fn modal_block() -> Block<'static> { + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(palette::BORDER_COLOR)) + .style(Style::default().bg(palette::DEEPSEEK_INK)) + .padding(Padding::uniform(1)) +} + +fn truncate_to_width(text: &str, max_width: usize) -> String { + if max_width == 0 { + return String::new(); + } + if text.width() <= max_width { + return text.to_string(); + } + let mut out = String::new(); + let limit = max_width.saturating_sub(1); + for ch in text.chars() { + let next_width = out.width() + ch.to_string().width(); + if next_width > limit { + break; + } + out.push(ch); + } + out.push('…'); + out +} + +impl ModalView for HelpView { + fn kind(&self) -> ModalKind { + ModalKind::Help + } + + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { + match key.code { + KeyCode::Esc => ViewAction::Close, + KeyCode::Up => { + self.move_selection(-1); + ViewAction::None + } + KeyCode::Down => { + self.move_selection(1); + ViewAction::None + } + KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.move_selection(-1); + ViewAction::None + } + KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.move_selection(1); + ViewAction::None + } + KeyCode::PageUp => { + self.move_selection(-10); + ViewAction::None + } + KeyCode::PageDown => { + self.move_selection(10); + ViewAction::None + } + KeyCode::Home => { + self.selected = 0; + ViewAction::None + } + KeyCode::End => { + if !self.filtered.is_empty() { + self.selected = self.filtered.len() - 1; + } + ViewAction::None + } + KeyCode::Backspace => { + self.query.pop(); + self.refilter(); + ViewAction::None + } + KeyCode::Char(c) + if !c.is_control() + && (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT) => + { + self.query.push(c); + self.refilter(); + ViewAction::None + } + _ => ViewAction::None, + } + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + let popup_width = 90.min(area.width.saturating_sub(4)); + let popup_height = 28.min(area.height.saturating_sub(4)); + let popup_area = Rect { + x: area.width.saturating_sub(popup_width) / 2, + y: area.height.saturating_sub(popup_height) / 2, + width: popup_width, + height: popup_height, + }; + + Clear.render(popup_area, buf); + + let mut lines: Vec> = Vec::new(); + + let query_label = if self.query.is_empty() { + "Type to filter".to_string() + } else { + format!("Filter: {}", self.query) + }; + lines.push(Line::from(Span::styled( + query_label, + Style::default() + .fg(palette::DEEPSEEK_SKY) + .add_modifier(Modifier::BOLD), + ))); + + let match_count = if self.query.is_empty() { + format!("{} entries", self.entries.len()) + } else { + format!("{} / {} matches", self.filtered.len(), self.entries.len()) + }; + lines.push(Line::from(Span::styled( + match_count, + Style::default() + .fg(palette::TEXT_DIM) + .add_modifier(Modifier::ITALIC), + ))); + lines.push(Line::from("")); + + if self.filtered.is_empty() { + lines.push(Line::from(Span::styled( + " No matches.", + Style::default() + .fg(palette::TEXT_MUTED) + .add_modifier(Modifier::ITALIC), + ))); + } else { + // The chord/label column takes up to 28 cols on wide screens; + // descriptions fill the remainder. Borders and padding eat 4 + // cells from each side (border 1 + padding 1) × 2. + let inner_width = popup_width.saturating_sub(4) as usize; + let label_width = 28.min(inner_width.saturating_sub(8)); + let desc_capacity = inner_width.saturating_sub(label_width + 4); + + // Visible window: header (3) + footer hint (handled by block); + // budget the remaining rows for entries and inserted section + // headings. Section headings can push us past the budget on tiny + // terminals — we still render them because losing the heading is + // worse than losing one trailing row of entries. + let header_lines = lines.len(); + let visible_budget = (popup_height as usize) + .saturating_sub(header_lines + 3) + .max(1); + + // Centre the selected row in the visible window when it is far + // down, otherwise keep the natural top-aligned listing. + let scroll = self + .selected + .saturating_sub(visible_budget.saturating_sub(1)); + let mut active_section: Option = None; + let mut rendered_rows = 0usize; + + for (slot, idx) in self.filtered.iter().enumerate() { + if slot < scroll { + continue; + } + if rendered_rows >= visible_budget { + break; + } + + let entry = &self.entries[*idx]; + if active_section != Some(entry.section) { + if rendered_rows > 0 { + lines.push(Line::from("")); + rendered_rows += 1; + } + let count = self + .filtered + .iter() + .filter(|idx| self.entries[**idx].section == entry.section) + .count(); + lines.push(Line::from(Span::styled( + format!(" {} ({})", entry.section.label(), count), + Style::default() + .fg(palette::DEEPSEEK_BLUE) + .add_modifier(Modifier::BOLD), + ))); + rendered_rows += 1; + active_section = Some(entry.section); + if rendered_rows >= visible_budget { + break; + } + } + + let is_selected = slot == self.selected; + let style = if is_selected { + Style::default() + .fg(palette::SELECTION_TEXT) + .bg(palette::SELECTION_BG) + } else { + Style::default().fg(palette::TEXT_PRIMARY) + }; + let cursor = if is_selected { "▶ " } else { " " }; + let label = truncate_to_width(&entry.label, label_width); + let desc = truncate_to_width(&entry.description, desc_capacity); + let line_text = format!("{cursor}{label: KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + fn type_filter(view: &mut HelpView, text: &str) { + for ch in text.chars() { + view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + } + + #[test] + fn empty_filter_lists_all_entries() { + let view = HelpView::new(); + // Total = registered slash commands + catalogued keybindings. + let expected = commands::COMMANDS.len() + KEYBINDINGS.len(); + assert_eq!(view.filtered.len(), expected); + assert_eq!(view.entries.len(), expected); + } + + #[test] + fn substring_filter_narrows_to_command() { + let mut view = HelpView::new(); + type_filter(&mut view, "yolo"); + assert!(!view.filtered.is_empty()); + // Every filtered entry should genuinely contain the query in its + // searchable haystack — no false positives slipped past. + for idx in &view.filtered { + assert!( + view.entries[*idx].haystack.contains("yolo"), + "entry {:?} leaked through `yolo` filter", + view.entries[*idx] + ); + } + // The `/yolo` command must survive the filter; it's the canonical + // single-term match. + assert!( + view.filtered + .iter() + .any(|idx| view.entries[*idx].label == "/yolo"), + "/yolo should match the `yolo` filter" + ); + } + + #[test] + fn substring_filter_finds_keybinding_by_chord() { + let mut view = HelpView::new(); + type_filter(&mut view, "ctrl+r"); + assert!(!view.filtered.is_empty(), "Ctrl+R should match"); + assert!( + view.filtered + .iter() + .any(|idx| view.entries[*idx].label.eq_ignore_ascii_case("ctrl+r")), + "Ctrl+R chord must surface in the filtered set" + ); + } + + #[test] + fn multiple_terms_act_as_and() { + let mut view = HelpView::new(); + type_filter(&mut view, "session picker"); + assert!( + !view.filtered.is_empty(), + "expected at least one entry mentioning both `session` and `picker`" + ); + for idx in &view.filtered { + let haystack = &view.entries[*idx].haystack; + assert!( + haystack.contains("session") && haystack.contains("picker"), + "entry {:?} leaked through `session picker` AND filter", + view.entries[*idx] + ); + } + } + + #[test] + fn unknown_filter_yields_empty_set() { + let mut view = HelpView::new(); + type_filter(&mut view, "zzzqqxxnope"); + assert!(view.filtered.is_empty()); + assert_eq!(view.selected, 0); + } + + #[test] + fn backspace_widens_match_set() { + let mut view = HelpView::new(); + type_filter(&mut view, "yolox"); + let narrow = view.filtered.len(); + view.handle_key(key(KeyCode::Backspace)); + let wider = view.filtered.len(); + assert!( + wider > narrow, + "backspace must broaden the matching set (was {narrow}, now {wider})" + ); + } + + #[test] + fn esc_closes_overlay() { + let mut view = HelpView::new(); + let action = view.handle_key(key(KeyCode::Esc)); + assert!(matches!(action, ViewAction::Close)); + } + + #[test] + fn arrow_keys_move_selection_within_bounds() { + let mut view = HelpView::new(); + // Down once → row 1; Up twice → clamped at 0. + view.handle_key(key(KeyCode::Down)); + assert_eq!(view.selected, 1); + view.handle_key(key(KeyCode::Up)); + view.handle_key(key(KeyCode::Up)); + assert_eq!(view.selected, 0); + // End → last row. + view.handle_key(key(KeyCode::End)); + assert_eq!(view.selected, view.filtered.len() - 1); + } + + #[test] + fn render_includes_help_chrome_for_empty_filter() { + let view = HelpView::new(); + let area = Rect::new(0, 0, 96, 32); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + + let dump = buffer_text(&buf, area); + // Title border + section headings should always render. + assert!(dump.contains("Help"), "missing help title:\n{dump}"); + assert!( + dump.contains("Type to filter"), + "missing filter prompt:\n{dump}" + ); + assert!( + dump.contains("Slash commands"), + "missing slash-command section heading:\n{dump}" + ); + // Footer hint should advertise close key on the bottom border. + assert!( + dump.contains("Esc close"), + "missing Esc close footer hint:\n{dump}" + ); + } + + #[test] + fn render_with_filter_shows_only_matching_section_and_status() { + let mut view = HelpView::new(); + type_filter(&mut view, "yolo"); + let area = Rect::new(0, 0, 96, 24); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + + let dump = buffer_text(&buf, area); + assert!( + dump.contains("Filter: yolo"), + "filter echo missing:\n{dump}" + ); + assert!( + dump.contains("matches"), + "match counter missing in dump:\n{dump}" + ); + assert!( + dump.contains("/yolo"), + "expected /yolo command in filtered render:\n{dump}" + ); + assert!( + !dump.contains("/agent"), + "non-matching commands should not render under a `yolo` filter:\n{dump}" + ); + } + + fn buffer_text(buf: &Buffer, area: Rect) -> String { + let mut out = String::new(); + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + out + } +} diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index d30abfac..b5794610 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -2,15 +2,11 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{buffer::Buffer, layout::Rect}; use std::cell::Cell; use std::fmt; -use std::path::{Path, PathBuf}; use crate::palette; use crate::settings::Settings; use crate::tools::UserInputResponse; -use crate::tools::spec::ApprovalRequirement; -use crate::tools::spec::ToolCapability; use crate::tools::subagent::{SubAgentResult, SubAgentStatus, SubAgentType}; -use crate::tools::{ToolContext, ToolRegistryBuilder}; use crate::tui::app::App; use crate::tui::approval::{ElevationOption, ReviewDecision}; @@ -207,55 +203,6 @@ impl fmt::Debug for ViewStack { } } -const HELP_COMMAND_SECTION_ORDER: [&str; 7] = [ - "Core", - "Modes", - "Session", - "Configuration", - "Workflows", - "Planning", - "Debug", -]; - -fn help_command_section(name: &str) -> &'static str { - match name { - "help" | "clear" | "exit" | "model" | "models" | "home" | "links" => "Core", - "agent" | "yolo" | "plan" | "trust" | "logout" => "Modes", - "save" | "sessions" | "load" | "export" | "compact" | "queue" => "Session", - "config" | "settings" => "Configuration", - "task" | "skills" | "skill" | "subagents" | "review" => "Workflows", - "note" | "cost" | "context" | "system" | "undo" | "retry" => "Planning", - "init" => "Debug", - _ => "Debug", - } -} - -fn grouped_commands( - commands: &'static [crate::commands::CommandInfo], -) -> Vec<(&'static str, Vec<&'static crate::commands::CommandInfo>)> { - let mut grouped: Vec<(&'static str, Vec<&crate::commands::CommandInfo>)> = - HELP_COMMAND_SECTION_ORDER - .iter() - .map(|section| (*section, Vec::new())) - .collect(); - - for command in commands { - let section = help_command_section(command.name); - let Some((_, entries)) = grouped - .iter_mut() - .find(|(entry_section, _)| *entry_section == section) - else { - continue; - }; - entries.push(command); - } - - grouped - .into_iter() - .filter(|(_, entries)| !entries.is_empty()) - .collect() -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ConfigScope { Session, @@ -844,429 +791,9 @@ impl ModalView for ConfigView { } } -pub struct HelpView { - scroll: usize, - tool_sections: Vec<(String, Vec)>, -} +pub mod help; -impl HelpView { - pub fn new() -> Self { - Self::new_for_workspace(PathBuf::from(".")) - } - - pub fn new_for_workspace(workspace: PathBuf) -> Self { - Self { - scroll: 0, - tool_sections: build_help_tool_sections(&workspace), - } - } -} - -fn build_help_tool_sections(workspace: &Path) -> Vec<(String, Vec)> { - let context = ToolContext::new(workspace.to_path_buf()); - let registry = ToolRegistryBuilder::new() - .with_file_tools() - .with_search_tools() - .with_shell_tools() - .with_web_tools() - .with_git_tools() - .with_user_input_tool() - .with_parallel_tool() - .with_patch_tools() - .with_note_tool() - .with_diagnostics_tool() - .with_project_tools() - .with_test_runner_tool() - .build(context); - - let mut auto = Vec::new(); - let mut suggest = Vec::new(); - let mut required = Vec::new(); - - for tool in registry.all() { - let mut tags = Vec::new(); - let capabilities = tool.capabilities(); - if capabilities.contains(&ToolCapability::ReadOnly) { - tags.push("read-only"); - } - if capabilities.contains(&ToolCapability::WritesFiles) { - tags.push("writes"); - } - if capabilities.contains(&ToolCapability::ExecutesCode) { - tags.push("shell"); - } - if capabilities.contains(&ToolCapability::Network) { - tags.push("network"); - } - if tool.supports_parallel() { - tags.push("parallel"); - } - - let mut description = tool.description().to_string(); - if !tags.is_empty() { - description.push_str(" ["); - description.push_str(&tags.join(", ")); - description.push(']'); - } - - let name = tool.name(); - if name.trim().is_empty() { - continue; - } - - let line = if description.is_empty() { - format!(" {}", name) - } else { - format!(" {:<15} - {}", name, description) - }; - - match tool.approval_requirement() { - ApprovalRequirement::Required => required.push(line), - ApprovalRequirement::Suggest => suggest.push(line), - ApprovalRequirement::Auto => auto.push(line), - } - } - - auto.sort_unstable(); - suggest.sort_unstable(); - required.sort_unstable(); - - let mut sections = Vec::new(); - if !auto.is_empty() { - sections.push(("Tools (auto/low risk)".to_string(), auto)); - } - if !suggest.is_empty() { - sections.push(("Tools (suggest approval)".to_string(), suggest)); - } - if !required.is_empty() { - sections.push(("Tools (requires approval)".to_string(), required)); - } - - sections -} - -impl ModalView for HelpView { - fn kind(&self) -> ModalKind { - ModalKind::Help - } - - fn handle_key(&mut self, key: KeyEvent) -> ViewAction { - use crossterm::event::KeyCode; - - match key.code { - KeyCode::Esc | KeyCode::Char('q') | KeyCode::Enter => ViewAction::Close, - KeyCode::Up | KeyCode::Char('k') => { - self.scroll = self.scroll.saturating_sub(1); - ViewAction::None - } - KeyCode::Down | KeyCode::Char('j') => { - self.scroll = self.scroll.saturating_add(1); - ViewAction::None - } - _ => ViewAction::None, - } - } - - fn render(&self, area: Rect, buf: &mut Buffer) { - use ratatui::{ - prelude::Stylize, - style::Style, - text::{Line, Span}, - widgets::{Block, Borders, Clear, Padding, Paragraph, Widget}, - }; - - let popup_width = 70.min(area.width.saturating_sub(4)); - let popup_height = 28.min(area.height.saturating_sub(4)); - - let popup_area = Rect { - x: (area.width - popup_width) / 2, - y: (area.height - popup_height) / 2, - width: popup_width, - height: popup_height, - }; - - Clear.render(popup_area, buf); - - // Render keybinding hints through `key_hint::KeyBinding` so they pick - // up the host-platform notation (`⌥` on macOS, `alt+X` on Linux / - // Windows). See `crates/tui/src/tui/widgets/key_hint.rs`. - use crate::tui::widgets::key_hint::{alt, ctrl, plain, shift}; - let kb = |b: crate::tui::widgets::key_hint::KeyBinding| b.to_string(); - - let row = |label: String, desc: &str| -> Line<'static> { - Line::from(format!(" {:<22} - {}", label, desc)) - }; - - let mut help_lines: Vec = vec![ - Line::from(vec![Span::styled( - "DeepSeek TUI Help", - Style::default().fg(palette::DEEPSEEK_BLUE).bold(), - )]), - Line::from(""), - Line::from(vec![Span::styled( - "=== Navigation ===", - Style::default().fg(palette::DEEPSEEK_SKY).bold(), - )]), - row( - format!("{} / {}", kb(plain(KeyCode::Up)), kb(plain(KeyCode::Down))), - "Scroll transcript (or navigate history)", - ), - row( - format!("{} / {}", kb(ctrl(KeyCode::Up)), kb(ctrl(KeyCode::Down))), - "Navigate input history", - ), - row( - format!("{} / {}", kb(alt(KeyCode::Up)), kb(alt(KeyCode::Down))), - "Scroll transcript", - ), - row( - format!( - "{} / {}", - kb(plain(KeyCode::PageUp)), - kb(plain(KeyCode::PageDown)) - ), - "Scroll transcript by page", - ), - row( - format!("{} / {}", kb(plain(KeyCode::Home)), kb(plain(KeyCode::End))), - "Jump to top / bottom of transcript", - ), - row( - format!("{} / {}", kb(plain(KeyCode::Char('g'))), "G"), - "Jump to top / bottom (when input empty)", - ), - row("[ / ]".to_string(), "Jump between tool output blocks"), - Line::from(""), - Line::from(vec![Span::styled( - "=== Input Editing ===", - Style::default().fg(palette::DEEPSEEK_SKY).bold(), - )]), - row( - format!( - "{} / {}", - kb(plain(KeyCode::Left)), - kb(plain(KeyCode::Right)) - ), - "Move cursor", - ), - row( - format!( - "{} / {}", - kb(ctrl(KeyCode::Char('a'))), - kb(ctrl(KeyCode::Char('e'))) - ), - "Jump to start / end of line", - ), - row( - format!( - "{} / {}", - kb(plain(KeyCode::Backspace)), - kb(plain(KeyCode::Delete)) - ), - "Delete character before / after cursor", - ), - row(kb(ctrl(KeyCode::Char('u'))), "Clear the current draft"), - Line::from(""), - Line::from(vec![Span::styled( - "=== Multi-line Input ===", - Style::default().fg(palette::DEEPSEEK_SKY).bold(), - )]), - row( - format!( - "{} / {}", - kb(ctrl(KeyCode::Char('j'))), - kb(alt(KeyCode::Enter)) - ), - "Insert a new line in the composer", - ), - Line::from(""), - Line::from(vec![Span::styled( - "=== Actions ===", - Style::default().fg(palette::DEEPSEEK_SKY).bold(), - )]), - row(kb(plain(KeyCode::Enter)), "Send the current draft"), - row( - kb(plain(KeyCode::Esc)), - "Close menu, cancel request, discard draft, or clear input", - ), - row( - kb(ctrl(KeyCode::Char('c'))), - "Cancel request or exit application", - ), - row(kb(ctrl(KeyCode::Char('d'))), "Exit when input is empty"), - row(kb(ctrl(KeyCode::Char('k'))), "Open command palette"), - row( - kb(plain(KeyCode::Char('l'))), - "Open pager for last message (when input empty)", - ), - row( - kb(plain(KeyCode::Char('v'))), - "Open details for the selected tool or message", - ), - row( - format!("{} (selection)", kb(plain(KeyCode::Enter))), - "Open pager for selected text", - ), - Line::from(""), - Line::from(vec![Span::styled( - "=== Modes ===", - Style::default().fg(palette::DEEPSEEK_SKY).bold(), - )]), - row( - format!("{} / {}", kb(plain(KeyCode::Tab)), kb(shift(KeyCode::Tab))), - "Complete /command or cycle modes", - ), - row( - format!("{}/2/3", kb(alt(KeyCode::Char('1')))), - "Directly jump to Plan/Agent/YOLO", - ), - row( - format!("{}/A/Y", kb(alt(KeyCode::Char('p')))), - "Alternative jump to Plan/Agent/YOLO", - ), - row( - format!("{}/@/#/$/)", kb(alt(KeyCode::Char('!')))), - "Focus Plan/Todos/Tasks/Agents/Auto sidebar", - ), - row("/agent /yolo /plan".to_string(), "Set mode directly"), - row( - kb(ctrl(KeyCode::Char('x'))), - "Toggle between Plan and Agent modes", - ), - Line::from(""), - Line::from(vec![Span::styled( - "=== Sessions ===", - Style::default().fg(palette::DEEPSEEK_SKY).bold(), - )]), - row(kb(ctrl(KeyCode::Char('r'))), "Open session picker"), - Line::from(""), - Line::from(vec![Span::styled( - "=== Clipboard ===", - Style::default().fg(palette::DEEPSEEK_SKY).bold(), - )]), - row( - kb(ctrl(KeyCode::Char('v'))), - "Paste text or attach clipboard image", - ), - row( - kb(crate::tui::widgets::key_hint::KeyBinding::new( - KeyCode::Char('c'), - crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::SHIFT, - )), - "Copy selection (Cmd+C on macOS)", - ), - row( - "@path".to_string(), - "Add local text file or directory context", - ), - row( - "/attach ".to_string(), - "Attach local image/video media path", - ), - Line::from(""), - Line::from(vec![Span::styled( - "=== Help ===", - Style::default().fg(palette::DEEPSEEK_SKY).bold(), - )]), - row( - format!( - "{} / {}", - kb(plain(KeyCode::F(1))), - kb(ctrl(KeyCode::Char('/'))) - ), - "Toggle this help view", - ), - Line::from(""), - Line::from(vec![Span::styled( - "=== Mouse ===", - Style::default().fg(palette::DEEPSEEK_SKY).bold(), - )]), - Line::from(" Native drag - Select/copy visible text in your terminal"), - Line::from(" --no-alt-screen - Use terminal scrollback"), - Line::from(" --mouse-capture - Enable wheel + internal selection"), - Line::from(""), - Line::from(vec![Span::styled( - "Mode Cycle:", - Style::default().fg(palette::DEEPSEEK_SKY).bold(), - )]), - Line::from(" Plan → Agent → YOLO (Tab), reverse with Shift+Tab"), - Line::from(""), - Line::from(vec![Span::styled( - "Commands:", - Style::default().fg(palette::DEEPSEEK_SKY).bold(), - )]), - ]; - - let grouped = grouped_commands(crate::commands::COMMANDS); - for (section, commands) in grouped { - if !commands.is_empty() { - help_lines.push(Line::from(Span::styled( - format!(" [{}]", section), - Style::default().fg(palette::DEEPSEEK_BLUE).bold(), - ))); - for cmd in commands { - help_lines.push(Line::from(format!( - " /{:<11} - {}", - cmd.name, cmd.description - ))); - } - help_lines.push(Line::from("")); - } - } - - help_lines.push(Line::from("")); - help_lines.push(Line::from(Span::styled( - "Tools:", - Style::default().fg(palette::DEEPSEEK_SKY).bold(), - ))); - if self.tool_sections.is_empty() { - help_lines.push(Line::from(" Tool registry unavailable")); - } else { - for (section, tools) in &self.tool_sections { - help_lines.push(Line::from(Span::styled( - format!(" {}", section), - Style::default().fg(palette::DEEPSEEK_BLUE).bold(), - ))); - for tool in tools { - help_lines.push(Line::from(tool.as_str())); - } - help_lines.push(Line::from("")); - } - } - help_lines.push(Line::from("")); - - let total_lines = help_lines.len(); - let visible_lines = (popup_height as usize).saturating_sub(3); - let max_scroll = total_lines.saturating_sub(visible_lines); - let scroll = self.scroll.min(max_scroll); - - let scroll_indicator = if total_lines > visible_lines { - format!(" [{}/{} ↑↓] ", scroll + 1, max_scroll + 1) - } else { - String::new() - }; - - let help = Paragraph::new(help_lines) - .block( - Block::default() - .title(Line::from(vec![Span::styled( - " Help ", - Style::default().fg(palette::DEEPSEEK_BLUE).bold(), - )])) - .title_bottom(Line::from(vec![ - Span::styled(" Esc to close ", Style::default().fg(palette::TEXT_MUTED)), - Span::styled(scroll_indicator, Style::default().fg(palette::DEEPSEEK_SKY)), - ])) - .borders(Borders::ALL) - .border_style(Style::default().fg(palette::BORDER_COLOR)) - .style(Style::default().bg(palette::DEEPSEEK_INK)) - .padding(Padding::uniform(1)), - ) - .scroll((scroll as u16, 0)); - - help.render(popup_area, buf); - } -} +pub use help::HelpView; pub struct SubAgentsView { agents: Vec,