/** * First-run wizard (L5). Shown on the very first IDE launch: it offers the * pre-filled, selectable reference profiles (Claude/Codex — only AIs drivable in * structured mode, §17.6/D7) with **editable** commands, lets the user detect * which CLIs are installed (✓/✗), then saves the chosen profiles and closes the * first run. * * The candidate list is already filtered server-side (`reference_profiles` only * exposes selectable profiles), so the wizard renders whatever it receives. * Adding an arbitrary custom profile is no longer offered, since we cannot drive * it in structured mode. * * Pure presentation: all behaviour comes from {@link useFirstRun} (the * {@link ProfileGateway} port). Profile validation is the pure logic in * `./profile`. */ import { useCallback, useEffect, useState } from "react"; import type { AgentProfile, GatewayError, HttpChatConfig, LocalModelServerConfig, OpenCodeConfig, OpenCodeProviderCatalogEntry, } from "@/domain"; import { useGateways } from "@/app/di"; import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared"; import { ModelServersPanel, ModelServerSelect, useModelServers, } from "@/features/model-servers"; import { useFirstRun, type WizardEntry } from "./useFirstRun"; import { defaultHttpChatConfig, defaultOpenCodeConfig, parseArgs, validateProfile, type ProfileErrors, } from "./profile"; /** A small caption above a control. */ function Caption({ children }: { children: React.ReactNode }) { return {children}; } function describeError(e: unknown): string { if (e && typeof e === "object" && "message" in e) { return String((e as GatewayError).message); } return String(e); } /** View-model for the OpenCode cloud-provider catalogue (ticket #92). */ interface OpenCodeProviderCatalog { providers: OpenCodeProviderCatalogEntry[] | null; loading: boolean; error: string | null; reload: () => void; } /** * Loads the static OpenCode cloud-provider catalogue once for the whole * wizard (every Cloud row shares it), so the provider/model pickers can be * populated. Exposes a `reload` for the blocking "Réessayer" state. */ function useOpenCodeProviderCatalog(): OpenCodeProviderCatalog { const { profile } = useGateways(); const [providers, setProviders] = useState( null, ); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const load = useCallback(async () => { setLoading(true); setError(null); try { setProviders(await profile.listOpenCodeProviders()); } catch (e) { setProviders(null); setError(describeError(e)); } finally { setLoading(false); } }, [profile]); useEffect(() => { void load(); }, [load]); return { providers, loading, error, reload: () => void load() }; } /** * Renders the wizard when it is the first run. Calls `onDone` once the user * finishes (so the host can drop the wizard and show the normal UI). Returns * Returns `null` while loading. By default it also returns `null` once the first * run is done (auto-show path in {@link App}); pass `forceOpen` to render it * regardless — used by "Settings ▸ Configure profiles" to reopen the wizard after * the first run. */ export function FirstRunWizard({ onDone, forceOpen = false, }: { onDone?: () => void; /** Render the wizard even when it is no longer the first run. */ forceOpen?: boolean; }) { // Mode is explicit (ticket #44), driven by the entry point — never inferred // from `isFirstRun`: reopening from Settings (`forceOpen`) is the "edit" mode, // which pre-loads and pre-selects the already-configured profiles. const vm = useFirstRun(forceOpen ? "edit" : "firstRun"); const modelServers = useModelServers(); const providerCatalog = useOpenCodeProviderCatalog(); if (vm.isFirstRun === null) return null; if (!forceOpen && vm.isFirstRun === false) return null; async function finish() { await vm.finish(); onDone?.(); } return (

Welcome to IdeA

Choose which AI CLIs to configure. Commands are pre-filled and editable.

} >
{vm.error && (

{vm.error}

)} {/* `detecting` deliberately does NOT disable this button (ticket #28): detection is best-effort and may never answer. */} {vm.detecting && ( Detecting… )} {/* F36: declare several local OpenCode profiles. Each click clones the canonical `opencode-llamacpp` seed into a new, editable row. */} {/* F35.2 — declare/edit/delete the local llama.cpp servers an OpenCode profile can bind to. Sits above the profile list so a server exists before it is picked in the OpenCode dropdown. */}
    {vm.entries.map((entry) => ( vm.toggle(entry.profile.id)} onChange={(p) => vm.updateProfile(entry.profile.id, p)} onRemove={() => vm.remove(entry.profile.id)} onDuplicate={ entry.profile.structuredAdapter === "openCode" ? () => vm.addOpenCodeProfile({ name: `${entry.profile.name} (copy)`, opencode: entry.profile.opencode, }) : undefined } /> ))}
); } /** One editable candidate row: select, edit command/args, see availability. */ function ProfileRow({ entry, servers, providerCatalog, onToggle, onChange, onRemove, onDuplicate, }: { entry: WizardEntry; /** Declared local model servers (F35.2), for the OpenCode binding dropdown. */ servers: LocalModelServerConfig[]; /** OpenCode cloud-provider catalogue (ticket #92), shared across rows. */ providerCatalog: OpenCodeProviderCatalog; onToggle: () => void; onChange: (p: AgentProfile) => void; onRemove: () => void; /** Present only for OpenCode rows: clone this row into a new profile (F36). */ onDuplicate?: () => void; }) { const { profile, selected, available } = entry; const errors = validateProfile(profile); const isOpenCode = profile.structuredAdapter === "openCode"; return (
  • {available === null ? "—" : available ? "✓ installed" : "✗ not found"}
    {onDuplicate && ( )} ×
    {/* F36: an OpenCode profile's name is identity-neutral but user-facing, so it is editable per profile (several local models coexist). */} {isOpenCode && ( )} {profile.structuredAdapter === "openAiCompatible" && ( )} {profile.structuredAdapter === "openCode" && ( )}
  • ); } /** * Segmented control (F — ticket #92) choosing whether an OpenCode profile runs * against the local `llama.cpp` endpoint or a cloud provider from the OpenCode * registry, and renders the matching sub-form. The two sub-forms never overlap; * switching segments keeps the inactive one's draft in memory (component-local * state) without touching `profile` until it is actually submitted. */ function OpenCodeModeFields({ profile, errors, servers, providerCatalog, onChange, }: { profile: AgentProfile; errors: ProfileErrors; servers: LocalModelServerConfig[]; providerCatalog: OpenCodeProviderCatalog; onChange: (p: AgentProfile) => void; }) { const [mode, setMode] = useState<"local" | "cloud">( profile.opencodeProvider ? "cloud" : "local", ); return (
    {( [ { id: "local", label: "Local (llama.cpp)" }, { id: "cloud", label: "Provider cloud" }, ] as const ).map((seg) => { const active = mode === seg.id; return ( ); })}
    {mode === "local" && ( )} {mode === "cloud" && ( )}
    ); } /** Field-keyed validation errors for the Cloud sub-form, surfaced on submit. */ interface CloudFieldErrors { providerId?: string; model?: string; apiKey?: string; npm?: string; baseUrl?: string; } /** Sentinel `