feat(agents): complete typed paths outside the project after @

@../, @./ and @/ list the directory typed so far instead of searching the
project index. Folders descend in place; files resolve to absolute paths.
This commit is contained in:
l0ng-ai
2026-10-30 11:19:08 +08:00
parent b1be3c6a09
commit e375fa3985
3 changed files with 61 additions and 3 deletions
+33 -3
View File
@@ -16,6 +16,8 @@ import {
MAX_IMAGE_BYTES,
applyCompletion,
atEdgeLine,
isPathQuery,
resolveTyped,
completionAt,
imageFromDataUrl,
matchCommands,
@@ -34,7 +36,7 @@ import { isImeKey } from "@/platform/ime";
const MAX_PROMPT_HEIGHT = 280;
const MAX_ITEMS = 12;
type Item = { kind: "file"; path: string; rel: string } | { kind: "command"; command: AgentCommand };
type Item = { kind: "file"; path: string; rel: string; dir?: boolean } | { kind: "command"; command: AgentCommand };
export interface ComposerProps {
/** Store key of the draft. */
@@ -100,8 +102,30 @@ export function Composer(props: ComposerProps) {
useEffect(() => {
if (completion?.kind !== "file") return;
let alive = true;
const query = completion.query;
const timer = setTimeout(() => {
call(commands.findFiles(completion.query, MAX_ITEMS))
if (isPathQuery(query)) {
// Outside the index: list the directory typed so far.
const typed = /(^|\/)\.\.?$/.test(query) ? `${query}/` : query;
const dirPart = typed.slice(0, typed.lastIndexOf("/") + 1);
const last = typed.slice(dirPart.length);
call(commands.pathCompletions(`${typed.startsWith("/") ? "" : root}/_`, typed))
.then((found) => {
if (!alive) return;
setFiles(
found
.filter((f) => f.name.toLowerCase().startsWith(last.toLowerCase()))
.slice(0, MAX_ITEMS)
.map((f) => {
const rel = `${dirPart}${f.name}`;
return { kind: "file", rel, dir: f.dir, path: resolveTyped(root, rel) };
}),
);
})
.catch(() => alive && setFiles([]));
return;
}
call(commands.findFiles(query, MAX_ITEMS))
.then((found) => {
if (!alive) return;
setFiles(
@@ -145,6 +169,12 @@ export function Composer(props: ComposerProps) {
const accept = (item: Item | undefined) => {
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);
@@ -311,7 +341,7 @@ export function Composer(props: ComposerProps) {
>
{item.kind === "file" ? (
<>
<Icon name={fileIconName(basename(item.rel))} size={16} />
<Icon name={item.dir ? "folder" : fileIconName(basename(item.rel))} size={16} />
<span className={styles.itemName}>{basename(item.rel)}</span>
<span className={styles.itemDetail}>{item.rel}</span>
</>
+15
View File
@@ -5,6 +5,8 @@ import {
atEdgeLine,
canSend,
completionAt,
isPathQuery,
resolveTyped,
imageFromDataUrl,
mainOptions,
matchCommands,
@@ -275,3 +277,16 @@ describe("retryParts", () => {
expect(retryParts({ ...retry, maxRetries: null }, 9_000)).toEqual({ attempt: "3", wait: 0 });
});
});
describe("typed path references", () => {
it("tells a path from a search", () => {
for (const q of ["../", "..", "./a", "/etc", "../x/y"]) expect(isPathQuery(q)).toBe(true);
for (const q of ["", "src/le", ".gitignore", "a/../b"]) expect(isPathQuery(q)).toBe(false);
});
it("resolves against the task root", () => {
expect(resolveTyped("/r/idea", "../kalo/a.ts")).toBe("/r/kalo/a.ts");
expect(resolveTyped("/r/idea", "./src")).toBe("/r/idea/src");
expect(resolveTyped("/r/idea", "/etc/hosts")).toBe("/etc/hosts");
});
});
+13
View File
@@ -74,6 +74,19 @@ function inOrder(text: string, query: string): boolean {
return i >= query.length;
}
/** Whether an `@` query is a typed-out path (`../x`, `./x`, `/x`) rather than a project file search. */
export const isPathQuery = (query: string) => /^(\.\.?(\/|$)|\/)/.test(query);
/** `rel` (as typed after `@`) resolved against `root`, `.` and `..` folded lexically. */
export function resolveTyped(root: string, rel: string): string {
const out: string[] = [];
for (const part of (rel.startsWith("/") ? rel : `${root}/${rel}`).split("/")) {
if (part === "..") out.pop();
else if (part && part !== ".") out.push(part);
}
return `/${out.join("/")}`;
}
/** `@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}`;