diff --git a/src/features/agents/Composer.tsx b/src/features/agents/Composer.tsx index b08558d8..22a045aa 100644 --- a/src/features/agents/Composer.tsx +++ b/src/features/agents/Composer.tsx @@ -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" ? ( <> - + {basename(item.rel)} {item.rel} diff --git a/src/features/agents/agents.test.ts b/src/features/agents/agents.test.ts index 085a2b39..5b269bc6 100644 --- a/src/features/agents/agents.test.ts +++ b/src/features/agents/agents.test.ts @@ -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"); + }); +}); diff --git a/src/features/agents/composerModel.ts b/src/features/agents/composerModel.ts index f599c1bc..66e0d7ed 100644 --- a/src/features/agents/composerModel.ts +++ b/src/features/agents/composerModel.ts @@ -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}`;