From 98ea5b6e7e907b6d4eb52b294b9a7770bbd0f18e Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 23 Apr 2026 16:19:19 -0400 Subject: [PATCH] feat(tui): support builtin protocol for handling context from editors (#24034) --- packages/opencode/src/cli/cmd/tui/app.tsx | 5 +- .../cmd/tui/component/prompt/autocomplete.tsx | 104 ++++-- .../cli/cmd/tui/component/prompt/index.tsx | 50 ++- .../src/cli/cmd/tui/context/editor.ts | 318 ++++++++++++++++++ 4 files changed, 448 insertions(+), 29 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/context/editor.ts diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 2b31d078cb..eb5cb44e8d 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -24,6 +24,7 @@ import { DialogProvider as DialogProviderList } from "@tui/component/dialog-prov import { ErrorComponent } from "@tui/component/error-component" import { PluginRouteMissing } from "@tui/component/plugin-route-missing" import { ProjectProvider } from "@tui/context/project" +import { EditorContextProvider } from "@tui/context/editor" import { useEvent } from "@tui/context/event" import { SDKProvider, useSDK } from "@tui/context/sdk" import { StartupLoading } from "@tui/component/startup-loading" @@ -177,7 +178,9 @@ export function tui(input: { - + + + 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 305d076223..4a5e1a4ca6 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -1,9 +1,11 @@ import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core" import { pathToFileURL } from "bun" import fuzzysort from "fuzzysort" +import path from "path" import { firstBy } from "remeda" import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js" import { createStore } from "solid-js/store" +import { useEditorContext } from "@tui/context/editor" import { useSDK } from "@tui/context/sdk" import { useSync } from "@tui/context/sync" import { getScrollAcceleration } from "../../util/scroll" @@ -77,6 +79,7 @@ export function Autocomplete(props: { agentStyleId: number promptPartTypeId: () => number }) { + const editor = useEditorContext() const sdk = useSDK() const sync = useSync() const command = useCommandDialog() @@ -221,6 +224,70 @@ export function Autocomplete(props: { } } + function createFilePart(item: string, lineRange?: { startLine: number; endLine?: number }) { + const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "") + const fullPath = path.isAbsolute(item) ? item : path.join(baseDir, item) + const urlObj = pathToFileURL(fullPath) + const filename = + lineRange && !item.endsWith("/") + ? `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}` + : item + + if (lineRange && !item.endsWith("/")) { + urlObj.searchParams.set("start", String(lineRange.startLine)) + if (lineRange.endLine !== undefined) { + urlObj.searchParams.set("end", String(lineRange.endLine)) + } + } + + return { + filename, + url: urlObj.href, + part: { + type: "file" as const, + mime: "text/plain", + filename, + url: urlObj.href, + source: { + type: "file" as const, + text: { + start: 0, + end: 0, + value: "", + }, + path: item, + }, + }, + } + } + + function normalizeMentionPath(filePath: string) { + const baseDir = sync.path.directory || process.cwd() + const absolute = path.resolve(filePath) + const relative = path.relative(baseDir, absolute) + + if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) { + return relative.split(path.sep).join("/") + } + + return absolute.split(path.sep).join("/") + } + + function insertFileMention(input: { filePath: string; lineStart: number; lineEnd: number }) { + const item = normalizeMentionPath(input.filePath) + const lineRange = { + startLine: input.lineStart, + endLine: input.lineEnd > input.lineStart ? input.lineEnd : undefined, + } + const { filename, part } = createFilePart(item, lineRange) + const index = store.visible === "@" ? store.index : props.input().cursorOffset + + command.keybinds(true) + setStore("visible", false) + setStore("index", index) + insertPart(filename, part) + } + const [files] = createResource( () => search(), async (query) => { @@ -250,18 +317,7 @@ export function Autocomplete(props: { const width = props.anchor().width - 4 options.push( ...sortedFiles.map((item): AutocompleteOption => { - const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "") - const fullPath = `${baseDir}/${item}` - const urlObj = pathToFileURL(fullPath) - let filename = item - if (lineRange && !item.endsWith("/")) { - filename = `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}` - urlObj.searchParams.set("start", String(lineRange.startLine)) - if (lineRange.endLine !== undefined) { - urlObj.searchParams.set("end", String(lineRange.endLine)) - } - } - const url = urlObj.href + const { filename, url, part } = createFilePart(item, lineRange) const isDir = item.endsWith("/") return { @@ -270,21 +326,7 @@ export function Autocomplete(props: { isDirectory: isDir, path: item, onSelect: () => { - insertPart(filename, { - type: "file", - mime: "text/plain", - filename, - url, - source: { - type: "file", - text: { - start: 0, - end: 0, - value: "", - }, - path: item, - }, - }) + insertPart(filename, part) }, } }), @@ -501,6 +543,14 @@ export function Autocomplete(props: { } onMount(() => { + const unsubscribeMention = editor.onMention((mention) => { + insertFileMention(mention) + }) + + onCleanup(() => { + unsubscribeMention() + }) + props.ref({ get visible() { return store.visible 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 2e08e66a4a..5288a819b3 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -12,6 +12,7 @@ import { useRoute } from "@tui/context/route" import { useProject } from "@tui/context/project" import { useSync } from "@tui/context/sync" import { useEvent } from "@tui/context/event" +import { useEditorContext } from "@tui/context/editor" import { MessageID, PartID } from "@/session/schema" import { createStore, produce, unwrap } from "solid-js/store" import { useKeybind } from "@tui/context/keybind" @@ -21,7 +22,7 @@ import { usePromptStash } from "./stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { useCommandDialog } from "../dialog-command" -import { useRenderer, type JSX } from "@opentui/solid" +import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import * as Editor from "@tui/util/editor" import { useExit } from "../../context/exit" import * as Clipboard from "../../util/clipboard" @@ -94,6 +95,7 @@ export function Prompt(props: PromptProps) { const local = useLocal() const args = useArgs() const sdk = useSDK() + const editor = useEditorContext() const route = useRoute() const project = useProject() const sync = useSync() @@ -104,11 +106,34 @@ export function Prompt(props: PromptProps) { const stash = usePromptStash() const command = useCommandDialog() const renderer = useRenderer() + const dimensions = useTerminalDimensions() const { theme, syntax } = useTheme() const kv = useKV() const animationsEnabled = createMemo(() => kv.get("animations_enabled", true)) const list = createMemo(() => props.placeholders?.normal ?? []) const shell = createMemo(() => props.placeholders?.shell ?? []) + const editorPath = createMemo(() => editor.selection()?.filePath) + const editorSelectionLabel = createMemo(() => { + const selection = editor.selection()?.selection + if (!selection) return + if (selection.start.line === selection.end.line && selection.start.character === selection.end.character) return + if (selection.start.line === selection.end.line) return `#${selection.start.line}` + return `#${selection.start.line}-${selection.end.line}` + }) + const editorFileLabel = createMemo(() => { + const value = editorPath() + if (!value) return + const filename = path.basename(value) + const file = /^index\.[^./]+$/.test(filename) + ? [path.basename(path.dirname(value)), filename].filter(Boolean).join("/") + : filename + return `${file.split(path.sep).join("/")}${editorSelectionLabel() ?? ""}` + }) + const editorFileLabelDisplay = createMemo(() => { + const file = editorFileLabel() + if (!file) return + return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3)))) + }) const [auto, setAuto] = createSignal() const currentProviderLabel = createMemo(() => local.model.parsed().provider) const hasRightContent = createMemo(() => Boolean(props.right)) @@ -721,6 +746,27 @@ export function Prompt(props: PromptProps) { // Capture mode before it gets reset const currentMode = store.mode const variant = local.model.variant.current() + const editorSelection = editor.selection() + const editorParts = editorSelection + ? [ + { + id: PartID.ascending(), + type: "text" as const, + text: (() => { + const start = editorSelection.selection.start + const end = editorSelection.selection.end + if (start.line === end.line && start.character === end.character) { + return `Note: The user opened the file "${editorSelection.filePath}".` + } + if (start.line === end.line) { + return `Note: The user selected line ${start.line} from "${editorSelection.filePath}": ${editorSelection.text}` + } + return `Note: The user selected lines ${start.line} to ${end.line} from "${editorSelection.filePath}": ${editorSelection.text}` + })(), + synthetic: true, + }, + ] + : [] if (store.mode === "shell") { void sdk.client.session.shell({ @@ -773,6 +819,7 @@ export function Prompt(props: PromptProps) { model: selectedModel, variant, parts: [ + ...editorParts, { id: PartID.ascending(), type: "text", @@ -1332,6 +1379,7 @@ export function Prompt(props: PromptProps) { + {(file) => {file()}} diff --git a/packages/opencode/src/cli/cmd/tui/context/editor.ts b/packages/opencode/src/cli/cmd/tui/context/editor.ts new file mode 100644 index 0000000000..663b0f0fac --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/editor.ts @@ -0,0 +1,318 @@ +import { readdirSync, readFileSync, statSync } from "node:fs" +import os from "node:os" +import path from "node:path" +import { onCleanup, onMount } from "solid-js" +import { createStore } from "solid-js/store" +import z from "zod" +import { createSimpleContext } from "./helper" + +const MCP_PROTOCOL_VERSION = "2025-11-25" + +const JsonRpcMessageSchema = z.object({ + id: z.union([z.number(), z.string(), z.null()]).optional(), + method: z.string().optional(), + params: z.unknown().optional(), + result: z.unknown().optional(), + error: z + .object({ + code: z.number().optional(), + message: z.string().optional(), + }) + .optional(), +}) + +const PositionSchema = z.object({ + line: z.number(), + character: z.number(), +}) + +const EditorSelectionSchema = z.object({ + text: z.string(), + filePath: z.string(), + selection: z.object({ + start: PositionSchema, + end: PositionSchema, + }), +}) + +const EditorMentionSchema = z.object({ + filePath: z.string(), + lineStart: z.number(), + lineEnd: z.number(), +}) + +const EditorServerInfoSchema = z.object({ + protocolVersion: z.string().optional(), + serverInfo: z + .object({ + name: z.string().optional(), + version: z.string().optional(), + }) + .optional(), +}) + +type JsonRpcMessage = z.infer +export type EditorSelection = z.infer +export type EditorMention = z.infer +type EditorServerInfo = z.infer + +type EditorConnection = { + url: string + authToken?: string + source: string +} + +type EditorLockFile = { + port: number + authToken?: string + transport?: string + workspaceFolders: string[] + mtimeMs: number +} + +export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({ + name: "EditorContext", + init: () => { + const mentionListeners = new Set<(mention: EditorMention) => void>() + const [store, setStore] = createStore<{ + status: "disabled" | "connecting" | "connected" + selection: EditorSelection | undefined + server: EditorServerInfo | undefined + }>({ + status: "disabled", + selection: undefined, + server: undefined, + }) + + onMount(() => { + let socket: WebSocket | undefined + let closed = false + let reconnect: ReturnType | undefined + let attempt = 0 + let requestID = 0 + const pending = new Map() + + const send = (payload: JsonRpcMessage) => { + if (!socket || socket.readyState !== WebSocket.OPEN) return + socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload })) + } + + const request = (method: string, params?: unknown) => { + requestID += 1 + pending.set(requestID, method) + send({ id: requestID, method, params }) + } + + const scheduleReconnect = (delay: number) => { + if (closed) return + if (reconnect) clearTimeout(reconnect) + reconnect = setTimeout(connect, delay) + } + + const connect = () => { + if (closed) return + + const connection = resolveEditorConnection() + if (!connection) { + setStore("status", "disabled") + scheduleReconnect(1000) + return + } + + setStore("status", "connecting") + const current = openEditorSocket(connection) + socket = current + + current.addEventListener("open", () => { + if (socket !== current) { + current.close() + return + } + + attempt = 0 + setStore("status", "connected") + request("initialize", { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "opencode", version: "0.0.0" }, + }) + }) + + current.addEventListener("message", (event) => { + const message = parseMessage(event.data) + if (!message) return + + const selection = message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined + if (selection?.success) { + setStore("selection", selection.data) + return + } + + const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined + if (mention?.success) { + mentionListeners.forEach((listener) => listener(mention.data)) + return + } + + if (typeof message.id !== "number") return + + const method = pending.get(message.id) + if (!method) return + + pending.delete(message.id) + if (message.error) return + + const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined + if (initialize?.success) { + setStore("server", initialize.data) + send({ method: "notifications/initialized" }) + return + } + }) + + current.addEventListener("close", () => { + if (socket !== current) return + + socket = undefined + pending.clear() + if (closed) return + + setStore("status", "connecting") + attempt += 1 + const delay = Math.min(1000 * 2 ** (attempt - 1), 30000) + scheduleReconnect(delay) + }) + } + + scheduleReconnect(0) + + onCleanup(() => { + closed = true + if (reconnect) clearTimeout(reconnect) + socket?.close() + }) + }) + + return { + enabled() { + return Boolean(resolveEditorConnection()) + }, + connected() { + return store.status === "connected" + }, + selection() { + return store.selection + }, + onMention(listener: (mention: EditorMention) => void) { + mentionListeners.add(listener) + return () => mentionListeners.delete(listener) + }, + server() { + return store.server + }, + } + }, +}) + +function parsePort(value: string | undefined) { + if (!value) return + + const parsed = Number.parseInt(value, 10) + if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) return + return parsed +} + +function resolveEditorConnection(): EditorConnection | undefined { + const lock = resolveEditorLockFile() + if (lock) { + return { + url: `ws://127.0.0.1:${lock.port}`, + authToken: lock.authToken, + source: `lock:${lock.port}`, + } + } + + const port = parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT) + if (!port) return + return { + url: `ws://127.0.0.1:${port}`, + source: `env:${port}`, + } +} + +function resolveEditorLockFile() { + const directory = path.join(os.homedir(), ".claude", "ide") + let entries: string[] + + try { + entries = readdirSync(directory) + } catch { + return + } + + const cwd = process.cwd() + const locks = entries + .filter((entry) => entry.endsWith(".lock")) + .map((entry) => readEditorLockFile(path.join(directory, entry))) + .filter((entry): entry is EditorLockFile => Boolean(entry)) + .sort((left, right) => scoreEditorLock(right, cwd) - scoreEditorLock(left, cwd)) + + return locks[0] +} + +function readEditorLockFile(filePath: string): EditorLockFile | undefined { + const port = parsePort(path.basename(filePath, ".lock")) + if (!port) return + + try { + const parsed = JSON.parse(readFileSync(filePath, "utf-8")) as unknown + if (!isRecord(parsed)) return + if (parsed.transport !== undefined && parsed.transport !== "ws") return + + return { + port, + authToken: typeof parsed.authToken === "string" ? parsed.authToken : undefined, + transport: typeof parsed.transport === "string" ? parsed.transport : undefined, + workspaceFolders: Array.isArray(parsed.workspaceFolders) + ? parsed.workspaceFolders.filter((value): value is string => typeof value === "string") + : [], + mtimeMs: statSync(filePath).mtimeMs, + } + } catch { + return + } +} + +function scoreEditorLock(lock: EditorLockFile, cwd: string) { + const workspaceMatch = lock.workspaceFolders.some((folder) => pathContains(folder, cwd)) ? 1 : 0 + return workspaceMatch * 1_000_000_000_000 + lock.mtimeMs +} + +function pathContains(parent: string, child: string) { + const relative = path.relative(path.resolve(parent), path.resolve(child)) + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) +} + +function openEditorSocket(connection: EditorConnection) { + if (!connection.authToken) return new WebSocket(connection.url) + + return new WebSocket(connection.url, { + headers: { + "x-claude-code-ide-authorization": connection.authToken, + }, + } as any) +} + +function parseMessage(value: unknown) { + if (typeof value !== "string") return + + try { + return JsonRpcMessageSchema.parse(JSON.parse(value)) + } catch { + return + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +}