From 1f160b57697b3347622712aafafdb24bf5e8d078 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:08:49 +0000 Subject: [PATCH] fix(app): autocomplete configured references Co-authored-by: opencode-agent[bot] --- packages/app/src/components/prompt-input.tsx | 81 ++++++++++++++++--- .../prompt-input/build-request-parts.test.ts | 35 ++++++++ .../prompt-input/build-request-parts.ts | 4 +- .../components/prompt-input/slash-popover.tsx | 18 +++++ packages/app/src/context/prompt.tsx | 10 ++- 5 files changed, 136 insertions(+), 12 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index e6f933f0f7..a720e6062e 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -28,6 +28,7 @@ import { } from "@/context/prompt" import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" +import { useServerSDK } from "@/context/server-sdk" import { useSync } from "@/context/sync" import { useComments } from "@/context/comments" import { Button } from "@opencode-ai/ui/button" @@ -68,6 +69,7 @@ import { promptPlaceholder } from "./prompt-input/placeholder" import { createPromptInputTransientState } from "./prompt-input/transient-state" import { showToast } from "@/utils/toast" import { ImagePreview } from "@opencode-ai/ui/image-preview" +import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" export type PromptInputState = ReturnType @@ -198,6 +200,7 @@ const EXAMPLES = [ export const PromptInput: Component = (props) => { const sdk = useSDK() + const serverSDK = useServerSDK() const sync = useSync() const files = useFile() @@ -629,6 +632,43 @@ export const PromptInput: Component = (props) => { }) } + const [references, { refetch: refetchReferences }] = createResource( + () => sdk().directory, + async () => { + try { + const result = await sdk().client.v2.reference.list() + return result.data?.data ?? [] + } catch { + return [] + } + }, + { initialValue: [] as ReferenceInfo[] }, + ) + + createEffect(() => { + const unsubscribe = serverSDK().event.listen((event) => { + if (event.details.type === "reference.updated") void refetchReferences() + }) + onCleanup(unsubscribe) + }) + + const referenceDescription = (reference: ReferenceInfo) => + reference.source.type === "git" ? reference.source.repository : reference.source.path + + const referenceList = createMemo(() => + references() + .filter((reference) => !reference.hidden) + .map( + (reference): AtOption => ({ + type: "reference", + name: reference.name, + path: reference.path, + display: reference.name, + description: reference.description ?? referenceDescription(reference), + }), + ), + ) + const agentList = createMemo(() => props.controls.agents.available .filter((agent) => !agent.hidden && agent.mode !== "primary") @@ -639,14 +679,28 @@ export const PromptInput: Component = (props) => { if (!option) return if (option.type === "agent") { addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 }) - } else { - addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) + return } + if (option.type === "reference") { + addPart({ + type: "file", + path: option.path, + content: "@" + option.name, + start: 0, + end: 0, + mime: "application/x-directory", + filename: option.name, + }) + return + } + addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) } const atKey = (x: AtOption | undefined) => { if (!x) return "" - return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}` + if (x.type === "agent") return `agent:${x.name}` + if (x.type === "reference") return `reference:${x.name}` + return `file:${x.path}` } const { @@ -657,30 +711,33 @@ export const PromptInput: Component = (props) => { onKeyDown: atOnKeyDown, } = useFilteredList({ items: async (query) => { + const references = referenceList() const agents = agentList() const open = recent() const seen = new Set(open) const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) - if (!query.trim()) return [...agents, ...pinned] + if (!query.trim()) return [...references, ...agents, ...pinned] const paths = await files.searchFilesAndDirectories(query) const fileOptions: AtOption[] = paths .filter((path) => !seen.has(path)) .map((path) => ({ type: "file", path, display: path })) - return [...agents, ...pinned, ...fileOptions] + return [...references, ...agents, ...pinned, ...fileOptions] }, key: atKey, filterKeys: ["display"], skipFilter: (item) => item.type === "file" && !item.recent, groupBy: (item) => { + if (item.type === "reference") return "reference" if (item.type === "agent") return "agent" if (item.recent) return "recent" return "file" }, sortGroupsBy: (a, b) => { const rank = (category: string) => { - if (category === "agent") return 0 - if (category === "recent") return 1 - return 2 + if (category === "reference") return 0 + if (category === "agent") return 1 + if (category === "recent") return 2 + return 3 } return rank(a.category) - rank(b.category) }, @@ -746,7 +803,11 @@ export const PromptInput: Component = (props) => { const pill = document.createElement("span") pill.textContent = part.content pill.setAttribute("data-type", part.type) - if (part.type === "file") pill.setAttribute("data-path", part.path) + if (part.type === "file") { + pill.setAttribute("data-path", part.path) + if (part.mime) pill.setAttribute("data-mime", part.mime) + if (part.filename) pill.setAttribute("data-filename", part.filename) + } if (part.type === "agent") pill.setAttribute("data-name", part.name) pill.setAttribute("contenteditable", "false") pill.style.userSelect = "text" @@ -868,6 +929,8 @@ export const PromptInput: Component = (props) => { content, start: position, end: position + content.length, + ...(file.dataset.mime ? { mime: file.dataset.mime } : {}), + ...(file.dataset.filename ? { filename: file.dataset.filename } : {}), }) position += content.length } diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts index 099200f23f..ab84cb6eae 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.test.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts @@ -100,6 +100,41 @@ describe("buildRequestParts", () => { ) }) + test("preserves reference aliases as directory file parts", () => { + const result = buildRequestParts({ + prompt: [ + { + type: "file", + path: "/repo/../docs", + content: "@docs", + start: 0, + end: 5, + mime: "application/x-directory", + filename: "docs", + }, + ], + context: [], + images: [], + text: "@docs", + messageID: "msg_reference", + sessionID: "ses_reference", + sessionDirectory: "/repo/app", + }) + + const filePart = result.requestParts.find((part) => part.type === "file") + expect(filePart).toBeDefined() + if (filePart?.type === "file") { + expect(filePart.mime).toBe("application/x-directory") + expect(filePart.filename).toBe("docs") + expect(filePart.url).toBe("file:///repo/../docs") + expect(filePart.source?.type).toBe("file") + if (filePart.source?.type === "file") { + expect(filePart.source.path).toBe("/repo/../docs") + expect(filePart.source.text.value).toBe("@docs") + } + } + }) + test("deduplicates context files when prompt already includes same path", () => { const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }] diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts index 043425356a..2654d1442f 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.ts @@ -102,9 +102,9 @@ export function buildRequestParts(input: BuildRequestPartsInput) { return { id: Identifier.ascending("part"), type: "file", - mime: "text/plain", + mime: attachment.mime ?? "text/plain", url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`, - filename: getFilename(attachment.path), + filename: attachment.filename ?? getFilename(attachment.path), source: { type: "file", text: { diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx index d8c4bd035c..7a1849f206 100644 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -5,6 +5,7 @@ import { getDirectory, getFilename } from "@opencode-ai/core/util/path" export type AtOption = | { type: "agent"; name: string; display: string } + | { type: "reference"; name: string; path: string; display: string; description: string } | { type: "file"; path: string; display: string; recent?: boolean } export interface SlashCommand { @@ -69,6 +70,23 @@ export const PromptPopover: Component = (props) => { ) } + if (item.type === "reference") { + return ( + + ) + } + const isDirectory = item.path.endsWith("/") const directory = isDirectory ? item.path : getDirectory(item.path) const filename = isDirectory ? "" : getFilename(item.path) diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 014352506a..5c00d02a98 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -27,6 +27,8 @@ export interface FileAttachmentPart extends PartBase { type: "file" path: string selection?: FileSelection + mime?: string + filename?: string } export interface AgentPart extends PartBase { @@ -73,7 +75,13 @@ function isPartEqual(partA: ContentPart, partB: ContentPart) { case "text": return partB.type === "text" && partA.content === partB.content case "file": - return partB.type === "file" && partA.path === partB.path && isSelectionEqual(partA.selection, partB.selection) + return ( + partB.type === "file" && + partA.path === partB.path && + partA.mime === partB.mime && + partA.filename === partB.filename && + isSelectionEqual(partA.selection, partB.selection) + ) case "agent": return partB.type === "agent" && partA.name === partB.name case "image":