feat(ai): OpenCode provider via HTTP + SSE (#379)

* feat(ai): add OpenCode provider via HTTP + SSE

Bundle @opencode-ai/sdk to spawn `opencode serve` and communicate via
HTTP REST + SSE. Richest provider: fork, resume, streaming, tools, and
runtime permission approvals all work natively.

- OpenCodeProvider: lazy server spawn, shared HTTP client, dynamic model
  discovery via GET /provider
- OpenCodeSession: SSE event drain loop, system prompt via API field,
  permission reply (once/reject), abort via HTTP
- mapOpenCodeEvent: SSE → AIMessage mapping (text deltas, tool lifecycle,
  permissions, session status, errors)
- 11 new event mapping tests (78 total, 157 expect calls)

For provenance purposes, this commit was AI assisted.

* docs(ai): add OpenCode to AI features guide and code review docs

For provenance purposes, this commit was AI assisted.

* fix(ai): prevent double server spawn in OpenCode ensureServer()

Use a promise-mutex pattern so concurrent callers (e.g. fetchModels +
createSession) share a single server spawn instead of racing to create two.
Also remove empty finally block from SSE drain loop.

For provenance purposes, this commit was AI assisted.

* fix(ai): await SSE subscribe in OpenCode provider

The SDK's event.subscribe() is async — must be awaited before
destructuring the stream.

For provenance purposes, this commit was AI assisted.

* fix(ui): scrollable model dropdown with search filter

Cap dropdown height at min(320px, 50vh) so it doesn't overflow the
viewport when providers expose many models (e.g. OpenCode with 20+).
Add a search/filter input when there are more than 8 models.

For provenance purposes, this commit was AI assisted.

* fix(ai): close SSE stream, rename to opencode-sdk, move dep to root

- Close SSE stream in finally block to prevent connection leak on
  abort/early return
- Rename provider from opencode to opencode-sdk for consistency with
  claude-agent-sdk, codex-sdk, pi-sdk
- Move @opencode-ai/sdk dependency to root package.json alongside the
  other SDK dependencies

For provenance purposes, this commit was AI assisted.

* feat(ui): add first-run AI setup dialog for provider selection

Show a one-time modal on first review when AI providers are available.
Users pick a default provider and model from a 2-column grid, with a
brief feature intro and auth disclaimer. Persists via cookie, can be
changed later in Settings.

For provenance purposes, this commit was AI assisted.

* fix(ai): clear startPromise on failure so OpenCode provider can retry

If doStart() fails (port conflict, timeout, binary crash), the cached
rejected promise prevented all future ensureServer() calls from
retrying. Now cleared on failure and on dispose().

Also updates stale "future" comment in types.ts.

For provenance purposes, this commit was AI assisted.
This commit is contained in:
Michael Ramos
2026-03-23 17:37:56 -07:00
committed by GitHub
parent 877b0c78ac
commit d4ff4a8df7
16 changed files with 986 additions and 30 deletions
@@ -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
@@ -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 |
+4 -1
View File
@@ -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=="],
+1
View File
@@ -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",
+174 -4
View File
@@ -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([]);
});
});
+1
View File
@@ -74,6 +74,7 @@ export type {
ClaudeAgentSDKConfig,
CodexSDKConfig,
PiSDKConfig,
OpenCodeConfig,
} from "./types.ts";
// Provider registry
+4 -2
View File
@@ -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": {}
}
+490
View File
@@ -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<void> | null = null;
constructor(config: OpenCodeConfig) {
this.config = config;
}
/** Lazy-spawn the OpenCode server and create the HTTP client. */
async ensureServer(): Promise<void> {
if (this.server && this.client) return;
this.startPromise ??= this.doStart().catch((err) => {
this.startPromise = null;
throw err;
});
return this.startPromise;
}
private async doStart(): Promise<void> {
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<AISession> {
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<AISession> {
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<AISession> {
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<void> {
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<string, { id: string; providerID: string; name: string }>;
}>;
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<AIMessage> {
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<string, unknown> | undefined;
if (!props) continue;
// Filter: only events for our session
const eventSessionId =
(props.sessionID as string) ??
((props.info as Record<string, unknown>)?.sessionID as string) ??
((props.part as Record<string, unknown>)?.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<string, unknown>,
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<string, unknown>;
if (!part) return [];
const partType = part.type as string;
if (partType === "tool") {
const state = part.state as Record<string, unknown>;
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<string, unknown>) ?? {},
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<string, unknown>) ?? {};
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<string, unknown>;
if (status?.type === "idle") {
return [
{
type: "result",
sessionId,
success: true,
},
];
}
return [];
}
case "session.error": {
const error = props.error as Record<string, unknown>;
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<string, unknown>;
if (!info) return [];
const msgError = info.error as Record<string, unknown>;
if (msgError) {
const errorData = msgError.data as Record<string, unknown>;
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),
);
+9 -1
View File
@@ -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;
}
+18 -1
View File
@@ -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 */}
<AISetupDialog
isOpen={showAISetup}
providers={aiProviders}
onComplete={(providerId) => {
setShowAISetup(false);
handleAIConfigChange({ providerId });
}}
/>
{/* Completion overlay - shown after approve/feedback */}
<CompletionOverlay
submitted={submitted}
@@ -44,7 +44,9 @@ export const AIConfigBar: React.FC<AIConfigBarProps> = ({
}) => {
const [showSessionNote, setShowSessionNote] = useState(false);
const [openMenu, setOpenMenu] = useState<'provider' | 'model' | 'effort' | null>(null);
const [modelSearch, setModelSearch] = useState('');
const barRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
// Flash "New chat session" briefly when config changes while a session exists
useEffect(() => {
@@ -60,6 +62,7 @@ export const AIConfigBar: React.FC<AIConfigBarProps> = ({
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<AIConfigBarProps> = ({
if (hasSession) setShowSessionNote(true);
onModelChange(id);
setOpenMenu(null);
setModelSearch('');
};
const handleEffortSelect = (id: string) => {
@@ -173,24 +177,40 @@ export const AIConfigBar: React.FC<AIConfigBarProps> = ({
{openMenu === 'model' && (
<div className="ai-config-menu">
{models.map(m => {
const isActive = m.id === effectiveModel;
return (
<button
key={m.id}
type="button"
onClick={() => handleModelSelect(m.id)}
className={`ai-config-menu-item ${isActive ? 'ai-config-menu-item-active' : ''}`}
>
<span>{m.label}</span>
{isActive && (
<svg className="w-3 h-3 ml-auto text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</button>
);
})}
{models.length > 8 && (
<div className="ai-config-menu-search">
<input
ref={searchInputRef}
type="text"
placeholder="Filter models…"
value={modelSearch}
onChange={e => setModelSearch(e.target.value)}
autoFocus
/>
</div>
)}
<div className={models.length > 8 ? 'ai-config-menu-scroll' : ''}>
{models
.filter(m => !modelSearch || m.label.toLowerCase().includes(modelSearch.toLowerCase()))
.map(m => {
const isActive = m.id === effectiveModel;
return (
<button
key={m.id}
type="button"
onClick={() => handleModelSelect(m.id)}
className={`ai-config-menu-item ${isActive ? 'ai-config-menu-item-active' : ''}`}
>
<span>{m.label}</span>
{isActive && (
<svg className="w-3 h-3 ml-auto text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</button>
);
})}
</div>
</div>
)}
</div>
+35
View File
@@ -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;
+18
View File
@@ -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({
+153
View File
@@ -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<string, boolean>;
models?: AIProviderModel[];
}
interface AISetupDialogProps {
isOpen: boolean;
providers: AIProvider[];
onComplete: (providerId: string) => void;
}
export const AISetupDialog: React.FC<AISetupDialogProps> = ({
isOpen,
providers,
onComplete,
}) => {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [preferredModels, setPreferredModels] = useState<Record<string, string>>(() =>
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(
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-background/90 backdrop-blur-sm p-4">
<div className="bg-card border border-border rounded-xl w-full max-w-lg shadow-2xl">
{/* Header */}
<div className="p-5 border-b border-border">
<div className="flex items-center gap-2 mb-2">
<div className="p-1.5 rounded-lg bg-primary/15">
<svg className="w-5 h-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z" />
</svg>
</div>
<h3 className="font-semibold text-base">AI-Assisted Code Review</h3>
</div>
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
{/* Provider grid */}
<div className="p-4">
<div className="text-xs text-muted-foreground mb-2">Choose your default provider</div>
<div className="grid grid-cols-2 gap-2">
{providers.map(p => {
const meta = getProviderMeta(p.name);
const Icon = meta.icon;
const isSelected = effectiveId === p.id;
return (
<button
key={p.id}
type="button"
onClick={() => handleSelectProvider(p.id)}
className={`flex items-center gap-2.5 p-3 rounded-lg border transition-colors text-left ${
isSelected
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/30 hover:bg-muted/50'
}`}
>
<div className="w-7 h-7 rounded-md bg-muted flex items-center justify-center text-muted-foreground flex-shrink-0">
<Icon className="w-4.5 h-4.5" />
</div>
<span className="text-sm font-medium">{meta.label}</span>
{isSelected && (
<svg className="w-3.5 h-3.5 text-primary ml-auto flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</button>
);
})}
</div>
{/* 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 (
<div className="mt-3">
<label className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Model</span>
<select
value={selectedModel}
onChange={(e) => handleModelChange(effectiveId, e.target.value)}
className="flex-1 text-xs bg-muted rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary/50 cursor-pointer"
>
{models.map(m => (
<option key={m.id} value={m.id}>{m.label}</option>
))}
</select>
</label>
</div>
);
})()}
</div>
{/* Footer */}
<div className="p-4 border-t border-border flex justify-between items-center gap-3">
<p className="text-[10px] text-muted-foreground/70 flex-1">
Providers are detected from installed CLI tools.
You must be authenticated with each CLI independently.{' '}
<a href="https://plannotator.ai/docs/guides/ai-features/" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">Learn more</a>
</p>
<button
onClick={handleDone}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg text-sm font-medium hover:opacity-90 transition-opacity flex-shrink-0"
>
Done
</button>
</div>
</div>
</div>,
document.body
);
};
+9
View File
@@ -21,6 +21,14 @@ export const PiIcon: React.FC<{ className?: string }> = ({ className = 'w-4 h-4'
</svg>
);
/** OpenCode icon — extracted from apps/marketing/public/assets/icon-opencode-dark.svg */
export const OpenCodeIcon: React.FC<{ className?: string }> = ({ className = 'w-4 h-4' }) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" className={className}>
<path d="M3 32V0h26v32zM22 7H10v18h12z" fill="currentColor"/>
<path d="M10 13h12v12H10z" fill="currentColor" opacity={0.4}/>
</svg>
);
/** Generic fallback icon for unknown providers */
const GenericProviderIcon: React.FC<{ className?: string }> = ({ className = 'w-4 h-4' }) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className={className}>
@@ -33,6 +41,7 @@ export const PROVIDER_META: Record<string, { label: string; icon: React.FC<{ cla
'claude-agent-sdk': { label: 'Claude', icon: ClaudeIcon },
'codex-sdk': { label: 'Codex', icon: CodexIcon },
'pi-sdk': { label: 'Pi', icon: PiIcon },
'opencode-sdk': { label: 'OpenCode', icon: OpenCodeIcon },
};
/** Get provider metadata, with fallback for unknown providers. */
+18
View File
@@ -0,0 +1,18 @@
/**
* AI Setup Dialog Utility
*
* Tracks whether the user has seen the first-run AI setup dialog.
* Uses cookies (not localStorage) for the same reason as all other settings.
*/
import { storage } from './storage';
const STORAGE_KEY = 'plannotator-ai-setup-done';
export function needsAISetup(): boolean {
return storage.getItem(STORAGE_KEY) !== 'true';
}
export function markAISetupDone(): void {
storage.setItem(STORAGE_KEY, 'true');
}