1export type WizardNavigation =
2 | { readonly kind: "navigate"; readonly step: number }
3 | { readonly kind: "blocked"; readonly step: number; readonly error: string };
4
5const IDENTITY_STEP = 1;
6const ACP_REGISTRY_IDENTITY_STEP = 1;
7
8export const ADD_PROVIDER_WIZARD_STEPS = ["Provider", "Identity", "Config"] as const;
9export const ACP_REGISTRY_WIZARD_STEPS = ["Provider", "Identity", "Sign in"] as const;
10export const LOCAL_ACP_WIZARD_STEPS = ["Provider", "Identity"] as const;
11
12export interface ProviderIdentityDraft {
13 readonly label: string;
14 readonly accentColor: string;
15 readonly instanceIdOverride: string | null;
16}
17
18const EMPTY_PROVIDER_IDENTITY_DRAFT: ProviderIdentityDraft = {
19 label: "",
20 accentColor: "",
21 instanceIdOverride: null,
22};
23
24export function getProviderIdentityDraft(
25 drafts: Readonly<Record<string, ProviderIdentityDraft>>,
26 driver: string,
27): ProviderIdentityDraft {
28 return drafts[driver] ?? EMPTY_PROVIDER_IDENTITY_DRAFT;
29}
30
31export function updateProviderIdentityDraft(
32 drafts: Readonly<Record<string, ProviderIdentityDraft>>,
33 driver: string,
34 update: Partial<ProviderIdentityDraft>,
35): Record<string, ProviderIdentityDraft> {
36 return {
37 ...drafts,
38 [driver]: { ...getProviderIdentityDraft(drafts, driver), ...update },
39 };
40}
41
42export function deriveAvailableInstanceId(
43 derive: (label: string) => string,
44 label: string,
45 existing: ReadonlySet<string>,
46): string {
47 const base = derive(label);
48 if (!base || !existing.has(base)) return base;
49
50 for (let suffix = 2; ; suffix += 1) {
51 const suffixText = `_${suffix}`;
52 const candidate = `${base.slice(0, 64 - suffixText.length)}${suffixText}`;
53 if (!existing.has(candidate)) return candidate;
54 }
55}
56
57export function isConfiguredAcpRegistryAgent(
58 instances: Readonly<Record<string, { readonly driver: string; readonly config?: unknown }>>,
59 agentId: string,
60): boolean {
61 return Object.values(instances).some((instance) => {
62 if (
63 instance.driver !== "acpRegistry" ||
64 !instance.config ||
65 typeof instance.config !== "object"
66 ) {
67 return false;
68 }
69 const config = instance.config as Record<string, unknown>;
70 return config.source !== "local" && config.agentId === agentId;
71 });
72}
73
74/**
75 * Resolve navigation within the add-provider wizard.
76 *
77 * Moving forward past Identity requires a valid instance id, whether the user
78 * advances one step at a time or skips directly to Config from a step header.
79 * A blocked skip lands on Identity so its existing inline validation is
80 * visible. Backward navigation is always preserved.
81 */
82export function resolveWizardNavigation(
83 currentStep: number,
84 requestedStep: number,
85 stepCount: number,
86 validation: {
87 readonly instanceIdError: string | null;
88 readonly identityStep?: number;
89 readonly prerequisite?: {
90 readonly step: number;
91 readonly error: string | null;
92 };
93 },
94): WizardNavigation {
95 const lastStep = Math.max(0, stepCount - 1);
96 const targetStep = Math.max(0, Math.min(lastStep, requestedStep));
97 const identityStep = validation.identityStep ?? IDENTITY_STEP;
98 const prerequisite = validation.prerequisite;
99
100 if (prerequisite?.error && currentStep <= prerequisite.step && targetStep > prerequisite.step) {
101 return {
102 kind: "blocked",
103 step: Math.min(prerequisite.step, lastStep),
104 error: prerequisite.error,
105 };
106 }
107
108 const movesForwardPastIdentity = currentStep <= identityStep && targetStep > identityStep;
109
110 if (movesForwardPastIdentity && validation.instanceIdError !== null) {
111 return {
112 kind: "blocked",
113 step: Math.min(identityStep, lastStep),
114 error: validation.instanceIdError,
115 };
116 }
117
118 return { kind: "navigate", step: targetStep };
119}
120
121export function resolveAcpRegistryWizardNavigation(
122 currentStep: number,
123 requestedStep: number,
124 validation: {
125 readonly instanceIdError: string | null;
126 readonly selectionError: string | null;
127 },
128): WizardNavigation {
129 return resolveWizardNavigation(currentStep, requestedStep, ACP_REGISTRY_WIZARD_STEPS.length, {
130 instanceIdError: validation.instanceIdError,
131 identityStep: ACP_REGISTRY_IDENTITY_STEP,
132 prerequisite: { step: 0, error: validation.selectionError },
133 });
134}