mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
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:
@@ -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 |
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,6 +74,7 @@ export type {
|
||||
ClaudeAgentSDKConfig,
|
||||
CodexSDKConfig,
|
||||
PiSDKConfig,
|
||||
OpenCodeConfig,
|
||||
} from "./types.ts";
|
||||
|
||||
// Provider registry
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
@@ -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. */
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user