1use serde::{Deserialize, Serialize};
2use std::collections::HashSet;
3
4const BUILTIN_HELP_JSON: &str = include_str!("../assets/help.json");
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
7pub enum HelpTopicKind {
8 Branch,
9 #[default]
10 Command,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct HelpExample {
15 pub command: String,
16 pub description: String,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct HelpEntry {
21 pub id: String,
22 pub command: String,
23 pub title: String,
24 pub summary: String,
25 #[serde(default = "default_plugin_category")]
26 pub category: String,
27 #[serde(default)]
28 pub topic: HelpTopicKind,
29 #[serde(default)]
30 pub protected: bool,
31 #[serde(default)]
32 pub common: bool,
33 #[serde(default)]
34 pub aliases: Vec<String>,
35 #[serde(default)]
36 pub keywords: Vec<String>,
37 #[serde(default)]
38 pub lines: Vec<String>,
39 #[serde(default)]
40 pub usage: Option<String>,
41 #[serde(default)]
42 pub examples: Vec<HelpExample>,
43 #[serde(default)]
44 pub related: Vec<String>,
45 #[serde(default)]
46 pub source: Option<String>,
47}
48
49#[derive(Debug, Clone)]
50pub struct HelpRegistry {
51 entries: Vec<HelpEntry>,
52}
53
54#[derive(Debug, Clone)]
55pub struct HelpFindState {
56 entries: Vec<HelpEntry>,
57 filter: String,
58 cursor: usize,
59 scroll: usize,
60 visible_height: usize,
61 detail_idx: Option<usize>,
62 recently_opened: Vec<String>,
63 help_commands_only: bool,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum HelpFindRow<'a> {
68 Category(&'a str),
69 Entry(&'a HelpEntry),
70}
71
72impl<'a> HelpFindRow<'a> {
73 pub fn category(&self) -> Option<&'a str> {
74 match self {
75 Self::Category(category) => Some(category),
76 Self::Entry(_) => None,
77 }
78 }
79
80 pub fn entry(&self) -> Option<&'a HelpEntry> {
81 match self {
82 Self::Category(_) => None,
83 Self::Entry(entry) => Some(entry),
84 }
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct HighlightSegment {
90 pub text: String,
91 pub matched: bool,
92}
93
94impl HelpFindState {
95 pub fn new(entries: Vec<HelpEntry>, query: &str) -> Self {
96 Self::new_with_scope(entries, query, false)
97 }
98
99 pub fn new_help_commands(entries: Vec<HelpEntry>, query: &str) -> Self {
100 Self::new_with_scope(entries, query, true)
101 }
102
103 fn new_with_scope(entries: Vec<HelpEntry>, query: &str, help_commands_only: bool) -> Self {
104 let mut state = Self {
105 entries,
106 filter: query.trim().to_string(),
107 cursor: 0,
108 scroll: 0,
109 visible_height: 10,
110 detail_idx: None,
111 recently_opened: Vec::new(),
112 help_commands_only,
113 };
114 state.reset_position();
115 state
116 }
117
118 pub fn filter(&self) -> &str {
119 &self.filter
120 }
121
122 pub fn cursor(&self) -> usize {
123 self.cursor
124 }
125
126 pub fn result_cursor(&self) -> usize {
127 self.filtered_rows()
128 .iter()
129 .take(self.cursor + 1)
130 .filter(|row| row.entry().is_some())
131 .count()
132 .saturating_sub(1)
133 }
134
135 pub fn scroll(&self) -> usize {
136 self.scroll
137 }
138
139 pub fn set_visible_height(&mut self, height: usize) {
140 self.visible_height = height.max(1);
141 self.scroll_to_cursor();
142 }
143
144 pub fn filtered_entries(&self) -> Vec<&HelpEntry> {
145 ranked_entries_with_mru(&self.entries, &self.filter, &self.recently_opened)
146 .into_iter()
147 .filter(|entry| !self.help_commands_only || is_help_command(entry))
148 .collect()
149 }
150
151 pub fn filtered_rows(&self) -> Vec<HelpFindRow<'_>> {
152 let entries = self.filtered_entries();
153 if !self.filter.trim().is_empty() {
154 return entries.into_iter().map(HelpFindRow::Entry).collect();
155 }
156
157 let mut category_names: Vec<&str> = entries
158 .iter()
159 .map(|entry| display_category(entry))
160 .collect();
161 category_names.sort_by(|a, b| {
162 category_sort_key(a)
163 .cmp(&category_sort_key(b))
164 .then_with(|| {
165 category_best_score(&entries, b).cmp(&category_best_score(&entries, a))
166 })
167 .then_with(|| a.cmp(b))
168 });
169 category_names.dedup();
170
171 let mut rows = Vec::new();
172 for category in category_names {
173 rows.push(HelpFindRow::Category(category));
174 let mut category_entries = entries
175 .iter()
176 .copied()
177 .filter(|entry| display_category(entry) == category)
178 .collect::<Vec<_>>();
179 category_entries.sort_by(|a, b| {
180 help_parent_sort_key(a)
181 .cmp(&help_parent_sort_key(b))
182 .then_with(|| a.command.cmp(&b.command))
183 });
184 rows.extend(category_entries.into_iter().map(HelpFindRow::Entry));
185 }
186 rows
187 }
188
189 pub fn no_results_message(&self) -> String {
190 let query = self.filter.trim();
191 if query.is_empty() {
192 "No help topics available. Try: model, settings, plugins, sessions, doctor".to_string()
193 } else {
194 format!(
195 "No help matches for '{}'. Try: model, settings, plugins, sessions, doctor",
196 query
197 )
198 }
199 }
200
201 pub fn selected(&self) -> Option<&HelpEntry> {
202 self.filtered_rows()
203 .get(self.cursor)
204 .and_then(HelpFindRow::entry)
205 }
206
207 pub fn open_selected(&mut self) {
208 let selected_command = self.selected().map(|entry| entry.command.clone());
209 if let Some(command) = selected_command.as_ref() {
210 self.remember_opened(command);
211 }
212 self.detail_idx = selected_command.and_then(|command| {
213 self.entries
214 .iter()
215 .position(|entry| entry.command == command)
216 });
217 }
218
219 pub fn close_detail(&mut self) {
220 self.detail_idx = None;
221 }
222
223 pub fn detail_entry(&self) -> Option<&HelpEntry> {
224 self.detail_idx.and_then(|idx| self.entries.get(idx))
225 }
226
227 pub fn move_down(&mut self) {
228 let rows = self.filtered_rows();
229 if rows.is_empty() {
230 self.cursor = 0;
231 self.scroll = 0;
232 return;
233 }
234 let mut next = (self.cursor + 1).min(rows.len() - 1);
235 while next < rows.len() && rows[next].entry().is_none() {
236 if next == rows.len() - 1 {
237 break;
238 }
239 next += 1;
240 }
241 if rows[next].entry().is_some() {
242 self.cursor = next;
243 }
244 self.scroll_to_cursor();
245 }
246
247 pub fn move_up(&mut self) {
248 let rows = self.filtered_rows();
249 if rows.is_empty() {
250 self.cursor = 0;
251 self.scroll = 0;
252 return;
253 }
254 let mut next = self.cursor.saturating_sub(1);
255 while next > 0 && rows[next].entry().is_none() {
256 next = next.saturating_sub(1);
257 }
258 if rows[next].entry().is_some() {
259 self.cursor = next;
260 }
261 self.scroll_to_cursor();
262 }
263
264 pub fn push_char(&mut self, ch: char) {
265 self.filter.push(ch);
266 self.reset_position();
267 }
268
269 pub fn backspace(&mut self) {
270 self.filter.pop();
271 self.reset_position();
272 }
273
274 pub fn clear_filter(&mut self) {
275 self.filter.clear();
276 self.reset_position();
277 }
278
279 fn reset_position(&mut self) {
280 self.cursor = self.first_entry_row_index();
281 self.scroll = 0;
282 }
283
284 fn first_entry_row_index(&self) -> usize {
285 self.filtered_rows()
286 .iter()
287 .position(|row| row.entry().is_some())
288 .unwrap_or(0)
289 }
290
291 fn remember_opened(&mut self, command: &str) {
292 self.recently_opened.retain(|existing| existing != command);
293 self.recently_opened.insert(0, command.to_string());
294 self.recently_opened.truncate(10);
295 }
296
297 fn scroll_to_cursor(&mut self) {
298 if self.cursor < self.scroll {
299 self.scroll = self.cursor;
300 }
301 let bottom = self.scroll + self.visible_height;
302 if self.cursor >= bottom {
303 self.scroll = self.cursor + 1 - self.visible_height;
304 }
305 let len = self.filtered_rows().len();
306 if self.cursor >= len {
307 self.cursor = self.first_entry_row_index();
308 self.scroll = 0;
309 }
310 }
311}
312
313impl HelpRegistry {
314 pub fn new(core_entries: Vec<HelpEntry>, plugin_entries: Vec<HelpEntry>) -> Self {
315 let protected = protected_commands(&core_entries);
316 let mut seen = HashSet::new();
317 let mut entries = Vec::new();
318
319 for mut entry in core_entries {
320 normalize_command(&mut entry);
321 if seen.insert(entry.command.clone()) {
322 entries.push(entry);
323 }
324 }
325
326 for mut entry in plugin_entries {
327 normalize_command(&mut entry);
328 if protected.contains(&entry.command)
329 || protected.contains(&entry.id)
330 || entry.aliases.iter().any(|alias| protected.contains(alias))
331 {
332 tracing::warn!(
333 command = %entry.command,
334 id = %entry.id,
335 "plugin help entry conflicts with protected namespace; ignoring"
336 );
337 continue;
338 }
339 if entry.protected {
340 entry.protected = false;
341 }
342 entry.source = Some(plugin_source_label(entry.source.as_deref()));
343 if entry.category.trim().is_empty() || entry.category.eq_ignore_ascii_case("plugin") {
344 entry.category = extension_help_category(entry.source.as_deref());
345 }
346 if seen.insert(entry.command.clone()) {
347 entries.push(entry);
348 }
349 }
350
351 entries.sort_by(|a, b| a.command.cmp(&b.command));
352 Self { entries }
353 }
354
355 pub fn entries(&self) -> &[HelpEntry] {
356 &self.entries
357 }
358
359 pub fn entry_by_command(&self, command: &str) -> Option<&HelpEntry> {
360 let needle = normalize_query_command(command);
361 self.entries.iter().find(|entry| {
362 entry.command == needle
363 || entry
364 .aliases
365 .iter()
366 .any(|alias| normalize_query_command(alias) == needle)
367 })
368 }
369
370 pub fn branch(&self, topic: &str) -> Option<&HelpEntry> {
371 let normalized = topic
372 .trim()
373 .trim_start_matches("/help")
374 .trim()
375 .trim_start_matches('/');
376 self.entries.iter().find(|entry| {
377 entry.topic == HelpTopicKind::Branch
378 && (entry.id == normalized
379 || entry.command == format!("/help {}", normalized)
380 || entry
381 .aliases
382 .iter()
383 .any(|alias| alias.trim_start_matches("/help ") == normalized))
384 })
385 }
386
387 pub fn search(&self, query: &str) -> Vec<&HelpEntry> {
388 ranked_entries(&self.entries, query)
389 }
390
391 pub fn entry_for_help_topic(&self, topic: &str) -> Option<&HelpEntry> {
392 let normalized = normalize_help_topic(topic);
393 if normalized.is_empty() {
394 return None;
395 }
396
397 self.entry_by_command_exact(&format!("/help {}", normalized))
398 .or_else(|| self.entry_by_command_exact(&normalized))
399 .or_else(|| self.entry_by_command_exact(&format!("/{}", normalized)))
400 .or_else(|| self.entry_by_command(&format!("/{}", normalized)))
401 .or_else(|| self.branch(&normalized))
402 }
403
404 pub fn command_prefix_match_count(&self, partial: &str) -> usize {
405 let needle = partial.trim().trim_start_matches('/').to_ascii_lowercase();
406 if needle.is_empty() {
407 return 0;
408 }
409 self.entries
410 .iter()
411 .filter(|entry| {
412 entry
413 .command
414 .trim_start_matches('/')
415 .to_ascii_lowercase()
416 .starts_with(&needle)
417 || entry.aliases.iter().any(|alias| {
418 alias
419 .trim_start_matches('/')
420 .to_ascii_lowercase()
421 .starts_with(&needle)
422 })
423 })
424 .count()
425 }
426
427 fn entry_by_command_exact(&self, command: &str) -> Option<&HelpEntry> {
428 let needle = normalize_query_command(command);
429 self.entries.iter().find(|entry| entry.command == needle)
430 }
431}
432
433pub fn builtin_entries() -> Vec<HelpEntry> {
434 serde_json::from_str(BUILTIN_HELP_JSON).expect("assets/help.json must be valid help JSON")
435}
436
437fn default_plugin_category() -> String {
438 "Plugin".to_string()
439}
440
441fn category_sort_key(category: &str) -> u8 {
442 if category == "Help commands" {
443 1
444 } else if is_extension_help_category(category) {
445 2
446 } else {
447 0
448 }
449}
450
451fn help_parent_sort_key(entry: &HelpEntry) -> usize {
452 if is_help_command(entry) {
453 entry.command.matches(' ').count()
454 } else {
455 0
456 }
457}
458
459fn display_category(entry: &HelpEntry) -> &str {
460 if is_help_command(entry) {
461 "Help commands"
462 } else {
463 entry.category.as_str()
464 }
465}
466
467fn category_best_score(entries: &[&HelpEntry], category: &str) -> i32 {
468 entries
469 .iter()
470 .filter(|entry| display_category(entry) == category)
471 .map(|entry| empty_query_score(entry))
472 .max()
473 .unwrap_or(0)
474}
475
476fn is_help_command(entry: &HelpEntry) -> bool {
477 entry.command == "/help" || entry.command.starts_with("/help ")
478}
479
480pub fn prefilter_query_for_slash_command(input: &str) -> Option<String> {
481 let trimmed = input.trim();
482 let partial = trimmed.strip_prefix('/')?.trim();
483 if partial.is_empty() {
484 return None;
485 }
486 Some(partial.to_string())
487}
488
489pub fn render_help(registry: &HelpRegistry, branch: Option<&str>) -> Option<String> {
490 match branch.map(str::trim).filter(|s| !s.is_empty()) {
491 None => registry.entry_by_command("/help").map(render_entry),
492 Some("find") => registry.entry_by_command("/help find").map(render_entry),
493 Some("topics") => Some(render_topics(registry)),
494 Some("reference") => Some(render_reference(registry)),
495 Some(topic) => registry
496 .entry_for_help_topic(topic)
497 .map(render_entry)
498 .or_else(|| Some(render_unknown_topic(registry, topic))),
499 }
500}
501
502pub fn render_entry(entry: &HelpEntry) -> String {
503 let mut lines = vec![entry.title.clone(), String::new(), entry.summary.clone()];
504
505 if !entry.lines.is_empty() {
506 lines.push(String::new());
507 lines.extend(body_lines_without_structured_related(entry));
508 }
509
510 append_usage_examples_related(&mut lines, entry);
511 lines.join("\n")
512}
513
514fn body_lines_without_structured_related(entry: &HelpEntry) -> Vec<String> {
515 if entry.related.is_empty() {
516 return entry.lines.clone();
517 }
518 let mut lines = entry.lines.clone();
519 while lines.last().is_some_and(|line| line.trim().is_empty()) {
520 lines.pop();
521 }
522 if lines
523 .last()
524 .is_some_and(|line| line.trim_start().starts_with("Related:"))
525 {
526 lines.pop();
527 while lines.last().is_some_and(|line| line.trim().is_empty()) {
528 lines.pop();
529 }
530 }
531 lines
532}
533
534fn render_topics(registry: &HelpRegistry) -> String {
535 let mut entries: Vec<&HelpEntry> = registry
536 .entries()
537 .iter()
538 .filter(|entry| entry.topic == HelpTopicKind::Branch)
539 .collect();
540 entries.sort_by(|a, b| {
541 a.category
542 .cmp(&b.category)
543 .then_with(|| a.command.cmp(&b.command))
544 });
545
546 let mut lines = vec![
547 "Help topics".to_string(),
548 String::new(),
549 "Conceptual guides and discovery paths. Use /help <topic> for details.".to_string(),
550 String::new(),
551 ];
552
553 for entry in entries {
554 lines.push(format!(" {} — {}", entry.command, entry.summary));
555 }
556
557 lines.join("\n")
558}
559
560fn render_reference(registry: &HelpRegistry) -> String {
561 let mut entries: Vec<&HelpEntry> = registry.entries().iter().collect();
562 entries.sort_by(|a, b| {
563 a.category
564 .cmp(&b.category)
565 .then_with(|| a.command.cmp(&b.command))
566 });
567
568 let mut lines = vec![
569 "Help reference".to_string(),
570 String::new(),
571 "All help entries grouped by category.".to_string(),
572 ];
573 let mut current_category: Option<&str> = None;
574
575 for entry in entries {
576 if current_category != Some(entry.category.as_str()) {
577 current_category = Some(entry.category.as_str());
578 lines.push(String::new());
579 lines.push(entry.category.clone());
580 }
581 lines.push(format!(" {} — {}", entry.command, entry.summary));
582 }
583
584 lines.join("\n")
585}
586
587pub fn source_display(entry: &HelpEntry) -> Option<String> {
588 match entry.source.as_deref() {
589 Some(source) if !source.trim().is_empty() => Some(plugin_source_label(Some(source))),
590 _ => None,
591 }
592}
593
594fn append_usage_examples_related(lines: &mut Vec<String>, entry: &HelpEntry) {
595 if let Some(usage) = entry
596 .usage
597 .as_ref()
598 .filter(|usage| !usage.trim().is_empty())
599 {
600 lines.push(String::new());
601 lines.push("Usage".to_string());
602 lines.push(format!(" {}", usage));
603 }
604
605 if !entry.examples.is_empty() {
606 lines.push(String::new());
607 lines.push("Examples".to_string());
608 for example in &entry.examples {
609 if example.description.trim().is_empty() {
610 lines.push(format!(" {}", example.command));
611 } else {
612 lines.push(format!(" {:<16} {}", example.command, example.description));
613 }
614 }
615 }
616
617 if !entry.related.is_empty() {
618 lines.push(String::new());
619 lines.push(format!("Related: {}", entry.related.join(", ")));
620 }
621}
622
623fn normalize_command(entry: &mut HelpEntry) {
624 entry.command = normalize_query_command(&entry.command);
625 entry.aliases = entry
626 .aliases
627 .iter()
628 .map(|alias| normalize_query_command(alias))
629 .collect();
630}
631
632fn normalize_query_command(command: &str) -> String {
633 let trimmed = command.trim();
634 if trimmed.starts_with('/') {
635 trimmed.to_string()
636 } else {
637 format!("/{}", trimmed)
638 }
639}
640
641fn normalize_help_topic(topic: &str) -> String {
642 let mut normalized = topic.trim();
643 if let Some(rest) = normalized.strip_prefix("/help") {
644 normalized = rest.trim();
645 }
646 normalized
647 .trim_start_matches('/')
648 .trim()
649 .to_ascii_lowercase()
650}
651
652fn render_unknown_topic(registry: &HelpRegistry, topic: &str) -> String {
653 let mut lines = vec![
654 format!("No help topic for '{}'.", topic),
655 String::new(),
656 "Try /help find to search every topic.".to_string(),
657 ];
658 let suggestions = closest_help_matches(registry, topic, 3);
659 if !suggestions.is_empty() {
660 lines.push(String::new());
661 lines.push(format!("Closest matches: {}", suggestions.join(", ")));
662 }
663 lines.join("\n")
664}
665
666fn closest_help_matches(registry: &HelpRegistry, topic: &str, limit: usize) -> Vec<String> {
667 let needle = normalize_help_topic(topic);
668 if needle.is_empty() {
669 return Vec::new();
670 }
671
672 let mut scored: Vec<(&HelpEntry, usize)> = registry
673 .entries()
674 .iter()
675 .filter_map(|entry| {
676 suggestion_distance(entry, &needle)
677 .filter(|distance| *distance <= 3)
678 .map(|distance| (entry, distance))
679 })
680 .collect();
681
682 scored.sort_by(|(entry_a, distance_a), (entry_b, distance_b)| {
683 distance_a
684 .cmp(distance_b)
685 .then_with(|| entry_b.common.cmp(&entry_a.common))
686 .then_with(|| entry_a.command.len().cmp(&entry_b.command.len()))
687 .then_with(|| entry_a.command.cmp(&entry_b.command))
688 });
689 scored
690 .into_iter()
691 .map(|(entry, _)| entry.command.clone())
692 .take(limit)
693 .collect()
694}
695
696fn suggestion_distance(entry: &HelpEntry, needle: &str) -> Option<usize> {
697 entry
698 .aliases
699 .iter()
700 .map(|alias| normalize_help_topic(alias))
701 .chain(std::iter::once(normalize_help_topic(&entry.command)))
702 .chain(std::iter::once(entry.id.to_ascii_lowercase()))
703 .chain(std::iter::once(entry.title.to_ascii_lowercase()))
704 .map(|candidate| levenshtein(needle, candidate.trim_start_matches("help ")))
705 .min()
706}
707
708fn levenshtein(a: &str, b: &str) -> usize {
709 let b_chars: Vec<char> = b.chars().collect();
710 let mut costs: Vec<usize> = (0..=b_chars.len()).collect();
711
712 for (i, ca) in a.chars().enumerate() {
713 let mut previous = costs[0];
714 costs[0] = i + 1;
715 for (j, cb) in b_chars.iter().enumerate() {
716 let current = costs[j + 1];
717 costs[j + 1] = if ca == *cb {
718 previous
719 } else {
720 1 + previous.min(current).min(costs[j])
721 };
722 previous = current;
723 }
724 }
725
726 costs[b_chars.len()]
727}
728
729fn plugin_source_label(source: Option<&str>) -> String {
730 match source.map(str::trim).filter(|source| !source.is_empty()) {
731 None => "plugin".to_string(),
732 Some(source) if source.eq_ignore_ascii_case("plugin") => "plugin".to_string(),
733 Some(source) if source.to_ascii_lowercase().starts_with("plugin ") => source.to_string(),
734 Some(source) => format!("plugin {}", source),
735 }
736}
737
738fn extension_help_category(source: Option<&str>) -> String {
739 let Some(source) = source.map(str::trim).filter(|source| !source.is_empty()) else {
740 return "Extensions".to_string();
741 };
742 let plugin_name = source.strip_prefix("plugin ").unwrap_or(source).trim();
743 if plugin_name.is_empty() || plugin_name.eq_ignore_ascii_case("plugin") {
744 return "Extensions".to_string();
745 }
746 plugin_name
747 .split(['-', '_', ' '])
748 .filter(|word| !word.is_empty())
749 .map(title_case_word)
750 .collect::<Vec<_>>()
751 .join(" ")
752}
753
754fn title_case_word(word: &str) -> String {
755 let mut chars = word.chars();
756 match chars.next() {
757 None => String::new(),
758 Some(first) => {
759 first.to_uppercase().collect::<String>() + &chars.as_str().to_ascii_lowercase()
760 }
761 }
762}
763
764fn is_extension_help_category(category: &str) -> bool {
765 !category.is_empty()
766 && category != "Help commands"
767 && !matches!(
768 category,
769 "Advanced"
770 | "Core"
771 | "Diagnostics"
772 | "Extensions"
773 | "Models"
774 | "Plugins"
775 | "Sessions"
776 | "Settings"
777 | "Tools"
778 )
779}
780
781fn protected_commands(entries: &[HelpEntry]) -> HashSet<String> {
782 let mut protected = HashSet::new();
783 for entry in entries.iter().filter(|entry| entry.protected) {
784 protected.insert(normalize_query_command(&entry.command));
785 protected.insert(entry.id.clone());
786 for alias in &entry.aliases {
787 protected.insert(normalize_query_command(alias));
788 }
789 }
790 for builtin in crate::skills::BUILTIN_COMMANDS {
791 protected.insert(format!("/{}", builtin));
792 protected.insert((*builtin).to_string());
793 }
794 protected
795}
796
797fn ranked_entries<'a>(entries: &'a [HelpEntry], query: &str) -> Vec<&'a HelpEntry> {
798 ranked_entries_with_mru(entries, query, &[])
799}
800
801fn ranked_entries_with_mru<'a>(
802 entries: &'a [HelpEntry],
803 query: &str,
804 recently_opened: &[String],
805) -> Vec<&'a HelpEntry> {
806 let needle = query.trim().to_ascii_lowercase();
807 let mut scored: Vec<(&HelpEntry, i32)> = entries
808 .iter()
809 .filter_map(|entry| {
810 if needle.is_empty() {
811 Some((entry, empty_query_score(entry)))
812 } else {
813 match_score(entry, &needle)
814 .map(|score| (entry, score + mru_bonus(entry, recently_opened)))
815 }
816 })
817 .collect();
818
819 scored.sort_by(|(a, score_a), (b, score_b)| {
820 score_b
821 .cmp(score_a)
822 .then_with(|| a.category.cmp(&b.category))
823 .then_with(|| a.command.cmp(&b.command))
824 });
825 scored.into_iter().map(|(entry, _)| entry).collect()
826}
827
828fn empty_query_score(entry: &HelpEntry) -> i32 {
829 let mut score = 0;
830 if entry.common {
831 score += 2_000;
832 }
833 if entry.category.eq_ignore_ascii_case("core") {
834 score += 1_000;
835 }
836 score
837}
838
839fn match_score(entry: &HelpEntry, needle: &str) -> Option<i32> {
840 let command = entry.command.to_ascii_lowercase();
841 let title = entry.title.to_ascii_lowercase();
842 if command == needle || command.trim_start_matches('/') == needle {
843 return Some(11_000 + common_bonus(entry));
844 }
845 if title == needle {
846 return Some(10_000 + common_bonus(entry));
847 }
848 if command.starts_with(needle)
849 || command
850 .trim_start_matches('/')
851 .starts_with(needle.trim_start_matches('/'))
852 {
853 return Some(8_500 + common_bonus(entry));
854 }
855 if title.starts_with(needle) {
856 return Some(8_000 + common_bonus(entry));
857 }
858 if entry
859 .aliases
860 .iter()
861 .any(|alias| field_matches(alias, needle))
862 {
863 return Some(6_000 + common_bonus(entry));
864 }
865 if entry
866 .keywords
867 .iter()
868 .any(|keyword| field_matches(keyword, needle))
869 {
870 return Some(5_000 + common_bonus(entry));
871 }
872 if field_matches(&entry.summary, needle) {
873 return Some(4_000 + common_bonus(entry));
874 }
875 if entry.lines.iter().any(|line| field_matches(line, needle))
876 || entry
877 .usage
878 .as_ref()
879 .is_some_and(|usage| field_matches(usage, needle))
880 || entry.examples.iter().any(|example| {
881 field_matches(&example.command, needle) || field_matches(&example.description, needle)
882 })
883 {
884 return Some(3_000 + common_bonus(entry));
885 }
886 None
887}
888
889fn field_matches(value: &str, needle: &str) -> bool {
890 value.to_ascii_lowercase().contains(needle)
891}
892
893fn common_bonus(entry: &HelpEntry) -> i32 {
894 if entry.common {
895 100
896 } else {
897 0
898 }
899}
900
901fn mru_bonus(entry: &HelpEntry, recently_opened: &[String]) -> i32 {
902 recently_opened
903 .iter()
904 .position(|command| command == &entry.command)
905 .map(|idx| 50 - (idx as i32).min(49))
906 .unwrap_or(0)
907}
908
909pub fn highlight_segments(text: &str, query: &str) -> Vec<HighlightSegment> {
910 let needle = query.trim().to_ascii_lowercase();
911 if needle.is_empty() {
912 return vec![HighlightSegment {
913 text: text.to_string(),
914 matched: false,
915 }];
916 }
917
918 let lower = text.to_ascii_lowercase();
919 let mut segments = Vec::new();
920 let mut start = 0;
921 while let Some(relative) = lower[start..].find(&needle) {
922 let match_start = start + relative;
923 let match_end = match_start + needle.len();
924 if match_start > start {
925 segments.push(HighlightSegment {
926 text: text[start..match_start].to_string(),
927 matched: false,
928 });
929 }
930 segments.push(HighlightSegment {
931 text: text[match_start..match_end].to_string(),
932 matched: true,
933 });
934 start = match_end;
935 }
936 if start < text.len() {
937 segments.push(HighlightSegment {
938 text: text[start..].to_string(),
939 matched: false,
940 });
941 }
942 if segments.is_empty() {
943 segments.push(HighlightSegment {
944 text: text.to_string(),
945 matched: false,
946 });
947 }
948 segments
949}
950
951pub fn wrap_help_text(text: &str, width: usize) -> Vec<String> {
952 if width == 0 || text.len() <= width {
953 return vec![text.to_string()];
954 }
955
956 let indent: String = text.chars().take_while(|ch| ch.is_whitespace()).collect();
957 let content = text.trim_start();
958 if content.is_empty() {
959 return vec![text.to_string()];
960 }
961
962 let mut lines = Vec::new();
963 let mut current = indent.clone();
964 for word in content.split_whitespace() {
965 let separator = if current.trim().is_empty() { "" } else { " " };
966 let candidate_len = current.len() + separator.len() + word.len();
967 if candidate_len > width && !current.trim().is_empty() {
968 lines.push(current);
969 current = format!("{}{}", indent, word);
970 } else {
971 if !separator.is_empty() {
972 current.push(' ');
973 }
974 current.push_str(word);
975 }
976 }
977 if !current.trim().is_empty() {
978 lines.push(current);
979 }
980 if lines.is_empty() {
981 vec![text.to_string()]
982 } else {
983 lines
984 }
985}
986
987pub fn visible_help_find_window(
988 row_heights: &[usize],
989 cursor: usize,
990 scroll: usize,
991 visible_height: usize,
992) -> usize {
993 if row_heights.is_empty() || visible_height == 0 {
994 return 0;
995 }
996 let cursor = cursor.min(row_heights.len() - 1);
997 let mut start = scroll.min(cursor);
998 while start < cursor && visual_height_between(row_heights, start, cursor) > visible_height {
999 start += 1;
1000 }
1001 start
1002}
1003
1004pub fn wrap_help_find_entry_lines(
1005 command: &str,
1006 summary: &str,
1007 selected: bool,
1008 width: usize,
1009) -> Vec<(String, String)> {
1010 let marker = if selected { "›" } else { " " };
1011 let first_prefix = format!("{} ", marker);
1012 let continuation_prefix = " ";
1013 let first_command_width = width
1014 .saturating_sub(first_prefix.chars().count())
1015 .clamp(8, 22);
1016 let command_lines = wrap_help_find_token(command, first_command_width);
1017 let mut lines = Vec::new();
1018
1019 for (idx, command_part) in command_lines.iter().enumerate() {
1020 if idx == 0 {
1021 lines.push((format!("{}{}", first_prefix, command_part), String::new()));
1022 } else {
1023 lines.push((
1024 format!("{}{}", continuation_prefix, command_part),
1025 String::new(),
1026 ));
1027 }
1028 }
1029
1030 let summary_indent = " ";
1031 let summary_width = width.saturating_sub(summary_indent.len()).max(8);
1032 let summary_lines = wrap_help_text(summary, summary_width);
1033 for line in summary_lines {
1034 lines.push((summary_indent.to_string(), line.trim_start().to_string()));
1035 }
1036
1037 if lines.is_empty() {
1038 vec![(first_prefix, String::new())]
1039 } else {
1040 lines
1041 }
1042}
1043
1044fn wrap_help_find_token(text: &str, width: usize) -> Vec<String> {
1045 if width == 0 || text.chars().count() <= width {
1046 return vec![text.to_string()];
1047 }
1048 let chars = text.chars().collect::<Vec<_>>();
1049 let mut lines = Vec::new();
1050 let mut start = 0;
1051 while start < chars.len() {
1052 let mut end = (start + width).min(chars.len());
1053 if end < chars.len() {
1054 if let Some(split) = chars[start..end]
1055 .iter()
1056 .rposition(|ch| matches!(ch, ':' | '-' | '_' | '/'))
1057 .filter(|split| *split > 0)
1058 {
1059 end = start + split + 1;
1060 }
1061 }
1062 lines.push(chars[start..end].iter().collect::<String>());
1063 start = end;
1064 }
1065 lines
1066}
1067
1068fn visual_height_between(row_heights: &[usize], start: usize, end_inclusive: usize) -> usize {
1069 row_heights[start..=end_inclusive]
1070 .iter()
1071 .map(|height| (*height).max(1))
1072 .sum()
1073}
1074
1075#[cfg(test)]
1076mod tests {
1077 use super::*;
1078
1079 #[test]
1080 fn builtin_help_json_loads() {
1081 let entries = builtin_entries();
1082 assert!(entries.iter().any(|entry| entry.command == "/help"));
1083 assert!(entries.iter().any(|entry| entry.command == "/help find"));
1084 }
1085
1086 #[test]
1088 fn effort_is_a_builtin_command_with_help_entry() {
1089 assert!(
1090 crate::skills::BUILTIN_COMMANDS.contains(&"effort"),
1091 "/effort must be a registered built-in for autocomplete + streaming gating"
1092 );
1093 let entries = builtin_entries();
1094 let effort = entries
1095 .iter()
1096 .find(|entry| entry.command == "/effort")
1097 .expect("/effort must have a builtin help entry");
1098 assert!(
1099 effort.keywords.iter().any(|k| k.contains("reasoning")),
1100 "help entry should be discoverable via reasoning keywords"
1101 );
1102 }
1103
1104 #[test]
1105 fn wrap_help_text_wraps_words_to_width_and_preserves_indent() {
1106 let lines = wrap_help_text(" summary text should wrap neatly", 18);
1107
1108 assert_eq!(lines, vec![" summary text", " should wrap", " neatly"]);
1109 assert!(lines.iter().all(|line| line.len() <= 18));
1110 }
1111
1112 #[test]
1113 fn wrap_help_text_returns_original_line_when_width_is_too_small() {
1114 let lines = wrap_help_text("abc def", 0);
1115
1116 assert_eq!(lines, vec!["abc def"]);
1117 }
1118
1119 #[test]
1120 fn visible_help_find_window_scrolls_by_visual_row_height_to_keep_cursor_visible() {
1121 let heights = vec![1, 1, 3, 1, 1];
1122
1123 assert_eq!(visible_help_find_window(&heights, 2, 0, 3), 2);
1124 assert_eq!(visible_help_find_window(&heights, 4, 2, 4), 3);
1125 }
1126
1127 #[test]
1128 fn wrap_help_find_entry_lines_wraps_commands_and_indents_wrapped_descriptions_lightly() {
1129 let lines = wrap_help_find_entry_lines(
1130 "/extension-showcase:very-long-demo-command",
1131 "description wraps onto a second visual line",
1132 false,
1133 34,
1134 );
1135
1136 assert!(lines.len() > 1);
1137 assert_eq!(lines[0].0, " /extension-showcase:");
1138 assert!(
1139 lines[1].0.starts_with(" very-long-demo"),
1140 "wrapped command should indent slightly: {:?}",
1141 lines
1142 );
1143 assert_eq!(
1144 lines[2].0, " ",
1145 "description should start on its own lightly indented line: {:?}",
1146 lines
1147 );
1148 assert_eq!(lines[2].1, "description wraps onto a");
1149 assert_eq!(
1150 lines[3].0, " ",
1151 "wrapped description should keep small indent: {:?}",
1152 lines
1153 );
1154 assert_eq!(lines[3].1, "second visual line");
1155 }
1156}