Skip to content

Commit b116df7

Browse files
authored
fix(tui): stabilize Zed editor context polling (anomalyco#24656)
1 parent eb9c5dd commit b116df7

4 files changed

Lines changed: 128 additions & 21 deletions

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,12 @@ export function Prompt(props: PromptProps) {
764764
return `Note: The user selected lines ${start.line} to ${end.line} from "${editorSelection.filePath}": ${editorSelection.text}`
765765
})(),
766766
synthetic: true,
767+
metadata: {
768+
kind: "editor_context",
769+
source: editorSelection.source ?? "editor",
770+
filePath: editorSelection.filePath,
771+
selection: editorSelection.selection,
772+
},
767773
},
768774
]
769775
: []

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

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,32 +21,45 @@ const ZedEditorContentsSchema = z.object({
2121

2222
type ZedEditorRow = z.infer<typeof ZedEditorRowSchema>
2323

24-
export async function resolveZedSelection(dbPath: string): Promise<EditorSelection | undefined> {
25-
const row = queryZedActiveEditor(dbPath, process.cwd())
26-
if (!row?.buffer_path || row.selection_start == null || row.selection_end == null) return
24+
export type ZedSelectionResult =
25+
| { type: "selection"; selection: EditorSelection }
26+
| { type: "empty" }
27+
| { type: "unavailable" }
2728

29+
export async function resolveZedSelection(dbPath: string, cwd = process.cwd()): Promise<ZedSelectionResult> {
30+
const active = queryZedActiveEditor(dbPath, cwd)
31+
if (active.type !== "row") return active
32+
33+
const row = active.row
34+
if (!row.buffer_path) return { type: "empty" }
35+
if (row.selection_start == null || row.selection_end == null) return { type: "unavailable" }
36+
37+
const contents = queryZedEditorContents(dbPath, row)
2838
const text =
29-
queryZedEditorContents(dbPath, row) ??
30-
(await Bun.file(row.buffer_path)
31-
.text()
32-
.catch(() => undefined))
33-
if (text == null) return
39+
contents.type === "contents" && contents.contents != null
40+
? contents.contents
41+
: await Bun.file(row.buffer_path).text().catch(() => undefined)
42+
if (text == null) return { type: "unavailable" }
3443

3544
const startOffset = Math.min(row.selection_start, row.selection_end)
3645
const endOffset = Math.max(row.selection_start, row.selection_end)
3746

3847
return {
39-
text: text.slice(startOffset, endOffset),
40-
filePath: row.buffer_path,
41-
selection: offsetsToSelection(text, startOffset, endOffset),
48+
type: "selection",
49+
selection: {
50+
text: text.slice(startOffset, endOffset),
51+
filePath: row.buffer_path,
52+
source: "zed",
53+
selection: offsetsToSelection(text, startOffset, endOffset),
54+
},
4255
}
4356
}
4457

4558
function queryZedActiveEditor(dbPath: string, cwd: string) {
4659
let db: Database | undefined
4760
try {
4861
db = new Database(dbPath, { readonly: true })
49-
return db
62+
const raw = db
5063
.query(
5164
`select
5265
e.item_id as editor_id,
@@ -65,15 +78,23 @@ function queryZedActiveEditor(dbPath: string, cwd: string) {
6578
order by w.timestamp desc`,
6679
)
6780
.all()
81+
82+
const rows = raw
6883
.flatMap((row) => {
6984
const parsed = ZedEditorRowSchema.safeParse(row)
7085
return parsed.success ? [parsed.data] : []
7186
})
87+
88+
if (raw.length > 0 && rows.length === 0) return { type: "unavailable" as const }
89+
90+
const row = rows
7291
.map((row) => ({ row, score: scoreZedWorkspace(row.workspace_paths, cwd) }))
7392
.filter((entry) => entry.score > 0)
7493
.sort((left, right) => right.score - left.score || right.row.timestamp.localeCompare(left.row.timestamp))[0]?.row
94+
if (!row) return { type: "empty" as const }
95+
return { type: "row" as const, row }
7596
} catch {
76-
return
97+
return { type: "unavailable" as const }
7798
} finally {
7899
db?.close()
79100
}
@@ -83,17 +104,19 @@ function queryZedEditorContents(dbPath: string, row: ZedEditorRow) {
83104
let db: Database | undefined
84105
try {
85106
db = new Database(dbPath, { readonly: true })
86-
return ZedEditorContentsSchema.safeParse(
107+
const parsed = ZedEditorContentsSchema.safeParse(
87108
db
88109
.query(
89110
`select contents
90111
from editors
91112
where item_id = $editorID and workspace_id = $workspaceID`,
92113
)
93114
.get({ $editorID: row.editor_id, $workspaceID: row.workspace_id }),
94-
).data?.contents
115+
)
116+
if (!parsed.success) return { type: "unavailable" as const }
117+
return { type: "contents" as const, contents: parsed.data.contents }
95118
} catch {
96-
return
119+
return { type: "unavailable" as const }
97120
} finally {
98121
db?.close()
99122
}

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const PositionSchema = z.object({
3131
const EditorSelectionSchema = z.object({
3232
text: z.string(),
3333
filePath: z.string(),
34+
source: z.enum(["websocket", "zed"]).optional(),
3435
selection: z.object({
3536
start: PositionSchema,
3637
end: PositionSchema,
@@ -125,8 +126,10 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
125126
return
126127
}
127128
zedSelection ??= resolveZedSelection(dbPath)
128-
.then((selection) => {
129+
.then((result) => {
129130
if (closed || socket) return
131+
if (result.type === "unavailable") return
132+
const selection = result.type === "selection" ? result.selection : undefined
130133
const key = editorSelectionKey(selection)
131134
if (key !== lastZedSelectionKey) {
132135
lastZedSelectionKey = key
@@ -135,8 +138,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
135138
}
136139
})
137140
.catch(() => {
138-
if (closed || socket) return
139-
setStore("status", "disabled")
141+
// Keep the last known Zed selection for transient polling failures.
140142
})
141143
.finally(() => {
142144
zedSelection = undefined
@@ -171,7 +173,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
171173
const selection =
172174
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
173175
if (selection?.success) {
174-
setStore("selection", selection.data)
176+
setStore("selection", { ...selection.data, source: "websocket" })
175177
return
176178
}
177179

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,85 @@
1+
import { Database } from "bun:sqlite"
2+
import path from "node:path"
13
import { expect, test } from "bun:test"
2-
import { offsetToPosition } from "../../../src/cli/cmd/tui/context/editor-zed"
4+
import { offsetToPosition, resolveZedSelection } from "../../../src/cli/cmd/tui/context/editor-zed"
5+
import { tmpdir } from "../../fixture/fixture"
6+
7+
type ZedFixtureOptions = {
8+
workspacePaths?: string | null
9+
selectionStart?: number | null
10+
selectionEnd?: number | null
11+
}
12+
13+
async function writeZedFixture(dir: string, options: ZedFixtureOptions = {}) {
14+
const dbPath = path.join(dir, "zed.sqlite")
15+
const filePath = path.join(dir, "file.ts")
16+
await Bun.write(filePath, "one\ntwo\nthree")
17+
18+
const db = new Database(dbPath)
19+
db.run("create table workspaces (workspace_id integer, paths text, timestamp text)")
20+
db.run("create table panes (pane_id integer, workspace_id integer, active integer)")
21+
db.run("create table items (item_id integer, workspace_id integer, pane_id integer, active integer, kind text)")
22+
db.run("create table editors (item_id integer, workspace_id integer, buffer_path text, contents text)")
23+
db.run("create table editor_selections (editor_id integer, workspace_id integer, start integer, end integer)")
24+
db.run("insert into workspaces values (1, ?, ?)", [options.workspacePaths ?? JSON.stringify([dir]), "2026-04-27"])
25+
db.run("insert into panes values (1, 1, 1)")
26+
db.run("insert into items values (1, 1, 1, 1, 'Editor')")
27+
db.run("insert into editors values (1, 1, ?, ?)", [filePath, "one\ntwo\nthree"])
28+
db.run(
29+
"insert into editor_selections values (1, 1, ?, ?)",
30+
[
31+
options.selectionStart === undefined ? 4 : options.selectionStart,
32+
options.selectionEnd === undefined ? 7 : options.selectionEnd,
33+
],
34+
)
35+
db.close()
36+
37+
return { dbPath, filePath }
38+
}
339

440
test("offsetToPosition converts Zed offsets to 1-based editor positions", () => {
541
expect(offsetToPosition("one\ntwo\nthree", 0)).toEqual({ line: 1, character: 1 })
642
expect(offsetToPosition("one\ntwo\nthree", 4)).toEqual({ line: 2, character: 1 })
743
expect(offsetToPosition("one\ntwo\nthree", 6)).toEqual({ line: 2, character: 3 })
844
expect(offsetToPosition("one\ntwo\nthree", 100)).toEqual({ line: 3, character: 6 })
945
})
46+
47+
test("resolveZedSelection returns active editor selection", async () => {
48+
await using tmp = await tmpdir()
49+
const fixture = await writeZedFixture(tmp.path)
50+
51+
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
52+
type: "selection",
53+
selection: {
54+
text: "two",
55+
filePath: fixture.filePath,
56+
source: "zed",
57+
selection: {
58+
start: { line: 2, character: 1 },
59+
end: { line: 2, character: 4 },
60+
},
61+
},
62+
})
63+
})
64+
65+
test("resolveZedSelection returns empty when no workspace matches", async () => {
66+
await using tmp = await tmpdir()
67+
const fixture = await writeZedFixture(tmp.path, {
68+
workspacePaths: JSON.stringify([path.join(path.dirname(tmp.path), "other-workspace")]),
69+
})
70+
71+
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "empty" })
72+
})
73+
74+
test("resolveZedSelection returns unavailable when the database cannot be queried", async () => {
75+
await using tmp = await tmpdir()
76+
77+
expect(await resolveZedSelection(path.join(tmp.path, "missing.sqlite"), tmp.path)).toEqual({ type: "unavailable" })
78+
})
79+
80+
test("resolveZedSelection returns unavailable when active selection is missing offsets", async () => {
81+
await using tmp = await tmpdir()
82+
const fixture = await writeZedFixture(tmp.path, { selectionStart: null, selectionEnd: null })
83+
84+
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "unavailable" })
85+
})

0 commit comments

Comments
 (0)