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}`;