| import { 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(() => { |
| |
| }) |
| .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 |
| } |
| } |
|
|