Skip to main content

recursive/tui/
completion.rs

1//! Tab-completion and history-search helpers for the Recursive TUI.
2//!
3//! Provides [`glob_workspace_files`] for `@`-file completion, [`search_history`]
4//! for Ctrl+R fuzzy history search, and [`default_offline_tool_catalog`] for the
5//! static tool list shown when the runtime is unavailable.
6
7// ──────────────────────────────────────────────────────────────────────
8// Constants
9// ──────────────────────────────────────────────────────────────────────
10
11/// Maximum candidates shown in the @file popup.
12pub const MAX_ATFILE_SUGGESTIONS: usize = 12;
13
14/// Maximum results shown in the Ctrl+R history-search popup.
15pub const MAX_HSEARCH_RESULTS: usize = 12;
16
17// ──────────────────────────────────────────────────────────────────────
18// Tool catalog
19// ──────────────────────────────────────────────────────────────────────
20
21/// Static fallback list of tools shown by `/tools` when the TUI is
22/// running in offline mode (no runtime to query). Mirrors the set
23/// `backend::build_default_tools` registers.
24pub fn default_offline_tool_catalog() -> Vec<(String, String)> {
25    vec![
26        ("read_file".into(), "Read a file from the workspace".into()),
27        ("write_file".into(), "Write a file to the workspace".into()),
28        ("apply_patch".into(), "Apply a V4A patch to a file".into()),
29        (
30            "list_dir".into(),
31            "List a directory under the workspace".into(),
32        ),
33        (
34            "run_shell".into(),
35            "Run a shell command in the workspace".into(),
36        ),
37        (
38            "search_files".into(),
39            "Search files for a regex pattern".into(),
40        ),
41    ]
42}
43
44// ──────────────────────────────────────────────────────────────────────
45// History search (Goal 160)
46// ──────────────────────────────────────────────────────────────────────
47
48/// Fuzzy-search `history` for `query` (case-insensitive substring match).
49///
50/// Returns indices into `history` ordered by relevance: prefix matches come
51/// before substring matches. When `query` is empty, returns all indices in
52/// reverse insertion order (most-recent first). Results are capped at
53/// [`MAX_HSEARCH_RESULTS`].
54pub fn search_history(history: &[String], query: &str) -> Vec<usize> {
55    let q = query.to_lowercase();
56    if q.is_empty() {
57        // All entries, most recent first.
58        let mut all: Vec<usize> = (0..history.len()).rev().collect();
59        all.truncate(MAX_HSEARCH_RESULTS);
60        return all;
61    }
62    let mut prefix: Vec<usize> = Vec::new();
63    let mut substr: Vec<usize> = Vec::new();
64    for (i, entry) in history.iter().enumerate() {
65        let lower = entry.to_lowercase();
66        if lower.starts_with(&q) {
67            prefix.push(i);
68        } else if lower.contains(&q) {
69            substr.push(i);
70        }
71    }
72    // Most recent prefix matches first, then most recent substr matches.
73    prefix.reverse();
74    substr.reverse();
75    let mut out = prefix;
76    out.extend(substr);
77    out.truncate(MAX_HSEARCH_RESULTS);
78    out
79}
80
81// ──────────────────────────────────────────────────────────────────────
82// @file autocomplete (Goal 158)
83// ──────────────────────────────────────────────────────────────────────
84
85/// Enumerate workspace files matching `query` (case-insensitive prefix /
86/// substring match). Returns relative paths, newest-first within each
87/// depth tier, capped at [`MAX_ATFILE_SUGGESTIONS`].
88///
89/// Excludes: `target/`, `.git/`, `node_modules/`. Walks at most 3
90/// directory levels deep so the function stays fast even in large trees.
91pub fn glob_workspace_files(query: &str) -> Vec<String> {
92    let Ok(cwd) = std::env::current_dir() else {
93        return Vec::new();
94    };
95    let q = query.to_lowercase();
96    let mut results: Vec<String> = Vec::new();
97    collect_files(&cwd, &cwd, 0, &q, &mut results);
98    results.sort();
99    results.dedup();
100    // Prefer entries whose filename starts with the query.
101    results.sort_by_key(|p| {
102        let name = std::path::Path::new(p)
103            .file_name()
104            .and_then(|n| n.to_str())
105            .unwrap_or("")
106            .to_lowercase();
107        if name.starts_with(&q) {
108            0u8
109        } else {
110            1u8
111        }
112    });
113    results.truncate(MAX_ATFILE_SUGGESTIONS);
114    results
115}
116
117pub fn collect_files(
118    root: &std::path::Path,
119    dir: &std::path::Path,
120    depth: usize,
121    query: &str,
122    out: &mut Vec<String>,
123) {
124    if depth > 3 {
125        return;
126    }
127    let Ok(entries) = std::fs::read_dir(dir) else {
128        return;
129    };
130    for entry in entries.flatten() {
131        let path = entry.path();
132        let name = entry.file_name();
133        let name_str = name.to_string_lossy();
134        // Skip hidden dirs and common large dirs.
135        if name_str.starts_with('.') || name_str == "target" || name_str == "node_modules" {
136            continue;
137        }
138        if path.is_dir() {
139            collect_files(root, &path, depth + 1, query, out);
140        } else if path.is_file() {
141            let rel = path
142                .strip_prefix(root)
143                .map(|p| p.to_string_lossy().into_owned())
144                .unwrap_or_else(|_| name_str.to_string());
145            if (query.is_empty() || rel.to_lowercase().contains(query))
146                && out.len() < MAX_ATFILE_SUGGESTIONS * 4
147            {
148                out.push(rel);
149            }
150        }
151    }
152}