feat(tui): #93 help overlay — ? opens searchable command + keybinding reference

New HelpView modal lists all slash commands with descriptions and all
keybindings, with a live substring filter. Bound to `?` when focus is
outside the composer; Esc / `?` toggles.

Slash commands pull from the existing slash_menu registry; keybindings
pull from a new KeybindingCatalog single-source-of-truth so docs can't
drift from the wired handlers.
This commit is contained in:
Hunter Bown
2026-04-27 21:54:25 -05:00
parent 4ac7219d77
commit b759e3f74c
5 changed files with 928 additions and 479 deletions
+307
View File
@@ -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<u8> = sections.iter().map(|s| s.rank()).collect();
ranks.sort_unstable();
ranks.dedup();
assert_eq!(ranks.len(), sections.len(), "ranks must be unique");
}
}
+1
View File
@@ -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;
+17 -4
View File
@@ -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');
+601
View File
@@ -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<HelpEntry>,
/// Indices into `entries`, in display order, after filtering.
filtered: Vec<usize>,
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<usize> = 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<HelpEntry> {
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::<Vec<_>>()
.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<Line<'static>> = 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<HelpSection> = 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:<label_width$} {desc}", label = label,);
lines.push(Line::from(Span::styled(line_text, style)));
rendered_rows += 1;
}
}
let block = modal_block()
.title(Line::from(vec![Span::styled(
" Help ",
Style::default()
.fg(palette::DEEPSEEK_BLUE)
.add_modifier(Modifier::BOLD),
)]))
.title_bottom(Line::from(vec![
Span::styled(" type to filter ", Style::default().fg(palette::TEXT_MUTED)),
Span::styled(" ↑/↓ move ", Style::default().fg(palette::TEXT_MUTED)),
Span::styled(" PgUp/PgDn jump ", Style::default().fg(palette::TEXT_MUTED)),
Span::styled(" Esc close ", Style::default().fg(palette::TEXT_MUTED)),
]));
Paragraph::new(lines).block(block).render(popup_area, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn key(code: KeyCode) -> 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
}
}
+2 -475
View File
@@ -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<String>)>,
}
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<String>)> {
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<Line> = 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 <path>".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<SubAgentResult>,