Bladeren bron

feat(app): autocomplete mcp resources (#34597)

Brendan Allan 1 maand geleden
bovenliggende
commit
06cb0de0b1

+ 62 - 4
packages/app/src/components/prompt-input.tsx

@@ -664,6 +664,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
   )
 
+  const mcpResourceList = createMemo(() =>
+    Object.values(sync().data.mcp_resource).map(
+      (resource): AtOption => ({
+        type: "resource",
+        name: resource.name,
+        uri: resource.uri,
+        client: resource.client,
+        display: resource.name,
+        description: resource.description,
+        mime: resource.mimeType,
+      }),
+    ),
+  )
+
   const handleAtSelect = (option: AtOption | undefined) => {
     if (!option) return
     if (option.type === "agent") {
@@ -682,6 +696,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       })
       return
     }
+    if (option.type === "resource") {
+      addPart({
+        type: "file",
+        path: option.uri,
+        content: "@" + option.name,
+        start: 0,
+        end: 0,
+        mime: option.mime ?? "text/plain",
+        filename: option.name,
+        url: option.uri,
+        source: {
+          type: "resource",
+          text: { value: "@" + option.name, start: 0, end: 0 },
+          clientName: option.client,
+          uri: option.uri,
+        },
+      })
+      return
+    }
     addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 })
   }
 
@@ -689,6 +722,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     if (!x) return ""
     if (x.type === "agent") return `agent:${x.name}`
     if (x.type === "reference") return `reference:${x.name}`
+    if (x.type === "resource") return `resource:${x.client}:${x.uri}`
     return `file:${x.path}`
   }
 
@@ -702,15 +736,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     items: async (query) => {
       const references = referenceList()
       const agents = agentList()
+      const mcpResources = mcpResourceList()
       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 [...references, ...agents, ...pinned]
+      if (!query.trim()) return [...references, ...agents, ...mcpResources, ...pinned]
       const paths = await files.searchFilesAndDirectories(query)
       const fileOptions: AtOption[] = paths
         .filter((path) => !seen.has(path))
         .map((path) => ({ type: "file", path, display: path }))
-      return [...references, ...agents, ...pinned, ...fileOptions]
+      return [...references, ...agents, ...mcpResources, ...pinned, ...fileOptions]
     },
     key: atKey,
     filterKeys: ["display"],
@@ -718,6 +753,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     groupBy: (item) => {
       if (item.type === "reference") return "reference"
       if (item.type === "agent") return "agent"
+      if (item.type === "resource") return "resource"
       if (item.recent) return "recent"
       return "file"
     },
@@ -725,8 +761,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       const rank = (category: string) => {
         if (category === "reference") return 0
         if (category === "agent") return 1
-        if (category === "recent") return 2
-        return 3
+        if (category === "resource") return 2
+        if (category === "recent") return 3
+        return 4
       }
       return rank(a.category) - rank(b.category)
     },
@@ -796,6 +833,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       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.url) pill.setAttribute("data-url", part.url)
+      if (part.source?.type === "resource") {
+        pill.setAttribute("data-source-type", part.source.type)
+        pill.setAttribute("data-source-client-name", part.source.clientName)
+        pill.setAttribute("data-source-uri", part.source.uri)
+      }
     }
     if (part.type === "agent") pill.setAttribute("data-name", part.name)
     pill.setAttribute("contenteditable", "false")
@@ -911,6 +954,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
     const pushFile = (file: HTMLElement) => {
       const content = file.textContent ?? ""
+      const source =
+        file.dataset.sourceType === "resource" && file.dataset.sourceClientName && file.dataset.sourceUri
+          ? {
+              type: "resource" as const,
+              text: {
+                value: content,
+                start: position,
+                end: position + content.length,
+              },
+              clientName: file.dataset.sourceClientName,
+              uri: file.dataset.sourceUri,
+            }
+          : undefined
       parts.push({
         type: "file",
         path: file.dataset.path!,
@@ -919,6 +975,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         end: position + content.length,
         ...(file.dataset.mime ? { mime: file.dataset.mime } : {}),
         ...(file.dataset.filename ? { filename: file.dataset.filename } : {}),
+        ...(file.dataset.url ? { url: file.dataset.url } : {}),
+        ...(source ? { source } : {}),
       })
       position += content.length
     }

+ 20 - 10
packages/app/src/components/prompt-input/build-request-parts.ts

