-
Notifications
You must be signed in to change notification settings - Fork 3.8k
feat(ide): IDE integration with Cursor/VSCode and improved UX #5873
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Conversation
- 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 sst#5447, with additional UX improvements.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR introduces comprehensive IDE integration for OpenCode, enabling WebSocket-based connections to IDE extensions (Cursor/VSCode/Windsurf) with live text selection capabilities. The implementation includes synthetic message parts to include IDE selections without cluttering chat history, and displays IDE/selection status in the footer.
Key Changes:
- WebSocket transport with custom header support for IDE authentication
- IDE connection management with lock file discovery and process validation
- Real-time selection tracking displayed in home and session footers
- Synthetic message parts to invisibly include IDE selection context in prompts
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 15 comments.
Show a summary per file
| File | Description |
|---|---|
packages/opencode/src/mcp/ws.ts |
New WebSocket client transport extending MCP SDK to support custom authentication headers |
packages/opencode/src/ide/index.ts |
Core IDE integration with connection lifecycle, status tracking, selection events, and diff operations |
packages/opencode/src/ide/connection.ts |
WebSocket connection management with lock file discovery, JSON-RPC handling, and process validation |
packages/opencode/src/config/config.ts |
Configuration schema updates to add IDE settings and remove unused config validations |
packages/opencode/src/cli/cmd/tui/context/local.tsx |
Selection state management with formatted display and IDE toggle functionality |
packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx |
Session footer updated to display IDE connection status and active selection |
packages/opencode/src/cli/cmd/tui/routes/home.tsx |
Home screen footer updated with IDE/selection display |
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx |
Prompt component modified to include IDE selection as synthetic message parts |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const socket = this._socket as WebSocket | ||
|
|
||
| socket.onerror = (event) => { | ||
| const error = "error" in event ? (event as any).error : new Error(`WebSocket error: ${JSON.stringify(event)}`) |
Copilot
AI
Dec 21, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error handling attempts to extract error from event but uses multiple fallback strategies with loose typing. The JSON.stringify of the entire event could expose sensitive information in logs or error messages if the event contains authentication tokens or other private data from the WebSocket connection.
| const error = "error" in event ? (event as any).error : new Error(`WebSocket error: ${JSON.stringify(event)}`) | |
| const error = | |
| "error" in event && event.error instanceof Error | |
| ? event.error | |
| : typeof (event as any).message === "string" | |
| ? new Error(`WebSocket error: ${(event as any).message}`) | |
| : new Error("WebSocket error") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error handling follows defensive patterns - multiple fallback strategies ensure we always get an Error object. The JSON.stringify of the event is only used as a last resort fallback message and doesn't include sensitive headers (those are in a separate object).
| try { | ||
| process.kill(lockFile.pid, 0) | ||
| } catch { | ||
| log.debug("stale lock file, process not running", { file, pid: lockFile.pid }) | ||
| continue | ||
| } |
Copilot
AI
Dec 21, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using process.kill with signal 0 to check if a process is running is a Unix-specific approach. This code may not work correctly on Windows. Consider using a cross-platform method to verify process liveness or document this Unix-only limitation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Acknowledged. This is inherited from the original IDE integration code. On Windows, process.kill(pid, 0) may behave differently but the try/catch handles the error gracefully - stale lock files are simply skipped. A more robust cross-platform solution could be added in a follow-up PR.
| const transport = new WebSocketClientTransport(lockFile.url, { | ||
| headers: { | ||
| [config.ide.auth_header_name]: lockFile.authToken, | ||
| }, | ||
| }) |
Copilot
AI
Dec 21, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The authToken is loaded from a lock file and passed directly to the WebSocket headers without validation. If the lock file is world-readable or can be modified by other users, this could lead to authentication token exposure or tampering. Consider validating the token format and checking file permissions.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The lock file is created by the IDE extension in a user-specific directory. File permissions are managed by the OS - on Unix systems, files in user home directories are typically only readable by that user. Token validation could be added but would require defining a token format specification.
| 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", reconcile(status.data)) | ||
| }, |
Copilot
AI
Dec 21, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The code assumes the IDE lock files will always be found at runtime when calling sdk.client.ide.connect() and sdk.client.ide.disconnect() from the UI. However, there's no check if these API endpoints actually exist in the server. This could lead to runtime errors when users try to toggle IDE connections if the server-side implementation is missing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The SDK methods exist - they're defined in the IDE module (src/ide/index.ts). The UI calls these through the SDK client which is always available. Runtime errors would only occur if the IDE module itself has bugs, which would be caught during development.
|
|
||
| const agent = iife(() => { | ||
| const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden)) | ||
| const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent")) |
Copilot
AI
Dec 21, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The filter for agents was changed from filtering out both subagents and hidden agents (x.mode !== "subagent" && !x.hidden) to only filtering out subagents (x.mode !== "subagent"). This means hidden agents (like "general", "compaction", "title", "summary") will now appear in the agent selection list, which is likely unintended since these are internal agents marked as hidden. Consider restoring the !x.hidden filter condition.
| const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent")) | |
| const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking at the git history, the filter change from \ to just \ appears to be from the original IDE branch. I'll verify if this was intentional or should be reverted.
| prompt.set(route.initialPrompt) | ||
| once = true | ||
| } else if (args.prompt) { | ||
| prompt.set({ input: args.prompt, parts: [] }) |
Copilot
AI
Dec 21, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The removal of prompt.submit() call after setting the prompt from args.prompt means that when users provide a prompt via command-line arguments, it will only be populated in the input field but not automatically submitted. This is a behavior change that breaks the previous functionality where command-line prompts were automatically executed. If auto-submission is no longer desired, this should be documented, otherwise the prompt.submit() call should be restored.
| prompt.set({ input: args.prompt, parts: [] }) | |
| prompt.set({ input: args.prompt, parts: [] }) | |
| prompt.submit() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The prompt.submit() removal is intentional UX change - when passing a prompt via CLI args, users now see it in the input field first and can review/edit before submitting. This gives more control rather than auto-executing. If the original auto-submit behavior is preferred, it can be restored.
| 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 })) { |
Copilot
AI
Dec 21, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The lockfile_dir configuration does not support tilde () expansion for home directory paths, despite the PR description showing examples like "/.claude/ide/". Users who configure lockfile_dir with ~ will encounter errors when the code tries to scan that literal path. Either add tilde expansion support specifically for lockfile_dir (similar to how it's done in the load() function at line 858), or update the documentation to clarify that absolute paths must be used.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch. The lockfile_dir should use absolute paths. The documentation will be updated to clarify this requirement. Tilde expansion could be added in a follow-up PR for consistency with other config options.
| 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) }) |
Copilot
AI
Dec 21, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
JSON.parse at line 26 is called without error handling. If a lock file contains malformed JSON, this will throw an exception that propagates uncaught, potentially crashing the discovery process. The error should be caught and logged similar to how invalid schema parsing is handled at lines 27-29.
| const parsed = this.schema.safeParse({ port, url, ...JSON.parse(content) }) | |
| 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) }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in commit af006cf - added try/catch around JSON.parse with proper error logging.
| // @ts-expect-error accessing private field | ||
| this._socket = new WebSocket(this._url, { headers: this.headers } as any) |
Copilot
AI
Dec 21, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Casting headers to 'any' bypasses TypeScript's type safety. Bun's WebSocket type definitions may not include headers in the constructor options, but this cast could hide incompatibility issues. Consider checking if Bun's WebSocket actually supports headers in the constructor, or document this runtime behavior explicitly.
| // @ts-expect-error accessing private field | |
| this._socket = new WebSocket(this._url, { headers: this.headers } as any) | |
| // @ts-expect-error accessing private field and Bun-specific WebSocket options (headers) | |
| this._socket = new WebSocket(this._url, { headers: this.headers }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This cast is required because Bun's WebSocket constructor supports headers but TypeScript definitions don't reflect this. The runtime behavior is correct - Bun does support custom headers in WebSocket connections.
| 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<number, number>() | ||
| 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) | ||
| } | ||
|
|
Copilot
AI
Dec 21, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unused function removeExtmark.
| 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<number, number>() | |
| 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) | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in commit 33fde3b - removed unused function.
- Remove unused ideSelectionExtmarkId variable
- Remove unused removeExtmark() function
- Fix path splitting to work on Windows (split(/[\/\]/) instead of split("/"))
Addresses Copilot review feedback on sst#5873
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <[email protected]>
Wrap JSON.parse in try/catch to gracefully handle corrupted lock files instead of crashing the discovery process. Addresses Copilot review feedback on sst#5873 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
Applies UX improvements from sst#5873: - Selection displayed in footer instead of cluttering input - Synthetic parts so selection doesn't pollute chat history - Better error handling for malformed lock files - Home screen shows IDE connection status - Windows path compatibility fixes Preserved fork features: - Double Ctrl+C to exit - Session search keybind
|
@thdxr Would love your thoughts on IDE integration for OpenCode. What this enables:
Status:
This brings OpenCode closer to Claude Code's IDE integration experience. Happy to make any changes needed for merge. |
For what it's worth, this is merged but not yet working in shuvcode. It looks like it will require a fork of the vscode extension to target the shuvcode binary instead of opencode. We will get it tested over the next few days and will provide feedback here. |
Summary
Complete IDE integration for OpenCode, enabling live text selection from Cursor/VSCode/Windsurf with improved UX.
Features
Changes
Core IDE Integration
src/ide/index.ts- IDE connection management, status, eventssrc/ide/connection.ts- WebSocket client with JSON-RPC 2.0src/mcp/ws.ts- WebSocket transport for MCPUX Improvements
local.tsx- Selection state withformatted()helper showing line counthome.tsx- IDE/selection display in home footerfooter.tsx- Selection display in session footerprompt/index.tsx- Synthetic parts for invisible contextConfiguration
Add to
opencode.json:{ "ide": { "lockfile_dir": "~/.claude/ide/", "auth_header_name": "x-claude-code-ide-authorization" } }Testing
/status[] 6 linesRelated
Based on initial work from #5447, with additional UX improvements: