diff --git a/README.md b/README.md index 4dac3cb..3b13575 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,55 @@ Set `CODEFLOW_STORE_ROOT` to override the default location. Browser-only preferences such as live completions, auto-implement, and theme stay in local storage. The raw NVIDIA API key is no longer persisted there; it is held in the current browser session only unless you provide it through `NVIDIA_API_KEY`. +## OpenCode Integration + +CodeFlow supports OpenCode as an alternative AI backend for code generation and implementation: + +### Setup + +1. Install OpenCode globally (optional, for CLI usage): + ```bash + npm install -g opencode-ai + ``` + +2. Configure OpenCode from the CodeFlow settings panel: + - Open Settings (gear icon or press `S`) + - Click "Configure OpenCode" + - Select your AI provider (Anthropic, OpenAI, Google, etc.) + - Enter your API key + - Click "Start Server" + +### Features + +- **Multiple AI Providers**: Support for Anthropic (Claude), OpenAI (GPT), Google (Gemini), Azure OpenAI, Groq, Mistral, Cohere, Perplexity, OpenRouter, AWS Bedrock, and local models +- **MCP Servers**: Configure Model Context Protocol servers for enhanced agent capabilities +- **Skills & Hooks**: Enable OpenCode skills and configure pre/post hooks for automated workflows +- **Agent Types**: Choose between "build" (full file access) or "plan" (read-only analysis) agents + +### OpenCode API Routes + +- `GET /api/opencode/status` - Get server status +- `POST /api/opencode/start` - Start the OpenCode server +- `POST /api/opencode/stop` - Stop the OpenCode server +- `POST /api/opencode/restart` - Restart the OpenCode server +- `POST /api/opencode/agent` - Send a message to the agent +- `GET /api/opencode/sessions` - List OpenCode sessions +- `POST /api/opencode/sessions` - Create a new session +- `GET /api/opencode/sessions/:id` - Get session details +- `POST /api/opencode/sessions/:id` - Send message to session +- `DELETE /api/opencode/sessions/:id` - Delete session +- `GET /api/opencode/mcp` - List configured MCP servers +- `POST /api/opencode/mcp` - Configure MCP server +- `GET /api/opencode/permissions` - List pending permission requests +- `POST /api/opencode/permissions` - Reply to permission request + +### Using OpenCode for Code Generation + +When OpenCode is running, you can use it for: +- Node implementation (`POST /api/implement-node` with `useOpencode: true`) +- Code suggestions (`POST /api/code-suggestions` with `useOpencode: true`) +- Direct agent queries via the OpenCode panel in the workbench + ## API routes - `POST /api/blueprint` diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package-lock.json b/package-lock.json index 7adaab0..893cdd1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,9 +13,11 @@ "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", "@xyflow/react": "^12.10.1", + "cross-spawn": "^7.0.6", "framer-motion": "^12.38.0", "monaco-editor": "^0.55.1", "next": "^16.1.6", + "opencode-ai": "^1.3.13", "react": "^19.2.4", "react-dom": "^19.2.4", "react-rnd": "^10.4.13", @@ -29,6 +31,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/cross-spawn": "^6.0.6", "@types/node": "^25.5.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -2936,6 +2939,16 @@ "integrity": "sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==", "license": "MIT" }, + "node_modules/@types/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", @@ -8293,6 +8306,174 @@ "wrappy": "1" } }, + "node_modules/opencode-ai": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/opencode-ai/-/opencode-ai-1.3.13.tgz", + "integrity": "sha512-+FmXiAwCiSNYFkWEBsRWKMpDAGR2KuViVelIH9i3w5s2lltjw84Q4IQybDqzOzwTN/H4g9UgQuf5QzDqsZ1xNw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "opencode": "bin/opencode" + }, + "optionalDependencies": { + "opencode-darwin-arm64": "1.3.13", + "opencode-darwin-x64": "1.3.13", + "opencode-darwin-x64-baseline": "1.3.13", + "opencode-linux-arm64": "1.3.13", + "opencode-linux-arm64-musl": "1.3.13", + "opencode-linux-x64": "1.3.13", + "opencode-linux-x64-baseline": "1.3.13", + "opencode-linux-x64-baseline-musl": "1.3.13", + "opencode-linux-x64-musl": "1.3.13", + "opencode-windows-arm64": "1.3.13", + "opencode-windows-x64": "1.3.13", + "opencode-windows-x64-baseline": "1.3.13" + } + }, + "node_modules/opencode-darwin-arm64": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.3.13.tgz", + "integrity": "sha512-pZv7qhuMFhtv+pksKXmX2IhlK2QHOQkoJQHByzCEhvDq9JVxIduwB9ADepNJDfcY7tXePrSJCLxIYKFzVjVOzA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/opencode-darwin-x64": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.3.13.tgz", + "integrity": "sha512-BSBSaHwLY8REwW4hVOm9hsihJBJMN559FE0+01I1nwrAg5OM7bPCoDL/PK+xnGkWe6wpbjL7x1hju3BPx3Dk+g==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/opencode-darwin-x64-baseline": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/opencode-darwin-x64-baseline/-/opencode-darwin-x64-baseline-1.3.13.tgz", + "integrity": "sha512-KYAAodKxVoE+MQvl1de8WXfmy0gQtsDLS1XGmro608nlQOheVMOBZfWJbRi51rcjL4cDoF0/k4CQ35uluxsciw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/opencode-linux-arm64": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.3.13.tgz", + "integrity": "sha512-FalygguOjawwNkZOyyERKsZHC9kU5O05Nf0X9iNCNdeNIYD8oktqB1GfDymedKVLSM3s1wLiY2K2AadwnivRGQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/opencode-linux-arm64-musl": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/opencode-linux-arm64-musl/-/opencode-linux-arm64-musl-1.3.13.tgz", + "integrity": "sha512-prJuZc93GcYxeWs/uz1BuoYGZSQEswJmKoh9nWh55QmCXf+Lb/x1vp2CdtiGzxpb7mj+NJ4QfVOa9Fc0UZMj0Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/opencode-linux-x64": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.3.13.tgz", + "integrity": "sha512-zL+gG/TnevdTgbc6z3RwYXIpaodmdt2IqHF+CAQuf8Ayzx1LF0SvkkiclIEz12kdjlfttjNOxuRFWCJLzQIJIw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/opencode-linux-x64-baseline": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline/-/opencode-linux-x64-baseline-1.3.13.tgz", + "integrity": "sha512-0F6biOgk9u889ZvckOnrHpSz9b29vqR/kcL1LxQ1t41Erq89uYcNh/RbNJcTJkYPsG30+yu8hHg+eQWvFjxvyw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/opencode-linux-x64-baseline-musl": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline-musl/-/opencode-linux-x64-baseline-musl-1.3.13.tgz", + "integrity": "sha512-vxKW4tPQH3WKf4eoyNw4bzf503IvfxZ+94PXbvD0TK49Qhkab/W7ibigtTJ/WyC1JllBz7W3RQS6odstgXqUVA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/opencode-linux-x64-musl": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-musl/-/opencode-linux-x64-musl-1.3.13.tgz", + "integrity": "sha512-jk+YSEYje0JfV7xapUX02zkF1N8fHfuH0O9di5S/VP4L4zLHO8uWrEj/4LxiwPWwdUgSATP9pf8WMx/Shn5dZQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/opencode-windows-arm64": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.3.13.tgz", + "integrity": "sha512-FMDHGS0PQ/rFFEpJccn5jxxk+xwB8uU4iWHNaVCDnw7HtB9qxE2wq7cIkMnrBr3NzeCV4P9O4edbhiSwt7cZsQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/opencode-windows-x64": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.3.13.tgz", + "integrity": "sha512-URW1iowKefYTzxKEyD3Kq03HjqVRlwPHAAFiB7qzA17AcWCZFppp+rhOlKz25oxXjugJj53YGd63Shlrjc/oUg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/opencode-windows-x64-baseline": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/opencode-windows-x64-baseline/-/opencode-windows-x64-baseline-1.3.13.tgz", + "integrity": "sha512-6bUttlnruIcTE+iU2MWR5kNtMOa6PIgXx5cyPNCvbbNUzdgo8AB2BWubB7IFn4DG2hy8UxFCW0ICjRv8ic5Tbw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", diff --git a/package.json b/package.json index bb5464c..84acade 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,11 @@ "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", "@xyflow/react": "^12.10.1", + "cross-spawn": "^7.0.6", "framer-motion": "^12.38.0", "monaco-editor": "^0.55.1", "next": "^16.1.6", + "opencode-ai": "^1.3.13", "react": "^19.2.4", "react-dom": "^19.2.4", "react-rnd": "^10.4.13", @@ -39,6 +41,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/cross-spawn": "^6.0.6", "@types/node": "^25.5.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/src/app/api/code-suggestions/route.ts b/src/app/api/code-suggestions/route.ts index 6249468..7c594f5 100644 --- a/src/app/api/code-suggestions/route.ts +++ b/src/app/api/code-suggestions/route.ts @@ -10,6 +10,7 @@ import { import { withCodeflowGovernance } from "@/lib/blueprint/prompt-governance"; import { blueprintGraphSchema } from "@/lib/blueprint/schema"; import { getNvidiaKeySource, requestNvidiaChatCompletion, resolveNvidiaApiKey } from "@/lib/blueprint/nvidia"; +import { isOpencodeAvailable, sendToOpencode } from "@/lib/opencode/agent"; const requestSchema = z.object({ graph: blueprintGraphSchema, @@ -18,7 +19,8 @@ const requestSchema = z.object({ instruction: z.string().trim().optional(), retrievalQuery: z.string().trim().min(1).optional(), retrievalDepth: z.number().int().min(1).max(6).optional(), - nvidiaApiKey: z.string().optional() + nvidiaApiKey: z.string().optional(), + useOpencode: z.boolean().optional().default(false) }); const responseSchema = z.object({ @@ -50,8 +52,9 @@ export async function POST(request: Request) { try { const body = requestSchema.parse(await request.json()); - const apiKey = resolveNvidiaApiKey(body.nvidiaApiKey); - const keySource = getNvidiaKeySource(body.nvidiaApiKey); + const useOpencode = body.useOpencode && isOpencodeAvailable(); + const apiKey = useOpencode ? null : resolveNvidiaApiKey(body.nvidiaApiKey); + const keySource = useOpencode ? "opencode" : getNvidiaKeySource(body.nvidiaApiKey); const context = getNodeAssistanceContext(body.graph, body.nodeId); if (!context) { @@ -67,11 +70,16 @@ export async function POST(request: Request) { keySource }); - if (!apiKey) { - return NextResponse.json( - { error: "NVIDIA API key is required. Provide it in the UI or set NVIDIA_API_KEY environment variable." }, - { status: 400 } - ); + if (!useOpencode && !apiKey) { + // Check if OpenCode is available as fallback + if (isOpencodeAvailable()) { + body.useOpencode = true; + } else { + return NextResponse.json( + { error: "No AI backend available. Either start OpenCode server or provide NVIDIA API key." }, + { status: 400 } + ); + } } const retrievalContext = await resolveAgentRetrievalContext({ @@ -132,16 +140,31 @@ Return the JSON suggestion now.`; "implementation" ); - const content = await requestNvidiaChatCompletion({ - apiKey, - messages: [ - { role: "system", content: governedSystemPrompt }, - { role: "user", content: userPrompt } - ], - temperature: 0.2, - topP: 0.7, - maxTokens: 4096 - }); + let content: string; + + if (useOpencode || body.useOpencode) { + const result = await sendToOpencode({ + systemPrompt: governedSystemPrompt, + prompt: userPrompt, + }); + + if (!result.success) { + throw new Error(result.error ?? "OpenCode request failed"); + } + + content = result.content ?? ""; + } else { + content = await requestNvidiaChatCompletion({ + apiKey: apiKey!, + messages: [ + { role: "system", content: governedSystemPrompt }, + { role: "user", content: userPrompt } + ], + temperature: 0.2, + topP: 0.7, + maxTokens: 4096 + }); + } const jsonMatch = content.match(/\{[\s\S]*\}/); const jsonString = jsonMatch ? jsonMatch[0] : content; diff --git a/src/app/api/implement-node/route.ts b/src/app/api/implement-node/route.ts index c346d6c..4ed02d7 100644 --- a/src/app/api/implement-node/route.ts +++ b/src/app/api/implement-node/route.ts @@ -16,6 +16,7 @@ import { blueprintGraphSchema } from "@/lib/blueprint/schema"; import { getNvidiaKeySource, requestNvidiaChatCompletion, resolveNvidiaApiKey } from "@/lib/blueprint/nvidia"; import { createRunPlan } from "@/lib/blueprint/plan"; import { upsertSession } from "@/lib/blueprint/session-store"; +import { isOpencodeAvailable, sendToOpencode, extractJsonFromResponse } from "@/lib/opencode/agent"; const requestSchema = z.object({ graph: blueprintGraphSchema, @@ -23,7 +24,8 @@ const requestSchema = z.object({ currentCode: z.string().optional(), retrievalQuery: z.string().trim().min(1).optional(), retrievalDepth: z.number().int().min(1).max(6).optional(), - nvidiaApiKey: z.string().optional() + nvidiaApiKey: z.string().optional(), + useOpencode: z.boolean().optional().default(false) }); const responseSchema = z.object({ @@ -66,8 +68,9 @@ export async function POST(request: Request) { try { const rawBody = requestSchema.parse(await request.json()); const graph: BlueprintGraph = rawBody.graph; - const apiKey = resolveNvidiaApiKey(rawBody.nvidiaApiKey); - const keySource = getNvidiaKeySource(rawBody.nvidiaApiKey); + const useOpencode = rawBody.useOpencode && isOpencodeAvailable(); + const apiKey = useOpencode ? null : resolveNvidiaApiKey(rawBody.nvidiaApiKey); + const keySource = useOpencode ? "opencode" : getNvidiaKeySource(rawBody.nvidiaApiKey); const context = getNodeAssistanceContext(graph, rawBody.nodeId); if (!context) { @@ -82,11 +85,17 @@ export async function POST(request: Request) { ); } - if (!apiKey) { - return NextResponse.json( - { error: "NVIDIA API key is required. Provide it in the UI or set NVIDIA_API_KEY environment variable." }, - { status: 400 } - ); + if (!useOpencode && !apiKey) { + // Check if OpenCode is available as fallback + if (isOpencodeAvailable()) { + // Redirect to use OpenCode + rawBody.useOpencode = true; + } else { + return NextResponse.json( + { error: "No AI backend available. Either start OpenCode server or provide NVIDIA API key." }, + { status: 400 } + ); + } } const currentCode = rawBody.currentCode ?? node.implementationDraft ?? generateNodeCode(node, graph) ?? ""; @@ -173,14 +182,39 @@ Implement only this node. Preserve compatibility with the blueprint contract and let parsed: z.infer | null = null; let lastFailure = "Implementation validation did not complete."; - for (let attempt = 1; attempt <= IMPLEMENTATION_ATTEMPT_LIMIT; attempt++) { - const content = await requestNvidiaChatCompletion({ - apiKey, - messages, + // Helper to get completion from either OpenCode or NVIDIA + const getCompletion = async (msgs: typeof messages): Promise => { + if (useOpencode || rawBody.useOpencode) { + // Build full prompt from messages for OpenCode + const systemMsg = msgs.find(m => m.role === "system")?.content ?? ""; + const userMsgs = msgs.filter(m => m.role !== "system") + .map(m => `${m.role === "user" ? "User" : "Assistant"}: ${m.content}`) + .join("\n\n"); + + const result = await sendToOpencode({ + systemPrompt: systemMsg, + prompt: userMsgs, + }); + + if (!result.success) { + throw new Error(result.error ?? "OpenCode request failed"); + } + + return result.content ?? ""; + } + + // Use NVIDIA + return requestNvidiaChatCompletion({ + apiKey: apiKey!, + messages: msgs, temperature: 0.2, topP: 0.7, maxTokens: 4096 }); + }; + + for (let attempt = 1; attempt <= IMPLEMENTATION_ATTEMPT_LIMIT; attempt++) { + const content = await getCompletion(messages); try { parsed = responseSchema.parse(JSON.parse(extractJsonPayload(content))); diff --git a/src/app/api/opencode/agent/route.ts b/src/app/api/opencode/agent/route.ts new file mode 100644 index 0000000..6e002e4 --- /dev/null +++ b/src/app/api/opencode/agent/route.ts @@ -0,0 +1,187 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import type { AgentRequest, AgentResponse } from "@/lib/opencode/types"; +import { getOpencodeServerInfo } from "@/lib/opencode/server"; + +const agentRequestSchema = z.object({ + type: z.enum(["build", "plan", "general"]), + message: z.string(), + context: z.object({ + files: z.array(z.string()).optional(), + codeSnippets: z.array(z.object({ + path: z.string(), + content: z.string(), + })).optional(), + previousMessages: z.array(z.object({ + role: z.enum(["user", "assistant"]), + content: z.string(), + })).optional(), + }).optional(), +}); + +/** + * Create or get a session for the agent interaction + */ +async function getOrCreateSession(serverUrl: string, agentType: string): Promise { + // List existing sessions + const listRes = await fetch(`${serverUrl}/session?limit=1`, { + headers: { "Content-Type": "application/json" }, + }); + + if (listRes.ok) { + const sessions = await listRes.json(); + if (sessions.length > 0) { + return sessions[0].id; + } + } + + // Create a new session + const createRes = await fetch(`${serverUrl}/session`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: `CodeFlow ${agentType} session`, + agent: agentType, + }), + }); + + if (!createRes.ok) { + throw new Error(`Failed to create session: ${createRes.statusText}`); + } + + const session = await createRes.json(); + return session.id; +} + +/** + * Send a prompt to the OpenCode server and wait for response + */ +async function sendPrompt( + serverUrl: string, + sessionId: string, + message: string, + context?: AgentRequest["context"] +): Promise { + // Build the prompt with context + let fullPrompt = message; + + if (context?.codeSnippets && context.codeSnippets.length > 0) { + fullPrompt += "\n\n--- Context ---\n"; + for (const snippet of context.codeSnippets) { + fullPrompt += `\nFile: ${snippet.path}\n\`\`\`\n${snippet.content}\n\`\`\`\n`; + } + } + + if (context?.files && context.files.length > 0) { + fullPrompt += `\n\nRelevant files: ${context.files.join(", ")}`; + } + + // Send message to session + const promptRes = await fetch(`${serverUrl}/session/${sessionId}/message`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + parts: [{ type: "text", text: fullPrompt }], + }), + }); + + if (!promptRes.ok) { + const errorText = await promptRes.text(); + return { + success: false, + error: `Failed to send prompt: ${errorText}`, + }; + } + + // Parse streamed response + const responseText = await promptRes.text(); + + try { + const parsed = JSON.parse(responseText); + + // Extract text response from parts + let responseContent = ""; + const actions: AgentResponse["actions"] = []; + + if (parsed.parts && Array.isArray(parsed.parts)) { + for (const part of parsed.parts) { + if (part.type === "text" && part.text) { + responseContent += part.text; + } else if (part.type === "tool-call" || part.type === "tool_call") { + // Extract tool calls as actions + const toolName = part.toolName || part.name; + if (toolName === "write_file" || toolName === "create_file") { + actions.push({ + type: "file_create", + payload: part.args || part.input, + }); + } else if (toolName === "edit_file" || toolName === "replace") { + actions.push({ + type: "file_edit", + payload: part.args || part.input, + }); + } else if (toolName === "bash" || toolName === "shell") { + actions.push({ + type: "bash_command", + payload: part.args || part.input, + }); + } + } + } + } + + return { + success: true, + response: responseContent || JSON.stringify(parsed), + actions: actions.length > 0 ? actions : undefined, + }; + } catch { + // If not valid JSON, return as plain text + return { + success: true, + response: responseText, + }; + } +} + +export async function POST(request: Request) { + try { + const body = await request.json(); + const validated = agentRequestSchema.parse(body) as AgentRequest; + + // Check if OpenCode server is running + const serverInfo = getOpencodeServerInfo(); + + if (serverInfo.status !== "running" || !serverInfo.url) { + return NextResponse.json({ + success: false, + error: "OpenCode server is not running. Please start it from Settings > OpenCode Agent.", + }, { status: 503 }); + } + + // Get or create a session + const sessionId = await getOrCreateSession(serverInfo.url, validated.type); + + // Send the prompt and get response + const response = await sendPrompt( + serverInfo.url, + sessionId, + validated.message, + validated.context + ); + + return NextResponse.json(response); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { success: false, error: "Invalid request body", details: error.issues }, + { status: 400 } + ); + } + + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : "Failed to process agent request" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/opencode/mcp/route.ts b/src/app/api/opencode/mcp/route.ts new file mode 100644 index 0000000..3a16ac0 --- /dev/null +++ b/src/app/api/opencode/mcp/route.ts @@ -0,0 +1,82 @@ +import { NextResponse } from "next/server"; +import { getOpencodeServerInfo } from "@/lib/opencode/server"; + +/** + * GET /api/opencode/mcp - List MCP servers configured in OpenCode + */ +export async function GET() { + try { + const serverInfo = getOpencodeServerInfo(); + + if (serverInfo.status !== "running" || !serverInfo.url) { + return NextResponse.json({ + servers: [], + error: "OpenCode server is not running" + }); + } + + // Get MCP server list from OpenCode + const response = await fetch(`${serverInfo.url}/mcp/servers`, { + headers: { "Content-Type": "application/json" }, + }); + + if (!response.ok) { + const errorText = await response.text(); + return NextResponse.json({ + servers: [], + error: `Failed to fetch MCP servers: ${errorText}` + }); + } + + const servers = await response.json(); + + return NextResponse.json({ servers }); + } catch (error) { + return NextResponse.json( + { servers: [], error: error instanceof Error ? error.message : "Failed to list MCP servers" }, + { status: 500 } + ); + } +} + +/** + * POST /api/opencode/mcp - Configure an MCP server in OpenCode + */ +export async function POST(request: Request) { + try { + const body = await request.json(); + + const serverInfo = getOpencodeServerInfo(); + + if (serverInfo.status !== "running" || !serverInfo.url) { + return NextResponse.json( + { success: false, error: "OpenCode server is not running" }, + { status: 503 } + ); + } + + // Forward MCP server configuration to OpenCode + const response = await fetch(`${serverInfo.url}/mcp/server`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + return NextResponse.json( + { success: false, error: `Failed to configure MCP server: ${errorText}` }, + { status: response.status } + ); + } + + const result = await response.json(); + + return NextResponse.json({ success: true, ...result }); + } catch (error) { + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : "Failed to configure MCP server" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/opencode/permissions/route.ts b/src/app/api/opencode/permissions/route.ts new file mode 100644 index 0000000..f764bd1 --- /dev/null +++ b/src/app/api/opencode/permissions/route.ts @@ -0,0 +1,95 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getOpencodeServerInfo } from "@/lib/opencode/server"; + +/** + * GET /api/opencode/permissions - List pending permission requests + */ +export async function GET() { + try { + const serverInfo = getOpencodeServerInfo(); + + if (serverInfo.status !== "running" || !serverInfo.url) { + return NextResponse.json({ + permissions: [], + error: "OpenCode server is not running" + }); + } + + const response = await fetch(`${serverInfo.url}/permission`, { + headers: { "Content-Type": "application/json" }, + }); + + if (!response.ok) { + const errorText = await response.text(); + return NextResponse.json({ + permissions: [], + error: `Failed to fetch permissions: ${errorText}` + }); + } + + const permissions = await response.json(); + + return NextResponse.json({ permissions }); + } catch (error) { + return NextResponse.json( + { permissions: [], error: error instanceof Error ? error.message : "Failed to list permissions" }, + { status: 500 } + ); + } +} + +const replySchema = z.object({ + requestId: z.string().min(1), + reply: z.enum(["yes", "no", "always", "never"]), + message: z.string().optional(), +}); + +/** + * POST /api/opencode/permissions - Reply to a permission request + */ +export async function POST(request: Request) { + try { + const body = replySchema.parse(await request.json()); + + const serverInfo = getOpencodeServerInfo(); + + if (serverInfo.status !== "running" || !serverInfo.url) { + return NextResponse.json( + { success: false, error: "OpenCode server is not running" }, + { status: 503 } + ); + } + + const response = await fetch(`${serverInfo.url}/permission/${body.requestId}/reply`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + reply: body.reply, + message: body.message, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + return NextResponse.json( + { success: false, error: `Failed to reply to permission: ${errorText}` }, + { status: response.status } + ); + } + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { success: false, error: "Invalid request body", details: error.issues }, + { status: 400 } + ); + } + + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : "Failed to reply to permission" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/opencode/restart/route.ts b/src/app/api/opencode/restart/route.ts new file mode 100644 index 0000000..61310c4 --- /dev/null +++ b/src/app/api/opencode/restart/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { restartOpencodeServer } from "@/lib/opencode/server"; +import { buildOpencodeConfig } from "@/lib/opencode/config"; +import type { OpencodeProvider } from "@/lib/opencode/types"; + +const restartRequestSchema = z.object({ + provider: z.string(), + apiKey: z.string(), + model: z.string().optional(), + baseUrl: z.string().optional(), + logLevel: z.enum(["debug", "info", "warn", "error"]).optional(), +}); + +export async function POST(request: Request) { + try { + const body = await request.json(); + const validated = restartRequestSchema.parse(body); + + const config = buildOpencodeConfig( + validated.provider as OpencodeProvider, + validated.apiKey, + { + model: validated.model, + baseUrl: validated.baseUrl, + logLevel: validated.logLevel, + } + ); + + const info = await restartOpencodeServer(config); + return NextResponse.json(info); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid request body", details: error.issues }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to restart server" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/opencode/sessions/[id]/route.ts b/src/app/api/opencode/sessions/[id]/route.ts new file mode 100644 index 0000000..c23da69 --- /dev/null +++ b/src/app/api/opencode/sessions/[id]/route.ts @@ -0,0 +1,154 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getOpencodeServerInfo } from "@/lib/opencode/server"; + +type RouteContext = { params: Promise<{ id: string }> }; + +/** + * GET /api/opencode/sessions/[id] - Get session details + */ +export async function GET( + request: Request, + context: RouteContext +) { + try { + const params = await context.params; + const sessionId = params.id; + + const serverInfo = getOpencodeServerInfo(); + + if (serverInfo.status !== "running" || !serverInfo.url) { + return NextResponse.json( + { error: "OpenCode server is not running" }, + { status: 503 } + ); + } + + const response = await fetch(`${serverInfo.url}/session/${sessionId}`, { + headers: { "Content-Type": "application/json" }, + }); + + if (!response.ok) { + const errorText = await response.text(); + return NextResponse.json( + { error: `Failed to fetch session: ${errorText}` }, + { status: response.status } + ); + } + + const session = await response.json(); + + return NextResponse.json({ session }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to fetch session" }, + { status: 500 } + ); + } +} + +const messageSchema = z.object({ + message: z.string().min(1), +}); + +/** + * POST /api/opencode/sessions/[id] - Send message to session + */ +export async function POST( + request: Request, + context: RouteContext +) { + try { + const params = await context.params; + const sessionId = params.id; + const body = messageSchema.parse(await request.json()); + + const serverInfo = getOpencodeServerInfo(); + + if (serverInfo.status !== "running" || !serverInfo.url) { + return NextResponse.json( + { error: "OpenCode server is not running" }, + { status: 503 } + ); + } + + const response = await fetch(`${serverInfo.url}/session/${sessionId}/message`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + parts: [{ type: "text", text: body.message }], + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + return NextResponse.json( + { error: `Failed to send message: ${errorText}` }, + { status: response.status } + ); + } + + const responseText = await response.text(); + + try { + const parsed = JSON.parse(responseText); + return NextResponse.json({ response: parsed }); + } catch { + return NextResponse.json({ response: responseText }); + } + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid request body", details: error.issues }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to send message" }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/opencode/sessions/[id] - Delete session + */ +export async function DELETE( + request: Request, + context: RouteContext +) { + try { + const params = await context.params; + const sessionId = params.id; + + const serverInfo = getOpencodeServerInfo(); + + if (serverInfo.status !== "running" || !serverInfo.url) { + return NextResponse.json( + { error: "OpenCode server is not running" }, + { status: 503 } + ); + } + + const response = await fetch(`${serverInfo.url}/session/${sessionId}`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + }); + + if (!response.ok) { + const errorText = await response.text(); + return NextResponse.json( + { error: `Failed to delete session: ${errorText}` }, + { status: response.status } + ); + } + + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to delete session" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/opencode/sessions/route.ts b/src/app/api/opencode/sessions/route.ts new file mode 100644 index 0000000..47d0484 --- /dev/null +++ b/src/app/api/opencode/sessions/route.ts @@ -0,0 +1,97 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getOpencodeServerInfo } from "@/lib/opencode/server"; + +/** + * GET /api/opencode/sessions - List OpenCode sessions + */ +export async function GET(request: Request) { + try { + const serverInfo = getOpencodeServerInfo(); + + if (serverInfo.status !== "running" || !serverInfo.url) { + return NextResponse.json({ + sessions: [], + error: "OpenCode server is not running" + }); + } + + const url = new URL(request.url); + const limit = url.searchParams.get("limit") ?? "10"; + + const response = await fetch(`${serverInfo.url}/session?limit=${limit}`, { + headers: { "Content-Type": "application/json" }, + }); + + if (!response.ok) { + const errorText = await response.text(); + return NextResponse.json({ + sessions: [], + error: `Failed to fetch sessions: ${errorText}` + }); + } + + const sessions = await response.json(); + + return NextResponse.json({ sessions }); + } catch (error) { + return NextResponse.json( + { sessions: [], error: error instanceof Error ? error.message : "Failed to list sessions" }, + { status: 500 } + ); + } +} + +const createSessionSchema = z.object({ + title: z.string().optional(), + agent: z.enum(["build", "plan"]).optional().default("build"), + cwd: z.string().optional(), +}); + +/** + * POST /api/opencode/sessions - Create a new OpenCode session + */ +export async function POST(request: Request) { + try { + const body = createSessionSchema.parse(await request.json()); + + const serverInfo = getOpencodeServerInfo(); + + if (serverInfo.status !== "running" || !serverInfo.url) { + return NextResponse.json( + { error: "OpenCode server is not running" }, + { status: 503 } + ); + } + + const response = await fetch(`${serverInfo.url}/session`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + return NextResponse.json( + { error: `Failed to create session: ${errorText}` }, + { status: response.status } + ); + } + + const session = await response.json(); + + return NextResponse.json({ session }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid request body", details: error.issues }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to create session" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/opencode/start/route.ts b/src/app/api/opencode/start/route.ts new file mode 100644 index 0000000..594d36b --- /dev/null +++ b/src/app/api/opencode/start/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { startOpencodeServer } from "@/lib/opencode/server"; +import { buildOpencodeConfig } from "@/lib/opencode/config"; +import type { OpencodeProvider } from "@/lib/opencode/types"; + +const startRequestSchema = z.object({ + provider: z.string(), + apiKey: z.string(), + model: z.string().optional(), + baseUrl: z.string().optional(), + logLevel: z.enum(["debug", "info", "warn", "error"]).optional(), +}); + +export async function POST(request: Request) { + try { + const body = await request.json(); + const validated = startRequestSchema.parse(body); + + const config = buildOpencodeConfig( + validated.provider as OpencodeProvider, + validated.apiKey, + { + model: validated.model, + baseUrl: validated.baseUrl, + logLevel: validated.logLevel, + } + ); + + const info = await startOpencodeServer(config); + return NextResponse.json(info); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid request body", details: error.issues }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to start server" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/opencode/status/route.ts b/src/app/api/opencode/status/route.ts new file mode 100644 index 0000000..ec09b3f --- /dev/null +++ b/src/app/api/opencode/status/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { getOpencodeServerInfo } from "@/lib/opencode/server"; + +export async function GET() { + try { + const info = getOpencodeServerInfo(); + return NextResponse.json(info); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to get server status" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/opencode/stop/route.ts b/src/app/api/opencode/stop/route.ts new file mode 100644 index 0000000..e2a843d --- /dev/null +++ b/src/app/api/opencode/stop/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { stopOpencodeServer } from "@/lib/opencode/server"; + +export async function POST() { + try { + await stopOpencodeServer(); + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to stop server" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/terminal/sessions/[sessionId]/route.ts b/src/app/api/terminal/sessions/[sessionId]/route.ts new file mode 100644 index 0000000..5daf51f --- /dev/null +++ b/src/app/api/terminal/sessions/[sessionId]/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { + closeTerminalSession, + getTerminalSession, + writeTerminalInput +} from "@/lib/server/terminal-sessions"; + +const terminalInputSchema = z.object({ + input: z.string().min(1), + echoInput: z.boolean().optional() +}); + +const isValidSessionId = (value: string): boolean => /^[A-Za-z0-9-]+$/.test(value); + +export async function GET( + _request: Request, + { params }: { params: Promise<{ sessionId: string }> } +) { + const { sessionId } = await params; + if (!isValidSessionId(sessionId)) { + return NextResponse.json({ error: "Invalid terminal session id." }, { status: 400 }); + } + + const session = getTerminalSession(sessionId); + if (!session) { + return NextResponse.json({ error: "Terminal session not found." }, { status: 404 }); + } + + return NextResponse.json({ session }); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ sessionId: string }> } +) { + try { + const { sessionId } = await params; + if (!isValidSessionId(sessionId)) { + return NextResponse.json({ error: "Invalid terminal session id." }, { status: 400 }); + } + + const payload = terminalInputSchema.parse(await request.json()); + const session = await writeTerminalInput(sessionId, payload.input, { + echoInput: payload.echoInput + }); + + return NextResponse.json({ session }); + } catch (error) { + const status = + error instanceof z.ZodError + ? 400 + : error instanceof Error && /not found/i.test(error.message) + ? 404 + : 400; + const message = error instanceof Error ? error.message : "Failed to write terminal input."; + return NextResponse.json({ error: message }, { status }); + } +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ sessionId: string }> } +) { + const { sessionId } = await params; + if (!isValidSessionId(sessionId)) { + return NextResponse.json({ error: "Invalid terminal session id." }, { status: 400 }); + } + + const deleted = closeTerminalSession(sessionId); + if (!deleted) { + return NextResponse.json({ error: "Terminal session not found." }, { status: 404 }); + } + + return NextResponse.json({ deleted: true }); +} diff --git a/src/app/api/terminal/sessions/route.test.ts b/src/app/api/terminal/sessions/route.test.ts new file mode 100644 index 0000000..3056aa1 --- /dev/null +++ b/src/app/api/terminal/sessions/route.test.ts @@ -0,0 +1,57 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { GET, POST } from "@/app/api/terminal/sessions/route"; +import { shutdownAllTerminalSessions } from "@/lib/server/terminal-sessions"; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "codeflow-terminal-sessions-")); + process.env.CODEFLOW_TERMINAL_SHELL = "/bin/sh"; +}); + +afterEach(async () => { + shutdownAllTerminalSessions(); + delete process.env.CODEFLOW_TERMINAL_SHELL; + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe("GET /api/terminal/sessions", () => { + it("lists terminal sessions after one is created", async () => { + const createResponse = await POST( + new Request("http://localhost/api/terminal/sessions", { + method: "POST", + headers: { + "content-type": "application/json", + "x-codeflow-repo-path": tmpDir + }, + body: JSON.stringify({ title: "Workspace shell" }) + }) + ); + const createBody = (await createResponse.json()) as { + session: { id: string; cwd: string; title: string; status: string }; + }; + + expect(createResponse.status).toBe(201); + expect(createBody.session.cwd).toBe(tmpDir); + expect(createBody.session.title).toBe("Workspace shell"); + expect(createBody.session.status).toBe("running"); + + const listResponse = await GET(); + const listBody = (await listResponse.json()) as { + sessions: Array<{ id: string; cwd: string; title: string }>; + }; + + expect(listResponse.status).toBe(200); + expect(listBody.sessions).toHaveLength(1); + expect(listBody.sessions[0]).toMatchObject({ + id: createBody.session.id, + cwd: tmpDir, + title: "Workspace shell" + }); + }); +}); diff --git a/src/app/api/terminal/sessions/route.ts b/src/app/api/terminal/sessions/route.ts new file mode 100644 index 0000000..f1b5e4c --- /dev/null +++ b/src/app/api/terminal/sessions/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { + TERMINAL_REPO_PATH_HEADER, + createTerminalSession, + listTerminalSessions +} from "@/lib/server/terminal-sessions"; + +const createSessionSchema = z.object({ + title: z.string().trim().min(1).max(80).optional() +}); + +export async function GET() { + return NextResponse.json({ + sessions: listTerminalSessions() + }); +} + +export async function POST(request: Request) { + try { + const payload = createSessionSchema.parse( + await request + .json() + .catch(() => ({})) + ); + const repoPath = request.headers.get(TERMINAL_REPO_PATH_HEADER) ?? undefined; + const session = await createTerminalSession({ + cwd: repoPath, + title: payload.title + }); + + return NextResponse.json( + { session }, + { status: 201 } + ); + } catch (error) { + const status = error instanceof z.ZodError ? 400 : 500; + const message = error instanceof Error ? error.message : "Failed to create terminal session."; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 95f008d..8c32cc3 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,3 +1,5 @@ +@import "@xyflow/react/dist/style.css"; + :root { color-scheme: light; --bg: #eef2ff; @@ -3449,6 +3451,9 @@ select:focus { } .workbench-shell { + display: flex; + flex-direction: column; + height: 100vh; padding: 1rem; } @@ -3607,7 +3612,9 @@ select:focus { } .workbench-main { - min-height: calc(100vh - 8rem); + flex: 1 1 auto; + min-height: 0; + overflow: hidden; padding-top: 0; } @@ -4894,14 +4901,16 @@ select:focus { /* IDE shell */ .workbench-main-ide { - min-height: calc(100vh - 8.75rem); + height: 100%; + min-height: 0; } .ide-layout-shell { display: grid; grid-template-columns: 16.5rem minmax(0, 1fr) 22rem; gap: 0.9rem; - min-height: calc(100vh - 10rem); + height: 100%; + min-height: 0; align-items: stretch; } @@ -4925,7 +4934,7 @@ select:focus { .ide-main-stack { display: grid; - grid-template-rows: minmax(0, 1fr) 18rem; + grid-template-rows: minmax(0, 1fr) clamp(15.5rem, 25vh, 19rem); gap: 0.9rem; min-height: 0; } @@ -4943,6 +4952,7 @@ select:focus { } .ide-bottom-panel { + min-height: clamp(15.5rem, 25vh, 19rem); overflow: hidden; border-radius: 24px; border: 1px solid rgba(148, 163, 184, 0.14); @@ -5001,6 +5011,23 @@ select:focus { gap: 0.5rem; } +.numeric-stepper { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 0.55rem; + align-items: center; +} + +.numeric-stepper button { + min-width: 2.75rem; + padding-inline: 0.85rem; +} + +.numeric-stepper input { + text-align: center; + font-family: "IBM Plex Mono", "SFMono-Regular", monospace; +} + .prompt-dock-sidebar { width: 100%; padding: 0.95rem; @@ -5442,7 +5469,22 @@ select:focus { } @media (max-width: 960px) { + .workbench-shell { + height: auto; + min-height: 100vh; + overflow: auto; + } + + .workbench-main { + overflow: visible; + } + + .workbench-main-ide { + height: auto; + } + .ide-layout-shell { + height: auto; grid-template-columns: 1fr; } @@ -5460,3 +5502,68 @@ select:focus { height: calc(100% - 5rem); } } + +/* ── OpenCode Integration Styles ───────────────────────────────────────────── */ + +.opencode-status-mini { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + background: rgba(0, 0, 0, 0.04); + border-radius: 8px; + margin-bottom: 0.75rem; + font-size: 0.85rem; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.status-dot.status-stopped { + background: #888; +} + +.status-dot.status-starting { + background: #f59e0b; + animation: status-pulse 1s ease-in-out infinite; +} + +.status-dot.status-running { + background: #22c55e; +} + +.status-dot.status-error { + background: #ef4444; +} + +@keyframes status-pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} + +.opencode-panel { + width: min(28rem, 95vw); + max-height: 85vh; + overflow-y: auto; +} + +.opencode-panel .field { + margin-bottom: 0.75rem; +} + +.opencode-panel .button-row { + margin-top: 0.75rem; +} + +.opencode-panel button.primary { + background: var(--accent); + color: white; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 400ad97..08434b2 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,4 @@ import type { Metadata } from "next"; -import "@xyflow/react/dist/style.css"; import "@/app/globals.css"; diff --git a/src/components/blueprint-workbench.test.tsx b/src/components/blueprint-workbench.test.tsx index becfe29..d3d6ad3 100644 --- a/src/components/blueprint-workbench.test.tsx +++ b/src/components/blueprint-workbench.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { BlueprintWorkbench } from "@/components/blueprint-workbench"; @@ -284,6 +284,17 @@ describe("BlueprintWorkbench", () => { return; } + if (input === "/api/coderag" && (!init?.method || init.method === "GET")) { + resolve({ + ok: true, + json: async () => ({ + status: "not_initialized", + message: "Build or export a repo-backed blueprint to initialize CodeRAG for this workspace." + }) + }); + return; + } + resolveBuild = resolve; }) ); @@ -372,6 +383,143 @@ describe("BlueprintWorkbench", () => { expect(screen.getByText(/Built 1 nodes, 0 edges, and 0 workflows/)).toBeInTheDocument(); }); + it("surfaces CodeRAG as a repo dock after a repo-backed build", async () => { + const repoPath = "/tmp/sample-repo"; + let codeRagReady = false; + + fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { + if (input === "/api/generate-blueprint" && (!init?.method || init.method === "GET")) { + return { + ok: true, + json: async () => ({ + serverApiKeyConfigured: false + }) + }; + } + + if (input === "/api/files/list") { + return { + ok: true, + json: async () => [] + }; + } + + if (input === "/api/coderag" && (!init?.method || init.method === "GET")) { + return { + ok: true, + json: async () => + codeRagReady + ? { + status: "ready", + details: { + indexedNodeCount: 12, + storageRoot: "/tmp/coderag" + } + } + : { + status: "not_initialized", + message: "Build or export a repo-backed blueprint to initialize CodeRAG for this workspace." + } + }; + } + + if (input === "/api/blueprint") { + codeRagReady = true; + + return { + ok: true, + json: async () => ({ + graph: { + projectName: "Workbench", + mode: "essential", + generatedAt: "2026-03-13T00:00:00.000Z", + warnings: [], + workflows: [], + edges: [], + nodes: [ + { + id: "function:verify-token", + kind: "function", + name: "verifyToken", + summary: "Verify and decode a JWT.", + contract: { + summary: "Verify and decode a JWT.", + inputs: [], + outputs: [], + sideEffects: [], + errors: [], + dependencies: [], + uiAccess: [], + backendAccess: [], + notes: [] + }, + sourceRefs: [], + generatedRefs: [], + traceRefs: [] + } + ] + }, + runPlan: { + generatedAt: "2026-03-13T00:00:00.000Z", + tasks: [], + batches: [], + warnings: [] + }, + session: { + sessionId: "session-coderag", + projectName: "Workbench", + updatedAt: "2026-03-13T00:00:00.000Z", + repoPath, + graph: { + projectName: "Workbench", + mode: "essential", + generatedAt: "2026-03-13T00:00:00.000Z", + warnings: [], + workflows: [], + edges: [], + nodes: [] + }, + runPlan: { + generatedAt: "2026-03-13T00:00:00.000Z", + tasks: [], + batches: [], + warnings: [] + }, + approvalIds: [] + } + }) + }; + } + + return { + ok: true, + json: async () => ({}) + }; + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + fireEvent.click(screen.getByLabelText("PRD / Repo (JS/TS)")); + fireEvent.change(screen.getByPlaceholderText("/absolute/path/to/repo"), { + target: { value: repoPath } + }); + fireEvent.click(screen.getByRole("button", { name: "Build blueprint" })); + + expect(await screen.findByRole("button", { name: "verifyToken" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: /repo/i })); + const repoDock = screen.getByRole("tabpanel"); + + expect(within(repoDock).getByRole("button", { name: "Search repo" })).toBeInTheDocument(); + expect(within(repoDock).getAllByText(/Indexed 12 nodes/).length).toBeGreaterThan(0); + expect( + within(repoDock).getByText(/Suggest code, Implement node, and Monaco completions share this retrieval index/) + ).toBeInTheDocument(); + expect(within(repoDock).getByText(/\/tmp\/sample-repo/)).toBeInTheDocument(); + }); + it("shows an actionable message when the local server is unreachable during blueprint build", async () => { fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { if (input === "/api/generate-blueprint" && (!init?.method || init.method === "GET")) { diff --git a/src/components/blueprint-workbench.tsx b/src/components/blueprint-workbench.tsx index d2f661a..6496d43 100644 --- a/src/components/blueprint-workbench.tsx +++ b/src/components/blueprint-workbench.tsx @@ -14,6 +14,7 @@ import { FileTabs } from "@/components/file-tabs"; import { FileTree } from "@/components/file-tree"; import { GraphCanvas } from "@/components/graph-canvas"; import { IdeLayout } from "@/components/ide-layout"; +import { OpencodeSettings } from "@/components/opencode-settings"; import { generateNodeCode, getNodeStubPath, isCodeBearingNode } from "@/lib/blueprint/codegen"; import { addEdgeToGraph, addNodeToGraph, deleteNodeFromGraph } from "@/lib/blueprint/edit"; import { buildDetailFlow, indexRuntimeExecutionResult } from "@/lib/blueprint/flow-view"; @@ -63,6 +64,7 @@ import type { VcrRecording } from "@/lib/blueprint/schema"; import { emptyContract, traceSpanSchema } from "@/lib/blueprint/schema"; +import type { OpencodeServerInfo } from "@/lib/opencode/types"; type BuildResponse = { graph?: BlueprintGraph; @@ -220,7 +222,7 @@ type RefactorHealResponse = { }; type StatusTone = "info" | "success" | "danger"; -type IdeDockTab = "terminal" | "heatmap" | "vcr" | "traces" | "problems"; +type IdeDockTab = "terminal" | "repo" | "heatmap" | "vcr" | "traces" | "problems"; type ActivityEntryTone = "info" | "success" | "error" | "command"; type ActivityEntry = { id: string; @@ -274,6 +276,11 @@ const normalizeContract = (contract: Partial) => ({ ...contract }); +const formatIndexedNodeCount = (value: number) => + `Indexed ${value} node${value === 1 ? "" : "s"}.`; + +const clampCodeRagDepth = (value: number) => Math.max(1, Math.min(6, Math.floor(value))); + type ToolbarMenuKey = "brand" | "build" | "view" | "tools" | null; function ToolbarMenu({ @@ -371,6 +378,7 @@ export function BlueprintWorkbench() { const [activityFeed, setActivityFeed] = useState([]); const [codeRagStatus, setCodeRagStatus] = useState<"checking" | "ready" | "not_initialized" | "error">("checking"); const [codeRagMessage, setCodeRagMessage] = useState("Checking CodeRAG status..."); + const [codeRagDetails, setCodeRagDetails] = useState | null>(null); const [codeRagQuery, setCodeRagQuery] = useState(""); const [codeRagDepth, setCodeRagDepth] = useState(2); const [codeRagResult, setCodeRagResult] = useState(null); @@ -459,6 +467,11 @@ export function BlueprintWorkbench() { const [editorRevealTarget, setEditorRevealTarget] = useState>(null); const [navigationError, setNavigationError] = useState(null); + // ── OpenCode Integration ────────────────────────────────────────────── + const [showOpencodePanel, setShowOpencodePanel] = useState(false); + const [opencodeStatus, setOpencodeStatus] = useState({ status: "stopped" }); + const [useOpencodeForAgent, setUseOpencodeForAgent] = useState(false); + const selectedNode = graph?.nodes.find((node) => node.id === selectedNodeId) ?? null; const drilldownNodeId = drilldownStack.at(-1) ?? null; const drilldownRootNode = graph?.nodes.find((node) => node.id === drilldownNodeId) ?? null; @@ -514,6 +527,19 @@ export function BlueprintWorkbench() { const verifiedNodeCount = graph?.nodes.filter((node) => isCodeBearingNode(node) && node.status === "verified").length ?? 0; const allCodeNodesVerified = codeBearingNodeCount > 0 && verifiedNodeCount === codeBearingNodeCount; + const codeRagIndexedNodeCount = + typeof codeRagDetails?.indexedNodeCount === "number" ? codeRagDetails.indexedNodeCount : null; + const codeRagStorageRoot = + typeof codeRagDetails?.storageRoot === "string" ? codeRagDetails.storageRoot : null; + const codeRagRepoTarget = repoPath?.trim() || session?.repoPath?.trim() || null; + const codeRagIntegrationSummary = + "Search repo, Suggest code, Implement node, and Monaco completions share this retrieval index."; + const codeRagIndexSummary = + codeRagIndexedNodeCount !== null + ? `${formatIndexedNodeCount(codeRagIndexedNodeCount)}${codeRagRepoTarget ? ` Source: ${codeRagRepoTarget}.` : ""}` + : codeRagRepoTarget + ? `Repo target: ${codeRagRepoTarget}.` + : null; const activeMascotScene = useMemo(() => { if (showGeneticPanel || tournamentResult) { return "genetic" as const; @@ -625,23 +651,37 @@ export function BlueprintWorkbench() { const response = await fetch("/api/coderag"); const body = (await response.json()) as CodeRagStatusResponse; const nextStatus = response.ok ? body.status ?? "error" : "error"; - const nextMessage = body.message ?? body.error ?? "Failed to load CodeRAG status."; + const nextIndexedNodeCount = + typeof body.details?.indexedNodeCount === "number" ? body.details.indexedNodeCount : null; + const nextMessage = + nextStatus === "ready" + ? nextIndexedNodeCount !== null + ? `CodeRAG ready. ${formatIndexedNodeCount(nextIndexedNodeCount)}` + : "CodeRAG ready for repo search and coding assistance." + : body.message ?? body.error ?? "Failed to load CodeRAG status."; setCodeRagStatus(nextStatus); setCodeRagMessage(nextMessage); + setCodeRagDetails(body.details ?? null); if (nextStatus !== "ready") { setCodeRagResult(null); } setCodeRagError(null); if (announce) { - pushActivity("CodeRAG", nextMessage, nextStatus === "ready" ? "success" : "info"); + pushActivity( + "CodeRAG", + nextMessage, + nextStatus === "ready" ? "success" : "info", + nextStatus === "ready" ? codeRagIntegrationSummary : undefined + ); } } catch (caughtError) { const message = caughtError instanceof Error ? caughtError.message : "Failed to load CodeRAG status."; setCodeRagStatus("error"); setCodeRagMessage(message); + setCodeRagDetails(null); setCodeRagError(message); if (announce) { @@ -649,7 +689,30 @@ export function BlueprintWorkbench() { } } }, - [pushActivity] + [codeRagIntegrationSummary, pushActivity] + ); + const setCodeRagDepthFromValue = useCallback((value: number) => { + if (!Number.isFinite(value)) { + return; + } + + setCodeRagDepth(clampCodeRagDepth(value)); + }, []); + const handleCodeRagDepthInput = useCallback( + (rawValue: string) => { + if (!rawValue.trim()) { + return; + } + + setCodeRagDepthFromValue(Number(rawValue)); + }, + [setCodeRagDepthFromValue] + ); + const adjustCodeRagDepth = useCallback( + (delta: number) => { + setCodeRagDepth((current) => clampCodeRagDepth(current + delta)); + }, + [] ); const apiKeyStatus = nvidiaApiKey.trim() ? `Browser session key active (${maskApiKey(nvidiaApiKey)}).` @@ -1265,7 +1328,7 @@ export function BlueprintWorkbench() { setShowPromptPanel(false); setExportResult(null); setGhostSuggestions([]); - void refreshCodeRagStatus(); + await refreshCodeRagStatus(Boolean("repoPath" in payload && payload.repoPath)); pushActivity( useAI ? "NVIDIA" : "Blueprint", `Blueprint ready with ${body.graph.nodes.length} nodes and ${body.graph.edges.length} edges.`, @@ -1382,7 +1445,7 @@ export function BlueprintWorkbench() { setRunPlan(body.runPlan); setSession(body.session); setExportResult(body.result); - void refreshCodeRagStatus(); + await refreshCodeRagStatus(Boolean(body.session.repoPath ?? repoPath?.trim())); pushActivity("Export", `Artifacts written to ${body.result.rootDir}.`, "success"); } catch (caughtError) { pushActivity( @@ -1450,7 +1513,8 @@ export function BlueprintWorkbench() { currentCode, retrievalQuery: codeRagQuery.trim() || undefined, retrievalDepth: codeRagDepth, - nvidiaApiKey: nvidiaApiKey.trim() || undefined + nvidiaApiKey: nvidiaApiKey.trim() || undefined, + useOpencode: useOpencodeForAgent || opencodeStatus.status === "running" }) }); const body = (await response.json()) as ImplementNodeResponse; @@ -1821,6 +1885,18 @@ export function BlueprintWorkbench() { [openFiles, pushActivity, setActiveFile, setOpenFiles] ); + const openCodeRagContext = useCallback( + (node: RetrievedNodeContext) => + handleOpenFile(node.filePath, { + filePath: node.filePath, + lineNumber: node.startLine, + endLineNumber: node.endLine, + columnStart: 1, + symbolName: node.name + }), + [handleOpenFile] + ); + const handleOpenNodeSource = useCallback( (nodeId: string) => { if (!graph) { @@ -3201,6 +3277,150 @@ export function BlueprintWorkbench() { ); + const renderCodeRagWorkspace = (placement: "sidebar" | "dock" = "sidebar") => { + const isDock = placement === "dock"; + const codeRagPrimaryNode = codeRagResult?.context.primaryNode ?? null; + const codeRagRelatedNodes = codeRagResult?.context.relatedNodes ?? []; + + return ( +
+
+
+

CodeRAG

+

{isDock ? "Repo retrieval workspace" : "Repo context"}

+
+ +
+

{codeRagMessage}

+ {codeRagIndexSummary ?

{codeRagIndexSummary}

: null} +

{codeRagIntegrationSummary}

+ {codeRagStorageRoot ?

Index root: {codeRagStorageRoot}

: null} +