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:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,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>,
|
||||
|
||||
Reference in New Issue
Block a user