From a319500b466660f265ed792fa6c7c14efb9e3499 Mon Sep 17 00:00:00 2001 From: XiaoMouz Date: Tue, 12 May 2026 10:18:02 +0800 Subject: [PATCH] feat: implement Copilot live model selection via ACP probe - Sidecar: add probeModelsOnce()/runModelProbe() to CopilotAcpSessionManager so listModels() fetches real models from ACP on first call (15s timeout, singleton Promise to prevent concurrent spawns) - Rust catalog: replace static copilot_section() with copilot_section_from_prefs() that reads cachedModels from app.copilot_provider settings; add CopilotPrefs struct and load_copilot_prefs() helper - Rust command: add fetch_copilot_models() + list_copilot_models Tauri command (mirrors list_cursor_models pattern) - Frontend settings: add CopilotProviderSettings type, copilotProvider key in SETTINGS_KEY_MAP, parseCopilotProviderSettings(), default and saveSettings wiring - Frontend API: add listCopilotModels() invoke wrapper + CopilotModelEntry type - Frontend UI: add CopilotProviderPanel with auto-fetch on mount, refresh button, and model list display; wire into Settings > Models panel Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/copilot-acp-provider.md | 5 + .changeset/copilot-model-selection.md | 5 + .changeset/copilot-models-and-modes.md | 9 + sidecar/bun.lock | 3 + sidecar/package.json | 1 + sidecar/src/context-usage.test.ts | 34 + sidecar/src/context-usage.ts | 24 + .../src/copilot-acp-session-manager.test.ts | 6 + sidecar/src/copilot-acp-session-manager.ts | 972 ++++++++++++++++++ sidecar/src/index.ts | 12 +- sidecar/src/model-catalog.ts | 7 + sidecar/src/request-parser.ts | 7 +- sidecar/src/session-manager.ts | 2 +- src-tauri/src/agents.rs | 7 + src-tauri/src/agents/catalog.rs | 151 ++- src-tauri/src/agents/queries.rs | 92 ++ src-tauri/src/commands/system_commands.rs | 55 + src-tauri/src/lib.rs | 1 + src-tauri/src/pipeline/accumulator/mod.rs | 15 + src-tauri/src/pipeline/accumulator/tests.rs | 69 ++ src-tauri/src/sidecar.rs | 23 + src/components/model-icon.tsx | 3 + src/features/composer/container.tsx | 28 +- .../composer/context-usage-ring/index.tsx | 3 +- src/features/composer/index.tsx | 38 +- .../composer/usage-stats-indicator/index.tsx | 3 +- src/features/onboarding/agent-login-state.ts | 10 + .../components/login-terminal-preview.tsx | 1 + src/features/panel/header.tsx | 6 + .../panel/use-confirm-session-close.tsx | 1 + src/features/settings/index.tsx | 2 + .../settings/panels/copilot-provider.tsx | 105 ++ src/lib/api.ts | 21 +- src/lib/settings.ts | 44 + src/lib/workspace-helpers.ts | 3 + 35 files changed, 1737 insertions(+), 31 deletions(-) create mode 100644 .changeset/copilot-acp-provider.md create mode 100644 .changeset/copilot-model-selection.md create mode 100644 .changeset/copilot-models-and-modes.md create mode 100644 sidecar/src/copilot-acp-session-manager.test.ts create mode 100644 sidecar/src/copilot-acp-session-manager.ts create mode 100644 src/features/settings/panels/copilot-provider.tsx diff --git a/.changeset/copilot-acp-provider.md b/.changeset/copilot-acp-provider.md new file mode 100644 index 000000000..c37d398cf --- /dev/null +++ b/.changeset/copilot-acp-provider.md @@ -0,0 +1,5 @@ +--- +"helmor": patch +--- + +Add GitHub Copilot CLI as an agent provider via the Agent Client Protocol, with hybrid binary discovery, async image attachments, shared `.agents/skills` slash-command scanning, and accurate permission deny handling. diff --git a/.changeset/copilot-model-selection.md b/.changeset/copilot-model-selection.md new file mode 100644 index 000000000..349009d87 --- /dev/null +++ b/.changeset/copilot-model-selection.md @@ -0,0 +1,5 @@ +--- +"helmor": patch +--- + +Copilot model picker now shows live models fetched from the ACP server instead of a static "Default" entry. diff --git a/.changeset/copilot-models-and-modes.md b/.changeset/copilot-models-and-modes.md new file mode 100644 index 000000000..7816c3294 --- /dev/null +++ b/.changeset/copilot-models-and-modes.md @@ -0,0 +1,9 @@ +--- +"helmor": patch +--- + +Bring GitHub Copilot to feature parity with Codex/Claude in the composer: +- Model picker is now sourced live from Copilot's ACP `SessionModelState` and applied per turn via `unstable_setSessionModel`. +- Plan mode and a Copilot-only Autopilot toggle drive ACP `setSessionMode` (interactive/plan/autopilot). +- Context-window ring + usage status are wired from Copilot's `usage_update` notifications through the same persistence path as Codex. +- Effort levels (low/medium/high/xhigh) are exposed on the static catalog entry. diff --git a/sidecar/bun.lock b/sidecar/bun.lock index c8488374e..1d1736e75 100644 --- a/sidecar/bun.lock +++ b/sidecar/bun.lock @@ -5,6 +5,7 @@ "": { "name": "@helmor/sidecar", "dependencies": { + "@agentclientprotocol/sdk": "^0.21.0", "@anthropic-ai/claude-agent-sdk": "0.2.126", "@anthropic-ai/claude-code": "2.1.126", "@cursor/sdk": "^1.0.12", @@ -20,6 +21,8 @@ "@anthropic-ai/claude-code", ], "packages": { + "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.21.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ONj+Q8qOdNQp5XbH5jnMwzT9IKZJsSN0p0lkceS4GtUtNOPVLpNzSS8gqQdGMKfBvA0ESbkL8BTaSN1Rc9miEw=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.126", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.126", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.126", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.126", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.126", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.126", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.126", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.126", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.126" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-4ZrVu0XUEwNG6wxvsLgppRAmSfAf3oeEMEUPhgazb0AXUUe/7W8MxwZKJWOffqSLWaNYzOt3ZCIL7NJY6toqWw=="], "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.126", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JFlJBbeAlx7Ic5s4lGUN9SppobryXk/lIqPCvhp6KrJTQIerh3MIBzxsVIJ0MaDut7jVni/oYgsvDni7NIyqHA=="], diff --git a/sidecar/package.json b/sidecar/package.json index 18aa93266..15efc88b7 100644 --- a/sidecar/package.json +++ b/sidecar/package.json @@ -13,6 +13,7 @@ "typecheck": "bunx tsc --noEmit" }, "dependencies": { + "@agentclientprotocol/sdk": "^0.21.0", "@anthropic-ai/claude-agent-sdk": "0.2.126", "@anthropic-ai/claude-code": "2.1.126", "@cursor/sdk": "^1.0.12", diff --git a/sidecar/src/context-usage.test.ts b/sidecar/src/context-usage.test.ts index 71524e24d..252bf9654 100644 --- a/sidecar/src/context-usage.test.ts +++ b/sidecar/src/context-usage.test.ts @@ -3,6 +3,7 @@ import { buildClaudeRichMeta, buildClaudeStoredMeta, buildCodexStoredMeta, + buildCopilotStoredMeta, } from "./context-usage"; const CLAUDE_MODEL = "claude-opus-4-7[1m]"; @@ -377,3 +378,36 @@ describe("buildCodexStoredMeta", () => { expect(meta?.modelId).toBe(""); }); }); + +describe("buildCopilotStoredMeta", () => { + it("maps used/size into the persisted meta shape", () => { + const meta = buildCopilotStoredMeta( + { used: 12_000, size: 200_000 }, + "claude-sonnet-4.6", + ); + expect(meta).toEqual({ + modelId: "claude-sonnet-4.6", + usedTokens: 12_000, + maxTokens: 200_000, + percentage: 6, + }); + }); + + it("clamps used to size when ACP over-reports", () => { + const meta = buildCopilotStoredMeta( + { used: 250_000, size: 200_000 }, + "copilot-default", + ); + expect(meta?.usedTokens).toBe(200_000); + expect(meta?.percentage).toBe(100); + }); + + it("falls back to a stable model id when ACP hasn't reported one", () => { + const meta = buildCopilotStoredMeta({ used: 500, size: 100_000 }, null); + expect(meta?.modelId).toBe("copilot"); + }); + + it("returns null when both fields are zero", () => { + expect(buildCopilotStoredMeta({ used: 0, size: 0 }, null)).toBeNull(); + }); +}); diff --git a/sidecar/src/context-usage.ts b/sidecar/src/context-usage.ts index 9176dd7c3..8c8148004 100644 --- a/sidecar/src/context-usage.ts +++ b/sidecar/src/context-usage.ts @@ -180,3 +180,27 @@ export function buildCodexStoredMeta( percentage: computePercentage(usedClamped, max), }; } + +/** + * Build the persisted meta from a Copilot ACP `usage_update` session + * notification. ACP exposes raw `used` / `size` token counts (size = + * full context window). When the active session hasn't reported a + * model id yet (e.g. older Copilot CLI builds without + * `SessionModelState`), fall back to the constant `"copilot"` so the + * UI ring still renders. + */ +export function buildCopilotStoredMeta( + usage: { used?: number | null; size?: number | null }, + modelId: string | null, +): StoredContextUsageMeta | null { + const used = num(usage.used); + const max = num(usage.size); + if (used <= 0 && max <= 0) return null; + const usedClamped = max > 0 ? Math.min(used, max) : used; + return { + modelId: modelId ?? "copilot", + usedTokens: usedClamped, + maxTokens: max, + percentage: computePercentage(usedClamped, max), + }; +} diff --git a/sidecar/src/copilot-acp-session-manager.test.ts b/sidecar/src/copilot-acp-session-manager.test.ts new file mode 100644 index 000000000..6b5c8ab4d --- /dev/null +++ b/sidecar/src/copilot-acp-session-manager.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from "bun:test"; +import { COPILOT_ACP_ARGS } from "./copilot-acp-session-manager.js"; + +test("Copilot ACP uses the supported top-level --acp flag", () => { + expect(COPILOT_ACP_ARGS).toEqual(["--acp"]); +}); diff --git a/sidecar/src/copilot-acp-session-manager.ts b/sidecar/src/copilot-acp-session-manager.ts new file mode 100644 index 000000000..116a84528 --- /dev/null +++ b/sidecar/src/copilot-acp-session-manager.ts @@ -0,0 +1,972 @@ +/** SessionManager backed by GitHub Copilot CLI's ACP server. + * + * One `copilot --acp` process is held per Helmor + * session. ACP session updates are converted into a small `copilot/` + * event vocabulary that Rust normalizes through the shared ACP-shape + * accumulator (see `src-tauri/src/pipeline/accumulator/cursor.rs`). + */ + +import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { extname, join } from "node:path"; +import { Readable, Writable } from "node:stream"; +import { + type Agent, + type Client, + ClientSideConnection, + type ContentBlock, + type ModelInfo, + ndJsonStream, + type PermissionOption, + PROTOCOL_VERSION, + type PromptRequest, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionMode, + type SessionModelState, + type SessionModeState, + type SessionNotification, + type SessionUpdate, + type ToolCall, + type ToolCallUpdate, +} from "@agentclientprotocol/sdk"; +import { buildCopilotStoredMeta } from "./context-usage.js"; +import { scanCursorSkills } from "./cursor-skill-scanner.js"; +import type { SidecarEmitter } from "./emitter.js"; +import { readImageWithResize } from "./image-resize.js"; +import { parseImageRefs } from "./images.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"; + +/// Hybrid bin discovery — same priority order Tauri uses for the +/// other agent CLIs: explicit env override → bundled vendor binary +/// (resolved relative to the sidecar entry point so `bun run dev` and +/// the compiled `helmor-sidecar` both work) → PATH lookup. +function resolveCopilotBinPath(): string { + const envOverride = process.env.HELMOR_COPILOT_BIN_PATH?.trim(); + if (envOverride) return envOverride; + + // `import.meta.dir` resolves to the source dir under `bun run dev` + // and to the bundle root under `bun build --compile`. The vendor + // staging script (`scripts/stage-vendor.ts`) places the binary at + // `dist/vendor/copilot/copilot` next to the sidecar executable. + const candidates = [ + join(import.meta.dir, "..", "vendor", "copilot", "copilot"), + join(import.meta.dir, "..", "dist", "vendor", "copilot", "copilot"), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + + return "copilot"; +} + +const COPILOT_BIN_PATH = resolveCopilotBinPath(); + +/// Sidecar version surfaced to the ACP server. Read once at module +/// load to keep `clientInfo` honest across releases without forcing +/// callers to thread the value through. Bundle layouts: package.json +/// sits one directory above the source file in `bun run dev` and one +/// above the compiled bundle. +const SIDECAR_VERSION = readSidecarVersion(); + +function readSidecarVersion(): string { + const candidates = [ + join(import.meta.dir, "..", "package.json"), + join(import.meta.dir, "..", "..", "package.json"), + ]; + for (const candidate of candidates) { + try { + if (!existsSync(candidate)) continue; + const raw = readFileSync(candidate, "utf8"); + const parsed = JSON.parse(raw) as { version?: unknown }; + if (typeof parsed.version === "string") return parsed.version; + } catch { + // fall through + } + } + return "0.0.0"; +} + +/// Upstream `@github/copilot` exposes the ACP server as a top-level +/// `--acp` flag. Keep this constant authoritative so future bumps stay +/// in sync with `copilot --help`. +export const COPILOT_ACP_ARGS = ["--acp"] as const; + +/// No-op ACP client used for the lightweight model-probe session. +/// Probe sessions never send prompts, so all callbacks are empty stubs. +const NOOP_CLIENT: Client = { + sessionUpdate: async () => {}, + requestPermission: async () => ({ outcome: { outcome: "cancelled" } }), +}; + +interface PendingPermission { + sessionId: string; + resolve: (response: RequestPermissionResponse) => void; + options: readonly PermissionOption[]; +} + +interface CopilotSessionContext { + child: ChildProcessWithoutNullStreams; + agent: Agent; + connection: ClientSideConnection; + providerSessionId: string; + cwd: string; + additionalDirectories: string[]; + activeRequestId: string | null; + activeEmitter: SidecarEmitter | null; + activePermissionMode: string | undefined; + currentPrompt: Promise | null; + aborted: boolean; + /// ACP-reported state — populated from the `newSession`/`resumeSession` + /// response and refreshed via `current_mode_update` / future + /// `current_model_update` notifications. Drives composer pickers. + availableModels: readonly ModelInfo[]; + currentModelId: string | null; + availableModes: readonly SessionMode[]; + currentModeId: string | null; +} + +export class CopilotAcpSessionManager implements SessionManager { + private sessions = new Map(); + private pendingPermissions = new Map(); + /// Last-known ACP model list across any spawned ACP child. Used as + /// the source for `listModels()` so the composer picker can show + /// real Copilot model IDs even before the user has sent a prompt. + private lastKnownModels: readonly ModelInfo[] = []; + /// Singleton probe promise — ensures only one temp ACP child runs + /// at a time when `listModels()` is called concurrently cold. + private probePromise: Promise | null = null; + + resolvePermission(permissionId: string, behavior: "allow" | "deny"): void { + const pending = this.pendingPermissions.get(permissionId); + if (!pending) return; + this.pendingPermissions.delete(permissionId); + + const option = pickPermissionOption(pending.options, behavior); + if (!option) { + pending.resolve({ outcome: { outcome: "cancelled" } }); + return; + } + pending.resolve({ + outcome: { outcome: "selected", optionId: option.optionId }, + }); + } + + resolveUserInput( + _userInputId: string, + _resolution: UserInputResolution, + ): boolean { + return false; + } + + async sendMessage( + requestId: string, + params: SendMessageParams, + emitter: SidecarEmitter, + ): Promise { + const cwd = params.cwd ?? process.cwd(); + const additionalDirectories = params.additionalDirectories ?? []; + const ctx = await this.ensureContext(params.sessionId, cwd, params.resume, [ + ...additionalDirectories, + ]); + // ACP carries the linked-directory context natively via + // `additionalDirectories` on the session-create call (see + // `ensureContext`). Avoid double-feeding by NOT also prepending + // it as a synthetic system-prompt prefix. + const input = await buildPromptInput(params.prompt, params.images); + const messageId = randomUUID(); + + ctx.activeRequestId = requestId; + ctx.activeEmitter = emitter; + ctx.activePermissionMode = params.permissionMode; + ctx.aborted = false; + + // Apply per-turn model + mode BEFORE the prompt fires. Both + // calls are best-effort: ACP servers may not advertise the + // requested id (e.g. user picked a stale option), in which case + // we emit a copilot/status warning and let the prompt run with + // the previously-applied state. + const appliedModelId = await this.applyModel(ctx, params.model, requestId); + const appliedModeId = await this.applyPermissionMode( + ctx, + params.permissionMode, + requestId, + ); + + emitter.passthrough(requestId, { + type: "copilot/session_started", + session_id: ctx.providerSessionId, + model: appliedModelId ?? params.model ?? "default", + mode: appliedModeId ?? null, + }); + emitter.passthrough(requestId, { + type: "copilot/status", + status: "RUNNING", + run_id: messageId, + mode: appliedModeId ?? null, + model: appliedModelId ?? params.model ?? null, + }); + + try { + const promptRequest: PromptRequest = { + sessionId: ctx.providerSessionId, + messageId, + prompt: input, + }; + const promptPromise = ctx.agent.prompt(promptRequest); + ctx.currentPrompt = promptPromise; + const result = await promptPromise; + if (result.stopReason === "cancelled") { + ctx.aborted = true; + } + emitter.passthrough(requestId, { + type: "copilot/status", + status: "FINISHED", + run_id: messageId, + stopReason: result.stopReason, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error(`[${requestId}] Copilot prompt failed: ${msg}`, { + ...errorDetails(err), + }); + emitter.error(requestId, `Copilot: ${msg}`); + } finally { + ctx.currentPrompt = null; + ctx.activeRequestId = null; + ctx.activeEmitter = null; + ctx.activePermissionMode = undefined; + 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 { + // ACP has no one-shot prompt mode (unlike the Claude/Cursor + // SDKs), so spinning up a temporary `copilot` process just to + // label the chat would cost ~2-5 s per new session. Fall back + // to a deterministic single-line summary derived from the + // user's first message — same shape as Codex's offline title. + const firstLine = userMessage.split(/\r?\n/, 1)[0]?.trim() ?? ""; + const collapsed = firstLine.replace(/\s+/g, " "); + const truncated = + collapsed.length > 50 + ? `${collapsed.slice(0, 50).trimEnd()}…` + : collapsed; + emitter.titleGenerated(requestId, truncated || "New chat", undefined); + } + + async listSlashCommands( + params: ListSlashCommandsParams, + ): Promise { + // Copilot's ACP server has no slash-command RPC. Re-use the + // shared filesystem skill scan (`.agents/skills` is the + // cross-provider convention) so user-defined commands surface + // in the composer. + try { + return await scanCursorSkills(params); + } catch (err) { + logger.error( + `copilot listSlashCommands failed: ${err instanceof Error ? err.message : String(err)}`, + errorDetails(err), + ); + return []; + } + } + + async listModels(_opts?: { + apiKey?: string; + }): Promise { + // Prefer the live ACP-reported list — Copilot CLI tracks the + // user's actual entitlement (Pro vs Free, BYOK overrides, beta + // rollouts). Fall back to the static catalog only when no ACP + // child has reported yet (cold start before first prompt). + if (this.lastKnownModels.length === 0) { + await this.probeModelsOnce(); + } + if (this.lastKnownModels.length > 0) { + return this.lastKnownModels.map( + (model): ProviderModelInfo => ({ + id: model.modelId, + label: model.name, + cliModel: model.modelId, + }), + ); + } + return listProviderModels("copilot"); + } + + /// Spawn a lightweight ACP child just to enumerate available models. + /// Result is stored in `lastKnownModels` so subsequent `listModels()` + /// calls return live data immediately. The probe child is killed + /// after model capture — it is never used for prompts. A singleton + /// promise prevents concurrent spawns when multiple callers race. + private probeModelsOnce(): Promise { + if (this.probePromise) return this.probePromise; + this.probePromise = this.runModelProbe().finally(() => { + this.probePromise = null; + }); + return this.probePromise; + } + + private async runModelProbe(): Promise { + const PROBE_TIMEOUT_MS = 15_000; + const cwd = process.cwd(); + + const child = spawn(COPILOT_BIN_PATH, [...COPILOT_ACP_ARGS], { + cwd, + stdio: ["pipe", "pipe", "pipe"], + env: process.env, + }); + child.stderr.on("data", (chunk: Buffer) => { + logger.debug("copilot acp probe stderr", { + data: chunk.toString().trim(), + }); + }); + + const timeoutHandle = setTimeout(() => { + logger.info("copilot model probe timed out — killing probe child"); + child.kill(); + }, PROBE_TIMEOUT_MS); + + try { + const stream = ndJsonStream( + Writable.toWeb(child.stdin), + Readable.toWeb(child.stdout) as unknown as ReadableStream, + ); + // Probe sessions never emit events back to a Helmor session, + // so we provide a no-op client. + const connection = new ClientSideConnection(() => NOOP_CLIENT, stream); + const agent = connection as unknown as Agent; + + await agent.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientInfo: { + name: "helmor_desktop", + title: "Helmor Desktop", + version: SIDECAR_VERSION, + }, + clientCapabilities: {}, + }); + + const session = await agent.newSession({ + cwd, + additionalDirectories: [], + mcpServers: [], + }); + const models = + (session as { models?: SessionModelState | null }).models ?? null; + if (models?.availableModels && models.availableModels.length > 0) { + this.lastKnownModels = models.availableModels; + logger.info( + `copilot model probe: captured ${models.availableModels.length} models`, + ); + } + + try { + await ( + agent as unknown as { + closeSession?: (req: { sessionId: string }) => Promise; + } + ).closeSession?.({ + sessionId: + "sessionId" in session && typeof session.sessionId === "string" + ? session.sessionId + : "", + }); + } catch { + // best-effort close + } + } catch (err) { + logger.info( + `copilot model probe failed: ${err instanceof Error ? err.message : String(err)}`, + errorDetails(err), + ); + } finally { + clearTimeout(timeoutHandle); + child.kill(); + } + } + + async stopSession(sessionId: string): Promise { + const ctx = this.sessions.get(sessionId); + if (!ctx) return; + ctx.aborted = true; + for (const [id, pending] of this.pendingPermissions) { + if (pending.sessionId !== ctx.providerSessionId) continue; + pending.resolve({ outcome: { outcome: "cancelled" } }); + this.pendingPermissions.delete(id); + } + try { + await ctx.agent.cancel({ sessionId: ctx.providerSessionId }); + } catch (err) { + logger.debug("Copilot cancel failed; killing ACP process", { + ...errorDetails(err), + }); + ctx.child.kill(); + this.sessions.delete(sessionId); + } + } + + /// ACP has no mid-turn steering RPC — the upstream protocol expects + /// callers to cancel and resend. Returning `false` lets the sidecar + /// fall through to the cancel+resend path, matching how the Codex + /// manager handles the same gap. + async steer( + _sessionId: string, + _prompt: string, + _files: readonly string[], + _images: readonly string[], + ): Promise { + return false; + } + + async shutdown(): Promise { + for (const [sessionId, ctx] of this.sessions) { + try { + await ctx.agent.closeSession?.({ sessionId: ctx.providerSessionId }); + } catch { + // Process teardown below is the fallback. + } + ctx.child.kill(); + this.sessions.delete(sessionId); + } + } + + private async ensureContext( + helmorSessionId: string, + cwd: string, + resume: string | undefined, + additionalDirectories: string[], + ): Promise { + const existing = this.sessions.get(helmorSessionId); + // Recreate when the working directory OR the linked-directory + // set changes — ACP bakes both into the session-create call, + // so a stale child would silently lose visibility of new dirs. + if ( + existing && + existing.cwd === cwd && + sameDirectories(existing.additionalDirectories, additionalDirectories) + ) { + return existing; + } + if (existing) { + existing.child.kill(); + this.sessions.delete(helmorSessionId); + } + + const child = spawn(COPILOT_BIN_PATH, [...COPILOT_ACP_ARGS], { + cwd, + stdio: ["pipe", "pipe", "pipe"], + env: process.env, + }); + child.stderr.on("data", (chunk: Buffer) => { + logger.debug("copilot acp stderr", { data: chunk.toString().trim() }); + }); + + const stream = ndJsonStream( + Writable.toWeb(child.stdin), + Readable.toWeb(child.stdout) as unknown as ReadableStream, + ); + let ctx!: CopilotSessionContext; + const connection = new ClientSideConnection( + () => this.buildClient(() => ctx), + stream, + ); + const agent = connection as unknown as Agent; + + await agent.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientInfo: { + name: "helmor_desktop", + title: "Helmor Desktop", + version: SIDECAR_VERSION, + }, + clientCapabilities: {}, + }); + + const sessionParams = { + cwd, + additionalDirectories, + mcpServers: [], + }; + const session = + resume && agent.resumeSession + ? await agent.resumeSession({ + ...sessionParams, + sessionId: resume, + }) + : await agent.newSession(sessionParams); + + const providerSessionId: string | null = + resume ?? + ("sessionId" in session && typeof session.sessionId === "string" + ? session.sessionId + : null); + if (!providerSessionId) { + child.kill(); + throw new Error("Copilot ACP did not return a session id"); + } + + ctx = { + child, + agent, + connection, + providerSessionId, + cwd, + additionalDirectories: [...additionalDirectories], + activeRequestId: null, + activeEmitter: null, + activePermissionMode: undefined, + currentPrompt: null, + aborted: false, + availableModels: [], + currentModelId: null, + availableModes: [], + currentModeId: null, + }; + this.captureSessionState( + ctx, + (session as { models?: SessionModelState | null }).models ?? null, + (session as { modes?: SessionModeState | null }).modes ?? null, + ); + child.on("exit", () => { + if (this.sessions.get(helmorSessionId) === ctx) { + this.sessions.delete(helmorSessionId); + } + }); + this.sessions.set(helmorSessionId, ctx); + return ctx; + } + + /// Mirror ACP's `SessionModelState` / `SessionModeState` into the + /// per-session context and refresh the cross-process model cache. + private captureSessionState( + ctx: CopilotSessionContext, + models: SessionModelState | null, + modes: SessionModeState | null, + ): void { + if (models) { + ctx.availableModels = models.availableModels ?? []; + ctx.currentModelId = models.currentModelId ?? null; + if (ctx.availableModels.length > 0) { + this.lastKnownModels = ctx.availableModels; + } + } + if (modes) { + ctx.availableModes = modes.availableModes ?? []; + ctx.currentModeId = modes.currentModeId ?? null; + } + } + + /// Pick the ACP `modeId` that best matches the user's `permissionMode`. + /// Returns null when the ACP server didn't advertise modes (older + /// Copilot builds) or no mapping fits — caller skips the set call. + private resolveModeId( + ctx: CopilotSessionContext, + permissionMode: string | undefined, + ): string | null { + if (ctx.availableModes.length === 0) return null; + const findMode = (predicate: (mode: SessionMode) => boolean) => + ctx.availableModes.find(predicate)?.id ?? null; + + const target = (permissionMode ?? "default").toLowerCase(); + switch (target) { + case "plan": + return ( + findMode((m) => m.id.toLowerCase() === "plan") ?? + findMode((m) => m.name.toLowerCase().includes("plan")) + ); + case "autopilot": + return ( + findMode((m) => m.id.toLowerCase() === "autopilot") ?? + findMode((m) => m.name.toLowerCase().includes("autopilot")) ?? + findMode((m) => m.name.toLowerCase().includes("auto")) + ); + case "bypasspermissions": + // Helmor's "skip permission prompts" maps onto Copilot's + // autopilot mode where available; otherwise stay + // interactive and let the bypass code path swallow + // approvals at the requestPermission layer. + return ( + findMode((m) => m.id.toLowerCase() === "autopilot") ?? + findMode((m) => m.name.toLowerCase().includes("autopilot")) ?? + findMode((m) => m.id.toLowerCase() === "interactive") ?? + null + ); + default: + return ( + findMode((m) => m.id.toLowerCase() === "interactive") ?? + findMode((m) => m.name.toLowerCase().includes("interactive")) + ); + } + } + + private async applyModel( + ctx: CopilotSessionContext, + requested: string | undefined, + requestId: string, + ): Promise { + if (!requested || requested === "default") return ctx.currentModelId; + if (requested === ctx.currentModelId) return ctx.currentModelId; + // `unstable_setSessionModel` is the experimental ACP RPC for + // per-session model switching. Optional on the agent side; the + // SDK exposes it on `ClientSideConnection` directly. + const setter = ( + ctx.agent as unknown as { + unstable_setSessionModel?: (req: { + sessionId: string; + modelId: string; + }) => Promise; + } + ).unstable_setSessionModel; + if (!setter) return ctx.currentModelId; + try { + await setter.call(ctx.agent, { + sessionId: ctx.providerSessionId, + modelId: requested, + }); + ctx.currentModelId = requested; + return requested; + } catch (err) { + logger.info("copilot setSessionModel failed", { + modelId: requested, + ...errorDetails(err), + }); + ctx.activeEmitter?.passthrough(requestId, { + type: "copilot/status", + status: "WARNING", + warning: "model_switch_failed", + modelId: requested, + }); + return ctx.currentModelId; + } + } + + private async applyPermissionMode( + ctx: CopilotSessionContext, + permissionMode: string | undefined, + requestId: string, + ): Promise { + const target = this.resolveModeId(ctx, permissionMode); + if (!target || target === ctx.currentModeId) return ctx.currentModeId; + const setter = ( + ctx.agent as unknown as { + setSessionMode?: (req: { + sessionId: string; + modeId: string; + }) => Promise; + } + ).setSessionMode; + if (!setter) return ctx.currentModeId; + try { + await setter.call(ctx.agent, { + sessionId: ctx.providerSessionId, + modeId: target, + }); + ctx.currentModeId = target; + return target; + } catch (err) { + logger.info("copilot setSessionMode failed", { + modeId: target, + ...errorDetails(err), + }); + ctx.activeEmitter?.passthrough(requestId, { + type: "copilot/status", + status: "WARNING", + warning: "mode_switch_failed", + modeId: target, + }); + return ctx.currentModeId; + } + } + + private buildClient(getCtx: () => CopilotSessionContext): Client { + return { + sessionUpdate: async (params: SessionNotification) => { + const ctx = getCtx(); + const requestId = ctx.activeRequestId; + const emitter = ctx.activeEmitter; + if (!requestId || !emitter) return; + const update = params.update; + // usage_update → persist via emitter.contextUsageUpdated + // so it flows through the same Codex pipeline that + // updates `sessions.context_usage_meta` and triggers + // `UiMutationEvent::ContextUsageChanged`. Also emit the + // raw passthrough for any UI that wants live deltas. + if (update.sessionUpdate === "usage_update") { + const usage = update as { + used?: number; + size?: number; + _meta?: Record | null; + }; + const meta = buildCopilotStoredMeta( + { + used: usage.used, + size: usage.size, + }, + ctx.currentModelId, + ); + if (meta) { + emitter.contextUsageUpdated( + requestId, + ctx.providerSessionId, + JSON.stringify(meta), + ); + } + } + // current_mode_update → reflect server-driven mode + // changes (slash commands, autopilot continues) so the + // next sendMessage doesn't try to "switch back" via a + // redundant setSessionMode call. + if (update.sessionUpdate === "current_mode_update") { + const modeUpdate = update as { currentModeId?: string }; + if (modeUpdate.currentModeId) { + ctx.currentModeId = modeUpdate.currentModeId; + emitter.passthrough(requestId, { + type: "copilot/status", + status: "MODE_CHANGED", + mode: modeUpdate.currentModeId, + }); + } + } + for (const event of mapSessionUpdate(update)) { + emitter.passthrough(requestId, event); + } + }, + requestPermission: async ( + params: RequestPermissionRequest, + ): Promise => { + const ctx = getCtx(); + const requestId = ctx.activeRequestId; + const emitter = ctx.activeEmitter; + if (!requestId || !emitter) { + return { outcome: { outcome: "cancelled" } }; + } + if (ctx.activePermissionMode === "bypassPermissions") { + const option = pickPermissionOption(params.options, "allow"); + return option + ? { + outcome: { + outcome: "selected", + optionId: option.optionId, + }, + } + : { outcome: { outcome: "cancelled" } }; + } + const permissionId = `copilot-${randomUUID()}`; + const response = new Promise((resolve) => { + this.pendingPermissions.set(permissionId, { + sessionId: ctx.providerSessionId, + options: params.options, + resolve, + }); + }); + emitter.permissionRequest( + requestId, + permissionId, + toolNameForPermission(params.toolCall), + toolInputForPermission(params.toolCall), + undefined, + params.toolCall.title ?? "Copilot requested permission", + ); + return response; + }, + }; + } +} + +function buildPromptInput( + prompt: string, + images: readonly string[], +): Promise { + const parsed = parseImageRefs(prompt, images); + const reads = parsed.imagePaths.map(async (imagePath) => { + try { + const { buffer } = await readImageWithResize(imagePath); + const block: ContentBlock = { + type: "image", + data: buffer.toString("base64"), + mimeType: extToMediaType(imagePath), + uri: imagePath, + }; + return block; + } catch (err) { + logger.error("Failed to read Copilot image attachment", { + imagePath, + ...errorDetails(err), + }); + return null; + } + }); + return Promise.all(reads).then((imageBlocks) => { + const blocks: ContentBlock[] = []; + if (parsed.text) { + blocks.push({ type: "text", text: parsed.text }); + } + for (const block of imageBlocks) { + if (block) blocks.push(block); + } + if (blocks.length === 0) { + blocks.push({ type: "text", text: prompt }); + } + return blocks; + }); +} + +/// Cheap order-independent equality for the linked-directory list. +function sameDirectories(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) return false; + const sortedA = [...a].sort(); + const sortedB = [...b].sort(); + for (let i = 0; i < sortedA.length; i += 1) { + if (sortedA[i] !== sortedB[i]) return false; + } + return true; +} + +function extToMediaType(path: string): string { + switch (extname(path).toLowerCase()) { + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".gif": + return "image/gif"; + case ".webp": + return "image/webp"; + default: + return "image/png"; + } +} + +function mapSessionUpdate(update: SessionUpdate): object[] { + switch (update.sessionUpdate) { + case "agent_message_chunk": + return textFromContent(update.content).map((text) => ({ + type: "copilot/assistant", + message: { content: [{ type: "text", text }] }, + })); + case "agent_thought_chunk": + return textFromContent(update.content).map((text) => ({ + type: "copilot/thinking", + text, + })); + case "tool_call": + return [ + { + type: + update.status === "completed" || update.status === "failed" + ? "copilot/tool_call_end" + : "copilot/tool_call_start", + ...toolCallToEvent(update), + }, + ]; + case "tool_call_update": + return [ + { + type: + update.status === "completed" || update.status === "failed" + ? "copilot/tool_call_end" + : "copilot/tool_call_start", + ...toolCallUpdateToEvent(update), + }, + ]; + case "plan": + return [ + { + type: "copilot/thinking", + text: update.entries + .map((entry) => `${entry.status}: ${entry.content}`) + .join("\n"), + }, + ]; + case "usage_update": + return [ + { + type: "copilot/usage", + used: update.used, + size: update.size, + }, + ]; + default: + return []; + } +} + +function textFromContent(content: ContentBlock): string[] { + if (content.type === "text" && content.text) return [content.text]; + return []; +} + +function toolCallToEvent(tool: ToolCall): Record { + return { + call_id: tool.toolCallId, + name: tool.title, + args: tool.rawInput ?? {}, + result: tool.rawOutput ?? tool.content ?? null, + status: tool.status ?? "pending", + }; +} + +function toolCallUpdateToEvent(tool: ToolCallUpdate): Record { + return { + call_id: tool.toolCallId, + name: tool.title ?? "tool", + args: tool.rawInput ?? {}, + result: tool.rawOutput ?? tool.content ?? null, + status: tool.status ?? "pending", + }; +} + +function toolNameForPermission(tool: ToolCallUpdate): string { + if (tool.kind === "execute") return "Bash"; + if (tool.kind === "edit") return "apply_patch"; + if (tool.kind === "read") return "Read"; + return tool.title ?? "Copilot"; +} + +function toolInputForPermission(tool: ToolCallUpdate): Record { + return { + title: tool.title, + kind: tool.kind, + rawInput: tool.rawInput, + locations: tool.locations, + }; +} + +/// Pick the option that matches the requested behaviour. For "deny" +/// we MUST return undefined when no reject-prefixed option exists — +/// callers fall back to a synthesized `cancelled` outcome rather than +/// silently picking the first (likely allow) option, which would +/// invert the user's intent. +function pickPermissionOption( + options: readonly PermissionOption[], + behavior: "allow" | "deny", +): PermissionOption | undefined { + if (behavior === "allow") { + return ( + options.find((option) => option.kind.startsWith("allow")) ?? options[0] + ); + } + return options.find((option) => option.kind.startsWith("reject")); +} diff --git a/sidecar/src/index.ts b/sidecar/src/index.ts index 6214ec604..487c5c297 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 { CopilotAcpSessionManager } from "./copilot-acp-session-manager.js"; import { CursorSessionManager } from "./cursor-session-manager.js"; import { createSidecarEmitter } from "./emitter.js"; import { errorDetails, logger } from "./logger.js"; @@ -43,10 +44,12 @@ import { const claudeManager = new ClaudeSessionManager(); const codexManager = new CodexAppServerManager(); const cursorManager = new CursorSessionManager(); +const copilotManager = new CopilotAcpSessionManager(); const managers: Record = { claude: claudeManager, codex: codexManager, cursor: cursorManager, + copilot: copilotManager, }; // `parentGone` flips to true only when stdin EOFs — that's the @@ -596,9 +599,16 @@ for await (const line of rl) { const message = typeof params.message === "string" ? params.message : undefined; logger.debug(`[${id}] permissionResponse`, { permissionId, behavior }); - // Route to the right provider — Codex permissions use "codex-" prefix + // Route to the right provider via permission-id prefix: + // "codex-*" → Codex AppServer manager + // "copilot-*" → Copilot ACP manager + // anything else (including unprefixed Claude SDK ids that look + // like opaque tokens) → Claude manager. Each provider mints its + // own ids in its handler, so the prefix is the source of truth. if (permissionId.startsWith("codex-")) { codexManager.resolvePermission(permissionId, behavior); + } else if (permissionId.startsWith("copilot-")) { + copilotManager.resolvePermission(permissionId, behavior); } else { claudeManager.resolvePermission( permissionId, diff --git a/sidecar/src/model-catalog.ts b/sidecar/src/model-catalog.ts index d68320368..6511bb9dc 100644 --- a/sidecar/src/model-catalog.ts +++ b/sidecar/src/model-catalog.ts @@ -99,6 +99,13 @@ const MODEL_CATALOG: Record = { effortLevels: CURSOR_REASONING_LEVELS, }, ], + copilot: [ + { + id: "copilot-default", + label: "Default", + cliModel: "default", + }, + ], }; export function listProviderModels(provider: Provider): ProviderModelInfo[] { 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 69afb68da..7b3f77a8a 100644 --- a/src-tauri/src/agents.rs +++ b/src-tauri/src/agents.rs @@ -214,6 +214,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..8c0b7c859 100644 --- a/src-tauri/src/agents/catalog.rs +++ b/src-tauri/src/agents/catalog.rs @@ -41,6 +41,7 @@ pub fn static_model_sections() -> Vec { model_sections_for_inputs( super::custom_providers::configured_models(), load_cursor_prefs(), + load_copilot_prefs(), ) } @@ -49,6 +50,7 @@ pub fn static_model_sections() -> Vec { fn model_sections_for_inputs( custom: Vec, cursor_prefs: Option, + copilot_prefs: Option, ) -> Vec { let mut claude_section = official_claude_section(); claude_section @@ -57,6 +59,7 @@ fn model_sections_for_inputs( let mut sections = vec![claude_section]; sections.push(codex_section()); sections.push(cursor_section_from_prefs(cursor_prefs)); + sections.push(copilot_section_from_prefs(copilot_prefs)); sections } @@ -118,6 +121,96 @@ fn cursor_section_from_prefs(prefs: Option) -> AgentModelSection { } } +/// Copilot picker section. When `app.copilot_provider.cachedModels` has been +/// populated by a prior `fetch_copilot_models` call, those live models are +/// shown. Otherwise falls back to the single "Default" entry so the section +/// is always non-empty. +fn copilot_section_from_prefs(prefs: Option) -> AgentModelSection { + let options = match prefs { + Some(p) if !p.cached_models.is_empty() => p + .cached_models + .into_iter() + .map(|(id, label)| AgentModelOption { + id: id.clone(), + provider: "copilot".to_string(), + label, + cli_model: id, + provider_key: None, + effort_levels: vec![ + "low".to_string(), + "medium".to_string(), + "high".to_string(), + "xhigh".to_string(), + ], + supports_fast_mode: false, + supports_context_usage: true, + }) + .collect(), + _ => vec![copilot_default_option()], + }; + AgentModelSection { + id: "copilot".to_string(), + label: "GitHub Copilot".to_string(), + status: AgentModelSectionStatus::Ready, + options, + } +} + +fn copilot_default_option() -> AgentModelOption { + AgentModelOption { + id: "copilot-default".to_string(), + provider: "copilot".to_string(), + label: "Default".to_string(), + cli_model: "default".to_string(), + provider_key: None, + // Copilot CLI's `--effort` flag accepts low/medium/high/xhigh. + // Surface them so the composer's effort picker renders. + effort_levels: vec![ + "low".to_string(), + "medium".to_string(), + "high".to_string(), + "xhigh".to_string(), + ], + supports_fast_mode: false, + // ACP `usage_update` notifications now flow through the + // shared contextUsageUpdated pipeline; the composer ring + // is gated on this flag. + supports_context_usage: true, + } +} + +#[derive(Debug, Clone)] +struct CopilotPrefs { + /// `(modelId, label)` pairs from the last `fetch_copilot_models` snapshot. + cached_models: Vec<(String, String)>, +} + +fn load_copilot_prefs() -> Option { + let raw = crate::models::settings::load_setting_value("app.copilot_provider") + .ok() + .flatten()?; + let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?; + let cached_models = match parsed.get("cachedModels") { + Some(serde_json::Value::Array(arr)) => { + let mut out: Vec<(String, String)> = Vec::with_capacity(arr.len()); + for item in arr { + let Some(id) = item.get("id").and_then(serde_json::Value::as_str) else { + continue; + }; + let label = item + .get("label") + .and_then(serde_json::Value::as_str) + .unwrap_or(id) + .to_string(); + out.push((id.to_string(), label)); + } + out + } + _ => vec![], + }; + Some(CopilotPrefs { cached_models }) +} + #[derive(Debug, Clone)] struct CursorCachedModelEntry { label: String, @@ -393,9 +486,11 @@ pub fn resolve_model(model_id: &str, provider_hint: Option<&str>) -> ResolvedMod let provider = match provider_hint { Some("cursor") => "cursor", + Some("copilot") => "copilot", Some("codex") => "codex", Some("claude") => "claude", _ if model_id.starts_with("cursor-") => "cursor", + _ if model_id.starts_with("copilot-") => "copilot", _ if model_id.starts_with("composer-") => "cursor", _ if model_id.starts_with("gpt-") => "codex", _ => "claude", @@ -407,6 +502,11 @@ pub fn resolve_model(model_id: &str, provider_hint: Option<&str>) -> ResolvedMod .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() }; @@ -428,9 +528,9 @@ mod tests { #[test] fn static_model_sections_returns_hardcoded_catalog() { // `None` cursor_prefs → cursor section degrades to just Auto. - let sections = model_sections_for_inputs(Vec::new(), None); + let sections = model_sections_for_inputs(Vec::new(), None, 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!( @@ -479,6 +579,16 @@ mod tests { assert_eq!(auto.cli_model, "default"); assert_eq!(auto.provider, "cursor"); assert_eq!(sections[2].options.len(), 1); + + assert_eq!(sections[3].id, "copilot"); + assert_eq!(sections[3].label, "GitHub Copilot"); + assert_eq!(sections[3].options[0].id, "copilot-default"); + assert_eq!(sections[3].options[0].provider, "copilot"); + assert_eq!( + sections[3].options[0].effort_levels, + vec!["low", "medium", "high", "xhigh"] + ); + assert!(sections[3].options[0].supports_context_usage); } #[test] @@ -493,9 +603,10 @@ mod tests { api_key: "sk-test".to_string(), }], None, + 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!( @@ -599,6 +710,18 @@ mod tests { assert_eq!(m.cli_model, "gpt-5.3-codex"); } + #[test] + fn copilot_namespaced_id_strips_to_default_wire_model() { + let m = resolve_model("copilot-default", Some("copilot")); + assert_eq!(m.provider, "copilot"); + assert_eq!(m.id, "copilot-default"); + assert_eq!(m.cli_model, "default"); + + let inferred = resolve_model("copilot-default", None); + assert_eq!(inferred.provider, "copilot"); + assert_eq!(inferred.cli_model, "default"); + } + #[test] fn claude_default_no_longer_collides_with_cursor_auto() { // `default` belongs to Claude (Opus 4.7 1M). Cursor's Auto is @@ -648,7 +771,7 @@ mod tests { Some(vec![cursor_param("reasoning", &["low", "medium", "high"])]), )]), }; - let sections = model_sections_for_inputs(Vec::new(), Some(prefs)); + let sections = model_sections_for_inputs(Vec::new(), Some(prefs), None); let cursor = sections.iter().find(|s| s.id == "cursor").unwrap(); assert_eq!(cursor.options.len(), 1); let opt = &cursor.options[0]; @@ -670,7 +793,7 @@ mod tests { Some(vec![cursor_param("fast", &["true", "false"])]), )]), }; - let sections = model_sections_for_inputs(Vec::new(), Some(prefs)); + let sections = model_sections_for_inputs(Vec::new(), Some(prefs), None); let cursor = sections.iter().find(|s| s.id == "cursor").unwrap(); let opt = &cursor.options[0]; assert!(opt.effort_levels.is_empty()); @@ -691,7 +814,7 @@ mod tests { Some(vec![cursor_param("thinking", &["false", "true"])]), )]), }; - let sections = model_sections_for_inputs(Vec::new(), Some(prefs)); + let sections = model_sections_for_inputs(Vec::new(), Some(prefs), None); let opt = §ions.iter().find(|s| s.id == "cursor").unwrap().options[0]; assert!(opt.effort_levels.is_empty()); assert!(!opt.supports_fast_mode); @@ -714,7 +837,7 @@ mod tests { ]), )]), }; - let sections = model_sections_for_inputs(Vec::new(), Some(prefs)); + let sections = model_sections_for_inputs(Vec::new(), Some(prefs), None); let opt = §ions.iter().find(|s| s.id == "cursor").unwrap().options[0]; assert_eq!(opt.effort_levels, vec!["low", "medium", "high", "max"]); assert!(opt.supports_fast_mode); @@ -735,7 +858,7 @@ mod tests { ]), )]), }; - let sections = model_sections_for_inputs(Vec::new(), Some(prefs)); + let sections = model_sections_for_inputs(Vec::new(), Some(prefs), None); let opt = §ions.iter().find(|s| s.id == "cursor").unwrap().options[0]; assert_eq!(opt.effort_levels, vec!["max"]); } @@ -753,7 +876,7 @@ mod tests { ]), )]), }; - let sections = model_sections_for_inputs(Vec::new(), Some(prefs)); + let sections = model_sections_for_inputs(Vec::new(), Some(prefs), None); let opt = §ions.iter().find(|s| s.id == "cursor").unwrap().options[0]; assert_eq!(opt.effort_levels, vec!["low", "medium", "high"]); assert!(opt.supports_fast_mode); @@ -769,7 +892,7 @@ mod tests { enabled_ids: Some(vec!["legacy".to_string()]), cached_models: Some(vec![cursor_cache("legacy", "Legacy Cached", None)]), }; - let sections = model_sections_for_inputs(Vec::new(), Some(prefs)); + let sections = model_sections_for_inputs(Vec::new(), Some(prefs), None); let opt = §ions.iter().find(|s| s.id == "cursor").unwrap().options[0]; assert!(opt.effort_levels.is_empty()); assert!(!opt.supports_fast_mode); @@ -784,7 +907,7 @@ mod tests { enabled_ids: Some(vec!["mystery-model".to_string()]), cached_models: Some(Vec::new()), }; - let sections = model_sections_for_inputs(Vec::new(), Some(prefs)); + let sections = model_sections_for_inputs(Vec::new(), Some(prefs), None); let opt = §ions.iter().find(|s| s.id == "cursor").unwrap().options[0]; assert_eq!(opt.cli_model, "mystery-model"); assert_eq!(opt.label, "mystery-model"); @@ -827,7 +950,7 @@ mod tests { enabled_ids: Some(pick.iter().map(|s| s.to_string()).collect()), cached_models: Some(cached_models), }; - let sections = model_sections_for_inputs(Vec::new(), Some(prefs)); + let sections = model_sections_for_inputs(Vec::new(), Some(prefs), None); let cursor = sections.iter().find(|s| s.id == "cursor").unwrap(); let by_wire: std::collections::HashMap = cursor .options @@ -888,5 +1011,9 @@ mod tests { assert_eq!(claude.provider, "claude"); let cursor = resolve_model("claude-sonnet-4-5", Some("cursor")); assert_eq!(cursor.provider, "cursor"); + + let copilot = resolve_model("default", Some("copilot")); + assert_eq!(copilot.provider, "copilot"); + assert_eq!(copilot.cli_model, "default"); } } diff --git a/src-tauri/src/agents/queries.rs b/src-tauri/src/agents/queries.rs index f23b0340f..6ac792feb 100644 --- a/src-tauri/src/agents/queries.rs +++ b/src-tauri/src/agents/queries.rs @@ -1107,6 +1107,98 @@ fn parse_cursor_parameters(arr: &[Value]) -> Vec { .collect() } +// --------------------------------------------------------------------------- +// Copilot model list — proxied to the sidecar's ACP probe +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CopilotModelEntry { + pub id: String, + pub label: String, +} + +/// 20s budget — the sidecar's own probe has a 15s internal timeout, so +/// this outer cap catches sidecar hangs without leaving the Tauri command +/// waiting forever. +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 sidecar_req = crate::sidecar::SidecarRequest { + id: request_id.clone(), + method: "listModels".to_string(), + params: serde_json::json!({ "provider": "copilot" }), + }; + + 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(); + models.push(CopilotModelEntry { + id: id.to_string(), + label, + }); + } + } + 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 a107b607f..883f28432 100644 --- a/src-tauri/src/commands/system_commands.rs +++ b/src-tauri/src/commands/system_commands.rs @@ -47,6 +47,7 @@ pub struct AgentLoginStatus { pub claude: bool, pub codex: bool, pub cursor: bool, + pub copilot: bool, } #[derive(Debug, Clone, Serialize)] @@ -390,6 +391,7 @@ fn helmor_skills_status() -> anyhow::Result { claude: claude_login_ready(), codex: codex_login_ready(), cursor: cursor_login_ready(), + copilot: copilot_login_ready(), }, ))) } @@ -528,6 +530,7 @@ pub async fn install_helmor_skills() -> CmdResult { claude: claude_login_ready(), codex: codex_login_ready(), cursor: cursor_login_ready(), + copilot: copilot_login_ready(), }; let agents = ready_skill_agents(&login); let command = helmor_skills_install_command(&agents); @@ -656,6 +659,7 @@ pub async fn get_agent_login_status() -> CmdResult { claude: claude_login_ready(), codex: codex_login_ready(), cursor: cursor_login_ready(), + copilot: copilot_login_ready(), }) }) .await @@ -735,6 +739,53 @@ fn codex_login_ready() -> bool { } } +fn copilot_login_ready() -> bool { + // Step 1 — Copilot CLI must be reachable (env override → PATH). + // Without the binary, no env token can rescue the session. + let copilot_bin = std::env::var("HELMOR_COPILOT_BIN_PATH") + .ok() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "copilot".to_string()); + + match std::process::Command::new(&copilot_bin) + .arg("--version") + .output() + { + Ok(output) if output.status.success() => {} + Ok(output) => { + tracing::trace!( + stderr = %String::from_utf8_lossy(&output.stderr).trim(), + "Copilot CLI unavailable (--version returned non-zero)" + ); + return false; + } + Err(error) => { + tracing::debug!("Copilot CLI unavailable: {error}"); + return false; + } + } + + // Step 2 — credential probe. Headless authentication uses one of + // the documented env vars (`COPILOT_GITHUB_TOKEN` is the + // Copilot-specific name; `GH_TOKEN`/`GITHUB_TOKEN` are accepted + // for parity with other GitHub tooling). When none are set the + // CLI falls back to OS-keychain credentials written by an + // interactive `copilot login` / in-REPL `/login` — Helmor cannot + // probe the keychain portably, so CLI presence alone is treated + // as "ready" and any per-session auth failure surfaces through + // the normal error path. + if std::env::var("COPILOT_GITHUB_TOKEN") + .or_else(|_| std::env::var("GH_TOKEN")) + .or_else(|_| std::env::var("GITHUB_TOKEN")) + .ok() + .is_some_and(|token| !token.trim().is_empty()) + { + return true; + } + + true +} + fn parse_claude_login_status(stdout: &[u8]) -> bool { serde_json::from_slice::(stdout) .ok() @@ -751,6 +802,10 @@ fn agent_login_command(provider: &str) -> anyhow::Result<&'static str> { match provider { "claude" => Ok("claude auth login"), "codex" => Ok("codex login"), + // `copilot login` is the headless equivalent. Users already + // inside the REPL can also run `/login` interactively — both + // routes write to the same OS-keychain credentials. + "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 4145c2fd6..6c51a7773 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -225,6 +225,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ agents::list_agent_model_sections, agents::list_cursor_models, + agents::list_copilot_models, agents::send_agent_message_stream, agents::stop_agent_stream, agents::list_active_streams, diff --git a/src-tauri/src/pipeline/accumulator/mod.rs b/src-tauri/src/pipeline/accumulator/mod.rs index 6e10c4231..0675ede12 100644 --- a/src-tauri/src/pipeline/accumulator/mod.rs +++ b/src-tauri/src/pipeline/accumulator/mod.rs @@ -492,6 +492,21 @@ 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), + // ── GitHub Copilot ACP events (namespaced by sidecar manager) ── + // Copilot speaks the Agent Client Protocol whose event vocabulary + // overlaps with Cursor's SDK; both providers reuse the cursor::* + // handlers. session_id is lifted by push_event extractor above. + Some("copilot/session_started") => PushOutcome::NoOp, + Some("copilot/status") => cursor::handle_status(self, value), + Some("copilot/thinking") => cursor::handle_thinking(self, value), + Some("copilot/assistant") => cursor::handle_assistant_delta(self, value), + Some("copilot/tool_call_start") => cursor::handle_tool_call_start(self, value), + Some("copilot/tool_call_end") => cursor::handle_tool_call_end(self, value), + // Live token telemetry for the composer ring. Persistence + // happens upstream via emitter.contextUsageUpdated; the + // raw passthrough is informational only. + Some("copilot/usage") => PushOutcome::NoOp, + // ── Codex informational notifications (no render) ──────── Some("thread/status/changed") | Some("thread/tokenUsage/updated") diff --git a/src-tauri/src/pipeline/accumulator/tests.rs b/src-tauri/src/pipeline/accumulator/tests.rs index 53469b55e..68f9a1ce4 100644 --- a/src-tauri/src/pipeline/accumulator/tests.rs +++ b/src-tauri/src/pipeline/accumulator/tests.rs @@ -469,6 +469,75 @@ fn codex_command_execution_synthesis() { assert_eq!(snapshot[1].role, MessageRole::User); } +#[test] +fn copilot_acp_text_and_tool_synthesize_shared_messages() { + let mut acc = StreamAccumulator::new("copilot", "default"); + acc.push_event( + &json!({ + "type": "copilot/session_started", + "session_id": "copilot-session-1", + "model": "default" + }), + "", + ); + acc.push_event( + &json!({ + "type": "copilot/status", + "status": "RUNNING", + "run_id": "run-1" + }), + "", + ); + acc.push_event( + &json!({ + "type": "copilot/tool_call_start", + "call_id": "tool-1", + "name": "List files", + "args": {"command": "ls"} + }), + "", + ); + acc.push_event( + &json!({ + "type": "copilot/tool_call_end", + "call_id": "tool-1", + "name": "List files", + "args": {"command": "ls"}, + "result": {"status": "success", "output": "src"} + }), + "", + ); + acc.push_event( + &json!({ + "type": "copilot/assistant", + "message": {"content": [{"type": "text", "text": "Done"}]} + }), + "", + ); + acc.push_event( + &json!({ + "type": "copilot/status", + "status": "FINISHED", + "run_id": "run-1" + }), + "", + ); + + assert_eq!(acc.session_id.as_deref(), Some("copilot-session-1")); + assert_eq!(acc.turns_len(), 2); + let snapshot = acc.snapshot("ctx", "sess"); + assert_eq!(snapshot.len(), 3); + assert_eq!(snapshot[0].role, MessageRole::Assistant); + assert_eq!(snapshot[1].role, MessageRole::User); + assert_eq!(snapshot[2].role, MessageRole::Assistant); + let assistant = snapshot[0].parsed.as_ref().unwrap(); + let content = assistant["message"]["content"].as_array().unwrap(); + assert!(content.iter().any(|part| part["type"] == "tool_use")); + assert!(content + .iter() + .any(|part| part["type"] == "text" && part["text"] == "Done")); +} + #[test] fn partial_identity_stays_stable_across_deltas() { let mut acc = StreamAccumulator::new("claude", "opus"); diff --git a/src-tauri/src/sidecar.rs b/src-tauri/src/sidecar.rs index 89bf4dd5a..7b539c364 100644 --- a/src-tauri/src/sidecar.rs +++ b/src-tauri/src/sidecar.rs @@ -80,6 +80,7 @@ struct SidecarProcess { struct BundledAgentPaths { claude_bin: Option, codex_bin: Option, + copilot_bin: Option, } fn resolve_bundled_agent_paths() -> BundledAgentPaths { @@ -109,13 +110,24 @@ fn resolve_bundled_agent_paths_for_exe(exe: &std::path::Path) -> Option; + if (model?.provider === "copilot") + return ; if (model?.provider === "codex") return ; if (model?.providerKey === "custom") diff --git a/src/features/composer/container.tsx b/src/features/composer/container.tsx index a7a7e9cf5..b477ac7ee 100644 --- a/src/features/composer/container.tsx +++ b/src/features/composer/container.tsx @@ -480,9 +480,21 @@ export const WorkspaceComposerContainer = memo( const sessionPermissionMode = sessionIsConfigured ? currentSession?.permissionMode : null; + // Preserve the full set of permission modes Helmor understands. + // Copilot adds "autopilot" as a third state alongside plan/ + // bypassPermissions; Cursor & older sessions still collapse to + // the legacy default of bypassPermissions. + const knownModes = new Set([ + "default", + "plan", + "bypassPermissions", + "autopilot", + ]); const effectivePermissionMode = cachedPermissionMode ?? - (sessionPermissionMode === "plan" ? "plan" : "bypassPermissions"); + (sessionPermissionMode && knownModes.has(sessionPermissionMode) + ? sessionPermissionMode + : "bypassPermissions"); const supportsFastMode = effectiveModel?.supportsFastMode === true; const cachedFastMode = fastModes[composerContextKey]; const sessionFastMode = sessionIsConfigured @@ -630,7 +642,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 = @@ -1000,11 +1014,11 @@ export const WorkspaceComposerContainer = memo( placeholder={placeholder} providerSessionId={currentSession?.providerSessionId ?? null} agentType={ - effectiveModel?.provider === "codex" - ? "codex" - : effectiveModel?.provider === "cursor" - ? "cursor" - : "claude" + effectiveModel?.provider === "codex" || + effectiveModel?.provider === "cursor" || + effectiveModel?.provider === "copilot" + ? effectiveModel.provider + : "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 a202be52b..64e1d035c 100644 --- a/src/features/composer/context-usage-ring/index.tsx +++ b/src/features/composer/context-usage-ring/index.tsx @@ -5,6 +5,7 @@ import { HoverCardContent, HoverCardTrigger, } from "@/components/ui/hover-card"; +import type { AgentProvider } from "@/lib/api"; import { setSessionContextUsage } from "@/lib/api"; import { claudeRichContextUsageQueryOptions, @@ -29,7 +30,7 @@ type Props = { * right project config. */ cwd: string | null; /** Only Claude supports the rich hover breakdown. */ - agentType: "claude" | "codex" | "cursor" | null; + agentType: AgentProvider | 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 d9f9f5887..bbafa9498 100644 --- a/src/features/composer/index.tsx +++ b/src/features/composer/index.tsx @@ -43,6 +43,7 @@ import { normalizeShortcutEvent } from "@/features/shortcuts/format"; import { InlineShortcutDisplay } from "@/features/shortcuts/shortcut-display"; import type { AgentModelSection, + AgentProvider, CandidateDirectory, SlashCommandEntry, } from "@/lib/api"; @@ -171,10 +172,10 @@ type WorkspaceComposerProps = { * context-usage ring for its hover-triggered live fetch. */ providerSessionId?: string | null; /** Agent provider for this session — gates the Claude-only rich fetch - * 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; + * and selects which rate-limits API to query. Cursor/Copilot don't + * expose rate-limit / context-usage endpoints here yet, so the + * indicators just hide for those sessions. */ + agentType?: AgentProvider | null; focusShortcut?: string | null; togglePlanShortcut?: string | null; /** Hotkey that submits the current draft with the opposite follow-up @@ -326,8 +327,13 @@ export const WorkspaceComposer = memo(function WorkspaceComposer({ const supportsEffort = availableEffortLevels.length > 0; const supportsFastMode = selectedModel?.supportsFastMode === true; const supportsContextUsage = selectedModel?.supportsContextUsage !== false; - // Cursor SDK auto-handles plans internally — no toggle to expose. + // Cursor handles plan-mode internally with no toggle. Copilot ACP + // exposes plan/autopilot via setSessionMode → keep the toggle. const supportsPlanMode = selectedModel?.provider !== "cursor"; + // Autopilot is a Copilot-only third state (besides default/plan). + // Wired through `permissionMode = "autopilot"` so the sidecar can + // translate it to ACP's `setSessionMode(autopilot)`. + const supportsAutopilot = selectedModel?.provider === "copilot"; const effectiveEffort = useMemo( () => clampEffort(effortLevel, availableEffortLevels), [effortLevel, availableEffortLevels], @@ -989,6 +995,28 @@ export const WorkspaceComposer = memo(function WorkspaceComposer({ Plan ) : null} + {supportsAutopilot ? ( + + onChangePermissionMode( + permissionMode === "autopilot" + ? "default" + : "autopilot", + ) + } + > + + Autopilot + + ) : null} {onToggleContextPanel ? ( diff --git a/src/features/composer/usage-stats-indicator/index.tsx b/src/features/composer/usage-stats-indicator/index.tsx index 178ea1860..f4f7b44f6 100644 --- a/src/features/composer/usage-stats-indicator/index.tsx +++ b/src/features/composer/usage-stats-indicator/index.tsx @@ -6,6 +6,7 @@ import { HoverCardContent, HoverCardTrigger, } from "@/components/ui/hover-card"; +import type { AgentProvider } from "@/lib/api"; import { claudeRateLimitsQueryOptions, codexRateLimitsQueryOptions, @@ -22,7 +23,7 @@ import { import { LimitRow } from "../context-usage-ring/popover-parts"; type Props = { - agentType: "claude" | "codex" | "cursor" | null; + agentType: AgentProvider | null; disabled?: boolean; className?: string; }; diff --git a/src/features/onboarding/agent-login-state.ts b/src/features/onboarding/agent-login-state.ts index 3e4ea14e3..c05eb199e 100644 --- a/src/features/onboarding/agent-login-state.ts +++ b/src/features/onboarding/agent-login-state.ts @@ -1,3 +1,4 @@ +import { GithubBrandIcon } from "@/components/brand-icon"; import { ClaudeIcon, CursorIcon, OpenAIIcon } from "@/components/icons"; import type { AgentLoginStatusResult } from "@/lib/api"; import type { AgentLoginItem } from "./types"; @@ -33,5 +34,14 @@ export function buildAgentLoginItems( : "Add a Cursor API key to use Cursor models in Helmor.", status: status?.cursor ? "ready" : "needsSetup", }, + { + icon: GithubBrandIcon, + provider: "copilot", + label: "GitHub Copilot", + description: status?.copilot + ? "GitHub Copilot CLI is installed and ready." + : "Install GitHub Copilot CLI (`npm i -g @github/copilot`) and sign in with `copilot login` to use Copilot 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 7f0e2c6a0..edb8924e1 100644 --- a/src/features/onboarding/components/login-terminal-preview.tsx +++ b/src/features/onboarding/components/login-terminal-preview.tsx @@ -17,6 +17,7 @@ import { cn } from "@/lib/utils"; const providerLabels: Record = { claude: "Claude Code", codex: "Codex", + copilot: "GitHub Copilot", // Cursor never reaches the login terminal — kept here only to // satisfy the exhaustive Record type. cursor: "Cursor", diff --git a/src/features/panel/header.tsx b/src/features/panel/header.tsx index b0d37c87c..523d0b937 100644 --- a/src/features/panel/header.tsx +++ b/src/features/panel/header.tsx @@ -22,6 +22,7 @@ import { accountInfoFromForgeAccount, } from "@/components/account-hover-card-content"; import { BranchPickerPopover } from "@/components/branch-picker"; +import { GithubBrandIcon } from "@/components/brand-icon"; import { CachedAvatar } from "@/components/cached-avatar"; import { HelmorThinkingIndicator } from "@/components/helmor-thinking-indicator"; import { ClaudeIcon, CursorIcon, OpenAIIcon } from "@/components/icons"; @@ -974,6 +975,11 @@ function SessionProviderIcon({ if (agentType === "cursor") { return ; } + if (agentType === "copilot") { + return ( + + ); + } return ; } diff --git a/src/features/panel/use-confirm-session-close.tsx b/src/features/panel/use-confirm-session-close.tsx index 2943d2fa1..e5e99c2be 100644 --- a/src/features/panel/use-confirm-session-close.tsx +++ b/src/features/panel/use-confirm-session-close.tsx @@ -112,6 +112,7 @@ export function useConfirmSessionClose({ const provider = pending.provider ?? pending.session.agentType; if (provider === "codex") return "Codex"; if (provider === "cursor") return "Cursor"; + if (provider === "copilot") return "GitHub Copilot"; return "Claude"; }, [pending]); diff --git a/src/features/settings/index.tsx b/src/features/settings/index.tsx index f6334ed0e..690b17416 100644 --- a/src/features/settings/index.tsx +++ b/src/features/settings/index.tsx @@ -66,6 +66,7 @@ import { AccountPanel } from "./panels/account"; import { AppUpdatesPanel } from "./panels/app-updates"; import { CliInstallPanel } from "./panels/cli-install"; import { ConductorImportPanel } from "./panels/conductor-import"; +import { CopilotProviderPanel } from "./panels/copilot-provider"; import { CursorProviderPanel } from "./panels/cursor-provider"; import { DevToolsPanel } from "./panels/dev-tools"; import { InboxSettingsPanel } from "./panels/inbox"; @@ -722,6 +723,7 @@ export const SettingsDialog = memo(function SettingsDialog({ /> + )} diff --git a/src/features/settings/panels/copilot-provider.tsx b/src/features/settings/panels/copilot-provider.tsx new file mode 100644 index 000000000..e31c29e71 --- /dev/null +++ b/src/features/settings/panels/copilot-provider.tsx @@ -0,0 +1,105 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { RefreshCcw } from "lucide-react"; +import { useEffect, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { listCopilotModels } from "@/lib/api"; +import { helmorQueryKeys } from "@/lib/query-client"; +import { type CopilotProviderSettings, useSettings } from "@/lib/settings"; +import { SettingsRow } from "../components/settings-row"; + +export function CopilotProviderPanel() { + const queryClient = useQueryClient(); + const { settings, updateSettings } = useSettings(); + const copilot = settings.copilotProvider; + + const persist = async (patch: Partial) => { + await Promise.resolve( + updateSettings({ copilotProvider: { ...copilot, ...patch } }), + ); + queryClient.invalidateQueries({ + queryKey: helmorQueryKeys.agentModelSections, + }); + }; + + const fetchMutation = useMutation({ + mutationFn: () => listCopilotModels(), + onSuccess: async (models) => { + await persist({ + cachedModels: models.map((m) => ({ id: m.id, label: m.label })), + }); + }, + }); + + // Auto-fetch once on mount if no catalog yet. + const fetchedOnceRef = useRef(false); + useEffect(() => { + if ( + copilot.cachedModels === null && + !fetchMutation.isPending && + !fetchedOnceRef.current + ) { + fetchedOnceRef.current = true; + fetchMutation.mutate(); + } + }, [copilot.cachedModels, fetchMutation]); + + const models = copilot.cachedModels ?? []; + const isPending = fetchMutation.isPending; + const error = fetchMutation.isError + ? fetchMutation.error instanceof Error + ? fetchMutation.error.message + : String(fetchMutation.error) + : null; + + return ( + +
+
+ + {isPending + ? "Fetching models…" + : models.length > 0 + ? `${models.length} model${models.length === 1 ? "" : "s"} available` + : "No models cached yet"} + + +
+ + {error &&

{error}

} + + {models.length > 0 && ( +
    + {models.map((m) => ( +
  • + {m.label} + + {m.id} + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index d57b9ef24..294d2ed85 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -115,7 +115,7 @@ export type DataInfo = { archiveRoot: string; }; -export type AgentProvider = "claude" | "codex" | "cursor"; +export type AgentProvider = "claude" | "codex" | "cursor" | "copilot"; export type AgentModelOption = { id: string; @@ -755,12 +755,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; }; export async function getAgentLoginStatus(): Promise { @@ -955,6 +956,22 @@ export async function listCursorModels( } } +export type CopilotModelEntry = { + id: string; + label: string; +}; + +/// Live Copilot model list via ACP probe (sidecar). +export async function listCopilotModels(): Promise { + try { + return await invoke("list_copilot_models"); + } catch (error) { + throw new Error( + describeInvokeError(error, "Unable to list Copilot models."), + ); + } +} + // --------------------------------------------------------------------------- // Inbox (kanban-mode left sidebar) // --------------------------------------------------------------------------- diff --git a/src/lib/settings.ts b/src/lib/settings.ts index b6815db50..64e9b44cd 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -92,6 +92,16 @@ export type CursorProviderSettings = { cachedModels: CursorCachedModel[] | null; }; +export type CopilotCachedModel = { + id: string; + label: string; +}; + +export type CopilotProviderSettings = { + /** Last fetched model catalog from the ACP probe. `null` = not yet fetched. */ + cachedModels: CopilotCachedModel[] | null; +}; + /** Per-account toggles for which item kinds the inbox should pull from * a given forge login. Keyed externally by `:` (e.g. * `github:octocat`). Missing keys default to all `true` — newly added @@ -230,6 +240,7 @@ export type AppSettings = { shortcuts: ShortcutOverrides; claudeCustomProviders: ClaudeCustomProviderSettings; cursorProvider: CursorProviderSettings; + copilotProvider: CopilotProviderSettings; inboxSourceConfig: InboxSourceConfig; kanbanViewState: KanbanViewState; }; @@ -288,6 +299,9 @@ export const DEFAULT_SETTINGS: AppSettings = { enabledModelIds: null, cachedModels: null, }, + copilotProvider: { + cachedModels: null, + }, inboxSourceConfig: { accounts: {} }, kanbanViewState: DEFAULT_KANBAN_VIEW_STATE, }; @@ -333,6 +347,7 @@ const SETTINGS_KEY_MAP: Record< shortcuts: "app.shortcuts", claudeCustomProviders: "app.claude_custom_providers", cursorProvider: "app.cursor_provider", + copilotProvider: "app.copilot_provider", inboxSourceConfig: "app.inbox_source_config", kanbanViewState: "app.kanban_view_state", }; @@ -618,6 +633,31 @@ function parseCursorProviderSettings( } } +function parseCopilotProviderSettings( + raw: string | undefined, +): CopilotProviderSettings { + if (!raw) return DEFAULT_SETTINGS.copilotProvider; + try { + const parsed = JSON.parse(raw) as Record; + const cachedModels = parseCopilotCachedModels(parsed.cachedModels); + return { cachedModels }; + } catch { + return DEFAULT_SETTINGS.copilotProvider; + } +} + +function parseCopilotCachedModels(value: unknown): CopilotCachedModel[] | null { + if (!Array.isArray(value)) return null; + const models: CopilotCachedModel[] = []; + for (const entry of value) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue; + const obj = entry as Record; + if (typeof obj.id !== "string" || typeof obj.label !== "string") continue; + models.push({ id: obj.id, label: obj.label }); + } + return models; +} + function parseEnabledModelIds(value: unknown): string[] | null { if (value === null) return null; if (!Array.isArray(value)) return null; @@ -819,6 +859,9 @@ export async function loadSettings(): Promise { cursorProvider: parseCursorProviderSettings( raw[SETTINGS_KEY_MAP.cursorProvider], ), + copilotProvider: parseCopilotProviderSettings( + raw[SETTINGS_KEY_MAP.copilotProvider], + ), inboxSourceConfig: parseInboxSourceConfig( raw[SETTINGS_KEY_MAP.inboxSourceConfig], ), @@ -862,6 +905,7 @@ export async function saveSettings(patch: Partial): Promise { key === "shortcuts" || key === "claudeCustomProviders" || key === "cursorProvider" || + key === "copilotProvider" || key === "inboxSourceConfig" || key === "kanbanViewState" ? JSON.stringify(value) diff --git a/src/lib/workspace-helpers.ts b/src/lib/workspace-helpers.ts index 09c936e1b..3ae19e8d3 100644 --- a/src/lib/workspace-helpers.ts +++ b/src/lib/workspace-helpers.ts @@ -447,6 +447,9 @@ export function resolveSessionDisplayProvider({ if (session.agentType === "cursor") { return "cursor"; } + if (session.agentType === "copilot") { + return "copilot"; + } return null; }