Преглед изворни кода

feat(scout): autocomplete configured mentions (#26843)

Shoubhit Dash пре 3 месеци
родитељ
комит
4bae84c8b0

+ 147 - 3
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<Config.Info["reference"]>,
+      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

+ 1 - 1
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({

+ 74 - 42
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<void>
   readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts>
@@ -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<typeof mentionSource>
-        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<PromptInput["parts"][number], { type: "file" }>,
+        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<Draft<MessageV2.Part>[]> = 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<MessageV2.Part>[] = [
+                  ...(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,

+ 65 - 1
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", () =>

+ 6 - 0
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