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}