diff --git a/apps/web/src/components/AgentComposerBar.tsx b/apps/web/src/components/AgentComposerBar.tsx index a342441..2dab033 100644 --- a/apps/web/src/components/AgentComposerBar.tsx +++ b/apps/web/src/components/AgentComposerBar.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { Check, ChevronDown, RefreshCw, Shield, Brain, Bird, Bot, Dog, Terminal as TermIcon } from 'lucide-react'; +import { Check, ChevronDown, RefreshCw, Loader2, Shield, Brain, Bird, Bot, Dog, Terminal as TermIcon } from 'lucide-react'; import { ClaudeIcon, OpenCodeIcon } from '@/components/icons/ProviderIcons'; import { api } from '@/api/client'; import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types'; @@ -176,8 +176,12 @@ interface Props { export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected }: Props) { const allEntries = useProviderSnapshot(projectPath); + // 5.5 — the composer picker only offers ENABLED providers that are ready (or + // still loading). Disabled (enabled:false) and unavailable/error providers are + // hidden here and managed in Settings → Providers. Native boocode is always + // enabled+ready, so it always appears. const entries = useMemo( - () => allEntries?.filter((e) => e.installed && e.status !== 'error') ?? null, + () => allEntries?.filter((e) => e.enabled && (e.status === 'ready' || e.status === 'loading')) ?? null, [allEntries], ); const [refreshing, setRefreshing] = useState(false); @@ -200,6 +204,35 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma onChange(resolveConfig(entry, prefs)); }, [entries, onChange, value.provider]); + // If the active provider is disabled in the settings drawer it drops out of + // `entries` (the 5.5 filter) — fall back to boocode so the composer never + // strands on an unselectable provider with empty model/mode pickers. + useEffect(() => { + if (!entries?.length) return; + if (entries.some((e) => e.name === value.provider)) return; + const fallback = entries.find((e) => e.name === 'boocode') ?? entries[0]; + if (!fallback) return; + onChange(resolveConfig(fallback, loadPrefs())); + }, [entries, value.provider, onChange]); + + // 5.6 — loading poll: while any entry is loading (Phase 2's sync cache-miss + // return), refetch until terminal. Capped; no provider_snapshot_updated WS + // frame (deferred Tier-2). Dormant today since the snapshot awaits the build. + const pollsRef = useRef(0); + useEffect(() => { + const anyLoading = allEntries?.some((e) => e.status === 'loading') ?? false; + if (!anyLoading) { + pollsRef.current = 0; + return; + } + if (pollsRef.current >= 10) return; + const t = setTimeout(() => { + pollsRef.current += 1; + void refreshProviderSnapshot(projectPath); + }, 2000); + return () => clearTimeout(t); + }, [allEntries, projectPath]); + const currentEntry = useMemo( () => entries?.find((e) => e.name === value.provider), [entries, value.provider], @@ -283,7 +316,11 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma value={value.provider} options={providerOptions} onPick={pickProvider} - icon={providerIcon(value.provider)} + icon={ + currentEntry?.status === 'loading' + ? + : providerIcon(value.provider) + } /> void; + /** Fired after a successful add so the parent can refetch the snapshot. */ + onAdded: (id: string) => void; +} + +/** + * v2.3 Phase 5 (design.md §7.3). Search the curated ACP catalog and register a + * provider: PATCH /api/providers/config with its custom-ACP override, then + * refresh that one provider. Adding only edits config — it does NOT install the + * binary, so the provider shows "Not installed" until the CLI is on PATH. + */ +export function AddProviderModal({ open, onOpenChange, onAdded }: Props) { + const [query, setQuery] = useState(''); + const [busyId, setBusyId] = useState(null); + const [error, setError] = useState(null); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return ACP_PROVIDER_CATALOG; + return ACP_PROVIDER_CATALOG.filter( + (e) => + e.id.toLowerCase().includes(q) || + e.label.toLowerCase().includes(q) || + e.description.toLowerCase().includes(q), + ); + }, [query]); + + async function add(entry: AcpCatalogEntry): Promise { + setBusyId(entry.id); + setError(null); + try { + await api.coder.patchProvidersConfig(buildAcpProviderConfigPatch(entry)); + await api.coder.refreshProviders([entry.id]); + onAdded(entry.id); + onOpenChange(false); + } catch (err) { + // 422 from PATCH (invalid override) surfaces here as ApiError.message. + setError(err instanceof Error ? err.message : 'failed to add provider'); + } finally { + setBusyId(null); + } + } + + return ( + + + + Add ACP provider + + Registers the provider in your coder config. It is not installed — install the CLI + yourself; until it's on PATH it shows as “Not installed”. + + + +
+
+ + setQuery(e.target.value)} + placeholder="Search providers…" + className="pl-7" + /> +
+ +
+ {filtered.length === 0 && ( +
No matching providers.
+ )} + {filtered.map((e) => ( +
+
+
+
{e.label}
+
{e.description}
+
+ +
+
+ $ {e.command.join(' ')} +
+
+ + Install {e.label} + + {e.installCmd && ( + + {e.installCmd} + + )} +
+
+ ))} +
+ + {error &&
{error}
} +
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/coder/ProvidersSettings.tsx b/apps/web/src/components/coder/ProvidersSettings.tsx new file mode 100644 index 0000000..13acc18 --- /dev/null +++ b/apps/web/src/components/coder/ProvidersSettings.tsx @@ -0,0 +1,218 @@ +import { useEffect, useRef, useState } from 'react'; +import { Loader2, Plus, RefreshCw, Stethoscope } from 'lucide-react'; +import { api } from '@/api/client'; +import type { CoderProvidersFile, ProviderOverride, ProviderSnapshotEntry } from '@/api/types'; +import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot'; +import { Button } from '@/components/ui/button'; +import { AddProviderModal } from './AddProviderModal'; +import { cn } from '@/lib/utils'; + +/** Map a snapshot entry to a status badge (design.md §7.1 labels). */ +function statusBadge(e: ProviderSnapshotEntry): { label: string; cls: string } { + if (e.status === 'loading') return { label: 'Loading', cls: 'bg-muted text-muted-foreground' }; + if (!e.enabled) return { label: 'Disabled', cls: 'bg-muted text-muted-foreground' }; + if (e.status === 'ready') + return { label: 'Available', cls: 'bg-green-500/15 text-green-600 dark:text-green-400' }; + if (e.status === 'error') + return { label: 'Error', cls: 'bg-red-500/15 text-red-600 dark:text-red-400' }; + if (!e.installed) + return { label: 'Not installed', cls: 'bg-amber-500/15 text-amber-600 dark:text-amber-400' }; + return { label: 'Unavailable', cls: 'bg-muted text-muted-foreground' }; +} + +/** + * v2.3 — provider management as a Settings tab section (design.md §7.1). Lists + * ALL registered providers (including the disabled/unavailable ones the composer + * picker hides). Per row: label + model count, status badge, per-id refresh, + * diagnostic, and an enable/disable toggle. Native boocode is always-on. + * + * Uses the home-cwd snapshot (no project arg) — provider management is global, + * not per-project (design.md §4.5). + */ +export function ProvidersSettings() { + const allEntries = useProviderSnapshot(); + const [config, setConfig] = useState(null); + const [busyId, setBusyId] = useState(null); + const [error, setError] = useState(null); + const [addOpen, setAddOpen] = useState(false); + const [diagId, setDiagId] = useState(null); + const [diagText, setDiagText] = useState(null); + + // The raw config is needed to preserve a provider's FULL override when + // toggling: the PATCH replaces an id's override wholesale, so a bare + // { enabled } would wipe a custom ACP provider's command/label. + useEffect(() => { + api.coder + .getProvidersConfig() + .then(setConfig) + .catch(() => setConfig({ providers: {} })); + }, []); + + // While any entry is loading, refetch until terminal (capped, no WS frame). + const pollsRef = useRef(0); + useEffect(() => { + const anyLoading = allEntries?.some((e) => e.status === 'loading') ?? false; + if (!anyLoading) { + pollsRef.current = 0; + return; + } + if (pollsRef.current >= 10) return; + const t = setTimeout(() => { + pollsRef.current += 1; + void refreshProviderSnapshot(); + }, 2000); + return () => clearTimeout(t); + }, [allEntries]); + + async function toggle(e: ProviderSnapshotEntry): Promise { + setBusyId(e.name); + setError(null); + try { + const existing: ProviderOverride = config?.providers[e.name] ?? {}; + const resp = await api.coder.patchProvidersConfig({ + providers: { [e.name]: { ...existing, enabled: !e.enabled } }, + }); + setConfig({ providers: resp.providers }); + await refreshProviderSnapshot(); + } catch (err) { + setError(err instanceof Error ? err.message : 'failed to update provider'); + } finally { + setBusyId(null); + } + } + + async function refreshOne(id: string): Promise { + setBusyId(id); + setError(null); + try { + await api.coder.refreshProviders([id]); + await refreshProviderSnapshot(); + } catch (err) { + setError(err instanceof Error ? err.message : 'failed to refresh'); + } finally { + setBusyId(null); + } + } + + async function openDiagnostic(id: string): Promise { + if (diagId === id) { + setDiagId(null); + setDiagText(null); + return; + } + setDiagId(id); + setDiagText('Loading…'); + try { + const { diagnostic } = await api.coder.getProviderDiagnostic(id); + setDiagText(diagnostic); + } catch (err) { + setDiagText(err instanceof Error ? err.message : 'failed to load diagnostic'); + } + } + + const entries = allEntries ?? []; + + return ( +
+
+

