From 9b16686fe801258084e5cfa1542db3f80a88d987 Mon Sep 17 00:00:00 2001 From: shuv Date: Thu, 8 Jan 2026 21:33:35 -0800 Subject: [PATCH 1/5] refactor: remove IDE integration from TUI --- packages/opencode/src/cli/cmd/tui/app.tsx | 9 - .../src/cli/cmd/tui/component/dialog-ide.tsx | 76 ----- .../src/cli/cmd/tui/context/local.tsx | 55 +-- .../opencode/src/cli/cmd/tui/context/sync.tsx | 5 - .../opencode/src/cli/cmd/tui/routes/home.tsx | 15 - .../src/cli/cmd/tui/routes/session/footer.tsx | 15 - packages/opencode/src/ide/connection.ts | 169 --------- packages/opencode/src/ide/index.ts | 323 ------------------ 8 files changed, 2 insertions(+), 665 deletions(-) delete mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-ide.tsx delete mode 100644 packages/opencode/src/ide/connection.ts delete mode 100644 packages/opencode/src/ide/index.ts diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 12a19af0a1f..9ef40e5addf 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -13,7 +13,6 @@ import { LocalProvider, useLocal } from "@tui/context/local" import { DialogModel, useConnected } from "@tui/component/dialog-model" import { DialogMcp } from "@tui/component/dialog-mcp" import { DialogTools } from "@tui/component/dialog-tools" -import { DialogIde } from "@tui/component/dialog-ide" import { DialogStatus } from "@tui/component/dialog-status" import { DialogThemeList } from "@tui/component/dialog-theme-list" import { DialogSpinnerList, DialogSpinnerInterval } from "@tui/component/dialog-spinner" @@ -378,14 +377,6 @@ 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 deleted file mode 100644 index 8998f6f5a0b..00000000000 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-ide.tsx +++ /dev/null @@ -1,76 +0,0 @@ -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/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index ef9c4b7c93b..ceb052bad75 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -1,4 +1,4 @@ -import { createStore, reconcile } from "solid-js/store" +import { createStore } from "solid-js/store" import { batch, createEffect, createMemo } from "solid-js" import { useSync } from "@tui/context/sync" import { useTheme } from "@tui/context/theme" @@ -12,7 +12,6 @@ import { Provider } from "@/provider/provider" import { useArgs } from "./args" import { useSDK } from "./sdk" import { RGBA } from "@opentui/core" -import { Ide } from "@/ide" export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ name: "Local", @@ -39,7 +38,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const [agentStore, setAgentStore] = createStore<{ current: string }>({ - current: agents().find((x) => x.default)?.name ?? agents()[0].name, + current: agents()[0]?.name ?? "default", }) const { theme } = useTheme() const colors = createMemo(() => [ @@ -366,54 +365,6 @@ 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 Ide.disconnect() - } else { - await Ide.connect(name) - } - const status = await Ide.status() - sync.set("ide", reconcile(status)) - }, - } - - const selection = iife(() => { - const [selStore, setSelStore] = createStore<{ - current: Ide.Selection | null - }>({ current: null }) - - sdk.event.on(Ide.Event.SelectionChanged.type, async (evt) => { - setSelStore("current", evt.properties.selection) - // Refresh IDE status when we receive a selection - const status = await Ide.status() - sync.set("ide", reconcile(status)) - }) - - return { - current: () => selStore.current, - clear: () => setSelStore("current", null), - formatted: () => { - const sel = selStore.current - if (!sel || !sel.text) return null - const lines = sel.text.split("\n").length - return `${lines} lines` - }, - } - }) - // Automatically update model when agent changes createEffect(() => { const value = agent.current() @@ -436,8 +387,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ model, agent, mcp, - ide, - selection, } 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 3898ad8e574..674f8ebe7df 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -11,7 +11,6 @@ import type { QuestionRequest, LspStatus, McpStatus, - IdeStatus, FormatterStatus, SessionStatus, ProviderListResponse, @@ -36,7 +35,6 @@ import { useArgs } from "./args" import { batch, onMount } from "solid-js" import { Log } from "@/util/log" import type { Path } from "@opencode-ai/sdk" -import { Ide } from "@/ide" export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", @@ -76,7 +74,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ mcp: { [key: string]: McpStatus } - ide: { [key: string]: IdeStatus } mcp_resource: { [key: string]: McpResource } @@ -106,7 +103,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ part: {}, lsp: [], mcp: {}, - ide: {}, mcp_resource: {}, formatter: [], vcs: undefined, @@ -372,7 +368,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))), sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))), sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))), - Ide.status().then((x) => setStore("ide", reconcile(x))), // TODO: Re-enable after SDK regeneration (Phase 15) - sdk.client.experimental.resource.list() (sdk.client as { experimental?: { resource: { list: () => Promise<{ data?: Record }> } } }).experimental?.resource.list().then((x) => setStore("mcp_resource", reconcile(x?.data ?? {}))), sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))), diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index 504257fad63..dc661d217a0 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -8,7 +8,6 @@ import { useSync } from "../context/sync" import { Toast } from "../ui/toast" import { useArgs } from "../context/args" import { useDirectory } from "../context/directory" -import { useLocal } from "../context/local" import { useRouteData } from "@tui/context/route" import { usePromptRef } from "../context/prompt" import { Installation } from "@/installation" @@ -89,8 +88,6 @@ export function Home() { } }) const directory = useDirectory() - const local = useLocal() - const ide = createMemo(() => Object.values(sync.data.ide).find((x) => x.status === "connected")) return ( <> @@ -128,18 +125,6 @@ export function Home() { {connectedMcpCount()} MCP - - - - {ide()!.name} - - - - - [] - {local.selection.formatted()} - - /status 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 533ea604ff9..d10c49c833f 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx @@ -5,17 +5,14 @@ import { useDirectory } from "../../context/directory" import { useConnected } from "../../component/dialog-model" import { createStore } from "solid-js/store" import { useRoute } from "../../context/route" -import { useLocal } from "../../context/local" export function Footer() { const { theme } = useTheme() const sync = useSync() const route = useRoute() - const local = useLocal() 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] ?? [] @@ -82,18 +79,6 @@ export function Footer() { {mcp()} MCP - - - - {ide()!.name} - - - - - [] - {local.selection.formatted()} - - /status diff --git a/packages/opencode/src/ide/connection.ts b/packages/opencode/src/ide/connection.ts deleted file mode 100644 index bcbb3ccba53..00000000000 --- a/packages/opencode/src/ide/connection.ts +++ /dev/null @@ -1,169 +0,0 @@ -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() - let data: unknown - try { - data = JSON.parse(content) - } catch (error) { - log.warn("invalid lock file JSON", { file, error }) - return undefined - } - const parsed = this.schema.safeParse({ port, url, ...(data as object) }) - if (!parsed.success) { - log.warn("invalid lock file schema", { file, error: parsed.error }) - return undefined - } - return parsed.data - }, -} -type LockFile = z.infer - -export async function discoverLockFiles(): Promise> { - const results = new Map() - const config = await Config.get() - - if (!config.ide?.lockfile_dir) { - log.debug("ide.lockfile_dir not configured, skipping IDE discovery") - return results - } - - const glob = new Glob("*.lock") - for await (const file of glob.scan({ cwd: config.ide.lockfile_dir, absolute: true })) { - const lockFile = await LockFile.fromFile(file) - if (!lockFile) continue - - try { - process.kill(lockFile.pid, 0) - } catch { - log.debug("stale lock file, process not running", { file, pid: lockFile.pid }) - continue - } - - results.set(String(lockFile.port), lockFile) - } - - return results -} - -export class Connection { - key: string - name: string - private transport: WebSocketClientTransport - private requestId = 0 - private pendingRequests = new Map>() - onNotification?: (method: string, params: unknown) => void - onClose?: () => void - - private constructor(key: string, name: string, transport: WebSocketClientTransport) { - this.key = key - this.name = name - this.transport = transport - } - - static async create(key: string): Promise { - const config = await Config.get() - if (!config.ide?.auth_header_name) { - throw new Error("ide.auth_header_name is required in config") - } - - const discovered = await discoverLockFiles() - const lockFile = discovered.get(key) - if (!lockFile) { - throw new Error(`IDE instance not found: ${key}`) - } - - const transport = new WebSocketClientTransport(lockFile.url, { - headers: { - [config.ide.auth_header_name]: lockFile.authToken, - }, - }) - - const connection = new Connection(key, lockFile.ideName, transport) - - transport.onmessage = (message) => { - connection.handleMessage(message as any) - } - - transport.onclose = () => { - log.info("IDE transport closed", { key }) - connection.onClose?.() - } - - transport.onerror = (err) => { - log.error("IDE transport error", { key, error: err }) - } - - await transport.start() - - return connection - } - - private handleMessage(payload: { - id?: string | number - method?: string - params?: unknown - result?: unknown - error?: { code: number; message: string; data?: unknown } - }) { - // Handle responses to our requests - const pending = payload.id !== undefined ? this.pendingRequests.get(payload.id) : undefined - if (pending) { - this.pendingRequests.delete(payload.id!) - if (payload.error) { - const { code, message, data } = payload.error - // TODO put code in message on ws server. - pending.reject(new McpError(code, `${message} (code: ${code})`, data)) - } else { - pending.resolve(payload.result) - } - return - } - - // Handle notifications - if (payload.method) { - this.onNotification?.(payload.method, payload.params) - } - } - - async request(method: string, params?: Record): Promise { - const id = ++this.requestId - const pending = Promise.withResolvers() - this.pendingRequests.set(id, pending as PromiseWithResolvers) - this.transport.send({ - jsonrpc: "2.0" as const, - id, - method: `tools/call`, - params: { - name: method, - arguments: params ?? {}, - }, - }) - return pending.promise - } - - async close() { - await this.transport.close() - } -} diff --git a/packages/opencode/src/ide/index.ts b/packages/opencode/src/ide/index.ts deleted file mode 100644 index 9dd8112a99b..00000000000 --- a/packages/opencode/src/ide/index.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" -import { spawn } from "bun" -import z from "zod" -import path from "path" -import os from "os" -import fs from "fs/promises" -import { NamedError } from "@opencode-ai/util/error" -import { Log } from "../util/log" -import { Instance } from "../project/instance" -import { Connection, discoverLockFiles } from "./connection" -import { Permission } from "../permission" - -const GITHUB_REPO = "Latitudes-Dev/shuvcode" -const EXTENSION_ID = "latitudes-dev.shuvcode" - -const SUPPORTED_IDES = [ - { name: "Windsurf" as const, cmd: "windsurf" }, - { name: "Visual Studio Code - Insiders" as const, cmd: "code-insiders" }, - { name: "Visual Studio Code" as const, cmd: "code" }, - { name: "Cursor" as const, cmd: "cursor" }, - { name: "VSCodium" as const, cmd: "codium" }, -] - -export namespace Ide { - const log = Log.create({ service: "ide" }) - - export const Status = z - .object({ - status: z.enum(["connected", "disconnected", "failed"]), - name: z.string(), - workspaceFolders: z.array(z.string()).optional(), - error: z.string().optional(), - }) - .meta({ ref: "IdeStatus" }) - export type Status = z.infer - - export const Selection = z - .object({ - text: z.string(), - filePath: z.string(), - fileUrl: z.string(), - selection: z.object({ - start: z.object({ line: z.number(), character: z.number() }), - end: z.object({ line: z.number(), character: z.number() }), - isEmpty: z.boolean(), - }), - }) - .meta({ ref: "IdeSelection" }) - export type Selection = z.infer - - export const Event = { - Installed: BusEvent.define( - "ide.installed", - z.object({ - ide: z.string(), - }), - ), - SelectionChanged: BusEvent.define( - "ide.selection.updated", - z.object({ - selection: Selection, - }), - ), - } - - export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", z.object({})) - - export const InstallFailedError = NamedError.create( - "InstallFailedError", - z.object({ - stderr: z.string(), - }), - ) - - export function ide() { - if (process.env["TERM_PROGRAM"] === "vscode") { - const v = process.env["GIT_ASKPASS"] - for (const ide of SUPPORTED_IDES) { - if (v?.includes(ide.name)) return ide.name - } - } - return "unknown" - } - - export function alreadyInstalled() { - return process.env["SHUVCODE_CALLER"] === "vscode" || process.env["SHUVCODE_CALLER"] === "vscode-insiders" - } - - export async function install(ide: (typeof SUPPORTED_IDES)[number]["name"]) { - const cmd = SUPPORTED_IDES.find((i) => i.name === ide)?.cmd - if (!cmd) throw new Error(`Unknown IDE: ${ide}`) - - // First check if the extension is already installed - const checkInstalled = spawn([cmd, "--list-extensions"], { - stdout: "pipe", - stderr: "pipe", - }) - await checkInstalled.exited - const installedExtensions = await new Response(checkInstalled.stdout).text() - if (installedExtensions.toLowerCase().includes(EXTENSION_ID.toLowerCase())) { - throw new AlreadyInstalledError({}) - } - - // Download and install VSIX from GitHub Releases - log.info("fetching latest release from GitHub", { repo: GITHUB_REPO }) - - // Get the latest vscode-v* release - const releasesUrl = `https://api.github.com/repos/${GITHUB_REPO}/releases` - const headers: Record = { - Accept: "application/vnd.github.v3+json", - "User-Agent": "shuvcode-cli", - } - if (process.env["GH_TOKEN"]) { - headers["Authorization"] = `token ${process.env["GH_TOKEN"]}` - } - - const releasesResponse = await fetch(releasesUrl, { headers }) - if (!releasesResponse.ok) { - throw new InstallFailedError({ - stderr: `Failed to fetch releases from GitHub: ${releasesResponse.status} ${releasesResponse.statusText}`, - }) - } - - const releases = (await releasesResponse.json()) as Array<{ - tag_name: string - assets: Array<{ name: string; browser_download_url: string }> - }> - - // Find the latest vscode-v* release - const vscodeRelease = releases.find((r) => r.tag_name.startsWith("vscode-v")) - if (!vscodeRelease) { - throw new InstallFailedError({ - stderr: "No vscode-v* release found in GitHub Releases", - }) - } - - // Find the VSIX asset - const vsixAsset = vscodeRelease.assets.find((a) => a.name.endsWith(".vsix")) - if (!vsixAsset) { - throw new InstallFailedError({ - stderr: `No .vsix asset found in release ${vscodeRelease.tag_name}`, - }) - } - - log.info("downloading VSIX", { url: vsixAsset.browser_download_url, tag: vscodeRelease.tag_name }) - - // Download the VSIX to a temp directory - const tmpDir = path.join(os.tmpdir(), "shuvcode-install") - await fs.mkdir(tmpDir, { recursive: true }) - const vsixPath = path.join(tmpDir, vsixAsset.name) - - const vsixResponse = await fetch(vsixAsset.browser_download_url, { headers }) - if (!vsixResponse.ok) { - throw new InstallFailedError({ - stderr: `Failed to download VSIX: ${vsixResponse.status} ${vsixResponse.statusText}`, - }) - } - - const vsixBuffer = await vsixResponse.arrayBuffer() - await fs.writeFile(vsixPath, Buffer.from(vsixBuffer)) - - log.info("installing VSIX", { path: vsixPath }) - - // Install the VSIX - const p = spawn([cmd, "--install-extension", vsixPath], { - stdout: "pipe", - stderr: "pipe", - }) - await p.exited - const stdout = await new Response(p.stdout).text() - const stderr = await new Response(p.stderr).text() - - // Clean up - try { - await fs.unlink(vsixPath) - } catch { - // Ignore cleanup errors - } - - log.info("installed", { - ide, - stdout, - stderr, - }) - - if (p.exitCode !== 0) { - throw new InstallFailedError({ stderr }) - } - } - - // Connection - let activeConnection: Connection | null = null - - function diffTabName(filePath: string) { - // TODO this is used for a string match in claudecode.nvim that we could - // change if we incorporate a dedicated plugin - // (must start with ✻ and end with ⧉)) - return `✻ [shuvcode] Edit: ${path.basename(filePath)} ⧉` - } - - function resolveDirectory(directory?: string) { - if (directory) return directory - try { - return Instance.directory - } catch { - return process.cwd() - } - } - - export async function status(directory?: string): Promise> { - const target = resolveDirectory(directory) - return Instance.provide({ - directory: target, - fn: async () => { - const discovered = await discoverLockFiles() - const result: Record = {} - - for (const [key, lockFile] of discovered) { - result[key] = { - status: activeConnection?.key === key ? "connected" : "disconnected", - name: lockFile.ideName, - workspaceFolders: lockFile.workspaceFolders, - } - } - - return result - }, - }) - } - - export async function connect(key: string, directory?: string): Promise { - const target = resolveDirectory(directory) - await Instance.provide({ - directory: target, - fn: async () => { - if (activeConnection) { - await disconnect() - } - - const instanceDirectory = Instance.directory - const connection = await Connection.create(key) - - connection.onNotification = (method, params) => { - handleNotification(method, params, instanceDirectory) - } - - connection.onClose = () => { - log.info("IDE connection closed callback", { key }) - if (activeConnection?.key === key) { - activeConnection = null - } - } - - activeConnection = connection - }, - }) - } - - function handleNotification(method: string, params: unknown, instanceDirectory: string) { - if (method === "selection_changed") { - const parsed = Selection.safeParse(params) - if (!parsed.success) { - log.warn("failed to parse selection_changed params", { error: parsed.error }) - return - } - Instance.provide({ - directory: instanceDirectory, - fn: () => { - Bus.publish(Event.SelectionChanged, { selection: parsed.data }) - }, - }) - } - } - - export async function disconnect(): Promise { - if (activeConnection) { - log.info("IDE disconnecting", { key: activeConnection.key }) - await activeConnection.close() - activeConnection = null - } - } - - export function active(): Connection | null { - return activeConnection - } - - const DiffResponse = { - FILE_SAVED: "once", - DIFF_REJECTED: "reject", - } as const satisfies Record - - export async function openDiff(filePath: string, newContents: string): Promise { - const connection = active() - if (!connection) { - throw new Error("No IDE connected") - } - const name = diffTabName(filePath) - log.info("openDiff", { tabName: name }) - const result = await connection.request<{ content: Array<{ type: string; text: string }> }>("openDiff", { - old_file_path: filePath, - new_file_path: filePath, - new_file_contents: newContents, - tab_name: name, - }) - log.info("openDiff result", { text: result.content?.[0]?.text }) - const text = result.content?.[0]?.text as keyof typeof DiffResponse | undefined - if (text && text in DiffResponse) return DiffResponse[text] - throw new Error(`Unexpected openDiff result: ${text}`) - } - - async function closeTab(tabName: string): Promise { - const connection = active() - if (!connection) { - throw new Error("No IDE connected") - } - await connection.request("close_tab", { tab_name: tabName }) - } - - export async function closeDiff(filePath: string): Promise { - await closeTab(diffTabName(filePath)) - } -} From c46618aed05abd1db1b9e42fe452988a6a2b5e7c Mon Sep 17 00:00:00 2001 From: shuv Date: Thu, 8 Jan 2026 21:33:35 -0800 Subject: [PATCH 2/5] test: remove IDE integration tests --- packages/opencode/test/ide/ide.test.ts | 82 -------------------------- 1 file changed, 82 deletions(-) delete mode 100644 packages/opencode/test/ide/ide.test.ts diff --git a/packages/opencode/test/ide/ide.test.ts b/packages/opencode/test/ide/ide.test.ts deleted file mode 100644 index ae3fe1154c2..00000000000 --- a/packages/opencode/test/ide/ide.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, test, afterEach } from "bun:test" -import { Ide } from "../../src/ide" - -describe("ide", () => { - const original = structuredClone(process.env) - - afterEach(() => { - Object.keys(process.env).forEach((key) => { - delete process.env[key] - }) - Object.assign(process.env, original) - }) - - test("should detect Visual Studio Code", () => { - process.env["TERM_PROGRAM"] = "vscode" - process.env["GIT_ASKPASS"] = "/path/to/Visual Studio Code.app/Contents/Resources/app/extensions/git/dist/askpass.sh" - - expect(Ide.ide()).toBe("Visual Studio Code") - }) - - test("should detect Visual Studio Code Insiders", () => { - process.env["TERM_PROGRAM"] = "vscode" - process.env["GIT_ASKPASS"] = - "/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/extensions/git/dist/askpass.sh" - - expect(Ide.ide()).toBe("Visual Studio Code - Insiders") - }) - - test("should detect Cursor", () => { - process.env["TERM_PROGRAM"] = "vscode" - process.env["GIT_ASKPASS"] = "/path/to/Cursor.app/Contents/Resources/app/extensions/git/dist/askpass.sh" - - expect(Ide.ide()).toBe("Cursor") - }) - - test("should detect VSCodium", () => { - process.env["TERM_PROGRAM"] = "vscode" - process.env["GIT_ASKPASS"] = "/path/to/VSCodium.app/Contents/Resources/app/extensions/git/dist/askpass.sh" - - expect(Ide.ide()).toBe("VSCodium") - }) - - test("should detect Windsurf", () => { - process.env["TERM_PROGRAM"] = "vscode" - process.env["GIT_ASKPASS"] = "/path/to/Windsurf.app/Contents/Resources/app/extensions/git/dist/askpass.sh" - - expect(Ide.ide()).toBe("Windsurf") - }) - - test("should return unknown when TERM_PROGRAM is not vscode", () => { - process.env["TERM_PROGRAM"] = "iTerm2" - process.env["GIT_ASKPASS"] = - "/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/extensions/git/dist/askpass.sh" - - expect(Ide.ide()).toBe("unknown") - }) - - test("should return unknown when GIT_ASKPASS does not contain IDE name", () => { - process.env["TERM_PROGRAM"] = "vscode" - process.env["GIT_ASKPASS"] = "/path/to/unknown/askpass.sh" - - expect(Ide.ide()).toBe("unknown") - }) - - test("should recognize vscode-insiders SHUVCODE_CALLER", () => { - process.env["SHUVCODE_CALLER"] = "vscode-insiders" - - expect(Ide.alreadyInstalled()).toBe(true) - }) - - test("should recognize vscode SHUVCODE_CALLER", () => { - process.env["SHUVCODE_CALLER"] = "vscode" - - expect(Ide.alreadyInstalled()).toBe(true) - }) - - test("should return false for unknown SHUVCODE_CALLER", () => { - process.env["SHUVCODE_CALLER"] = "unknown" - - expect(Ide.alreadyInstalled()).toBe(false) - }) -}) From 6c9b2b93b79631a941ff5fc3b0ffe699d0544104 Mon Sep 17 00:00:00 2001 From: shuv Date: Thu, 8 Jan 2026 21:33:35 -0800 Subject: [PATCH 3/5] chore: update generated SDK types --- packages/sdk/js/src/v2/gen/types.gen.ts | 33 ------------------------- 1 file changed, 33 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 1fc8a405e0c..1a0a3437e68 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -917,37 +917,6 @@ export type EventGlobalDisposed = { } } -export type EventIdeInstalled = { - type: "ide.installed" - properties: { - ide: string - } -} - -export type IdeSelection = { - text: string - filePath: string - fileUrl: string - selection: { - start: { - line: number - character: number - } - end: { - line: number - character: number - } - isEmpty: boolean - } -} - -export type EventIdeSelectionUpdated = { - type: "ide.selection.updated" - properties: { - selection: IdeSelection - } -} - export type Event = | EventInstallationUpdated | EventInstallationUpdateAvailable @@ -991,8 +960,6 @@ export type Event = | EventPtyDeleted | EventServerConnected | EventGlobalDisposed - | EventIdeInstalled - | EventIdeSelectionUpdated export type GlobalEvent = { directory: string From c2148895db996d8fdb200d62b4e4e958a20d882f Mon Sep 17 00:00:00 2001 From: shuv Date: Thu, 8 Jan 2026 21:48:17 -0800 Subject: [PATCH 4/5] feat: bundle AnthropicAuthPlugin directly in codebase - Add builtin/anthropic-auth.ts with full OAuth plugin implementation - Load AnthropicAuthPlugin directly from code instead of npm - Remove opencode-anthropic-auth@0.0.5 from BUILTIN list - Keeps plugin bundling while removing external dependency --- .../src/plugin/builtin/anthropic-auth.ts | 239 ++++++++++++++++++ packages/opencode/src/plugin/index.ts | 22 +- 2 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/plugin/builtin/anthropic-auth.ts diff --git a/packages/opencode/src/plugin/builtin/anthropic-auth.ts b/packages/opencode/src/plugin/builtin/anthropic-auth.ts new file mode 100644 index 00000000000..ce60bc39b28 --- /dev/null +++ b/packages/opencode/src/plugin/builtin/anthropic-auth.ts @@ -0,0 +1,239 @@ +import { generatePKCE } from "@openauthjs/openauth/pkce" +import type { Plugin, PluginInput, Hooks } from "@opencode-ai/plugin" + +const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + +async function authorize(mode: "max" | "console") { + const pkce = await generatePKCE() + + const url = new URL( + `https://${mode === "console" ? "console.anthropic.com" : "claude.ai"}/oauth/authorize`, + import.meta.url, + ) + url.searchParams.set("code", "true") + url.searchParams.set("client_id", CLIENT_ID) + url.searchParams.set("response_type", "code") + url.searchParams.set("redirect_uri", "https://console.anthropic.com/oauth/code/callback") + url.searchParams.set("scope", "org:create_api_key user:profile user:inference") + url.searchParams.set("code_challenge", pkce.challenge) + url.searchParams.set("code_challenge_method", "S256") + url.searchParams.set("state", pkce.verifier) + return { + url: url.toString(), + verifier: pkce.verifier, + } +} + +async function exchange(code: string, verifier: string) { + const splits = code.split("#") + const result = await fetch("https://console.anthropic.com/v1/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code: splits[0], + state: splits[1], + grant_type: "authorization_code", + client_id: CLIENT_ID, + redirect_uri: "https://console.anthropic.com/oauth/code/callback", + code_verifier: verifier, + }), + }) + if (!result.ok) + return { + type: "failed" as const, + } + const json = await result.json() + return { + type: "success" as const, + refresh: json.refresh_token, + access: json.access_token, + expires: Date.now() + json.expires_in * 1000, + } +} + +export const AnthropicAuthPlugin: Plugin = async ({ client }: PluginInput): Promise => { + return { + auth: { + provider: "anthropic", + async loader(getAuth, provider) { + const auth = await getAuth() + if (auth.type === "oauth") { + // zero out cost for max plan + for (const model of Object.values(provider.models)) { + model.cost = { + input: 0, + output: 0, + cache: { + read: 0, + write: 0, + }, + } + } + return { + apiKey: "", + async fetch(input: RequestInfo | URL, init: RequestInit) { + const auth = await getAuth() + if (auth.type !== "oauth") return fetch(input, init) + if (!auth.access || auth.expires < Date.now()) { + const response = await fetch("https://console.anthropic.com/v1/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + grant_type: "refresh_token", + refresh_token: auth.refresh, + client_id: CLIENT_ID, + }), + }) + if (!response.ok) { + throw new Error(`Token refresh failed: ${response.status}`) + } + const json = await response.json() + await client.auth.set({ + path: { + id: "anthropic", + }, + body: { + type: "oauth", + refresh: json.refresh_token, + access: json.access_token, + expires: Date.now() + json.expires_in * 1000, + }, + }) + auth.access = json.access_token + } + // Add oauth-2025-04-20 beta to whatever betas are already present + const headers = init.headers as Record | undefined + const incomingBeta = headers?.["anthropic-beta"] || "" + const incomingBetasList = incomingBeta + .split(",") + .map((b) => b.trim()) + .filter(Boolean) + + // Add oauth beta and deduplicate + const mergedBetas = [ + ...new Set([ + "oauth-2025-04-20", + "claude-code-20250219", + "interleaved-thinking-2025-05-14", + "fine-grained-tool-streaming-2025-05-14", + ...incomingBetasList, + ]), + ].join(",") + + const newHeaders: Record = { + ...headers, + authorization: `Bearer ${auth.access}`, + "anthropic-beta": mergedBetas, + } + delete newHeaders["x-api-key"] + + const TOOL_PREFIX = "oc_" + let body = init.body + if (body && typeof body === "string") { + try { + const parsed = JSON.parse(body) + if (parsed.tools && Array.isArray(parsed.tools)) { + parsed.tools = parsed.tools.map((tool: { name?: string }) => ({ + ...tool, + name: tool.name ? `${TOOL_PREFIX}${tool.name}` : tool.name, + })) + body = JSON.stringify(parsed) + } + } catch { + // ignore parse errors + } + } + + const response = await fetch(input, { + ...init, + body, + headers: newHeaders, + }) + + // Transform streaming response to rename tools back + if (response.body) { + const reader = response.body.getReader() + const decoder = new TextDecoder() + const encoder = new TextEncoder() + + const stream = new ReadableStream({ + async pull(controller) { + const { done, value } = await reader.read() + if (done) { + controller.close() + return + } + + let text = decoder.decode(value, { stream: true }) + text = text.replace(/"name"\s*:\s*"oc_([^"]+)"/g, '"name": "$1"') + controller.enqueue(encoder.encode(text)) + }, + }) + + return new Response(stream, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }) + } + + return response + }, + } + } + + return {} + }, + methods: [ + { + label: "Claude Pro/Max", + type: "oauth", + authorize: async () => { + const { url, verifier } = await authorize("max") + return { + url: url, + instructions: "Paste the authorization code here: ", + method: "code", + callback: async (code: string) => { + const credentials = await exchange(code, verifier) + return credentials + }, + } + }, + }, + { + label: "Create an API Key", + type: "oauth", + authorize: async () => { + const { url, verifier } = await authorize("console") + return { + url: url, + instructions: "Paste the authorization code here: ", + method: "code", + callback: async (code: string) => { + const credentials = await exchange(code, verifier) + if (credentials.type === "failed") return credentials + const result = await fetch(`https://api.anthropic.com/api/oauth/claude_cli/create_api_key`, { + method: "POST", + headers: { + "Content-Type": "application/json", + authorization: `Bearer ${credentials.access}`, + }, + }).then((r) => r.json()) + return { type: "success" as const, key: result.raw_key } + }, + } + }, + }, + { + label: "Manually enter API Key", + type: "api", + }, + ], + }, + } +} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 62036e2009e..357901c0252 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -12,11 +12,13 @@ import { Flag } from "../flag/flag" import { Global } from "../global" import * as path from "node:path" import * as crypto from "node:crypto" +import { AnthropicAuthPlugin } from "./builtin/anthropic-auth" export namespace Plugin { const log = Log.create({ service: "plugin" }) - const BUILTIN = ["opencode-copilot-auth@0.0.9", "opencode-anthropic-auth@0.0.5"] + const BUILTIN = ["opencode-copilot-auth@0.0.9"] + const BUILTIN_PLUGINS: PluginInstance[] = [AnthropicAuthPlugin] /** * Bundle a local plugin file with its dependencies. @@ -160,6 +162,24 @@ export namespace Plugin { } } + // Load bundled built-in plugins directly (no npm fetch needed) + if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) { + for (const fn of BUILTIN_PLUGINS) { + try { + log.info("loading bundled plugin", { name: fn.name }) + const init = await fn(input) + hooks.push(init) + } catch (e) { + const err = e as Error + log.error("failed to load bundled plugin", { + name: fn.name, + error: err.message, + }) + throw e + } + } + } + return { hooks, input, From 241b9e82688fa908455f3c3423cc077d18d80b04 Mon Sep 17 00:00:00 2001 From: shuv Date: Thu, 8 Jan 2026 21:55:04 -0800 Subject: [PATCH 5/5] fix: remove /ide command from autocomplete The IDE integration was removed but the /ide slash command remained, which would cause a runtime error when triggered. --- .../src/cli/cmd/tui/component/prompt/autocomplete.tsx | 5 ----- 1 file changed, 5 deletions(-) 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 d5a3bf1eb82..8f679ad022d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -436,11 +436,6 @@ export function Autocomplete(props: { description: "list tools", onSelect: () => command.trigger("tool.list"), }, - { - display: "/ide", - description: "toggle IDEs", - onSelect: () => command.trigger("ide.list"), - }, { display: "/theme", description: "toggle theme",