From 90d11840f55c604dc0143ed19974c8df71761d5a Mon Sep 17 00:00:00 2001
From: David Engelmann
Date: Tue, 12 May 2026 20:10:27 -0400
Subject: [PATCH 1/8] feat(agents): provider capability contract
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace scattered `provider === "codex"` / `provider === "cursor"` /
`provider == "codex"` checks with a typed capability table that the
composer, session-close dialog, and streaming-stop hook all read off
of. Single source of truth lives in Rust
(`agents::provider_capabilities`); the frontend pulls a mirrored TS
shape through a new `list_provider_capabilities` command and a
forever-cached, on-disk-persisted React Query.
The capability fields cover the existing scattered checks:
- displayName (replaces hard-coded "Claude" / "Codex" / "Cursor")
- supportsActiveGoal (composer /goal interception + stream-stop pause)
- supportsPlanMode, supportsContextUsage, supportsSteer,
supportsSlashCommands, requiresApiKey
- permissionModes (the SDK wire-string list the composer's dropdown
should render; Claude=all four, Codex=default+bypass, Cursor=default)
Unknown provider ids fall back to Claude's defaults (the broadest
surface), so a future provider (Copilot via #511, Pi via #321) lands
without accidentally disabling composer features — adding a new row
to the matrix is a single edit.
The pipeline accumulator, model catalog, and sidecar are untouched —
this is purely a backend-Rust + Tauri-command + frontend-helper slice
that makes future provider work safer to review.
Tests:
- 6 Rust unit tests (per-provider rows, fallback, wire-format
camelCase gate, permission-mode SDK strings)
- 5 frontend unit tests (`findProviderCapabilities` matrix lookups,
null fallback, regression gates for active-goal and api-key flags)
- Existing composer `/goal pause/clear` interception tests still
pass after switching to capability-driven dispatch (with a small
seed update for the new query key).
Closes #321 only at the design level (the "support Pi-mono" issue —
the spine is now ready for a Pi-shaped provider row); does not add
Pi as a provider. Helps #510 / #511 by giving the Copilot ACP work a
typed capability slot to plug into.
---
.changeset/provider-capabilities-contract.md | 5 +
src-tauri/src/agents.rs | 15 +
src-tauri/src/agents/provider_capabilities.rs | 281 ++++++++++++++++++
src-tauri/src/lib.rs | 1 +
src/features/composer/container.test.tsx | 18 ++
src/features/composer/container.tsx | 32 +-
.../conversation/hooks/use-streaming.ts | 40 ++-
.../panel/use-confirm-session-close.tsx | 21 +-
src/lib/api.ts | 45 +++
src/lib/provider-capabilities.test.ts | 92 ++++++
src/lib/query-client.ts | 19 ++
11 files changed, 551 insertions(+), 18 deletions(-)
create mode 100644 .changeset/provider-capabilities-contract.md
create mode 100644 src-tauri/src/agents/provider_capabilities.rs
create mode 100644 src/lib/provider-capabilities.test.ts
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/src-tauri/src/agents.rs b/src-tauri/src/agents.rs
index 0e4a3d003..0d3b0407b 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>,
diff --git a/src-tauri/src/agents/provider_capabilities.rs b/src-tauri/src/agents/provider_capabilities.rs
new file mode 100644
index 000000000..d47c1455e
--- /dev/null
+++ b/src-tauri/src/agents/provider_capabilities.rs
@@ -0,0 +1,281 @@
+//! 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],
+ },
+ // 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"];
+
+#[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 unknown_provider_falls_back_to_claude_defaults() {
+ // Forward-compat: a future provider id (e.g. "copilot") that
+ // lands without a matrix update must not break composer UX —
+ // we default to Claude's feature surface, which is the
+ // broadest, until the matrix is updated.
+ let caps = capabilities_for_provider("copilot");
+ 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/lib.rs b/src-tauri/src/lib.rs
index 83afcf6c5..5e69b1e2e 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -226,6 +226,7 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
agents::list_agent_model_sections,
agents::list_cursor_models,
+ agents::list_provider_capabilities,
agents::send_agent_message_stream,
agents::stop_agent_stream,
agents::list_active_streams,
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..a593e03a4 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,
@@ -691,12 +693,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 +799,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 +833,12 @@ export const WorkspaceComposerContainer = memo(
options,
);
},
- [provider, displayedSessionId, activeGoal, handleComposerSubmitInner],
+ [
+ supportsActiveGoal,
+ displayedSessionId,
+ activeGoal,
+ handleComposerSubmitInner,
+ ],
);
const handleGoalReplaceConfirm = useCallback(() => {
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/panel/use-confirm-session-close.tsx b/src/features/panel/use-confirm-session-close.tsx
index 2943d2fa1..193f61d80 100644
--- a/src/features/panel/use-confirm-session-close.tsx
+++ b/src/features/panel/use-confirm-session-close.tsx
@@ -1,10 +1,12 @@
-import type { QueryClient } from "@tanstack/react-query";
+import { type QueryClient, useQuery } from "@tanstack/react-query";
import { type ReactNode, useCallback, useMemo, useState } from "react";
import {
+ findProviderCapabilities,
stopAgentStream,
type WorkspaceDetail,
type WorkspaceSessionSummary,
} from "@/lib/api";
+import { providerCapabilitiesQueryOptions } from "@/lib/query-client";
import type { PushWorkspaceToast } from "@/lib/workspace-toast-context";
import { shouldConfirmRunningSessionClose } from "./close-guard";
import { RunningSessionCloseDialog } from "./running-session-close-dialog";
@@ -105,15 +107,22 @@ export function useConfirmSessionClose({
await performClose(request);
}, [pending, performClose, pushToast]);
+ const capsQuery = useQuery(providerCapabilitiesQueryOptions());
+ const capsTable = capsQuery.data ?? [];
+
const agentLabel = useMemo(() => {
if (!pending) {
return "Claude";
}
- const provider = pending.provider ?? pending.session.agentType;
- if (provider === "codex") return "Codex";
- if (provider === "cursor") return "Cursor";
- return "Claude";
- }, [pending]);
+ const provider = pending.provider ?? pending.session.agentType ?? "";
+ // Data-driven display name — single source of truth in
+ // `agents::provider_capabilities`. Falls back to "Claude" when
+ // the capability table hasn't loaded yet (cold first paint)
+ // or for an unknown provider id (matches the Rust helper's
+ // fallback to Claude defaults).
+ const caps = findProviderCapabilities(capsTable, provider);
+ return caps?.displayName ?? "Claude";
+ }, [pending, capsTable]);
const dialogNode = (
{
}
}
+/** 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;
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),
From 6275437585156c79ff98e2436a31427a6df436d7 Mon Sep 17 00:00:00 2001
From: XiaoMouz
Date: Wed, 20 May 2026 13:31:30 +0800
Subject: [PATCH 2/8] feat: add support for GitHub Copilot integration
- Introduced CopilotSessionManager to manage Copilot sessions.
- Updated model catalog to include Copilot models with appropriate effort levels.
- Enhanced request parser to recognize "copilot" as a valid provider.
- Expanded session manager to include Copilot as a provider.
- Implemented Copilot capabilities in provider capabilities module.
- Added Copilot login status handling in system commands.
- Created a new accumulator for handling Copilot events.
- Integrated Copilot event handling into the main event processing pipeline.
- Added Copilot icon and updated onboarding to include Copilot authentication.
- Developed settings panel for managing Copilot provider settings.
---
sidecar/bun.lock | 3 +
sidecar/package.json | 1 +
sidecar/src/copilot-session-manager.ts | 598 ++++++++++++++++++
sidecar/src/index.ts | 3 +
sidecar/src/model-catalog.ts | 95 +++
sidecar/src/request-parser.ts | 7 +-
sidecar/src/session-manager.ts | 2 +-
src-tauri/src/agents.rs | 7 +
src-tauri/src/agents/catalog.rs | 125 +++-
src-tauri/src/agents/provider_capabilities.rs | 38 +-
src-tauri/src/agents/queries.rs | 101 +++
src-tauri/src/commands/system_commands.rs | 91 +++
src-tauri/src/lib.rs | 3 +
src-tauri/src/pipeline/accumulator/copilot.rs | 291 +++++++++
src-tauri/src/pipeline/accumulator/mod.rs | 16 +
src/components/icons.tsx | 13 +
src/features/onboarding/agent-login-state.ts | 16 +-
.../components/login-terminal-preview.tsx | 3 +-
src/features/settings/index.tsx | 2 +
.../settings/panels/copilot-provider.tsx | 199 ++++++
src/lib/api.ts | 35 +-
21 files changed, 1626 insertions(+), 23 deletions(-)
create mode 100644 sidecar/src/copilot-session-manager.ts
create mode 100644 src-tauri/src/pipeline/accumulator/copilot.rs
create mode 100644 src/features/settings/panels/copilot-provider.tsx
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..bcdebaf38
--- /dev/null
+++ b/sidecar/src/copilot-session-manager.ts
@@ -0,0 +1,598 @@
+/**
+ * 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 {
+ if (effortLevel) {
+ try {
+ await this.sendAcpRequest(ctx, "unstable_setSessionModel", {
+ sessionId: ctx.sessionId,
+ model: modelId,
+ options: { effort: effortLevel },
+ });
+ } catch {
+ // unstable — best effort
+ }
+ }
+
+ emitter.passthrough(requestId, {
+ type: "copilot/status",
+ status: "RUNNING",
+ });
+
+ await this.sendAcpRequest(ctx, "prompt", {
+ sessionId: ctx.sessionId,
+ prompt: [{ type: "text", text: prompt }],
+ });
+
+ 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, "cancel", {
+ sessionId: ctx.sessionId,
+ });
+ } catch {
+ // best effort
+ }
+ }
+ }
+
+ 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,
+ };
+
+ this.sessions.set(helmorSessionId, ctx);
+
+ 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();
+ });
+
+ // Initialize ACP connection
+ await this.sendAcpRequest(ctx, "initialize", {
+ protocolVersion: "0.1",
+ clientCapabilities: {
+ fs: { readTextFile: true, writeTextFile: true },
+ permissions: { requestPermission: true },
+ },
+ });
+
+ // Create a session
+ const sessionResult = (await this.sendAcpRequest(ctx, "newSession", {
+ cwd,
+ })) as { sessionId?: string };
+
+ ctx.sessionId = sessionResult?.sessionId ?? helmorSessionId;
+
+ 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 === "requestPermission" || method === "permissions/request") {
+ const permissionId = `copilot-${helmorSessionId}-${id}`;
+ this.pendingPermissions.set(permissionId, {
+ helmorSessionId,
+ jsonRpcId: id,
+ });
+
+ const toolName = (params.tool as string) ?? "unknown";
+ const description = (params.description as string) ?? "";
+ const toolInput = (params.input as Record) ?? {};
+
+ if (ctx.activeRequestId && ctx.activeEmitter) {
+ ctx.activeEmitter.passthrough(ctx.activeRequestId, {
+ type: "permissionRequest",
+ permissionId,
+ toolName,
+ toolInput,
+ title: toolName,
+ description,
+ });
+ }
+ return;
+ }
+
+ // Unknown agent request — auto-approve
+ this.sendJsonRpcResponse(ctx, id, {});
+ }
+
+ 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;
+
+ // Map ACP SessionUpdate notifications to copilot/ prefixed events
+ switch (method) {
+ case "sessionUpdate": {
+ const update = params as { type?: string; [key: string]: unknown };
+ const updateType = update.type ?? "unknown";
+
+ switch (updateType) {
+ case "agent_message_chunk":
+ emitter.passthrough(requestId, {
+ type: "copilot/assistant",
+ text: (update.text as string) ?? "",
+ });
+ break;
+ case "agent_thought_chunk":
+ emitter.passthrough(requestId, {
+ type: "copilot/thinking",
+ text: (update.text as string) ?? "",
+ });
+ break;
+ case "tool_call": {
+ const callId = (update.callId as string) ?? `tc-${Date.now()}`;
+ emitter.passthrough(requestId, {
+ type: "copilot/tool_call_start",
+ call_id: callId,
+ name: (update.name as string) ?? "unknown",
+ args: update.arguments ?? {},
+ });
+ if (update.result !== undefined) {
+ emitter.passthrough(requestId, {
+ type: "copilot/tool_call_end",
+ call_id: callId,
+ result: update.result,
+ is_error: (update.isError as boolean) ?? false,
+ });
+ }
+ break;
+ }
+ case "tool_call_update":
+ emitter.passthrough(requestId, {
+ type: "copilot/tool_call_update",
+ call_id: (update.callId as string) ?? "",
+ output: (update.output as string) ?? "",
+ });
+ break;
+ case "plan":
+ emitter.passthrough(requestId, {
+ type: "copilot/plan",
+ plan: update.plan ?? update.text ?? "",
+ });
+ break;
+ case "available_commands_update":
+ // Cache for listSlashCommands — no pipeline event needed
+ break;
+ case "current_mode_update":
+ break;
+ default:
+ emitter.passthrough(requestId, {
+ type: `copilot/${updateType}`,
+ ...update,
+ });
+ }
+ break;
+ }
+ default:
+ logger.debug(
+ `[copilot:${helmorSessionId}] unhandled notification: ${method}`,
+ );
+ }
+ }
+
+ private sendAcpRequest(
+ ctx: CopilotSession,
+ method: string,
+ params: Record,
+ ): Promise {
+ return new Promise((resolve, reject) => {
+ const id = ctx.nextId++;
+ const timeout = setTimeout(() => {
+ ctx.pendingRequests.delete(id);
+ reject(new Error(`ACP request '${method}' timed out`));
+ }, ACP_REQUEST_TIMEOUT_MS);
+
+ ctx.pendingRequests.set(id, { resolve, reject, timeout });
+
+ const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params });
+ try {
+ ctx.child.stdin.write(`${msg}\n`);
+ } catch (err) {
+ ctx.pendingRequests.delete(id);
+ 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 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 0d3b0407b..84e318d1c 100644
--- a/src-tauri/src/agents.rs
+++ b/src-tauri/src/agents.rs
@@ -229,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
index d47c1455e..51784ed7f 100644
--- a/src-tauri/src/agents/provider_capabilities.rs
+++ b/src-tauri/src/agents/provider_capabilities.rs
@@ -117,6 +117,17 @@ pub fn capabilities_for_provider(provider: &str) -> ProviderCapabilities {
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
@@ -143,7 +154,7 @@ pub fn capabilities_for_provider(provider: &str) -> ProviderCapabilities {
/// 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"];
+pub const KNOWN_PROVIDERS: &[&str] = &["claude", "codex", "cursor", "copilot"];
#[cfg(test)]
mod tests {
@@ -233,12 +244,27 @@ mod tests {
}
#[test]
- fn unknown_provider_falls_back_to_claude_defaults() {
- // Forward-compat: a future provider id (e.g. "copilot") that
- // lands without a matrix update must not break composer UX —
- // we default to Claude's feature surface, which is the
- // broadest, until the matrix is updated.
+ 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);
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..e9dcaceea 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,
#[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,97 @@ fn env_var_is_present(key: &str) -> bool {
.unwrap_or(false)
}
+fn copilot_login_ready() -> bool {
+ match std::process::Command::new("copilot")
+ .args(["auth", "status"])
+ .output()
+ {
+ Ok(output) if output.status.success() => true,
+ Ok(_) => {
+ // Fall back to `gh auth status` since Copilot uses GitHub auth.
+ match std::process::Command::new("gh")
+ .args(["auth", "status"])
+ .output()
+ {
+ Ok(gh_output) => gh_output.status.success(),
+ Err(_) => false,
+ }
+ }
+ Err(_) => {
+ tracing::debug!("Copilot CLI not found, checking gh auth");
+ 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 name: Option,
+ pub avatar_url: Option,
+}
+
+#[tauri::command]
+pub async fn get_copilot_account_info() -> CmdResult
- {/* 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}
+
+
+
+
+ ),
+ )}
+
+
+
+ )}
+