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 7f390f0eb6..3242de94d6 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -18,6 +18,8 @@ import { Locale } from "@/util/locale" import type { PromptInfo } from "./history" import { useFrecency } from "./frecency" import { useBindings } from "../../keymap" +import { Reference } from "@/reference/reference" +import type { Config } from "@/config/config" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") @@ -260,6 +262,87 @@ 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 [ + `Referenced configured reference @${reference.name}.`, + ...(reference.kind === "local" ? ["Kind: local directory"] : []), + ...(reference.kind === "git" ? ["Kind: git repository"] : []), + ...(reference.kind === "invalid" ? [`Repository: ${reference.repository}`] : []), + ...(reference.kind === "git" ? [`Repository: ${reference.repository}`] : []), + ...(reference.kind === "git" && reference.branch ? [`Branch/ref: ${reference.branch}`] : []), + ...(reference.kind === "invalid" ? [] : [`Reference root: ${reference.path}`]), + ...(problem + ? [`Problem: ${problem}`] + : [ + "For targeted context, inspect the reference path directly with Read, Glob, and Grep. For broader research, call the task tool with subagent scout and include this reference path.", + ]), + ].join("\n") + } + + const references = createMemo(() => + Reference.resolveAll({ + references: (sync.data.config.reference ?? {}) as NonNullable, + directory: sync.path.directory || process.cwd(), + worktree: sync.path.worktree || sync.path.directory || process.cwd(), + }), + ) + + const referenceSearch = createMemo(() => { + if (!store.visible || store.visible === "/") return + const { lineRange, 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, + } + }) + function normalizeMentionPath(filePath: string) { const baseDir = sync.path.directory || process.cwd() const absolute = path.resolve(filePath) @@ -291,6 +374,7 @@ export function Autocomplete(props: { () => search(), async (query) => { if (!store.visible || store.visible === "/") return [] + if (referenceSearch()) return [] const { lineRange, baseQuery } = extractLineRange(query ?? "") @@ -339,6 +423,43 @@ 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 [] @@ -397,6 +518,22 @@ export function Autocomplete(props: { ) }) + const referenceAliases = createMemo(() => + references().map( + (reference): AutocompleteOption => ({ + display: "@" + reference.name, + description: reference.kind === "invalid" ? reference.message : " configured reference", + onSelect: () => { + insertPart(reference.name, { + type: "text", + text: referencePromptText(reference), + synthetic: true, + }) + }, + }), + ), + ) + const commands = createMemo((): AutocompleteOption[] => { const results: AutocompleteOption[] = [...command.slashes()] @@ -428,11 +565,18 @@ export function Autocomplete(props: { const options = createMemo((prev: AutocompleteOption[] | undefined) => { const filesValue = files() + const referenceFilesValue = referenceFiles() + const referenceSearchValue = referenceSearch() const agentsValue = agents() + const referenceAliasesValue = referenceAliases() const commandsValue = commands() const mixed: AutocompleteOption[] = - store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue] + store.visible === "@" + ? referenceSearchValue + ? referenceFilesValue || [] + : [...referenceAliasesValue, ...agentsValue, ...(filesValue || []), ...mcpResources()] + : [...commandsValue] const searchValue = search() @@ -440,7 +584,7 @@ export function Autocomplete(props: { return mixed } - if (files.loading && prev && prev.length > 0) { + if ((files.loading || referenceFiles.loading) && prev && prev.length > 0) { return prev } @@ -505,7 +649,7 @@ export function Autocomplete(props: { const input = props.input() const currentCursorOffset = input.cursorOffset - const displayText = selected.display.trimEnd() + const displayText = (selected.value ?? selected.display).trimEnd() const path = displayText.startsWith("@") ? displayText.slice(1) : displayText input.cursorOffset = store.index diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 114a388036..c05d562c9d 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -145,7 +145,7 @@ export const Info = Schema.Struct({ }), skills: Schema.optional(ConfigSkills.Info).annotate({ description: "Additional skill folder paths" }), reference: Schema.optional(ConfigReference.Info).annotate({ - description: "Named git or local directory references that can be @ mentioned as Scout-backed subagents", + description: "Named git or local directory references that can be mentioned as @alias or @alias/path", }), watcher: Schema.optional( Schema.Struct({ diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 3b919e2f0a..6033b0944c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -121,6 +121,45 @@ function referencePromptMetadata(input: unknown): ReferencePromptMetadata | unde } } +function referenceTextPart(input: { + reference: Reference.Resolved + source: ReferencePromptMetadata["source"] + target?: string + targetPath?: string + problem?: string +}): MessageV2.TextPartInput { + const metadata: ReferencePromptMetadata = { + name: input.reference.name, + kind: input.reference.kind, + ...(input.reference.kind === "invalid" ? { repository: input.reference.repository } : { path: input.reference.path }), + ...(input.reference.kind === "git" ? { repository: input.reference.repository, branch: input.reference.branch } : {}), + ...(input.target === undefined ? {} : { target: input.target }), + ...(input.targetPath ? { targetPath: input.targetPath } : {}), + problem: input.problem ?? (input.reference.kind === "invalid" ? input.reference.message : undefined), + source: input.source, + } + const label = metadata.target === undefined ? `@${metadata.name}` : `@${metadata.name}/${metadata.target}` + return { + type: "text", + synthetic: true, + text: [ + `Referenced configured reference ${label}.`, + ...(metadata.kind === "local" ? ["Kind: local directory"] : []), + ...(metadata.kind === "git" ? ["Kind: git repository"] : []), + ...(metadata.repository ? [`Repository: ${metadata.repository}`] : []), + ...(metadata.branch ? [`Branch/ref: ${metadata.branch}`] : []), + ...(metadata.path ? [`Reference root: ${metadata.path}`] : []), + ...(metadata.targetPath ? [`Resolved path: ${metadata.targetPath}`] : []), + ...(metadata.problem + ? [`Problem: ${metadata.problem}`] + : [ + "For targeted context, inspect the reference path directly with Read, Glob, and Grep. For broader research, call the task tool with subagent scout and include this reference path.", + ]), + ].join("\n"), + metadata: { reference: metadata }, + } +} + export interface Interface { readonly cancel: (sessionID: SessionID) => Effect.Effect readonly prompt: (input: PromptInput) => Effect.Effect @@ -186,48 +225,6 @@ export const layer = Layer.effect( const start = match.index ?? 0 return { value: match[0], start, end: start + match[0].length } } - const referenceTextPart = (input: { - reference: Reference.Resolved - source: ReturnType - target?: string - targetPath?: string - problem?: string - }): MessageV2.TextPartInput => { - const metadata: ReferencePromptMetadata = { - name: input.reference.name, - kind: input.reference.kind, - ...(input.reference.kind === "invalid" - ? { repository: input.reference.repository } - : { path: input.reference.path }), - ...(input.reference.kind === "git" - ? { repository: input.reference.repository, branch: input.reference.branch } - : {}), - ...(input.target === undefined ? {} : { target: input.target }), - ...(input.targetPath ? { targetPath: input.targetPath } : {}), - problem: input.problem ?? (input.reference.kind === "invalid" ? input.reference.message : undefined), - source: input.source, - } - const label = metadata.target === undefined ? `@${metadata.name}` : `@${metadata.name}/${metadata.target}` - return { - type: "text", - synthetic: true, - text: [ - `Referenced configured reference ${label}.`, - ...(metadata.kind === "local" ? ["Kind: local directory"] : []), - ...(metadata.kind === "git" ? ["Kind: git repository"] : []), - ...(metadata.repository ? [`Repository: ${metadata.repository}`] : []), - ...(metadata.branch ? [`Branch/ref: ${metadata.branch}`] : []), - ...(metadata.path ? [`Reference root: ${metadata.path}`] : []), - ...(metadata.targetPath ? [`Resolved path: ${metadata.targetPath}`] : []), - ...(metadata.problem - ? [`Problem: ${metadata.problem}`] - : [ - "For targeted context, inspect the reference path directly with Read, Glob, and Grep. For broader research, call the task tool with subagent scout and include this reference path.", - ]), - ].join("\n"), - metadata: { reference: metadata }, - } - } yield* Effect.forEach( files, Effect.fnUntraced(function* (match) { @@ -1156,6 +1153,30 @@ NOTE: At any point in time through this workflow you should feel free to ask the 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 (!AppFileSystem.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) { @@ -1238,6 +1259,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the 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() @@ -1283,6 +1305,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the } const args = { filePath: filepath, offset, limit } const pieces: Draft[] = [ + ...(referenceContext + ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }] + : []), { messageID: info.id, sessionID: input.sessionID, @@ -1348,6 +1373,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the error: new NamedError.Unknown({ message }).toObject(), }) return [ + ...(referenceContext + ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }] + : []), { messageID: info.id, sessionID: input.sessionID, @@ -1358,6 +1386,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the ] } return [ + ...(referenceContext + ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }] + : []), { messageID: info.id, sessionID: input.sessionID, @@ -1377,6 +1408,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the } return [ + ...(referenceContext ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }] : []), { messageID: info.id, sessionID: input.sessionID, diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index f5c1674658..1043465305 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -4,7 +4,7 @@ import { expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer } from "effect" import fs from "fs/promises" import path from "path" -import { fileURLToPath } from "url" +import { fileURLToPath, pathToFileURL } from "url" import { NamedError } from "@opencode-ai/core/util/error" import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" @@ -1889,6 +1889,70 @@ it.live("injects metadata for bare configured reference mentions", () => ), ) +it.live("injects metadata for configured reference file attachments", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const docs = path.join(dir, "external-docs") + const readme = path.join(docs, "README.md") + yield* Effect.promise(() => fs.mkdir(docs, { recursive: true })) + yield* Effect.promise(() => Bun.write(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 }, + }, + }, + ], + }) + + const stored = MessageV2.get({ sessionID: session.id, messageID: message.info.id }) + const synthetic = stored.parts.filter( + (part): part is MessageV2.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 }, + }) + expect(synthetic.findIndex((part) => part === reference)).toBeLessThan( + synthetic.findIndex((part) => part.text.startsWith("Called the Read tool with the following input:")), + ) + + yield* sessions.remove(session.id) + }), + { + git: true, + config: { + ...cfg, + reference: { + docs: "./external-docs", + }, + }, + }, + ), +) + // Special characters in filenames it.live("handles filenames with # character", () => diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index 72d9658d16..b9103c702f 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -41,6 +41,12 @@ How is auth handled in @packages/functions/src/api/index.ts? The content of the file is added to the conversation automatically. +Configured references also appear in `@` autocomplete. Type `@alias` to add the reference root as context, or type `@alias/` to autocomplete files inside that reference. + +```text "@docs/README.md" +Compare our setup with @docs/README.md +``` + --- ## Bash commands