From b473876d597f43b320060028a2d1e45ac2e0f2f2 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:44:14 +0800 Subject: [PATCH] feat(agents): reference folders from @ completion (#1210) Folders now appear in the composer's `@` completion next to files, from a new `find_dirs` index query over the folders that hold indexed files (kept incrementally in the index, ~0.5 ms on 100k files). Enter or a click references the folder as `@dir/` and sends it as a resource link; Tab keeps completing inside it. Closes #1202 --- crates/ide-host/src/ops/nav.rs | 5 ++ crates/ide-host/src/trust.rs | 1 + crates/ide-index/benches/index.rs | 3 + crates/ide-index/src/data.rs | 47 +++++++++++ crates/ide-index/src/lib.rs | 9 +++ crates/ide-index/src/matching.rs | 72 ++++++++++++++++- crates/ide-index/src/tests.rs | 17 ++++ perf/budgets.json | 3 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/nav.rs | 7 ++ src/bindings.ts | 2 + src/features/agents/Composer.tsx | 44 +++++----- src/features/agents/HomeEditor.tsx | 4 +- src/features/agents/agents.test.ts | 27 +++++++ src/features/agents/composerFolders.test.tsx | 84 ++++++++++++++++++++ src/features/agents/composerModel.ts | 23 ++++++ src/platform/locales/text/fr.json | 2 +- src/platform/locales/text/ko.json | 2 +- src/platform/locales/text/zh.json | 2 +- 19 files changed, 331 insertions(+), 24 deletions(-) create mode 100644 src/features/agents/composerFolders.test.tsx diff --git a/crates/ide-host/src/ops/nav.rs b/crates/ide-host/src/ops/nav.rs index 5a4c3b32b..40266639c 100644 --- a/crates/ide-host/src/ops/nav.rs +++ b/crates/ide-host/src/ops/nav.rs @@ -36,6 +36,10 @@ pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeRe Ok(host.project(window)?.index.find_files(&query, limit as usize)) } +pub fn find_dirs(host: &Host, window: &str, query: String, limit: u32) -> IdeResult> { + Ok(host.project(window)?.index.find_dirs(&query, limit as usize)) +} + pub fn find_symbols( host: &Host, window: &str, @@ -157,6 +161,7 @@ pub async fn web_unresolved_references(host: &Host, window: &str, doc: DocId) -> pub(crate) fn register(d: &mut Dispatcher) { d.add("index_status", |c| async move { index_status(&c.host, &c.window) }); d.add("find_files", |c| async move { find_files(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) }); + d.add("find_dirs", |c| async move { find_dirs(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) }); d.add("find_symbols", |c| async move { find_symbols(&c.host, &c.window, c.arg("query")?, c.arg("filter")?, c.arg("limit")?) }); diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 0e828db4c..aa0b04973 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -226,6 +226,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "deploy_coverage" | "deploy_test_connection" | "find_files" + | "find_dirs" | "find_symbols" | "mark_recent" | "remove_recent" diff --git a/crates/ide-index/benches/index.rs b/crates/ide-index/benches/index.rs index d888e2c4f..efe173374 100644 --- a/crates/ide-index/benches/index.rs +++ b/crates/ide-index/benches/index.rs @@ -14,6 +14,9 @@ fn bench(c: &mut Criterion) { for q in ["ordcon", "src/user", "PaymentService", "zzzz", "a"] { g.bench_function(format!("find_files/{q}"), |b| b.iter(|| black_box(index.find_files(q, 50).len()))); } + for q in ["util", "src/ui", "a"] { + g.bench_function(format!("find_dirs/{q}"), |b| b.iter(|| black_box(index.find_dirs(q, 50).len()))); + } for q in ["OCo", "invoiceRepo", "renderView", "q"] { g.bench_function(format!("find_symbols/{q}"), |b| { b.iter(|| black_box(index.find_symbols(q, SymbolFilter::All, 50).len())) diff --git a/crates/ide-index/src/data.rs b/crates/ide-index/src/data.rs index 591154308..57bb76e42 100644 --- a/crates/ide-index/src/data.rs +++ b/crates/ide-index/src/data.rs @@ -134,6 +134,9 @@ impl FileEntry { pub(crate) struct IndexData { pub files: Vec>, by_path: HashMap, + /// Every folder holding an indexed file at any depth, keyed by its `rel` with a + /// trailing `/`: its absolute path and the number of files below it. + pub dirs: HashMap, (PathBuf, u32)>, free: Vec, pub file_count: u32, pub symbol_count: u32, @@ -167,6 +170,11 @@ impl IndexData { } }; let path = self.files[slot as usize].as_ref().map(|e| e.path.clone()).unwrap_or_default(); + if let Some(e) = &self.files[slot as usize] { + for (rel, abs) in ancestors(e) { + self.dirs.entry(rel.into()).or_insert_with(|| (abs.to_owned(), 0)).1 += 1; + } + } self.by_path.insert(path, slot); self.file_count += 1; self.symbol_count += symbols; @@ -176,6 +184,14 @@ impl IndexData { let slot = self.by_path.remove(path)?; let old = self.files[slot as usize].take()?; self.free.push(slot); + for (rel, _) in ancestors(&old) { + if let Some(d) = self.dirs.get_mut(rel) { + d.1 -= 1; + if d.1 == 0 { + self.dirs.remove(rel); + } + } + } self.file_count -= 1; self.symbol_count -= old.symbols.len() as u32; Some(old) @@ -197,6 +213,7 @@ impl IndexData { self.files.shrink_to_fit(); self.free.shrink_to_fit(); self.by_path.shrink_to_fit(); + self.dirs.shrink_to_fit(); } pub(crate) fn paths(&self) -> impl Iterator { @@ -204,6 +221,19 @@ impl IndexData { } } +/// The folders holding `e` (`rel` with a trailing `/`, absolute path), innermost first. +fn ancestors(e: &FileEntry) -> impl Iterator { + let mut rel = e.dir(); + let mut abs = e.path.parent(); + std::iter::from_fn(move || { + let path = abs.filter(|_| !rel.is_empty())?; + let here = rel; + rel = rel[..rel.len() - 1].rfind('/').map_or("", |i| &rel[..=i]); + abs = path.parent(); + Some((here, path)) + }) +} + #[cfg(test)] mod tests { use super::*; @@ -223,6 +253,23 @@ mod tests { FileEntry::new(PathBuf::from("/r").join(path), path.to_owned(), record) } + #[test] + fn dirs_follow_inserts_and_removals() { + let mut d = IndexData::default(); + d.insert(entry("a/b/c.rs", 0)); + d.insert(entry("a/d.rs", 0)); + d.insert(entry("a/d.rs", 0)); + d.insert(entry("top.rs", 0)); + let mut dirs: Vec<_> = d.dirs.iter().map(|(k, v)| (k.to_string(), v.1)).collect(); + dirs.sort(); + assert_eq!(dirs, [("a/".into(), 2), ("a/b/".into(), 1)]); + assert_eq!(d.dirs["a/b/"].0, PathBuf::from("/r/a/b")); + d.remove(Path::new("/r/a/b/c.rs")); + assert!(!d.dirs.contains_key("a/b/")); + d.remove_tree(Path::new("/r/a")); + assert!(d.dirs.is_empty()); + } + #[test] fn insert_replace_remove_keep_counts() { let mut d = IndexData::default(); diff --git a/crates/ide-index/src/lib.rs b/crates/ide-index/src/lib.rs index 88923decb..cf0256ac3 100644 --- a/crates/ide-index/src/lib.rs +++ b/crates/ide-index/src/lib.rs @@ -167,6 +167,11 @@ impl ProjectIndex { matching::find_files(&d, &matching::Recents { paths: &recent }, query, limit) } + /// Fuzzy search for folders that hold indexed files; `rel_path` ends in `/`. + pub fn find_dirs(&self, query: &str, limit: usize) -> Vec { + matching::find_dirs(&self.shared.data.read(), query, limit) + } + /// Fuzzy symbol search; CamelHump aware (`OCo` → `OrderController`). pub fn find_symbols(&self, query: &str, filter: SymbolFilter, limit: usize) -> Vec { let recent = self.shared.recent.lock().clone(); @@ -366,6 +371,10 @@ pub mod bench_support { matching::find_files(&self.data, &matching::Recents { paths: &self.recent }, query, limit) } + pub fn find_dirs(&self, query: &str, limit: usize) -> Vec { + matching::find_dirs(&self.data, query, limit) + } + pub fn find_symbols(&self, query: &str, filter: SymbolFilter, limit: usize) -> Vec { matching::find_symbols(&self.data, &matching::Recents { paths: &self.recent }, query, filter, limit) } diff --git a/crates/ide-index/src/matching.rs b/crates/ide-index/src/matching.rs index bea3f3c5e..dcb9b3839 100644 --- a/crates/ide-index/src/matching.rs +++ b/crates/ide-index/src/matching.rs @@ -12,7 +12,7 @@ use nucleo_matcher::{Config, Matcher, Utf32Str, Utf32String}; use rayon::prelude::*; use std::cell::RefCell; use std::cmp::Ordering; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; const TIER_NAME: u32 = 1 << 20; const EXACT: u32 = 4000; @@ -251,6 +251,76 @@ pub(crate) fn find_files(data: &IndexData, recents: &Recents, query: &str, limit .collect() } +/// Fuzzy folder search over the folders holding indexed files: folder-name matches beat +/// path matches; `/` in the query matches the whole path. `rel_path` ends in `/`. +pub(crate) fn find_dirs(data: &IndexData, query: &str, limit: usize) -> Vec { + let q = normalize_file_query(query); + if limit == 0 || q.is_empty() { + return Vec::new(); + } + let by_path = q.contains('/'); + let needle = Needle::new(q.trim_end_matches('/')); + // (score, rel without the trailing `/`, char offset of the name when it matched by name) + let score = |m: &mut Matcher, buf: &mut Vec, rel: &str| -> Option<(u32, Option)> { + let trimmed = &rel[..rel.len() - 1]; + let start = trimmed.rfind('/').map_or(0, |i| i + 1); + let name = &trimmed[start..]; + if !by_path + && needle.mask & char_mask(name) == needle.mask + && let Some(s) = needle.score(m, name, buf) + { + return Some((TIER_NAME + s * 4 + needle.name_boost(name), Some(trimmed[..start].chars().count() as u32))); + } + if needle.mask & char_mask(trimmed) != needle.mask { + return None; + } + needle.score(m, trimmed, buf).map(|s| (s, None)) + }; + let mut best: Vec<(u32, &str, &Path, Option)> = data + .dirs + .par_iter() + .filter_map(|(rel, (path, _))| { + let mut buf = Vec::new(); + with_matcher(|m| score(m, &mut buf, rel)).map(|(s, at)| (s, &**rel, path.as_path(), at)) + }) + .collect(); + let order = |a: &(u32, &str, &Path, Option), b: &(u32, &str, &Path, Option)| { + b.0.cmp(&a.0).then_with(|| a.1.len().cmp(&b.1.len())).then_with(|| a.1.cmp(b.1)) + }; + if best.len() > limit { + best.select_nth_unstable_by(limit, order); + best.truncate(limit); + } + // Match indices and the CamelHump bonus `find_files` gives, so equal matches rank alike. + let mut out: Vec = with_matcher(|m| { + let (mut buf, mut idx) = (Vec::new(), Vec::new()); + best.into_iter() + .map(|(score, rel, path, name_at)| { + let trimmed = &rel[..rel.len() - 1]; + let (bonus, indices) = match name_at { + Some(at) => { + let name = &trimmed[trimmed.rfind('/').map_or(0, |i| i + 1)..]; + needle.indices(m, name, &mut buf, &mut idx); + (hump_bonus(name, &idx), idx.iter().map(|i| i + at).collect()) + } + None => { + needle.indices(m, trimmed, &mut buf, &mut idx); + (hump_bonus(trimmed, &idx), idx.clone()) + } + }; + FileMatch { path: path.to_path_buf().into(), rel_path: rel.to_owned(), score: score + bonus, indices } + }) + .collect() + }); + out.sort_by(|a, b| { + b.score + .cmp(&a.score) + .then_with(|| a.rel_path.len().cmp(&b.rel_path.len())) + .then_with(|| a.rel_path.cmp(&b.rel_path)) + }); + out +} + fn file(data: &IndexData, slot: u32) -> &FileEntry { data.files[slot as usize].as_ref().expect("candidate slots are live") } diff --git a/crates/ide-index/src/tests.rs b/crates/ide-index/src/tests.rs index 97ba3c7cf..667e9f505 100644 --- a/crates/ide-index/src/tests.rs +++ b/crates/ide-index/src/tests.rs @@ -138,6 +138,23 @@ fn file_ranking_is_idea_like() { assert_eq!(shown.to_lowercase(), "uibtn"); } +#[test] +fn find_dirs_ranks_folder_names() { + let f = sample(); + let idx = f.open(); + let dirs = |q: &str| idx.find_dirs(q, 20).into_iter().map(|m| m.rel_path).collect::>(); + assert_eq!(dirs("order"), ["src/order/"]); + assert_eq!(dirs("src")[0], "src/"); + assert!(dirs("src").contains(&"src/ui/".to_owned()), "{:?}", dirs("src")); + assert_eq!(dirs("src/ui/"), ["src/ui/"]); + assert!(dirs("").is_empty()); + assert!(dirs("main").is_empty()); + + let m = &idx.find_dirs("ui", 1)[0]; + assert_eq!(m.path.0, f.root.join("src/ui")); + assert_eq!(m.indices, vec![4, 5]); +} + #[test] fn shorter_paths_win_ties_and_recent_files_boost() { let fx = Fixture::new(&[("a/b/c/util.rs", ""), ("x/util.rs", ""), ("y/util.rs", "")]); diff --git a/perf/budgets.json b/perf/budgets.json index f831d10c2..797d0c652 100644 --- a/perf/budgets.json +++ b/perf/budgets.json @@ -76,6 +76,9 @@ "index_100k/find_files/ordcon": 10000, "index_100k/find_files/src/user": 10000, "index_100k/find_files/PaymentService": 10000, + "index_100k/find_dirs/util": 5000, + "index_100k/find_dirs/src/ui": 5000, + "index_100k/find_dirs/a": 5000, "index_100k/find_symbols/OCo": 15000, "index_100k/find_symbols/q": 15000, "index_100k/find_symbols/invoiceRepo": 15000, diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index ffae151c9..bf45cdca9 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -188,6 +188,7 @@ pub fn builder() -> tauri_specta::Builder { nav::index_status, nav::spring_route_sources, nav::find_files, + nav::find_dirs, nav::find_symbols, nav::mark_recent, nav::recent_files, diff --git a/src-tauri/src/commands/nav.rs b/src-tauri/src/commands/nav.rs index dd1e1d5f4..605e7b275 100644 --- a/src-tauri/src/commands/nav.rs +++ b/src-tauri/src/commands/nav.rs @@ -22,6 +22,13 @@ pub fn find_files(window: Window, state: State<'_, AppState>, query: String, lim ide_host::ops::nav::find_files(&state.host, window.label(), query, limit) } +/// Folders holding indexed files, for `@` references. +#[tauri::command] +#[specta::specta] +pub fn find_dirs(window: Window, state: State<'_, AppState>, query: String, limit: u32) -> IdeResult> { + ide_host::ops::nav::find_dirs(&state.host, window.label(), query, limit) +} + /// Spring Web mapping sources for the request-path index of Search Everywhere. #[tauri::command] #[specta::specta] diff --git a/src/bindings.ts b/src/bindings.ts index 324f2122b..12e9b9368 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -327,6 +327,8 @@ export const commands = { /** Spring Web mapping sources for the request-path index of Search Everywhere. */ springRouteSources: () => typedError(__TAURI_INVOKE("spring_route_sources")), findFiles: (query: string, limit: number) => typedError(__TAURI_INVOKE("find_files", { query, limit })), + /** Folders holding indexed files, for `@` references. */ + findDirs: (query: string, limit: number) => typedError(__TAURI_INVOKE("find_dirs", { query, limit })), findSymbols: (query: string, filter: SymbolFilter, limit: number) => typedError(__TAURI_INVOKE("find_symbols", { query, filter, limit })), markRecent: (path: string) => typedError(__TAURI_INVOKE("mark_recent", { path })), /** Recently opened files, most recent first (⌘E). */ diff --git a/src/features/agents/Composer.tsx b/src/features/agents/Composer.tsx index f84327c3f..cab5ec1bb 100644 --- a/src/features/agents/Composer.tsx +++ b/src/features/agents/Composer.tsx @@ -1,6 +1,6 @@ /** - * The message box of the New Task page and of sessions: `@` file references (project file - * search), `/` agent commands, ↑ / ↓ history, pasted or dropped images, Shift+Tab to cycle + * The message box of the New Task page and of sessions: `@` file and folder references + * (project index search), `/` agent commands, ↑ / ↓ history, pasted or dropped images, Shift+Tab to cycle * the agent's mode. Its draft lives in the store under `id`. */ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; @@ -19,6 +19,7 @@ import { applyCompletion, atEdgeLine, isPathQuery, + mergeMatches, resolveTyped, completionAt, imageFromDataUrl, @@ -129,14 +130,15 @@ export function Composer(props: ComposerProps) { .catch(() => alive && setFiles([])); return; } - call(commands.findFiles(query, MAX_ITEMS)) - .then((found) => { + Promise.all([call(commands.findFiles(query, MAX_ITEMS)), call(commands.findDirs(query, MAX_ITEMS))]) + .then(([found, dirs]) => { if (!alive) return; setFiles( - found.map((f) => ({ + mergeMatches(found, dirs, MAX_ITEMS).map((f) => ({ kind: "file", - rel: f.relPath, - path: root === projectRoot ? f.path : `${root}/${f.relPath}`, + rel: f.rel, + dir: f.dir, + path: root === projectRoot ? f.path : `${root}/${f.rel}`, })), ); }) @@ -171,19 +173,20 @@ export function Composer(props: ComposerProps) { } }; - const accept = (item: Item | undefined) => { + /** Enter or a click references the item; Tab on a folder keeps completing inside it. */ + const accept = (item: Item | undefined, descend = false) => { if (!item || !completion) return; - if (item.kind === "file" && item.dir) { - // Descend: keep completing inside the folder. - const insert = `${mentionOf(item.rel)}/`; - setText(text.slice(0, completion.from) + insert + text.slice(completion.to), completion.from + insert.length); - return; - } if (item.kind === "file") { - const mention = mentionOf(item.rel); - const next = applyCompletion(text, completion, mention); + const rel = item.dir ? `${item.rel}/` : item.rel; + // A folder stays referenced while its `@folder/` is left as typed (`toPrompt`). const current = composerDraft(id); - updateComposer(id, { files: [...current.files, { path: item.path, rel: item.rel } satisfies FileRef] }); + updateComposer(id, { files: [...current.files, { path: item.path, rel } satisfies FileRef] }); + if (item.dir && descend) { + const insert = mentionOf(rel); + setText(text.slice(0, completion.from) + insert + text.slice(completion.to), completion.from + insert.length); + return; + } + const next = applyCompletion(text, completion, mentionOf(rel)); setText(next.text, next.caret); } else { const next = applyCompletion(text, completion, `/${item.command.name}`); @@ -266,7 +269,7 @@ export function Composer(props: ComposerProps) { } if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); - accept(items[active]); + accept(items[active], e.key === "Tab"); return; } if (e.key === "Escape") { @@ -345,7 +348,10 @@ export function Composer(props: ComposerProps) { {item.kind === "file" ? ( <> - {basename(item.rel)} + + {basename(item.rel)} + {item.dir && "/"} + {item.rel} ) : ( diff --git a/src/features/agents/HomeEditor.tsx b/src/features/agents/HomeEditor.tsx index e909aa5e4..bf8e04213 100644 --- a/src/features/agents/HomeEditor.tsx +++ b/src/features/agents/HomeEditor.tsx @@ -99,7 +99,9 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito id={HOME} autoFocus placeholder={ - docked ? "Describe a task…" : "Describe a task for the agent. @ references a file, / runs a command" + docked + ? "Describe a task…" + : "Describe a task for the agent. @ references a file or folder, / runs a command" } root={root} projectRoot={root} diff --git a/src/features/agents/agents.test.ts b/src/features/agents/agents.test.ts index 018f34652..57a66e7b6 100644 --- a/src/features/agents/agents.test.ts +++ b/src/features/agents/agents.test.ts @@ -15,6 +15,7 @@ import { nextValue, recall, toPrompt, + mergeMatches, retryParts, usageLabel, limitLabel, @@ -67,6 +68,27 @@ const option = (category: string, values: string[], current: string | null): Age via: "option", }); +describe("mergeMatches", () => { + it("ranks folders among files by score, files first on a tie", () => { + const merged = mergeMatches( + [ + { path: "/p/src/agents.ts", relPath: "src/agents.ts", score: 50 }, + { path: "/p/x.ts", relPath: "x.ts", score: 10 }, + ], + [ + { path: "/p/src/agents", relPath: "src/agents/", score: 50 }, + { path: "/p/docs", relPath: "docs/", score: 70 }, + ], + 3, + ); + expect(merged).toEqual([ + { path: "/p/docs", rel: "docs", dir: true, score: 70 }, + { path: "/p/src/agents.ts", rel: "src/agents.ts", dir: false, score: 50 }, + { path: "/p/src/agents", rel: "src/agents", dir: true, score: 50 }, + ]); + }); +}); + describe("agent composer", () => { it("completes @files anywhere and /commands as the first word", () => { expect(completionAt("fix @src/le", 11)).toEqual({ kind: "file", query: "src/le", from: 4, to: 11 }); @@ -111,6 +133,11 @@ describe("agent composer", () => { }); expect(prompt).toEqual({ text: "look at @src/a.rs:3-5 and @b.rs", files: ["/w/src/a.rs", "/w/b.rs"], images: [] }); expect(mentionOf("a.rs", { from: 3, to: 3 })).toBe("@a.rs:3"); + // A folder counts while `@src/` is left as typed, not once a file inside it was picked. + const folder = { path: "/w/src", rel: "src/" }; + expect(toPrompt({ text: "explain @src/ please", files: [folder], images: [] }).files).toEqual(["/w/src"]); + expect(toPrompt({ text: "explain @src/", files: [folder], images: [] }).files).toEqual(["/w/src"]); + expect(toPrompt({ text: "explain @src/a.rs", files: [folder], images: [] }).files).toEqual([]); expect(canSend({ text: " ", files: [], images: [] })).toBe(false); }); diff --git a/src/features/agents/composerFolders.test.tsx b/src/features/agents/composerFolders.test.tsx new file mode 100644 index 000000000..3e619361a --- /dev/null +++ b/src/features/agents/composerFolders.test.tsx @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const findFiles = vi.fn(); +const findDirs = vi.fn(); +const pathCompletions = vi.fn(); + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + isTauri: vi.fn(() => false), + commands: { + ...real.commands, + findFiles: (query: string, limit: number) => findFiles(query, limit), + findDirs: (query: string, limit: number) => findDirs(query, limit), + pathCompletions: (base: string, typed: string) => pathCompletions(base, typed), + }, + }; +}); + +import { Composer } from "./Composer"; +import { composerDraft, updateComposer } from "./store"; +import { emptyDraft, toPrompt } from "./composerModel"; + +const ID = "folders-test"; +const ok = (data: T) => Promise.resolve({ status: "ok", data }); + +function renderComposer() { + render( + {}} + />, + ); + return screen.getByPlaceholderText("Ask") as HTMLTextAreaElement; +} + +beforeEach(() => { + updateComposer(ID, emptyDraft()); + const match = (path: string, relPath: string, score: number) => ({ path, relPath, score, indices: [] }); + findFiles.mockReset().mockReturnValue(ok([match("/p/src/agents.ts", "src/agents.ts", 40)])); + findDirs.mockReset().mockReturnValue(ok([match("/p/src/agents", "src/agents/", 90)])); + pathCompletions.mockReset().mockReturnValue(ok([{ name: "lib", dir: true }])); +}); + +describe("Composer @ folders", () => { + it("lists index folders among files and references one on Enter", async () => { + const box = renderComposer(); + await userEvent.type(box, "see @agen"); + const options = await screen.findAllByRole("option"); + expect(options.map((o) => o.textContent)).toEqual(["agents/src/agents", "agents.tssrc/agents.ts"]); + await userEvent.keyboard("{Enter}"); + expect(box.value).toBe("see @src/agents/ "); + expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/agents"]); + }); + + it("keeps completing inside a folder on Tab, which drops the folder once a file is picked", async () => { + const box = renderComposer(); + await userEvent.type(box, "@agen"); + await screen.findAllByRole("option"); + await userEvent.keyboard("{Tab}"); + expect(box.value).toBe("@src/agents/"); + expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/agents"]); + await userEvent.type(box, "x.ts "); + expect(toPrompt(composerDraft(ID)).files).toEqual([]); + }); + + it("references a folder of a typed path", async () => { + const box = renderComposer(); + await userEvent.type(box, "@./"); + await screen.findByRole("option"); + await userEvent.keyboard("{Enter}"); + expect(box.value).toBe("@./lib/ "); + expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/lib"]); + }); +}); diff --git a/src/features/agents/composerModel.ts b/src/features/agents/composerModel.ts index fdc93c01e..a0aafd410 100644 --- a/src/features/agents/composerModel.ts +++ b/src/features/agents/composerModel.ts @@ -94,6 +94,29 @@ export function resolveTyped(root: string, rel: string): string { return isDrive(out[0]) ? out.join("/") : `/${out.join("/")}`; } +interface IndexMatch { + path: string; + relPath: string; + score: number; +} + +/** + * Index file and folder matches as one list, best first (files first on a tie); folders' + * `rel` loses the trailing `/` the index gives them. + */ +export function mergeMatches( + files: readonly IndexMatch[], + dirs: readonly IndexMatch[], + limit: number, +): { path: string; rel: string; dir: boolean; score: number }[] { + return [ + ...files.map((f) => ({ path: f.path, rel: f.relPath, dir: false, score: f.score })), + ...dirs.map((d) => ({ path: d.path, rel: d.relPath.replace(/\/$/, ""), dir: true, score: d.score })), + ] + .sort((a, b) => b.score - a.score || Number(a.dir) - Number(b.dir)) + .slice(0, limit); +} + /** `@rel` with a line range suffix when given ("@src/a.rs:10-20"). */ export function mentionOf(rel: string, lines?: { from: number; to: number }): string { if (!lines) return `@${rel}`; diff --git a/src/platform/locales/text/fr.json b/src/platform/locales/text/fr.json index bdf9b335e..2bfbaf8b4 100644 --- a/src/platform/locales/text/fr.json +++ b/src/platform/locales/text/fr.json @@ -3385,7 +3385,7 @@ "Not installed": "Non installé", "{command} was not found on PATH": "{command} est introuvable dans le PATH", "Agent tasks": "Tâches d'agent", - "Describe a task for the agent. @ references a file, / runs a command": "Décrivez une tâche pour l'agent. @ référence un fichier, / lance une commande", + "Describe a task for the agent. @ references a file or folder, / runs a command": "Décrivez une tâche pour l'agent. @ référence un fichier ou un dossier, / lance une commande", "away from your changes": "à l'écart de vos modifications", "edits your files directly": "modifie directement vos fichiers", "from": "depuis", diff --git a/src/platform/locales/text/ko.json b/src/platform/locales/text/ko.json index aafda5f84..6595d1a48 100644 --- a/src/platform/locales/text/ko.json +++ b/src/platform/locales/text/ko.json @@ -3492,7 +3492,7 @@ "Not installed": "설치되지 않음", "{command} was not found on PATH": "PATH에서 {command}을(를) 찾을 수 없습니다", "Agent tasks": "에이전트 작업", - "Describe a task for the agent. @ references a file, / runs a command": "에이전트에게 맡길 작업을 설명하세요. @는 파일 참조, /는 명령 실행", + "Describe a task for the agent. @ references a file or folder, / runs a command": "에이전트에게 맡길 작업을 설명하세요. @는 파일·폴더 참조, /는 명령 실행", "away from your changes": "변경 사항과 분리됨", "edits your files directly": "파일을 직접 편집함", "from": "기준", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 02cc037fd..6f027b9ab 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -3498,7 +3498,7 @@ "Not installed": "未安装", "{command} was not found on PATH": "在 PATH 中找不到 {command}", "Agent tasks": "Agent 任务", - "Describe a task for the agent. @ references a file, / runs a command": "描述要交给 agent 的任务。@ 引用文件,/ 运行命令", + "Describe a task for the agent. @ references a file or folder, / runs a command": "描述要交给 agent 的任务。@ 引用文件或文件夹,/ 运行命令", "away from your changes": "不影响你的改动", "edits your files directly": "直接修改你的文件", "from": "基于",