diff --git a/.changeset/provider-capabilities-contract.md b/.changeset/provider-capabilities-contract.md new file mode 100644 index 000000000..405560f21 --- /dev/null +++ b/.changeset/provider-capabilities-contract.md @@ -0,0 +1,5 @@ +--- +"helmor": patch +--- + +Replace scattered `provider === "codex"` / `provider === "cursor"` checks with a data-driven provider-capability table exposed through a new `list_provider_capabilities` command, so adding a new provider becomes a single matrix edit instead of a codebase-wide grep. diff --git a/sidecar/bun.lock b/sidecar/bun.lock index 6ca092c03..9b01b01f8 100644 --- a/sidecar/bun.lock +++ b/sidecar/bun.lock @@ -5,6 +5,7 @@ "": { "name": "@helmor/sidecar", "dependencies": { + "@agentclientprotocol/sdk": "^0.21.1", "@anthropic-ai/claude-agent-sdk": "0.2.139", "@anthropic-ai/claude-code": "2.1.139", "@cursor/sdk": "^1.0.12", @@ -20,6 +21,8 @@ "@anthropic-ai/claude-code", ], "packages": { + "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.21.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ZTLH+o9QxcZDLX/9ww+W7C2iExnXFM+vD/uGFVSlR61Kzj9FaxUqBC6Rv/kwgA7qVWYUEI9c5ZNqCuO9PM4rKg=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.139", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.139", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.139", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.139", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.139", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.139", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.139", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.139", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.139" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-9zmitYoxCQiQZsTUbm9IGC6VyZt70J3NLtkRQPQvFVfz7bKDrhlZZKzXmyl2XmqedXEIeQy2ACmwdjwzPIVIAw=="], "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.139", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dnuO2E0x6o9GAk9iZZKlEd10h+0PQFdTfr5aQU4I0W+0ReKsFEoE9LAqfomS2EvLUQ9L62X0+n0iyZQmAVi1kw=="], diff --git a/sidecar/package.json b/sidecar/package.json index aa5ec1316..0a1b8ab82 100644 --- a/sidecar/package.json +++ b/sidecar/package.json @@ -13,6 +13,7 @@ "typecheck": "bunx tsc --noEmit" }, "dependencies": { + "@agentclientprotocol/sdk": "^0.21.1", "@anthropic-ai/claude-agent-sdk": "0.2.139", "@anthropic-ai/claude-code": "2.1.139", "@cursor/sdk": "^1.0.12", diff --git a/sidecar/src/copilot-session-manager.ts b/sidecar/src/copilot-session-manager.ts new file mode 100644 index 000000000..2f4839cd2 --- /dev/null +++ b/sidecar/src/copilot-session-manager.ts @@ -0,0 +1,701 @@ +/** + * SessionManager backed by GitHub Copilot CLI in ACP mode. + * + * Spawns `copilot --acp` as a child process per session, communicates + * via newline-delimited JSON-RPC 2.0 on stdin/stdout (the ACP transport). + * Streaming SessionUpdate notifications are forwarded as `copilot/`-prefixed + * passthrough events so the Rust accumulator can handle them uniformly. + */ + +import { + type ChildProcessWithoutNullStreams, + execFile, + spawn, +} from "node:child_process"; +import { existsSync } from "node:fs"; +import { createInterface } from "node:readline"; +import { promisify } from "node:util"; +import type { SidecarEmitter } from "./emitter.js"; +import { errorDetails, logger } from "./logger.js"; +import { listProviderModels } from "./model-catalog.js"; +import type { + GenerateTitleOptions, + ListSlashCommandsParams, + ProviderModelInfo, + SendMessageParams, + SessionManager, + SlashCommandInfo, + UserInputResolution, +} from "./session-manager.js"; + +function resolveCopilotBinPath(): string { + const override = process.env.HELMOR_COPILOT_BIN_PATH; + if (override && existsSync(override)) return override; + return "copilot"; +} + +const COPILOT_BIN_PATH = resolveCopilotBinPath(); + +interface AcpPendingRequest { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timeout: ReturnType; +} + +interface CopilotSession { + child: ChildProcessWithoutNullStreams; + sessionId: string | null; + pendingRequests: Map; + nextId: number; + activeRequestId: string | null; + activeEmitter: SidecarEmitter | null; + aborted: boolean; + modelId: string; +} + +const ACP_REQUEST_TIMEOUT_MS = 30_000; + +export class CopilotSessionManager implements SessionManager { + private sessions = new Map(); + private pendingPermissions = new Map< + string, + { helmorSessionId: string; jsonRpcId: number } + >(); + + resolveUserInput( + _userInputId: string, + _resolution: UserInputResolution, + ): boolean { + return false; + } + + resolvePermission(permissionId: string, behavior: "allow" | "deny"): void { + const pending = this.pendingPermissions.get(permissionId); + if (!pending) return; + this.pendingPermissions.delete(permissionId); + + const ctx = this.sessions.get(pending.helmorSessionId); + if (!ctx) return; + + const result = { approved: behavior === "allow" }; + this.sendJsonRpcResponse(ctx, pending.jsonRpcId, result); + logger.debug("Copilot permission resolved", { permissionId, behavior }); + } + + async sendMessage( + requestId: string, + params: SendMessageParams, + emitter: SidecarEmitter, + ): Promise { + const { sessionId, prompt, model, cwd, effortLevel } = params; + const workDir = cwd ?? process.cwd(); + const modelId = model ?? "gpt-4o"; + + let ctx = this.sessions.get(sessionId); + if (!ctx) { + try { + ctx = await this.spawnSession(sessionId, workDir, modelId, effortLevel); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.error( + `[${requestId}] Copilot spawn failed: ${msg}`, + errorDetails(error), + ); + emitter.error(requestId, `Copilot: ${msg}`); + emitter.end(requestId); + return; + } + } + + ctx.activeRequestId = requestId; + ctx.activeEmitter = emitter; + ctx.aborted = false; + ctx.modelId = modelId; + + emitter.passthrough(requestId, { + type: "copilot/session_init", + session_id: ctx.sessionId ?? sessionId, + model: modelId, + }); + + try { + emitter.passthrough(requestId, { + type: "copilot/status", + status: "RUNNING", + }); + + await this.sendAcpRequest( + ctx, + "session/prompt", + { + sessionId: ctx.sessionId, + prompt: [{ type: "text", text: prompt }], + }, + 0, + ); + + emitter.passthrough(requestId, { + type: "copilot/status", + status: "FINISHED", + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (ctx.aborted) { + logger.debug(`[${requestId}] Copilot stream aborted by user`); + } else { + logger.error( + `[${requestId}] Copilot prompt failed: ${msg}`, + errorDetails(error), + ); + emitter.error(requestId, `Copilot: ${msg}`); + } + } finally { + ctx.activeRequestId = null; + ctx.activeEmitter = null; + } + + if (ctx.aborted) { + emitter.aborted(requestId, "user_requested"); + } + emitter.end(requestId); + } + + async generateTitle( + _requestId: string, + _userMessage: string, + _branchRenamePrompt: string | null, + _emitter: SidecarEmitter, + _timeoutMs?: number, + _options?: GenerateTitleOptions, + ): Promise { + // Copilot doesn't have a lightweight title-gen path; delegate to + // Claude/Codex via the fallback chain in index.ts by throwing. + throw new Error("Copilot does not support title generation"); + } + + async listSlashCommands( + _params: ListSlashCommandsParams, + ): Promise { + return []; + } + + async listModels(_opts?: { + apiKey?: string; + }): Promise { + try { + const token = await this.getGhToken(); + if (!token) throw new Error("No gh auth token available"); + + const response = await fetch( + "https://api.individual.githubcopilot.com/models", + { + headers: { + Authorization: `Bearer ${token}`, + "Copilot-Integration-Id": "copilot-developer-cli", + }, + signal: AbortSignal.timeout(15_000), + }, + ); + + if (!response.ok) { + throw new Error(`Copilot API returned ${response.status}`); + } + + const body = (await response.json()) as { + data?: Array<{ + id: string; + name: string; + model_picker_enabled?: boolean; + model_picker_category?: string; + capabilities?: { + type?: string; + supports?: { reasoning_effort?: string[] }; + }; + }>; + }; + + const models = (body.data ?? []).filter( + (m) => + m.model_picker_enabled === true && + (m.capabilities?.type === "chat" || !m.capabilities?.type), + ); + + if (models.length > 0) { + return models.map((m) => ({ + id: m.id, + label: m.name, + cliModel: m.id, + effortLevels: + m.capabilities?.supports?.reasoning_effort?.filter( + (e) => e !== "none", + ) ?? [], + })); + } + } catch (err) { + logger.debug( + `Copilot API models fetch failed, using static catalog: ${err instanceof Error ? err.message : String(err)}`, + ); + } + return listProviderModels("copilot"); + } + + private ghTokenCache: { token: string; expiresAt: number } | null = null; + + private async getGhToken(): Promise { + if (this.ghTokenCache && this.ghTokenCache.expiresAt > Date.now()) { + return this.ghTokenCache.token; + } + try { + const ghPath = process.env.HELMOR_GH_BIN_PATH || "gh"; + const result = await promisify(execFile)(ghPath, ["auth", "token"], { + timeout: 5_000, + }); + const token = result.stdout.trim(); + if (token) { + this.ghTokenCache = { + token, + expiresAt: Date.now() + 5 * 60 * 1000, + }; + return token; + } + } catch { + // gh not available or not logged in + } + return null; + } + + async stopSession(sessionId: string): Promise { + const ctx = this.sessions.get(sessionId); + if (!ctx) return; + ctx.aborted = true; + + if (ctx.sessionId) { + try { + this.sendJsonRpcNotification(ctx, "session/cancel", { + sessionId: ctx.sessionId, + }); + } catch { + // best effort + } + } + + ctx.activeRequestId = null; + ctx.activeEmitter = null; + } + + async steer( + _sessionId: string, + _prompt: string, + _files: readonly string[], + _images: readonly string[], + ): Promise { + return false; + } + + async shutdown(): Promise { + for (const [, ctx] of this.sessions) { + try { + ctx.child.kill("SIGTERM"); + } catch { + // already dead + } + } + this.sessions.clear(); + } + + // ── Private helpers ───────────────────────────────────────────────── + + private async spawnSession( + helmorSessionId: string, + cwd: string, + modelId: string, + effortLevel?: string, + ): Promise { + const args = ["--acp"]; + if (effortLevel) { + args.push("--reasoning-effort", effortLevel); + } + const child = spawn(COPILOT_BIN_PATH, args, { + cwd, + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env }, + }); + + const ctx: CopilotSession = { + child, + sessionId: null, + pendingRequests: new Map(), + nextId: 1, + activeRequestId: null, + activeEmitter: null, + aborted: false, + modelId, + }; + + const rl = createInterface({ input: child.stdout }); + rl.on("line", (line) => { + this.handleLine(helmorSessionId, ctx, line); + }); + + child.stderr.on("data", (chunk: Buffer) => { + logger.debug( + `[copilot:${helmorSessionId}] stderr: ${chunk.toString().trim()}`, + ); + }); + + child.on("exit", (code, signal) => { + logger.info(`[copilot:${helmorSessionId}] exited`, { code, signal }); + this.sessions.delete(helmorSessionId); + for (const [, req] of ctx.pendingRequests) { + clearTimeout(req.timeout); + req.reject(new Error(`Copilot process exited (code=${code})`)); + } + ctx.pendingRequests.clear(); + }); + + try { + await this.sendAcpRequest(ctx, "initialize", { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + }, + }); + + const sessionResult = (await this.sendAcpRequest(ctx, "session/new", { + cwd, + mcpServers: [], + })) as { sessionId?: string }; + + ctx.sessionId = sessionResult?.sessionId ?? helmorSessionId; + } catch (err) { + try { + child.kill("SIGTERM"); + } catch { + // already dead + } + throw err; + } + + this.sessions.set(helmorSessionId, ctx); + return ctx; + } + + private handleLine( + helmorSessionId: string, + ctx: CopilotSession, + line: string, + ): void { + if (!line.trim()) return; + + let msg: Record; + try { + msg = JSON.parse(line); + } catch { + logger.debug( + `[copilot:${helmorSessionId}] non-JSON line: ${line.slice(0, 100)}`, + ); + return; + } + + // JSON-RPC response (has `id` + `result` or `error`) + if ("id" in msg && ("result" in msg || "error" in msg)) { + const id = msg.id as number; + const pending = ctx.pendingRequests.get(id); + if (pending) { + ctx.pendingRequests.delete(id); + clearTimeout(pending.timeout); + if ("error" in msg) { + const err = msg.error as { message?: string }; + pending.reject(new Error(err?.message ?? "ACP error")); + } else { + pending.resolve(msg.result); + } + } + return; + } + + // JSON-RPC request from agent (has `id` + `method`) + if ("id" in msg && "method" in msg) { + this.handleAgentRequest(helmorSessionId, ctx, msg); + return; + } + + // JSON-RPC notification (has `method`, no `id`) + if ("method" in msg && !("id" in msg)) { + this.handleNotification(helmorSessionId, ctx, msg); + return; + } + } + + private handleAgentRequest( + helmorSessionId: string, + ctx: CopilotSession, + msg: Record, + ): void { + const method = msg.method as string; + const id = msg.id as number; + const params = (msg.params ?? {}) as Record; + + if (method === "session/request_permission") { + const permissionId = `copilot-${helmorSessionId}-${id}`; + this.pendingPermissions.set(permissionId, { + helmorSessionId, + jsonRpcId: id, + }); + + const toolCall = (params.toolCall ?? {}) as Record; + const toolName = + (toolCall.title as string) ?? (toolCall.kind as string) ?? "tool"; + const toolInput = (toolCall.rawInput as Record) ?? {}; + + if (ctx.activeRequestId && ctx.activeEmitter) { + ctx.activeEmitter.passthrough(ctx.activeRequestId, { + type: "permissionRequest", + permissionId, + toolName, + toolInput, + title: toolName, + description: "", + }); + } + return; + } + + if (method === "fs/read_text_file") { + void this.handleFsReadTextFile(ctx, id, params); + return; + } + + if (method === "fs/write_text_file") { + void this.handleFsWriteTextFile(ctx, id, params); + return; + } + + this.sendJsonRpcResponse(ctx, id, {}); + } + + private async handleFsReadTextFile( + ctx: CopilotSession, + id: number, + params: Record, + ): Promise { + const path = params.path as string; + const line = params.line as number | undefined; + const limit = params.limit as number | undefined; + try { + const fs = await import("node:fs/promises"); + let content = await fs.readFile(path, "utf-8"); + if (line !== undefined || limit !== undefined) { + const lines = content.split("\n"); + const start = line ?? 0; + const end = limit !== undefined ? start + limit : lines.length; + content = lines.slice(start, end).join("\n"); + } + this.sendJsonRpcResponse(ctx, id, { content }); + } catch (err) { + this.sendJsonRpcError(ctx, id, { + code: -32603, + message: err instanceof Error ? err.message : String(err), + }); + } + } + + private async handleFsWriteTextFile( + ctx: CopilotSession, + id: number, + params: Record, + ): Promise { + const path = params.path as string; + const content = params.content as string; + try { + const fs = await import("node:fs/promises"); + await fs.writeFile(path, content, "utf-8"); + this.sendJsonRpcResponse(ctx, id, {}); + } catch (err) { + this.sendJsonRpcError(ctx, id, { + code: -32603, + message: err instanceof Error ? err.message : String(err), + }); + } + } + + private handleNotification( + helmorSessionId: string, + ctx: CopilotSession, + msg: Record, + ): void { + const method = msg.method as string; + const params = (msg.params ?? {}) as Record; + + if (!ctx.activeRequestId || !ctx.activeEmitter) return; + + const requestId = ctx.activeRequestId; + const emitter = ctx.activeEmitter; + + if (method !== "session/update") { + logger.debug( + `[copilot:${helmorSessionId}] unhandled notification: ${method}`, + ); + return; + } + + const update = (params.update ?? {}) as { + sessionUpdate?: string; + [key: string]: unknown; + }; + const variant = update.sessionUpdate ?? "unknown"; + + const extractText = (block: unknown): string => { + if (!block || typeof block !== "object") return ""; + const b = block as { type?: string; text?: unknown }; + if (b.type === "text" && typeof b.text === "string") return b.text; + return ""; + }; + + switch (variant) { + case "agent_message_chunk": + emitter.passthrough(requestId, { + type: "copilot/assistant", + text: extractText(update.content), + }); + break; + case "agent_thought_chunk": + emitter.passthrough(requestId, { + type: "copilot/thinking", + text: extractText(update.content), + }); + break; + case "user_message_chunk": + break; + case "tool_call": { + const callId = + (update.toolCallId as string) ?? + (update.callId as string) ?? + `tc-${Date.now()}`; + emitter.passthrough(requestId, { + type: "copilot/tool_call_start", + call_id: callId, + name: (update.title as string) ?? (update.kind as string) ?? "tool", + args: update.rawInput ?? {}, + }); + const status = update.status as string | undefined; + if (status === "completed" || status === "failed") { + emitter.passthrough(requestId, { + type: "copilot/tool_call_end", + call_id: callId, + result: update.content ?? update.rawOutput ?? null, + is_error: status === "failed", + }); + } + break; + } + case "tool_call_update": { + const callId = + (update.toolCallId as string) ?? (update.callId as string) ?? ""; + const status = update.status as string | undefined; + if (status === "completed" || status === "failed") { + emitter.passthrough(requestId, { + type: "copilot/tool_call_end", + call_id: callId, + result: update.content ?? update.rawOutput ?? null, + is_error: status === "failed", + }); + } else { + emitter.passthrough(requestId, { + type: "copilot/tool_call_update", + call_id: callId, + output: + (update.content as string) ?? (update.rawOutput as string) ?? "", + }); + } + break; + } + case "plan": + emitter.passthrough(requestId, { + type: "copilot/plan", + plan: update.entries ?? update.plan ?? [], + }); + break; + case "available_commands_update": + case "current_mode_update": + break; + default: + logger.debug( + `[copilot:${helmorSessionId}] unhandled session/update variant: ${variant}`, + ); + } + } + + private sendAcpRequest( + ctx: CopilotSession, + method: string, + params: Record, + timeoutMs?: number, + ): Promise { + return new Promise((resolve, reject) => { + const id = ctx.nextId++; + const effectiveTimeout = timeoutMs ?? ACP_REQUEST_TIMEOUT_MS; + const timeout = + effectiveTimeout > 0 + ? setTimeout(() => { + ctx.pendingRequests.delete(id); + reject(new Error(`ACP request '${method}' timed out`)); + }, effectiveTimeout) + : null; + + ctx.pendingRequests.set(id, { + resolve, + reject, + timeout: timeout as ReturnType, + }); + + const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params }); + try { + ctx.child.stdin.write(`${msg}\n`); + } catch (err) { + ctx.pendingRequests.delete(id); + if (timeout) clearTimeout(timeout); + reject(err instanceof Error ? err : new Error(String(err))); + } + }); + } + + private sendJsonRpcResponse( + ctx: CopilotSession, + id: number, + result: unknown, + ): void { + const msg = JSON.stringify({ jsonrpc: "2.0", id, result }); + try { + ctx.child.stdin.write(`${msg}\n`); + } catch { + // pipe closed + } + } + + private sendJsonRpcError( + ctx: CopilotSession, + id: number, + error: { code: number; message: string }, + ): void { + const msg = JSON.stringify({ jsonrpc: "2.0", id, error }); + try { + ctx.child.stdin.write(`${msg}\n`); + } catch { + // pipe closed + } + } + + private sendJsonRpcNotification( + ctx: CopilotSession, + method: string, + params: Record, + ): void { + const msg = JSON.stringify({ jsonrpc: "2.0", method, params }); + try { + ctx.child.stdin.write(`${msg}\n`); + } catch { + // pipe closed + } + } +} diff --git a/sidecar/src/index.ts b/sidecar/src/index.ts index 6214ec604..5c128c8cc 100644 --- a/sidecar/src/index.ts +++ b/sidecar/src/index.ts @@ -13,6 +13,7 @@ import type { PermissionUpdate } from "@anthropic-ai/claude-agent-sdk"; import { isAbortError } from "./abort.js"; import { ClaudeSessionManager } from "./claude-session-manager.js"; import { CodexAppServerManager } from "./codex-app-server-manager.js"; +import { CopilotSessionManager } from "./copilot-session-manager.js"; import { CursorSessionManager } from "./cursor-session-manager.js"; import { createSidecarEmitter } from "./emitter.js"; import { errorDetails, logger } from "./logger.js"; @@ -42,10 +43,12 @@ import { const claudeManager = new ClaudeSessionManager(); const codexManager = new CodexAppServerManager(); +const copilotManager = new CopilotSessionManager(); const cursorManager = new CursorSessionManager(); const managers: Record = { claude: claudeManager, codex: codexManager, + copilot: copilotManager, cursor: cursorManager, }; diff --git a/sidecar/src/model-catalog.ts b/sidecar/src/model-catalog.ts index d68320368..1d92f1ec3 100644 --- a/sidecar/src/model-catalog.ts +++ b/sidecar/src/model-catalog.ts @@ -75,6 +75,101 @@ const MODEL_CATALOG: Record = { supportsFastMode: true, }, ], + // Static fallback — the dynamic fetch via Copilot API is the real + // source of truth. This list covers picker-enabled models as of + // May 2026 so the picker has something to show before the first fetch. + copilot: [ + { + id: "claude-opus-4.7", + label: "Claude Opus 4.7", + cliModel: "claude-opus-4.7", + effortLevels: ["medium"], + }, + { + id: "claude-sonnet-4.6", + label: "Claude Sonnet 4.6", + cliModel: "claude-sonnet-4.6", + effortLevels: ["low", "medium", "high"], + }, + { + id: "claude-sonnet-4.5", + label: "Claude Sonnet 4.5", + cliModel: "claude-sonnet-4.5", + effortLevels: [], + }, + { + id: "claude-opus-4.5", + label: "Claude Opus 4.5", + cliModel: "claude-opus-4.5", + effortLevels: [], + }, + { + id: "claude-haiku-4.5", + label: "Claude Haiku 4.5", + cliModel: "claude-haiku-4.5", + effortLevels: [], + }, + { + id: "gemini-2.5-pro", + label: "Gemini 2.5 Pro", + cliModel: "gemini-2.5-pro", + effortLevels: [], + }, + { + id: "gpt-5.5", + label: "GPT-5.5", + cliModel: "gpt-5.5", + effortLevels: ["low", "medium", "high", "xhigh"], + }, + { + id: "gpt-5.4", + label: "GPT-5.4", + cliModel: "gpt-5.4", + effortLevels: ["low", "medium", "high", "xhigh"], + }, + { + id: "gpt-5.4-mini", + label: "GPT-5.4 mini", + cliModel: "gpt-5.4-mini", + effortLevels: ["low", "medium", "high", "xhigh"], + }, + { + id: "gpt-5.3-codex", + label: "GPT-5.3-Codex", + cliModel: "gpt-5.3-codex", + effortLevels: ["low", "medium", "high", "xhigh"], + }, + { + id: "gpt-5.2-codex", + label: "GPT-5.2-Codex", + cliModel: "gpt-5.2-codex", + effortLevels: ["low", "medium", "high", "xhigh"], + }, + { + id: "gpt-5.2", + label: "GPT-5.2", + cliModel: "gpt-5.2", + effortLevels: ["low", "medium", "high", "xhigh"], + }, + { + id: "gpt-5-mini", + label: "GPT-5 mini", + cliModel: "gpt-5-mini", + effortLevels: ["low", "medium", "high"], + }, + { + id: "gpt-4.1", + label: "GPT-4.1", + cliModel: "gpt-4.1", + effortLevels: [], + }, + { + id: "gpt-4o", + label: "GPT-4o", + cliModel: "gpt-4o", + effortLevels: [], + }, + ], // Static fallback only — `CursorSessionManager.listModels` hits the live // `Cursor.models.list` API for the full set with up-to-date capability // metadata. This list is what shows when the API key isn't configured diff --git a/sidecar/src/request-parser.ts b/sidecar/src/request-parser.ts index 986cb3ba2..ec081343d 100644 --- a/sidecar/src/request-parser.ts +++ b/sidecar/src/request-parser.ts @@ -83,7 +83,12 @@ export function optionalObject( } export function parseProvider(value: unknown): Provider { - if (value === "claude" || value === "codex" || value === "cursor") + if ( + value === "claude" || + value === "codex" || + value === "cursor" || + value === "copilot" + ) return value; throw new Error(`unknown provider: ${String(value)}`); } diff --git a/sidecar/src/session-manager.ts b/sidecar/src/session-manager.ts index cd19db7f5..398bad7d2 100644 --- a/sidecar/src/session-manager.ts +++ b/sidecar/src/session-manager.ts @@ -7,7 +7,7 @@ import type { SidecarEmitter } from "./emitter.js"; -export type Provider = "claude" | "codex" | "cursor"; +export type Provider = "claude" | "codex" | "cursor" | "copilot"; export interface SendMessageParams { readonly sessionId: string; diff --git a/src-tauri/src/agents.rs b/src-tauri/src/agents.rs index 0e4a3d003..84e318d1c 100644 --- a/src-tauri/src/agents.rs +++ b/src-tauri/src/agents.rs @@ -13,6 +13,7 @@ mod catalog; pub(crate) mod claude_project_files; mod custom_providers; mod persistence; +pub mod provider_capabilities; mod queries; mod slash_commands; pub(crate) mod streaming; @@ -205,6 +206,20 @@ pub async fn list_agent_model_sections() -> CmdResult> { Ok(queries::fetch_agent_model_sections()) } +/// Return the provider-capability table for every provider Helmor +/// ships today. Static — no DB hit, no IPC fan-out — so callers are +/// expected to cache the result for the lifetime of the app. Drives +/// the composer's feature-flag branches (active-goal interception, +/// permission-mode dropdown, etc.). +#[tauri::command] +pub async fn list_provider_capabilities( +) -> CmdResult> { + Ok(provider_capabilities::KNOWN_PROVIDERS + .iter() + .map(|p| provider_capabilities::capabilities_for_provider(p)) + .collect()) +} + #[tauri::command] pub async fn list_cursor_models( sidecar: tauri::State<'_, crate::sidecar::ManagedSidecar>, @@ -214,6 +229,13 @@ pub async fn list_cursor_models( queries::fetch_cursor_models(sidecar.inner(), api_key) } +#[tauri::command] +pub async fn list_copilot_models( + sidecar: tauri::State<'_, crate::sidecar::ManagedSidecar>, +) -> CmdResult> { + queries::fetch_copilot_models(sidecar.inner()) +} + #[tauri::command] pub async fn send_agent_message_stream( app: AppHandle, diff --git a/src-tauri/src/agents/catalog.rs b/src-tauri/src/agents/catalog.rs index a0077220e..26ff2c300 100644 --- a/src-tauri/src/agents/catalog.rs +++ b/src-tauri/src/agents/catalog.rs @@ -56,6 +56,7 @@ fn model_sections_for_inputs( .extend(custom_provider_options(custom)); let mut sections = vec![claude_section]; sections.push(codex_section()); + sections.push(copilot_section()); sections.push(cursor_section_from_prefs(cursor_prefs)); sections @@ -101,6 +102,47 @@ fn codex_section() -> AgentModelSection { } } +fn copilot_section() -> AgentModelSection { + AgentModelSection { + id: "copilot".to_string(), + label: "Copilot".to_string(), + status: AgentModelSectionStatus::Ready, + options: vec![ + copilot_model_with_effort("claude-opus-4.7", "Claude Opus 4.7", &["medium"]), + copilot_model_with_effort( + "claude-sonnet-4.6", + "Claude Sonnet 4.6", + &["low", "medium", "high"], + ), + copilot_model_with_effort("claude-sonnet-4.5", "Claude Sonnet 4.5", &[]), + copilot_model_with_effort("claude-opus-4.5", "Claude Opus 4.5", &[]), + copilot_model_with_effort("claude-haiku-4.5", "Claude Haiku 4.5", &[]), + copilot_model_with_effort("gemini-2.5-pro", "Gemini 2.5 Pro", &[]), + copilot_model_with_effort("gpt-5.5", "GPT-5.5", &["low", "medium", "high", "xhigh"]), + copilot_model_with_effort("gpt-5.4", "GPT-5.4", &["low", "medium", "high", "xhigh"]), + copilot_model_with_effort( + "gpt-5.4-mini", + "GPT-5.4 mini", + &["low", "medium", "high", "xhigh"], + ), + copilot_model_with_effort( + "gpt-5.3-codex", + "GPT-5.3-Codex", + &["low", "medium", "high", "xhigh"], + ), + copilot_model_with_effort( + "gpt-5.2-codex", + "GPT-5.2-Codex", + &["low", "medium", "high", "xhigh"], + ), + copilot_model_with_effort("gpt-5.2", "GPT-5.2", &["low", "medium", "high", "xhigh"]), + copilot_model_with_effort("gpt-5-mini", "GPT-5 mini", &["low", "medium", "high"]), + copilot_model_with_effort("gpt-4.1", "GPT-4.1", &[]), + copilot_model_with_effort("gpt-4o", "GPT-4o", &[]), + ], + } +} + /// Cursor picker section, driven by `app.cursor_provider` settings: /// `enabledModelIds` (user picks; `null` → auto-fill on next fetch) and /// `cachedModels` (last `Cursor.models.list` snapshot). When both are @@ -322,6 +364,39 @@ fn codex_model(id: &str, label: &str) -> AgentModelOption { } } +#[allow(dead_code)] +fn copilot_model(wire_id: &str, label: &str) -> AgentModelOption { + copilot_model_with_effort(wire_id, label, &["low", "medium", "high", "xhigh"]) +} + +fn copilot_model_with_effort( + wire_id: &str, + label: &str, + effort_levels: &[&str], +) -> AgentModelOption { + AgentModelOption { + id: namespaced_copilot_id(wire_id), + provider: "copilot".to_string(), + label: label.to_string(), + cli_model: wire_id.to_string(), + provider_key: None, + effort_levels: effort_levels + .iter() + .map(|level| level.to_string()) + .collect(), + supports_fast_mode: false, + supports_context_usage: false, + } +} + +fn namespaced_copilot_id(wire_id: &str) -> String { + if wire_id.starts_with("copilot-") { + wire_id.to_string() + } else { + format!("copilot-{wire_id}") + } +} + /// Build a Cursor option. Cursor wire ids collide with claude/codex /// (e.g. `default` = Claude Opus), so Helmor `id` is namespaced /// `cursor-`; `cli_model` keeps the bare wire id for `agent.send`. @@ -395,18 +470,25 @@ pub fn resolve_model(model_id: &str, provider_hint: Option<&str>) -> ResolvedMod Some("cursor") => "cursor", Some("codex") => "codex", Some("claude") => "claude", + Some("copilot") => "copilot", + _ if model_id.starts_with("copilot-") => "copilot", _ if model_id.starts_with("cursor-") => "cursor", _ if model_id.starts_with("composer-") => "cursor", _ if model_id.starts_with("gpt-") => "codex", _ => "claude", }; - // Strip `cursor-` for SDK; `composer-*` had no prefix. + // Strip namespace prefix for SDK; bare wire id is what the CLI expects. let cli_model = if provider == "cursor" { model_id .strip_prefix("cursor-") .unwrap_or(model_id) .to_string() + } else if provider == "copilot" { + model_id + .strip_prefix("copilot-") + .unwrap_or(model_id) + .to_string() } else { model_id.to_string() }; @@ -430,7 +512,7 @@ mod tests { // `None` cursor_prefs → cursor section degrades to just Auto. let sections = model_sections_for_inputs(Vec::new(), None); - assert_eq!(sections.len(), 3); + assert_eq!(sections.len(), 4); assert_eq!(sections[0].id, "claude"); assert_eq!(sections[0].status, AgentModelSectionStatus::Ready); assert_eq!( @@ -468,17 +550,40 @@ mod tests { .iter() .all(|model| model.supports_fast_mode)); - assert_eq!(sections[2].id, "cursor"); + assert_eq!(sections[2].id, "copilot"); assert_eq!(sections[2].status, AgentModelSectionStatus::Ready); - // Without an `app.cursor_provider` row in the test DB, the Cursor - // section degrades to the hard fallback: a single Auto entry. - // Helmor id is the namespaced `cursor-default`; cli_model is the - // bare `default` Cursor's SDK expects. - let auto = §ions[2].options[0]; + assert_eq!( + sections[2] + .options + .iter() + .map(|model| model.id.as_str()) + .collect::>(), + vec![ + "copilot-claude-opus-4.7", + "copilot-claude-sonnet-4.6", + "copilot-claude-sonnet-4.5", + "copilot-claude-opus-4.5", + "copilot-claude-haiku-4.5", + "copilot-gemini-2.5-pro", + "copilot-gpt-5.5", + "copilot-gpt-5.4", + "copilot-gpt-5.4-mini", + "copilot-gpt-5.3-codex", + "copilot-gpt-5.2-codex", + "copilot-gpt-5.2", + "copilot-gpt-5-mini", + "copilot-gpt-4.1", + "copilot-gpt-4o", + ] + ); + + assert_eq!(sections[3].id, "cursor"); + assert_eq!(sections[3].status, AgentModelSectionStatus::Ready); + let auto = §ions[3].options[0]; assert_eq!(auto.id, "cursor-default"); assert_eq!(auto.cli_model, "default"); assert_eq!(auto.provider, "cursor"); - assert_eq!(sections[2].options.len(), 1); + assert_eq!(sections[3].options.len(), 1); } #[test] @@ -495,7 +600,7 @@ mod tests { None, ); - assert_eq!(sections.len(), 3); + assert_eq!(sections.len(), 4); assert_eq!(sections[0].id, "claude"); assert_eq!(sections[0].label, "Claude Code"); assert_eq!( diff --git a/src-tauri/src/agents/provider_capabilities.rs b/src-tauri/src/agents/provider_capabilities.rs new file mode 100644 index 000000000..51784ed7f --- /dev/null +++ b/src-tauri/src/agents/provider_capabilities.rs @@ -0,0 +1,307 @@ +//! Provider capability contract. +//! +//! Encodes "does this provider support feature X?" as data instead of +//! `provider == "codex"` checks scattered across the codebase. The +//! frontend and the streaming layer both read off the same shape so +//! adding a new provider (Copilot/ACP via #511, Pi via #321, …) is a +//! single edit here plus a row in the test matrix below. +//! +//! Scope of this slice is intentionally narrow: +//! - shape + helper only; no new provider, no behavior change for the +//! three providers Helmor ships today; +//! - capabilities are the ones that have real call sites today +//! (active-goal, plan-mode, slash commands, context usage, steer, +//! display name, permission modes). Adding a new field is cheap; +//! adding a field with no call site is noise. + +use serde::{Deserialize, Serialize}; + +/// Permission-mode literals the composer's dropdown surfaces. Carried +/// as `&'static str` so the order is stable and the values match the +/// SDK strings the sidecars expect on the wire. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PermissionMode { + Default, + AcceptEdits, + Plan, + BypassPermissions, +} + +impl PermissionMode { + pub const fn as_str(&self) -> &'static str { + match self { + Self::Default => "default", + Self::AcceptEdits => "acceptEdits", + Self::Plan => "plan", + Self::BypassPermissions => "bypassPermissions", + } + } +} + +/// Static capability table for a single provider. Carried in +/// [`AgentModelSection`] etc. so the frontend can branch on data +/// rather than on the provider id string. The fields are the union of +/// the in-tree call sites that previously hard-coded +/// `provider === ""` checks; new fields land next to the call site +/// that needs them and ship with a matrix entry covering all known +/// providers. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderCapabilities { + /// Stable provider id — `"claude"`, `"codex"`, `"cursor"`, …. Same + /// string the rest of the codebase uses on `AgentModelOption`. + pub provider: String, + /// Human-readable label used in confirmation dialogs, status copy, + /// etc. Lets us rename "Codex" to "OpenAI" (or vice versa) in one + /// place without grepping for every string literal. + pub display_name: String, + /// Provider emits a "current plan" artefact the frontend can pin — + /// either Codex `turn/plan/updated` or Claude `ExitPlanMode`. The + /// pipeline maps both onto the same projection (see + /// `agents::session_plan`). + pub supports_plan_mode: bool, + /// Provider has a long-running goal/autopilot loop the frontend + /// needs to special-case during composer submit + stop (today: + /// Codex `/goal …`). Frontend uses this to decide whether to fire + /// the `getSessionCodexGoal` query and intercept `/goal` prompts. + pub supports_active_goal: bool, + /// Provider reports a live context-usage signal we surface in the + /// composer ring. Matches the per-model `supports_context_usage` + /// flag on [`super::catalog::AgentModelOption`] — duplicated here + /// as a provider-level default so frontends without a selected + /// model can still light the ring up. + pub supports_context_usage: bool, + /// Provider supports mid-turn "steer" follow-ups (queueing a new + /// prompt before the current one finishes). + pub supports_steer: bool, + /// Provider has slash-command discovery (`list_slash_commands` + /// returns a meaningful list, not an empty stub). + pub supports_slash_commands: bool, + /// Provider authenticates via an in-app key entry rather than the + /// embedded login terminal flow. True for Cursor; false for Claude + /// + Codex. + pub requires_api_key: bool, + /// Permission modes the composer's permission-mode dropdown should + /// offer for this provider, in display order. The first entry is + /// the default selection for new sessions. + pub permission_modes: Vec, +} + +/// Capabilities for the three providers Helmor ships today. +/// +/// New providers (e.g. Copilot via #511, Pi via #321) land here with a +/// matrix entry in [`tests::capabilities_table`] documenting every +/// flag against the Claude reference row — keeps the contract honest. +pub fn capabilities_for_provider(provider: &str) -> ProviderCapabilities { + match provider { + "codex" => ProviderCapabilities { + provider: "codex".into(), + display_name: "Codex".into(), + supports_plan_mode: true, + supports_active_goal: true, + supports_context_usage: true, + supports_steer: true, + supports_slash_commands: true, + requires_api_key: false, + permission_modes: vec![PermissionMode::Default, PermissionMode::BypassPermissions], + }, + "cursor" => ProviderCapabilities { + provider: "cursor".into(), + display_name: "Cursor".into(), + supports_plan_mode: false, + supports_active_goal: false, + supports_context_usage: false, + supports_steer: false, + supports_slash_commands: true, + requires_api_key: true, + permission_modes: vec![PermissionMode::Default], + }, + "copilot" => ProviderCapabilities { + provider: "copilot".into(), + display_name: "Copilot".into(), + supports_plan_mode: true, + supports_active_goal: false, + supports_context_usage: false, + supports_steer: false, + supports_slash_commands: true, + requires_api_key: false, + permission_modes: vec![PermissionMode::Default, PermissionMode::BypassPermissions], + }, + // Default arm covers "claude" and anything we haven't onboarded + // yet — keeping the safe defaults equal to Claude's behaviour + // means an unknown id never accidentally disables the + // composer's full feature surface. + _ => ProviderCapabilities { + provider: "claude".into(), + display_name: "Claude".into(), + supports_plan_mode: true, + supports_active_goal: false, + supports_context_usage: true, + supports_steer: true, + supports_slash_commands: true, + requires_api_key: false, + permission_modes: vec![ + PermissionMode::Default, + PermissionMode::AcceptEdits, + PermissionMode::Plan, + PermissionMode::BypassPermissions, + ], + }, + } +} + +/// Convenience: list every provider Helmor ships today. Frontends use +/// this to render the capability table in settings (eventually), and +/// tests use it to assert there are no holes in the matrix. +pub const KNOWN_PROVIDERS: &[&str] = &["claude", "codex", "cursor", "copilot"]; + +#[cfg(test)] +mod tests { + use super::*; + + /// Cross-provider capability matrix. Locks down every flag for + /// every shipping provider so the next person adding a feature + /// flag is forced to fill in the matrix or break this test. + #[test] + fn capabilities_table() { + for provider in KNOWN_PROVIDERS { + let caps = capabilities_for_provider(provider); + assert_eq!( + caps.provider, *provider, + "capabilities for `{provider}` returned a mismatched provider id" + ); + assert!( + !caps.display_name.is_empty(), + "{provider}: display_name must not be empty" + ); + assert!( + caps.permission_modes.contains(&PermissionMode::Default), + "{provider}: every provider must support the 'default' permission mode" + ); + } + } + + #[test] + fn claude_capabilities() { + let caps = capabilities_for_provider("claude"); + assert_eq!(caps.provider, "claude"); + assert!(caps.supports_plan_mode, "Claude has ExitPlanMode"); + assert!( + !caps.supports_active_goal, + "Claude has no long-running goal loop" + ); + assert!(caps.supports_context_usage); + assert!(caps.supports_steer); + assert!(caps.supports_slash_commands); + assert!(!caps.requires_api_key, "Claude uses embedded login"); + assert_eq!( + caps.permission_modes, + vec![ + PermissionMode::Default, + PermissionMode::AcceptEdits, + PermissionMode::Plan, + PermissionMode::BypassPermissions, + ] + ); + } + + #[test] + fn codex_capabilities() { + let caps = capabilities_for_provider("codex"); + assert_eq!(caps.provider, "codex"); + assert!(caps.supports_plan_mode, "Codex emits turn/plan/updated"); + assert!( + caps.supports_active_goal, + "Codex has /goal — composer must intercept it" + ); + assert!(caps.supports_context_usage); + assert!(caps.supports_steer); + assert!(caps.supports_slash_commands); + assert!(!caps.requires_api_key, "Codex uses embedded login"); + // Codex doesn't expose `acceptEdits` / `plan` — only the two + // bypass-or-default modes its sidecar understands. + assert_eq!( + caps.permission_modes, + vec![PermissionMode::Default, PermissionMode::BypassPermissions] + ); + } + + #[test] + fn cursor_capabilities() { + let caps = capabilities_for_provider("cursor"); + assert_eq!(caps.provider, "cursor"); + assert!(!caps.supports_plan_mode); + assert!(!caps.supports_active_goal); + assert!( + !caps.supports_context_usage, + "Cursor doesn't surface context usage today" + ); + assert!(!caps.supports_steer); + assert!(caps.supports_slash_commands); + assert!(caps.requires_api_key, "Cursor authenticates via API key"); + assert_eq!(caps.permission_modes, vec![PermissionMode::Default]); + } + + #[test] + fn copilot_capabilities() { + let caps = capabilities_for_provider("copilot"); + assert_eq!(caps.provider, "copilot"); + assert!(caps.supports_plan_mode, "Copilot ACP emits plan updates"); + assert!(!caps.supports_active_goal); + assert!( + !caps.supports_context_usage, + "Copilot ACP doesn't expose token usage yet" + ); + assert!(!caps.supports_steer); + assert!(caps.supports_slash_commands); + assert!(!caps.requires_api_key, "Copilot uses GitHub auth via CLI"); + assert_eq!( + caps.permission_modes, + vec![PermissionMode::Default, PermissionMode::BypassPermissions] + ); + } + + #[test] + fn unknown_provider_falls_back_to_claude_defaults() { + let caps = capabilities_for_provider("pi"); + let claude = capabilities_for_provider("claude"); + assert_eq!(caps.provider, claude.provider); + assert_eq!(caps.supports_plan_mode, claude.supports_plan_mode); + assert_eq!(caps.permission_modes, claude.permission_modes); + } + + /// Wire-format gate: the frontend reads the capability shape + /// straight out of `getProviderCapabilities`, so a snake_case + /// field leaking past `rename_all = "camelCase"` would silently + /// break every consumer. + #[test] + fn serialization_uses_camel_case_fields() { + let caps = capabilities_for_provider("claude"); + let json = serde_json::to_value(&caps).unwrap(); + assert!(json.get("displayName").is_some()); + assert!(json.get("supportsPlanMode").is_some()); + assert!(json.get("supportsActiveGoal").is_some()); + assert!(json.get("supportsContextUsage").is_some()); + assert!(json.get("supportsSteer").is_some()); + assert!(json.get("supportsSlashCommands").is_some()); + assert!(json.get("requiresApiKey").is_some()); + assert!(json.get("permissionModes").is_some()); + let raw = serde_json::to_string(&caps).unwrap(); + assert!(!raw.contains('_'), "snake_case field leaked: {raw}"); + } + + #[test] + fn permission_mode_serializes_to_sdk_wire_strings() { + for (mode, wire) in [ + (PermissionMode::Default, "default"), + (PermissionMode::AcceptEdits, "acceptEdits"), + (PermissionMode::Plan, "plan"), + (PermissionMode::BypassPermissions, "bypassPermissions"), + ] { + assert_eq!(mode.as_str(), wire); + let json = serde_json::to_string(&mode).unwrap(); + assert_eq!(json, format!("\"{wire}\"")); + } + } +} diff --git a/src-tauri/src/agents/queries.rs b/src-tauri/src/agents/queries.rs index 14ffc7538..938ee41f1 100644 --- a/src-tauri/src/agents/queries.rs +++ b/src-tauri/src/agents/queries.rs @@ -1136,6 +1136,107 @@ fn parse_cursor_parameters(arr: &[Value]) -> Vec { .collect() } +// --------------------------------------------------------------------------- +// Copilot model list — proxied to the sidecar's `listModels` for copilot +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CopilotModelEntry { + pub id: String, + pub label: String, + pub effort_levels: Vec, +} + +const LIST_COPILOT_MODELS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); + +pub fn fetch_copilot_models( + sidecar: &crate::sidecar::ManagedSidecar, +) -> CmdResult> { + let request_id = Uuid::new_v4().to_string(); + let params = serde_json::json!({ "provider": "copilot" }); + let sidecar_req = crate::sidecar::SidecarRequest { + id: request_id.clone(), + method: "listModels".to_string(), + params, + }; + + let rx = sidecar.subscribe(&request_id); + if let Err(e) = sidecar.send(&sidecar_req) { + sidecar.unsubscribe(&request_id); + return Err(anyhow::anyhow!("Sidecar send failed: {e}").into()); + } + + let mut models: Vec = Vec::new(); + let mut error: Option = None; + + loop { + match rx.recv_timeout(LIST_COPILOT_MODELS_TIMEOUT) { + Ok(event) => match event.event_type() { + "modelsListed" => { + if let Some(entries) = event.raw.get("models").and_then(Value::as_array) { + for entry in entries { + let Some(id) = entry.get("id").and_then(Value::as_str) else { + continue; + }; + let label = entry + .get("label") + .and_then(Value::as_str) + .unwrap_or(id) + .to_string(); + let effort_levels = entry + .get("effortLevels") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + models.push(CopilotModelEntry { + id: id.to_string(), + label, + effort_levels, + }); + } + } + break; + } + "error" => { + error = Some( + event + .raw + .get("message") + .and_then(Value::as_str) + .unwrap_or("Unknown error") + .to_string(), + ); + break; + } + _ => {} + }, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + error = Some(format!( + "Copilot model list timed out after {}s", + LIST_COPILOT_MODELS_TIMEOUT.as_secs() + )); + break; + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + error = Some("Sidecar disconnected during Copilot model list".to_string()); + break; + } + } + } + + sidecar.unsubscribe(&request_id); + + if let Some(message) = error { + return Err(anyhow::anyhow!(message).into()); + } + Ok(models) +} + // --------------------------------------------------------------------------- // Live context-usage (hover popover, Claude only) // --------------------------------------------------------------------------- diff --git a/src-tauri/src/commands/system_commands.rs b/src-tauri/src/commands/system_commands.rs index 32f01d4b3..3b996723c 100644 --- a/src-tauri/src/commands/system_commands.rs +++ b/src-tauri/src/commands/system_commands.rs @@ -18,7 +18,7 @@ use super::common::{run_blocking, CmdResult}; // Best-fit fixed window size for the current onboarding motion layout. // Resizing is restored when onboarding exits. const ONBOARDING_WINDOW_WIDTH: f64 = 1300.0; -const ONBOARDING_WINDOW_HEIGHT: f64 = 810.0; +const ONBOARDING_WINDOW_HEIGHT: f64 = 880.0; const HELMOR_SKILL_NAME: &str = "helmor-cli"; const HELMOR_SKILL_SOURCE: &str = "dohooo/helmor/.agents/skills/helmor-cli"; @@ -47,6 +47,7 @@ pub struct AgentLoginStatus { pub claude: bool, pub codex: bool, pub cursor: bool, + pub copilot: bool, #[serde(skip_serializing_if = "Option::is_none")] pub codex_provider: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -394,6 +395,7 @@ fn helmor_skills_status() -> anyhow::Result { claude: claude_login_ready(), codex: codex_auth_status().ready, cursor: cursor_login_ready(), + copilot: copilot_login_ready(), codex_provider: None, codex_auth_method: None, }, @@ -534,6 +536,7 @@ pub async fn install_helmor_skills() -> CmdResult { claude: claude_login_ready(), codex: codex_auth_status().ready, cursor: cursor_login_ready(), + copilot: copilot_login_ready(), codex_provider: None, codex_auth_method: None, }; @@ -665,6 +668,7 @@ pub async fn get_agent_login_status() -> CmdResult { claude: claude_login_ready(), codex: codex.ready, cursor: cursor_login_ready(), + copilot: copilot_login_ready(), codex_provider: codex.provider, codex_auth_method: codex.auth_method.map(str::to_string), }) @@ -801,10 +805,174 @@ fn env_var_is_present(key: &str) -> bool { .unwrap_or(false) } +fn copilot_login_ready() -> bool { + if let Some(home) = std::env::var_os("HOME") { + let path = std::path::Path::new(&home) + .join(".copilot") + .join("config.json"); + if let Ok(raw) = std::fs::read_to_string(&path) { + let stripped: String = raw + .lines() + .filter(|line| !line.trim_start().starts_with("//")) + .collect::>() + .join("\n"); + if let Ok(parsed) = serde_json::from_str::(&stripped) { + if let Some(arr) = parsed + .get("loggedInUsers") + .and_then(serde_json::Value::as_array) + { + return !arr.is_empty(); + } + } + } + } + match std::process::Command::new("gh") + .args(["auth", "status"]) + .output() + { + Ok(gh_output) => gh_output.status.success(), + Err(_) => false, + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CopilotAccountInfo { + pub login: String, + pub copilot_plan: String, + pub premium_requests_remaining: u64, + pub premium_requests_entitlement: u64, + pub premium_requests_percent_remaining: f64, + pub chat_unlimited: bool, + pub quota_reset_date: Option, + pub overage_permitted: bool, +} + +#[tauri::command] +pub async fn get_copilot_account_info() -> CmdResult> { + run_blocking(|| { + let output = std::process::Command::new("gh") + .args([ + "api", + "-H", + "Editor-Version: vscode/1.96.2", + "-H", + "Editor-Plugin-Version: copilot-chat/0.26.7", + "-H", + "User-Agent: GitHubCopilotChat/0.26.7", + "-H", + "X-Github-Api-Version: 2025-04-01", + "https://api.github.com/copilot_internal/user", + ]) + .output(); + match output { + Ok(out) if out.status.success() => { + let parsed: serde_json::Value = + serde_json::from_slice(&out.stdout).unwrap_or_default(); + let login = parsed + .get("login") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + if login.is_empty() { + return Ok(None); + } + let plan = parsed + .get("copilot_plan") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown") + .to_string(); + let premium = parsed + .get("quota_snapshots") + .and_then(|q| q.get("premium_interactions")); + let remaining = premium + .and_then(|p| p.get("remaining")) + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let entitlement = premium + .and_then(|p| p.get("entitlement")) + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let percent = premium + .and_then(|p| p.get("percent_remaining")) + .and_then(serde_json::Value::as_f64) + .unwrap_or(0.0); + let overage = premium + .and_then(|p| p.get("overage_permitted")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let chat_unlimited = parsed + .get("quota_snapshots") + .and_then(|q| q.get("chat")) + .and_then(|c| c.get("unlimited")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let reset_date = parsed + .get("quota_reset_date_utc") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + Ok(Some(CopilotAccountInfo { + login, + copilot_plan: plan, + premium_requests_remaining: remaining, + premium_requests_entitlement: entitlement, + premium_requests_percent_remaining: percent, + chat_unlimited, + quota_reset_date: reset_date, + overage_permitted: overage, + })) + } + _ => Ok(None), + } + }) + .await +} + +#[tauri::command] +pub async fn copilot_logout() -> CmdResult<()> { + // `copilot` CLI has no `logout` subcommand. The only authoritative + // sign-out is to clear `loggedInUsers` in ~/.copilot/config.json. + run_blocking(|| { + let home = std::env::var_os("HOME") + .ok_or_else(|| anyhow::anyhow!("HOME environment variable not set"))?; + let path = std::path::Path::new(&home) + .join(".copilot") + .join("config.json"); + if !path.exists() { + return Ok(()); + } + let raw = std::fs::read_to_string(&path) + .with_context(|| format!("Failed to read {}", path.display()))?; + // The file is JSONC: starts with `// ...` banner comments. Strip + // any leading lines that begin with `//` before parsing. + let stripped: String = raw + .lines() + .filter(|line| !line.trim_start().starts_with("//")) + .collect::>() + .join("\n"); + let mut parsed: serde_json::Value = serde_json::from_str(&stripped) + .with_context(|| format!("Failed to parse {}", path.display()))?; + if let Some(obj) = parsed.as_object_mut() { + obj.insert( + "loggedInUsers".to_string(), + serde_json::Value::Array(Vec::new()), + ); + obj.remove("lastLoggedInUser"); + } + let serialized = serde_json::to_string_pretty(&parsed) + .context("Failed to serialize updated copilot config")?; + std::fs::write(&path, serialized) + .with_context(|| format!("Failed to write {}", path.display()))?; + Ok(()) + }) + .await +} + fn agent_login_command(provider: &str) -> anyhow::Result<&'static str> { match provider { "claude" => Ok("claude auth login"), "codex" => Ok("codex login"), + "copilot" => Ok("copilot login"), _ => anyhow::bail!("Unknown agent provider: {provider}"), } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 83afcf6c5..f56c39ad3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -225,7 +225,9 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ agents::list_agent_model_sections, + agents::list_copilot_models, agents::list_cursor_models, + agents::list_provider_capabilities, agents::send_agent_message_stream, agents::stop_agent_stream, agents::list_active_streams, @@ -249,6 +251,8 @@ pub fn run() { commands::settings_commands::get_claude_rate_limits, commands::settings_commands::get_codex_rate_limits, commands::system_commands::get_cli_status, + commands::system_commands::get_copilot_account_info, + commands::system_commands::copilot_logout, commands::system_commands::get_data_info, commands::system_commands::get_agent_login_status, commands::system_commands::get_helmor_skills_status, diff --git a/src-tauri/src/pipeline/accumulator/copilot.rs b/src-tauri/src/pipeline/accumulator/copilot.rs new file mode 100644 index 000000000..ab8bceed3 --- /dev/null +++ b/src-tauri/src/pipeline/accumulator/copilot.rs @@ -0,0 +1,284 @@ +//! Copilot ACP event handling — `copilot/`-namespaced events from the sidecar. +//! +//! Events: +//! - `session_init` (synthetic, carries session_id) — NoOp here +//! - `status` RUNNING/FINISHED — turn boundary / finalize trigger +//! - `thinking` (text delta), `assistant` (text delta) +//! - `tool_call_start` / `tool_call_end` / `tool_call_update` +//! - `plan` — plan update +//! +//! Output: synthesized Claude-format messages so the adapter is shared. + +use std::collections::HashMap; + +use chrono::Utc; +use serde_json::{json, Value}; +use uuid::Uuid; + +use super::super::types::{CollectedTurn, IntermediateMessage, MessageRole}; +use super::{now_ms, PushOutcome, StreamAccumulator}; + +#[derive(Debug, Default)] +pub(in crate::pipeline) struct CopilotRunState { + pub assistant_text: String, + pub thinking_text: String, + pub tools: Vec, + pub tool_index: HashMap, + pub started_at: Option, +} + +#[derive(Debug, Default)] +pub(in crate::pipeline) struct CopilotToolCall { + pub call_id: String, + pub name: String, + pub args: Value, + pub output: String, + pub result: Option, + pub is_error: bool, +} + +pub(super) fn new_run_state() -> CopilotRunState { + CopilotRunState::default() +} + +pub(super) fn handle_status(acc: &mut StreamAccumulator, value: &Value) -> PushOutcome { + let status = value + .get("status") + .and_then(Value::as_str) + .unwrap_or_default(); + match status { + "RUNNING" => { + acc.copilot_state = new_run_state(); + acc.copilot_state.started_at = Some(now_ms()); + PushOutcome::NoOp + } + "FINISHED" => finalize(acc), + _ => PushOutcome::NoOp, + } +} + +pub(super) fn handle_thinking(acc: &mut StreamAccumulator, value: &Value) -> PushOutcome { + if let Some(text) = value.get("text").and_then(Value::as_str) { + if !text.is_empty() { + acc.copilot_state.thinking_text.push_str(text); + acc.saw_thinking_delta = true; + } + } + PushOutcome::StreamingDelta +} + +pub(super) fn handle_assistant_delta(acc: &mut StreamAccumulator, value: &Value) -> PushOutcome { + if let Some(text) = value.get("text").and_then(Value::as_str) { + if !text.is_empty() { + acc.copilot_state.assistant_text.push_str(text); + acc.saw_text_delta = true; + } + } + PushOutcome::StreamingDelta +} + +pub(super) fn handle_tool_call_start(acc: &mut StreamAccumulator, value: &Value) -> PushOutcome { + let call_id = value + .get("call_id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + if call_id.is_empty() { + return PushOutcome::NoOp; + } + let name = value + .get("name") + .and_then(Value::as_str) + .unwrap_or("tool") + .to_string(); + let args = value.get("args").cloned().unwrap_or(json!({})); + + let idx = acc.copilot_state.tools.len(); + acc.copilot_state.tools.push(CopilotToolCall { + call_id: call_id.clone(), + name, + args, + output: String::new(), + result: None, + is_error: false, + }); + acc.copilot_state.tool_index.insert(call_id, idx); + PushOutcome::StreamingDelta +} + +pub(super) fn handle_tool_call_update(acc: &mut StreamAccumulator, value: &Value) -> PushOutcome { + let call_id = match value.get("call_id").and_then(Value::as_str) { + Some(id) => id, + None => return PushOutcome::NoOp, + }; + let output = value.get("output").and_then(Value::as_str).unwrap_or(""); + + if let Some(&idx) = acc.copilot_state.tool_index.get(call_id) { + if let Some(entry) = acc.copilot_state.tools.get_mut(idx) { + entry.output.push_str(output); + } + } + PushOutcome::StreamingDelta +} + +pub(super) fn handle_tool_call_end(acc: &mut StreamAccumulator, value: &Value) -> PushOutcome { + let call_id = match value.get("call_id").and_then(Value::as_str) { + Some(id) => id, + None => return PushOutcome::NoOp, + }; + let result = value.get("result").cloned(); + let is_error = value + .get("is_error") + .and_then(Value::as_bool) + .unwrap_or(false); + + if let Some(&idx) = acc.copilot_state.tool_index.get(call_id) { + if let Some(entry) = acc.copilot_state.tools.get_mut(idx) { + entry.result = result; + entry.is_error = is_error; + } + } + PushOutcome::StreamingDelta +} + +#[allow(dead_code)] +pub(super) fn flush_in_progress(acc: &mut StreamAccumulator) { + finalize(acc); +} + +fn finalize(acc: &mut StreamAccumulator) -> PushOutcome { + let state = std::mem::take(&mut acc.copilot_state); + + let has_text = !state.assistant_text.is_empty(); + let has_thinking = !state.thinking_text.is_empty(); + let has_tools = !state.tools.is_empty(); + if !has_text && !has_thinking && !has_tools { + acc.fallback_text.clear(); + acc.fallback_thinking.clear(); + return PushOutcome::NoOp; + } + + let assistant_id = acc + .active_turn_id + .take() + .unwrap_or_else(|| Uuid::new_v4().to_string()); + let session_id_value: Value = acc + .session_id + .as_deref() + .map(|s| Value::String(s.to_string())) + .unwrap_or(Value::Null); + let resolved_model = acc.resolved_model.clone(); + let created_at = Utc::now().to_rfc3339(); + + let mut content: Vec = Vec::with_capacity(2 + state.tools.len()); + if has_thinking { + content.push(json!({ + "type": "thinking", + "thinking": state.thinking_text, + "signature": "", + })); + } + for tool in &state.tools { + content.push(json!({ + "type": "tool_use", + "id": tool.call_id, + "name": tool.name, + "input": tool.args, + })); + } + if has_text { + content.push(json!({ + "type": "text", + "text": state.assistant_text, + })); + } + + let assistant_msg = json!({ + "type": "assistant", + "session_id": session_id_value, + "message": { + "id": assistant_id, + "role": "assistant", + "model": resolved_model, + "content": content, + }, + }); + let raw_json = assistant_msg.to_string(); + acc.collected.push(IntermediateMessage { + id: assistant_id.clone(), + role: MessageRole::Assistant, + raw_json: raw_json.clone(), + parsed: Some(assistant_msg), + created_at: created_at.clone(), + is_streaming: false, + }); + acc.turns.push(CollectedTurn { + id: assistant_id.clone(), + role: MessageRole::Assistant, + content_json: raw_json, + }); + + for tool in &state.tools { + let Some(result) = &tool.result else { continue }; + let result_text = if !tool.output.is_empty() { + tool.output.clone() + } else { + serde_json::to_string_pretty(result).unwrap_or_default() + }; + let user_msg = json!({ + "type": "user", + "session_id": session_id_value, + "message": { + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": tool.call_id, + "content": result_text, + "is_error": tool.is_error, + }], + }, + }); + let raw = user_msg.to_string(); + let id = format!("tool_result_{}", tool.call_id); + acc.collected.push(IntermediateMessage { + id: id.clone(), + role: MessageRole::User, + raw_json: raw.clone(), + parsed: Some(user_msg), + created_at: created_at.clone(), + is_streaming: false, + }); + acc.turns.push(CollectedTurn { + id, + role: MessageRole::User, + content_json: raw, + }); + } + + if has_text { + if !acc.assistant_text.is_empty() { + acc.assistant_text.push('\n'); + } + acc.assistant_text.push_str(&state.assistant_text); + } + if has_thinking { + if !acc.thinking_text.is_empty() { + acc.thinking_text.push('\n'); + } + acc.thinking_text.push_str(&state.thinking_text); + } + + if let Some(started) = state.started_at { + let duration = now_ms() - started; + if duration > 0.0 { + let enriched = json!({ "type": "turn/completed", "duration_ms": duration }); + let enriched_str = serde_json::to_string(&enriched).unwrap_or_default(); + let id = Uuid::new_v4().to_string(); + acc.result_id = Some(id.clone()); + acc.result_json = Some(enriched_str.clone()); + acc.collect_message(&enriched_str, &enriched, MessageRole::Assistant, Some(&id)); + } + } + + PushOutcome::Finalized +} diff --git a/src-tauri/src/pipeline/accumulator/mod.rs b/src-tauri/src/pipeline/accumulator/mod.rs index 6e10c4231..b1492a9d0 100644 --- a/src-tauri/src/pipeline/accumulator/mod.rs +++ b/src-tauri/src/pipeline/accumulator/mod.rs @@ -10,6 +10,7 @@ //! collection helpers used by both submodules. mod codex; +mod copilot; mod cursor; mod streaming; @@ -151,6 +152,10 @@ pub struct StreamAccumulator { /// Per-run cursor state; see `cursor.rs`. cursor_state: cursor::CursorRunState, + // ── Copilot state ─────────────────────────────────────────────── + /// Per-run copilot state; see `copilot.rs`. + pub(super) copilot_state: copilot::CopilotRunState, + // ── Coverage guard ─────────────────────────────────────────────── /// Top-level event types that fell through `push_event`'s match /// without a handler. Tested as a hard-zero invariant in @@ -310,6 +315,7 @@ impl StreamAccumulator { codex_partial_idx: None, codex_turn_started_at: None, cursor_state: cursor::new_run_state(), + copilot_state: copilot::new_run_state(), dropped_event_types: Vec::new(), } } @@ -492,6 +498,16 @@ impl StreamAccumulator { Some("cursor/tool_call_start") => cursor::handle_tool_call_start(self, value), Some("cursor/tool_call_end") => cursor::handle_tool_call_end(self, value), + // ── Copilot ACP events (namespaced by sidecar manager) ─── + Some("copilot/session_init") => PushOutcome::NoOp, + Some("copilot/status") => copilot::handle_status(self, value), + Some("copilot/thinking") => copilot::handle_thinking(self, value), + Some("copilot/assistant") => copilot::handle_assistant_delta(self, value), + Some("copilot/tool_call_start") => copilot::handle_tool_call_start(self, value), + Some("copilot/tool_call_end") => copilot::handle_tool_call_end(self, value), + Some("copilot/tool_call_update") => copilot::handle_tool_call_update(self, value), + Some("copilot/plan") => PushOutcome::NoOp, + // ── Codex informational notifications (no render) ──────── Some("thread/status/changed") | Some("thread/tokenUsage/updated") @@ -599,12 +615,85 @@ impl StreamAccumulator { }) } + /// Build a streaming partial for Copilot that includes in-progress + /// tool calls alongside any accumulated text/thinking. Without this, + /// tool calls only appear on `finalize()` (FINISHED), making the UI + /// look frozen during agentic work. + pub fn build_copilot_partial(&mut self, session_id: &str) -> Option { + if self.provider != "copilot" { + return None; + } + let has_tools = !self.copilot_state.tools.is_empty(); + let has_text = !self.copilot_state.assistant_text.is_empty(); + let has_thinking = !self.copilot_state.thinking_text.is_empty(); + if !has_tools && !has_text && !has_thinking { + return None; + } + + let (turn_id, created_at) = self.get_or_create_turn_identity(); + let session_id_value = serde_json::Value::String(session_id.to_string()); + + let mut content: Vec = Vec::new(); + if has_thinking { + // Thinking is "done" once tools or text start arriving + let thinking_still_active = !has_tools && !has_text; + content.push(serde_json::json!({ + "type": "thinking", + "thinking": self.copilot_state.thinking_text, + "signature": "", + "__is_streaming": thinking_still_active, + })); + } + for tool in &self.copilot_state.tools { + let mut block = serde_json::json!({ + "type": "tool_use", + "id": tool.call_id, + "name": tool.name, + "input": tool.args, + "__streaming_status": if tool.result.is_some() { "done" } else { "streaming" }, + }); + if !tool.output.is_empty() { + block["__streaming_output"] = serde_json::Value::String(tool.output.clone()); + } + content.push(block); + } + if has_text { + content.push(serde_json::json!({ + "type": "text", + "text": self.copilot_state.assistant_text, + })); + } + + let msg = serde_json::json!({ + "type": "assistant", + "session_id": session_id_value, + "message": { + "id": turn_id, + "role": "assistant", + "model": self.resolved_model, + "content": content, + }, + }); + let raw_json = msg.to_string(); + Some(IntermediateMessage { + id: turn_id, + role: super::types::MessageRole::Assistant, + raw_json, + parsed: Some(msg), + created_at, + is_streaming: true, + }) + } + /// Whether the accumulator has an active streaming partial. pub fn has_active_partial(&self) -> bool { !self.blocks.is_empty() || !self.fallback_text.trim().is_empty() || !self.fallback_thinking.trim().is_empty() || self.codex_partial_idx.is_some() + || !self.copilot_state.tools.is_empty() + || !self.copilot_state.assistant_text.is_empty() + || !self.copilot_state.thinking_text.is_empty() } // ── Persistence accessors ─────────────────────────────────────── diff --git a/src-tauri/src/pipeline/mod.rs b/src-tauri/src/pipeline/mod.rs index 8c5d09ce9..4f3e9aefd 100644 --- a/src-tauri/src/pipeline/mod.rs +++ b/src-tauri/src/pipeline/mod.rs @@ -139,6 +139,7 @@ impl MessagePipeline { .accumulator .build_partial(&self.context_key, &self.session_id) .or_else(|| self.accumulator.build_codex_partial()) + .or_else(|| self.accumulator.build_copilot_partial(&self.session_id)) { Some(p) => p, None => return PipelineEmit::None, diff --git a/src/assets/github-copilot.svg b/src/assets/github-copilot.svg new file mode 100644 index 000000000..8ac418dcb --- /dev/null +++ b/src/assets/github-copilot.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/icons.tsx b/src/components/icons.tsx index be9965922..eeea5d5bc 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -72,3 +72,20 @@ export function XiaomiMiMoIcon(props: SVGProps) { export function ZhipuIcon(props: SVGProps) { return ; } + +export function GitHubCopilotIcon(props: SVGProps) { + return ( + + + + + ); +} diff --git a/src/components/model-icon.tsx b/src/components/model-icon.tsx index de93e8a6c..1413f9b91 100644 --- a/src/components/model-icon.tsx +++ b/src/components/model-icon.tsx @@ -3,6 +3,7 @@ import { ClaudeColorIcon, CursorIcon, DeepSeekIcon, + GitHubCopilotIcon, KimiIcon, MinimaxIcon, OpenAIColorIcon, @@ -19,6 +20,8 @@ export function ModelIcon({ model?: AgentModelOption | null; className?: string; }) { + if (model?.provider === "copilot") + return ; if (model?.provider === "cursor") return ; if (model?.provider === "codex") return ; diff --git a/src/features/composer/container.test.tsx b/src/features/composer/container.test.tsx index 56b5e9631..634e9bd5f 100644 --- a/src/features/composer/container.test.tsx +++ b/src/features/composer/container.test.tsx @@ -1085,6 +1085,24 @@ describe("WorkspaceComposerContainer", () => { helmorQueryKeys.agentModelSections, MODEL_SECTIONS, ); + // Seed the provider-capability table so the composer's + // `/goal` interception path treats Codex as an active-goal + // provider. Without this the helper falls back to "no caps + // loaded yet → supportsActiveGoal=false" and the pause/clear + // branches no-op. + queryClient.setQueryData(helmorQueryKeys.providerCapabilities, [ + { + provider: "codex", + displayName: "Codex", + supportsPlanMode: true, + supportsActiveGoal: true, + supportsContextUsage: true, + supportsSteer: true, + supportsSlashCommands: true, + requiresApiKey: false, + permissionModes: ["default", "bypassPermissions"], + }, + ]); queryClient.setQueryData( helmorQueryKeys.workspaceDetail("workspace-1"), WORKSPACE_DETAIL, diff --git a/src/features/composer/container.tsx b/src/features/composer/container.tsx index fab63fb80..04bf2df32 100644 --- a/src/features/composer/container.tsx +++ b/src/features/composer/container.tsx @@ -22,6 +22,7 @@ import type { } from "@/lib/api"; import { createSession, + findProviderCapabilities, mutateCodexGoal, saveAutoCloseActionKinds, setWorkspaceLinkedDirectories, @@ -35,6 +36,7 @@ import { agentModelSectionsQueryOptions, autoCloseActionKindsQueryOptions, helmorQueryKeys, + providerCapabilitiesQueryOptions, sessionCodexGoalQueryOptions, slashCommandsQueryOptions, workspaceCandidateDirectoriesQueryOptions, @@ -639,7 +641,9 @@ export const WorkspaceComposerContainer = memo( // cursor sessions as claude — the Rust cache then served cached // claude skills back to the cursor popup. Keep cursor explicit. const slashProvider: AgentProvider = - provider === "codex" || provider === "cursor" ? provider : "claude"; + provider === "codex" || provider === "cursor" || provider === "copilot" + ? provider + : "claude"; // Prefer the repoId from a real workspace; on the start page there's no // workspace yet, so fall back to the caller-supplied repoId hint. const effectiveRepoId = @@ -691,12 +695,31 @@ export const WorkspaceComposerContainer = memo( void slashCommandsQuery.refetch(); }, [slashCommandsQuery]); + // Provider capability lookup — single source of truth for the + // active-goal interception below (composer needs to know whether + // the current provider has a `/goal` loop at all). Falls back to + // Claude defaults while the table is loading so unknown + // providers don't accidentally enable codex-only branches. + const providerCapabilitiesQuery = useQuery( + providerCapabilitiesQueryOptions(), + ); + const providerCapabilities = useMemo( + () => + findProviderCapabilities( + providerCapabilitiesQuery.data ?? [], + provider, + ), + [providerCapabilitiesQuery.data, provider], + ); + const supportsActiveGoal = + providerCapabilities?.supportsActiveGoal ?? false; + // Pull the active codex goal so we can intercept `/goal X` submissions // when one is already in flight and ask the user for confirmation // before replacing it. const codexGoalQuery = useQuery({ ...sessionCodexGoalQueryOptions(displayedSessionId ?? "__none__"), - enabled: Boolean(displayedSessionId) && provider === "codex", + enabled: Boolean(displayedSessionId) && supportsActiveGoal, }); const activeGoal = codexGoalQuery.data ?? null; @@ -778,7 +801,7 @@ export const WorkspaceComposerContainer = memo( // the goal-continuation turn codex auto-spawns. // - `/goal ` while a goal already exists // → confirm-replace panel. - if (provider === "codex" && displayedSessionId) { + if (supportsActiveGoal && displayedSessionId) { const match = prompt.trim().match(/^\/goal\s+([\s\S]+)$/); const arg = match ? (match[1]?.trim() ?? "") : ""; if (arg === "pause" || arg === "clear") { @@ -812,7 +835,12 @@ export const WorkspaceComposerContainer = memo( options, ); }, - [provider, displayedSessionId, activeGoal, handleComposerSubmitInner], + [ + supportsActiveGoal, + displayedSessionId, + activeGoal, + handleComposerSubmitInner, + ], ); const handleGoalReplaceConfirm = useCallback(() => { @@ -1018,7 +1046,9 @@ export const WorkspaceComposerContainer = memo( ? "codex" : effectiveModel?.provider === "cursor" ? "cursor" - : "claude" + : effectiveModel?.provider === "copilot" + ? "copilot" + : "claude" } focusShortcut={focusShortcut} togglePlanShortcut={togglePlanShortcut} diff --git a/src/features/composer/context-usage-ring/index.tsx b/src/features/composer/context-usage-ring/index.tsx index 25faf474e..94224ee9c 100644 --- a/src/features/composer/context-usage-ring/index.tsx +++ b/src/features/composer/context-usage-ring/index.tsx @@ -29,7 +29,7 @@ type Props = { * right project config. */ cwd: string | null; /** Only Claude supports the rich hover breakdown. */ - agentType: "claude" | "codex" | "cursor" | null; + agentType: "claude" | "codex" | "cursor" | "copilot" | null; /** Composer's current model id; used as the rich-fetch cache key. */ composerModelId: string | null; alwaysShow: boolean; diff --git a/src/features/composer/index.tsx b/src/features/composer/index.tsx index abcdef8dd..27b090cf7 100644 --- a/src/features/composer/index.tsx +++ b/src/features/composer/index.tsx @@ -174,7 +174,7 @@ type WorkspaceComposerProps = { * and selects which rate-limits API to query. `"cursor"` exists but * Cursor's SDK doesn't expose rate-limit / context-usage endpoints * yet, so the indicators just hide for cursor sessions. */ - agentType?: "claude" | "codex" | "cursor" | null; + agentType?: "claude" | "codex" | "cursor" | "copilot" | null; focusShortcut?: string | null; togglePlanShortcut?: string | null; /** Hotkey that submits the current draft with the opposite follow-up diff --git a/src/features/composer/usage-stats-indicator/index.tsx b/src/features/composer/usage-stats-indicator/index.tsx index a2edc2dd4..c25e02fa9 100644 --- a/src/features/composer/usage-stats-indicator/index.tsx +++ b/src/features/composer/usage-stats-indicator/index.tsx @@ -22,7 +22,7 @@ import { import { LimitRow } from "../context-usage-ring/popover-parts"; type Props = { - agentType: "claude" | "codex" | "cursor" | null; + agentType: "claude" | "codex" | "cursor" | "copilot" | null; disabled?: boolean; className?: string; }; diff --git a/src/features/conversation/hooks/use-streaming.ts b/src/features/conversation/hooks/use-streaming.ts index 8fb552aa4..2eb52f4d9 100644 --- a/src/features/conversation/hooks/use-streaming.ts +++ b/src/features/conversation/hooks/use-streaming.ts @@ -21,6 +21,7 @@ import type { ThreadMessageLike, } from "@/lib/api"; import { + findProviderCapabilities, generateSessionTitle, loadRepoPreferences, mutateCodexGoal, @@ -36,6 +37,7 @@ import { extractError, isRecoverableByPurge } from "@/lib/errors"; import { agentModelSectionsQueryOptions, helmorQueryKeys, + providerCapabilitiesQueryOptions, sessionThreadMessagesQueryOptions, } from "@/lib/query-client"; import { resolveGeneralPreferencePrefix } from "@/lib/repo-preferences-prompts"; @@ -222,6 +224,15 @@ export function useConversationStreaming({ ); const modelSectionsQuery = useQuery(agentModelSectionsQueryOptions()); + // Provider capability table — looked up in `handleStop` to decide + // whether the active provider has a long-running goal loop that + // needs an out-of-band pause before the abort. The query is + // effectively static (persisted, never refetched), so reading it + // here costs one ref-cell lookup per render. + const providerCapabilitiesQuery = useQuery( + providerCapabilitiesQueryOptions(), + ); + const providerCapabilitiesTable = providerCapabilitiesQuery.data ?? null; // Value-stable fingerprint for effects that only care about the set // of active session ids, not the array's reference. const activeSessionIdsKey = useMemo( @@ -445,14 +456,19 @@ export function useConversationStreaming({ return; } - // For codex sessions with an active goal, flip the goal to paused - // FIRST so codex doesn't auto-spawn a fresh continuation turn the - // moment we abort the current one. Sequential: mutate -> stop, so - // the codex child is still alive when mutateCodexGoal needs it. - // (mutateCodexGoal is best-effort on the sidecar side too — if a - // race somehow kills the child first it just no-ops.) The user - // resumes by typing `/goal resume`. - if (provider === "codex") { + // For providers with a long-running goal loop, flip the goal to + // paused FIRST so the provider doesn't auto-spawn a fresh + // continuation turn the moment we abort the current one. + // Sequential: mutate -> stop, so the child is still alive when + // mutateCodexGoal needs it. (mutateCodexGoal is best-effort on + // the sidecar side too — if a race somehow kills the child + // first it just no-ops.) The user resumes by typing + // `/goal resume`. + const caps = findProviderCapabilities( + providerCapabilitiesTable ?? [], + provider, + ); + if (caps?.supportsActiveGoal) { const goal = queryClient.getQueryData( helmorQueryKeys.sessionCodexGoal(sessionId), ); @@ -466,7 +482,13 @@ export function useConversationStreaming({ } } await stopAgentStream(sessionId, provider); - }, [activeSessionByContext, activeStreams, composerContextKey, queryClient]); + }, [ + activeSessionByContext, + activeStreams, + composerContextKey, + providerCapabilitiesTable, + queryClient, + ]); const handlePermissionResponse = useCallback( ( diff --git a/src/features/onboarding/agent-login-state.ts b/src/features/onboarding/agent-login-state.ts index 18704d24d..04165502d 100644 --- a/src/features/onboarding/agent-login-state.ts +++ b/src/features/onboarding/agent-login-state.ts @@ -1,4 +1,9 @@ -import { ClaudeIcon, CursorIcon, OpenAIIcon } from "@/components/icons"; +import { + ClaudeIcon, + CursorIcon, + GitHubCopilotIcon, + OpenAIIcon, +} from "@/components/icons"; import type { AgentLoginStatusResult } from "@/lib/api"; import type { AgentLoginItem } from "./types"; @@ -31,6 +36,15 @@ export function buildAgentLoginItems( : "Add a Cursor API key to use Cursor models in Helmor.", status: status?.cursor ? "ready" : "needsSetup", }, + { + icon: GitHubCopilotIcon, + provider: "copilot", + label: "Copilot", + description: status?.copilot + ? "GitHub authenticated and ready to run Copilot in Helmor." + : "Sign in with GitHub to use Copilot models in Helmor.", + status: status?.copilot ? "ready" : "needsSetup", + }, ]; } diff --git a/src/features/onboarding/components/login-terminal-preview.tsx b/src/features/onboarding/components/login-terminal-preview.tsx index 854f63b97..2f808385d 100644 --- a/src/features/onboarding/components/login-terminal-preview.tsx +++ b/src/features/onboarding/components/login-terminal-preview.tsx @@ -17,9 +17,8 @@ import { cn } from "@/lib/utils"; const providerLabels: Record = { claude: "Claude Code", codex: "Codex", - // Cursor never reaches the login terminal — kept here only to - // satisfy the exhaustive Record type. cursor: "Cursor", + copilot: "Copilot", }; export function OnboardingTerminalPreview({ diff --git a/src/features/onboarding/mockup/conversation.tsx b/src/features/onboarding/mockup/conversation.tsx index 2dbbced58..aca7cb990 100644 --- a/src/features/onboarding/mockup/conversation.tsx +++ b/src/features/onboarding/mockup/conversation.tsx @@ -6,7 +6,7 @@ import { Wrench, Zap, } from "lucide-react"; -import { ClaudeIcon, OpenAIIcon } from "@/components/icons"; +import { ClaudeIcon, GitHubCopilotIcon, OpenAIIcon } from "@/components/icons"; import { cn } from "@/lib/utils"; import { type MockMessage, type MockSession, mockConversation } from "./data"; import { AssistantTextUI } from "./ui/assistant-text.ui"; @@ -25,7 +25,12 @@ import { UserMessageBubbleUI } from "./ui/user-message-bubble.ui"; import { WorkingIndicatorUI } from "./ui/working-indicator.ui"; function ProviderIcon({ provider }: { provider: MockSession["provider"] }) { - const Icon = provider === "codex" ? OpenAIIcon : ClaudeIcon; + const Icon = + provider === "codex" + ? OpenAIIcon + : provider === "copilot" + ? GitHubCopilotIcon + : ClaudeIcon; return ; } diff --git a/src/features/onboarding/mockup/data.ts b/src/features/onboarding/mockup/data.ts index a34ff6032..48a6bee91 100644 --- a/src/features/onboarding/mockup/data.ts +++ b/src/features/onboarding/mockup/data.ts @@ -42,7 +42,7 @@ export type MockWorkspaceGroup = { export type MockSession = { id: string; title: string; - provider: "claude" | "codex"; + provider: "claude" | "codex" | "cursor" | "copilot"; active?: boolean; unread?: boolean; streaming?: boolean; diff --git a/src/features/onboarding/steps/agent-login-step.tsx b/src/features/onboarding/steps/agent-login-step.tsx index 50e5e8da3..cffecad83 100644 --- a/src/features/onboarding/steps/agent-login-step.tsx +++ b/src/features/onboarding/steps/agent-login-step.tsx @@ -1,6 +1,11 @@ import { ArrowLeft, ArrowRight } from "lucide-react"; import { useCallback, useState } from "react"; import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import type { AgentLoginProvider } from "@/lib/api"; import { AgentStatusAction } from "../components/agent-status-action"; import { CursorApiKeyAction } from "../components/cursor-api-key-action"; @@ -8,6 +13,8 @@ import { LoginTerminalPreview } from "../components/login-terminal-preview"; import { ReadyStatus } from "../components/ready-status"; import type { AgentLoginItem, OnboardingStep } from "../types"; +const MORE_AGENTS_PROVIDERS = new Set(["copilot"]); + export function AgentLoginStep({ step, loginItems, @@ -32,8 +39,14 @@ export function AgentLoginStep({ const terminalProvider = activeLoginProvider ?? primedLoginProvider; const terminalActive = activeLoginProvider !== null; + const primaryItems = loginItems.filter( + (item) => !MORE_AGENTS_PROVIDERS.has(item.provider), + ); + const moreItems = loginItems.filter((item) => + MORE_AGENTS_PROVIDERS.has(item.provider), + ); + const startLogin = useCallback((provider: AgentLoginProvider) => { - // Cursor uses an API key, not a CLI login terminal. if (provider === "cursor") return; setPrimedLoginProvider(provider); setActiveLoginProvider(provider); @@ -57,9 +70,6 @@ export function AgentLoginStep({ setWaitingProvider(null); }, []); - /// Bail out of the in-progress login. `setActiveLoginProvider(null)` - /// triggers `LoginTerminalPreview`'s effect cleanup, which kills - /// the spawned PTY via `stopAgentLoginTerminal`. const handleAbortLogin = useCallback(() => { setActiveLoginProvider(null); setLoginInstanceId(null); @@ -94,10 +104,8 @@ export function AgentLoginStep({ log in now, or continue and log in later.

- {/* h-13 (~52px) keeps three tiles + Back/Next inside the - step container at ~720–820px laptop viewports. */}
- {loginItems.map( + {primaryItems.map( ({ icon: Icon, provider, label, description, status }) => { const subLabel = provider === "cursor" && cursorKeyError @@ -149,6 +157,59 @@ export function AgentLoginStep({ )}
+ {moreItems.length > 0 && ( + + + + + +
+ {moreItems.map( + ({ icon: Icon, provider, label, description, status }) => ( +
+
+ +
+
+ + {label} + + + {description} + +
+ +
+ ), + )} +
+
+
+ )} +
+
+ ) : ( +
+ + {signingIn ? "Waiting for sign-in…" : "Need Reauthentication"} + + +
+ )} + + + + {/* Account info card */} + {isReady && accountQuery.isLoading && ( +
+ + + Loading account info… + +
+ )} + + {isReady && accountQuery.isError && ( +
+ + Could not load account info. Check your GitHub authentication. + +
+ )} + + {isReady && account && ( +
+ {/* Row 1: Avatar + Identity + Plan */} +
+ {account.login} { + e.currentTarget.style.display = "none"; + }} + /> +
+ + {account.login} + + + Plan: {formatPlan(account.copilotPlan)} + +
+
+ + {/* Row 2: Premium Requests */} + {account.premiumRequestsEntitlement > 0 && ( +
+
+ Premium Requests + + {account.premiumRequestsRemaining} /{" "} + {account.premiumRequestsEntitlement} ( + {Math.round(account.premiumRequestsPercentRemaining)}%) · + Resets {formatResetDate(account.quotaResetDate)} + +
+
+
+
+
+ )} + + {/* Row 3: Chat */} + {account.chatUnlimited && ( +
+
+ Chat + Unlimited +
+
+
+
+
+ )} +
+ )} + + {/* Models */} + {isReady && ( +
+
+
+ + Models + + + {fetchError + ? `Could not fetch — ${fetchError}` + : "Available models from Copilot API."} + +
+ +
+ + {cachedModels.length > 0 && ( +
+ + + + + + + + + {cachedModels.map((model) => ( + + + + + ))} + +
Model + Effort Levels +
+ {model.label} + + {model.effortLevels.length > 0 + ? model.effortLevels.join(", ") + : "—"} +
+
+ )} +
+ )} + +

+ Requires a GitHub Copilot subscription and the{" "} + copilot CLI. +

+
+ ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 0e55427ee..d2041e371 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -123,7 +123,7 @@ export type DataInfo = { archiveRoot: string; }; -export type AgentProvider = "claude" | "codex" | "cursor"; +export type AgentProvider = "claude" | "codex" | "cursor" | "copilot"; export type AgentModelOption = { id: string; @@ -145,6 +145,31 @@ export type AgentModelSection = { options: AgentModelOption[]; }; +/** Wire strings the sidecars accept for permission mode. The composer's + * permission-mode dropdown reads {@link ProviderCapabilities.permissionModes} + * to decide which entries to render — every provider supports `default`. */ +export type PermissionModeLiteral = + | "default" + | "acceptEdits" + | "plan" + | "bypassPermissions"; + +/** Static capability table for a single provider. Mirrors the Rust + * `agents::provider_capabilities::ProviderCapabilities` shape so a + * cross-stack provider check is data-driven instead of a scattered + * `provider === "codex"` string compare. */ +export type ProviderCapabilities = { + provider: string; + displayName: string; + supportsPlanMode: boolean; + supportsActiveGoal: boolean; + supportsContextUsage: boolean; + supportsSteer: boolean; + supportsSlashCommands: boolean; + requiresApiKey: boolean; + permissionModes: PermissionModeLiteral[]; +}; + export type AgentSendRequest = { provider: AgentProvider; modelId: string; @@ -764,12 +789,13 @@ export async function exitOnboardingWindowMode(): Promise { await invoke("exit_onboarding_window_mode"); } -export type AgentLoginProvider = "claude" | "codex" | "cursor"; +export type AgentLoginProvider = "claude" | "codex" | "cursor" | "copilot"; export type AgentLoginStatusResult = { claude: boolean; codex: boolean; cursor: boolean; + copilot: boolean; codexProvider?: string | null; codexAuthMethod?: "login" | "apiKey" | string | null; }; @@ -932,6 +958,26 @@ export async function loadAgentModelSections(): Promise { } } +/** Static provider-capability table. Backed by the Rust source of truth + * in `agents::provider_capabilities`; callers cache the result for the + * lifetime of the app and look up rows by `provider`. */ +export async function loadProviderCapabilities(): Promise< + ProviderCapabilities[] +> { + return invoke("list_provider_capabilities"); +} + +/** Look up a single provider's capabilities from a previously-fetched + * table. Returns `null` when the provider id isn't represented — the + * composer treats that as "use Claude's safe defaults", matching the + * Rust helper's fallback. */ +export function findProviderCapabilities( + table: readonly ProviderCapabilities[], + provider: string, +): ProviderCapabilities | null { + return table.find((caps) => caps.provider === provider) ?? null; +} + export type CursorModelParameterValue = { value: string; displayName?: string; @@ -966,6 +1012,41 @@ export async function listCursorModels( } } +export type CopilotModelEntry = { + id: string; + label: string; + effortLevels: string[]; +}; + +export async function listCopilotModels(): Promise { + try { + return await invoke("list_copilot_models"); + } catch (error) { + throw new Error( + describeInvokeError(error, "Unable to list Copilot models."), + ); + } +} + +export type CopilotAccountInfo = { + login: string; + copilotPlan: string; + premiumRequestsRemaining: number; + premiumRequestsEntitlement: number; + premiumRequestsPercentRemaining: number; + chatUnlimited: boolean; + quotaResetDate: string | null; + overagePermitted: boolean; +}; + +export async function getCopilotAccountInfo(): Promise { + return invoke("get_copilot_account_info"); +} + +export async function copilotLogout(): Promise { + await invoke("copilot_logout"); +} + // --------------------------------------------------------------------------- // Inbox (kanban-mode left sidebar) // --------------------------------------------------------------------------- diff --git a/src/lib/provider-capabilities.test.ts b/src/lib/provider-capabilities.test.ts new file mode 100644 index 000000000..0e7128cd7 --- /dev/null +++ b/src/lib/provider-capabilities.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { findProviderCapabilities, type ProviderCapabilities } from "./api"; + +const claudeCaps: ProviderCapabilities = { + provider: "claude", + displayName: "Claude", + supportsPlanMode: true, + supportsActiveGoal: false, + supportsContextUsage: true, + supportsSteer: true, + supportsSlashCommands: true, + requiresApiKey: false, + permissionModes: ["default", "acceptEdits", "plan", "bypassPermissions"], +}; + +const codexCaps: ProviderCapabilities = { + provider: "codex", + displayName: "Codex", + supportsPlanMode: true, + supportsActiveGoal: true, + supportsContextUsage: true, + supportsSteer: true, + supportsSlashCommands: true, + requiresApiKey: false, + permissionModes: ["default", "bypassPermissions"], +}; + +const cursorCaps: ProviderCapabilities = { + provider: "cursor", + displayName: "Cursor", + supportsPlanMode: false, + supportsActiveGoal: false, + supportsContextUsage: false, + supportsSteer: false, + supportsSlashCommands: true, + requiresApiKey: true, + permissionModes: ["default"], +}; + +const table: ProviderCapabilities[] = [claudeCaps, codexCaps, cursorCaps]; + +describe("findProviderCapabilities", () => { + it.each([ + ["claude", claudeCaps], + ["codex", codexCaps], + ["cursor", cursorCaps], + ])("returns the row for %s", (provider, expected) => { + expect(findProviderCapabilities(table, provider)).toBe(expected); + }); + + it("returns null for an unknown provider id", () => { + // Forward-compat: callers receiving null are expected to fall + // back to safe defaults. This mirrors the Rust helper's + // behaviour (Claude defaults) at the data-access boundary. + expect(findProviderCapabilities(table, "copilot")).toBeNull(); + }); + + it("returns null on an empty table", () => { + expect(findProviderCapabilities([], "claude")).toBeNull(); + }); + + it("distinguishes Codex active-goal support from Claude / Cursor", () => { + // Regression gate for the composer's `/goal` interception + // switching from `provider === "codex"` to a capability check. + // If a future provider ever needs `supportsActiveGoal`, the + // composer's special-case path needs to be reviewed alongside. + expect(findProviderCapabilities(table, "codex")?.supportsActiveGoal).toBe( + true, + ); + expect(findProviderCapabilities(table, "claude")?.supportsActiveGoal).toBe( + false, + ); + expect(findProviderCapabilities(table, "cursor")?.supportsActiveGoal).toBe( + false, + ); + }); + + it("surfaces Cursor's requires-api-key flag", () => { + // Regression gate: a future refactor of the onboarding/login + // step would lose the in-app API-key path if this flag flipped + // silently. Keep the assertion explicit per-provider. + expect(findProviderCapabilities(table, "cursor")?.requiresApiKey).toBe( + true, + ); + expect(findProviderCapabilities(table, "claude")?.requiresApiKey).toBe( + false, + ); + expect(findProviderCapabilities(table, "codex")?.requiresApiKey).toBe( + false, + ); + }); +}); diff --git a/src/lib/query-client.ts b/src/lib/query-client.ts index c622515e5..3fe63f760 100644 --- a/src/lib/query-client.ts +++ b/src/lib/query-client.ts @@ -35,6 +35,7 @@ import { loadArchivedWorkspaces, loadAutoCloseActionKinds, loadAutoCloseOptInAsked, + loadProviderCapabilities, loadSessionThreadMessages, loadWorkspaceDetail, loadWorkspaceForgeActionStatus, @@ -63,6 +64,7 @@ export const helmorQueryKeys = { archivedWorkspaces: ["archivedWorkspaces"] as const, repositories: ["repositories"] as const, agentModelSections: ["agentModelSections"] as const, + providerCapabilities: ["providerCapabilities"] as const, workspaceDetail: (workspaceId: string) => ["workspaceDetail", workspaceId] as const, workspaceSessions: (workspaceId: string) => @@ -411,6 +413,23 @@ export function agentModelSectionsQueryOptions() { }); } +/** Provider-capability table. The shape is intentionally static across + * the app's lifetime (no per-session inputs), so the query is cached + * forever and persisted to disk like the model catalog — first paint + * on cold start has the data ready. Cleared via the React Query + * devtools or a release-bumped persistence key if the shape changes. */ +export function providerCapabilitiesQueryOptions() { + return queryOptions({ + queryKey: helmorQueryKeys.providerCapabilities, + queryFn: loadProviderCapabilities, + staleTime: Number.POSITIVE_INFINITY, + gcTime: Number.POSITIVE_INFINITY, + refetchOnWindowFocus: false, + retry: false, + meta: PERSIST_META, + }); +} + export function workspaceDetailQueryOptions(workspaceId: string) { return queryOptions({ queryKey: helmorQueryKeys.workspaceDetail(workspaceId), diff --git a/src/lib/workspace-helpers.ts b/src/lib/workspace-helpers.ts index 50e1a4580..fa99253b3 100644 --- a/src/lib/workspace-helpers.ts +++ b/src/lib/workspace-helpers.ts @@ -692,6 +692,9 @@ export function resolveSessionDisplayProvider({ if (session.agentType === "cursor") { return "cursor"; } + if (session.agentType === "copilot") { + return "copilot"; + } return null; }