diff --git a/apps/marketing/src/content/docs/commands/code-review.md b/apps/marketing/src/content/docs/commands/code-review.md index 70ca8de7..f581a4f5 100644 --- a/apps/marketing/src/content/docs/commands/code-review.md +++ b/apps/marketing/src/content/docs/commands/code-review.md @@ -84,8 +84,9 @@ Plannotator supports multiple AI providers. Providers are auto-detected based on - **Claude** — Requires the `claude` CLI ([Claude Code](https://docs.anthropic.com/en/docs/claude-code)) - **Codex** — Requires the `codex` CLI ([OpenAI Codex](https://github.com/openai/codex)) - **Pi** — Requires the `pi` CLI ([Pi](https://github.com/mariozechner/pi-coding-agent)) +- **OpenCode** — Requires the `opencode` CLI ([OpenCode](https://opencode.ai)) -All providers can be available simultaneously. Plannotator does not manage API keys — you must be authenticated with each CLI independently (`claude` uses `~/.claude/` credentials, `codex` uses `OPENAI_API_KEY`, `pi` uses its own local configuration). +All providers can be available simultaneously. Plannotator does not manage API keys — you must be authenticated with each CLI independently (`claude` uses `~/.claude/` credentials, `codex` uses `OPENAI_API_KEY`, `pi` and `opencode` use their own local configuration). ### Choosing a provider diff --git a/apps/marketing/src/content/docs/guides/ai-features.md b/apps/marketing/src/content/docs/guides/ai-features.md index e2ce2d71..cbd9ecf4 100644 --- a/apps/marketing/src/content/docs/guides/ai-features.md +++ b/apps/marketing/src/content/docs/guides/ai-features.md @@ -39,6 +39,12 @@ Requires the `pi` CLI installed and configured. Plannotator spawns `pi --mode rp No API keys are managed by Plannotator — Pi uses its own local configuration. +### OpenCode (via OpenCode SDK) + +Requires the `opencode` CLI installed and authenticated. Plannotator spawns `opencode serve` and communicates via HTTP + SSE. Models are discovered dynamically from your connected providers. + +OpenCode supports session forking, resuming, and runtime permission approvals — the richest capability set of all four providers. + ## Configuration Provider and model selection is available in **Settings > AI**. These persist via cookies across sessions. @@ -55,6 +61,8 @@ A session is created lazily on your first question. Until then, no resources are **Pi sessions** inject the review context as a system prompt prefix, similar to Codex. Pi uses its full default toolset (read, bash, edit, write). Pi sessions are always standalone — fork and resume are not available. +**OpenCode sessions** pass the review context via the `system` field on the prompt API. OpenCode supports forking from a parent session and resuming previous sessions. Permission requests work the same as Claude — approval cards appear inline. + **Diff context handling:** Large diffs are truncated at roughly 40k characters to stay within context limits. However, when you select specific lines and ask a question, the selected code is always sent alongside the question regardless of truncation. ## Permission requests @@ -63,19 +71,21 @@ When using Claude, the AI may request permission to use tools like Read, Glob, G Codex sessions run in a sandboxed read-only mode, so permission requests do not apply. +OpenCode supports the same permission approval flow as Claude — tool calls that need approval appear as inline cards. You can approve or deny each request. + Pi does not expose a permission approval gate over RPC, so tool execution is handled entirely by Pi's own runtime. ## Reasoning effort Codex supports a reasoning effort setting with four levels: **Low**, **Medium**, **High**, and **Max**. This is available in the config bar at the bottom of the AI sidebar. Higher effort means slower but more thorough responses. -This setting only applies to Codex — Claude and Pi do not expose a reasoning effort control. +This setting only applies to Codex — Claude, Pi, and OpenCode do not expose a reasoning effort control. ## Available settings | Setting | Description | Provider | |---------|-------------|----------| -| Provider | Claude, Codex, or Pi | All | +| Provider | Claude, Codex, Pi, or OpenCode | All | | Model | Model selection per provider | All | | Reasoning effort | Low / Medium / High / Max | Codex only | | Default tools | Read, Glob, Grep, WebSearch | Claude only | diff --git a/bun.lock b/bun.lock index addfc76f..5c79397d 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.81", "@openai/codex-sdk": "^0.116.0", + "@opencode-ai/sdk": "^1.3.0", "@pierre/diffs": "^1.1.0-beta.19", "diff": "^8.0.3", "dompurify": "^3.3.3", @@ -578,7 +579,7 @@ "@opencode-ai/plugin": ["@opencode-ai/plugin@1.1.25", "", { "dependencies": { "@opencode-ai/sdk": "1.1.25", "zod": "4.1.8" } }, "sha512-oTUWS446H/j7z3pdzo3cOrB5N87XZ/RKdgPD8yHv/rLX92B4YQHjOqggVQ56Q+1VEnN0jxzhoqRylv/0ZEts/Q=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.25", "", {}, "sha512-mWUX489ArEF2ICg3iZsx2VQaGS3Z2j/dwAJDacao9t7dGDzjOIaacPw2weZ10zld7XmT9V9C0PM/A5lDZ52J+w=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.3.0", "", {}, "sha512-5WyYEpcV6Zk9otXOMIrvZRbJm1yxt/c8EXSBn1p6Sw1yagz8HRljkoUTJFxzD0x2+/6vAZItr3OrXDZfE+oA2g=="], "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], @@ -2452,6 +2453,8 @@ "@mistralai/mistralai/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@opencode-ai/plugin/@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.25", "", {}, "sha512-mWUX489ArEF2ICg3iZsx2VQaGS3Z2j/dwAJDacao9t7dGDzjOIaacPw2weZ10zld7XmT9V9C0PM/A5lDZ52J+w=="], + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], diff --git a/package.json b/package.json index 26e36e24..ff8b7f4f 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.81", "@openai/codex-sdk": "^0.116.0", + "@opencode-ai/sdk": "^1.3.0", "@pierre/diffs": "^1.1.0-beta.19", "diff": "^8.0.3", "dompurify": "^3.3.3", diff --git a/packages/ai/ai.test.ts b/packages/ai/ai.test.ts index ff7fbfe2..98eea78d 100644 --- a/packages/ai/ai.test.ts +++ b/packages/ai/ai.test.ts @@ -326,11 +326,11 @@ describe("ProviderRegistry", () => { test("mixed provider types", () => { const reg = new ProviderRegistry(); reg.register(mockProvider("claude-agent-sdk"), "claude-1"); - reg.register(mockProvider("opencode"), "oc-1"); + reg.register(mockProvider("opencode-sdk"), "oc-1"); expect(reg.size).toBe(2); expect(reg.getByType("claude-agent-sdk").length).toBe(1); - expect(reg.getByType("opencode").length).toBe(1); + expect(reg.getByType("opencode-sdk").length).toBe(1); }); test("disposeAll clears everything", () => { @@ -403,7 +403,7 @@ describe("AI endpoints", () => { test("capabilities lists multiple providers", async () => { const { reg, endpoints } = setup(); reg.register(mockProvider("claude-agent-sdk"), "claude-1"); - reg.register(mockProvider("opencode"), "oc-1"); + reg.register(mockProvider("opencode-sdk"), "oc-1"); const res = await endpoints["/api/ai/capabilities"]( new Request("http://localhost/api/ai/capabilities") @@ -466,7 +466,7 @@ describe("AI endpoints", () => { test("session creation with specific provider ID", async () => { const { reg, endpoints } = setup(); reg.register(mockProvider("claude-agent-sdk"), "claude-fast"); - reg.register(mockProvider("opencode"), "oc-default"); + reg.register(mockProvider("opencode-sdk"), "oc-default"); const createRes = await endpoints["/api/ai/session"]( new Request("http://localhost/api/ai/session", { @@ -1054,3 +1054,173 @@ describe("mapPiEvent", () => { }]); }); }); + +// --------------------------------------------------------------------------- +// OpenCode event mapping +// --------------------------------------------------------------------------- + +import { mapOpenCodeEvent } from "./providers/opencode-sdk.ts"; + +describe("mapOpenCodeEvent", () => { + const SESSION_ID = "oc_session_1"; + + test("message.part.delta with text field maps to text_delta", () => { + const result = mapOpenCodeEvent("message.part.delta", { + sessionID: SESSION_ID, + messageID: "msg_1", + partID: "part_1", + field: "text", + delta: "Hello ", + }, SESSION_ID); + expect(result).toEqual([{ type: "text_delta", delta: "Hello " }]); + }); + + test("message.part.delta with non-text field returns empty", () => { + const result = mapOpenCodeEvent("message.part.delta", { + field: "reasoning", + delta: "thinking...", + }, SESSION_ID); + expect(result).toEqual([]); + }); + + test("message.part.updated with running tool maps to tool_use", () => { + const result = mapOpenCodeEvent("message.part.updated", { + part: { + id: "part_1", + type: "tool", + tool: "read", + callID: "call_1", + state: { + status: "running", + input: { path: "/foo.ts" }, + time: { start: 1000 }, + }, + }, + }, SESSION_ID); + expect(result).toEqual([{ + type: "tool_use", + toolName: "read", + toolInput: { path: "/foo.ts" }, + toolUseId: "call_1", + }]); + }); + + test("message.part.updated with completed tool maps to tool_result", () => { + const result = mapOpenCodeEvent("message.part.updated", { + part: { + id: "part_1", + type: "tool", + tool: "read", + callID: "call_1", + state: { + status: "completed", + output: "file contents here", + time: { start: 1000, end: 1100 }, + }, + }, + }, SESSION_ID); + expect(result).toEqual([{ + type: "tool_result", + toolUseId: "call_1", + result: "file contents here", + }]); + }); + + test("message.part.updated with error tool maps to tool_result with [Error] prefix", () => { + const result = mapOpenCodeEvent("message.part.updated", { + part: { + id: "part_1", + type: "tool", + tool: "read", + callID: "call_1", + state: { + status: "error", + error: "file not found", + time: { start: 1000, end: 1100 }, + }, + }, + }, SESSION_ID); + expect(result).toEqual([{ + type: "tool_result", + toolUseId: "call_1", + result: "[Error] file not found", + }]); + }); + + test("permission.updated maps to permission_request", () => { + const result = mapOpenCodeEvent("permission.updated", { + id: "perm_1", + type: "write_file", + title: "Write to /src/foo.ts", + callID: "call_2", + sessionID: SESSION_ID, + metadata: { path: "/src/foo.ts" }, + }, SESSION_ID); + expect(result).toEqual([{ + type: "permission_request", + requestId: "perm_1", + toolName: "write_file", + toolInput: { path: "/src/foo.ts" }, + title: "Write to /src/foo.ts", + toolUseId: "call_2", + }]); + }); + + test("session.status idle maps to result", () => { + const result = mapOpenCodeEvent("session.status", { + sessionID: SESSION_ID, + status: { type: "idle" }, + }, SESSION_ID); + expect(result).toEqual([{ + type: "result", + sessionId: SESSION_ID, + success: true, + }]); + }); + + test("session.status busy returns empty", () => { + const result = mapOpenCodeEvent("session.status", { + sessionID: SESSION_ID, + status: { type: "busy" }, + }, SESSION_ID); + expect(result).toEqual([]); + }); + + test("message.updated with error maps to error", () => { + const result = mapOpenCodeEvent("message.updated", { + info: { + id: "msg_1", + sessionID: SESSION_ID, + role: "assistant", + error: { + name: "ProviderAuthError", + data: { message: "Invalid API key", providerID: "anthropic" }, + }, + }, + }, SESSION_ID); + expect(result).toEqual([{ + type: "error", + error: "Invalid API key", + code: "opencode_message_error", + }]); + }); + + test("session.error maps to error", () => { + const result = mapOpenCodeEvent("session.error", { + sessionID: SESSION_ID, + error: { message: "Something went wrong" }, + }, SESSION_ID); + expect(result).toEqual([{ + type: "error", + error: "Something went wrong", + code: "opencode_session_error", + }]); + }); + + test("ignored events return empty", () => { + expect(mapOpenCodeEvent("session.created", { sessionID: SESSION_ID }, SESSION_ID)).toEqual([]); + expect(mapOpenCodeEvent("session.updated", { sessionID: SESSION_ID }, SESSION_ID)).toEqual([]); + expect(mapOpenCodeEvent("server.connected", {}, SESSION_ID)).toEqual([]); + expect(mapOpenCodeEvent("file.edited", { file: "foo.ts" }, SESSION_ID)).toEqual([]); + }); +}); diff --git a/packages/ai/index.ts b/packages/ai/index.ts index 8b845ea2..ca15082d 100644 --- a/packages/ai/index.ts +++ b/packages/ai/index.ts @@ -74,6 +74,7 @@ export type { ClaudeAgentSDKConfig, CodexSDKConfig, PiSDKConfig, + OpenCodeConfig, } from "./types.ts"; // Provider registry diff --git a/packages/ai/package.json b/packages/ai/package.json index 3303bf48..674a6a62 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -12,6 +12,8 @@ "./endpoints": "./endpoints.ts", "./providers/claude-agent-sdk": "./providers/claude-agent-sdk.ts", "./providers/codex-sdk": "./providers/codex-sdk.ts", - "./providers/pi-sdk": "./providers/pi-sdk.ts" - } + "./providers/pi-sdk": "./providers/pi-sdk.ts", + "./providers/opencode-sdk": "./providers/opencode-sdk.ts" + }, + "dependencies": {} } diff --git a/packages/ai/providers/opencode-sdk.ts b/packages/ai/providers/opencode-sdk.ts new file mode 100644 index 00000000..1d98d8f4 --- /dev/null +++ b/packages/ai/providers/opencode-sdk.ts @@ -0,0 +1,490 @@ +/** + * OpenCode provider — bridges Plannotator's AI layer with OpenCode's agent server. + * + * Uses @opencode-ai/sdk to spawn `opencode serve` and communicate via HTTP + SSE. + * One server per provider, shared across all sessions. The user must have the + * `opencode` CLI installed and authenticated. + */ + +import { BaseSession } from "../base-session.ts"; +import { buildSystemPrompt } from "../context.ts"; +import type { + AIMessage, + AIProvider, + AIProviderCapabilities, + AISession, + CreateSessionOptions, + OpenCodeConfig, +} from "../types.ts"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const PROVIDER_NAME = "opencode-sdk"; + +// --------------------------------------------------------------------------- +// SDK import cache — resolve once, reuse across all sessions +// --------------------------------------------------------------------------- + +// biome-ignore lint/suspicious/noExplicitAny: SDK types not available at compile time +let sdk: any = null; + +async function getSDK() { + if (!sdk) { + sdk = await import("@opencode-ai/sdk"); + } + return sdk; +} + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + +export class OpenCodeProvider implements AIProvider { + readonly name = PROVIDER_NAME; + readonly capabilities: AIProviderCapabilities = { + fork: true, + resume: true, + streaming: true, + tools: true, + }; + models?: Array<{ id: string; label: string; default?: boolean }>; + + private config: OpenCodeConfig; + // biome-ignore lint/suspicious/noExplicitAny: SDK types not available at compile time + private server: { url: string; close: () => void } | null = null; + // biome-ignore lint/suspicious/noExplicitAny: SDK types not available at compile time + private client: any = null; + private startPromise: Promise | null = null; + + constructor(config: OpenCodeConfig) { + this.config = config; + } + + /** Lazy-spawn the OpenCode server and create the HTTP client. */ + async ensureServer(): Promise { + if (this.server && this.client) return; + this.startPromise ??= this.doStart().catch((err) => { + this.startPromise = null; + throw err; + }); + return this.startPromise; + } + + private async doStart(): Promise { + const { createOpencodeServer, createOpencodeClient } = await getSDK(); + + this.server = await createOpencodeServer({ + hostname: this.config.hostname ?? "127.0.0.1", + ...(this.config.port != null && { port: this.config.port }), + timeout: 15_000, + }); + + this.client = createOpencodeClient({ + baseUrl: this.server.url, + directory: this.config.cwd ?? process.cwd(), + }); + } + + async createSession(options: CreateSessionOptions): Promise { + await this.ensureServer(); + + const result = await this.client.session.create({ + query: { directory: options.cwd ?? this.config.cwd ?? process.cwd() }, + }); + const sessionData = result.data; + + const session = new OpenCodeSession({ + sessionId: sessionData.id, + systemPrompt: buildSystemPrompt(options.context), + client: this.client, + model: options.model, + parentSessionId: null, + }); + return session; + } + + async forkSession(options: CreateSessionOptions): Promise { + await this.ensureServer(); + + const parentId = options.context.parent?.sessionId; + if (!parentId) { + throw new Error("Fork requires a parent session ID."); + } + + const result = await this.client.session.fork({ + path: { id: parentId }, + }); + const sessionData = result.data; + + return new OpenCodeSession({ + sessionId: sessionData.id, + systemPrompt: buildSystemPrompt(options.context), + client: this.client, + model: options.model, + parentSessionId: parentId, + }); + } + + async resumeSession(sessionId: string): Promise { + await this.ensureServer(); + + // Verify session exists + await this.client.session.get({ path: { id: sessionId } }); + + return new OpenCodeSession({ + sessionId, + systemPrompt: null, + client: this.client, + model: undefined, + parentSessionId: null, + }); + } + + dispose(): void { + if (this.server) { + this.server.close(); + this.server = null; + this.client = null; + this.startPromise = null; + } + } + + /** Fetch available models from OpenCode. Call before registering the provider. */ + async fetchModels(): Promise { + try { + await this.ensureServer(); + + const result = await this.client.provider.list({ + query: { directory: this.config.cwd ?? process.cwd() }, + }); + const data = result.data; + const connected = new Set(data.connected as string[]); + const allProviders = data.all as Array<{ + id: string; + models: Record; + }>; + + const models: Array<{ id: string; label: string; default?: boolean }> = []; + for (const provider of allProviders) { + if (!connected.has(provider.id)) continue; + for (const model of Object.values(provider.models)) { + models.push({ + id: `${model.providerID}/${model.id}`, + label: model.name ?? model.id, + }); + } + } + + if (models.length > 0) { + // Mark first model as default + models[0].default = true; + this.models = models; + } + } catch { + // OpenCode not configured or no models available + } + } +} + +// --------------------------------------------------------------------------- +// Session +// --------------------------------------------------------------------------- + +interface SessionConfig { + sessionId: string; + systemPrompt: string | null; + // biome-ignore lint/suspicious/noExplicitAny: SDK types not available at compile time + client: any; + /** Model in "providerID/modelID" format. */ + model?: string; + parentSessionId: string | null; +} + +class OpenCodeSession extends BaseSession { + private config: SessionConfig; + + constructor(config: SessionConfig) { + super({ + parentSessionId: config.parentSessionId, + initialId: config.sessionId, + }); + this.config = config; + this._resolvedId = config.sessionId; + } + + async *query(prompt: string): AsyncIterable { + const started = this.startQuery(); + if (!started) { + yield BaseSession.BUSY_ERROR; + return; + } + const { gen } = started; + + try { + // Build model param if specified + let modelParam: { providerID: string; modelID: string } | undefined; + if (this.config.model) { + const [providerID, ...rest] = this.config.model.split("/"); + const modelID = rest.join("/"); + if (providerID && modelID) { + modelParam = { providerID, modelID }; + } + } + + // Subscribe to SSE events + const { stream } = await this.config.client.event.subscribe(); + + try { + // Send prompt asynchronously + try { + await this.config.client.session.promptAsync({ + path: { id: this.config.sessionId }, + body: { + ...(!this._firstQuerySent && + this.config.systemPrompt && { + system: this.config.systemPrompt, + }), + ...(modelParam && { model: modelParam }), + parts: [{ type: "text", text: prompt }], + }, + }); + } catch (err) { + yield { + type: "error", + error: `OpenCode rejected prompt: ${err instanceof Error ? err.message : String(err)}`, + code: "opencode_prompt_rejected", + }; + return; + } + this._firstQuerySent = true; + + // Drain SSE events filtered by session ID + for await (const event of stream) { + const eventType = event.type as string; + const props = event.properties as Record | undefined; + if (!props) continue; + + // Filter: only events for our session + const eventSessionId = + (props.sessionID as string) ?? + ((props.info as Record)?.sessionID as string) ?? + ((props.part as Record)?.sessionID as string); + if (eventSessionId && eventSessionId !== this.config.sessionId) continue; + + const mapped = mapOpenCodeEvent(eventType, props, this.id); + for (const msg of mapped) { + yield msg; + if (msg.type === "result" || (msg.type === "error" && isTerminalEvent(eventType))) { + return; + } + } + } + } finally { + stream.return?.(); + } + } catch (err) { + yield { + type: "error", + error: err instanceof Error ? err.message : String(err), + code: "provider_error", + }; + } finally { + this.endQuery(gen); + } + } + + abort(): void { + this.config.client.session + .abort({ path: { id: this.config.sessionId } }) + .catch(() => {}); + super.abort(); + } + + respondToPermission( + requestId: string, + allow: boolean, + _message?: string, + ): void { + this.config.client + .postSessionIdPermissionsPermissionId({ + path: { id: this.config.sessionId, permissionID: requestId }, + body: { response: allow ? "once" : "reject" }, + }) + .catch(() => {}); + } +} + +// --------------------------------------------------------------------------- +// Event mapping +// --------------------------------------------------------------------------- + +/** Returns true for events that should terminate the query when mapped to an error. */ +function isTerminalEvent(eventType: string): boolean { + return eventType === "session.error" || eventType === "session.status"; +} + +/** + * Map an OpenCode SSE event to AIMessage[]. + * + * Key events: + * message.part.delta → text_delta (streaming text) + * message.part.updated → tool_use / tool_result (tool lifecycle) + * permission.updated → permission_request + * session.status → result (when idle) + * message.updated → error (when message has error) + */ +export function mapOpenCodeEvent( + eventType: string, + props: Record, + sessionId: string, +): AIMessage[] { + switch (eventType) { + case "message.part.delta": { + const field = props.field as string; + const delta = props.delta as string; + if (field === "text" && delta) { + return [{ type: "text_delta", delta }]; + } + return []; + } + + case "message.part.updated": { + const part = props.part as Record; + if (!part) return []; + + const partType = part.type as string; + + if (partType === "tool") { + const state = part.state as Record; + if (!state) return []; + + const status = state.status as string; + const callID = (part.callID as string) ?? (part.id as string); + const toolName = part.tool as string; + + switch (status) { + case "running": + return [ + { + type: "tool_use", + toolName: toolName ?? "unknown", + toolInput: (state.input as Record) ?? {}, + toolUseId: callID, + }, + ]; + + case "completed": { + const output = (state.output as string) ?? ""; + return [ + { + type: "tool_result", + toolUseId: callID, + result: output, + }, + ]; + } + + case "error": { + const error = (state.error as string) ?? "Tool execution failed"; + return [ + { + type: "tool_result", + toolUseId: callID, + result: `[Error] ${error}`, + }, + ]; + } + + default: + return []; + } + } + + return []; + } + + case "permission.updated": { + const id = props.id as string; + const permType = props.type as string; + const title = props.title as string; + const callID = props.callID as string; + const metadata = (props.metadata as Record) ?? {}; + + return [ + { + type: "permission_request", + requestId: id, + toolName: permType ?? "unknown", + toolInput: metadata, + title: title ?? permType, + toolUseId: callID ?? id, + }, + ]; + } + + case "session.status": { + const status = props.status as Record; + if (status?.type === "idle") { + return [ + { + type: "result", + sessionId, + success: true, + }, + ]; + } + return []; + } + + case "session.error": { + const error = props.error as Record; + const message = + (error?.message as string) ?? (props.message as string) ?? "Session error"; + return [ + { + type: "error", + error: message, + code: "opencode_session_error", + }, + ]; + } + + case "message.updated": { + const info = props.info as Record; + if (!info) return []; + + const msgError = info.error as Record; + if (msgError) { + const errorData = msgError.data as Record; + const message = + (errorData?.message as string) ?? + (msgError.name as string) ?? + "Message error"; + return [ + { + type: "error", + error: message, + code: "opencode_message_error", + }, + ]; + } + return []; + } + + default: + return []; + } +} + +// --------------------------------------------------------------------------- +// Factory registration +// --------------------------------------------------------------------------- + +import { registerProviderFactory } from "../provider.ts"; + +registerProviderFactory( + PROVIDER_NAME, + async (config) => new OpenCodeProvider(config as OpenCodeConfig), +); diff --git a/packages/ai/types.ts b/packages/ai/types.ts index 304d13af..13c0fbd9 100644 --- a/packages/ai/types.ts +++ b/packages/ai/types.ts @@ -244,7 +244,7 @@ export interface CreateSessionOptions { * 3. Streaming responses back as AIMessage events * * Providers are registered by name and selected at runtime based on the - * host environment (Claude Code → "claude-agent-sdk", OpenCode → future). + * host environment (Claude Code → "claude-agent-sdk", OpenCode → "opencode-sdk"). */ export interface AIProvider { /** Unique name for this provider (e.g. "claude-agent-sdk"). */ @@ -359,3 +359,11 @@ export interface PiSDKConfig extends AIProviderConfig { */ piExecutablePath?: string; } + +export interface OpenCodeConfig extends AIProviderConfig { + type: "opencode-sdk"; + /** Hostname for the OpenCode server. Default: "127.0.0.1". */ + hostname?: string; + /** Port for the OpenCode server. Default: random. */ + port?: number; +} diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx index dfdb56ca..49fe5e01 100644 --- a/packages/review-editor/App.tsx +++ b/packages/review-editor/App.tsx @@ -14,6 +14,8 @@ import { getPlatformLabel, getMRLabel, getMRNumberLabel, getDisplayRepo } from ' import { getIdentity } from '@plannotator/ui/utils/identity'; import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/utils/agentSwitch'; import { getAIProviderSettings, saveAIProviderSettings, getPreferredModel } from '@plannotator/ui/utils/aiProvider'; +import { AISetupDialog } from '@plannotator/ui/components/AISetupDialog'; +import { needsAISetup } from '@plannotator/ui/utils/aiSetup'; import { CodeAnnotation, CodeAnnotationType, SelectedLineRange } from '@plannotator/ui/types'; import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel'; import { useCodeAnnotationDraft } from '@plannotator/ui/hooks/useCodeAnnotationDraft'; @@ -192,6 +194,7 @@ const ReviewApp: React.FC = () => { reasoningEffort: null as string | null, }; }); + const [showAISetup, setShowAISetup] = useState(false); const [reviewPanelTabOverride, setReviewPanelTabOverride] = useState<'ai' | undefined>(undefined); const aiChat = useAIChat({ patch: diffData?.rawPatch ?? '', @@ -207,7 +210,11 @@ const ReviewApp: React.FC = () => { .then(data => { if (data?.available) { setAiAvailable(true); - setAiProviders(data.providers ?? []); + const providers = data.providers ?? []; + setAiProviders(providers); + if (providers.length > 0 && needsAISetup()) { + setShowAISetup(true); + } } }) .catch(() => {}); @@ -1509,6 +1516,16 @@ const ReviewApp: React.FC = () => { showCancel /> + {/* AI setup dialog — first-run only */} + { + setShowAISetup(false); + handleAIConfigChange({ providerId }); + }} + /> + {/* Completion overlay - shown after approve/feedback */} = ({ }) => { const [showSessionNote, setShowSessionNote] = useState(false); const [openMenu, setOpenMenu] = useState<'provider' | 'model' | 'effort' | null>(null); + const [modelSearch, setModelSearch] = useState(''); const barRef = useRef(null); + const searchInputRef = useRef(null); // Flash "New chat session" briefly when config changes while a session exists useEffect(() => { @@ -60,6 +62,7 @@ export const AIConfigBar: React.FC = ({ const handler = (e: MouseEvent) => { if (barRef.current && !barRef.current.contains(e.target as Node)) { setOpenMenu(null); + setModelSearch(''); } }; document.addEventListener('mousedown', handler); @@ -95,6 +98,7 @@ export const AIConfigBar: React.FC = ({ if (hasSession) setShowSessionNote(true); onModelChange(id); setOpenMenu(null); + setModelSearch(''); }; const handleEffortSelect = (id: string) => { @@ -173,24 +177,40 @@ export const AIConfigBar: React.FC = ({ {openMenu === 'model' && (
- {models.map(m => { - const isActive = m.id === effectiveModel; - return ( - - ); - })} + {models.length > 8 && ( +
+ setModelSearch(e.target.value)} + autoFocus + /> +
+ )} +
8 ? 'ai-config-menu-scroll' : ''}> + {models + .filter(m => !modelSearch || m.label.toLowerCase().includes(modelSearch.toLowerCase())) + .map(m => { + const isActive = m.id === effectiveModel; + return ( + + ); + })} +
)} diff --git a/packages/review-editor/index.css b/packages/review-editor/index.css index 9d1fe409..707b6840 100644 --- a/packages/review-editor/index.css +++ b/packages/review-editor/index.css @@ -683,6 +683,9 @@ diffs-container { bottom: calc(100% + 4px); left: 0; min-width: 140px; + max-height: min(320px, 50vh); + display: flex; + flex-direction: column; background: var(--popover); border: 1px solid var(--border); border-radius: var(--radius-sm); @@ -692,6 +695,38 @@ diffs-container { animation: ai-menu-in 120ms ease-out; } +.ai-config-menu-scroll { + overflow-y: auto; + flex: 1; + min-height: 0; +} + +.ai-config-menu-search { + padding: 4px; + margin-bottom: 2px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.ai-config-menu-search input { + width: 100%; + background: var(--muted); + border: 1px solid var(--border); + border-radius: calc(var(--radius-sm) - 2px); + padding: 3px 6px; + font-size: 0.6875rem; + color: var(--foreground); + outline: none; +} + +.ai-config-menu-search input::placeholder { + color: var(--muted-foreground); +} + +.ai-config-menu-search input:focus { + border-color: var(--primary); +} + .ai-config-menu-item { display: flex; align-items: center; diff --git a/packages/server/review.ts b/packages/server/review.ts index 84166dba..d0fbccd6 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -154,6 +154,24 @@ export async function startReviewServer( // Pi not available } + // Try OpenCode + try { + const { OpenCodeProvider } = await import("@plannotator/ai/providers/opencode-sdk"); + const opencodePath = Bun.which("opencode"); + if (opencodePath) { + const provider = await createProvider({ + type: "opencode-sdk", + cwd: process.cwd(), + }); + if (provider instanceof OpenCodeProvider) { + await provider.fetchModels(); + } + aiRegistry.register(provider); + } + } catch { + // OpenCode not available + } + // Create endpoints if any provider registered if (aiRegistry.size > 0) { aiEndpoints = createAIEndpoints({ diff --git a/packages/ui/components/AISetupDialog.tsx b/packages/ui/components/AISetupDialog.tsx new file mode 100644 index 00000000..119a8ff3 --- /dev/null +++ b/packages/ui/components/AISetupDialog.tsx @@ -0,0 +1,153 @@ +import React, { useState } from 'react'; +import { createPortal } from 'react-dom'; +import { getProviderMeta } from './ProviderIcons'; +import { saveAIProviderSettings, savePreferredModel, getAIProviderSettings } from '../utils/aiProvider'; +import { markAISetupDone } from '../utils/aiSetup'; + +interface AIProviderModel { + id: string; + label: string; + default?: boolean; +} + +interface AIProvider { + id: string; + name: string; + capabilities: Record; + models?: AIProviderModel[]; +} + +interface AISetupDialogProps { + isOpen: boolean; + providers: AIProvider[]; + onComplete: (providerId: string) => void; +} + +export const AISetupDialog: React.FC = ({ + isOpen, + providers, + onComplete, +}) => { + const [selectedId, setSelectedId] = useState(null); + const [preferredModels, setPreferredModels] = useState>(() => + getAIProviderSettings().preferredModels + ); + + if (!isOpen || providers.length === 0) return null; + + const effectiveId = selectedId ?? providers[0]?.id ?? null; + + const handleSelectProvider = (id: string) => { + setSelectedId(id); + }; + + const handleModelChange = (providerId: string, modelId: string) => { + savePreferredModel(providerId, modelId); + setPreferredModels(prev => ({ ...prev, [providerId]: modelId })); + }; + + const handleDone = () => { + if (!effectiveId) return; + const settings = getAIProviderSettings(); + saveAIProviderSettings({ ...settings, providerId: effectiveId }); + markAISetupDone(); + onComplete(effectiveId); + }; + + return createPortal( +
+
+ {/* Header */} +
+
+
+ + + +
+

AI-Assisted Code Review

+
+

+ Ask questions about code changes inline or in a side chat. Select lines in a diff, + click the sparkle, and get streaming answers with full diff context. +

+
+ + {/* Provider grid */} +
+
Choose your default provider
+
+ {providers.map(p => { + const meta = getProviderMeta(p.name); + const Icon = meta.icon; + const isSelected = effectiveId === p.id; + return ( + + ); + })} +
+ + {/* Model selector for selected provider */} + {effectiveId && (() => { + const provider = providers.find(p => p.id === effectiveId); + const models = provider?.models ?? []; + if (models.length <= 1) return null; + const defaultModel = models.find(m => m.default) ?? models[0]; + const selectedModel = preferredModels[effectiveId] ?? defaultModel?.id ?? ''; + return ( +
+ +
+ ); + })()} +
+ + {/* Footer */} +
+

+ Providers are detected from installed CLI tools. + You must be authenticated with each CLI independently.{' '} + Learn more +

+ +
+
+
, + document.body + ); +}; diff --git a/packages/ui/components/ProviderIcons.tsx b/packages/ui/components/ProviderIcons.tsx index f878c902..77f9dabb 100644 --- a/packages/ui/components/ProviderIcons.tsx +++ b/packages/ui/components/ProviderIcons.tsx @@ -21,6 +21,14 @@ export const PiIcon: React.FC<{ className?: string }> = ({ className = 'w-4 h-4' ); +/** OpenCode icon — extracted from apps/marketing/public/assets/icon-opencode-dark.svg */ +export const OpenCodeIcon: React.FC<{ className?: string }> = ({ className = 'w-4 h-4' }) => ( + + + + +); + /** Generic fallback icon for unknown providers */ const GenericProviderIcon: React.FC<{ className?: string }> = ({ className = 'w-4 h-4' }) => ( @@ -33,6 +41,7 @@ export const PROVIDER_META: Record