Просмотр исходного кода

feat(tui): support builtin protocol for handling context from editors (#24034)

James Long 4 месяцев назад
Родитель
Сommit
98ea5b6e7e

+ 4 - 1
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -24,6 +24,7 @@ import { DialogProvider as DialogProviderList } from "@tui/component/dialog-prov
 import { ErrorComponent } from "@tui/component/error-component"
 import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
 import { ProjectProvider } from "@tui/context/project"
+import { EditorContextProvider } from "@tui/context/editor"
 import { useEvent } from "@tui/context/event"
 import { SDKProvider, useSDK } from "@tui/context/sdk"
 import { StartupLoading } from "@tui/component/startup-loading"
@@ -177,7 +178,9 @@ export function tui(input: {
                                         <FrecencyProvider>
                                           <PromptHistoryProvider>
                                             <PromptRefProvider>
-                                              <App onSnapshot={input.onSnapshot} />
+                                              <EditorContextProvider>
+                                                <App onSnapshot={input.onSnapshot} />
+                                              </EditorContextProvider>
                                             </PromptRefProvider>
                                           </PromptHistoryProvider>
                                         </FrecencyProvider>

+ 77 - 27
packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx

@@ -1,9 +1,11 @@
 import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core"
 import { pathToFileURL } from "bun"
 import fuzzysort from "fuzzysort"
+import path from "path"
 import { firstBy } from "remeda"
 import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
 import { createStore } from "solid-js/store"
+import { useEditorContext } from "@tui/context/editor"
 import { useSDK } from "@tui/context/sdk"
 import { useSync } from "@tui/context/sync"
 import { getScrollAcceleration } from "../../util/scroll"
@@ -77,6 +79,7 @@ export function Autocomplete(props: {
   agentStyleId: number
   promptPartTypeId: () => number
 }) {
+  const editor = useEditorContext()
   const sdk = useSDK()
   const sync = useSync()
   const command = useCommandDialog()
@@ -221,6 +224,70 @@ export function Autocomplete(props: {
     }
   }
 
+  function createFilePart(item: string, lineRange?: { startLine: number; endLine?: number }) {
+    const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "")
+    const fullPath = path.isAbsolute(item) ? item : path.join(baseDir, item)
+    const urlObj = pathToFileURL(fullPath)
+    const filename =
+      lineRange && !item.endsWith("/")
+        ? `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
+        : item
+
+    if (lineRange && !item.endsWith("/")) {
+      urlObj.searchParams.set("start", String(lineRange.startLine))
+      if (lineRange.endLine !== undefined) {
+        urlObj.searchParams.set("end", String(lineRange.endLine))
+      }
+    }
+
+    return {
+      filename,
+      url: urlObj.href,
+      part: {
+        type: "file" as const,
+        mime: "text/plain",
+        filename,
+        url: urlObj.href,
+        source: {
+          type: "file" as const,
+          text: {
+            start: 0,
+            end: 0,
+            value: "",
+          },
+          path: item,
+        },
+      },
+    }
+  }
+
+  function normalizeMentionPath(filePath: string) {
+    const baseDir = sync.path.directory || process.cwd()
+    const absolute = path.resolve(filePath)
+    const relative = path.relative(baseDir, absolute)
+
+    if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
+      return relative.split(path.sep).join("/")
+    }
+
+    return absolute.split(path.sep).join("/")
+  }
+
+  function insertFileMention(input: { filePath: string; lineStart: number; lineEnd: number }) {
+    const item = normalizeMentionPath(input.filePath)
+    const lineRange = {
+      startLine: input.lineStart,
+      endLine: input.lineEnd > input.lineStart ? input.lineEnd : undefined,
+    }
+    const { filename, part } = createFilePart(item, lineRange)
+    const index = store.visible === "@" ? store.index : props.input().cursorOffset
+
+    command.keybinds(true)
+    setStore("visible", false)
+    setStore("index", index)
+    insertPart(filename, part)
+  }
+
   const [files] = createResource(
     () => search(),
     async (query) => {
@@ -250,18 +317,7 @@ export function Autocomplete(props: {
         const width = props.anchor().width - 4
         options.push(
           ...sortedFiles.map((item): AutocompleteOption => {
-            const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "")
-            const fullPath = `${baseDir}/${item}`
-            const urlObj = pathToFileURL(fullPath)
-            let filename = item
-            if (lineRange && !item.endsWith("/")) {
-              filename = `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
-              urlObj.searchParams.set("start", String(lineRange.startLine))
-              if (lineRange.endLine !== undefined) {
-                urlObj.searchParams.set("end", String(lineRange.endLine))
-              }
-            }
-            const url = urlObj.href
+            const { filename, url, part } = createFilePart(item, lineRange)
 
             const isDir = item.endsWith("/")
             return {
@@ -270,21 +326,7 @@ export function Autocomplete(props: {
               isDirectory: isDir,
               path: item,
               onSelect: () => {
-                insertPart(filename, {
-                  type: "file",
-                  mime: "text/plain",
-                  filename,
-                  url,
-                  source: {
-                    type: "file",
-                    text: {
-                      start: 0,
-                      end: 0,
-                      value: "",
-                    },
-                    path: item,
-                  },
-                })
+                insertPart(filename, part)
               },
             }
           }),
@@ -501,6 +543,14 @@ export function Autocomplete(props: {
   }
 
   onMount(() => {
+    const unsubscribeMention = editor.onMention((mention) => {
+      insertFileMention(mention)
+    })
+
+    onCleanup(() => {
+      unsubscribeMention()
+    })
+
     props.ref({
       get visible() {
         return store.visible

+ 49 - 1
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -12,6 +12,7 @@ import { useRoute } from "@tui/context/route"
 import { useProject } from "@tui/context/project"
 import { useSync } from "@tui/context/sync"
 import { useEvent } from "@tui/context/event"
+import { useEditorContext } from "@tui/context/editor"
 import { MessageID, PartID } from "@/session/schema"
 import { createStore, produce, unwrap } from "solid-js/store"
 import { useKeybind } from "@tui/context/keybind"
@@ -21,7 +22,7 @@ import { usePromptStash } from "./stash"
 import { DialogStash } from "../dialog-stash"
 import { type AutocompleteRef, Autocomplete } from "./autocomplete"
 import { useCommandDialog } from "../dialog-command"
-import { useRenderer, type JSX } from "@opentui/solid"
+import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
 import * as Editor from "@tui/util/editor"
 import { useExit } from "../../context/exit"
 import * as Clipboard from "../../util/clipboard"
@@ -94,6 +95,7 @@ export function Prompt(props: PromptProps) {
   const local = useLocal()
   const args = useArgs()
   const sdk = useSDK()
+  const editor = useEditorContext()
   const route = useRoute()
   const project = useProject()
   const sync = useSync()
@@ -104,11 +106,34 @@ export function Prompt(props: PromptProps) {
   const stash = usePromptStash()
   const command = useCommandDialog()
   const renderer = useRenderer()
+  const dimensions = useTerminalDimensions()
   const { theme, syntax } = useTheme()
   const kv = useKV()
   const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
   const list = createMemo(() => props.placeholders?.normal ?? [])
   const shell = createMemo(() => props.placeholders?.shell ?? [])
+  const editorPath = createMemo(() => editor.selection()?.filePath)
+  const editorSelectionLabel = createMemo(() => {
+    const selection = editor.selection()?.selection
+    if (!selection) return
+    if (selection.start.line === selection.end.line && selection.start.character === selection.end.character) return
+    if (selection.start.line === selection.end.line) return `#${selection.start.line}`
+    return `#${selection.start.line}-${selection.end.line}`
+  })
+  const editorFileLabel = createMemo(() => {
+    const value = editorPath()
+    if (!value) return
+    const filename = path.basename(value)
+    const file = /^index\.[^./]+$/.test(filename)
+      ? [path.basename(path.dirname(value)), filename].filter(Boolean).join("/")
+      : filename
+    return `${file.split(path.sep).join("/")}${editorSelectionLabel() ?? ""}`
+  })
+  const editorFileLabelDisplay = createMemo(() => {
+    const file = editorFileLabel()
+    if (!file) return
+    return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3))))
+  })
   const [auto, setAuto] = createSignal<AutocompleteRef>()
   const currentProviderLabel = createMemo(() => local.model.parsed().provider)
   const hasRightContent = createMemo(() => Boolean(props.right))
@@ -721,6 +746,27 @@ export function Prompt(props: PromptProps) {
     // Capture mode before it gets reset
     const currentMode = store.mode
     const variant = local.model.variant.current()
+    const editorSelection = editor.selection()
+    const editorParts = editorSelection
+      ? [
+          {
+            id: PartID.ascending(),
+            type: "text" as const,
+            text: (() => {
+              const start = editorSelection.selection.start
+              const end = editorSelection.selection.end
+              if (start.line === end.line && start.character === end.character) {
+                return `Note: The user opened the file "${editorSelection.filePath}".`
+              }
+              if (start.line === end.line) {
+                return `Note: The user selected line ${start.line} from  "${editorSelection.filePath}": ${editorSelection.text}`
+              }
+              return `Note: The user selected lines ${start.line} to ${end.line} from "${editorSelection.filePath}": ${editorSelection.text}`
+            })(),
+            synthetic: true,
+          },
+        ]
+      : []
 
     if (store.mode === "shell") {
       void sdk.client.session.shell({
@@ -773,6 +819,7 @@ export function Prompt(props: PromptProps) {
           model: selectedModel,
           variant,
           parts: [
+            ...editorParts,
             {
               id: PartID.ascending(),
               type: "text",
@@ -1332,6 +1379,7 @@ export function Prompt(props: PromptProps) {
           </Show>
           <Show when={status().type !== "retry"}>
             <box gap={2} flexDirection="row">
+              <Show when={editorFileLabelDisplay()}>{(file) => <text fg={theme.secondary}>{file()}</text>}</Show>
               <Switch>
                 <Match when={store.mode === "normal"}>
                   <Switch>

+ 318 - 0
packages/opencode/src/cli/cmd/tui/context/editor.ts

@@ -0,0 +1,318 @@
+import { readdirSync, readFileSync, statSync } from "node:fs"
+import os from "node:os"
+import path from "node:path"
+import { onCleanup, onMount } from "solid-js"
+import { createStore } from "solid-js/store"
+import z from "zod"
+import { createSimpleContext } from "./helper"
+
+const MCP_PROTOCOL_VERSION = "2025-11-25"
+
+const JsonRpcMessageSchema = z.object({
+  id: z.union([z.number(), z.string(), z.null()]).optional(),
+  method: z.string().optional(),
+  params: z.unknown().optional(),
+  result: z.unknown().optional(),
+  error: z
+    .object({
+      code: z.number().optional(),
+      message: z.string().optional(),
+    })
+    .optional(),
+})
+
+const PositionSchema = z.object({
+  line: z.number(),
+  character: z.number(),
+})
+
+const EditorSelectionSchema = z.object({
+  text: z.string(),
+  filePath: z.string(),
+  selection: z.object({
+    start: PositionSchema,
+    end: PositionSchema,
+  }),
+})
+
+const EditorMentionSchema = z.object({
+  filePath: z.string(),
+  lineStart: z.number(),
+  lineEnd: z.number(),
+})
+
+const EditorServerInfoSchema = z.object({
+  protocolVersion: z.string().optional(),
+  serverInfo: z
+    .object({
+      name: z.string().optional(),
+      version: z.string().optional(),
+    })
+    .optional(),
+})
+
+type JsonRpcMessage = z.infer<typeof JsonRpcMessageSchema>
+export type EditorSelection = z.infer<typeof EditorSelectionSchema>
+export type EditorMention = z.infer<typeof EditorMentionSchema>
+type EditorServerInfo = z.infer<typeof EditorServerInfoSchema>
+
+type EditorConnection = {
+  url: string
+  authToken?: string
+  source: string
+}
+
+type EditorLockFile = {
+  port: number
+  authToken?: string
+  transport?: string
+  workspaceFolders: string[]
+  mtimeMs: number
+}
+
+export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({
+  name: "EditorContext",
+  init: () => {
+    const mentionListeners = new Set<(mention: EditorMention) => void>()
+    const [store, setStore] = createStore<{
+      status: "disabled" | "connecting" | "connected"
+      selection: EditorSelection | undefined
+      server: EditorServerInfo | undefined
+    }>({
+      status: "disabled",
+      selection: undefined,
+      server: undefined,
+    })
+
+    onMount(() => {
+      let socket: WebSocket | undefined
+      let closed = false
+      let reconnect: ReturnType<typeof setTimeout> | undefined
+      let attempt = 0
+      let requestID = 0
+      const pending = new Map<number, string>()
+
+      const send = (payload: JsonRpcMessage) => {
+        if (!socket || socket.readyState !== WebSocket.OPEN) return
+        socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
+      }
+
+      const request = (method: string, params?: unknown) => {
+        requestID += 1
+        pending.set(requestID, method)
+        send({ id: requestID, method, params })
+      }
+
+      const scheduleReconnect = (delay: number) => {
+        if (closed) return
+        if (reconnect) clearTimeout(reconnect)
+        reconnect = setTimeout(connect, delay)
+      }
+
+      const connect = () => {
+        if (closed) return
+
+        const connection = resolveEditorConnection()
+        if (!connection) {
+          setStore("status", "disabled")
+          scheduleReconnect(1000)
+          return
+        }
+
+        setStore("status", "connecting")
+        const current = openEditorSocket(connection)
+        socket = current
+
+        current.addEventListener("open", () => {
+          if (socket !== current) {
+            current.close()
+            return
+          }
+
+          attempt = 0
+          setStore("status", "connected")
+          request("initialize", {
+            protocolVersion: MCP_PROTOCOL_VERSION,
+            capabilities: {},
+            clientInfo: { name: "opencode", version: "0.0.0" },
+          })
+        })
+
+        current.addEventListener("message", (event) => {
+          const message = parseMessage(event.data)
+          if (!message) return
+
+          const selection = message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
+          if (selection?.success) {
+            setStore("selection", selection.data)
+            return
+          }
+
+          const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined
+          if (mention?.success) {
+            mentionListeners.forEach((listener) => listener(mention.data))
+            return
+          }
+
+          if (typeof message.id !== "number") return
+
+          const method = pending.get(message.id)
+          if (!method) return
+
+          pending.delete(message.id)
+          if (message.error) return
+
+          const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined
+          if (initialize?.success) {
+            setStore("server", initialize.data)
+            send({ method: "notifications/initialized" })
+            return
+          }
+        })
+
+        current.addEventListener("close", () => {
+          if (socket !== current) return
+
+          socket = undefined
+          pending.clear()
+          if (closed) return
+
+          setStore("status", "connecting")
+          attempt += 1
+          const delay = Math.min(1000 * 2 ** (attempt - 1), 30000)
+          scheduleReconnect(delay)
+        })
+      }
+
+      scheduleReconnect(0)
+
+      onCleanup(() => {
+        closed = true
+        if (reconnect) clearTimeout(reconnect)
+        socket?.close()
+      })
+    })
+
+    return {
+      enabled() {
+        return Boolean(resolveEditorConnection())
+      },
+      connected() {
+        return store.status === "connected"
+      },
+      selection() {
+        return store.selection
+      },
+      onMention(listener: (mention: EditorMention) => void) {
+        mentionListeners.add(listener)
+        return () => mentionListeners.delete(listener)
+      },
+      server() {
+        return store.server
+      },
+    }
+  },
+})
+
+function parsePort(value: string | undefined) {
+  if (!value) return
+
+  const parsed = Number.parseInt(value, 10)
+  if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) return
+  return parsed
+}
+
+function resolveEditorConnection(): EditorConnection | undefined {
+  const lock = resolveEditorLockFile()
+  if (lock) {
+    return {
+      url: `ws://127.0.0.1:${lock.port}`,
+      authToken: lock.authToken,
+      source: `lock:${lock.port}`,
+    }
+  }
+
+  const port = parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT)
+  if (!port) return
+  return {
+    url: `ws://127.0.0.1:${port}`,
+    source: `env:${port}`,
+  }
+}
+
+function resolveEditorLockFile() {
+  const directory = path.join(os.homedir(), ".claude", "ide")
+  let entries: string[]
+
+  try {
+    entries = readdirSync(directory)
+  } catch {
+    return
+  }
+
+  const cwd = process.cwd()
+  const locks = entries
+    .filter((entry) => entry.endsWith(".lock"))
+    .map((entry) => readEditorLockFile(path.join(directory, entry)))
+    .filter((entry): entry is EditorLockFile => Boolean(entry))
+    .sort((left, right) => scoreEditorLock(right, cwd) - scoreEditorLock(left, cwd))
+
+  return locks[0]
+}
+
+function readEditorLockFile(filePath: string): EditorLockFile | undefined {
+  const port = parsePort(path.basename(filePath, ".lock"))
+  if (!port) return
+
+  try {
+    const parsed = JSON.parse(readFileSync(filePath, "utf-8")) as unknown
+    if (!isRecord(parsed)) return
+    if (parsed.transport !== undefined && parsed.transport !== "ws") return
+
+    return {
+      port,
+      authToken: typeof parsed.authToken === "string" ? parsed.authToken : undefined,
+      transport: typeof parsed.transport === "string" ? parsed.transport : undefined,
+      workspaceFolders: Array.isArray(parsed.workspaceFolders)
+        ? parsed.workspaceFolders.filter((value): value is string => typeof value === "string")
+        : [],
+      mtimeMs: statSync(filePath).mtimeMs,
+    }
+  } catch {
+    return
+  }
+}
+
+function scoreEditorLock(lock: EditorLockFile, cwd: string) {
+  const workspaceMatch = lock.workspaceFolders.some((folder) => pathContains(folder, cwd)) ? 1 : 0
+  return workspaceMatch * 1_000_000_000_000 + lock.mtimeMs
+}
+
+function pathContains(parent: string, child: string) {
+  const relative = path.relative(path.resolve(parent), path.resolve(child))
+  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
+}
+
+function openEditorSocket(connection: EditorConnection) {
+  if (!connection.authToken) return new WebSocket(connection.url)
+
+  return new WebSocket(connection.url, {
+    headers: {
+      "x-claude-code-ide-authorization": connection.authToken,
+    },
+  } as any)
+}
+
+function parseMessage(value: unknown) {
+  if (typeof value !== "string") return
+
+  try {
+    return JsonRpcMessageSchema.parse(JSON.parse(value))
+  } catch {
+    return
+  }
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === "object" && value !== null && !Array.isArray(value)
+}