+ Enable, disable, refresh, or add coding agents. Disabled and unavailable providers are + hidden from the composer picker but managed here. +

+ +
+ +
+ {allEntries === null && ( +
Loading…
+ )} + {entries.map((e) => { + const badge = statusBadge(e); + const isNative = e.transport === 'native'; + const busy = busyId === e.name; + return ( +
+
+
+
{e.label}
+
+ {e.models.length} model{e.models.length === 1 ? '' : 's'} +
+
+ + {e.status === 'loading' && } + {badge.label} + + + + {isNative ? ( + + Always on + + ) : ( + + )} +
+ {diagId === e.name && ( +
+                  {diagText}
+                
+ )} +
+ ); + })} +
+ + {error &&
{error}
} + + void refreshProviderSnapshot()} + /> +
+ ); +} diff --git a/apps/web/src/components/panes/SettingsPane.tsx b/apps/web/src/components/panes/SettingsPane.tsx index 9f39c5a..36df0b6 100644 --- a/apps/web/src/components/panes/SettingsPane.tsx +++ b/apps/web/src/components/panes/SettingsPane.tsx @@ -15,9 +15,10 @@ import { } from '@/components/ui/dialog'; import { ModelPicker } from '@/components/ModelPicker'; import { ThemePicker } from '@/components/ThemePicker'; +import { ProvidersSettings } from '@/components/coder/ProvidersSettings'; import { cn } from '@/lib/utils'; -type Section = 'session' | 'project' | 'theme'; +type Section = 'session' | 'project' | 'theme' | 'providers'; interface Props { session: Session; @@ -73,7 +74,7 @@ export function SettingsPane({ session, project, maximized, onToggleMaximize, on
- {(['session', 'project', 'theme'] as const).map((s) => ( + {(['session', 'project', 'theme', 'providers'] as const).map((s) => (
diff --git a/apps/web/src/data/acp-provider-catalog.ts b/apps/web/src/data/acp-provider-catalog.ts new file mode 100644 index 0000000..eb04719 --- /dev/null +++ b/apps/web/src/data/acp-provider-catalog.ts @@ -0,0 +1,83 @@ +import type { ProviderConfigPatch } from '@/api/types'; + +/** + * v2.3 Phase 5 (design.md §7.3) — a SMALL curated catalog of ACP coding agents + * the user might register. We deliberately do NOT port Paseo's 30+ entry list. + * + * Non-goal: we never install anything. Each entry is a manual-install hint + * (`installUrl` / `installCmd`) plus the config `command` that gets written into + * `/data/coder-providers.json`. The user installs the CLI themselves; until the + * binary is on PATH the provider shows as "Not installed". Commands are + * editable after adding — versions are aliased/untrimmed on purpose; pin on your + * own host once verified. + */ +export interface AcpCatalogEntry { + id: string; + label: string; + description: string; + /** Config command written verbatim into providers[id].command: [binary, ...args]. */ + command: [string, ...string[]]; + /** Where to install the CLI manually — we LINK, never install. */ + installUrl: string; + /** Optional suggested install command, shown as a copyable hint. */ + installCmd?: string; +} + +export const ACP_PROVIDER_CATALOG: AcpCatalogEntry[] = [ + { + id: 'amp-acp', + label: 'Amp', + description: 'Sourcegraph Amp — agentic coding CLI with an ACP bridge.', + command: ['amp-acp'], + installUrl: 'https://ampcode.com/', + installCmd: 'npm i -g @sourcegraph/amp', + }, + { + id: 'gemini', + label: 'Gemini CLI', + description: 'Google Gemini CLI in ACP mode (--experimental-acp).', + command: ['gemini', '--experimental-acp'], + installUrl: 'https://github.com/google-gemini/gemini-cli', + installCmd: 'npm i -g @google/gemini-cli', + }, + { + id: 'cline', + label: 'Cline', + description: 'Cline coding agent over ACP (run via npx).', + command: ['npx', '-y', 'cline', '--acp'], + installUrl: 'https://cline.bot/', + }, + { + id: 'claude-code-acp', + label: 'Claude Code (ACP)', + description: "Zed's ACP adapter for Claude Code — distinct from the built-in PTY claude provider.", + command: ['npx', '-y', '@zed-industries/claude-code-acp'], + installUrl: 'https://github.com/zed-industries/claude-code-acp', + }, + { + id: 'pi-acp', + label: 'Pi', + description: 'Example custom ACP entry — build the binary from source, then edit the command.', + command: ['pi-acp'], + installUrl: 'https://agentclientprotocol.com/', + }, +]; + +/** + * Build the PATCH body that registers a catalog entry: a single-id partial + * providers map with the custom-ACP override (extends:'acp' + label + command), + * enabled. Sent to PATCH /api/providers/config (then refreshProviders([id])). + */ +export function buildAcpProviderConfigPatch(entry: AcpCatalogEntry): ProviderConfigPatch { + return { + providers: { + [entry.id]: { + extends: 'acp', + label: entry.label, + description: entry.description, + command: entry.command, + enabled: true, + }, + }, + }; +}