From 328734904ace8fe4221d80bf91d811ab8d1b49fe Mon Sep 17 00:00:00 2001 From: Numerilab Date: Sat, 20 Dec 2025 19:42:55 -0500 Subject: [PATCH 1/3] feat(ide): IDE integration with Cursor/VSCode and improved UX - Add IDE connection via WebSocket with JSON-RPC - Live text selection from editor displayed in footer - Selection sent as synthetic part (invisible but included in context) - IDE status visible in home screen footer - Fix reactivity with reconcile for IDE status updates Based on initial work from #5447, with additional UX improvements. --- .../cli/cmd/tui/component/prompt/index.tsx | 93 +++++++--- .../src/cli/cmd/tui/context/local.tsx | 58 ++++++- .../opencode/src/cli/cmd/tui/routes/home.tsx | 18 +- .../src/cli/cmd/tui/routes/session/footer.tsx | 15 ++ packages/opencode/src/config/config.ts | 51 ++---- packages/opencode/src/ide/connection.ts | 162 ++++++++++++++++++ packages/opencode/src/ide/index.ts | 145 ++++++++++++++++ packages/opencode/src/mcp/ws.ts | 64 +++++++ 8 files changed, 536 insertions(+), 70 deletions(-) create mode 100644 packages/opencode/src/ide/connection.ts create mode 100644 packages/opencode/src/mcp/ws.ts 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 99a90ab46acd..bc14c06128d5 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" @@ -44,7 +45,6 @@ export type PromptRef = { reset(): void blur(): void focus(): void - submit(): void } const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"] @@ -116,7 +116,7 @@ export function Prompt(props: PromptProps) { const sync = useSync() const dialog = useDialog() const toast = useToast() - const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" }) + const status = createMemo(() => sync.data.session_status[props.sessionID ?? ""] ?? { type: "idle" }) const history = usePromptHistory() const command = useCommandDialog() const renderer = useRenderer() @@ -312,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 @@ -342,6 +346,49 @@ 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) { + // Selection is now displayed in footer via local.selection + // No visual insertion in the input needed + // Content will be included at submit time from local.selection + } + function restoreExtmarksFromParts(parts: PromptInfo["parts"]) { input.extmarks.clear() setStore("extmarkToPartIndex", new Map()) @@ -448,14 +495,11 @@ export function Prompt(props: PromptProps) { }) setStore("extmarkToPartIndex", new Map()) }, - submit() { - submit() - }, }) async function submit() { if (props.disabled) return - if (autocomplete?.visible) return + if (autocomplete.visible) return if (!store.prompt.input) return const trimmed = store.prompt.input.trim() if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { @@ -476,6 +520,8 @@ export function Prompt(props: PromptProps) { const messageID = Identifier.ascending("message") let inputText = store.prompt.input + // IDE selection is displayed in footer only - not injected into message + // Expand pasted text inline before submitting const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId) const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start) @@ -495,9 +541,6 @@ export function Prompt(props: PromptProps) { // Filter out text parts (pasted content) since they're now expanded inline const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text") - // Capture mode before it gets reset - const currentMode = store.mode - if (store.mode === "shell") { sdk.client.session.shell({ sessionID, @@ -539,6 +582,12 @@ export function Prompt(props: PromptProps) { type: "text", text: inputText, }, + ...(local.selection.current()?.text ? [{ + id: Identifier.ascending("part"), + type: "text" as const, + text: `\n\n[IDE Selection: ${local.selection.current()!.filePath.split("/").pop() || local.selection.current()!.filePath}:${local.selection.current()!.selection.start.line + 1}-${local.selection.current()!.selection.end.line + 1}]\n\`\`\`\n${local.selection.current()!.text}\n\`\`\``, + synthetic: true, + }] : []), ...nonTextParts.map((x) => ({ id: Identifier.ascending("part"), ...x, @@ -546,16 +595,14 @@ export function Prompt(props: PromptProps) { ], }) } - history.append({ - ...store.prompt, - mode: currentMode, - }) + history.append(store.prompt) input.extmarks.clear() setStore("prompt", { input: "", parts: [], }) setStore("extmarkToPartIndex", new Map()) + ideSelectionExtmarkId = null props.onSubmit?.() // temporary hack to make sure the message is sent @@ -715,8 +762,8 @@ export function Prompt(props: PromptProps) { >