@@ -99,21 +99,31 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
 
   const files = input.prompt.filter(isFileAttachment).map((attachment) => {
     const path = absolute(input.sessionDirectory, attachment.path)
+    const source = attachment.source
+      ? {
+          ...attachment.source,
+          text: {
+            value: attachment.content,
+            start: attachment.start,
+            end: attachment.end,
+          },
+        }
+      : {
+          type: "file" as const,
+          text: {
+            value: attachment.content,
+            start: attachment.start,
+            end: attachment.end,
+          },
+          path,
+        }
     return {
       id: Identifier.ascending("part"),
       type: "file",
       mime: attachment.mime ?? "text/plain",
-      url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
+      url: attachment.url ?? `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
       filename: attachment.filename ?? getFilename(attachment.path),
-      source: {
-        type: "file",
-        text: {
-          value: attachment.content,
-          start: attachment.start,
-          end: attachment.end,
-        },
-        path,
-      },
+      source,
     } satisfies PromptRequestPart
   })
 

+ 78 - 6
packages/app/src/components/prompt-input/slash-popover.tsx

@@ -7,6 +7,7 @@ import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
 
 export type AtOption =
   | { type: "agent"; name: string; display: string }
+  | { type: "resource"; name: string; uri: string; client: string; display: string; description?: string; mime?: string }
   | { type: "reference"; name: string; path: string; display: string; description: string }
   | { type: "file"; path: string; display: string; recent?: boolean }
 
@@ -104,18 +105,89 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
                     )
                   }
 
+                  if (item.type === "resource") {
+                    return (
+                      <button
+                        class="w-full flex items-center gap-x-2 px-2 py-0.5"
+                        classList={{
+                          "rounded-[4px]": props.newLayoutDesigns,
+                          "rounded-md": !props.newLayoutDesigns,
+                          "bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
+                          "bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
+                        }}
+                        onClick={() => props.onAtSelect(item)}
+                        onPointerMove={() => props.setAtActive(key)}
+                      >
+                        <FileIcon node={{ path: item.uri, type: "file" }} class="shrink-0 size-4" />
+                        <div
+                          class="flex items-center min-w-0"
+                          classList={{
+                            "text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
+                              props.newLayoutDesigns,
+                            "text-14-regular": !props.newLayoutDesigns,
+                          }}
+                        >
+                          <span
+                            class="text-text-strong whitespace-nowrap"
+                            classList={{ "text-v2-text-text-base": props.newLayoutDesigns }}
+                          >
+                            @{item.name}
+                          </span>
+                          <Show when={item.description}>
+                            {(description) => (
+                              <span
+                                class="whitespace-nowrap truncate min-w-0 ml-2"
+                                classList={{
+                                  "text-v2-text-text-muted": props.newLayoutDesigns,
+                                  "text-text-weak": !props.newLayoutDesigns,
+                                }}
+                              >
+                                {description()}
+                              </span>
+                            )}
+                          </Show>
+                        </div>
+                      </button>
+                    )
+                  }
+
                   if (item.type === "reference") {
                     return (
                       <button
-                        class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
-                        classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
+                        class="w-full flex items-center gap-x-2 px-2 py-0.5"
+                        classList={{
+                          "rounded-[4px]": props.newLayoutDesigns,
+                          "rounded-md": !props.newLayoutDesigns,
+                          "bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
+                          "bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
+                        }}
                         onClick={() => props.onAtSelect(item)}
-                        onMouseEnter={() => props.setAtActive(key)}
+                        onPointerMove={() => props.setAtActive(key)}
                       >
                         <FileIcon node={{ path: item.path, type: "directory" }} class="shrink-0 size-4" />
-                        <div class="flex items-center text-14-regular min-w-0">
-                          <span class="text-text-strong whitespace-nowrap">@{item.name}</span>
-                          <span class="text-text-weak whitespace-nowrap truncate min-w-0 ml-2">{item.description}</span>
+                        <div
+                          class="flex items-center min-w-0"
+                          classList={{
+                            "text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
+                              props.newLayoutDesigns,
+                            "text-14-regular": !props.newLayoutDesigns,
+                          }}
+                        >
+                          <span
+                            class="text-text-strong whitespace-nowrap"
+                            classList={{ "text-v2-text-text-base": props.newLayoutDesigns }}
+                          >
+                            @{item.name}
+                          </span>
+                          <span
+                            class="whitespace-nowrap truncate min-w-0 ml-2"
+                            classList={{
+                              "text-v2-text-text-muted": props.newLayoutDesigns,
+                              "text-text-weak": !props.newLayoutDesigns,
+                            }}
+                          >
+                            {item.description}
+                          </span>
                         </div>
                       </button>
                     )

+ 1 - 0
packages/app/src/context/global-sync/bootstrap.test.ts

@@ -36,6 +36,7 @@ describe("bootstrapDirectory", () => {
       question: {},
       mcp_ready: true,
       mcp: {},
+      mcp_resource: {},
       lsp_ready: true,
       lsp: [],
       vcs: undefined,

+ 2 - 1
packages/app/src/context/global-sync/bootstrap.ts

@@ -19,7 +19,7 @@ import type { ServerSession } from "../server-session"
 import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
 import { formatServerError } from "@/utils/server-errors"
 import { QueryClient, queryOptions } from "@tanstack/solid-query"
-import { loadMcpQuery } from "../server-sync"
+import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
 import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
 import { ScopedKey, type ServerScope } from "@/utils/server-scope"
 
@@ -348,6 +348,7 @@ export async function bootstrapDirectory(input: {
         ),
       () => Promise.resolve(input.loadSessions(input.directory)),
       input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
+      input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))),
       () =>
         input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => {
           const project = getFilename(input.directory)

+ 7 - 1
packages/app/src/context/global-sync/child-store.test.ts

@@ -34,7 +34,9 @@ const queryOptionsApi = {
   }),
   agents: (directory: string) => ({ queryKey: [directory, "agents"], queryFn: async () => [] }),
   mcp: (directory: string) => ({ queryKey: [directory, "mcp"], queryFn: async () => ({}) }),
+  mcpResources: (directory: string) => ({ queryKey: [directory, "mcpResources"], queryFn: async () => ({}) }),
   lsp: (directory: string) => ({ queryKey: [directory, "lsp"], queryFn: async () => [] }),
+  references: (directory: string) => ({ queryKey: [directory, "references"], queryFn: async () => [] }),
   sessions: (directory: string) => ({ queryKey: [directory, "loadSessions"] as const }),
 } as unknown as QueryOptionsApi
 
@@ -197,14 +199,18 @@ describe("createChildStoreManager", () => {
     try {
       if (!manager) throw new Error("manager required")
       const [store, setStore] = manager.child("/project", { bootstrap: false })
-      expect(querySingles.length - offset).toBe(4)
+      expect(querySingles.length - offset).toBe(6)
       const query = querySingles[offset + 1]
+      const resourceQuery = querySingles[offset + 2]
       if (!query) throw new Error("query required")
+      if (!resourceQuery) throw new Error("resource query required")
       expect(query().enabled).toBe(false)
+      expect(resourceQuery().enabled).toBe(false)
 
       setStore("status", "complete")
       manager.child("/project", { bootstrap: false, mcp: true })
       expect(query().enabled).toBe(true)
+      expect(resourceQuery().enabled).toBe(true)
       expect(store.mcp).toEqual({ demo: { status: "disabled" } })
       expect(mcpLoads).toEqual(["/project"])
 

+ 4 - 0
packages/app/src/context/global-sync/child-store.ts

@@ -185,6 +185,7 @@ export function createChildStoreManager(input: {
 
           const pathQuery = useQuery(() => input.queryOptions.path(key))
           const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
+          const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
           const lspQuery = useQuery(() => input.queryOptions.lsp(key))
           const providerQuery = useQuery(() => input.queryOptions.providers(key))
           const referenceQuery = useQuery(() => input.queryOptions.references(key))
@@ -231,6 +232,9 @@ export function createChildStoreManager(input: {
             get mcp() {
               return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {})
             },
+            get mcp_resource() {
+              return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {})
+            },
             get lsp_ready() {
               return !lspQuery.isLoading
             },

+ 4 - 0
packages/app/src/context/global-sync/types.ts

@@ -3,6 +3,7 @@ import type {
   Command,
   Config,
   LspStatus,
+  McpResource,
   McpStatus,
   Message,
   Part,
@@ -65,6 +66,9 @@ export type State = {
   mcp: {
     [name: string]: McpStatus
   }
+  mcp_resource: {
+    [key: string]: McpResource
+  }
   lsp_ready: boolean
   lsp: LspStatus[]
   vcs: VcsInfo | undefined

+ 3 - 0
packages/app/src/context/prompt.tsx

@@ -12,6 +12,7 @@ import { useTabs, type Tab } from "./tabs"
 import { ServerConnection } from "./server"
 import { requireServerKey } from "@/utils/session-route"
 import { useSettings } from "./settings"
+import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
 
 interface PartBase {
   content: string
@@ -29,6 +30,8 @@ export interface FileAttachmentPart extends PartBase {
   selection?: FileSelection
   mime?: string
   filename?: string
+  url?: string
+  source?: FilePartSource
 }
 
 export interface AgentPart extends PartBase {

+ 10 - 1
packages/app/src/context/server-sync.tsx

@@ -1,4 +1,4 @@
-import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse } from "@opencode-ai/sdk/v2/client"
+import type { Config, McpResource, OpencodeClient, Path, Project, ProviderAuthResponse } from "@opencode-ai/sdk/v2/client"
 import { showToast } from "@/utils/toast"
 import { getFilename } from "@opencode-ai/core/util/path"
 import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
@@ -57,6 +57,13 @@ export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: Opencod
     queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
   })
 
+export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
+  queryOptions<Record<string, McpResource>>({
+    queryKey: [scope, directory, "mcpResources"] as const,
+    queryFn: () => sdk.experimental.resource.list().then((r) => r.data ?? {}),
+    placeholderData: {},
+  })
+
 export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
   queryOptions({
     queryKey: [scope, directory, "lsp"] as const,
@@ -78,6 +85,7 @@ function makeQueryOptionsApi(
     agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
     references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)),
     mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
+    mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, sdkFor(directory)),
     lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
     sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
   }
@@ -489,6 +497,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
           },
           refresh: async () => {
             await queryClient.refetchQueries(queryOptionsApi.mcp(key))
+            await queryClient.refetchQueries(queryOptionsApi.mcpResources(key))
           },
         })
       },