diff --git a/package.json b/package.json
index 39733b931a3..b7ac54aa2d5 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"typecheck": "bun turbo typecheck",
"prepare": "husky",
+ "generate": "bun run --cwd packages/sdk/js build",
"random": "echo 'Random script'",
"hello": "echo 'Hello World!'"
},
diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index 28e84112240..3f1bda5384e 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -13,6 +13,7 @@ import { SyncProvider, useSync } from "@tui/context/sync"
import { LocalProvider, useLocal } from "@tui/context/local"
import { DialogModel, useConnected } from "@tui/component/dialog-model"
import { DialogMcp } from "@tui/component/dialog-mcp"
+import { DialogIde } from "@tui/component/dialog-ide"
import { DialogStatus } from "@tui/component/dialog-status"
import { DialogThemeList } from "@tui/component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
@@ -312,6 +313,14 @@ function App() {
dialog.replace(() => )
},
},
+ {
+ title: "Toggle IDEs",
+ value: "ide.list",
+ category: "Agent",
+ onSelect: () => {
+ dialog.replace(() => )
+ },
+ },
{
title: "Agent cycle",
value: "agent.cycle",
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-ide.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-ide.tsx
new file mode 100644
index 00000000000..8998f6f5a0b
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-ide.tsx
@@ -0,0 +1,76 @@
+import { createMemo, createSignal } from "solid-js"
+import { useLocal } from "@tui/context/local"
+import { useSync } from "@tui/context/sync"
+import { map, pipe, entries, sortBy } from "remeda"
+import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "@tui/ui/dialog-select"
+import { useTheme } from "../context/theme"
+import { Keybind } from "@/util/keybind"
+import { TextAttributes } from "@opentui/core"
+
+function Status(props: { connected: boolean; loading: boolean }) {
+ const { theme } = useTheme()
+ if (props.loading) {
+ return ⋯ Loading
+ }
+ if (props.connected) {
+ return ✓ Connected
+ }
+ return ○ Disconnected
+}
+
+export function DialogIde() {
+ const local = useLocal()
+ const sync = useSync()
+ const [, setRef] = createSignal>()
+ const [loading, setLoading] = createSignal(null)
+
+ const options = createMemo(() => {
+ const ideData = sync.data.ide
+ const loadingIde = loading()
+ const projectDir = process.cwd()
+
+ return pipe(
+ ideData ?? {},
+ entries(),
+ sortBy(
+ ([key]) => {
+ const folders = local.ide.getWorkspaceFolders(key)
+ // Exact match - highest priority
+ if (folders.some((folder: string) => folder === projectDir)) return 0
+ // IDE workspace contains current directory (we're in a subdirectory of IDE workspace)
+ if (folders.some((folder: string) => projectDir.startsWith(folder + "/"))) return 1
+ return 2
+ },
+ ([, status]) => status.name,
+ ),
+ map(([key, status]) => {
+ return {
+ value: key,
+ title: status.name,
+ description: local.ide.getWorkspaceFolders(key)[0],
+ footer: ,
+ category: undefined,
+ }
+ }),
+ )
+ })
+
+ const keybinds = createMemo(() => [
+ {
+ keybind: Keybind.parse("space")[0],
+ title: "toggle",
+ onTrigger: async (option: DialogSelectOption) => {
+ if (loading() !== null) return
+
+ setLoading(option.value)
+ try {
+ await local.ide.toggle(option.value)
+ } finally {
+ setLoading(null)
+ }
+ },
+ },
+ ])
+
+ return {}} />
+}
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
index c40aa114ac8..b193b2e2b4d 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
@@ -315,6 +315,11 @@ export function Autocomplete(props: {
description: "toggle MCPs",
onSelect: () => command.trigger("mcp.list"),
},
+ {
+ display: "/ide",
+ description: "toggle IDEs",
+ onSelect: () => command.trigger("ide.list"),
+ },
{
display: "/theme",
description: "toggle theme",
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 669ed189795..194d1cda860 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
@@ -20,6 +20,7 @@ import { useExit } from "../../context/exit"
import { Clipboard } from "../../util/clipboard"
import type { FilePart } from "@opencode-ai/sdk/v2"
import { TuiEvent } from "../../event"
+import { Ide } from "@/ide"
import { iife } from "@/util/iife"
import { Locale } from "@/util/locale"
import { createColors, createFrames } from "../../ui/spinner.ts"
@@ -311,6 +312,10 @@ export function Prompt(props: PromptProps) {
input.insertText(evt.properties.text)
})
+ sdk.event.on(Ide.Event.SelectionChanged.type, (evt) => {
+ updateIdeSelection(evt.properties.selection)
+ })
+
createEffect(() => {
if (props.disabled) input.cursorColor = theme.backgroundElement
if (!props.disabled) input.cursorColor = theme.text
@@ -341,6 +346,95 @@ export function Prompt(props: PromptProps) {
promptPartTypeId = input.extmarks.registerType("prompt-part")
})
+ // Track IDE selection extmark so we can update/remove it
+ let ideSelectionExtmarkId: number | null = null
+
+ function removeExtmark(extmarkId: number) {
+ const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
+ const extmark = allExtmarks.find((e) => e.id === extmarkId)
+ const partIndex = store.extmarkToPartIndex.get(extmarkId)
+
+ if (partIndex !== undefined) {
+ setStore(
+ produce((draft) => {
+ draft.prompt.parts.splice(partIndex, 1)
+ draft.extmarkToPartIndex.delete(extmarkId)
+ const newMap = new Map()
+ for (const [id, idx] of draft.extmarkToPartIndex) {
+ newMap.set(id, idx > partIndex ? idx - 1 : idx)
+ }
+ draft.extmarkToPartIndex = newMap
+ }),
+ )
+ }
+
+ if (extmark) {
+ const savedOffset = input.cursorOffset
+ input.cursorOffset = extmark.start
+ const start = { ...input.logicalCursor }
+ input.cursorOffset = extmark.end + 1
+ input.deleteRange(start.row, start.col, input.logicalCursor.row, input.logicalCursor.col)
+ input.cursorOffset =
+ savedOffset > extmark.start
+ ? Math.max(extmark.start, savedOffset - (extmark.end + 1 - extmark.start))
+ : savedOffset
+ }
+
+ input.extmarks.delete(extmarkId)
+ }
+
+ function updateIdeSelection(selection: Ide.Selection | null) {
+ if (!input || promptPartTypeId === undefined) return
+
+ if (ideSelectionExtmarkId !== null) {
+ removeExtmark(ideSelectionExtmarkId)
+ ideSelectionExtmarkId = null
+ }
+
+ // Ignore empty selections (just a cursor position)
+ if (!selection || !selection.text) return
+
+ const { filePath, text } = selection
+ const filename = filePath.split("/").pop() || filePath
+ const start = selection.selection.start.line + 1
+ const end = selection.selection.end.line + 1
+ const lines = text.split("\n").length
+
+ const previewText = `[${filename}:${start}-${end} ~${lines} lines]`
+ const contextText = `\`\`\`\n# ${filePath}:${start}-${end}\n${text}\n\`\`\`\n\n`
+
+ const extmarkStart = input.visualCursor.offset
+ const extmarkEnd = extmarkStart + previewText.length
+
+ input.insertText(previewText + " ")
+
+ ideSelectionExtmarkId = input.extmarks.create({
+ start: extmarkStart,
+ end: extmarkEnd,
+ virtual: true,
+ styleId: pasteStyleId,
+ typeId: promptPartTypeId,
+ })
+
+ setStore(
+ produce((draft) => {
+ const partIndex = draft.prompt.parts.length
+ draft.prompt.parts.push({
+ type: "text" as const,
+ text: contextText,
+ source: {
+ text: {
+ start: extmarkStart,
+ end: extmarkEnd,
+ value: previewText,
+ },
+ },
+ })
+ draft.extmarkToPartIndex.set(ideSelectionExtmarkId!, partIndex)
+ }),
+ )
+ }
+
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
input.extmarks.clear()
setStore("extmarkToPartIndex", new Map())
@@ -546,6 +640,7 @@ export function Prompt(props: PromptProps) {
parts: [],
})
setStore("extmarkToPartIndex", new Map())
+ ideSelectionExtmarkId = null
props.onSubmit?.()
// temporary hack to make sure the message is sent
diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx
index 6cc97e04167..82bcefced13 100644
--- a/packages/opencode/src/cli/cmd/tui/context/local.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx
@@ -329,10 +329,35 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
}
+ const ide = {
+ isConnected(name: string) {
+ const status = sync.data.ide[name]
+ return status?.status === "connected"
+ },
+ getWorkspaceFolders(name: string) {
+ const status = sync.data.ide[name]
+ if (status && "workspaceFolders" in status && status.workspaceFolders) {
+ return status.workspaceFolders
+ }
+ return []
+ },
+ async toggle(name: string) {
+ const current = sync.data.ide[name]
+ if (current?.status === "connected") {
+ await sdk.client.ide.disconnect({ name })
+ } else {
+ await sdk.client.ide.connect({ name })
+ }
+ const status = await sdk.client.ide.status()
+ if (status.data) sync.set("ide", status.data)
+ },
+ }
+
const result = {
model,
agent,
mcp,
+ ide,
}
return result
},
diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
index f74f787db8c..7197ee44872 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
@@ -10,6 +10,7 @@ import type {
Permission,
LspStatus,
McpStatus,
+ IdeStatus,
FormatterStatus,
SessionStatus,
ProviderListResponse,
@@ -61,6 +62,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
mcp: {
[key: string]: McpStatus
}
+ ide: { [key: string]: IdeStatus }
formatter: FormatterStatus[]
vcs: VcsInfo | undefined
path: Path
@@ -86,6 +88,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
part: {},
lsp: [],
mcp: {},
+ ide: {},
formatter: [],
vcs: undefined,
path: { state: "", config: "", worktree: "", directory: "" },
@@ -285,6 +288,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
sdk.client.command.list().then((x) => setStore("command", x.data ?? [])),
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)),
sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)),
+ sdk.client.ide.status().then((x) => setStore("ide", x.data!)),
sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)),
sdk.client.session.status().then((x) => setStore("session_status", x.data!)),
sdk.client.provider.auth().then((x) => setStore("provider_auth", x.data ?? {})),
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx
index 69082c870ba..04bb664aa87 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx
@@ -13,6 +13,7 @@ export function Footer() {
const mcp = createMemo(() => Object.values(sync.data.mcp).filter((x) => x.status === "connected").length)
const mcpError = createMemo(() => Object.values(sync.data.mcp).some((x) => x.status === "failed"))
const lsp = createMemo(() => Object.keys(sync.data.lsp))
+ const ide = createMemo(() => Object.values(sync.data.ide).find((x) => x.status === "connected"))
const permissions = createMemo(() => {
if (route.data.type !== "session") return []
return sync.data.permission[route.data.sessionID] ?? []
@@ -79,6 +80,12 @@ export function Footer() {
{mcp()} MCP
+
+
+ ◆
+ {ide()!.name}
+
+
/status
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 42f6b11e9f5..4a5e23cf699 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -748,6 +748,13 @@ export namespace Config {
url: z.string().optional().describe("Enterprise URL"),
})
.optional(),
+ ide: z
+ .object({
+ lockfile_dir: z.string().optional().describe("Directory containing IDE lock files for WebSocket connections"),
+ auth_header_name: z.string().optional().describe("HTTP header name for IDE WebSocket authentication"),
+ })
+ .optional()
+ .describe("IDE integration settings"),
experimental: z
.object({
hook: z
diff --git a/packages/opencode/src/ide/connection.ts b/packages/opencode/src/ide/connection.ts
new file mode 100644
index 00000000000..c2385d468e8
--- /dev/null
+++ b/packages/opencode/src/ide/connection.ts
@@ -0,0 +1,162 @@
+import z from "zod/v4"
+import path from "path"
+import { Glob } from "bun"
+import { Log } from "../util/log"
+import { WebSocketClientTransport, McpError } from "../mcp/ws"
+import { Config } from "../config/config"
+
+const log = Log.create({ service: "ide" })
+
+const WS_PREFIX = "ws://127.0.0.1"
+
+const LockFile = {
+ schema: z.object({
+ port: z.number(),
+ url: z.instanceof(URL),
+ pid: z.number(),
+ workspaceFolders: z.array(z.string()),
+ ideName: z.string(),
+ transport: z.string(),
+ authToken: z.string(),
+ }),
+ async fromFile(file: string) {
+ const port = parseInt(path.basename(file, ".lock"))
+ const url = new URL(`${WS_PREFIX}:${port}`)
+ const content = await Bun.file(file).text()
+ const parsed = this.schema.safeParse({ port, url, ...JSON.parse(content) })
+ if (!parsed.success) {
+ log.warn("invalid lock file", { file, error: parsed.error })
+ return undefined
+ }
+ return parsed.data
+ },
+}
+type LockFile = z.infer
+
+export async function discoverLockFiles(): Promise