Something went wrong. Try again.
opencode v2 mirror
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409import { onCleanup, onMount } from "solid-js"import { createStore } from "solid-js/store"import { Option, Schema, SchemaGetter } from "effect"import { isRecord } from "../util/record"import { useTuiPaths } from "./runtime"import { createSimpleContext } from "./helper"import { editorIntegration } from "../editor"
const MCP_PROTOCOL_VERSION = "2025-11-25"
const JsonRpcMessageSchema = Schema.Struct({ id: Schema.optional(Schema.Union([Schema.Number, Schema.String, Schema.Null])), method: Schema.optional(Schema.String), params: Schema.optional(Schema.Unknown), result: Schema.optional(Schema.Unknown), error: Schema.optional( Schema.Struct({ code: Schema.optional(Schema.Number), message: Schema.optional(Schema.String), }), ),})
const PositionSchema = Schema.Struct({ line: Schema.Number, character: Schema.Number,})
const EditorSelectionRangeSchema = Schema.Struct({ text: Schema.String, selection: Schema.Struct({ start: PositionSchema, end: PositionSchema, }),})
const EditorSelectionRangesSchema = Schema.Struct({ filePath: Schema.String, source: Schema.optional(Schema.Literals(["websocket", "zed"])), ranges: Schema.mutable(Schema.Array(EditorSelectionRangeSchema).check(Schema.isMinLength(1))),})
const EditorSelectionSchema = Schema.Union([ EditorSelectionRangesSchema, Schema.Struct({ text: Schema.String, filePath: Schema.String, source: Schema.optional(Schema.Literals(["websocket", "zed"])), selection: Schema.Struct({ start: PositionSchema, end: PositionSchema, }), }),]).pipe( Schema.decodeTo(EditorSelectionRangesSchema, { decode: SchemaGetter.transform((value) => "ranges" in value ? value : { filePath: value.filePath, source: value.source, ranges: [ { text: value.text, selection: value.selection, }, ], }, ), encode: SchemaGetter.passthrough({ strict: false }), }),)
const EditorMentionSchema = Schema.Struct({ filePath: Schema.String, lineStart: Schema.Number, lineEnd: Schema.Number,})
const EditorServerInfoSchema = Schema.Struct({ protocolVersion: Schema.optional(Schema.String), serverInfo: Schema.optional( Schema.Struct({ name: Schema.optional(Schema.String), version: Schema.optional(Schema.String), }), ),})
const decodeJsonRpcMessage = Schema.decodeUnknownOption(JsonRpcMessageSchema)const decodeEditorSelection = Schema.decodeUnknownOption(EditorSelectionSchema)const decodeEditorMention = Schema.decodeUnknownOption(EditorMentionSchema)const decodeEditorServerInfo = Schema.decodeUnknownOption(EditorServerInfoSchema)
type JsonRpcMessage = Schema.Schema.Type<typeof JsonRpcMessageSchema>export type EditorSelection = Schema.Schema.Type<typeof EditorSelectionSchema>export type EditorMention = Schema.Schema.Type<typeof EditorMentionSchema>export type EditorLabelState = "pending" | "sent" | "none"type EditorServerInfo = Schema.Schema.Type<typeof EditorServerInfoSchema>
type EditorConnection = { url: string authToken?: string source: string}
export type EditorIntegration = Readonly<{ connection?(directory: string): EditorConnection | undefined selection?(directory: string): Promise<unknown>}>
export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({ name: "EditorContext", init: (props: { integration?: EditorIntegration; WebSocketImpl?: typeof WebSocket }) => { const paths = useTuiPaths() const editor = props.integration ?? editorIntegration const value = process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT const parsedPort = value ? Number.parseInt(value, 10) : undefined const port = parsedPort && Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535 ? parsedPort : undefined const zedTerminal = process.env.ZED_TERM === "true" || process.env.TERM_PROGRAM?.toLowerCase() === "zed" const mentionListeners = new Set<(mention: EditorMention) => void>() const WebSocketImpl = props.WebSocketImpl ?? WebSocket const [store, setStore] = createStore<{ status: "disabled" | "connecting" | "connected" selection: EditorSelection | undefined selectionSent: boolean server: EditorServerInfo | undefined }>({ status: "disabled", selection: undefined, selectionSent: false, server: undefined, })
let socket: WebSocket | undefined let closed = false let reconnect: ReturnType<typeof setTimeout> | undefined let attempt = 0 let requestID = 0 let zedSelection: Promise<void> | undefined let lastZedSelectionKey: string | undefined let directory = paths.cwd let preserveSelectionOnReconnect = false const pending = new Map<number, string>()
const setSelection = (selection: EditorSelection | undefined) => { const changed = editorSelectionKey(selection) !== editorSelectionKey(store.selection) setStore("selection", selection) if (changed) setStore("selectionSent", false) }
const clearSelectionForReconnect = (options?: { resetZedSelectionKey?: boolean }) => { if (preserveSelectionOnReconnect) { preserveSelectionOnReconnect = false return } if (options?.resetZedSelectionKey) lastZedSelectionKey = undefined setSelection(undefined) }
const send = (payload: JsonRpcMessage) => { if (!socket || socket.readyState !== 1) 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 connect = () => { if (closed) return
const connection = resolveEditorConnection(directory, port, editor.connection) if (!connection) { if (!zedTerminal) { setStore("status", "disabled") scheduleReconnect() return } if (!editor.selection) { setStore("status", "disabled") scheduleReconnect() return }
zedSelection ??= editor .selection(directory) .then((result) => { if (closed || socket) return if (!isRecord(result) || result.type === "unavailable") return const decoded = result.type === "selection" ? decodeEditorSelection(result.selection) : Option.none() const selection = Option.getOrUndefined(decoded) const key = editorSelectionKey(selection) if (key !== lastZedSelectionKey) { lastZedSelectionKey = key setSelection(selection) setStore("status", selection ? "connected" : "disabled") } }) .catch(() => { // Keep the last known Zed selection for transient polling failures. }) .finally(() => { zedSelection = undefined }) scheduleZedPoll() return }
setStore("status", "connecting") const current = openEditorSocket(connection, WebSocketImpl) 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" ? decodeEditorSelection(message.params) : Option.none() if (Option.isSome(selection)) { setSelection({ ...selection.value, source: "websocket" }) return }
const mention = message.method === "at_mentioned" ? decodeEditorMention(message.params) : Option.none() if (Option.isSome(mention)) { mentionListeners.forEach((listener) => listener(mention.value)) 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" ? decodeEditorServerInfo(message.result) : Option.none() if (Option.isSome(initialize)) { setStore("server", initialize.value) send({ method: "notifications/initialized" }) return } })
current.addEventListener("close", () => { if (socket !== current) return
socket = undefined pending.clear() if (closed) return
setStore("status", "connecting") scheduleReconnect() }) }
const scheduleReconnect = () => { if (closed) return if (reconnect) clearTimeout(reconnect) attempt += 1 const delay = Math.min(1000 * 2 ** (attempt - 1), 10_000) reconnect = setTimeout(connect, delay) }
const scheduleZedPoll = () => { if (closed) return if (reconnect) clearTimeout(reconnect) reconnect = setTimeout(connect, 1000) }
const reconnectWithDirectory = (nextDirectory?: string) => { const resolved = nextDirectory || paths.cwd const sameDirectory = directory === resolved clearSelectionForReconnect({ resetZedSelectionKey: !sameDirectory }) if (sameDirectory) return
directory = resolved attempt = 0 pending.clear() if (reconnect) clearTimeout(reconnect) reconnect = undefined if (socket) { const current = socket socket = undefined current.close() } setStore("status", "disabled") setStore("server", undefined) connect() }
onMount(() => { connect()
onCleanup(() => { closed = true if (reconnect) clearTimeout(reconnect) socket?.close() }) })
return { enabled() { return Boolean(resolveEditorConnection(directory, port, editor.connection) || (zedTerminal && editor.selection)) }, connected() { return store.status === "connected" }, selection() { return store.selection }, clearSelection() { lastZedSelectionKey = undefined zedSelection = undefined setSelection(undefined) }, preserveSelectionFromNewSession() { preserveSelectionOnReconnect = true }, markSelectionSent() { if (!store.selection) return setStore("selectionSent", true) }, labelState(): EditorLabelState { if (!store.selection) return "none" return store.selectionSent ? "sent" : "pending" }, onMention(listener: (mention: EditorMention) => void) { mentionListeners.add(listener) return () => mentionListeners.delete(listener) }, server() { return store.server }, reconnect(directory?: string) { reconnectWithDirectory(directory) }, } },})
function resolveEditorConnection( directory: string, port: number | undefined, discover: ((directory: string) => EditorConnection | undefined) | undefined,): EditorConnection | undefined { if (port) { return { url: `ws://127.0.0.1:${port}`, source: `env:${port}`, } }
return discover?.(directory)}
export function editorSelectionKey(selection: EditorSelection | undefined) { if (!selection) return "" return [ selection.filePath, ...selection.ranges.flatMap((range) => [ range.selection.start.line, range.selection.start.character, range.selection.end.line, range.selection.end.character, range.text, ]), ].join("\0")}
function openEditorSocket(connection: EditorConnection, WebSocketImpl: typeof WebSocket) { if (!connection.authToken) return new WebSocketImpl(connection.url)
return new WebSocketImpl(connection.url, { headers: { "x-claude-code-ide-authorization": connection.authToken, }, } as any)}
function parseMessage(value: unknown) { if (typeof value !== "string") return
try { return Option.getOrUndefined(decodeJsonRpcMessage(JSON.parse(value))) } catch { return }}