1/**
2 * How a configured provider instance presents itself in a client: its label,
3 * its accent color, and whether its icon carries the account badge. Shared by
4 * web and mobile so both clients name and badge the same instance identically.
5 *
6 * @module providerInstanceDisplay
7 */
8import {
9 defaultInstanceIdForDriver,
10 PROVIDER_DISPLAY_NAMES,
11 type ProviderDriverKind,
12 type ServerProvider,
13} from "@t3tools/contracts";
14
15/**
16 * Title-case a slug: splits on `_` / `-` and camelCase boundaries, so
17 * `codex_personal` becomes "Codex Personal" and `myCustomInstance` becomes
18 * "My Custom Instance".
19 */
20function humanizeSlug(slug: string): string {
21 return slug
22 .replace(/([a-z])([A-Z])/g, "$1 $2")
23 .replace(/[_-]+/g, " ")
24 .trim()
25 .replace(/\b\w/g, (char) => char.toUpperCase());
26}
27
28/**
29 * Resolve an instance's label with a tiered priority:
30 *
31 * 1. A snapshot `displayName` that differs from the driver's brand label —
32 * the server has explicitly named this instance, trust it.
33 * 2. For non-default instances, a humanized `instanceId` — the server fell
34 * back to the driver-level label (the same for every instance of that
35 * kind), so the slug is what keeps "Codex" and "Codex Personal" apart.
36 * 3. The snapshot's `displayName`, or the brand label from contracts.
37 */
38export function resolveProviderInstanceDisplayName(
39 snapshot: Pick<ServerProvider, "instanceId" | "driver" | "displayName">,
40): string {
41 const trimmedSnapshotName = snapshot.displayName?.trim();
42 const kindLabel = PROVIDER_DISPLAY_NAMES[snapshot.driver] ?? humanizeSlug(snapshot.driver);
43 if (trimmedSnapshotName && trimmedSnapshotName !== kindLabel) return trimmedSnapshotName;
44 if (snapshot.instanceId !== defaultInstanceIdForDriver(snapshot.driver)) {
45 const humanized = humanizeSlug(snapshot.instanceId);
46 if (humanized.length > 0) return humanized;
47 }
48 return trimmedSnapshotName || kindLabel;
49}
50
51/**
52 * Turn a display name into up to two initials for the badge: the first two
53 * characters of a single word, or the first character of each of the first
54 * two words. Iterates by code point so an emoji never splits into surrogates.
55 */
56export function providerInstanceInitials(label: string): string {
57 const words = label.replace(/[_-]+/g, " ").split(/\s+/u).filter(Boolean);
58 if (words.length === 0) return "";
59 if (words.length === 1) return Array.from(words[0]!).slice(0, 2).join("").toUpperCase();
60 return words
61 .slice(0, 2)
62 .map((word) => Array.from(word)[0]?.toUpperCase() ?? "")
63 .join("");
64}
65
66/** Only `#rrggbb` accent colors render; anything else is treated as unset. */
67export function normalizeProviderAccentColor(value: string | undefined): string | undefined {
68 const trimmed = value?.trim();
69 if (!trimmed) return undefined;
70 return /^#[0-9a-fA-F]{6}$/u.test(trimmed) ? trimmed : undefined;
71}
72
73/**
74 * Whether an instance's icon carries the account badge: accent color set, or
75 * several instances sharing a provider so the brand glyph alone is ambiguous.
76 * ACP agents have distinct glyphs even though they share the registry driver.
77 * Shared by the composer trigger, the picker rail, and sidebar/thread rows.
78 */
79export function shouldShowInstanceBadge(
80 entry: {
81 readonly driverKind: ProviderDriverKind;
82 readonly accentColor?: string | undefined;
83 readonly acpRegistryAgentId?: string | undefined;
84 },
85 entries: Iterable<{
86 readonly driverKind: ProviderDriverKind;
87 readonly acpRegistryAgentId?: string | undefined;
88 }>,
89): boolean {
90 if (entry.accentColor) return true;
91 let sharedProviderCount = 0;
92 for (const candidate of entries) {
93 if (candidate.driverKind !== entry.driverKind) continue;
94 if (
95 entry.driverKind === "acpRegistry" &&
96 candidate.acpRegistryAgentId !== entry.acpRegistryAgentId
97 ) {
98 continue;
99 }
100 if (++sharedProviderCount > 1) return true;
101 }
102 return false;
103}