Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

- **Native diff viewer for Cursor edits (blocks mode).** A Cursor `edit` tool
call is now surfaced under opencode's registered `edit` tool with its real
unified diff in `metadata.diff`, so opencode renders its built-in diff viewer
instead of a generic block. The required `oldString`/`newString` (which Cursor
does not expose) are reconstructed from the diff; the call is provider-executed
so they are never applied to disk. Any edit without a usable diff (errors,
unexpected shapes, or a host without a registered `edit` tool) falls back to a
safe `cursor_edit` block. Other Cursor tools (shell/read/mcp/…) remain
prefixed `cursor_*` blocks.
- **`toolDisplay` now defaults to `"blocks"`.** Cursor's internal tool activity
renders as structured, provider-executed tool blocks out of the box (was
`"reasoning"`). `"reasoning"` remains available as the fallback for
older/non-V3 opencode hosts via `provider.cursor.options.toolDisplay:
"reasoning"`; `"blocks"` requires a V3-native host (opencode 1.16+).
- `0.1.0-rc.2` — pre-release on the npm `next` dist-tag. Fixes found while
validating rc.1 against opencode 1.16.2:
- **Plugin now loads when installed by package name.** Added the
Expand Down Expand Up @@ -51,11 +65,12 @@ and a permission-gated delegation tool surface.
session via `Agent.resume()` across turns, with automatic fallback to a fresh
agent. A run wedged by a crashed/duplicate process is recovered by retrying
the send once with the SDK's `local.force` escape hatch.
- **`toolDisplay` provider option** (`"reasoning"` default | `"blocks"`):
- **`toolDisplay` provider option** (`"blocks"` default | `"reasoning"`):
- `"reasoning"` renders Cursor's internal tool activity (including the real
MCP tool name) as concise `[tool] …` reasoning lines. Always safe — tool
calls never cross opencode's tool-execution boundary.
- `"blocks"` emits structured, provider-executed **dynamic** `tool-call` /
calls never cross opencode's tool-execution boundary; the fallback for
older/non-V3 hosts.
- `"blocks"` (default) emits structured, provider-executed **dynamic** `tool-call` /
`tool-result` parts so opencode renders native tool blocks. Names are
`cursor_`-prefixed and sanitized (`shell` → `cursor_shell`,
`serena/find_symbol` → `cursor_serena_find_symbol`) so they can't collide
Expand Down
28 changes: 15 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ It uses your Cursor API key to:
- register a `cursor` provider in opencode,
- **list the models available to your account** (live, via `Cursor.models.list()`), and
- run chats through Cursor's local agent runtime (`Agent.create` / `agent.send`), streaming
text and reasoning back into opencode (Cursor's own tool activity is surfaced as reasoning).
text and reasoning back into opencode (Cursor's own tool activity is surfaced as structured tool
blocks by default; see [Tool display](#tool-display)).

This plugin registers Cursor as a **native opencode provider**: its models appear in
`opencode models` and the model picker, and you talk to a Cursor model *directly* — with live model
Expand Down Expand Up @@ -135,7 +136,7 @@ This plugin also registers two **delegation tools** that complement the provider
| `session` | `false` | Reuse one Cursor agent per opencode session (resume across turns; see below) |
| `forwardMcp` | `true` | Forward opencode's configured MCP servers to the Cursor agent |
| `mcpServers` | — | Extra MCP servers (Cursor `McpServerConfig` shape); merged with forwarded ones |
| `toolDisplay` | `"reasoning"` | How Cursor's internal tool activity is shown: `"reasoning"` (compact lines, works everywhere) or `"blocks"` (structured provider-executed tool blocks; opt-in, see [Tool display](#tool-display)) |
| `toolDisplay` | `"blocks"` | How Cursor's internal tool activity is shown: `"blocks"` (structured provider-executed tool blocks; default, requires opencode 1.16+) or `"reasoning"` (compact lines, the fallback for older/non-V3 hosts). See [Tool display](#tool-display) |

### Session reuse (`session`)

Expand Down Expand Up @@ -311,31 +312,32 @@ no sidecar is spawned.
Cursor runs its own agent loop and executes its own tools. The `toolDisplay` option controls how
that activity appears in opencode:

- **`"reasoning"` (default)** — each tool call is shown as a compact reasoning line
(`[tool] write {"path":…}`; failures as `[tool] x failed`). Robust on every host: no tool-call
parts cross into opencode, so there's no dependency on how the host treats provider-executed
tools.
- **`"blocks"` (opt-in)** — tool activity is emitted as structured, **provider-executed**
- **`"blocks"` (default)** — tool activity is emitted as structured, **provider-executed**
`tool-call`/`tool-result` parts so opencode renders proper, collapsible tool blocks with inputs
and outputs. opencode skips execution for provider-executed calls (they're display-only), so
Cursor's tools (`shell`, `mcp`, …) don't trigger an "unavailable tool" error. This requires a
Cursor's tools (`shell`, `mcp`, …) don't trigger an "unavailable tool" error. Requires a
V3-native opencode host (1.16+).
- **`"reasoning"` (fallback)** — each tool call is shown as a compact reasoning line
(`[tool] write {"path":…}`; failures as `[tool] x failed`). Robust on every host: no tool-call
parts cross into opencode, so there's no dependency on how the host treats provider-executed
tools. Use this on older/non-V3 opencode hosts.

Enable blocks mode in your opencode config:
The default needs no configuration. To force the reasoning fallback (e.g. on a pre-1.16 host):

```jsonc
{
"provider": {
"cursor": {
"options": { "toolDisplay": "blocks" }
"options": { "toolDisplay": "reasoning" }
}
}
}
```

> Why opt-in: `"blocks"` depends on V3-native, provider-executed dynamic tool parts and has been
> verified against opencode 1.16+. `"reasoning"` requires nothing from the host and remains the
> always-safe default. If `"blocks"` renders cleanly for you, it's the nicer experience.
> Why blocks by default: structured tool blocks are the nicer experience and have been verified
> against opencode 1.16+. `"blocks"` depends on V3-native, provider-executed dynamic tool parts; if
> your host predates that (or renders them poorly), set `"toolDisplay": "reasoning"` — it requires
> nothing from the host and works everywhere.

## Troubleshooting

Expand Down
170 changes: 95 additions & 75 deletions src/provider/index.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,68 @@
import type { EmbeddingModelV3, ImageModelV3, ProviderV3 } from "@ai-sdk/provider";
import type {
EmbeddingModelV3,
ImageModelV3,
ProviderV3,
} from "@ai-sdk/provider";
import { NoSuchModelError } from "@ai-sdk/provider";
import type { AgentDefinition, AgentModeOption, McpServerConfig, SettingSource } from "@cursor/sdk";
import type {
AgentDefinition,
AgentModeOption,
McpServerConfig,
SettingSource,
} from "@cursor/sdk";
import { resolveCursorApiKey } from "../api-key.js";
import { CursorLanguageModel, type CursorModelConfig } from "./language-model.js";
import {
CursorLanguageModel,
type CursorModelConfig,
} from "./language-model.js";
import type { ToolDisplay } from "./stream-map.js";

export interface CursorProviderOptions {
/**
* Cursor API key. opencode passes this from the provider's resolved auth /
* options. When omitted, falls back to the CURSOR_API_KEY environment
* variable at call time.
*/
apiKey?: string;
/** Provider id, supplied by opencode as `name`. Defaults to "cursor". */
name?: string;
/** Working directory for the local Cursor agent. Defaults to process.cwd(). */
cwd?: string;
/** Default conversation mode: "agent" (default) or "plan". Overridable per-request. */
mode?: AgentModeOption;
/** Default Cursor model params (id -> value), e.g. { thinking: "high" }. */
params?: Record<string, string>;
/**
* MCP servers to make available to the Cursor agent, keyed by name. The
* plugin's `config` hook populates this by translating opencode's configured
* `config.mcp` servers, so the agent can use the same MCP servers (e.g.
* Serena) that opencode does.
*/
mcpServers?: Record<string, McpServerConfig>;
/**
* Cursor settings layers to load from the local filesystem ("project",
* "user", "all", ...). Enables the agent to pick up your Cursor skills,
* rules, and `.cursor/mcp.json` servers.
*/
settingSources?: SettingSource[];
/** Run the agent's tools inside Cursor's sandbox. */
sandbox?: boolean;
/** Cursor subagent definitions (`{ description, prompt, model?, mcpServers? }`). */
agents?: Record<string, AgentDefinition>;
/**
* Reuse one Cursor agent per opencode session (resume across turns instead of
* creating a fresh agent each turn). Off by default.
*/
session?: boolean;
/**
* How Cursor's internal tool activity (shell/read/edit/mcp/…) is surfaced:
* - `"reasoning"` (default): compact reasoning lines (works on every host).
* - `"blocks"`: structured provider-executed `tool-call`/`tool-result` parts
* so opencode renders proper tool blocks. Opt-in; requires a V3-native host.
*/
toolDisplay?: ToolDisplay;
/**
* Cursor API key. opencode passes this from the provider's resolved auth /
* options. When omitted, falls back to the CURSOR_API_KEY environment
* variable at call time.
*/
apiKey?: string;
/** Provider id, supplied by opencode as `name`. Defaults to "cursor". */
name?: string;
/** Working directory for the local Cursor agent. Defaults to process.cwd(). */
cwd?: string;
/** Default conversation mode: "agent" (default) or "plan". Overridable per-request. */
mode?: AgentModeOption;
/** Default Cursor model params (id -> value), e.g. { thinking: "high" }. */
params?: Record<string, string>;
/**
* MCP servers to make available to the Cursor agent, keyed by name. The
* plugin's `config` hook populates this by translating opencode's configured
* `config.mcp` servers, so the agent can use the same MCP servers (e.g.
* Serena) that opencode does.
*/
mcpServers?: Record<string, McpServerConfig>;
/**
* Cursor settings layers to load from the local filesystem ("project",
* "user", "all", ...). Enables the agent to pick up your Cursor skills,
* rules, and `.cursor/mcp.json` servers.
*/
settingSources?: SettingSource[];
/** Run the agent's tools inside Cursor's sandbox. */
sandbox?: boolean;
/** Cursor subagent definitions (`{ description, prompt, model?, mcpServers? }`). */
agents?: Record<string, AgentDefinition>;
/**
* Reuse one Cursor agent per opencode session (resume across turns instead of
* creating a fresh agent each turn). Off by default.
*/
session?: boolean;
/**
* How Cursor's internal tool activity (shell/read/edit/mcp/…) is surfaced:
* - `"blocks"` (default): structured provider-executed `tool-call`/
* `tool-result` parts so opencode renders proper tool blocks. Requires a
* V3-native opencode host (1.16+).
* - `"reasoning"`: compact reasoning lines; the fallback for older/non-V3
* hosts (works everywhere).
*/
toolDisplay?: ToolDisplay;
}

/**
Expand All @@ -60,35 +74,41 @@ export interface CursorProviderOptions {
* and then calls `.languageModel(modelId)`.
*/
export function createCursor(options: CursorProviderOptions = {}): ProviderV3 {
const mcpServers =
options.mcpServers && Object.keys(options.mcpServers).length > 0 ? options.mcpServers : undefined;
const config: CursorModelConfig = {
providerName: options.name ?? "cursor",
apiKey: resolveCursorApiKey(options.apiKey),
cwd: options.cwd ?? process.cwd(),
mode: options.mode ?? "agent",
...(options.params ? { params: options.params } : {}),
...(mcpServers ? { mcpServers } : {}),
...(options.settingSources ? { settingSources: options.settingSources } : {}),
...(options.sandbox !== undefined ? { sandbox: options.sandbox } : {}),
...(options.agents ? { agents: options.agents } : {}),
...(options.session !== undefined ? { session: options.session } : {}),
...(options.toolDisplay ? { toolDisplay: options.toolDisplay } : {}),
};
const mcpServers =
options.mcpServers && Object.keys(options.mcpServers).length > 0
? options.mcpServers
: undefined;
const config: CursorModelConfig = {
providerName: options.name ?? "cursor",
apiKey: resolveCursorApiKey(options.apiKey),
cwd: options.cwd ?? process.cwd(),
mode: options.mode ?? "agent",
...(options.params ? { params: options.params } : {}),
...(mcpServers ? { mcpServers } : {}),
...(options.settingSources
? { settingSources: options.settingSources }
: {}),
...(options.sandbox !== undefined ? { sandbox: options.sandbox } : {}),
...(options.agents ? { agents: options.agents } : {}),
...(options.session !== undefined ? { session: options.session } : {}),
toolDisplay: options.toolDisplay ?? "blocks",
};

const notImplemented = (kind: string, modelId: string): never => {
throw new NoSuchModelError({
modelId,
modelType: kind as "languageModel",
message: `The Cursor provider does not support ${kind} models.`,
});
};
const notImplemented = (kind: string, modelId: string): never => {
throw new NoSuchModelError({
modelId,
modelType: kind as "languageModel",
message: `The Cursor provider does not support ${kind} models.`,
});
};

return {
specificationVersion: "v3",
languageModel: (modelId: string) => new CursorLanguageModel(modelId, config),
embeddingModel: (modelId: string): EmbeddingModelV3 =>
notImplemented("embeddingModel", modelId),
imageModel: (modelId: string): ImageModelV3 => notImplemented("imageModel", modelId),
};
return {
specificationVersion: "v3",
languageModel: (modelId: string) =>
new CursorLanguageModel(modelId, config),
embeddingModel: (modelId: string): EmbeddingModelV3 =>
notImplemented("embeddingModel", modelId),
imageModel: (modelId: string): ImageModelV3 =>
notImplemented("imageModel", modelId),
};
}
Loading