Skip to content

Commit c9b47d1

Browse files
authored
fix(core): better state handling of editor context (anomalyco#25911)
1 parent 33153e1 commit c9b47d1

4 files changed

Lines changed: 140 additions & 51 deletions

File tree

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -173,8 +173,7 @@ export function Prompt(props: PromptProps) {
173173
if (!file) return
174174
return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3))))
175175
})
176-
const [editorContextHover, setEditorContextHover] = createSignal(false)
177-
let lastSubmittedEditorSelectionKey: string | undefined
176+
const editorContextLabelState = createMemo(() => editor.labelState())
178177
const [auto, setAuto] = createSignal<AutocompleteRef>()
179178
const [workspaceSelection, setWorkspaceSelection] = createSignal<WorkspaceSelection>()
180179
const [workspaceCreating, setWorkspaceCreating] = createSignal(false)
@@ -916,9 +915,8 @@ export function Prompt(props: PromptProps) {
916915
// Capture mode before it gets reset
917916
const currentMode = store.mode
918917
const editorSelection = editorContext()
919-
const currentEditorSelectionKey = editorSelectionKey(editorSelection)
920918
const editorParts =
921-
editorSelection && currentEditorSelectionKey !== lastSubmittedEditorSelectionKey
919+
editorSelection && editor.labelState() === "pending"
922920
? [
923921
{
924922
id: PartID.ascending(),
@@ -996,7 +994,7 @@ export function Prompt(props: PromptProps) {
996994
],
997995
})
998996
.catch(() => {})
999-
lastSubmittedEditorSelectionKey = currentEditorSelectionKey
997+
if (editorParts.length > 0) editor.markSelectionSent()
1000998
}
1001999
history.append({
10021000
...store.prompt,
@@ -1011,13 +1009,15 @@ export function Prompt(props: PromptProps) {
10111009
props.onSubmit?.()
10121010

10131011
// temporary hack to make sure the message is sent
1014-
if (!props.sessionID)
1012+
if (!props.sessionID) {
1013+
if (editorParts.length > 0) editor.preserveSelectionFromNewSession()
10151014
setTimeout(() => {
10161015
route.navigate({
10171016
type: "session",
10181017
sessionID,
10191018
})
10201019
}, 50)
1020+
}
10211021
input.clear()
10221022
return true
10231023
}
@@ -1608,16 +1608,9 @@ export function Prompt(props: PromptProps) {
16081608
</Switch>
16091609
<Show when={status().type !== "retry"}>
16101610
<box gap={2} flexDirection="row">
1611-
<Show when={editorFileLabelDisplay()}>
1611+
<Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
16121612
{(file) => (
1613-
<text
1614-
fg={theme.secondary}
1615-
onMouseOver={() => setEditorContextHover(true)}
1616-
onMouseOut={() => setEditorContextHover(false)}
1617-
onMouseUp={dismissEditorContext}
1618-
>
1619-
{editorContextHover() ? `x ${file()}` : file()}
1620-
</text>
1613+
<text fg={editorContextLabelState() === "pending" ? theme.secondary : theme.textMuted}>{file()}</text>
16211614
)}
16221615
</Show>
16231616
<Switch>

packages/opencode/src/cli/cmd/tui/context/editor.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ const EditorServerInfoSchema = z.object({
8787
type JsonRpcMessage = z.infer<typeof JsonRpcMessageSchema>
8888
export type EditorSelection = z.infer<typeof EditorSelectionSchema>
8989
export type EditorMention = z.infer<typeof EditorMentionSchema>
90+
export type EditorLabelState = "pending" | "sent" | "none"
9091
type EditorServerInfo = z.infer<typeof EditorServerInfoSchema>
9192

9293
type EditorConnection = {
@@ -111,10 +112,12 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
111112
const [store, setStore] = createStore<{
112113
status: "disabled" | "connecting" | "connected"
113114
selection: EditorSelection | undefined
115+
selectionSent: boolean
114116
server: EditorServerInfo | undefined
115117
}>({
116118
status: "disabled",
117119
selection: undefined,
120+
selectionSent: false,
118121
server: undefined,
119122
})
120123

@@ -126,8 +129,24 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
126129
let zedSelection: Promise<void> | undefined
127130
let lastZedSelectionKey: string | undefined
128131
let directory = process.cwd()
132+
let preserveSelectionOnReconnect = false
129133
const pending = new Map<number, string>()
130134

135+
const setSelection = (selection: EditorSelection | undefined) => {
136+
const changed = editorSelectionKey(selection) !== editorSelectionKey(store.selection)
137+
setStore("selection", selection)
138+
if (changed) setStore("selectionSent", false)
139+
}
140+
141+
const clearSelectionForReconnect = (options?: { resetZedSelectionKey?: boolean }) => {
142+
if (preserveSelectionOnReconnect) {
143+
preserveSelectionOnReconnect = false
144+
return
145+
}
146+
if (options?.resetZedSelectionKey) lastZedSelectionKey = undefined
147+
setSelection(undefined)
148+
}
149+
131150
const send = (payload: JsonRpcMessage) => {
132151
if (!socket || socket.readyState !== 1) return
133152
socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
@@ -158,7 +177,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
158177
const key = editorSelectionKey(selection)
159178
if (key !== lastZedSelectionKey) {
160179
lastZedSelectionKey = key
161-
setStore("selection", selection)
180+
setSelection(selection)
162181
setStore("status", selection ? "connected" : "disabled")
163182
}
164183
})
@@ -198,7 +217,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
198217
const selection =
199218
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
200219
if (selection?.success) {
201-
setStore("selection", { ...selection.data, source: "websocket" })
220+
setSelection({ ...selection.data, source: "websocket" })
202221
return
203222
}
204223

@@ -252,12 +271,13 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
252271

253272
const reconnectWithDirectory = (nextDirectory?: string) => {
254273
const resolved = nextDirectory || process.cwd()
255-
if (directory === resolved) return
274+
const sameDirectory = directory === resolved
275+
clearSelectionForReconnect({ resetZedSelectionKey: !sameDirectory })
276+
if (sameDirectory) return
256277

257278
directory = resolved
258279
attempt = 0
259280
pending.clear()
260-
lastZedSelectionKey = undefined
261281
if (reconnect) clearTimeout(reconnect)
262282
reconnect = undefined
263283
if (socket) {
@@ -266,7 +286,6 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
266286
current.close()
267287
}
268288
setStore("status", "disabled")
269-
setStore("selection", undefined)
270289
setStore("server", undefined)
271290
connect()
272291
}
@@ -293,7 +312,19 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
293312
},
294313
clearSelection() {
295314
lastZedSelectionKey = undefined
296-
setStore("selection", undefined)
315+
zedSelection = undefined
316+
setSelection(undefined)
317+
},
318+
preserveSelectionFromNewSession() {
319+
preserveSelectionOnReconnect = true
320+
},
321+
markSelectionSent() {
322+
if (!store.selection) return
323+
setStore("selectionSent", true)
324+
},
325+
labelState(): EditorLabelState {
326+
if (!store.selection) return "none"
327+
return store.selectionSent ? "sent" : "pending"
297328
},
298329
onMention(listener: (mention: EditorMention) => void) {
299330
mentionListeners.add(listener)
@@ -303,7 +334,6 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
303334
return store.server
304335
},
305336
reconnect(directory?: string) {
306-
setStore("selection", undefined)
307337
reconnectWithDirectory(directory)
308338
},
309339
}

packages/opencode/src/cli/cmd/tui/routes/home.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Prompt, type PromptRef } from "@tui/component/prompt"
2-
import { createEffect, createSignal } from "solid-js"
2+
import { createEffect, createSignal, onMount } from "solid-js"
33
import { Logo } from "../component/logo"
44
import { useProject } from "../context/project"
55
import { useSync } from "../context/sync"
@@ -9,6 +9,7 @@ import { useRouteData } from "@tui/context/route"
99
import { usePromptRef } from "../context/prompt"
1010
import { useLocal } from "../context/local"
1111
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
12+
import { useEditorContext } from "@tui/context/editor"
1213

1314
let once = false
1415
const placeholder = {
@@ -24,8 +25,13 @@ export function Home() {
2425
const [ref, setRef] = createSignal<PromptRef | undefined>()
2526
const args = useArgs()
2627
const local = useLocal()
28+
const editor = useEditorContext()
2729
let sent = false
2830

31+
onMount(() => {
32+
editor.clearSelection()
33+
})
34+
2935
const bind = (r: PromptRef | undefined) => {
3036
setRef(r)
3137
promptRef.set(r)

packages/opencode/test/cli/tui/editor-context.test.tsx

Lines changed: 88 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,39 @@ function createWebSocketImpl(...sockets: FakeWebSocket[]) {
5959
} as unknown as typeof WebSocket
6060
}
6161

62+
function sendSelection(socket: FakeWebSocket, filePath: string, text = "foo") {
63+
socket.message(
64+
JSON.stringify({
65+
jsonrpc: "2.0",
66+
method: "selection_changed",
67+
params: {
68+
text,
69+
filePath,
70+
selection: {
71+
start: { line: 1, character: 1 },
72+
end: { line: 1, character: 4 },
73+
},
74+
},
75+
}),
76+
)
77+
}
78+
79+
function expectedSelection(filePath: string, text = "foo") {
80+
return {
81+
filePath,
82+
source: "websocket" as const,
83+
ranges: [
84+
{
85+
text,
86+
selection: {
87+
start: { line: 1, character: 1 },
88+
end: { line: 1, character: 4 },
89+
},
90+
},
91+
],
92+
}
93+
}
94+
6295
test("useEditorContext reconnect switches editor server by session directory", async () => {
6396
await using tmp = await tmpdir()
6497
const startupDirectory = path.join(tmp.path, "startup")
@@ -93,12 +126,18 @@ test("useEditorContext reconnect switches editor server by session directory", a
93126
await nextTick()
94127

95128
expect(firstSocket.closed).toBeFalse()
129+
sendSelection(firstSocket, path.join(startupDirectory, "file.ts"))
130+
131+
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
132+
expect(mounted.editor.labelState()).toBe("pending")
96133

97134
mounted.editor.reconnect(sessionDirectory)
98135
await nextTick()
99136

100137
expect(firstSocket.closed).toBeTrue()
101138
expect(secondSocket.closed).toBeFalse()
139+
expect(mounted.editor.selection()).toBeUndefined()
140+
expect(mounted.editor.labelState()).toBe("none")
102141

103142
mounted.dispose()
104143
})
@@ -131,7 +170,7 @@ test("useEditorContext favors configured port over lock files", async () => {
131170
mounted.dispose()
132171
})
133172

134-
test("useEditorContext resets selection when reconnecting", async () => {
173+
test("useEditorContext clears selection when reconnecting", async () => {
135174
await using tmp = await tmpdir()
136175
const startupDirectory = path.join(tmp.path, "startup")
137176
const ideDirectory = path.join(tmp.path, ".claude", "ide")
@@ -169,45 +208,66 @@ test("useEditorContext resets selection when reconnecting", async () => {
169208
},
170209
}),
171210
)
172-
socket.message(
173-
JSON.stringify({
174-
jsonrpc: "2.0",
175-
method: "selection_changed",
176-
params: {
177-
text: "foo",
178-
filePath: path.join(startupDirectory, "file.ts"),
179-
selection: {
180-
start: { line: 1, character: 1 },
181-
end: { line: 1, character: 4 },
182-
},
183-
},
184-
}),
185-
)
211+
sendSelection(socket, path.join(startupDirectory, "file.ts"))
186212

187213
expect(mounted.editor.connected()).toBeTrue()
188214
expect(mounted.editor.server()).toEqual({
189215
protocolVersion: "2025-11-25",
190216
serverInfo: { name: "test", version: "0.0.0" },
191217
})
192-
expect(mounted.editor.selection()).toEqual({
193-
filePath: path.join(startupDirectory, "file.ts"),
194-
source: "websocket",
195-
ranges: [
196-
{
197-
text: "foo",
198-
selection: {
199-
start: { line: 1, character: 1 },
200-
end: { line: 1, character: 4 },
201-
},
202-
},
203-
],
204-
})
218+
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
219+
expect(mounted.editor.labelState()).toBe("pending")
220+
mounted.editor.markSelectionSent()
221+
expect(mounted.editor.labelState()).toBe("sent")
205222

206223
mounted.editor.reconnect(startupDirectory)
207224

208225
expect(socket.closed).toBeFalse()
209226
expect(mounted.editor.connected()).toBeTrue()
210227
expect(mounted.editor.selection()).toBeUndefined()
228+
expect(mounted.editor.labelState()).toBe("none")
229+
230+
mounted.dispose()
231+
})
232+
233+
test("useEditorContext preserves selection for the next reconnect when requested", async () => {
234+
await using tmp = await tmpdir()
235+
const startupDirectory = path.join(tmp.path, "startup")
236+
const ideDirectory = path.join(tmp.path, ".claude", "ide")
237+
await mkdir(startupDirectory, { recursive: true })
238+
await mkdir(ideDirectory, { recursive: true })
239+
await writeFile(
240+
path.join(ideDirectory, "3001.lock"),
241+
JSON.stringify({
242+
transport: "ws",
243+
workspaceFolders: [startupDirectory],
244+
}),
245+
)
246+
247+
process.env.CLAUDE_CODE_SSE_PORT = undefined
248+
process.env.OPENCODE_EDITOR_SSE_PORT = undefined
249+
spyOn(process, "cwd").mockImplementation(() => startupDirectory)
250+
spyOn(os, "homedir").mockImplementation(() => tmp.path)
251+
const socket = new FakeWebSocket("ws://127.0.0.1:3001")
252+
253+
const mounted = mountEditorContext(createWebSocketImpl(socket))
254+
await nextTick()
255+
256+
sendSelection(socket, path.join(startupDirectory, "file.ts"))
257+
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
258+
259+
mounted.editor.markSelectionSent()
260+
mounted.editor.preserveSelectionFromNewSession()
261+
mounted.editor.reconnect(startupDirectory)
262+
263+
expect(socket.closed).toBeFalse()
264+
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
265+
expect(mounted.editor.labelState()).toBe("sent")
266+
267+
mounted.editor.reconnect(startupDirectory)
268+
269+
expect(mounted.editor.selection()).toBeUndefined()
270+
expect(mounted.editor.labelState()).toBe("none")
211271

212272
mounted.dispose()
213273
})

0 commit comments

Comments
 (0)