diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index d30cf6252b66..0df1ba2f2782 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -271,47 +271,6 @@ export function Autocomplete(props: { } } - function createReferenceFilePart(input: { - alias: string - root: string - item: string - lineRange?: { startLine: number; endLine?: number } - }) { - const filename = `${input.alias}/${ - input.lineRange && !input.item.endsWith("/") - ? `${input.item}#${input.lineRange.startLine}${input.lineRange.endLine ? `-${input.lineRange.endLine}` : ""}` - : input.item - }` - const urlObj = pathToFileURL(path.join(input.root, input.item)) - - if (input.lineRange && !input.item.endsWith("/")) { - urlObj.searchParams.set("start", String(input.lineRange.startLine)) - if (input.lineRange.endLine !== undefined) { - urlObj.searchParams.set("end", String(input.lineRange.endLine)) - } - } - - return { - filename, - url: urlObj.href, - part: { - type: "file" as const, - mime: input.item.endsWith("/") ? "application/x-directory" : "text/plain", - filename, - url: urlObj.href, - source: { - type: "file" as const, - text: { - start: 0, - end: 0, - value: "", - }, - path: filename, - }, - }, - } - } - function referencePromptText(reference: Reference.Resolved) { const problem = reference.kind === "invalid" ? reference.message : undefined return [ @@ -336,18 +295,12 @@ export function Autocomplete(props: { }), ) - const referenceSearch = createMemo(() => { + const referenceMatch = createMemo(() => { if (!store.visible || store.visible === "/") return - const { lineRange, baseQuery } = extractLineRange(search()) + const { baseQuery } = extractLineRange(search()) const slash = baseQuery.indexOf("/") - if (slash === -1) return - const reference = references().find((item) => item.name === baseQuery.slice(0, slash)) - if (!reference || reference.kind === "invalid") return - return { - reference, - query: baseQuery.slice(slash + 1), - lineRange, - } + const alias = slash === -1 ? baseQuery : baseQuery.slice(0, slash) + return references().find((item) => item.name === alias) }) function normalizeMentionPath(filePath: string) { @@ -380,7 +333,7 @@ export function Autocomplete(props: { () => search(), async (query) => { if (!store.visible || store.visible === "/") return [] - if (referenceSearch()) return [] + if (referenceMatch()) return [] const { lineRange, baseQuery } = extractLineRange(query ?? "") @@ -430,43 +383,6 @@ export function Autocomplete(props: { }, ) - const [referenceFiles] = createResource( - () => referenceSearch(), - async (match) => { - if (!match) return [] - - const result = await sdk.client.find.files({ - directory: match.reference.path, - query: match.query, - limit: 50, - }) - - if (result.error || !result.data) return [] - - const width = props.anchor().width - 4 - return result.data.map((item): AutocompleteOption => { - const { filename, part } = createReferenceFilePart({ - alias: match.reference.name, - root: match.reference.path, - item, - lineRange: match.lineRange, - }) - return { - display: Locale.truncateMiddle(filename, width), - value: filename, - isDirectory: item.endsWith("/"), - path: filename, - onSelect: () => { - insertPart(filename, part) - }, - } - }) - }, - { - initialValue: [], - }, - ) - const mcpResources = createMemo(() => { if (!store.visible || store.visible === "/") return [] @@ -529,8 +445,22 @@ export function Autocomplete(props: { references().map( (reference): AutocompleteOption => ({ display: "@" + reference.name, - description: reference.kind === "invalid" ? reference.message : " configured reference", + description: reference.kind === "invalid" ? reference.message : " dir", onSelect: () => { + if (reference.kind !== "invalid") { + insertPart(reference.name, { + type: "file", + mime: "application/x-directory", + filename: reference.name, + url: pathToFileURL(reference.path).href, + source: { + type: "file", + text: { start: 0, end: 0, value: "" }, + path: reference.name, + }, + }) + return + } insertPart(reference.name, { type: "text", text: referencePromptText(reference), @@ -572,16 +502,15 @@ export function Autocomplete(props: { const options = createMemo((prev: AutocompleteOption[] | undefined) => { const filesValue = files() - const referenceFilesValue = referenceFiles() - const referenceSearchValue = referenceSearch() + const referenceMatchValue = referenceMatch() const agentsValue = agents() const referenceAliasesValue = referenceAliases() const commandsValue = commands() const mixed: AutocompleteOption[] = store.visible === "@" - ? referenceSearchValue - ? referenceFilesValue || [] + ? referenceMatchValue + ? referenceAliasesValue.filter((item) => item.display === `@${referenceMatchValue.name}`) : [...referenceAliasesValue, ...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue] @@ -591,10 +520,12 @@ export function Autocomplete(props: { return mixed } - if ((files.loading || referenceFiles.loading) && prev && prev.length > 0) { + if (files.loading && prev && prev.length > 0) { return prev } + if (referenceMatchValue) return mixed + const result = fuzzysort.go(removeLineRange(searchValue), mixed, { keys: [ (obj) => removeLineRange((obj.value ?? obj.display).trimEnd()), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a75a3c993ac5..d036c6542d3a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -145,9 +145,47 @@ export const layer = Layer.effect( yield* state.cancel(sessionID) }) + const resolveReferenceParts = Effect.fnUntraced(function* (template: string) { + const parts: Types.DeepMutable = [] + const seen = new Set() + yield* Effect.forEach( + ConfigMarkdown.files(template), + Effect.fnUntraced(function* (match) { + const name = match[1] + if (!name) return + const alias = name.split("/")[0] + if (!alias || seen.has(alias)) return + const reference = yield* references.get(alias) + if (!reference) return + seen.add(alias) + + const start = match.index ?? 0 + const source = { value: match[0], start, end: start + match[0].length } + if (reference.kind === "invalid") { + parts.push(referenceTextPart({ reference, source })) + return + } + + yield* references.ensure(reference.path) + parts.push({ + type: "file", + url: pathToFileURL(reference.path).href, + filename: alias, + mime: "application/x-directory", + source: { type: "file", text: source, path: alias }, + }) + }), + { concurrency: 1, discard: true }, + ) + return parts + }) + const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) { const ctx = yield* InstanceState.context - const parts: Types.DeepMutable = [{ type: "text", text: template }] + const parts: Types.DeepMutable = [ + { type: "text", text: template }, + ...(yield* resolveReferenceParts(template)), + ] const files = ConfigMarkdown.files(template) const seen = new Set() yield* Effect.forEach( @@ -160,60 +198,7 @@ export const layer = Layer.effect( const slash = name.indexOf("/") const alias = slash === -1 ? name : name.slice(0, slash) - const reference = yield* references.get(alias) - if (reference) { - const start = match.index ?? 0 - const source = { value: match[0], start, end: start + match[0].length } - if (reference.kind === "invalid") { - parts.push( - referenceTextPart({ reference, source, target: slash === -1 ? undefined : name.slice(slash + 1) }), - ) - return - } - - yield* references.ensure(reference.path) - if (slash === -1) { - parts.push(referenceTextPart({ reference, source })) - return - } - - const target = name.slice(slash + 1) - const targetPath = path.resolve(reference.path, target) - if (!FSUtil.contains(reference.path, targetPath)) { - parts.push( - referenceTextPart({ - reference, - source, - target, - targetPath, - problem: `Path escapes configured reference @${alias}: ${target}`, - }), - ) - return - } - - const info = yield* fsys.stat(targetPath).pipe(Effect.option) - if (Option.isNone(info)) { - parts.push( - referenceTextPart({ - reference, - source, - target, - targetPath, - problem: `Path does not exist inside configured reference @${alias}: ${target}`, - }), - ) - return - } - - parts.push({ - type: "file", - url: pathToFileURL(targetPath).href, - filename: name, - mime: info.value.type === "Directory" ? "application/x-directory" : "text/plain", - }) - return - } + if (yield* references.get(alias)) return const filepath = name.startsWith("~/") ? path.join(os.homedir(), name.slice(2)) @@ -770,30 +755,6 @@ export const layer = Layer.effect( id: part.id ? PartID.make(part.id) : PartID.ascending(), }) - const referenceContextFromFilePart = Effect.fnUntraced(function* ( - part: Extract, - filepath: string, - ) { - const name = part.filename?.replace(/#\d+(?:-\d*)?$/, "") - if (!name) return - const slash = name.indexOf("/") - if (slash === -1) return - - const reference = yield* references.get(name.slice(0, slash)) - if (!reference || reference.kind === "invalid") return - if (!FSUtil.contains(reference.path, filepath)) return - - const target = path.relative(reference.path, filepath).split(path.sep).join("/") - if (!target || target.startsWith("../") || target === "..") return - - return referenceTextPart({ - reference, - source: part.source?.text ?? { value: `@${name}`, start: 0, end: name.length + 1 }, - target, - targetPath: filepath, - }) - }) - const resolvePart: (part: PromptInput["parts"][number]) => Effect.Effect[]> = Effect.fn( "SessionPrompt.resolveUserPart", )(function* (part) { @@ -876,7 +837,6 @@ export const layer = Layer.effect( case "file:": { log.info("file", { mime: part.mime }) const filepath = fileURLToPath(part.url) - const referenceContext = yield* referenceContextFromFilePart(part, filepath) const mime = (yield* fsys.isDir(filepath)) ? "application/x-directory" : part.mime const { read } = yield* registry.named() @@ -922,9 +882,6 @@ export const layer = Layer.effect( } const args = { filePath: filepath, offset, limit } const pieces: Draft[] = [ - ...(referenceContext - ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }] - : []), { messageID: info.id, sessionID: input.sessionID, @@ -990,9 +947,6 @@ export const layer = Layer.effect( error: new NamedError.Unknown({ message }).toObject(), }) return [ - ...(referenceContext - ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }] - : []), { messageID: info.id, sessionID: input.sessionID, @@ -1003,9 +957,6 @@ export const layer = Layer.effect( ] } return [ - ...(referenceContext - ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }] - : []), { messageID: info.id, sessionID: input.sessionID, @@ -1025,7 +976,6 @@ export const layer = Layer.effect( } return [ - ...(referenceContext ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }] : []), { messageID: info.id, sessionID: input.sessionID, @@ -1071,7 +1021,22 @@ export const layer = Layer.effect( return [{ ...part, messageID: info.id, sessionID: input.sessionID }] }) - const resolvedParts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe( + const submittedParts: Types.DeepMutable = [...input.parts] + const attachedReferences = new Set( + input.parts.flatMap((part) => + part.type === "file" && part.mime === "application/x-directory" ? [part.url] : [], + ), + ) + for (const part of input.parts) { + if (part.type !== "text" || part.synthetic) continue + for (const reference of yield* resolveReferenceParts(part.text)) { + if (reference.type === "file" && attachedReferences.has(reference.url)) continue + if (reference.type === "file") attachedReferences.add(reference.url) + submittedParts.push(reference) + } + } + + const resolvedParts = yield* Effect.forEach(submittedParts, resolvePart, { concurrency: "unbounded" }).pipe( Effect.map((x) => x.flat().map(assign)), ) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 7c2f6be0f071..cb9694b56dbd 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1969,7 +1969,7 @@ noLLMServer.instance( ) noLLMServer.instance( - "resolves configured reference mentions before workspace paths and agents", + "resolves configured reference mentions to one root directory attachment", () => Effect.gen(function* () { const { directory: dir } = yield* TestInstance @@ -1984,33 +1984,18 @@ noLLMServer.instance( const parts = yield* prompt.resolvePromptParts( "Use @docs and @docs/README.md and @docs/guide and @docs/missing.md and @docs/README.md and @build", ) - const references = parts.filter( - (part): part is SessionV1.TextPartInput => - part.type === "text" && part.synthetic === true && part.text.startsWith("Referenced configured reference "), - ) const files = parts.filter((part): part is SessionV1.FilePartInput => part.type === "file") const agents = parts.filter((part): part is SessionV1.AgentPartInput => part.type === "agent") - const bare = references.find((part) => part.text.includes("@docs.")) - const missing = references.find((part) => part.text.includes("@docs/missing.md")) - const guide = files.find((part) => part.filename === "docs/guide") - - expect(references.length).toBe(2) - expect(bare?.metadata?.reference).toMatchObject({ - name: "docs", - kind: "local", - path: docs, - }) - expect(missing?.text).toContain("Path does not exist inside configured reference @docs") - expect(missing?.metadata?.reference).toMatchObject({ - target: "missing.md", - targetPath: path.join(docs, "missing.md"), + const text = parts.find((part): part is SessionV1.TextPartInput => part.type === "text" && !part.synthetic) + + expect(text?.text).toContain("@docs") + expect(files).toHaveLength(1) + expect(files[0]).toMatchObject({ + filename: "docs", + mime: "application/x-directory", + source: { type: "file", path: "docs", text: { value: "@docs" } }, }) - - expect(files.length).toBe(2) - expect(files.map((file) => fileURLToPath(file.url)).sort()).toEqual( - [path.join(docs, "README.md"), path.join(docs, "guide")].sort(), - ) - expect(guide?.mime).toBe("application/x-directory") + expect(fileURLToPath(files[0].url)).toBe(docs) expect(agents.map((agent) => agent.name)).toEqual(["build"]) }), { @@ -2024,96 +2009,38 @@ noLLMServer.instance( ) noLLMServer.instance( - "injects metadata for bare configured reference mentions", - () => - Effect.gen(function* () { - const { directory: dir } = yield* TestInstance - const docs = path.join(dir, "external-docs") - yield* ensureDir(docs) - - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({}) - const message = yield* prompt.prompt({ - sessionID: session.id, - noReply: true, - parts: yield* prompt.resolvePromptParts("Use @docs for context"), - }) - - const stored = yield* MessageV2.get({ sessionID: session.id, messageID: message.info.id }) - const synthetic = stored.parts.filter( - (part): part is SessionV1.TextPart => part.type === "text" && part.synthetic === true, - ) - const reference = synthetic.find((part) => part.text.startsWith("Referenced configured reference @docs.")) - - expect(reference?.metadata?.reference).toMatchObject({ name: "docs", kind: "local", path: docs }) - expect(synthetic.some((part) => part.text.includes(`Reference root: ${docs}`))).toBe(true) - expect(synthetic.some((part) => part.text.includes("Inspect the configured reference"))).toBe(true) - - yield* sessions.remove(session.id) - }), - { - config: { - ...cfg, - reference: { - docs: "./external-docs", - }, - }, - }, -) - -noLLMServer.instance( - "injects metadata for configured reference file attachments", + "stores raw reference mentions alongside directory attachments", () => Effect.gen(function* () { const { directory: dir } = yield* TestInstance const docs = path.join(dir, "external-docs") - const readme = path.join(docs, "README.md") yield* ensureDir(docs) - yield* writeText(readme, "reference readme") const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service const session = yield* sessions.create({}) const message = yield* prompt.prompt({ sessionID: session.id, - agent: "build", noReply: true, - parts: [ - { type: "text", text: "Read @docs/README.md" }, - { - type: "file", - mime: "text/plain", - filename: "docs/README.md", - url: pathToFileURL(readme).href, - source: { - type: "file", - path: "docs/README.md", - text: { value: "@docs/README.md", start: 5, end: 20 }, - }, - }, - ], + parts: [{ type: "text", text: "Use @docs for context" }], }) const stored = yield* MessageV2.get({ sessionID: session.id, messageID: message.info.id }) const synthetic = stored.parts.filter( (part): part is SessionV1.TextPart => part.type === "text" && part.synthetic === true, ) - const reference = synthetic.find((part) => - part.text.startsWith("Referenced configured reference @docs/README.md."), - ) - - expect(reference?.metadata?.reference).toMatchObject({ - name: "docs", - kind: "local", - path: docs, - target: "README.md", - targetPath: readme, - source: { value: "@docs/README.md", start: 5, end: 20 }, + const files = stored.parts.filter((part): part is SessionV1.FilePart => part.type === "file") + const text = stored.parts.find((part): part is SessionV1.TextPart => part.type === "text" && !part.synthetic) + + expect(text?.text).toBe("Use @docs for context") + expect(synthetic.some((part) => part.text.includes(JSON.stringify({ filePath: docs })))).toBe(true) + expect(files).toHaveLength(1) + expect(files[0]).toMatchObject({ + filename: "docs", + mime: "application/x-directory", + source: { type: "file", path: "docs", text: { value: "@docs", start: 4, end: 9 } }, }) - expect(synthetic.findIndex((part) => part === reference)).toBeLessThan( - synthetic.findIndex((part) => part.text.startsWith("Called the Read tool with the following input:")), - ) + expect(fileURLToPath(files[0].url)).toBe(docs) yield* sessions.remove(session.id) }),