feat(tui): support builtin protocol for handling context from editors (#24034)
This commit is contained in:
@@ -24,6 +24,7 @@ import { DialogProvider as DialogProviderList } from "@tui/component/dialog-prov
|
|||||||
import { ErrorComponent } from "@tui/component/error-component"
|
import { ErrorComponent } from "@tui/component/error-component"
|
||||||
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
|
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
|
||||||
import { ProjectProvider } from "@tui/context/project"
|
import { ProjectProvider } from "@tui/context/project"
|
||||||
|
import { EditorContextProvider } from "@tui/context/editor"
|
||||||
import { useEvent } from "@tui/context/event"
|
import { useEvent } from "@tui/context/event"
|
||||||
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||||
import { StartupLoading } from "@tui/component/startup-loading"
|
import { StartupLoading } from "@tui/component/startup-loading"
|
||||||
@@ -177,7 +178,9 @@ export function tui(input: {
|
|||||||
<FrecencyProvider>
|
<FrecencyProvider>
|
||||||
<PromptHistoryProvider>
|
<PromptHistoryProvider>
|
||||||
<PromptRefProvider>
|
<PromptRefProvider>
|
||||||
<App onSnapshot={input.onSnapshot} />
|
<EditorContextProvider>
|
||||||
|
<App onSnapshot={input.onSnapshot} />
|
||||||
|
</EditorContextProvider>
|
||||||
</PromptRefProvider>
|
</PromptRefProvider>
|
||||||
</PromptHistoryProvider>
|
</PromptHistoryProvider>
|
||||||
</FrecencyProvider>
|
</FrecencyProvider>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core"
|
import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core"
|
||||||
import { pathToFileURL } from "bun"
|
import { pathToFileURL } from "bun"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
|
import path from "path"
|
||||||
import { firstBy } from "remeda"
|
import { firstBy } from "remeda"
|
||||||
import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
|
import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
import { useEditorContext } from "@tui/context/editor"
|
||||||
import { useSDK } from "@tui/context/sdk"
|
import { useSDK } from "@tui/context/sdk"
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
import { getScrollAcceleration } from "../../util/scroll"
|
import { getScrollAcceleration } from "../../util/scroll"
|
||||||
@@ -77,6 +79,7 @@ export function Autocomplete(props: {
|
|||||||
agentStyleId: number
|
agentStyleId: number
|
||||||
promptPartTypeId: () => number
|
promptPartTypeId: () => number
|
||||||
}) {
|
}) {
|
||||||
|
const editor = useEditorContext()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const command = useCommandDialog()
|
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(
|
const [files] = createResource(
|
||||||
() => search(),
|
() => search(),
|
||||||
async (query) => {
|
async (query) => {
|
||||||
@@ -250,18 +317,7 @@ export function Autocomplete(props: {
|
|||||||
const width = props.anchor().width - 4
|
const width = props.anchor().width - 4
|
||||||
options.push(
|
options.push(
|
||||||
...sortedFiles.map((item): AutocompleteOption => {
|
...sortedFiles.map((item): AutocompleteOption => {
|
||||||
const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "")
|
const { filename, url, part } = createFilePart(item, lineRange)
|
||||||
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 isDir = item.endsWith("/")
|
const isDir = item.endsWith("/")
|
||||||
return {
|
return {
|
||||||
@@ -270,21 +326,7 @@ export function Autocomplete(props: {
|
|||||||
isDirectory: isDir,
|
isDirectory: isDir,
|
||||||
path: item,
|
path: item,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
insertPart(filename, {
|
insertPart(filename, part)
|
||||||
type: "file",
|
|
||||||
mime: "text/plain",
|
|
||||||
filename,
|
|
||||||
url,
|
|
||||||
source: {
|
|
||||||
type: "file",
|
|
||||||
text: {
|
|
||||||
start: 0,
|
|
||||||
end: 0,
|
|
||||||
value: "",
|
|
||||||
},
|
|
||||||
path: item,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -501,6 +543,14 @@ export function Autocomplete(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
const unsubscribeMention = editor.onMention((mention) => {
|
||||||
|
insertFileMention(mention)
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
unsubscribeMention()
|
||||||
|
})
|
||||||
|
|
||||||
props.ref({
|
props.ref({
|
||||||
get visible() {
|
get visible() {
|
||||||
return store.visible
|
return store.visible
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useRoute } from "@tui/context/route"
|
|||||||
import { useProject } from "@tui/context/project"
|
import { useProject } from "@tui/context/project"
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
import { useEvent } from "@tui/context/event"
|
import { useEvent } from "@tui/context/event"
|
||||||
|
import { useEditorContext } from "@tui/context/editor"
|
||||||
import { MessageID, PartID } from "@/session/schema"
|
import { MessageID, PartID } from "@/session/schema"
|
||||||
import { createStore, produce, unwrap } from "solid-js/store"
|
import { createStore, produce, unwrap } from "solid-js/store"
|
||||||
import { useKeybind } from "@tui/context/keybind"
|
import { useKeybind } from "@tui/context/keybind"
|
||||||
@@ -21,7 +22,7 @@ import { usePromptStash } from "./stash"
|
|||||||
import { DialogStash } from "../dialog-stash"
|
import { DialogStash } from "../dialog-stash"
|
||||||
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||||
import { useCommandDialog } from "../dialog-command"
|
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 * as Editor from "@tui/util/editor"
|
||||||
import { useExit } from "../../context/exit"
|
import { useExit } from "../../context/exit"
|
||||||
import * as Clipboard from "../../util/clipboard"
|
import * as Clipboard from "../../util/clipboard"
|
||||||
@@ -94,6 +95,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const args = useArgs()
|
const args = useArgs()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
const editor = useEditorContext()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const project = useProject()
|
const project = useProject()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
@@ -104,11 +106,34 @@ export function Prompt(props: PromptProps) {
|
|||||||
const stash = usePromptStash()
|
const stash = usePromptStash()
|
||||||
const command = useCommandDialog()
|
const command = useCommandDialog()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
|
const dimensions = useTerminalDimensions()
|
||||||
const { theme, syntax } = useTheme()
|
const { theme, syntax } = useTheme()
|
||||||
const kv = useKV()
|
const kv = useKV()
|
||||||
const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
|
const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
|
||||||
const list = createMemo(() => props.placeholders?.normal ?? [])
|
const list = createMemo(() => props.placeholders?.normal ?? [])
|
||||||
const shell = createMemo(() => props.placeholders?.shell ?? [])
|
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 [auto, setAuto] = createSignal<AutocompleteRef>()
|
||||||
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
|
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
|
||||||
const hasRightContent = createMemo(() => Boolean(props.right))
|
const hasRightContent = createMemo(() => Boolean(props.right))
|
||||||
@@ -721,6 +746,27 @@ export function Prompt(props: PromptProps) {
|
|||||||
// Capture mode before it gets reset
|
// Capture mode before it gets reset
|
||||||
const currentMode = store.mode
|
const currentMode = store.mode
|
||||||
const variant = local.model.variant.current()
|
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") {
|
if (store.mode === "shell") {
|
||||||
void sdk.client.session.shell({
|
void sdk.client.session.shell({
|
||||||
@@ -773,6 +819,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
model: selectedModel,
|
model: selectedModel,
|
||||||
variant,
|
variant,
|
||||||
parts: [
|
parts: [
|
||||||
|
...editorParts,
|
||||||
{
|
{
|
||||||
id: PartID.ascending(),
|
id: PartID.ascending(),
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -1332,6 +1379,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
<Show when={status().type !== "retry"}>
|
<Show when={status().type !== "retry"}>
|
||||||
<box gap={2} flexDirection="row">
|
<box gap={2} flexDirection="row">
|
||||||
|
<Show when={editorFileLabelDisplay()}>{(file) => <text fg={theme.secondary}>{file()}</text>}</Show>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={store.mode === "normal"}>
|
<Match when={store.mode === "normal"}>
|
||||||
<Switch>
|
<Switch>
|
||||||
|
|||||||
318
packages/opencode/src/cli/cmd/tui/context/editor.ts
Normal file
318
packages/opencode/src/cli/cmd/tui/context/editor.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user