summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-25 14:10:58 -0400
committerGitHub <[email protected]>2026-04-25 18:10:58 +0000
commit625aca49de2325c29189c6ac6ec5a49efc7e9450 (patch)
tree896358fc5763a7ae4c098c4060c5a0f3aa9f426e
parent3bc0c36acec47aa7ff1278bb222e1224e56641e8 (diff)
downloadopencode-625aca49de2325c29189c6ac6ec5a49efc7e9450.tar.gz
opencode-625aca49de2325c29189c6ac6ec5a49efc7e9450.zip
feat(tui): read Zed editor context from state db (#24352)
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/editor-zed.ts172
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/editor.ts46
-rw-r--r--packages/opencode/test/cli/tui/editor-context.test.ts9
3 files changed, 221 insertions, 6 deletions
diff --git a/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts b/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts
new file mode 100644
index 000000000..cbf995f8d
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts
@@ -0,0 +1,172 @@
+import { Database } from "bun:sqlite"
+import os from "node:os"
+import path from "node:path"
+import z from "zod"
+import { Filesystem } from "@/util"
+import type { EditorSelection } from "./editor"
+
+const ZedEditorRowSchema = z.object({
+ editor_id: z.number(),
+ workspace_id: z.number(),
+ workspace_paths: z.string().nullable(),
+ timestamp: z.string(),
+ buffer_path: z.string().nullable(),
+ selection_start: z.number().nullable(),
+ selection_end: z.number().nullable(),
+})
+
+const ZedEditorContentsSchema = z.object({
+ contents: z.string().nullable(),
+})
+
+type ZedEditorRow = z.infer<typeof ZedEditorRowSchema>
+
+export async function resolveZedSelection(dbPath: string): Promise<EditorSelection | undefined> {
+ const row = queryZedActiveEditor(dbPath, process.cwd())
+ if (!row?.buffer_path || row.selection_start == null || row.selection_end == null) return
+
+ const text =
+ queryZedEditorContents(dbPath, row) ??
+ (await Bun.file(row.buffer_path)
+ .text()
+ .catch(() => undefined))
+ if (text == null) return
+
+ const startOffset = Math.min(row.selection_start, row.selection_end)
+ const endOffset = Math.max(row.selection_start, row.selection_end)
+
+ return {
+ text: text.slice(startOffset, endOffset),
+ filePath: row.buffer_path,
+ selection: offsetsToSelection(text, startOffset, endOffset),
+ }
+}
+
+function queryZedActiveEditor(dbPath: string, cwd: string) {
+ let db: Database | undefined
+ try {
+ db = new Database(dbPath, { readonly: true })
+ return db
+ .query(
+ `select
+ e.item_id as editor_id,
+ e.workspace_id as workspace_id,
+ w.paths as workspace_paths,
+ w.timestamp as timestamp,
+ e.buffer_path as buffer_path,
+ s.start as selection_start,
+ s.end as selection_end
+ from items i
+ join panes p on p.pane_id = i.pane_id and p.workspace_id = i.workspace_id
+ join workspaces w on w.workspace_id = i.workspace_id
+ join editors e on e.item_id = i.item_id and e.workspace_id = i.workspace_id
+ left join editor_selections s on s.editor_id = e.item_id and s.workspace_id = e.workspace_id
+ where i.active = 1 and p.active = 1 and i.kind = 'Editor' and e.buffer_path is not null
+ order by w.timestamp desc`,
+ )
+ .all()
+ .flatMap((row) => {
+ const parsed = ZedEditorRowSchema.safeParse(row)
+ return parsed.success ? [parsed.data] : []
+ })
+ .map((row) => ({ row, score: scoreZedWorkspace(row.workspace_paths, cwd) }))
+ .filter((entry) => entry.score > 0)
+ .sort((left, right) => right.score - left.score || right.row.timestamp.localeCompare(left.row.timestamp))[0]?.row
+ } catch {
+ return
+ } finally {
+ db?.close()
+ }
+}
+
+function queryZedEditorContents(dbPath: string, row: ZedEditorRow) {
+ let db: Database | undefined
+ try {
+ db = new Database(dbPath, { readonly: true })
+ return ZedEditorContentsSchema.safeParse(
+ db
+ .query(
+ `select contents
+ from editors
+ where item_id = $editorID and workspace_id = $workspaceID`,
+ )
+ .get({ $editorID: row.editor_id, $workspaceID: row.workspace_id }),
+ ).data?.contents
+ } catch {
+ return
+ } finally {
+ db?.close()
+ }
+}
+
+export function resolveZedDbPath() {
+ const candidates = [
+ process.env.OPENCODE_ZED_DB,
+ path.join(os.homedir(), "Library", "Application Support", "Zed", "db", "0-stable", "db.sqlite"),
+ path.join(os.homedir(), ".local", "share", "zed", "db", "0-stable", "db.sqlite"),
+ ].filter((item): item is string => Boolean(item))
+
+ return candidates.find((item) => Filesystem.stat(item)?.isFile())
+}
+
+function scoreZedWorkspace(workspacePaths: string | null, cwd: string) {
+ return zedWorkspacePaths(workspacePaths).reduce((score, item) => {
+ if (pathContains(item, cwd)) return Math.max(score, 2)
+ if (pathContains(cwd, item)) return Math.max(score, 1)
+ return score
+ }, 0)
+}
+
+function zedWorkspacePaths(value: string | null) {
+ if (!value) return []
+ const parsed = parseJson(value)
+ if (Array.isArray(parsed)) return parsed.filter((item): item is string => typeof item === "string")
+ return value.split(/\r?\n/).filter(Boolean)
+}
+
+export function offsetToPosition(text: string, offset: number) {
+ return offsetsToSelection(text, offset, offset).start
+}
+
+function offsetsToSelection(text: string, startOffset: number, endOffset: number) {
+ const start = Math.max(0, Math.min(startOffset, text.length))
+ const end = Math.max(0, Math.min(endOffset, text.length))
+ let line = 1
+ let lineStart = 0
+ let startPosition = position(line, lineStart, start)
+ let endPosition = position(line, lineStart, end)
+
+ for (let index = 0; index <= end; index++) {
+ if (index === start) startPosition = position(line, lineStart, index)
+ if (index === end) {
+ endPosition = position(line, lineStart, index)
+ break
+ }
+ if (text[index] === "\n") {
+ line += 1
+ lineStart = index + 1
+ }
+ }
+
+ return { start: startPosition, end: endPosition }
+}
+
+function position(line: number, lineStart: number, offset: number) {
+ return {
+ line,
+ character: offset - lineStart + 1,
+ }
+}
+
+function pathContains(parent: string, child: string) {
+ const relative = path.relative(path.resolve(parent), path.resolve(child))
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
+}
+
+function parseJson(value: string) {
+ try {
+ return JSON.parse(value) as unknown
+ } catch {
+ return
+ }
+}
diff --git a/packages/opencode/src/cli/cmd/tui/context/editor.ts b/packages/opencode/src/cli/cmd/tui/context/editor.ts
index 4e6c97f6e..75c5440f5 100644
--- a/packages/opencode/src/cli/cmd/tui/context/editor.ts
+++ b/packages/opencode/src/cli/cmd/tui/context/editor.ts
@@ -4,7 +4,9 @@ import path from "node:path"
import { onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import z from "zod"
+import { isRecord } from "@/util/record"
import { createSimpleContext } from "./helper"
+import { resolveZedDbPath, resolveZedSelection } from "./editor-zed"
const MCP_PROTOCOL_VERSION = "2025-11-25"
@@ -90,6 +92,8 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
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) => {
@@ -114,7 +118,29 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
const connection = resolveEditorConnection()
if (!connection) {
- setStore("status", "disabled")
+ const dbPath = resolveZedDbPath()
+ if (!dbPath) {
+ setStore("status", "disabled")
+ scheduleReconnect(1000)
+ return
+ }
+ zedSelection ??= resolveZedSelection(dbPath)
+ .then((selection) => {
+ if (closed || socket) return
+ const key = editorSelectionKey(selection)
+ if (key !== lastZedSelectionKey) {
+ lastZedSelectionKey = key
+ setStore("selection", selection)
+ setStore("status", selection ? "connected" : "disabled")
+ }
+ })
+ .catch(() => {
+ if (closed || socket) return
+ setStore("status", "disabled")
+ })
+ .finally(() => {
+ zedSelection = undefined
+ })
scheduleReconnect(1000)
return
}
@@ -196,7 +222,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
return {
enabled() {
- return Boolean(resolveEditorConnection())
+ return Boolean(resolveEditorConnection() || resolveZedDbPath())
},
connected() {
return store.status === "connected"
@@ -289,6 +315,18 @@ function scoreEditorLock(lock: EditorLockFile, cwd: string) {
return workspaceMatch * 1_000_000_000_000 + lock.mtimeMs
}
+function editorSelectionKey(selection: EditorSelection | undefined) {
+ if (!selection) return ""
+ return [
+ selection.filePath,
+ selection.selection.start.line,
+ selection.selection.start.character,
+ selection.selection.end.line,
+ selection.selection.end.character,
+ selection.text,
+ ].join("\0")
+}
+
function pathContains(parent: string, child: string) {
const relative = path.relative(path.resolve(parent), path.resolve(child))
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
@@ -313,7 +351,3 @@ function parseMessage(value: unknown) {
return
}
}
-
-function isRecord(value: unknown): value is Record<string, unknown> {
- return typeof value === "object" && value !== null && !Array.isArray(value)
-}
diff --git a/packages/opencode/test/cli/tui/editor-context.test.ts b/packages/opencode/test/cli/tui/editor-context.test.ts
new file mode 100644
index 000000000..c605029ca
--- /dev/null
+++ b/packages/opencode/test/cli/tui/editor-context.test.ts
@@ -0,0 +1,9 @@
+import { expect, test } from "bun:test"
+import { offsetToPosition } from "../../../src/cli/cmd/tui/context/editor-zed"
+
+test("offsetToPosition converts Zed offsets to 1-based editor positions", () => {
+ expect(offsetToPosition("one\ntwo\nthree", 0)).toEqual({ line: 1, character: 1 })
+ expect(offsetToPosition("one\ntwo\nthree", 4)).toEqual({ line: 2, character: 1 })
+ expect(offsetToPosition("one\ntwo\nthree", 6)).toEqual({ line: 2, character: 3 })
+ expect(offsetToPosition("one\ntwo\nthree", 100)).toEqual({ line: 3, character: 6 })
+})