summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorJames Long <[email protected]>2026-04-29 15:11:44 -0400
committerGitHub <[email protected]>2026-04-29 15:11:44 -0400
commit293877cb7e60610b4b0c25992dbab2169c6f614e (patch)
tree62ba7efb981ffeef27b62ef1f304b49f5dce1900 /packages
parentc48000655458bf1317314413259808b8f8293dd0 (diff)
downloadopencode-293877cb7e60610b4b0c25992dbab2169c6f614e.tar.gz
opencode-293877cb7e60610b4b0c25992dbab2169c6f614e.zip
fix(core): reconnect editor context for session directory (#24984)
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx75
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/editor.ts295
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/index.tsx3
-rw-r--r--packages/opencode/test/cli/tui/editor-context-zed.test.ts (renamed from packages/opencode/test/cli/tui/editor-context.test.ts)0
-rw-r--r--packages/opencode/test/cli/tui/editor-context.test.tsx224
-rw-r--r--packages/opencode/test/lib/websocket.ts46
6 files changed, 479 insertions, 164 deletions
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
index 641edd30a..cd47e9170 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
@@ -12,7 +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 { useEditorContext, type EditorSelection } from "@tui/context/editor"
import { MessageID, PartID } from "@/session/schema"
import { createStore, produce, unwrap } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
@@ -84,6 +84,18 @@ function fadeColor(color: RGBA, alpha: number) {
return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha)
}
+function getEditorSelectionKey(selection: EditorSelection) {
+ return [
+ selection.filePath,
+ selection.text,
+ selection.source ?? "",
+ selection.selection.start.line,
+ selection.selection.start.character,
+ selection.selection.end.line,
+ selection.selection.end.character,
+ ].join("-")
+}
+
let stashed: { prompt: PromptInfo; cursor: number } | undefined
export function Prompt(props: PromptProps) {
@@ -135,6 +147,7 @@ export function Prompt(props: PromptProps) {
if (!file) return
return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3))))
})
+ let lastSubmittedEditorSelectionKey: string | undefined
const [auto, setAuto] = createSignal<AutocompleteRef>()
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right))
@@ -748,36 +761,38 @@ export function Prompt(props: PromptProps) {
const currentMode = store.mode
const variant = local.model.variant.current()
const editorSelection = fileContextEnabled() ? editor.selection() : undefined
- const editorParts = editorSelection
- ? [
- {
- id: PartID.ascending(),
- type: "text" as const,
- text: (() => {
- const start = editorSelection.selection.start
- const end = editorSelection.selection.end
-
- let text = ""
- if (start.line === end.line && start.character === end.character) {
- text = `Note: The user opened the file "${editorSelection.filePath}".`
- } else if (start.line === end.line) {
- text = `Note: The user selected line ${start.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n`
- } else {
- text = `Note: The user selected lines ${start.line + 1} to ${end.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n`
- }
+ const editorSelectionKey = editorSelection ? getEditorSelectionKey(editorSelection) : undefined
+ const editorParts =
+ editorSelection && editorSelectionKey !== lastSubmittedEditorSelectionKey
+ ? [
+ {
+ id: PartID.ascending(),
+ type: "text" as const,
+ text: (() => {
+ const start = editorSelection.selection.start
+ const end = editorSelection.selection.end
+
+ let text = ""
+ if (start.line === end.line && start.character === end.character) {
+ text = `Note: The user opened the file "${editorSelection.filePath}".`
+ } else if (start.line === end.line) {
+ text = `Note: The user selected line ${start.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n`
+ } else {
+ text = `Note: The user selected lines ${start.line + 1} to ${end.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n`
+ }
- return `<system-reminder>${text} This may or may not be relevant to the current task.</system-reminder>\n`
- })(),
- synthetic: true,
- metadata: {
- kind: "editor_context",
- source: editorSelection.source ?? "editor",
- filePath: editorSelection.filePath,
- selection: editorSelection.selection,
+ return `<system-reminder>${text} This may or may not be relevant to the current task.</system-reminder>\n`
+ })(),
+ synthetic: true,
+ metadata: {
+ kind: "editor_context",
+ source: editorSelection.source ?? "editor",
+ filePath: editorSelection.filePath,
+ selection: editorSelection.selection,
+ },
},
- },
- ]
- : []
+ ]
+ : []
if (store.mode === "shell") {
void sdk.client.session.shell({
@@ -840,7 +855,7 @@ export function Prompt(props: PromptProps) {
],
})
.catch(() => {})
- editor.clearSelection()
+ lastSubmittedEditorSelectionKey = editorSelectionKey
}
history.append({
...store.prompt,
diff --git a/packages/opencode/src/cli/cmd/tui/context/editor.ts b/packages/opencode/src/cli/cmd/tui/context/editor.ts
index 531bf4507..4ebc1c2c0 100644
--- a/packages/opencode/src/cli/cmd/tui/context/editor.ts
+++ b/packages/opencode/src/cli/cmd/tui/context/editor.ts
@@ -75,8 +75,9 @@ type EditorLockFile = {
export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({
name: "EditorContext",
- init: () => {
+ init: (props: { WebSocketImpl?: typeof WebSocket }) => {
const mentionListeners = new Set<(mention: EditorMention) => void>()
+ const WebSocketImpl = props.WebSocketImpl ?? WebSocket
const [store, setStore] = createStore<{
status: "disabled" | "connecting" | "connected"
selection: EditorSelection | undefined
@@ -87,138 +88,160 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
server: undefined,
})
- onMount(() => {
- 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
- 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 }))
- }
+ 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 = process.cwd()
+ const pending = new Map<number, string>()
+
+ 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 request = (method: string, params?: unknown) => {
+ requestID += 1
+ pending.set(requestID, method)
+ send({ id: requestID, method, params })
+ }
- 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 connect = () => {
+ if (closed) return
- const scheduleZedPoll = () => {
- if (closed) return
- if (reconnect) clearTimeout(reconnect)
- reconnect = setTimeout(connect, 1000)
+ const connection = resolveEditorConnection(directory)
+ if (!connection) {
+ const dbPath = resolveZedDbPath()
+ if (!dbPath) {
+ setStore("status", "disabled")
+ scheduleReconnect()
+ return
+ }
+ zedSelection ??= resolveZedSelection(dbPath, directory)
+ .then((result) => {
+ if (closed || socket) return
+ if (result.type === "unavailable") return
+ const selection = result.type === "selection" ? result.selection : undefined
+ const key = editorSelectionKey(selection)
+ if (key !== lastZedSelectionKey) {
+ lastZedSelectionKey = key
+ setStore("selection", selection)
+ setStore("status", selection ? "connected" : "disabled")
+ }
+ })
+ .catch(() => {
+ // Keep the last known Zed selection for transient polling failures.
+ })
+ .finally(() => {
+ zedSelection = undefined
+ })
+ scheduleZedPoll()
+ return
}
- const connect = () => {
- if (closed) return
+ setStore("status", "connecting")
+ const current = openEditorSocket(connection, WebSocketImpl)
+ socket = current
- const connection = resolveEditorConnection()
- if (!connection) {
- const dbPath = resolveZedDbPath()
- if (!dbPath) {
- setStore("status", "disabled")
- scheduleReconnect()
- return
- }
- zedSelection ??= resolveZedSelection(dbPath)
- .then((result) => {
- if (closed || socket) return
- if (result.type === "unavailable") return
- const selection = result.type === "selection" ? result.selection : undefined
- const key = editorSelectionKey(selection)
- if (key !== lastZedSelectionKey) {
- lastZedSelectionKey = key
- setStore("selection", selection)
- setStore("status", selection ? "connected" : "disabled")
- }
- })
- .catch(() => {
- // Keep the last known Zed selection for transient polling failures.
- })
- .finally(() => {
- zedSelection = undefined
- })
- scheduleZedPoll()
+ current.addEventListener("open", () => {
+ if (socket !== current) {
+ current.close()
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" },
- })
+ 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, source: "websocket" })
- 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("message", (event) => {
+ const message = parseMessage(event.data)
+ if (!message) return
- current.addEventListener("close", () => {
- if (socket !== current) return
+ const selection =
+ message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
+ if (selection?.success) {
+ setStore("selection", { ...selection.data, source: "websocket" })
+ return
+ }
- socket = undefined
- pending.clear()
- if (closed) return
+ const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined
+ if (mention?.success) {
+ mentionListeners.forEach((listener) => listener(mention.data))
+ return
+ }
- setStore("status", "connecting")
- scheduleReconnect()
- })
+ 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")
+ 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 || process.cwd()
+ if (directory === resolved) return
+
+ directory = resolved
+ attempt = 0
+ pending.clear()
+ lastZedSelectionKey = undefined
+ if (reconnect) clearTimeout(reconnect)
+ reconnect = undefined
+ if (socket) {
+ const current = socket
+ socket = undefined
+ current.close()
}
+ setStore("status", "disabled")
+ setStore("selection", undefined)
+ setStore("server", undefined)
+ connect()
+ }
+ onMount(() => {
connect()
onCleanup(() => {
@@ -230,7 +253,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
return {
enabled() {
- return Boolean(resolveEditorConnection() || resolveZedDbPath())
+ return Boolean(resolveEditorConnection(directory) || resolveZedDbPath())
},
connected() {
return store.status === "connected"
@@ -248,6 +271,10 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
server() {
return store.server
},
+ reconnect(directory?: string) {
+ setStore("selection", undefined)
+ reconnectWithDirectory(directory)
+ },
}
},
})
@@ -260,8 +287,16 @@ function parsePort(value: string | undefined) {
return parsed
}
-function resolveEditorConnection(): EditorConnection | undefined {
- const lock = resolveEditorLockFile()
+function resolveEditorConnection(directory: string): EditorConnection | undefined {
+ const port = parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT)
+ if (port) {
+ return {
+ url: `ws://127.0.0.1:${port}`,
+ source: `env:${port}`,
+ }
+ }
+
+ const lock = resolveEditorLockFile(directory)
if (lock) {
return {
url: `ws://127.0.0.1:${lock.port}`,
@@ -269,16 +304,9 @@ function resolveEditorConnection(): EditorConnection | undefined {
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() {
+function resolveEditorLockFile(activeDirectory: string) {
const directory = path.join(os.homedir(), ".claude", "ide")
let entries: string[]
@@ -288,10 +316,9 @@ function resolveEditorLockFile() {
return
}
- const cwd = process.cwd()
- // longest workspace folder that contains cwd; 0 if none match
+ // longest workspace folder that contains the active session directory; 0 if none match
const bestMatchLength = (lock: EditorLockFile) =>
- Math.max(0, ...lock.workspaceFolders.map((folder) => pathContainsLength(folder, cwd)))
+ Math.max(0, ...lock.workspaceFolders.map((folder) => pathContainsLength(folder, activeDirectory)))
const locks = entries
.filter((entry) => entry.endsWith(".lock"))
.map((entry) => readEditorLockFile(path.join(directory, entry)))
@@ -343,10 +370,10 @@ function pathContainsLength(parent: string, child: string) {
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved.length : 0
}
-function openEditorSocket(connection: EditorConnection) {
- if (!connection.authToken) return new WebSocket(connection.url)
+function openEditorSocket(connection: EditorConnection, WebSocketImpl: typeof WebSocket) {
+ if (!connection.authToken) return new WebSocketImpl(connection.url)
- return new WebSocket(connection.url, {
+ return new WebSocketImpl(connection.url, {
headers: {
"x-claude-code-ide-authorization": connection.authToken,
},
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index 60343de49..8855338d1 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -50,6 +50,7 @@ import type { QuestionTool } from "@/tool/question"
import type { SkillTool } from "@/tool/skill"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk"
+import { useEditorContext } from "@tui/context/editor"
import { useCommandDialog } from "@tui/component/dialog-command"
import type { DialogContext } from "@tui/ui/dialog"
import { useKeybind } from "@tui/context/keybind"
@@ -179,6 +180,7 @@ export function Session() {
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const toast = useToast()
const sdk = useSDK()
+ const editor = useEditorContext()
createEffect(() => {
const sessionID = route.sessionID
@@ -206,6 +208,7 @@ export function Session() {
await sync.bootstrap({ fatal: false })
} catch {}
}
+ editor.reconnect(result.data.directory)
await sync.session.sync(sessionID)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
})().catch((error) => {
diff --git a/packages/opencode/test/cli/tui/editor-context.test.ts b/packages/opencode/test/cli/tui/editor-context-zed.test.ts
index 4c5491461..4c5491461 100644
--- a/packages/opencode/test/cli/tui/editor-context.test.ts
+++ b/packages/opencode/test/cli/tui/editor-context-zed.test.ts
diff --git a/packages/opencode/test/cli/tui/editor-context.test.tsx b/packages/opencode/test/cli/tui/editor-context.test.tsx
new file mode 100644
index 000000000..e896c29fb
--- /dev/null
+++ b/packages/opencode/test/cli/tui/editor-context.test.tsx
@@ -0,0 +1,224 @@
+import { mkdir, writeFile } from "node:fs/promises"
+import os from "node:os"
+import path from "node:path"
+import { afterEach, expect, spyOn, test } from "bun:test"
+import { createRoot } from "solid-js"
+import { EditorContextProvider, useEditorContext } from "../../../src/cli/cmd/tui/context/editor"
+import { tmpdir } from "../../fixture/fixture"
+import { FakeWebSocket } from "../../lib/websocket"
+
+const originalClaudePort = process.env.CLAUDE_CODE_SSE_PORT
+const originalOpencodePort = process.env.OPENCODE_EDITOR_SSE_PORT
+
+afterEach(() => {
+ process.env.CLAUDE_CODE_SSE_PORT = originalClaudePort
+ process.env.OPENCODE_EDITOR_SSE_PORT = originalOpencodePort
+})
+
+function nextTick() {
+ return new Promise<void>((resolve) => queueMicrotask(resolve))
+}
+
+function mountEditorContext(WebSocketImpl?: typeof WebSocket) {
+ let editor!: ReturnType<typeof useEditorContext>
+ let dispose!: () => void
+
+ createRoot((nextDispose) => {
+ dispose = nextDispose
+
+ const Consumer = () => {
+ editor = useEditorContext()
+ return null
+ }
+
+ return (
+ <EditorContextProvider WebSocketImpl={WebSocketImpl}>
+ <Consumer />
+ </EditorContextProvider>
+ )
+ })
+
+ return {
+ editor,
+ dispose,
+ }
+}
+
+function createWebSocketImpl(...sockets: FakeWebSocket[]) {
+ let index = 0
+
+ return class {
+ constructor(url: string, options?: { headers?: Record<string, string> }) {
+ const socket = sockets[index]
+ index += 1
+ expect(socket).toBeDefined()
+ expect(url).toBe(socket!.url)
+ expect(options).toEqual(socket!.options)
+ return socket as unknown as object
+ }
+ } as unknown as typeof WebSocket
+}
+
+test("useEditorContext reconnect switches editor server by session directory", async () => {
+ await using tmp = await tmpdir()
+ const startupDirectory = path.join(tmp.path, "startup")
+ const sessionDirectory = path.join(tmp.path, "session")
+ const ideDirectory = path.join(tmp.path, ".claude", "ide")
+ await mkdir(startupDirectory, { recursive: true })
+ await mkdir(sessionDirectory, { recursive: true })
+ await mkdir(ideDirectory, { recursive: true })
+ await writeFile(
+ path.join(ideDirectory, "3001.lock"),
+ JSON.stringify({
+ transport: "ws",
+ workspaceFolders: [startupDirectory],
+ }),
+ )
+ await writeFile(
+ path.join(ideDirectory, "3002.lock"),
+ JSON.stringify({
+ transport: "ws",
+ workspaceFolders: [sessionDirectory],
+ }),
+ )
+
+ process.env.CLAUDE_CODE_SSE_PORT = undefined
+ process.env.OPENCODE_EDITOR_SSE_PORT = undefined
+ spyOn(process, "cwd").mockImplementation(() => startupDirectory)
+ spyOn(os, "homedir").mockImplementation(() => tmp.path)
+ const firstSocket = new FakeWebSocket("ws://127.0.0.1:3001")
+ const secondSocket = new FakeWebSocket("ws://127.0.0.1:3002")
+
+ const mounted = mountEditorContext(createWebSocketImpl(firstSocket, secondSocket))
+ await nextTick()
+
+ expect(firstSocket.closed).toBeFalse()
+
+ mounted.editor.reconnect(sessionDirectory)
+ await nextTick()
+
+ expect(firstSocket.closed).toBeTrue()
+ expect(secondSocket.closed).toBeFalse()
+
+ mounted.dispose()
+})
+
+test("useEditorContext favors configured port over lock files", async () => {
+ await using tmp = await tmpdir()
+ const startupDirectory = path.join(tmp.path, "startup")
+ const ideDirectory = path.join(tmp.path, ".claude", "ide")
+ await mkdir(startupDirectory, { recursive: true })
+ await mkdir(ideDirectory, { recursive: true })
+ await writeFile(
+ path.join(ideDirectory, "3001.lock"),
+ JSON.stringify({
+ transport: "ws",
+ workspaceFolders: [startupDirectory],
+ }),
+ )
+
+ process.env.CLAUDE_CODE_SSE_PORT = "4010"
+ process.env.OPENCODE_EDITOR_SSE_PORT = undefined
+ spyOn(process, "cwd").mockImplementation(() => startupDirectory)
+ spyOn(os, "homedir").mockImplementation(() => tmp.path)
+ const socket = new FakeWebSocket("ws://127.0.0.1:4010")
+
+ const mounted = mountEditorContext(createWebSocketImpl(socket))
+ await nextTick()
+
+ expect(socket.closed).toBeFalse()
+
+ mounted.dispose()
+})
+
+test("useEditorContext resets selection when reconnecting", async () => {
+ await using tmp = await tmpdir()
+ const startupDirectory = path.join(tmp.path, "startup")
+ const ideDirectory = path.join(tmp.path, ".claude", "ide")
+ await mkdir(startupDirectory, { recursive: true })
+ await mkdir(ideDirectory, { recursive: true })
+ await writeFile(
+ path.join(ideDirectory, "3001.lock"),
+ JSON.stringify({
+ transport: "ws",
+ workspaceFolders: [startupDirectory],
+ }),
+ )
+
+ process.env.CLAUDE_CODE_SSE_PORT = undefined
+ process.env.OPENCODE_EDITOR_SSE_PORT = undefined
+ spyOn(process, "cwd").mockImplementation(() => startupDirectory)
+ spyOn(os, "homedir").mockImplementation(() => tmp.path)
+ const socket = new FakeWebSocket("ws://127.0.0.1:3001")
+
+ const mounted = mountEditorContext(createWebSocketImpl(socket))
+ await nextTick()
+
+ expect(socket.closed).toBeFalse()
+ expect(mounted.editor.selection()).toBeUndefined()
+ expect(mounted.editor.connected()).toBeFalse()
+
+ socket.open()
+ socket.message(
+ JSON.stringify({
+ jsonrpc: "2.0",
+ id: 1,
+ result: {
+ protocolVersion: "2025-11-25",
+ serverInfo: { name: "test", version: "0.0.0" },
+ },
+ }),
+ )
+ socket.message(
+ JSON.stringify({
+ jsonrpc: "2.0",
+ method: "selection_changed",
+ params: {
+ text: "foo",
+ filePath: path.join(startupDirectory, "file.ts"),
+ selection: {
+ start: { line: 1, character: 1 },
+ end: { line: 1, character: 4 },
+ },
+ },
+ }),
+ )
+
+ expect(mounted.editor.connected()).toBeTrue()
+ expect(mounted.editor.server()).toEqual({
+ protocolVersion: "2025-11-25",
+ serverInfo: { name: "test", version: "0.0.0" },
+ })
+ expect(mounted.editor.selection()).toEqual({
+ text: "foo",
+ filePath: path.join(startupDirectory, "file.ts"),
+ source: "websocket",
+ selection: {
+ start: { line: 1, character: 1 },
+ end: { line: 1, character: 4 },
+ },
+ })
+
+ mounted.editor.reconnect(startupDirectory)
+
+ expect(socket.closed).toBeFalse()
+ expect(mounted.editor.connected()).toBeTrue()
+ expect(mounted.editor.selection()).toBeUndefined()
+
+ mounted.dispose()
+})
+
+test("useEditorContext connects with OPENCODE_EDITOR_SSE_PORT", async () => {
+ await using tmp = await tmpdir()
+ process.env.CLAUDE_CODE_SSE_PORT = undefined
+ process.env.OPENCODE_EDITOR_SSE_PORT = "4020"
+ spyOn(process, "cwd").mockImplementation(() => tmp.path)
+ const socket = new FakeWebSocket("ws://127.0.0.1:4020")
+
+ const mounted = mountEditorContext(createWebSocketImpl(socket))
+ await nextTick()
+
+ expect(socket.closed).toBeFalse()
+
+ mounted.dispose()
+})
diff --git a/packages/opencode/test/lib/websocket.ts b/packages/opencode/test/lib/websocket.ts
new file mode 100644
index 000000000..7f7d7fba8
--- /dev/null
+++ b/packages/opencode/test/lib/websocket.ts
@@ -0,0 +1,46 @@
+export class FakeWebSocket {
+ static CONNECTING = 0
+ static OPEN = 1
+ static CLOSING = 2
+ static CLOSED = 3
+
+ readyState = FakeWebSocket.CONNECTING
+ closed = false
+ sent: string[] = []
+ listeners = new Map<string, Set<(event: { data?: unknown }) => void>>()
+
+ constructor(
+ readonly url: string,
+ readonly options?: { headers?: Record<string, string> },
+ ) {}
+
+ addEventListener(type: string, listener: (event: { data?: unknown }) => void) {
+ const current = this.listeners.get(type) ?? new Set<(event: { data?: unknown }) => void>()
+ current.add(listener)
+ this.listeners.set(type, current)
+ }
+
+ send(data: string) {
+ this.sent.push(data)
+ }
+
+ close() {
+ if (this.readyState === FakeWebSocket.CLOSED) return
+ this.closed = true
+ this.readyState = FakeWebSocket.CLOSED
+ this.emit("close", {})
+ }
+
+ open() {
+ this.readyState = FakeWebSocket.OPEN
+ this.emit("open", {})
+ }
+
+ message(data: unknown) {
+ this.emit("message", { data })
+ }
+
+ emit(type: string, event: { data?: unknown }) {
+ this.listeners.get(type)?.forEach((listener) => listener(event))
+ }
+}