web(coder): provider settings UI — Settings → Providers tab, picker filter, ACP catalog
v2.3 Phase 5. Provider management lives in Settings → Providers: lists every registered provider with a status badge, enable/disable toggle (sends the full override so a custom ACP entry's command survives the wholesale-replace PATCH), per-provider refresh, and a plaintext diagnostic. The composer provider picker now filters to enabled && (status==='ready' || 'loading') — disabled/unavailable providers leave the picker and are managed only in settings; native boocode always shows. Adds a curated ACP catalog + AddProviderModal (PATCH config then subset refresh; the modal caps to the viewport with a single overscroll-contain scroll region). Loading state uses a capped client poll (no WS frame). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
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 { ClaudeIcon, OpenCodeIcon } from '@/components/icons/ProviderIcons';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
|
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
|
||||||
@@ -176,8 +176,12 @@ interface Props {
|
|||||||
|
|
||||||
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected }: Props) {
|
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected }: Props) {
|
||||||
const allEntries = useProviderSnapshot(projectPath);
|
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(
|
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],
|
[allEntries],
|
||||||
);
|
);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
@@ -200,6 +204,35 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
|||||||
onChange(resolveConfig(entry, prefs));
|
onChange(resolveConfig(entry, prefs));
|
||||||
}, [entries, onChange, value.provider]);
|
}, [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(
|
const currentEntry = useMemo(
|
||||||
() => entries?.find((e) => e.name === value.provider),
|
() => entries?.find((e) => e.name === value.provider),
|
||||||
[entries, value.provider],
|
[entries, value.provider],
|
||||||
@@ -283,7 +316,11 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
|||||||
value={value.provider}
|
value={value.provider}
|
||||||
options={providerOptions}
|
options={providerOptions}
|
||||||
onPick={pickProvider}
|
onPick={pickProvider}
|
||||||
icon={providerIcon(value.provider)}
|
icon={
|
||||||
|
currentEntry?.status === 'loading'
|
||||||
|
? <Loader2 size={13} className="shrink-0 animate-spin" />
|
||||||
|
: providerIcon(value.provider)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<CompactPicker
|
<CompactPicker
|
||||||
label="Mode"
|
label="Mode"
|
||||||
|
|||||||
139
apps/web/src/components/coder/AddProviderModal.tsx
Normal file
139
apps/web/src/components/coder/AddProviderModal.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { ExternalLink, Search } from 'lucide-react';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
ACP_PROVIDER_CATALOG,
|
||||||
|
buildAcpProviderConfigPatch,
|
||||||
|
type AcpCatalogEntry,
|
||||||
|
} from '@/data/acp-provider-catalog';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => 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<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(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<void> {
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-lg max-h-[85vh] grid-rows-[auto_minmax(0,1fr)_auto]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add ACP provider</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
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”.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col min-h-0 gap-3">
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search providers…"
|
||||||
|
className="pl-7"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 rounded-md border overflow-y-auto overscroll-contain divide-y">
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="px-3 py-2 text-sm text-muted-foreground">No matching providers.</div>
|
||||||
|
)}
|
||||||
|
{filtered.map((e) => (
|
||||||
|
<div key={e.id} className="px-3 py-2.5 space-y-1.5">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium">{e.label}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{e.description}</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={busyId !== null}
|
||||||
|
onClick={() => void add(e)}
|
||||||
|
>
|
||||||
|
{busyId === e.id ? 'Adding…' : 'Add'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-[11px] text-muted-foreground truncate">
|
||||||
|
$ {e.command.join(' ')}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<a
|
||||||
|
href={e.installUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Install {e.label} <ExternalLink className="size-3" />
|
||||||
|
</a>
|
||||||
|
{e.installCmd && (
|
||||||
|
<span className="font-mono text-[11px] text-muted-foreground truncate">
|
||||||
|
{e.installCmd}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="text-sm text-destructive shrink-0">{error}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busyId !== null}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
apps/web/src/components/coder/ProvidersSettings.tsx
Normal file
218
apps/web/src/components/coder/ProvidersSettings.tsx
Normal file
@@ -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<CoderProvidersFile | null>(null);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [diagId, setDiagId] = useState<string | null>(null);
|
||||||
|
const [diagText, setDiagText] = useState<string | null>(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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Enable, disable, refresh, or add coding agents. Disabled and unavailable providers are
|
||||||
|
hidden from the composer picker but managed here.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setAddOpen(true)} className="shrink-0">
|
||||||
|
<Plus className="size-3.5" /> Add provider
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border divide-y">
|
||||||
|
{allEntries === null && (
|
||||||
|
<div className="px-3 py-2 text-sm text-muted-foreground">Loading…</div>
|
||||||
|
)}
|
||||||
|
{entries.map((e) => {
|
||||||
|
const badge = statusBadge(e);
|
||||||
|
const isNative = e.transport === 'native';
|
||||||
|
const busy = busyId === e.name;
|
||||||
|
return (
|
||||||
|
<div key={e.name} className="px-3 py-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium truncate">{e.label}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{e.models.length} model{e.models.length === 1 ? '' : 's'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium',
|
||||||
|
badge.cls,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{e.status === 'loading' && <Loader2 className="size-3 mr-1 animate-spin" />}
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void refreshOne(e.name)}
|
||||||
|
disabled={busy}
|
||||||
|
className="inline-flex items-center justify-center size-7 rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||||
|
aria-label={`Refresh ${e.label}`}
|
||||||
|
title="Refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void openDiagnostic(e.name)}
|
||||||
|
className="inline-flex items-center justify-center size-7 rounded text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={`Diagnostic for ${e.label}`}
|
||||||
|
title="Diagnostic"
|
||||||
|
>
|
||||||
|
<Stethoscope className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
{isNative ? (
|
||||||
|
<span className="text-[11px] text-muted-foreground w-14 text-center">
|
||||||
|
Always on
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={e.enabled}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void toggle(e)}
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors disabled:opacity-40',
|
||||||
|
e.enabled ? 'bg-primary' : 'bg-muted-foreground/30',
|
||||||
|
)}
|
||||||
|
aria-label={`${e.enabled ? 'Disable' : 'Enable'} ${e.label}`}
|
||||||
|
title={e.enabled ? 'Enabled — click to disable' : 'Disabled — click to enable'}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-block size-4 rounded-full bg-background transition-transform',
|
||||||
|
e.enabled ? 'translate-x-4' : 'translate-x-0.5',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{diagId === e.name && (
|
||||||
|
<pre className="mt-2 max-h-48 overflow-auto rounded bg-muted/50 p-2 text-[11px] font-mono whitespace-pre-wrap">
|
||||||
|
{diagText}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||||
|
|
||||||
|
<AddProviderModal
|
||||||
|
open={addOpen}
|
||||||
|
onOpenChange={setAddOpen}
|
||||||
|
onAdded={() => void refreshProviderSnapshot()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,9 +15,10 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { ModelPicker } from '@/components/ModelPicker';
|
import { ModelPicker } from '@/components/ModelPicker';
|
||||||
import { ThemePicker } from '@/components/ThemePicker';
|
import { ThemePicker } from '@/components/ThemePicker';
|
||||||
|
import { ProvidersSettings } from '@/components/coder/ProvidersSettings';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type Section = 'session' | 'project' | 'theme';
|
type Section = 'session' | 'project' | 'theme' | 'providers';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
session: Session;
|
session: Session;
|
||||||
@@ -73,7 +74,7 @@ export function SettingsPane({ session, project, maximized, onToggleMaximize, on
|
|||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
|
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
|
||||||
<div className="flex items-center gap-1 flex-1 min-w-0">
|
<div className="flex items-center gap-1 flex-1 min-w-0">
|
||||||
{(['session', 'project', 'theme'] as const).map((s) => (
|
{(['session', 'project', 'theme', 'providers'] as const).map((s) => (
|
||||||
<button
|
<button
|
||||||
key={s}
|
key={s}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -116,6 +117,7 @@ export function SettingsPane({ session, project, maximized, onToggleMaximize, on
|
|||||||
{activeSection === 'session' && <SessionSection session={session} project={project} />}
|
{activeSection === 'session' && <SessionSection session={session} project={project} />}
|
||||||
{activeSection === 'project' && <ProjectSection project={project} />}
|
{activeSection === 'project' && <ProjectSection project={project} />}
|
||||||
{activeSection === 'theme' && <ThemePicker />}
|
{activeSection === 'theme' && <ThemePicker />}
|
||||||
|
{activeSection === 'providers' && <ProvidersSettings />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
83
apps/web/src/data/acp-provider-catalog.ts
Normal file
83
apps/web/src/data/acp-provider-catalog.ts
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user