返回 DeepSeek-TUI-2026
help.rs
根目录 / crates / tui / src / tui / views / help.rs
1 //! Searchable help overlay for `?`, `F1`, and `Ctrl+/`.
2 //!
3 //! Renders two stacked sections — *Slash commands* and *Keybindings* — with
4 //! a live substring filter applied as the user types in the search box. The
5 //! command list is sourced from [`crate::commands::COMMANDS`] and the
6 //! keybinding list from [`crate::tui::keybindings::KEYBINDINGS`] so neither
7 //! can drift from the wired-up handlers.
8 //!
9 //! Keys: any printable character extends the filter, `Backspace` shrinks it,
10 //! `↑`/`↓` (or `Ctrl+P`/`Ctrl+N`) move the selection, `PgUp`/`PgDn` jump by
11 //! ten rows, `Home`/`End` jump to ends, and `Esc` closes. Pressing `?` again
12 //! at the call-site (`tui::ui`) also toggles the overlay closed.
13
14 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
15 use ratatui::{
16 buffer::Buffer,
17 layout::Rect,
18 style::{Modifier, Style},
19 text::{Line, Span},
20 widgets::{Block, Borders, Clear, Padding, Paragraph, Widget},
21 };
22 use unicode_width::UnicodeWidthStr;
23
24 use crate::commands;
25 use crate::localization::{Locale, MessageId, tr};
26 use crate::palette;
27 use crate::tui::keybindings::KEYBINDINGS;
28 use crate::tui::views::{ModalKind, ModalView, ViewAction};
29
30 /// Two top-level sections rendered in the overlay.
31 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
32 enum HelpSection {
33 Command,
34 Keybinding,
35 }
36
37 impl HelpSection {
38 fn label(self, locale: Locale) -> &'static str {
39 match self {
40 Self::Command => tr(locale, MessageId::HelpSlashCommands),
41 Self::Keybinding => tr(locale, MessageId::HelpKeybindings),
42 }
43 }
44
45 /// Sort key — commands before keybindings keeps the most-used surface up
46 /// top so an unfiltered overlay opens with the user's likely target in
47 /// view without scrolling.
48 fn rank(self) -> u8 {
49 match self {
50 Self::Command => 0,
51 Self::Keybinding => 1,
52 }
53 }
54 }
55
56 #[derive(Debug, Clone)]
57 struct HelpEntry {
58 section: HelpSection,
59 /// Sort-within-section key — keybinding entries reuse their declared
60 /// section's rank so the help overlay groups Navigation, Editing, … in
61 /// the same order as `tui::keybindings`.
62 sub_rank: u8,
63 label: String,
64 description: String,
65 /// Lowercased haystack used for substring matching; pre-built so each
66 /// keystroke does not re-allocate per entry.
67 haystack: String,
68 }
69
70 pub struct HelpView {
71 locale: Locale,
72 entries: Vec<HelpEntry>,
73 /// Indices into `entries`, in display order, after filtering.
74 filtered: Vec<usize>,
75 query: String,
76 selected: usize,
77 }
78
79 impl Default for HelpView {
80 fn default() -> Self {
81 Self::new()
82 }
83 }
84
85 impl HelpView {
86 pub fn new() -> Self {
87 Self::new_for_locale(Locale::En)
88 }
89
90 pub fn new_for_locale(locale: Locale) -> Self {
91 let entries = build_entries(locale);
92 let mut view = Self {
93 locale,
94 entries,
95 filtered: Vec::new(),
96 query: String::new(),
97 selected: 0,
98 };
99 view.refilter();
100 view
101 }
102
103 fn tr(&self, id: MessageId) -> &'static str {
104 tr(self.locale, id)
105 }
106
107 fn refilter(&mut self) {
108 // Substring matching is intentional — fuzzy matchers can hide the
109 // exact-prefix hit a user is typing toward, which is the wrong
110 // failure mode for a *help* surface. We split on whitespace so
111 // multi-term queries (`apply mode`) act as an AND.
112 let query = self.query.trim().to_ascii_lowercase();
113 let terms: Vec<&str> = query
114 .split_whitespace()
115 .filter(|term| !term.is_empty())
116 .collect();
117
118 let mut filtered: Vec<usize> = self
119 .entries
120 .iter()
121 .enumerate()
122 .filter(|(_, entry)| terms.iter().all(|term| entry.haystack.contains(term)))
123 .map(|(idx, _)| idx)
124 .collect();
125
126 filtered.sort_by_key(|idx| {
127 let entry = &self.entries[*idx];
128 (entry.section.rank(), entry.sub_rank, entry.label.clone())
129 });
130 self.filtered = filtered;
131 if self.selected >= self.filtered.len() {
132 self.selected = self.filtered.len().saturating_sub(1);
133 }
134 }
135
136 fn move_selection(&mut self, delta: isize) {
137 if self.filtered.is_empty() {
138 self.selected = 0;
139 return;
140 }
141 let len = self.filtered.len() as isize;
142 let next = (self.selected as isize + delta).clamp(0, len - 1) as usize;
143 self.selected = next;
144 }
145 }
146
147 fn build_entries(locale: Locale) -> Vec<HelpEntry> {
148 let mut entries = Vec::new();
149
150 for command in commands::COMMANDS {
151 let label = format!("/{}", command.name);
152 let localized = command.description_for(locale);
153 let description = if command.aliases.is_empty() {
154 localized.to_string()
155 } else {
156 format!(
157 "{} (aliases: {})",
158 localized,
159 command
160 .aliases
161 .iter()
162 .map(|a| format!("/{a}"))
163 .collect::<Vec<_>>()
164 .join(", ")
165 )
166 };
167 let haystack = format!(
168 "{} {} {}",
169 label.to_ascii_lowercase(),
170 description.to_ascii_lowercase(),
171 command.usage.to_ascii_lowercase()
172 );
173 entries.push(HelpEntry {
174 section: HelpSection::Command,
175 // Commands have no inherent ordering — fall back to alphabetical
176 // by leaning on `label.clone()` in the final sort_by_key tuple.
177 sub_rank: 0,
178 label,
179 description,
180 haystack,
181 });
182 }
183
184 for binding in KEYBINDINGS {
185 let label = binding.chord.to_string();
186 let description = format!(
187 "[{}] {}",
188 binding.section.label(locale),
189 tr(locale, binding.description_id)
190 );
191 let haystack = format!(
192 "{} {}",
193 label.to_ascii_lowercase(),
194 description.to_ascii_lowercase()
195 );
196 entries.push(HelpEntry {
197 section: HelpSection::Keybinding,
198 sub_rank: binding.section.rank(),
199 label,
200 description,
201 haystack,
202 });
203 }
204
205 entries
206 }
207
208 fn modal_block() -> Block<'static> {
209 Block::default()
210 .borders(Borders::ALL)
211 .border_style(Style::default().fg(palette::BORDER_COLOR))
212 .style(Style::default().bg(palette::DEEPSEEK_INK))
213 .padding(Padding::uniform(1))
214 }
215
216 fn truncate_to_width(text: &str, max_width: usize) -> String {
217 if max_width == 0 {
218 return String::new();
219 }
220 if text.width() <= max_width {
221 return text.to_string();
222 }
223 let mut out = String::new();
224 let limit = max_width.saturating_sub(1);
225 for ch in text.chars() {
226 let next_width = out.width() + ch.to_string().width();
227 if next_width > limit {
228 break;
229 }
230 out.push(ch);
231 }
232 out.push('…');
233 out
234 }
235
236 impl ModalView for HelpView {
237 fn kind(&self) -> ModalKind {
238 ModalKind::Help
239 }
240
241 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
242 self
243 }
244
245 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
246 match key.code {
247 KeyCode::Esc => ViewAction::Close,
248 KeyCode::Up => {
249 self.move_selection(-1);
250 ViewAction::None
251 }
252 KeyCode::Down => {
253 self.move_selection(1);
254 ViewAction::None
255 }
256 KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
257 self.move_selection(-1);
258 ViewAction::None
259 }
260 KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
261 self.move_selection(1);
262 ViewAction::None
263 }
264 KeyCode::PageUp => {
265 self.move_selection(-10);
266 ViewAction::None
267 }
268 KeyCode::PageDown => {
269 self.move_selection(10);
270 ViewAction::None
271 }
272 KeyCode::Home => {
273 self.selected = 0;
274 ViewAction::None
275 }
276 KeyCode::End => {
277 if !self.filtered.is_empty() {
278 self.selected = self.filtered.len() - 1;
279 }
280 ViewAction::None
281 }
282 KeyCode::Backspace => {
283 self.query.pop();
284 self.refilter();
285 ViewAction::None
286 }
287 KeyCode::Char(c)
288 if !c.is_control()
289 && (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT) =>
290 {
291 self.query.push(c);
292 self.refilter();
293 ViewAction::None
294 }
295 _ => ViewAction::None,
296 }
297 }
298
299 fn render(&self, area: Rect, buf: &mut Buffer) {
300 let popup_width = 90.min(area.width.saturating_sub(4));
301 let popup_height = 28.min(area.height.saturating_sub(4));
302 let popup_area = Rect {
303 x: area.width.saturating_sub(popup_width) / 2,
304 y: area.height.saturating_sub(popup_height) / 2,
305 width: popup_width,
306 height: popup_height,
307 };
308
309 Clear.render(popup_area, buf);
310
311 let mut lines: Vec<Line<'static>> = Vec::new();
312
313 let query_label = if self.query.is_empty() {
314 self.tr(MessageId::HelpFilterPlaceholder).to_string()
315 } else {
316 format!("{}{}", self.tr(MessageId::HelpFilterPrefix), self.query)
317 };
318 lines.push(Line::from(Span::styled(
319 query_label,
320 Style::default()
321 .fg(palette::DEEPSEEK_SKY)
322 .add_modifier(Modifier::BOLD),
323 )));
324
325 let match_count = if self.query.is_empty() {
326 format!("{} entries", self.entries.len())
327 } else {
328 format!("{} / {} matches", self.filtered.len(), self.entries.len())
329 };
330 lines.push(Line::from(Span::styled(
331 match_count,
332 Style::default()
333 .fg(palette::TEXT_DIM)
334 .add_modifier(Modifier::ITALIC),
335 )));
336 lines.push(Line::from(""));
337
338 if self.filtered.is_empty() {
339 lines.push(Line::from(Span::styled(
340 self.tr(MessageId::HelpNoMatches),
341 Style::default()
342 .fg(palette::TEXT_MUTED)
343 .add_modifier(Modifier::ITALIC),
344 )));
345 } else {
346 // The chord/label column takes up to 28 cols on wide screens;
347 // descriptions fill the remainder. Borders and padding eat 4
348 // cells from each side (border 1 + padding 1) × 2.
349 let inner_width = popup_width.saturating_sub(4) as usize;
350 let label_width = 28.min(inner_width.saturating_sub(8));
351 let desc_capacity = inner_width.saturating_sub(label_width + 4);
352
353 // Visible window: header (3) + footer hint (handled by block);
354 // budget the remaining rows for entries and inserted section
355 // headings. Section headings can push us past the budget on tiny
356 // terminals — we still render them because losing the heading is
357 // worse than losing one trailing row of entries.
358 let header_lines = lines.len();
359 let visible_budget = (popup_height as usize)
360 .saturating_sub(header_lines + 3)
361 .max(1);
362
363 // Centre the selected row in the visible window when it is far
364 // down, otherwise keep the natural top-aligned listing.
365 let scroll = self
366 .selected
367 .saturating_sub(visible_budget.saturating_sub(1));
368 let mut active_section: Option<HelpSection> = None;
369 let mut rendered_rows = 0usize;
370
371 for (slot, idx) in self.filtered.iter().enumerate() {
372 if slot < scroll {
373 continue;
374 }
375 if rendered_rows >= visible_budget {
376 break;
377 }
378
379 let entry = &self.entries[*idx];
380 if active_section != Some(entry.section) {
381 if rendered_rows > 0 {
382 lines.push(Line::from(""));
383 rendered_rows += 1;
384 }
385 let count = self
386 .filtered
387 .iter()
388 .filter(|idx| self.entries[**idx].section == entry.section)
389 .count();
390 lines.push(Line::from(Span::styled(
391 format!(" {} ({})", entry.section.label(self.locale), count),
392 Style::default()
393 .fg(palette::DEEPSEEK_BLUE)
394 .add_modifier(Modifier::BOLD),
395 )));
396 rendered_rows += 1;
397 active_section = Some(entry.section);
398 if rendered_rows >= visible_budget {
399 break;
400 }
401 }
402
403 let is_selected = slot == self.selected;
404 let style = if is_selected {
405 Style::default()
406 .fg(palette::SELECTION_TEXT)
407 .bg(palette::SELECTION_BG)
408 } else {
409 Style::default().fg(palette::TEXT_PRIMARY)
410 };
411 let cursor = if is_selected { "▶ " } else { " " };
412 let label = truncate_to_width(&entry.label, label_width);
413 let desc = truncate_to_width(&entry.description, desc_capacity);
414 let line_text = format!("{cursor}{label:<label_width$} {desc}", label = label,);
415 lines.push(Line::from(Span::styled(line_text, style)));
416 rendered_rows += 1;
417 }
418 }
419
420 let block = modal_block()
421 .title(Line::from(vec![Span::styled(
422 format!(" {} ", self.tr(MessageId::HelpTitle)),
423 Style::default()
424 .fg(palette::DEEPSEEK_BLUE)
425 .add_modifier(Modifier::BOLD),
426 )]))
427 .title_bottom(Line::from(vec![
428 Span::styled(
429 self.tr(MessageId::HelpFooterTypeFilter),
430 Style::default().fg(palette::TEXT_MUTED),
431 ),
432 Span::styled(
433 self.tr(MessageId::HelpFooterMove),
434 Style::default().fg(palette::TEXT_MUTED),
435 ),
436 Span::styled(
437 self.tr(MessageId::HelpFooterJump),
438 Style::default().fg(palette::TEXT_MUTED),
439 ),
440 Span::styled(
441 self.tr(MessageId::HelpFooterClose),
442 Style::default().fg(palette::TEXT_MUTED),
443 ),
444 ]));
445
446 Paragraph::new(lines).block(block).render(popup_area, buf);
447 }
448 }
449
450 #[cfg(test)]
451 mod tests {
452 use super::*;
453 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
454
455 fn key(code: KeyCode) -> KeyEvent {
456 KeyEvent::new(code, KeyModifiers::NONE)
457 }
458
459 fn type_filter(view: &mut HelpView, text: &str) {
460 for ch in text.chars() {
461 view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
462 }
463 }
464
465 #[test]
466 fn empty_filter_lists_all_entries() {
467 let view = HelpView::new();
468 // Total = registered slash commands + catalogued keybindings.
469 let expected = commands::COMMANDS.len() + KEYBINDINGS.len();
470 assert_eq!(view.filtered.len(), expected);
471 assert_eq!(view.entries.len(), expected);
472 }
473
474 #[test]
475 fn substring_filter_narrows_to_command() {
476 let mut view = HelpView::new();
477 type_filter(&mut view, "yolo");
478 assert!(!view.filtered.is_empty());
479 // Every filtered entry should genuinely contain the query in its
480 // searchable haystack — no false positives slipped past.
481 for idx in &view.filtered {
482 assert!(
483 view.entries[*idx].haystack.contains("yolo"),
484 "entry {:?} leaked through `yolo` filter",
485 view.entries[*idx]
486 );
487 }
488 // The `/yolo` command must survive the filter; it's the canonical
489 // single-term match.
490 assert!(
491 view.filtered
492 .iter()
493 .any(|idx| view.entries[*idx].label == "/yolo"),
494 "/yolo should match the `yolo` filter"
495 );
496 }
497
498 #[test]
499 fn substring_filter_finds_keybinding_by_chord() {
500 let mut view = HelpView::new();
501 type_filter(&mut view, "ctrl+r");
502 assert!(!view.filtered.is_empty(), "Ctrl+R should match");
503 assert!(
504 view.filtered
505 .iter()
506 .any(|idx| view.entries[*idx].label.eq_ignore_ascii_case("ctrl+r")),
507 "Ctrl+R chord must surface in the filtered set"
508 );
509 }
510
511 #[test]
512 fn multiple_terms_act_as_and() {
513 let mut view = HelpView::new();
514 type_filter(&mut view, "session picker");
515 assert!(
516 !view.filtered.is_empty(),
517 "expected at least one entry mentioning both `session` and `picker`"
518 );
519 for idx in &view.filtered {
520 let haystack = &view.entries[*idx].haystack;
521 assert!(
522 haystack.contains("session") && haystack.contains("picker"),
523 "entry {:?} leaked through `session picker` AND filter",
524 view.entries[*idx]
525 );
526 }
527 }
528
529 #[test]
530 fn unknown_filter_yields_empty_set() {
531 let mut view = HelpView::new();
532 type_filter(&mut view, "zzzqqxxnope");
533 assert!(view.filtered.is_empty());
534 assert_eq!(view.selected, 0);
535 }
536
537 #[test]
538 fn backspace_widens_match_set() {
539 let mut view = HelpView::new();
540 type_filter(&mut view, "yolox");
541 let narrow = view.filtered.len();
542 view.handle_key(key(KeyCode::Backspace));
543 let wider = view.filtered.len();
544 assert!(
545 wider > narrow,
546 "backspace must broaden the matching set (was {narrow}, now {wider})"
547 );
548 }
549
550 #[test]
551 fn esc_closes_overlay() {
552 let mut view = HelpView::new();
553 let action = view.handle_key(key(KeyCode::Esc));
554 assert!(matches!(action, ViewAction::Close));
555 }
556
557 #[test]
558 fn arrow_keys_move_selection_within_bounds() {
559 let mut view = HelpView::new();
560 // Down once → row 1; Up twice → clamped at 0.
561 view.handle_key(key(KeyCode::Down));
562 assert_eq!(view.selected, 1);
563 view.handle_key(key(KeyCode::Up));
564 view.handle_key(key(KeyCode::Up));
565 assert_eq!(view.selected, 0);
566 // End → last row.
567 view.handle_key(key(KeyCode::End));
568 assert_eq!(view.selected, view.filtered.len() - 1);
569 }
570
571 #[test]
572 fn render_includes_help_chrome_for_empty_filter() {
573 let view = HelpView::new();
574 let area = Rect::new(0, 0, 96, 32);
575 let mut buf = Buffer::empty(area);
576 view.render(area, &mut buf);
577
578 let dump = buffer_text(&buf, area);
579 // Title border + section headings should always render.
580 assert!(dump.contains("Help"), "missing help title:\n{dump}");
581 assert!(
582 dump.contains("Type to filter"),
583 "missing filter prompt:\n{dump}"
584 );
585 assert!(
586 dump.contains("Slash commands"),
587 "missing slash-command section heading:\n{dump}"
588 );
589 // Footer hint should advertise close key on the bottom border.
590 assert!(
591 dump.contains("Esc close"),
592 "missing Esc close footer hint:\n{dump}"
593 );
594 }
595
596 #[test]
597 fn render_with_filter_shows_only_matching_section_and_status() {
598 let mut view = HelpView::new();
599 type_filter(&mut view, "yolo");
600 let area = Rect::new(0, 0, 96, 24);
601 let mut buf = Buffer::empty(area);
602 view.render(area, &mut buf);
603
604 let dump = buffer_text(&buf, area);
605 assert!(
606 dump.contains("Filter: yolo"),
607 "filter echo missing:\n{dump}"
608 );
609 assert!(
610 dump.contains("matches"),
611 "match counter missing in dump:\n{dump}"
612 );
613 assert!(
614 dump.contains("/yolo"),
615 "expected /yolo command in filtered render:\n{dump}"
616 );
617 assert!(
618 !dump.contains("/agent"),
619 "non-matching commands should not render under a `yolo` filter:\n{dump}"
620 );
621 }
622
623 #[test]
624 fn localized_help_chrome_renders_without_missing_markers() {
625 let view = HelpView::new_for_locale(Locale::ZhHans);
626 let area = Rect::new(0, 0, 48, 18);
627 let mut buf = Buffer::empty(area);
628 view.render(area, &mut buf);
629
630 let dump = buffer_text(&buf, area);
631 assert!(
632 dump.contains('帮') && dump.contains('助'),
633 "missing localized title:\n{dump}"
634 );
635 assert!(
636 !dump.contains("MISSING"),
637 "missing-key marker leaked:\n{dump}"
638 );
639 }
640
641 #[test]
642 fn localized_help_keybinding_descriptions_use_zh_hans() {
643 let entries = build_entries(Locale::ZhHans);
644 let kb_entries: Vec<_> = entries
645 .iter()
646 .filter(|e| e.section == HelpSection::Keybinding)
647 .collect();
648 assert!(!kb_entries.is_empty(), "no keybinding entries found");
649
650 for entry in &kb_entries {
651 assert!(
652 entry
653 .description
654 .chars()
655 .any(|c| { ('\u{4e00}'..='\u{9fff}').contains(&c) }),
656 "keybinding description not localized: {}",
657 entry.description
658 );
659 }
660 }
661
662 fn buffer_text(buf: &Buffer, area: Rect) -> String {
663 let mut out = String::new();
664 for y in area.top()..area.bottom() {
665 for x in area.left()..area.right() {
666 out.push_str(buf[(x, y)].symbol());
667 }
668 out.push('\n');
669 }
670 out
671 }
672 }
673
673 lines RUST