Skip to content

Conversation

@tofunori
Copy link

Summary

Complete IDE integration for OpenCode, enabling live text selection from Cursor/VSCode/Windsurf with improved UX.

Features

  • IDE Connection: WebSocket-based connection to IDE extensions via lock files
  • Live Selection: Real-time text selection from editor
  • Footer Display: Shows IDE status and selection in footer (not cluttering input)
  • Synthetic Context: Selection sent invisibly to model (doesn't pollute chat history)
  • Home Screen: IDE/selection visible from launch, not just in sessions

Changes

Core IDE Integration

  • src/ide/index.ts - IDE connection management, status, events
  • src/ide/connection.ts - WebSocket client with JSON-RPC 2.0
  • src/mcp/ws.ts - WebSocket transport for MCP

UX Improvements

  • local.tsx - Selection state with formatted() helper showing line count
  • home.tsx - IDE/selection display in home footer
  • footer.tsx - Selection display in session footer
  • prompt/index.tsx - Synthetic parts for invisible context

Configuration

Add to opencode.json:

{
  "ide": {
    "lockfile_dir": "~/.claude/ide/",
    "auth_header_name": "x-claude-code-ide-authorization"
  }
}

Testing

  1. Install the OpenCode extension in Cursor/VSCode
  2. Launch OpenCode in terminal
  3. Connect to IDE via /status
  4. Select text in editor
  5. Verify footer shows: [] 6 lines
  6. Send message - selection content is included invisibly

Related

Based on initial work from #5447, with additional UX improvements:

  • Selection displayed in footer instead of input area
  • Synthetic parts so selection doesn't clutter chat history
  • Home screen shows IDE connection status

- 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.
Copilot AI review requested due to automatic review settings December 21, 2025 00:45
Copy link
Contributor

Copilot AI left a 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)}`)
Copy link

Copilot AI Dec 21, 2025

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.

Suggested change
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")

Copilot uses AI. Check for mistakes.
Copy link
Author

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).

Comment on lines +50 to +55
try {
process.kill(lockFile.pid, 0)
} catch {
log.debug("stale lock file, process not running", { file, pid: lockFile.pid })
continue
}
Copy link

Copilot AI Dec 21, 2025

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.

Copilot uses AI. Check for mistakes.
Copy link
Author

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.

Comment on lines +90 to +94
const transport = new WebSocketClientTransport(lockFile.url, {
headers: {
[config.ide.auth_header_name]: lockFile.authToken,
},
})
Copy link

Copilot AI Dec 21, 2025

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.

Copilot uses AI. Check for mistakes.
Copy link
Author

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.

Comment on lines +345 to +354
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))
},
Copy link

Copilot AI Dec 21, 2025

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.

Copilot uses AI. Check for mistakes.
Copy link
Author

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"))
Copy link

Copilot AI Dec 21, 2025

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.

Suggested change
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))

Copilot uses AI. Check for mistakes.
Copy link
Author

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: [] })
Copy link

Copilot AI Dec 21, 2025

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.

Suggested change
prompt.set({ input: args.prompt, parts: [] })
prompt.set({ input: args.prompt, parts: [] })
prompt.submit()

Copilot uses AI. Check for mistakes.
Copy link
Author

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.

Comment on lines +40 to +46
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 })) {
Copy link

Copilot AI Dec 21, 2025

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.

Copilot uses AI. Check for mistakes.
Copy link
Author

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) })
Copy link

Copilot AI Dec 21, 2025

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.

Suggested change
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) })

Copilot uses AI. Check for mistakes.
Copy link
Author

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.

Comment on lines +34 to +35
// @ts-expect-error accessing private field
this._socket = new WebSocket(this._url, { headers: this.headers } as any)
Copy link

Copilot AI Dec 21, 2025

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.

Suggested change
// @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 })

Copilot uses AI. Check for mistakes.
Copy link
Author

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.

Comment on lines 352 to 385
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)
}

Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused function removeExtmark.

Suggested change
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 uses AI. Check for mistakes.
Copy link
Author

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.

Numerilab and others added 2 commits December 20, 2025 21:40
- 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]>
shuv1337 added a commit to Latitudes-Dev/shuvcode that referenced this pull request Dec 21, 2025
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
@tofunori
Copy link
Author

@thdxr Would love your thoughts on IDE integration for OpenCode.

What this enables:

  • Live text selection from Cursor/VSCode/Windsurf via WebSocket
  • Selection displayed in footer (not cluttering the input)
  • Synthetic context - selection sent to model without polluting chat history

Status:

This brings OpenCode closer to Claude Code's IDE integration experience. Happy to make any changes needed for merge.

shuv1337 added a commit to Latitudes-Dev/shuvcode that referenced this pull request Dec 22, 2025
@shuv1337
Copy link
Contributor

@thdxr Would love your thoughts on IDE integration for OpenCode.

What this enables:

  • Live text selection from Cursor/VSCode/Windsurf via WebSocket
  • Selection displayed in footer (not cluttering the input)
  • Synthetic context - selection sent to model without polluting chat history

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants