From 8e182c77829c2590bb64d6ee9e8b4adb05f74817 Mon Sep 17 00:00:00 2001 From: James Long Date: Tue, 5 May 2026 15:53:05 -0400 Subject: [PATCH] fix(core): better state handling of editor context (#25911) --- .../cli/cmd/tui/component/prompt/index.tsx | 23 ++-- .../src/cli/cmd/tui/context/editor.ts | 44 +++++-- .../opencode/src/cli/cmd/tui/routes/home.tsx | 8 +- .../test/cli/tui/editor-context.test.tsx | 116 +++++++++++++----- 4 files changed, 140 insertions(+), 51 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 74332c77be..0ae258c96e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -173,8 +173,7 @@ export function Prompt(props: PromptProps) { if (!file) return return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3)))) }) - const [editorContextHover, setEditorContextHover] = createSignal(false) - let lastSubmittedEditorSelectionKey: string | undefined + const editorContextLabelState = createMemo(() => editor.labelState()) const [auto, setAuto] = createSignal() const [workspaceSelection, setWorkspaceSelection] = createSignal() const [workspaceCreating, setWorkspaceCreating] = createSignal(false) @@ -916,9 +915,8 @@ export function Prompt(props: PromptProps) { // Capture mode before it gets reset const currentMode = store.mode const editorSelection = editorContext() - const currentEditorSelectionKey = editorSelectionKey(editorSelection) const editorParts = - editorSelection && currentEditorSelectionKey !== lastSubmittedEditorSelectionKey + editorSelection && editor.labelState() === "pending" ? [ { id: PartID.ascending(), @@ -996,7 +994,7 @@ export function Prompt(props: PromptProps) { ], }) .catch(() => {}) - lastSubmittedEditorSelectionKey = currentEditorSelectionKey + if (editorParts.length > 0) editor.markSelectionSent() } history.append({ ...store.prompt, @@ -1011,13 +1009,15 @@ export function Prompt(props: PromptProps) { props.onSubmit?.() // temporary hack to make sure the message is sent - if (!props.sessionID) + if (!props.sessionID) { + if (editorParts.length > 0) editor.preserveSelectionFromNewSession() setTimeout(() => { route.navigate({ type: "session", sessionID, }) }, 50) + } input.clear() return true } @@ -1608,16 +1608,9 @@ export function Prompt(props: PromptProps) { - + {(file) => ( - setEditorContextHover(true)} - onMouseOut={() => setEditorContextHover(false)} - onMouseUp={dismissEditorContext} - > - {editorContextHover() ? `x ${file()}` : file()} - + {file()} )} diff --git a/packages/opencode/src/cli/cmd/tui/context/editor.ts b/packages/opencode/src/cli/cmd/tui/context/editor.ts index 06dd6fd042..6d9e04cf84 100644 --- a/packages/opencode/src/cli/cmd/tui/context/editor.ts +++ b/packages/opencode/src/cli/cmd/tui/context/editor.ts @@ -87,6 +87,7 @@ const EditorServerInfoSchema = z.object({ type JsonRpcMessage = z.infer export type EditorSelection = z.infer export type EditorMention = z.infer +export type EditorLabelState = "pending" | "sent" | "none" type EditorServerInfo = z.infer type EditorConnection = { @@ -111,10 +112,12 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create const [store, setStore] = createStore<{ status: "disabled" | "connecting" | "connected" selection: EditorSelection | undefined + selectionSent: boolean server: EditorServerInfo | undefined }>({ status: "disabled", selection: undefined, + selectionSent: false, server: undefined, }) @@ -126,8 +129,24 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create let zedSelection: Promise | undefined let lastZedSelectionKey: string | undefined let directory = process.cwd() + let preserveSelectionOnReconnect = false const pending = new Map() + 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 })) @@ -158,7 +177,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create const key = editorSelectionKey(selection) if (key !== lastZedSelectionKey) { lastZedSelectionKey = key - setStore("selection", selection) + setSelection(selection) setStore("status", selection ? "connected" : "disabled") } }) @@ -198,7 +217,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create const selection = message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined if (selection?.success) { - setStore("selection", { ...selection.data, source: "websocket" }) + setSelection({ ...selection.data, source: "websocket" }) return } @@ -252,12 +271,13 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create const reconnectWithDirectory = (nextDirectory?: string) => { const resolved = nextDirectory || process.cwd() - if (directory === resolved) return + const sameDirectory = directory === resolved + clearSelectionForReconnect({ resetZedSelectionKey: !sameDirectory }) + if (sameDirectory) return directory = resolved attempt = 0 pending.clear() - lastZedSelectionKey = undefined if (reconnect) clearTimeout(reconnect) reconnect = undefined if (socket) { @@ -266,7 +286,6 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create current.close() } setStore("status", "disabled") - setStore("selection", undefined) setStore("server", undefined) connect() } @@ -293,7 +312,19 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create }, clearSelection() { lastZedSelectionKey = undefined - setStore("selection", 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) @@ -303,7 +334,6 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create return store.server }, reconnect(directory?: string) { - setStore("selection", undefined) reconnectWithDirectory(directory) }, } diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index 4c1cd1babd..43a52082be 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -1,5 +1,5 @@ import { Prompt, type PromptRef } from "@tui/component/prompt" -import { createEffect, createSignal } from "solid-js" +import { createEffect, createSignal, onMount } from "solid-js" import { Logo } from "../component/logo" import { useProject } from "../context/project" import { useSync } from "../context/sync" @@ -9,6 +9,7 @@ import { useRouteData } from "@tui/context/route" import { usePromptRef } from "../context/prompt" import { useLocal } from "../context/local" import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" +import { useEditorContext } from "@tui/context/editor" let once = false const placeholder = { @@ -24,8 +25,13 @@ export function Home() { const [ref, setRef] = createSignal() const args = useArgs() const local = useLocal() + const editor = useEditorContext() let sent = false + onMount(() => { + editor.clearSelection() + }) + const bind = (r: PromptRef | undefined) => { setRef(r) promptRef.set(r) diff --git a/packages/opencode/test/cli/tui/editor-context.test.tsx b/packages/opencode/test/cli/tui/editor-context.test.tsx index 14dead86ac..2c5aa7fa6c 100644 --- a/packages/opencode/test/cli/tui/editor-context.test.tsx +++ b/packages/opencode/test/cli/tui/editor-context.test.tsx @@ -59,6 +59,39 @@ function createWebSocketImpl(...sockets: FakeWebSocket[]) { } as unknown as typeof WebSocket } +function sendSelection(socket: FakeWebSocket, filePath: string, text = "foo") { + socket.message( + JSON.stringify({ + jsonrpc: "2.0", + method: "selection_changed", + params: { + text, + filePath, + selection: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 4 }, + }, + }, + }), + ) +} + +function expectedSelection(filePath: string, text = "foo") { + return { + filePath, + source: "websocket" as const, + ranges: [ + { + text, + selection: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 4 }, + }, + }, + ], + } +} + test("useEditorContext reconnect switches editor server by session directory", async () => { await using tmp = await tmpdir() const startupDirectory = path.join(tmp.path, "startup") @@ -93,12 +126,18 @@ test("useEditorContext reconnect switches editor server by session directory", a await nextTick() expect(firstSocket.closed).toBeFalse() + sendSelection(firstSocket, path.join(startupDirectory, "file.ts")) + + expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts"))) + expect(mounted.editor.labelState()).toBe("pending") mounted.editor.reconnect(sessionDirectory) await nextTick() expect(firstSocket.closed).toBeTrue() expect(secondSocket.closed).toBeFalse() + expect(mounted.editor.selection()).toBeUndefined() + expect(mounted.editor.labelState()).toBe("none") mounted.dispose() }) @@ -131,7 +170,7 @@ test("useEditorContext favors configured port over lock files", async () => { mounted.dispose() }) -test("useEditorContext resets selection when reconnecting", async () => { +test("useEditorContext clears selection when reconnecting", async () => { await using tmp = await tmpdir() const startupDirectory = path.join(tmp.path, "startup") const ideDirectory = path.join(tmp.path, ".claude", "ide") @@ -169,45 +208,66 @@ test("useEditorContext resets selection when reconnecting", async () => { }, }), ) - 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 }, - }, - }, - }), - ) + sendSelection(socket, path.join(startupDirectory, "file.ts")) 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({ - filePath: path.join(startupDirectory, "file.ts"), - source: "websocket", - ranges: [ - { - text: "foo", - selection: { - start: { line: 1, character: 1 }, - end: { line: 1, character: 4 }, - }, - }, - ], - }) + expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts"))) + expect(mounted.editor.labelState()).toBe("pending") + mounted.editor.markSelectionSent() + expect(mounted.editor.labelState()).toBe("sent") mounted.editor.reconnect(startupDirectory) expect(socket.closed).toBeFalse() expect(mounted.editor.connected()).toBeTrue() expect(mounted.editor.selection()).toBeUndefined() + expect(mounted.editor.labelState()).toBe("none") + + mounted.dispose() +}) + +test("useEditorContext preserves selection for the next reconnect when requested", 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() + + sendSelection(socket, path.join(startupDirectory, "file.ts")) + expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts"))) + + mounted.editor.markSelectionSent() + mounted.editor.preserveSelectionFromNewSession() + mounted.editor.reconnect(startupDirectory) + + expect(socket.closed).toBeFalse() + expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts"))) + expect(mounted.editor.labelState()).toBe("sent") + + mounted.editor.reconnect(startupDirectory) + + expect(mounted.editor.selection()).toBeUndefined() + expect(mounted.editor.labelState()).toBe("none") mounted.dispose() })