Skip to main content

agent_engine/
help.rs

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    // Slice C: /effort discovery — command registry + searchable help entry.
1087    #[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}