Split Control Center routes into modules

This commit is contained in:
OpenPets Dev 2026-06-18 05:08:04 +00:00
parent a03c3df410
commit 1d7d2e2c6a
12 changed files with 4620 additions and 4394 deletions

View file

@ -28,6 +28,7 @@ This is the canonical feature registry for the FamiliarOS working repo. It maps
| Custom Familiar name | **merged** | `feat/familiaros-rebrand` | current | `apps/desktop/src/app-state.ts`, `apps/desktop/src/tray.ts`, `apps/desktop/src/renderer/src/main.tsx` | User-defined name for the default Familiar appears in tray tooltip and context menu. |
| Curated familiar catalog safety | **merged** | `feat/familiaros-rebrand` | current | `apps/desktop/src/catalog.ts`, `apps/desktop/src/catalog-validation.ts`, `apps/desktop/catalog.v2.fixture.json`, `apps/desktop/contracts/catalog-fixture.contract.ts` | Curated catalog metadata now preserves `original` / `featured` / category fields end-to-end, uses IP-safer FamiliarOS fixture familiars, and keeps surfaceable pagination totals aligned with the real searchable set. |
| Control Center bridge hardening | **merged** | `feat/familiaros-rebrand` | current | `apps/desktop/src/renderer/src/main.tsx`, `apps/desktop/tests/plugin-ui-static.test.ts` | The Control Center API bridge now resolves to a required `ControlCenterApi` once at startup instead of leaking optionality through the whole renderer, and the static validation was updated to the stricter bridge shape. |
| Control Center route modularization | **merged** | `feat/familiaros-rebrand` | current | `apps/desktop/src/renderer/src/main.tsx`, `apps/desktop/src/renderer/src/control-center/*.tsx`, `apps/desktop/src/check-packaging-contract.ts`, `apps/desktop/tests/plugin-ui-static.test.ts` | The renderer shell now routes into dedicated dashboard, familiars, settings, integrations, and plugins modules, with shared bridge/types/UI extracted into `control-center/shared.tsx` so future Control Center work no longer has to funnel through one giant file. |
## 2. Floating chat / prompt window

View file

@ -71,6 +71,10 @@ assert.match(readFileSync(join(appDir, "src", "assets.ts"), "utf8"), /assets["']
const petWindowSource = readFileSync(join(appDir, "src", "familiar-window.ts"), "utf8");
const controlCenterPreloadSource = readFileSync(join(appDir, "control-center-preload.cjs"), "utf8");
const controlCenterRendererSource = readFileSync(join(appDir, "src", "renderer", "src", "main.tsx"), "utf8");
const controlCenterSharedSource = readFileSync(join(appDir, "src", "renderer", "src", "control-center", "shared.tsx"), "utf8");
const controlCenterSettingsSource = readFileSync(join(appDir, "src", "renderer", "src", "control-center", "settings-view.tsx"), "utf8");
const controlCenterFamiliarsSource = readFileSync(join(appDir, "src", "renderer", "src", "control-center", "familiars-view.tsx"), "utf8");
const controlCenterIntegrationsSource = readFileSync(join(appDir, "src", "renderer", "src", "control-center", "integrations-view.tsx"), "utf8");
const petPreloadSource = readFileSync(join(appDir, "familiar-preload.cjs"), "utf8");
const promptWindowPreloadSource = readFileSync(join(appDir, "prompt-window-preload.cjs"), "utf8");
const reactionMessagesSource = readFileSync(join(appDir, "src", "reaction-messages.ts"), "utf8");
@ -190,10 +194,10 @@ assert.match(controlCenterPreloadSource, /saveOpenApiCredential/, "Control Cente
assert.match(promptWindowPreloadSource, /submitPrompt/, "Prompt window preload must expose prompt submission.");
assert.match(promptWindowSource, /sendOpenApiChatPrompt/, "Prompt window must submit prompts through the main-process OpenAPI chat service.");
assert.match(promptWindowSource, /openControlCenterWindow\("settings"\)/, "Prompt window must be able to open Settings when the API key is missing.");
assert.match(controlCenterRendererSource, /API key or token|OpenAI API key/, "Control Center settings must expose OpenAPI chat credential setup.");
assert.match(controlCenterRendererSource, /function SettingsView\(/, "Control Center must include the settings page.");
assert.match(controlCenterRendererSource, /getPetsState/, "Control Center must include the familiars page data bridge.");
assert.match(controlCenterRendererSource, /function IntegrationsView\(\)/, "Control Center must include the integrations page.");
assert.match(`${controlCenterSettingsSource}\n${controlCenterSharedSource}`, /API key or token|OpenAI API key/, "Control Center settings must expose OpenAPI chat credential setup.");
assert.match(controlCenterRendererSource, /from "\.\/control-center\/settings-view"/, "Control Center shell must load the settings page module.");
assert.match(controlCenterFamiliarsSource, /getPetsState/, "Control Center must include the familiars page data bridge.");
assert.match(controlCenterIntegrationsSource, /export function IntegrationsView\(\)/, "Control Center must include the integrations page.");
assert.match(petWindowSource, /--bubble-max-width:/, "familiar window must define a scalable bubble max-width variable.");
assert.match(petWindowSource, /\.bubble\.is-very-long-message \{[\s\S]*?max-width:\s*min\([^)]*var\(--bubble-max-width\)/, "very long message bubbles must stay capped within the familiar window while allowing larger familiars.");
assert.match(petWindowSource, /\.bubble-body \{[\s\S]*?overflow-y:\s*auto/, "bubble bodies must be scrollable for long messages.");
@ -223,7 +227,7 @@ for (const reaction of allowedReactions) {
}
assert.equal(pickReactionMessage("success", () => 0), reactionMessagePools.success[0], "reaction message picking must be deterministic when random is injected.");
assert.doesNotMatch(controlCenterRendererSource, /OnboardingView|getOnboardingSnapshot|completeOnboarding/, "Control Center must not include the removed onboarding route.");
assert.match(controlCenterRendererSource, /function IntegrationsView\(\)/, "Control Center must include integrations.");
assert.match(controlCenterRendererSource, /from "\.\/control-center\/integrations-view"/, "Control Center shell must include the integrations module.");
assert.match(enCatalogSource, /Claude Code/, "Control Center integrations must include Claude Code.");
assert.match(enCatalogSource, /OpenCode/, "Control Center integrations must include OpenCode.");
assert.match(enCatalogSource, /Cursor/, "Control Center integrations must include Cursor.");

View file

@ -6,16 +6,24 @@ React/Tailwind source for the Control Center management UI. This renderer presen
## Design
- **Route Shell**: In-renderer route state supports `dashboard`, `familiars`, `integrations`, `plugins`, and `settings`; tray actions retarget the singleton window through route-change events.
- **Route Shell**: `main.tsx` now owns only the renderer bootstrap, translated route shell, theme wiring, and route switching for `dashboard`, `familiars`, `integrations`, `plugins`, and `settings`.
- **Route Modules**: Each major Control Center route now lives in `control-center/*.tsx`, so dashboard, familiars, integrations, plugins, and settings can evolve independently without re-growing a single renderer god file.
- **Dashboard**: Reads a narrowed dashboard snapshot for default familiar preview, install/catalog counts, plugin health, update status, and activity totals.
- **Familiars**: Combines installed familiars, catalog v3 pages/search, Codex imports, filters, detail panes, set-default/install/import/remove actions, and animated sprite previews.
- **Integrations**: Card-first setup UI for Claude Code, OpenCode, Cursor, and Pi guidance, including command mode/path controls and preview/action flows.
- **Plugins**: Gallery-first plugin hub for installed/catalog/local/broken filters, catalog refresh, local load, install/update/uninstall, enable/disable, config modal, command execution, runtime/status display, and broken-state feedback.
- **Settings**: Startup, launch-at-login, familiar scale, reaction-animation mapping, update check, default-familiar position reset, and familiar reaction previews.
- **Shared Control Center Surface**: `control-center/shared.tsx` owns the required preload bridge resolution, shared types, route metadata, reusable UI primitives, and safe familiar-preview helpers.
- **Bridge Contract**: All data and actions go through `window.familiarOSControlCenter` with `window.openPetsControlCenter` retained as a legacy alias; page snapshots intentionally omit raw install paths and unrelated app state.
## Key Files
- `main.tsx`: Single-file React app containing type definitions, route shell, page components, icons, snapshot loading, and action handlers.
- `main.tsx`: Renderer bootstrap plus translated route shell and theme wiring.
- `control-center/shared.tsx`: Required preload bridge resolver, renderer type surface, route metadata, icons, buttons, and familiar-preview helpers shared across route modules.
- `control-center/dashboard-view.tsx`: Dashboard route.
- `control-center/familiars-view.tsx`: Familiars route with catalog/search/detail workflows.
- `control-center/integrations-view.tsx`: Coding-agent integrations route.
- `control-center/plugins-view.tsx`: Plugin management route.
- `control-center/settings-view.tsx`: Settings, knowledge store, OpenAPI, and TTS route.
- `styles.css`: Tailwind base/components/utilities plus glass-card layout, navigation, galleries, modals, status pills, previews, and notifications.
- `vite-env.d.ts`: Vite/TypeScript renderer environment declarations.

View file

@ -0,0 +1,32 @@
# apps/desktop/src/renderer/src/control-center/
## Responsibility
Route-level Control Center renderer modules plus the shared renderer bridge/UI
surface they depend on.
## Design
- `shared.tsx` is the stable center of gravity for this folder. It owns the
required `ControlCenterApi` bridge resolution, shared renderer types, route
metadata, reusable buttons/cards/status pills, navigation icons, and safe
familiar-preview helpers.
- `dashboard-view.tsx` owns only the dashboard snapshot and activity UI.
- `familiars-view.tsx` owns familiar browsing, catalog paging/search, Codex
import, install/remove/default actions, and the familiar detail overlay.
- `integrations-view.tsx` owns coding-agent setup cards, MCP server previews,
toolkit installation flows, and command-path controls.
- `plugins-view.tsx` owns plugin catalog/install/config/runtime management.
- `settings-view.tsx` owns the settings tabs, OpenAPI/TTS settings, knowledge
store, memories, update status, and plugin platform settings.
## Working Notes
- Keep route-specific state inside the route module unless it truly needs to be
shared across routes.
- Add shared renderer helpers to `shared.tsx` only when at least two routes
need them or when the preload bridge/type contract changes.
- When a packaging or static assertion references the Control Center source
surface, update `apps/desktop/tests/plugin-ui-static.test.ts` and
`apps/desktop/src/check-packaging-contract.ts` so they follow the new module
boundary rather than assuming everything still lives in `main.tsx`.

View file

@ -0,0 +1,244 @@
import { useEffect, useState } from "react";
import { useI18n } from "../i18n";
import * as Shared from "./shared";
import type { DashboardSnapshot, Route } from "./shared";
const { api, ActivityIcon, BoxIcon, Button, GlassCard, HeartIcon, MessageIcon, PluginGlyph, RefreshIcon, ShieldIcon, Spinner, SpriteFrame, StarIcon, StatusPill, ZapIcon } = Shared;
export function DashboardView({ onNavigate }: { onNavigate: (route: Route) => void }) {
const { t } = useI18n();
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null);
const [error, setError] = useState("");
const load = async () => {
try {
const next = await api.getDashboardSnapshot();
setSnapshot(next);
setError("");
} catch (err) {
setError(String((err as Error)?.message ?? err));
}
};
useEffect(() => { void load(); }, []);
if (!snapshot) {
return (
<div className="flex flex-col gap-6 h-full">
<GlassCard className="flex h-full flex-col items-center justify-center gap-4 text-center py-16">
{!error && <Spinner />}
<p className="text-sm font-semibold text-slatecopy">{error || t("dashboard.loading")}</p>
{error && <Button variant="secondary" size="compact" icon={<RefreshIcon />} onClick={() => void load()}>{t("common.retry")}</Button>}
</GlassCard>
</div>
);
}
const { activity, defaultPet, plugins, installedPetCount, updateStatus, catalog } = snapshot;
// Find top familiar by activity or fallback to default
const topPetId = Object.entries(activity.perPetActivityCounts).sort(([, a], [, b]) => b - a)[0]?.[0];
const topPetName = topPetId === defaultPet.id ? defaultPet.displayName : (topPetId || defaultPet.displayName);
// Find top reaction
const reactionEntries = Object.entries(activity.reactionCounts)
.filter(([, count]) => count > 0)
.sort(([, a], [, b]) => b - a);
const reactionTotal = reactionEntries.reduce((total, [, count]) => total + count, 0);
const reactionColors = ["#3b82f6", "#a855f7", "#f97316", "#14b8a6"];
const reactionDonutSegments = reactionEntries.slice(0, 4).map(([label, count], index) => ({
label,
count,
color: reactionColors[index] ?? "#64748b",
}));
const topCompanionEntries = Object.entries(activity.perPetActivityCounts)
.filter(([, count]) => count > 0)
.sort(([, a], [, b]) => b - a)
.slice(0, 4);
const maxCompanionActivity = Math.max(...topCompanionEntries.map(([, count]) => count), 1);
const lastActiveLabel = activity.lastActivityAt ? new Date(activity.lastActivityAt).toLocaleString() : t("dashboard.lastActive.none");
const updateLabel = updateStatus.state === "available" ? t("dashboard.update.available") : updateStatus.state === "error" ? t("dashboard.update.error") : updateStatus.state === "checking" ? t("dashboard.update.checking") : updateStatus.state === "current" ? t("dashboard.update.current") : t("dashboard.update.notChecked");
return (
<div className="dashboard-layout">
{error && <div className="error">{error}</div>}
<section className="dashboard-hero">
<div className="dashboard-hero-content">
<p className="eyebrow !text-blue-100 opacity-80">{t("dashboard.hero.eyebrow")}</p>
<h2 className="dashboard-hero-title">{defaultPet.displayName}</h2>
<p className="dashboard-hero-desc">
{t("dashboard.hero.desc")}
</p>
<div className="flex gap-3 mt-3">
<Button variant="secondary" size="compact" onClick={() => onNavigate("familiars")}>{t("dashboard.hero.changePet")}</Button>
</div>
</div>
<div className="dashboard-hero-familiar">
<SpriteFrame src={defaultPet.previewSpriteUrl} label={defaultPet.displayName} state="idle" size="detail" />
</div>
</section>
<div className="dashboard-grid">
<article className="dashboard-stat-card">
<div className="dashboard-stat-header">
<div className="dashboard-stat-icon"><MessageIcon /></div>
<span className="dashboard-stat-label">{t("dashboard.stat.messages")}</span>
</div>
<div className="dashboard-stat-value">{activity.messagesSent.toLocaleString()}</div>
<div className="dashboard-stat-footer">{t("dashboard.stat.messages.footer")}</div>
</article>
<article className="dashboard-stat-card">
<div className="dashboard-stat-header">
<div className="dashboard-stat-icon"><HeartIcon /></div>
<span className="dashboard-stat-label">{t("dashboard.stat.reactions")}</span>
</div>
<div className="dashboard-stat-value">{activity.reactionsSent.toLocaleString()}</div>
<div className="dashboard-stat-footer">{t("dashboard.stat.reactions.footer")}</div>
</article>
<article className="dashboard-stat-card">
<div className="dashboard-stat-header">
<div className="dashboard-stat-icon"><StarIcon /></div>
<span className="dashboard-stat-label">{t("dashboard.stat.topCompanion")}</span>
</div>
<div className="dashboard-stat-value truncate text-2xl">{topPetName}</div>
<div className="dashboard-stat-footer">{t("dashboard.stat.topCompanion.footer")}</div>
</article>
</div>
<div className="dashboard-row">
<GlassCard className="dashboard-activity-card">
<div className="dashboard-section-title"><ActivityIcon /> {t("dashboard.activity.title")}</div>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3">
<span className="text-[10px] font-bold text-slatecopy uppercase tracking-wider">{t("dashboard.activity.topReactions")}</span>
<div className="dashboard-reaction-list">
{reactionEntries.length > 0 ? (
reactionEntries.slice(0, 6)
.map(([label, count]) => (
<div key={label} className="dashboard-reaction-item">
<span className="dashboard-reaction-count">{count}</span>
<span className="dashboard-reaction-label">{label}</span>
</div>
))
) : (
<div className="text-xs text-slatecopy italic py-2">{t("dashboard.activity.noReactions")}</div>
)}
</div>
</div>
<div className="dashboard-activity-charts">
<section className="dashboard-chart-panel dashboard-reaction-mix">
<div className="dashboard-chart-heading">
<span>{t("dashboard.reactionMix.title")}</span>
<small>{reactionTotal ? t("dashboard.reactionMix.total", { count: reactionTotal.toLocaleString() }) : t("dashboard.reactionMix.waiting")}</small>
</div>
<div className="dashboard-donut-row">
<div className="dashboard-donut" aria-label={t("dashboard.reactionMix.chartLabel")}>
<svg viewBox="0 0 100 100" role="img">
<circle className="dashboard-donut-track" cx="50" cy="50" r="40" />
{reactionTotal > 0 && reactionDonutSegments.map((segment, index) => {
const circumference = 251.327;
const previousTotal = reactionDonutSegments.slice(0, index).reduce((total, item) => total + item.count, 0);
const dash = (segment.count / reactionTotal) * circumference;
const offset = -(previousTotal / reactionTotal) * circumference;
return <circle key={segment.label} className="dashboard-donut-segment" cx="50" cy="50" r="40" stroke={segment.color} strokeDasharray={`${dash} ${circumference - dash}`} strokeDashoffset={offset} />;
})}
</svg>
<div className="dashboard-donut-center">
<strong>{reactionTotal.toLocaleString()}</strong>
<span>{t("dashboard.reactionMix.reactions")}</span>
</div>
</div>
<div className="dashboard-donut-legend">
{reactionDonutSegments.length ? reactionDonutSegments.map((segment) => (
<div key={segment.label} className="dashboard-donut-legend-item">
<span className="dashboard-donut-dot" style={{ background: segment.color }} />
<span>{segment.label}</span>
<strong>{segment.count}</strong>
</div>
)) : <p>{t("dashboard.reactionMix.empty")}</p>}
</div>
</div>
</section>
<section className="dashboard-chart-panel dashboard-companion-bars">
<div className="dashboard-chart-heading">
<span>{t("dashboard.companions.title")}</span>
<small>{t("dashboard.companions.subtitle")}</small>
</div>
<div className="dashboard-bars-list">
{topCompanionEntries.length ? topCompanionEntries.map(([petId, count]) => {
const label = petId === defaultPet.id ? defaultPet.displayName : petId.replace(/[-_]/g, " ");
return (
<div key={petId} className="dashboard-bar-item">
<div className="dashboard-bar-labels">
<span>{label}</span>
<strong>{count}</strong>
</div>
<div className="dashboard-bar-track"><span style={{ width: `${Math.max(8, Math.round((count / maxCompanionActivity) * 100))}%` }} /></div>
</div>
);
}) : <p className="dashboard-empty-note">{t("dashboard.companions.empty")}</p>}
</div>
</section>
<div className="dashboard-last-active-pill">{t("dashboard.lastActive.label")}<strong>{lastActiveLabel}</strong></div>
</div>
</div>
</GlassCard>
<GlassCard className="dashboard-system-card">
<div className="dashboard-section-title"><ZapIcon /> {t("dashboard.system.title")}</div>
<div className="dashboard-system-list">
<div className="dashboard-system-item">
<div className="dashboard-system-info">
<div className="dashboard-system-icon"><BoxIcon /></div>
<span className="dashboard-system-label">{t("dashboard.system.familiars")}</span>
</div>
<span className="dashboard-system-value">{t("dashboard.system.familiars.value", { count: installedPetCount })}</span>
</div>
<div className="dashboard-system-item">
<div className="dashboard-system-info">
<div className="dashboard-system-icon"><PluginGlyph className="w-4 h-4" /></div>
<span className="dashboard-system-label">{t("dashboard.system.plugins")}</span>
</div>
<div className="flex gap-1.5">
<StatusPill tone="green">{t("dashboard.system.plugins.enabled", { count: plugins.enabled })}</StatusPill>
{plugins.broken > 0 && <StatusPill tone="red">{plugins.broken}</StatusPill>}
</div>
</div>
<div className="dashboard-system-item">
<div className="dashboard-system-info">
<div className="dashboard-system-icon"><StarIcon /></div>
<span className="dashboard-system-label">{t("dashboard.system.catalog")}</span>
</div>
<span className="dashboard-system-value">{catalog.error ? t("dashboard.system.catalog.offline") : catalog.total ? t("dashboard.system.catalog.familiars", { count: catalog.total }) : t("dashboard.system.catalog.ready")}</span>
</div>
<div className="dashboard-system-item">
<div className="dashboard-system-info">
<div className="dashboard-system-icon"><ShieldIcon /></div>
<span className="dashboard-system-label">{t("dashboard.system.updates")}</span>
</div>
<StatusPill tone={updateStatus.state === "available" ? "orange" : "blue"}>
{updateLabel}
</StatusPill>
</div>
</div>
<div className="mt-auto pt-4 border-t border-blue-100/30">
<div className="flex items-center justify-between text-[10px] font-bold text-slatecopy uppercase tracking-wider">
<span>{t("dashboard.system.version")}</span>
<span className="font-mono">{updateStatus.currentVersion}</span>
</div>
</div>
</GlassCard>
</div>
</div>
);
}

View file

@ -0,0 +1,526 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../i18n";
import * as Shared from "./shared";
import type { CatalogState, CodexState, Filter, PetEntry, Route, SearchPetEntry, StateSnapshot } from "./shared";
const {
api,
Button,
CloseIcon,
defaultThumbUrl,
EyeIcon,
filterIcons,
filterLabelKeys,
FolderPlusIcon,
GlassCard,
HeartIcon,
imageDebug,
ImportIcon,
installedPetSpritesheetUrl,
InstallIcon,
logPetsError,
logPetsEvent,
NextIcon,
PetImage,
PrevIcon,
RefreshIcon,
RemoveIcon,
safePetImage,
SearchInput,
SetDefaultIcon,
SpriteFrame,
StatusPill,
} = Shared;
export function FamiliarsView() {
const { t } = useI18n();
const currentRoute: Route = "familiars";
const [state, setState] = useState<StateSnapshot | null>(null);
const [catalog, setCatalog] = useState<CatalogState | null>(null);
const [catalogPages, setCatalogPages] = useState<Record<number, PetEntry[]>>({});
const [catalogSearch, setCatalogSearch] = useState<SearchPetEntry[] | null>(null);
const [catalogPage, setCatalogPage] = useState(0);
const [codex, setCodex] = useState<CodexState>({ familiars: [] });
const [selectedId, setSelectedId] = useState("");
const [filter, setFilter] = useState<Filter>("all");
const [query, setQuery] = useState("");
const [busy, setBusy] = useState("");
const [error, setError] = useState("");
const petDetailDialogRef = useRef<HTMLDivElement | null>(null);
const previouslyFocusedElementRef = useRef<HTMLElement | null>(null);
async function loadPetsData() {
setError("");
const [nextState, nextCatalog, nextCodex] = await Promise.all([api.getPetsState(), api.getCatalog(), api.getCodexPets()]);
logPetsEvent("load-complete", { installed: nextState.familiars.installed.length, defaultPetId: nextState.preferences.defaultPetId, catalogSource: nextCatalog.source, catalogPets: nextCatalog.familiars.length, catalogPage: nextCatalog.page, catalogPageCount: nextCatalog.pageCount, codexPets: nextCodex.familiars.length, catalogError: nextCatalog.error, codexError: nextCodex.error, firstCatalogPet: nextCatalog.familiars[0] ? { id: nextCatalog.familiars[0].id, preview: imageDebug(nextCatalog.familiars[0].preview), thumbnail: imageDebug(nextCatalog.familiars[0].thumbnail), spritesheet: imageDebug(nextCatalog.familiars[0].spritesheet) } : null });
setState(nextState); setCatalog(nextCatalog); setCodex(nextCodex);
setCatalogPage(nextCatalog.page ?? 0);
setCatalogPages({ [nextCatalog.page ?? 0]: nextCatalog.familiars });
const visiblePetIds = new Set<string>([...nextState.familiars.installed.map((familiar) => familiar.id), ...nextCatalog.familiars.map((familiar) => familiar.id), ...nextCodex.familiars.map((familiar) => familiar.id)]);
setSelectedId((current) => current && visiblePetIds.has(current) ? current : "");
}
useEffect(() => {
if (currentRoute !== "familiars") return;
void loadPetsData().catch((err) => setError(String(err?.message ?? err)));
}, [currentRoute]);
const familiars = useMemo(() => {
const installed = new Map((state?.familiars.installed ?? []).map((p) => [p.id, p]));
const catalogMap = new Map<string, PetEntry>();
for (const pagePets of Object.values(catalogPages)) {
for (const p of pagePets) {
catalogMap.set(p.id, p);
}
}
const codexMap = new Map<string, PetEntry>((codex.familiars ?? []).map((p) => [p.id, p]));
const rows: PetEntry[] = (state?.familiars.installed ?? []).map((p) => {
const catalogPet = catalogMap.get(p.id);
const codexPet = codexMap.get(p.id);
const localSpritesheet = p.id && !catalogPet && !codexPet && !p.builtIn ? installedPetSpritesheetUrl(p.id) : undefined;
const spritesheet = safePetImage(codexPet?.spritesheet) || safePetImage(catalogPet?.spritesheet) || safePetImage(localSpritesheet);
const preview = safePetImage(codexPet?.preview) || safePetImage(catalogPet?.preview) || safePetImage(catalogPet?.thumbnail) || safePetImage(p.source && "preview" in p.source ? (p.source as { preview?: string }).preview : undefined) || safePetImage(localSpritesheet) || defaultThumbUrl;
const category = catalogPet?.category;
const original = catalogPet?.original;
const featured = catalogPet?.featured;
return {
...p,
spritesheet,
preview,
category,
original,
featured,
sourceKind: "installed" as const,
installed: true,
};
});
for (const p of catalogMap.values()) {
if (!installed.has(p.id)) {
rows.push({
...p,
preview: safePetImage(p.preview) || safePetImage(p.thumbnail) || defaultThumbUrl,
spritesheet: safePetImage(p.spritesheet),
sourceKind: "catalog",
installed: false,
});
}
}
for (const p of codexMap.values()) {
if (!installed.has(p.id) && !catalogMap.has(p.id)) {
rows.push({
...p,
preview: safePetImage(p.preview),
spritesheet: safePetImage(p.spritesheet),
sourceKind: "codex",
installed: false,
});
}
}
return rows.filter((p) => {
if (filter === "installed" && !p.installed) return false;
if (filter === "codex" && p.sourceKind !== "codex" && !(installed.get(p.id)?.source?.kind === "codex")) return false;
if (filter === "originals" && !p.original && !p.builtIn) return false;
if (filter === "featured" && (!p.featured || p.original)) return false;
const q = query.trim().toLowerCase();
return !q || `${p.displayName} ${p.description ?? ""} ${p.searchText ?? ""} ${p.id}`.toLowerCase().includes(q);
});
}, [state, catalogPages, catalogSearch, codex, filter, query]);
const selected = selectedId ? familiars.find((p) => p.id === selectedId) ?? null : null;
const defaultId = state?.preferences.defaultPetId;
useEffect(() => {
if (!selected) return;
const dialog = petDetailDialogRef.current;
if (!dialog) return;
previouslyFocusedElementRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const focusableSelector = "button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])";
requestAnimationFrame(() => {
dialog.querySelector<HTMLElement>(focusableSelector)?.focus();
});
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
setSelectedId("");
return;
}
if (event.key !== "Tab") return;
const focusable = Array.from(dialog.querySelectorAll<HTMLElement>(focusableSelector)).filter((element) => element.offsetParent !== null);
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
}
if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
previouslyFocusedElementRef.current?.focus();
};
}, [selected]);
useEffect(() => {
if (!selected) return;
logPetsEvent("selected-familiar", { id: selected.id, sourceKind: selected.sourceKind, installed: selected.installed, builtIn: selected.builtIn, preview: imageDebug(selected.preview), spritesheet: imageDebug(selected.spritesheet), hasSafePreview: Boolean(safePetImage(selected.preview)), hasSafeSpritesheet: Boolean(safePetImage(selected.spritesheet)), catalogPages: Object.keys(catalogPages).join(",") });
}, [selected]);
const statusText = useMemo(() => {
if (!selected) return "";
const isDefault = selected.id === defaultId;
const isCodex = selected.sourceKind === "codex" || (state?.familiars.installed.find(p => p.id === selected.id)?.source?.kind === "codex");
if (selected.broken) return selected.brokenReason || t("familiars.status.broken");
if (isDefault) return selected.protected ? t("familiars.status.defaultProtected") : t("familiars.status.default");
if (selected.installed) {
if (isCodex) return t("familiars.status.installedCodex");
return t("familiars.status.installed");
}
if (selected.sourceKind === "codex") return t("familiars.status.availableCodex");
return t("familiars.status.availableCatalog");
}, [selected, defaultId, state, t]);
async function act(label: string, fn: () => Promise<unknown>) {
try { setBusy(label); setError(""); await fn(); await loadPetsData(); }
catch (err) { setError(String((err as Error)?.message ?? err)); }
finally { setBusy(""); }
}
useEffect(() => {
if (currentRoute !== "familiars") return;
if (catalogSearch) return;
void api.getCatalogSearch().then((result) => {
if (result.error) setError(result.error);
setCatalogSearch(result.familiars ?? []);
}).catch((err) => setError(String(err?.message ?? err)));
}, [catalogSearch, currentRoute]);
useEffect(() => {
if (currentRoute !== "familiars") return;
if (!catalogSearch) return;
const q = query.trim().toLowerCase();
const needsRemotePages = !!q || filter === "featured" || filter === "originals";
const pages = new Set<number>();
if (state?.familiars.installed) {
for (const p of state.familiars.installed) {
const searchPet = catalogSearch.find(sp => sp.id === p.id);
if (searchPet && typeof searchPet.catalogPage === "number" && !catalogPages[searchPet.catalogPage]) {
pages.add(searchPet.catalogPage);
}
}
}
if (needsRemotePages) {
for (const familiar of catalogSearch) {
if (pages.size >= 12) break;
if (filter === "originals" && !familiar.original) continue;
if (filter === "featured" && (!familiar.featured || familiar.original)) continue;
if (q && !`${familiar.displayName} ${familiar.searchText ?? ""} ${familiar.id}`.toLowerCase().includes(q)) continue;
if (typeof familiar.catalogPage === "number" && !catalogPages[familiar.catalogPage]) pages.add(familiar.catalogPage);
}
}
if (!pages.size) return;
let cancelled = false;
void Promise.all([...pages].map((page) => api.getCatalogPage(page).catch((err) => ({ source: "error", familiars: [], error: String((err as Error)?.message ?? err), page } as CatalogState)))).then((results) => {
if (cancelled) return;
setCatalogPages((current) => {
const next = { ...current };
for (const result of results) if (result.source !== "error") next[result.page ?? 0] = result.familiars;
return next;
});
const firstError = results.find((result) => result.source === "error")?.error;
if (firstError) setError(firstError);
});
return () => { cancelled = true; };
}, [catalogPages, catalogSearch, filter, query, state, currentRoute]);
async function loadCatalogPage(page: number) {
if (catalogPages[page]) { setCatalogPage(page); return; }
try {
setBusy(t("familiars.busy.loadingPage")); setError("");
const next = await api.getCatalogPage(page);
setCatalog(next); setCatalogPage(next.page ?? page); setCatalogPages((pages) => ({ ...pages, [next.page ?? page]: next.familiars }));
} catch (err) { setError(String((err as Error)?.message ?? err)); }
finally { setBusy(""); }
}
return (
<div className="layout">
<GlassCard className="gallery">
<div className="toolbar"><SearchInput value={query} onChange={(e) => setQuery(e.target.value)} /></div>
<div className="filter-row">
<div className="filters">
{(["all", "installed", "featured", "originals", "codex"] as Filter[]).map((f) => (
<button
key={f}
className={`filter ${filter === f ? "active" : ""} ${f === "originals" ? "original" : ""} ${f === "featured" ? "featured" : ""}`}
onClick={() => setFilter(f)}
aria-current={filter === f ? "page" : undefined}
>
<span className="filter-icon-wrapper">{filterIcons[f]}</span>
<span className="filter-text">{t(filterLabelKeys[f])}</span>
</button>
))}
</div>
<div className="filter-actions">
<Button variant="secondary" size="compact" icon={<FolderPlusIcon />} disabled={!!busy} onClick={() => void act(t("familiars.busy.importing"), () => api.installLocalPet())}>{t("familiars.import")}</Button>
<Button variant="secondary" size="compact" icon={<HeartIcon />} onClick={() => void api.openGallery().catch((err) => setError(String(err?.message ?? err)))}>{t("familiars.gallery")}</Button>
</div>
</div>
<div className="familiars-grid">{familiars.map((familiar) => {
const isBuiltIn = familiar.builtIn;
const hasDistinctPreview = familiar.preview && familiar.preview !== familiar.spritesheet;
const useSpritesheetFrame = !isBuiltIn && !hasDistinctPreview && !!familiar.spritesheet;
const isDefault = familiar.id === defaultId;
const canInstall = !familiar.installed && familiar.sourceKind === "catalog";
const canImport = !familiar.installed && familiar.sourceKind === "codex";
const canSetDefault = familiar.installed && !isDefault && !familiar.broken;
const canRemove = familiar.installed && !familiar.builtIn && !familiar.protected;
return (
<div
key={`${familiar.sourceKind}-${familiar.id}`}
className={`familiar-card group ${selected?.id === familiar.id ? "selected" : ""}`}
>
<span className="thumb">
{useSpritesheetFrame ? (
<SpriteFrame src={familiar.spritesheet} label={t("familiars.spriteLabel.thumbnail", { name: familiar.displayName })} size="thumb" />
) : (
<PetImage src={familiar.preview} debugLabel={`${familiar.id}:card`} />
)}
</span>
<div className="card-content">
<span className="card-title-row">
<b className="card-title">{familiar.displayName}</b>
</span>
<p className="card-desc">{familiar.description || familiar.id}</p>
<div className="badges">{isDefault && <StatusPill tone="green">{t("familiars.badge.default")}</StatusPill>}{familiar.original || familiar.builtIn ? <StatusPill tone="yellow">{t("familiars.badge.original")}</StatusPill> : familiar.featured ? <StatusPill tone="purple">{t("familiars.badge.featured")}</StatusPill> : null}{familiar.installed && <StatusPill>{t("familiars.badge.installed")}</StatusPill>}{familiar.sourceKind === "codex" && <StatusPill tone="orange">{t("familiars.badge.codex")}</StatusPill>}</div>
<div className="familiar-card-actions" onClick={(event) => event.stopPropagation()}>
<Button
variant="secondary"
size="compact"
icon={<EyeIcon />}
ariaLabel={t("familiars.aria.view", { name: familiar.displayName })}
onClick={() => setSelectedId(familiar.id)}
>
{t("familiars.action.viewPet")}
</Button>
{canInstall && (
<Button
variant="primary"
size="compact"
icon={<InstallIcon />}
disabled={!!busy}
ariaLabel={t("familiars.aria.install", { name: familiar.displayName })}
onClick={() => { void act(t("familiars.busy.installing"), () => api.installPet(familiar.id)); }}
>
{t("familiars.action.install")}
</Button>
)}
{canImport && (
<Button
variant="warning"
size="compact"
icon={<ImportIcon />}
disabled={!!busy}
ariaLabel={t("familiars.aria.import", { name: familiar.displayName })}
onClick={() => { void act(t("familiars.busy.importing"), () => api.importCodexPet(familiar.id)); }}
>
{t("familiars.action.import")}
</Button>
)}
{canSetDefault && (
<Button
variant="primary"
size="compact"
icon={<SetDefaultIcon />}
disabled={!!busy}
ariaLabel={t("familiars.aria.setDefault", { name: familiar.displayName })}
onClick={() => { void act(t("familiars.busy.settingDefault"), () => api.setDefaultPet(familiar.id)); }}
>
{t("familiars.action.default")}
</Button>
)}
{canRemove && (
<Button
variant="danger"
size="compact"
icon={<RemoveIcon />}
disabled={!!busy}
ariaLabel={t("familiars.aria.remove", { name: familiar.displayName })}
onClick={() => { void act(t("familiars.busy.removing"), () => api.removePet(familiar.id)); }}
>
{t("familiars.action.remove")}
</Button>
)}
</div>
</div>
</div>
);
})}</div>
<div className="pager">
{!!catalog?.pageCount && catalog.pageCount > 1 ? (
<Button
variant="secondary"
size="compact"
icon={<PrevIcon />}
disabled={!!busy || catalogPage <= 0}
onClick={() => void loadCatalogPage(catalogPage - 1)}
>
{t("familiars.pager.prev")}
</Button>
) : <span />}
<span className="pager-text">{t("familiars.pager.count", { count: familiars.length })}{!!catalog?.pageCount && catalog.pageCount > 1 ? t("familiars.pager.page", { page: catalogPage + 1, pageCount: catalog.pageCount }) : ""}</span>
{!!catalog?.pageCount && catalog.pageCount > 1 ? (
<Button
variant="secondary"
size="compact"
icon={<NextIcon />}
iconPosition="right"
disabled={!!busy || catalogPage >= catalog.pageCount - 1}
onClick={() => void loadCatalogPage(catalogPage + 1)}
>
{t("familiars.pager.next")}
</Button>
) : <span />}
</div>
</GlassCard>
{selected ? (
<div ref={petDetailDialogRef} className="plugin-config-overlay" role="dialog" aria-modal="true" aria-label={t("familiars.detail.ariaLabel", { name: selected.displayName })}>
<button className="plugin-config-backdrop" type="button" aria-label={t("familiars.detail.closeAria")} onClick={() => setSelectedId("")} />
<GlassCard className="plugin-inspector familiar-detail-inspector">
<div className="plugin-inspector-head">
<span className="plugin-inspector-icon">
{safePetImage(selected.spritesheet) ? (
<SpriteFrame src={selected.spritesheet} label={t("familiars.spriteLabel.thumb", { name: selected.displayName })} size="thumb" />
) : (
<PetImage src={selected.preview} debugLabel={`${selected.id}:thumb`} />
)}
</span>
<div className="flex-1 min-w-0">
<p className="eyebrow">{t("familiars.detail.eyebrow")}</p>
<h2>{selected.displayName}</h2>
</div>
<Button variant="secondary" size="compact" icon={<CloseIcon />} onClick={() => setSelectedId("")}>{t("common.close")}</Button>
</div>
<div className="familiar-detail-content">
<div className="familiar-detail-main">
<p className="desc">{selected.description || selected.id}</p>
<div className="stage">
{safePetImage(selected.spritesheet) ? (
<SpriteFrame src={selected.spritesheet} label={t("familiars.spriteLabel.animatedPreview", { name: selected.displayName })} />
) : (
<PetImage src={selected.preview} debugLabel={`${selected.id}:detail-fallback`} />
)}
</div>
<div className="meta">
{selected.broken && <StatusPill tone="red">{t("familiars.badge.broken")}</StatusPill>}
{selected.installed && !selected.broken && <StatusPill tone="green">{t("familiars.badge.ready")}</StatusPill>}
{selected.builtIn && <StatusPill tone="orange">{t("familiars.badge.originals")}</StatusPill>}
{selected.original && !selected.builtIn && <StatusPill tone="yellow">{t("familiars.badge.original")}</StatusPill>}
{selected.featured && !selected.original && <StatusPill tone="purple">{t("familiars.badge.featured")}</StatusPill>}
</div>
{statusText && <p className="text-sm text-slatecopy mt-3 mb-0 font-medium">{statusText}</p>}
</div>
<aside className="familiar-detail-reactions">
<h3 className="text-xs font-bold uppercase tracking-wider text-slatecopy mb-3">{t("familiars.detail.previewAnimations")}</h3>
<div className="familiar-preview-grid">
{[
{ label: t("familiars.detail.preview.idle"), state: "idle" as const },
{ label: t("familiars.detail.preview.thinking"), state: "thinking" as const },
{ label: t("familiars.detail.preview.happy"), state: "happy" as const },
{ label: t("familiars.detail.preview.wave"), state: "wave" as const },
].map((previewState) => (
<article key={previewState.state} className="familiar-preview-item">
<SpriteFrame src={selected.spritesheet} label={t("familiars.spriteLabel.statePreview", { name: selected.displayName, state: previewState.label })} state={previewState.state} size="mini" />
<span className="text-xs font-bold text-slatecopy">{previewState.label}</span>
</article>
))}
</div>
</aside>
</div>
<div className="actions-container mt-6 flex flex-col gap-3 familiar-detail-actions">
{/* Main Action (Install, Import, Set Default) */}
{!selected.installed && selected.sourceKind === "catalog" && (
<Button
variant="primary"
fullWidth
icon={<InstallIcon />}
disabled={!!busy}
onClick={() => act(t("familiars.busy.installing"), () => api.installPet(selected.id))}
>
{busy || t("familiars.detail.installPet")}
</Button>
)}
{!selected.installed && selected.sourceKind === "codex" && (
<Button
variant="warning"
fullWidth
icon={<ImportIcon />}
disabled={!!busy}
onClick={() => act(t("familiars.busy.importing"), () => api.importCodexPet(selected.id))}
>
{busy || t("familiars.detail.importCodexPet")}
</Button>
)}
{selected.installed && selected.id !== defaultId && !selected.broken && (
<Button
variant="primary"
fullWidth
icon={<SetDefaultIcon />}
disabled={!!busy}
onClick={() => act(t("familiars.busy.settingDefault"), () => api.setDefaultPet(selected.id))}
>
{busy || t("familiars.detail.setDefaultPet")}
</Button>
)}
<div className={`grid gap-3 ${selected.installed && !selected.builtIn && !selected.protected ? "grid-cols-2" : "grid-cols-1"}`}>
{selected.installed && !selected.builtIn && !selected.protected && (
<Button
variant="danger"
icon={<RemoveIcon />}
disabled={!!busy}
onClick={() => act(t("familiars.busy.removing"), () => api.removePet(selected.id))}
>
{t("familiars.detail.remove")}
</Button>
)}
<Button
variant="secondary"
icon={<RefreshIcon />}
disabled={!!busy}
onClick={() => void loadPetsData()}
>
{t("familiars.detail.refresh")}
</Button>
</div>
</div>
</GlassCard>
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,942 @@
import { useEffect, useState } from "react";
import { useI18n } from "../i18n";
import { getMcpToolkitEntry, mcpToolkitEntries, mcpToolkitTierLabels } from "../mcp-toolkit-catalog";
import claudeLogoUrl from "../../../../assets/integrations/claude.svg";
import opencodeLogoUrl from "../../../../assets/integrations/opencode.svg";
import cursorLogoUrl from "../../../../assets/integrations/cursor.svg";
import piLogoUrl from "../../../../assets/integrations/pi.svg";
import vscodeLogoUrl from "../../../../assets/integrations/vscode.svg";
import windsurfLogoUrl from "../../../../assets/integrations/windsurf.svg";
import zedLogoUrl from "../../../../assets/integrations/zed.svg";
import * as Shared from "./shared";
import type {
AgentSetupAction,
AgentSetupCommandPaths,
AgentSetupSnapshot,
ClaudeCodeStatus,
CursorSetupStatus,
FamiliarOSMcpServerHealth,
FamiliarOSMcpServerPreview,
McpToolkitInstallMode,
McpToolkitInstallResult,
McpToolkitPersistentTarget,
OpenCodeSetupStatus,
StatusTone,
} from "./shared";
const {
api,
BoxIcon,
buildPersistentToolkitBundle,
Button,
CloseIcon,
commandModeLabelKeys,
ConfigureIcon,
CopyIcon,
ExternalLinkIcon,
FolderPlusIcon,
getPersistentToolkitFollowUps,
GlassCard,
HookIcon,
InstallIcon,
MemoryIcon,
NextIcon,
PluginGlyph,
RefreshIcon,
RemoveIcon,
ReplaceIcon,
SaveIcon,
ServerIcon,
Spinner,
StatusPill,
} = Shared;
export function PathField({ label, value, placeholder, onSave, disabled }: { label: string; value: string; placeholder: string; onSave: (v: string) => void; disabled?: boolean }) {
const { t } = useI18n();
const [draft, setDraft] = useState(value);
useEffect(() => { setDraft(value); }, [value]);
return (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-slatecopy uppercase tracking-wider">{label}</label>
<div className="flex gap-2">
<input
className="plugin-input flex-1"
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={placeholder}
disabled={disabled}
/>
<Button variant="secondary" size="compact" icon={<SaveIcon />} disabled={disabled || draft === value} onClick={() => onSave(draft)}>{t("common.save")}</Button>
</div>
</div>
);
}
export function IntegrationIcon({ id }: { id: string }) {
const logos: Record<string, string> = {
claude: claudeLogoUrl,
opencode: opencodeLogoUrl,
cursor: cursorLogoUrl,
pi: piLogoUrl,
vscode: vscodeLogoUrl,
windsurf: windsurfLogoUrl,
zed: zedLogoUrl,
};
const src = logos[id];
if (src) return <img src={src} className="integration-logo" alt="" draggable="false" />;
if (id === "mcp-toolkit" || id === "mcp-tool-servers") return <BoxIcon />;
if (id === "familiaros-mcp-server") return <ServerIcon />;
return <PluginGlyph />;
}
export function claudeStatusTone(state: ClaudeCodeStatus["state"]): StatusTone {
if (state === "configured") return "green";
if (state === "error") return "red";
if (state === "needs_setup" || state === "detected") return "blue";
return "slate";
}
export function opencodeStatusTone(state: OpenCodeSetupStatus["state"]): StatusTone {
if (state === "configured") return "green";
if (state === "error") return "red";
if (state === "needs_setup") return "blue";
return "slate";
}
export function cursorStatusTone(state: CursorSetupStatus["state"]): StatusTone {
if (state === "configured") return "green";
if (state === "error" || state === "conflict") return "red";
if (state === "needs_update") return "orange";
if (state === "needs_setup") return "blue";
return "slate";
}
export function mcpToolkitTrustTone(trust: "official" | "hosted" | "gateway" | "community"): StatusTone {
if (trust === "official") return "green";
if (trust === "hosted") return "blue";
if (trust === "gateway") return "orange";
return "slate";
}
export function IntegrationsView() {
const { t } = useI18n();
const [snapshot, setSnapshot] = useState<AgentSetupSnapshot | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selectedToolkitId, setSelectedToolkitId] = useState<string>(mcpToolkitEntries[0]?.id ?? "");
const [toolkitInstallMode, setToolkitInstallMode] = useState<McpToolkitInstallMode>("manual");
const [toolkitPersistentTarget, setToolkitPersistentTarget] = useState<McpToolkitPersistentTarget>("claude-user");
const [toolkitLastInstall, setToolkitLastInstall] = useState<McpToolkitInstallResult | null>(null);
const [vanillaChatTools, setVanillaChatTools] = useState<string[]>([]);
const [vanillaChatSaving, setVanillaChatSaving] = useState(false);
const [busy, setBusy] = useState("");
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const [familiarosMcpPreview, setFamiliarosMcpPreview] = useState<FamiliarOSMcpServerPreview | null>(null);
const [familiarosMcpTest, setFamiliarosMcpTest] = useState<{ busy: boolean; result: FamiliarOSMcpServerHealth | null }>({ busy: false, result: null });
const load = async (selectedPetId?: string, commandMode?: AgentSetupSnapshot["commandMode"]) => {
try {
const petId = selectedPetId === undefined ? snapshot?.selectedPetId : selectedPetId;
const mode = commandMode === undefined ? snapshot?.commandMode : commandMode;
const next = await api.getIntegrationsState(petId, mode);
setSnapshot(next);
setError("");
} catch (err) {
setError(String((err as Error)?.message ?? err));
}
};
useEffect(() => { void load(); }, []);
useEffect(() => {
if (!message) return;
const timeout = window.setTimeout(() => setMessage(""), 3000);
return () => window.clearTimeout(timeout);
}, [message]);
useEffect(() => {
void (async () => {
try {
const state = await api.getVanillaChatMcpTools();
setVanillaChatTools(state.enabled);
} catch {
// ignore
}
})();
}, []);
useEffect(() => {
if (!snapshot) return;
void api.getFamiliarOSMcpServerPreview(snapshot.selectedPetId, snapshot.commandMode)
.then(setFamiliarosMcpPreview)
.catch(() => setFamiliarosMcpPreview(null));
}, [snapshot?.selectedPetId, snapshot?.commandMode, snapshot?.commandPaths.node]);
const run = async (label: string, action: AgentSetupAction) => {
try {
setBusy(label);
setError("");
setMessage("");
const next = await api.runIntegrationAction(action, snapshot?.selectedPetId, snapshot?.commandMode);
setSnapshot(next);
if (next.lastAction) {
if (next.lastAction.ok) setMessage(next.lastAction.message);
else setError(next.lastAction.message);
}
} catch (err) {
setError(String((err as Error)?.message ?? err));
} finally {
setBusy("");
}
};
const updatePath = async (key: keyof AgentSetupCommandPaths, value: string) => {
try {
setBusy(t("integrations.busy.savingPath"));
await api.updateIntegrationCommandPaths({ [key]: value });
await load();
setMessage(t("integrations.toast.pathSaved"));
} catch (err) {
setError(String((err as Error)?.message ?? err));
} finally {
setBusy("");
}
};
const changeCommandMode = (mode: AgentSetupSnapshot["commandMode"]) => {
void load(snapshot?.selectedPetId, mode);
};
const copyText = async (label: string, value: string) => {
try {
await api.copyText(value);
setMessage(`${label} copied.`);
setError("");
} catch (err) {
setError(String((err as Error)?.message ?? err));
}
};
const openDocs = async (url: string) => {
try {
await api.openExternalUrl(url);
setError("");
} catch (err) {
setError(String((err as Error)?.message ?? err));
}
};
const testFamiliarOSMcpServer = async () => {
if (!snapshot) return;
try {
setFamiliarosMcpTest({ busy: true, result: null });
setError("");
setMessage("");
const result = await api.testFamiliarOSMcpServer(snapshot.selectedPetId, snapshot.commandMode);
setFamiliarosMcpTest({ busy: false, result });
if (result.ok) {
setMessage(t("integrations.familiarosMcpServer.testOk", { output: result.output }));
} else {
setError(t("integrations.familiarosMcpServer.testError", { error: result.error || "unknown" }));
}
} catch (err) {
setFamiliarosMcpTest({ busy: false, result: null });
setError(String((err as Error)?.message ?? err));
}
};
const copyFamiliarOSMcpJson = async () => {
if (!familiarosMcpPreview) return;
await copyText("MCP JSON", JSON.stringify(familiarosMcpPreview.mcpJson, null, 2));
};
const saveVanillaChatTools = async (toolIds: string[]) => {
try {
setVanillaChatSaving(true);
setError("");
const saved = await api.setVanillaChatMcpTools(toolIds);
setVanillaChatTools(saved);
setMessage(saved.length > 0 ? t("integrations.mcpToolServers.saved", { count: saved.length }) : "Vanilla chat tools deactivated.");
} catch (err) {
setError(String((err as Error)?.message ?? err));
} finally {
setVanillaChatSaving(false);
}
};
const toggleVanillaChatTool = (id: string) => {
setVanillaChatTools((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]);
};
const installToolkitHost = async () => {
try {
setBusy("Installing toolkit");
setError("");
setMessage("");
const result = await api.installMcpToolkit(toolkitPersistentTarget);
setToolkitLastInstall(result);
if (result.installed.length > 0) {
const installedSummary = result.installed.map((entry) => entry.name).join(", ");
setMessage(result.skipped.length > 0
? `Installed ${installedSummary}. Some lanes still need manual follow-up.`
: `Installed ${installedSummary}.`);
} else {
setMessage("No toolkit MCPs were installed automatically. Check the details below.");
}
} catch (err) {
setError(String((err as Error)?.message ?? err));
} finally {
setBusy("");
}
};
if (!snapshot) {
return (
<GlassCard className="flex h-64 flex-col items-center justify-center gap-4 text-center">
{!error && <Spinner />}
<p className="text-sm font-semibold text-slatecopy">{error || t("integrations.loading")}</p>
{error && <Button variant="secondary" size="compact" icon={<RefreshIcon />} onClick={() => void load()}>{t("common.retry")}</Button>}
</GlassCard>
);
}
const isBusy = Boolean(busy) || snapshot.busy;
const integrationDialogTitleId = selectedId ? `integration-detail-title-${selectedId}` : undefined;
const selectedToolkit = getMcpToolkitEntry(selectedToolkitId) ?? mcpToolkitEntries[0];
const persistentBundle = buildPersistentToolkitBundle(toolkitPersistentTarget);
const persistentFollowUps = getPersistentToolkitFollowUps();
const mcpToolServersActive = vanillaChatTools.length;
const integrations = [
{ id: "mcp-tool-servers", name: t("integrations.mcpToolServers.name"), icon: "mcp-tool-servers", status: mcpToolServersActive > 0 ? t("integrations.mcpToolServers.status", { count: mcpToolServersActive }) : t("integrations.mcpToolServers.noTools"), tone: mcpToolServersActive > 0 ? "green" : "slate", description: t("integrations.mcpToolServers.description") },
{ id: "familiaros-mcp-server", name: t("integrations.familiarosMcpServer.name"), icon: "familiaros-mcp-server", status: t("integrations.familiarosMcpServer.status"), tone: "blue" satisfies StatusTone, description: t("integrations.familiarosMcpServer.description") },
{ id: "claude", name: t("integrations.claude.name"), icon: "claude", status: snapshot.status.label, tone: claudeStatusTone(snapshot.status.state), description: t("integrations.claude.description") },
{ id: "opencode", name: t("integrations.opencode.name"), icon: "opencode", status: snapshot.opencodeStatus.label, tone: opencodeStatusTone(snapshot.opencodeStatus.state), description: t("integrations.opencode.description") },
{ id: "cursor", name: t("integrations.cursor.name"), icon: "cursor", status: snapshot.cursorStatus.label, tone: cursorStatusTone(snapshot.cursorStatus.state), description: t("integrations.cursor.description") },
{ id: "pi", name: t("integrations.pi.name"), icon: "pi", status: t("integrations.pi.status"), tone: "blue" satisfies StatusTone, description: t("integrations.pi.description") },
{ id: "mcp-toolkit", name: t("integrations.curatedToolkit.name"), icon: "mcp-toolkit", status: "Curated", tone: "purple" satisfies StatusTone, description: t("integrations.curatedToolkit.description") },
] as const;
const soon = [
{ name: t("integrations.soon.vscode"), icon: "vscode" },
{ name: t("integrations.soon.windsurf"), icon: "windsurf" },
{ name: t("integrations.soon.zed"), icon: "zed" },
];
const selectedIntegrationName = selectedId === "pi"
? t("integrations.pi.name")
: selectedId === "mcp-toolkit"
? t("integrations.curatedToolkit.name")
: selectedId === "mcp-tool-servers"
? t("integrations.mcpToolServers.name")
: selectedId === "familiaros-mcp-server"
? t("integrations.familiarosMcpServer.name")
: integrations.find((item) => item.id === selectedId)?.name;
return (
<div className="flex flex-col gap-6 h-full overflow-y-auto pr-2">
{error && <div className="error">{error}</div>}
{message && <div className="settings-success settings-message">{message}</div>}
<div className="integration-grid">
{integrations.map((item) => (
<article key={item.id} className={`integration-card ${selectedId === item.id ? "border-brand ring-4 ring-brand/15" : ""}`}>
<div className="plugin-card-body">
<div className="integration-icon">
<IntegrationIcon id={item.icon} />
</div>
<div className="plugin-card-content">
<div className="flex items-center justify-between">
<strong>{item.name}</strong>
<StatusPill tone={item.tone}>{item.status}</StatusPill>
</div>
<small>{item.description}</small>
</div>
</div>
<div className="plugin-card-footer">
<div className="flex gap-2 w-full">
{item.id === "claude" && snapshot.status.canConfigure && <Button variant="primary" size="compact" icon={<InstallIcon />} disabled={isBusy} onClick={() => run(t("integrations.busy.installing"), "configure")}>{t("integrations.install")}</Button>}
{item.id === "opencode" && snapshot.opencodeStatus.canInstall && <Button variant="primary" size="compact" icon={<InstallIcon />} disabled={isBusy} onClick={() => run(t("integrations.busy.installing"), "opencode-install")}>{t("integrations.install")}</Button>}
{item.id === "cursor" && snapshot.cursorStatus.canInstall && <Button variant="primary" size="compact" icon={<InstallIcon />} disabled={isBusy} onClick={() => run(t("integrations.busy.installing"), "cursor-install")}>{t("integrations.install")}</Button>}
<Button variant="secondary" size="compact" icon={<ConfigureIcon />} fullWidth={item.id === "pi"} onClick={() => setSelectedId(item.id)}>{item.id === "pi" ? t("integrations.viewSetup") : t("integrations.configure")}</Button>
</div>
</div>
</article>
))}
{soon.map((item) => (
<article key={item.name} className="integration-card opacity-60">
<div className="plugin-card-body">
<div className="integration-icon grayscale">
<IntegrationIcon id={item.icon} />
</div>
<div className="plugin-card-content">
<div className="flex items-center justify-between">
<strong>{item.name}</strong>
<StatusPill tone="slate">{t("integrations.soon.status")}</StatusPill>
</div>
<small>{t("integrations.soon.description")}</small>
</div>
</div>
<div className="plugin-card-footer">
<Button variant="secondary" size="compact" fullWidth disabled>{t("integrations.soon.button")}</Button>
</div>
</article>
))}
</div>
{selectedId && (
<div className="plugin-config-overlay" role="dialog" aria-modal="true" aria-labelledby={integrationDialogTitleId}>
<button className="plugin-config-backdrop" type="button" aria-label={t("integrations.closeAria")} onClick={() => setSelectedId(null)} />
<GlassCard className="plugin-inspector">
<div className="plugin-inspector-head">
<div className="plugin-inspector-icon">
<IntegrationIcon id={selectedId} />
</div>
<div className="flex-1 min-w-0">
<p className="eyebrow">{t("integrations.detail")}</p>
<h2 id={integrationDialogTitleId}>{selectedIntegrationName}</h2>
</div>
<Button variant="secondary" size="compact" icon={<CloseIcon />} onClick={() => setSelectedId(null)}>{t("integrations.close")}</Button>
</div>
<div className="flex flex-col gap-5 mt-4">
{selectedId === "familiaros-mcp-server" && (
<section className="plugin-section">
<div className="plugin-section-title"><small>{t("integrations.commandSource")}</small><strong>{t("integrations.cliMode")}</strong></div>
<select className="settings-select w-full" value={snapshot.commandMode} disabled={isBusy || familiarosMcpTest.busy} onChange={(event) => changeCommandMode(event.target.value as AgentSetupSnapshot["commandMode"])}>
<option value="published">{t(commandModeLabelKeys.published)}</option>
<option value="bundled">{t(commandModeLabelKeys.bundled)}</option>
<option value="local" disabled={!snapshot.localDevAvailable}>{t(commandModeLabelKeys.local)}{snapshot.localDevAvailable ? "" : t("integrations.localUnavailable")}</option>
</select>
<p className="text-xs text-slatecopy mt-2">{t("integrations.familiarosMcpServer.commandModeHelp")}</p>
</section>
)}
{(selectedId === "claude" || selectedId === "opencode" || selectedId === "cursor") && (
<section className="plugin-section">
<div className="plugin-section-title"><small>{t("integrations.commandSource")}</small><strong>{t("integrations.cliMode")}</strong></div>
<p className="text-sm text-slatecopy">{t("integrations.linkToCentralPanel")}</p>
<p className="text-xs text-slatecopy mt-2">{t(commandModeLabelKeys[snapshot.commandMode])}</p>
</section>
)}
{selectedId === "mcp-tool-servers" && (
<section className="plugin-section">
<div className="plugin-section-title"><small>{t("integrations.mcpToolServers.builtInChat")}</small><strong>{t("integrations.mcpToolServers.name")}</strong></div>
<p className="text-sm text-slatecopy leading-relaxed">
Select which MCP tools the familiar can use directly in the floating chat window. When active, the familiar can run terminal commands, browse files, fetch web pages, and more.
</p>
<div className="flex flex-col gap-4 mt-3">
{(["starter", "system", "advanced"] as const).map((tier) => {
const entries = mcpToolkitEntries.filter((entry) => entry.tier === tier);
return (
<div key={tier} className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<strong className="text-sm text-navy">{mcpToolkitTierLabels[tier]}</strong>
<small className="text-xs text-slatecopy">{entries.length} options</small>
</div>
<div className="plugin-chip-list">
{entries.map((entry) => {
const isSelected = vanillaChatTools.includes(entry.id);
return (
<button
key={entry.id}
type="button"
className={`plugin-chip ${isSelected ? "active" : ""}`}
onClick={() => toggleVanillaChatTool(entry.id)}
title={isSelected ? "Click to deactivate" : "Click to activate for vanilla chat"}
>
<span className={`inline-block w-3 h-3 rounded-full mr-1.5 ${isSelected ? "bg-green-500" : "bg-slate-300"}`} />
{entry.name}
</button>
);
})}
</div>
</div>
);
})}
</div>
<div className="flex gap-2 mt-4">
<Button variant="primary" icon={<InstallIcon />} disabled={isBusy || vanillaChatSaving} onClick={() => void saveVanillaChatTools(vanillaChatTools)}>
Save & Activate
</Button>
<Button variant="secondary" disabled={isBusy || vanillaChatSaving} onClick={() => void saveVanillaChatTools([])}>
Deactivate All
</Button>
</div>
<small className="text-xs text-slatecopy leading-relaxed mt-2 block">
Only tools with a green dot are active in the vanilla chat. The familiar will only use tools that are both selected <em>and</em> successfully started by the system.
</small>
</section>
)}
{selectedId === "familiaros-mcp-server" && (
<>
<section className="plugin-section">
<div className="plugin-section-title"><small>{t("integrations.configuration")}</small><strong>{t("integrations.commandPaths")}</strong></div>
<div className="flex flex-col gap-3">
<PathField label={t("integrations.nodeCommand")} value={snapshot.commandPaths.node} placeholder="node" onSave={(v) => updatePath("node", v)} disabled={isBusy || familiarosMcpTest.busy} />
</div>
</section>
<section className="plugin-section">
<div className="plugin-section-title"><small>{t("integrations.connection")}</small><strong>{t("integrations.statusRouting")}</strong></div>
<div className="mt-2">
<label className="text-xs font-bold text-slatecopy uppercase tracking-wider mb-1 block">{t("integrations.petRouting")}</label>
<select
className="settings-select w-full"
value={snapshot.selectedPetId || ""}
onChange={(e) => void load(e.target.value)}
disabled={isBusy || familiarosMcpTest.busy}
>
<option value="">{t("integrations.defaultPet")}</option>
{snapshot.petOptions.map(p => <option key={p.id} value={p.id}>{p.displayName}</option>)}
</select>
</div>
</section>
<section className="plugin-section">
<div className="plugin-section-title"><small>{t("integrations.actions")}</small><strong>{t("integrations.management")}</strong></div>
<div className="flex flex-wrap gap-2">
<Button variant="primary" icon={<RefreshIcon />} disabled={isBusy || familiarosMcpTest.busy} onClick={() => void testFamiliarOSMcpServer()}>
{familiarosMcpTest.busy ? t("integrations.familiarosMcpServer.testBusy") : t("integrations.familiarosMcpServer.testServer")}
</Button>
<Button variant="secondary" icon={<CopyIcon />} disabled={isBusy || !familiarosMcpPreview} onClick={() => void copyFamiliarOSMcpJson()}>
{t("integrations.familiarosMcpServer.copyMcpJson")}
</Button>
</div>
{familiarosMcpTest.result && (
<div className={`mt-3 p-3 rounded-2xl border text-xs ${familiarosMcpTest.result.ok ? "bg-green-50/50 border-green-200/80 text-green-900" : "bg-red-50/50 border-red-200/80 text-red-900"}`}>
{familiarosMcpTest.result.ok ? familiarosMcpTest.result.output : (familiarosMcpTest.result.error || "Unknown error")}
</div>
)}
</section>
<details className="plugin-section group">
<summary className="cursor-pointer list-none flex items-center justify-between">
<div className="plugin-section-title"><small>{t("integrations.advanced")}</small><strong>{t("integrations.mcpJsonPreview")}</strong></div>
<span className="text-brand group-open:rotate-180 transition-transform"><NextIcon /></span>
</summary>
<pre className="mt-3 p-3 rounded-xl bg-navy/5 text-[10px] font-mono overflow-x-auto border border-navy/5">
{familiarosMcpPreview ? JSON.stringify(familiarosMcpPreview.mcpJson, null, 2) : (
<span className="inline-flex items-center gap-2">
<Spinner className="h-3.5 w-3.5" />
{t("integrations.mcpJsonPreviewLoading")}
</span>
)}
</pre>
</details>
</>
)}
{selectedId === "claude" && (
<>
<section className="plugin-section">
<div className="plugin-section-title"><small>{t("integrations.connection")}</small><strong>{t("integrations.statusRouting")}</strong></div>
<div className="flex items-center justify-between p-3 rounded-2xl bg-blue-50/50 border border-blue-100/50">
<div className="flex flex-col">
<strong className="text-sm text-navy">{snapshot.status.label}</strong>
<small className="text-xs text-slatecopy">{snapshot.status.details}</small>
</div>
<StatusPill tone={claudeStatusTone(snapshot.status.state)}>{snapshot.status.state}</StatusPill>
</div>
</section>
<section className="plugin-section">
<div className="plugin-section-title"><small>{t("integrations.configuration")}</small><strong>{t("integrations.commandPaths")}</strong></div>
<div className="flex flex-col gap-3">
<PathField label={t("integrations.claudeCommand")} value={snapshot.commandPaths.claude} placeholder="claude" onSave={(v) => updatePath("claude", v)} disabled={isBusy} />
</div>
</section>
<div className="grid grid-cols-2 gap-3">
<section className="plugin-section">
<div className="plugin-section-title"><small>{t("integrations.optional")}</small><strong>{t("integrations.claudeHooks")}</strong></div>
<div className="flex items-center justify-between mb-2">
<StatusPill tone={snapshot.hookStatus.status === "installed" ? "green" : "blue"}>{snapshot.hookStatus.status}</StatusPill>
</div>
<div className="flex flex-col gap-2">
<Button variant="primary" size="compact" icon={<HookIcon />} disabled={isBusy} onClick={() => run(t("integrations.busy.installingHooks"), "install-hooks")}>{t("integrations.installHooks")}</Button>
<Button variant="danger" size="compact" icon={<RemoveIcon />} disabled={isBusy || snapshot.hookStatus.status === "needs_setup"} onClick={() => run(t("integrations.busy.removingHooks"), "uninstall-hooks")}>{t("integrations.removeHooks")}</Button>
</div>
</section>
<section className="plugin-section">
<div className="plugin-section-title"><small>{t("integrations.included")}</small><strong>{t("integrations.instructions")}</strong></div>
<div className="flex items-center justify-between mb-2">
<StatusPill tone={snapshot.memoryStatus.state === "installed" ? "green" : "blue"}>{snapshot.memoryStatus.state}</StatusPill>
</div>
<Button variant="secondary" size="compact" icon={<MemoryIcon />} disabled={isBusy} onClick={() => run(t("integrations.busy.updatingInstructions"), "install-memory")}>{t("integrations.updateInstructions")}</Button>
</section>
</div>
<section className="plugin-section">
<div className="plugin-section-title"><small>{t("integrations.actions")}</small><strong>{t("integrations.management")}</strong></div>
<div className="grid grid-cols-2 gap-2">
{snapshot.status.canConfigure && <Button variant="primary" icon={<InstallIcon />} disabled={isBusy} onClick={() => run(t("integrations.busy.installing"), "configure")}>{t("integrations.installMcp")}</Button>}
{snapshot.status.canReplace && <Button variant="warning" icon={<ReplaceIcon />} disabled={isBusy} onClick={() => run(t("integrations.busy.replacing"), "replace")}>{t("integrations.replaceMcp")}</Button>}
{snapshot.status.canRemove && <Button variant="danger" icon={<RemoveIcon />} disabled={isBusy} onClick={() => run(t("integrations.busy.removing"), "remove")}>{t("integrations.removeMcp")}</Button>}
<Button variant="secondary" icon={<RefreshIcon />} disabled={isBusy} onClick={() => void load()}>{t("integrations.refreshStatus")}</Button>
</div>
</section>
</>
)}
{selectedId === "opencode" && (
<>
<section className="plugin-section">
<div className="plugin-section-title"><small>{t("integrations.connection")}</small><strong>{t("integrations.globalSetup")}</strong></div>
<div className="flex items-center justify-between p-3 rounded-2xl bg-blue-50/50 border border-blue-100/50">
<div className="flex flex-col">
<strong className="text-sm text-navy">{snapshot.opencodeStatus.label}</strong>
<small className="text-xs text-slatecopy">{snapshot.opencodeStatus.details}</small>
</div>
<StatusPill tone={opencodeStatusTone(snapshot.opencodeStatus.state)}>{snapshot.opencodeStatus.state}</StatusPill>
</div>
</section>
<section className="plugin-section">
<div className="plugin-section-title"><small>{t("integrations.configuration")}</small><strong>{t("integrations.commandPaths")}</strong></div>
<div className="flex flex-col gap-3">
<PathField label={t("integrations.opencodeCommand")} value={snapshot.commandPaths.opencode} placeholder="opencode" onSave={(v) => updatePath("opencode", v)} disabled={isBusy} />
</div>
</section>
<section className="plugin-section">
<div className="plugin-section-title"><small>{t("integrations.actions")}</small><strong>{t("integrations.management")}</strong></div>
<div className="grid grid-cols-2 gap-2">
{snapshot.opencodeStatus.canInstall && <Button variant="primary" icon={<InstallIcon />} disabled={isBusy} onClick={() => run(t("integrations.busy.installing"), "opencode-install")}>{t("integrations.installGlobal")}</Button>}
{snapshot.opencodeStatus.canRemove && <Button variant="danger" icon={<RemoveIcon />} disabled={isBusy} onClick={() => run(t("integrations.busy.removing"), "opencode-remove")}>{t("integrations.removeGlobal")}</Button>}
<Button variant="secondary" icon={<RefreshIcon />} disabled={isBusy} onClick={() => void load()}>{t("integrations.refreshStatus")}</Button>
</div>
</section>
<details className="plugin-section group">
<summary className="cursor-pointer list-none flex items-center justify-between">
<div className="plugin-section-title"><small>{t("integrations.advanced")}</small><strong>{t("integrations.configPreview")}</strong></div>
<span className="text-brand group-open:rotate-180 transition-transform"><NextIcon /></span>
</summary>
<pre className="mt-3 p-3 rounded-xl bg-navy/5 text-[10px] font-mono overflow-x-auto border border-navy/5">
{JSON.stringify(snapshot.opencodePreview.configPreview, null, 2)}
</pre>
</details>
</>
)}
{selectedId === "cursor" && (
<>
<section className="plugin-section">
<div className="plugin-section-title"><small>{t("integrations.connection")}</small><strong>{t("integrations.globalMcp")}</strong></div>
<div className="flex items-center justify-between p-3 rounded-2xl bg-blue-50/50 border border-blue-100/50">
<div className="flex flex-col">
<strong className="text-sm text-navy">{snapshot.cursorStatus.label}</strong>
<small className="text-xs text-slatecopy">{snapshot.cursorStatus.details}</small>
</div>
<StatusPill tone={cursorStatusTone(snapshot.cursorStatus.state)}>{snapshot.cursorStatus.state}</StatusPill>
</div>
</section>
<section className="plugin-section">
<div className="plugin-section-title"><small>{t("integrations.actions")}</small><strong>{t("integrations.management")}</strong></div>
<div className="grid grid-cols-2 gap-2">
{snapshot.cursorStatus.canInstall && <Button variant="primary" icon={<InstallIcon />} disabled={isBusy} onClick={() => run(t("integrations.busy.installing"), "cursor-install")}>{t("integrations.installMcp")}</Button>}
{snapshot.cursorStatus.canReplace && <Button variant="warning" icon={<ReplaceIcon />} disabled={isBusy} onClick={() => run(t("integrations.busy.replacing"), "cursor-replace")}>{t("integrations.replaceMcp")}</Button>}
{snapshot.cursorStatus.canRemove && <Button variant="danger" icon={<RemoveIcon />} disabled={isBusy} onClick={() => run(t("integrations.busy.removing"), "cursor-remove")}>{t("integrations.removeMcp")}</Button>}
<Button variant="secondary" icon={<RefreshIcon />} disabled={isBusy} onClick={() => void load()}>{t("integrations.refreshStatus")}</Button>
</div>
</section>
<details className="plugin-section group">
<summary className="cursor-pointer list-none flex items-center justify-between">
<div className="plugin-section-title"><small>{t("integrations.advanced")}</small><strong>{t("integrations.rulesPreview")}</strong></div>
<span className="text-brand group-open:rotate-180 transition-transform"><NextIcon /></span>
</summary>
<p className="mt-3 text-xs text-slatecopy">{snapshot.cursorPreview.rulesPath}</p>
<pre className="mt-3 p-3 rounded-xl bg-navy/5 text-[10px] font-mono overflow-x-auto border border-navy/5">
{snapshot.cursorPreview.rulesContent}
</pre>
</details>
</>
)}
{selectedId === "pi" && (
<section className="plugin-section">
<div className="plugin-section-title"><small>{t("integrations.pi.manualSetup")}</small><strong>{t("integrations.pi.extension")}</strong></div>
<p className="text-sm text-slatecopy leading-relaxed">
{t("integrations.pi.intro")}
</p>
<div className="mt-3 p-4 rounded-2xl bg-navy/5 border border-navy/5 flex flex-col gap-3">
<div className="flex flex-col gap-1">
<span className="text-[10px] font-bold text-slatecopy uppercase tracking-wider">{t("integrations.pi.globalInstall")}</span>
<code className="bg-white px-2 py-1 rounded border border-blue-100 text-brand text-xs">pi install npm:@familiaros/pi</code>
</div>
<div className="flex flex-col gap-1">
<span className="text-[10px] font-bold text-slatecopy uppercase tracking-wider">{t("integrations.pi.projectInstall")}</span>
<code className="bg-white px-2 py-1 rounded border border-blue-100 text-brand text-xs">pi install -l npm:@familiaros/pi</code>
</div>
<div className="flex flex-col gap-1">
<span className="text-[10px] font-bold text-slatecopy uppercase tracking-wider">{t("integrations.pi.remove")}</span>
<code className="bg-white px-2 py-1 rounded border border-blue-100 text-brand text-xs">pi remove npm:@familiaros/pi</code>
</div>
</div>
<div className="mt-3 p-4 rounded-2xl bg-blue-50/50 border border-blue-100/60 flex flex-col gap-2">
<span className="text-[10px] font-bold text-slatecopy uppercase tracking-wider">{t("integrations.pi.slashCommands")}</span>
<code className="bg-white px-2 py-1 rounded border border-blue-100 text-brand text-xs">/familiaros status</code>
<code className="bg-white px-2 py-1 rounded border border-blue-100 text-brand text-xs">/familiaros test</code>
<code className="bg-white px-2 py-1 rounded border border-blue-100 text-brand text-xs">/familiaros react &lt;reaction&gt;</code>
<code className="bg-white px-2 py-1 rounded border border-blue-100 text-brand text-xs">/familiaros say &lt;message&gt;</code>
</div>
<p className="text-xs text-slatecopy mt-2">
{t("integrations.pi.outro")}
</p>
</section>
)}
{selectedId === "mcp-toolkit" && (
<>
<section className="plugin-section">
<div className="plugin-section-title"><small>Install Choice</small><strong>Toolkit behavior</strong></div>
<p className="text-sm text-slatecopy leading-relaxed">
Choose how you want to set up MCP tools for external host agents like Claude Code or Codex CLI.
</p>
<div className="plugin-chip-list">
<button
type="button"
className={`plugin-chip ${toolkitInstallMode === "manual" ? "active" : ""}`}
onClick={() => setToolkitInstallMode("manual")}
>
Manual Setup
</button>
<button
type="button"
className={`plugin-chip ${toolkitInstallMode === "persistent" ? "active" : ""}`}
onClick={() => setToolkitInstallMode("persistent")}
>
Persistent Full Access
</button>
</div>
<small className="text-xs text-slatecopy leading-relaxed">
<strong>Manual Setup</strong> shows copy-paste commands for each tool so you can install them yourself into your host agent. <strong>Persistent Full Access</strong> lets FamiliarOS register the supported baseline automatically into Claude Code or Codex CLI.
</small>
</section>
<section className="plugin-section">
<div className="plugin-section-title"><small>Catalog</small><strong>Browse & Details</strong></div>
<p className="text-sm text-slatecopy leading-relaxed">
Click any tool below to view its details, installation snippets, and safety notes.
</p>
<div className="flex flex-col gap-4 mt-3">
{(["starter", "system", "advanced"] as const).map((tier) => {
const entries = mcpToolkitEntries.filter((entry) => entry.tier === tier);
return (
<div key={tier} className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<strong className="text-sm text-navy">{mcpToolkitTierLabels[tier]}</strong>
<small className="text-xs text-slatecopy">{entries.length} options</small>
</div>
<div className="plugin-chip-list">
{entries.map((entry) => (
<button
key={entry.id}
type="button"
className={`plugin-chip ${selectedToolkit.id === entry.id ? "active" : ""}`}
onClick={() => setSelectedToolkitId(entry.id)}
>
{entry.name}
</button>
))}
</div>
</div>
);
})}
</div>
</section>
{toolkitInstallMode === "manual" ? (
<>
<section className="plugin-section">
<div className="flex items-start justify-between gap-3">
<div className="plugin-section-title">
<small>{mcpToolkitTierLabels[selectedToolkit.tier]}</small>
<strong>{selectedToolkit.name}</strong>
</div>
<div className="flex items-center gap-2">
<StatusPill tone={mcpToolkitTrustTone(selectedToolkit.trust)}>{selectedToolkit.badge}</StatusPill>
<Button variant="secondary" size="compact" icon={<ExternalLinkIcon />} onClick={() => void openDocs(selectedToolkit.docsUrl)}>
{selectedToolkit.docsLabel ?? "Open docs"}
</Button>
</div>
</div>
<p className="text-sm text-slatecopy leading-relaxed">{selectedToolkit.summary}</p>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="rounded-2xl border border-blue-100/70 bg-blue-50/35 p-4">
<strong className="block text-sm text-navy mb-1">Why it matters</strong>
<p className="m-0 text-sm text-slatecopy leading-relaxed">{selectedToolkit.whyItMatters}</p>
</div>
<div className="rounded-2xl border border-blue-100/70 bg-blue-50/35 p-4">
<strong className="block text-sm text-navy mb-1">VectorShell fit</strong>
<p className="m-0 text-sm text-slatecopy leading-relaxed">{selectedToolkit.vectorShellFit}</p>
</div>
</div>
<div className="rounded-2xl border border-amber-100/70 bg-amber-50/55 p-4">
<strong className="block text-sm text-amber-900 mb-1">Permission boundary</strong>
<p className="m-0 text-sm text-amber-900/80 leading-relaxed">{selectedToolkit.safety}</p>
</div>
<div className="flex flex-wrap gap-2">
{selectedToolkit.tags.map((tag) => (
<span key={tag} className="pill pill-slate">{tag}</span>
))}
</div>
</section>
{selectedToolkit.snippets?.length ? (
<section className="plugin-section">
<div className="plugin-section-title"><small>Install Surface</small><strong>Ready-to-paste snippets</strong></div>
<div className="flex flex-col gap-3">
{selectedToolkit.snippets.map((snippet) => (
<div key={snippet.id} className="rounded-2xl border border-blue-100/70 bg-navy/5 p-4">
<div className="mb-2 flex items-start justify-between gap-3">
<div className="min-w-0">
<strong className="block text-sm text-navy">{snippet.label}</strong>
{snippet.description && <small className="mt-1 block text-xs text-slatecopy leading-relaxed">{snippet.description}</small>}
</div>
<Button variant="secondary" size="compact" icon={<CopyIcon />} onClick={() => void copyText(snippet.label, snippet.value)}>
Copy
</Button>
</div>
<pre className="m-0 overflow-x-auto rounded-xl border border-navy/5 bg-white/80 p-3 text-[10px] leading-relaxed text-navy">{snippet.value}</pre>
</div>
))}
</div>
</section>
) : null}
{selectedToolkit.notes?.length ? (
<section className="plugin-section">
<div className="plugin-section-title"><small>Notes</small><strong>Selection guidance</strong></div>
<ul className="m-0 pl-5 text-sm text-slatecopy">
{selectedToolkit.notes.map((note) => <li key={note}>{note}</li>)}
</ul>
</section>
) : null}
</>
) : (
<>
<section className="plugin-section">
<div className="plugin-section-title"><small>Persistent Install</small><strong>Choose your host</strong></div>
<p className="text-sm text-slatecopy leading-relaxed">
This mode is for the install it for good path. FamiliarOS can install the supported persistent baseline for you now, and it also shows the exact matching bundle if you prefer to run it yourself.
</p>
<div className="plugin-chip-list">
<button
type="button"
className={`plugin-chip ${toolkitPersistentTarget === "claude-user" ? "active" : ""}`}
onClick={() => setToolkitPersistentTarget("claude-user")}
>
Claude User Scope
</button>
<button
type="button"
className={`plugin-chip ${toolkitPersistentTarget === "codex-global" ? "active" : ""}`}
onClick={() => setToolkitPersistentTarget("codex-global")}
>
Codex Global
</button>
</div>
</section>
<section className="plugin-section">
<div className="flex items-start justify-between gap-3">
<div className="plugin-section-title">
<small>Persistent Full Access</small>
<strong>{persistentBundle.label}</strong>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="primary" size="compact" icon={<InstallIcon />} disabled={isBusy} onClick={() => void installToolkitHost()}>
Install Now
</Button>
<Button variant="secondary" size="compact" icon={<CopyIcon />} onClick={() => void copyText(persistentBundle.label, persistentBundle.value)}>
Copy Bundle
</Button>
</div>
</div>
<p className="text-sm text-slatecopy leading-relaxed">{persistentBundle.description}</p>
<pre className="m-0 overflow-x-auto rounded-xl border border-navy/5 bg-white/80 p-3 text-[10px] leading-relaxed text-navy">{persistentBundle.value}</pre>
</section>
<section className="plugin-section">
<div className="plugin-section-title"><small>Included Now</small><strong>Stable persistent baseline</strong></div>
<div className="flex flex-wrap gap-2">
{["filesystem", "playwright", "browser-use", "memory", "fetch-web", "sequential-thinking"].map((id) => {
const entry = getMcpToolkitEntry(id);
return entry ? <span key={entry.id} className="pill pill-green">{entry.name}</span> : null;
})}
<span className="pill pill-green">Context7 / Docs</span>
</div>
<small className="text-xs text-slatecopy leading-relaxed">
These are the lanes FamiliarOS can attempt automatically today. Browser Use and Fetch / Web still depend on `uvx`, and Browser Use still needs its own runtime credential in the host environment.
</small>
</section>
{toolkitLastInstall && toolkitLastInstall.target === toolkitPersistentTarget && (
<section className="plugin-section">
<div className="plugin-section-title"><small>Last Install</small><strong>{toolkitLastInstall.label}</strong></div>
{toolkitLastInstall.installed.length > 0 && (
<div className="flex flex-col gap-3">
{toolkitLastInstall.installed.map((entry) => (
<div key={entry.id} className="rounded-2xl border border-green-200/80 bg-green-50/70 p-4">
<div className="flex items-center justify-between gap-3">
<strong className="text-sm text-green-900">{entry.name}</strong>
<StatusPill tone="green">Installed</StatusPill>
</div>
<small className="mt-2 block text-xs text-green-900/80 leading-relaxed">{entry.detail}</small>
</div>
))}
</div>
)}
{toolkitLastInstall.skipped.length > 0 && (
<div className="mt-3 flex flex-col gap-3">
{toolkitLastInstall.skipped.map((entry) => (
<div key={entry.id} className="rounded-2xl border border-amber-200/80 bg-amber-50/75 p-4">
<div className="flex items-center justify-between gap-3">
<strong className="text-sm text-amber-900">{entry.name}</strong>
<StatusPill tone="orange">Skipped</StatusPill>
</div>
<small className="mt-2 block text-xs text-amber-900/80 leading-relaxed">{entry.detail}</small>
</div>
))}
</div>
)}
{toolkitLastInstall.notes.length > 0 && (
<ul className="mt-3 m-0 pl-5 text-sm text-slatecopy">
{toolkitLastInstall.notes.map((note) => <li key={note}>{note}</li>)}
</ul>
)}
</section>
)}
<section className="plugin-section">
<div className="plugin-section-title"><small>Manual Follow-Up</small><strong>Still better done explicitly</strong></div>
<p className="text-sm text-slatecopy leading-relaxed">
These lanes stay outside the one-shot persistent bundle for now because they are more dependent on auth, provider choice, host environment, or high-risk permission boundaries.
</p>
<div className="flex flex-wrap gap-2">
{persistentFollowUps.map((name) => (
<span key={name} className="pill pill-slate">{name}</span>
))}
</div>
</section>
</>
)}
</>
)}
</div>
</GlassCard>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,607 @@
import { useEffect, useMemo, useState } from "react";
import { useI18n } from "../i18n";
import * as Shared from "./shared";
import type {
PluginCommandForm,
PluginCommandFormField,
PluginConfig,
PluginConfigField,
PluginConfigSchema,
PluginCatalogSnapshot,
PluginEntry,
PluginFilter,
PluginIconName,
PluginPermission,
PluginServiceResult,
PluginServiceSnapshot,
PluginStatus,
SafeCatalogPluginRecord,
SafePluginRecord,
} from "./shared";
const {
api,
Button,
CloseIcon,
ConfigureIcon,
FolderPlusIcon,
GlassCard,
HeartIcon,
InstallIcon,
RefreshIcon,
RemoveIcon,
SaveIcon,
StarIcon,
StatusPill,
statusPillToneClass,
} = Shared;
export const pluginFilterLabelKeys: Record<PluginFilter, string> = {
all: "plugins.filter.all",
installed: "plugins.filter.installed",
catalog: "plugins.filter.catalog",
local: "plugins.filter.local",
broken: "plugins.filter.broken",
};
export const pluginPermissionLabelKeys: Record<PluginPermission, string> = {
"familiar:speak": "plugins.permission.familiar:speak",
"familiar:reaction": "plugins.permission.familiar:reaction",
"familiar:move": "plugins.permission.familiar:move",
timer: "plugins.permission.timer",
schedule: "plugins.permission.schedule",
storage: "plugins.permission.storage",
status: "plugins.permission.status",
commands: "plugins.permission.commands",
network: "plugins.permission.network",
"familiar:interact": "plugins.permission.familiar:interact",
"familiar:pin": "plugins.permission.familiar:pin",
"familiar:animate": "plugins.permission.familiar:animate",
"familiar:speak:dynamic": "plugins.permission.familiar:speak:dynamic",
"familiar:drop": "plugins.permission.familiar:drop",
"familiars:read": "plugins.permission.familiars:read",
"familiars:manage": "plugins.permission.familiars:manage",
audio: "plugins.permission.audio",
events: "plugins.permission.events",
"ui:toast": "plugins.permission.ui:toast",
"ui:panel": "plugins.permission.ui:panel",
notify: "plugins.permission.notify",
bus: "plugins.permission.bus",
ai: "plugins.permission.ai",
secrets: "plugins.permission.secrets",
"voice:speak": "plugins.permission.voice:speak",
"voice:listen": "plugins.permission.voice:listen",
auth: "plugins.permission.auth",
files: "plugins.permission.files",
"system:openExternal": "plugins.permission.system:openExternal",
"system:metrics": "plugins.permission.system:metrics",
clipboard: "plugins.permission.clipboard",
"network:write": "plugins.permission.network:write",
};
export const sensitivePermissionSet = new Set<PluginPermission>(["voice:listen", "clipboard", "familiar:speak:dynamic"]);
export const pluginStatusTone: Record<NonNullable<PluginStatus["tone"]>, keyof typeof statusPillToneClass> = {
info: "blue",
success: "green",
warning: "orange",
error: "red",
};
export function PluginGlyph({ className = "plugin-glyph" }: { className?: string }) {
return <svg className={className} width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2 3 6.5l9 4.5 9-4.5z" />
<path d="m3 12 9 4.5 9-4.5" />
<path d="m3 17.5 9 4.5 9-4.5" />
</svg>;
}
export function PluginIcon({ icon = "plugin", className = "plugin-glyph" }: { icon?: PluginIconName; className?: string }) {
if (icon === "bell") return <svg className={className} width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M10 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3H4a4 4 0 0 0 2-3v-3a7 7 0 0 1 4-6M9 17v1a3 3 0 0 0 6 0v-1" />
</svg>;
if (icon === "timer") return <svg className={className} width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0-18 0" />
<path d="M12 7v5l3 3" />
</svg>;
if (icon === "github") return <svg className={className} width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M9 19c-4.3 1.4-4.3-2.5-6-3" />
<path d="M15 21v-3.5c0-1 .1-1.4-.5-2c2.8-.3 5.5-1.4 5.5-6a4.6 4.6 0 0 0-1.3-3.2a4.2 4.2 0 0 0-.1-3.2s-1.1-.3-3.5 1.3a12.3 12.3 0 0 0-6.2 0C6.5 2.8 5.4 3.1 5.4 3.1a4.2 4.2 0 0 0-.1 3.2A4.6 4.6 0 0 0 4 9.5c0 4.6 2.7 5.7 5.5 6c-.6.6-.6 1.2-.5 2V21" />
</svg>;
if (icon === "heart") return <HeartIcon />;
if (icon === "sparkles") return <StarIcon />;
if (icon === "coffee") return <svg className={className} width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M10 2v2" /><path d="M14 2v2" /><path d="M16 8h1a4 4 0 0 1 0 8h-1" /><path d="M6 8h10v7a5 5 0 0 1-5 5h0a5 5 0 0 1-5-5Z" /><path d="M4 22h14" />
</svg>;
if (icon === "focus") return <svg className={className} width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="4" /><path d="M12 3v3M12 18v3M3 12h3M18 12h3" />
</svg>;
if (icon === "droplet") return <svg className={className} width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 2.5S5 10 5 15a7 7 0 0 0 14 0c0-5-7-12.5-7-12.5Z" />
<path d="M8.5 15.5a3.5 3.5 0 0 0 5.5 2.9" />
</svg>;
return <PluginGlyph className={className} />;
}
export function isPluginIconDataUrl(value: string | undefined): value is string {
return typeof value === "string" && /^data:image\/svg\+xml;base64,[a-z0-9+/=]+$/iu.test(value);
}
export function PluginIconImage({ entry, className = "plugin-glyph" }: { entry: PluginEntry; className?: string }) {
const iconDataUrl = isPluginIconDataUrl(entry.installed?.iconDataUrl) ? entry.installed.iconDataUrl : isPluginIconDataUrl(entry.catalog?.iconDataUrl) ? entry.catalog.iconDataUrl : undefined;
if (iconDataUrl) return <img className={`${className} plugin-icon-img`} src={iconDataUrl} alt="" aria-hidden="true" draggable="false" />;
return <PluginIcon icon={pluginIcon(entry)} className={className} />;
}
export function pluginIcon(entry: PluginEntry): PluginIconName {
return entry.installed?.icon || entry.catalog?.icon || "plugin";
}
export function pluginName(entry: PluginEntry): string {
return entry.installed?.name || entry.catalog?.name || entry.id;
}
export function pluginDescription(entry: PluginEntry, t: (key: string, vars?: Record<string, string | number>) => string): string {
if (entry.installed?.brokenReason) return entry.installed.brokenReason;
return entry.installed?.description || entry.catalog?.description || (entry.installed ? t("plugins.description.installedReady") : t("plugins.description.availableCatalog"));
}
export function pluginPrimaryTone(entry: PluginEntry): keyof typeof statusPillToneClass {
if (entry.installed?.brokenReason) return "red";
if (entry.installed?.catalogDisabled) return "orange";
if (entry.installed?.enabled) return "green";
if (entry.installed) return "slate";
return "blue";
}
export function pluginPrimaryLabel(entry: PluginEntry, t: (key: string, vars?: Record<string, string | number>) => string): string {
if (entry.installed?.brokenReason) return t("plugins.status.broken");
if (entry.installed?.catalogDisabled) return t("plugins.status.catalogDisabled");
if (entry.installed?.enabled) return t("plugins.status.active");
if (entry.installed) return t("plugins.status.disabled");
return t("plugins.status.available");
}
export function mergePluginEntries(snapshot: PluginServiceSnapshot | null, catalog: PluginCatalogSnapshot | null): PluginEntry[] {
const merged = new Map<string, PluginEntry>();
for (const installed of snapshot?.plugins ?? []) merged.set(installed.id, { id: installed.id, installed });
for (const catalogPlugin of catalog?.plugins ?? []) {
const current = merged.get(catalogPlugin.id) ?? { id: catalogPlugin.id };
merged.set(catalogPlugin.id, { ...current, catalog: catalogPlugin });
}
return [...merged.values()].sort((a, b) => {
const installedDelta = Number(Boolean(b.installed)) - Number(Boolean(a.installed));
if (installedDelta) return installedDelta;
return pluginName(a).localeCompare(pluginName(b));
});
}
export function initialConfigValue(field: PluginConfigField): unknown {
if (field.default !== undefined) return field.default;
if (field.type === "boolean") return false;
if (field.type === "number") return field.min ?? 0;
if (field.type === "multiSelect" || field.type === "list") return [];
return "";
}
export function commandFieldToConfigField(field: PluginCommandFormField): PluginConfigField {
return { type: field.type, label: field.label, default: field.default, options: field.options, min: field.min, max: field.max, maxLength: field.maxLength };
}
export function materializeCommandDraft(form: PluginCommandForm | undefined, values: Record<string, unknown> | undefined): Record<string, unknown> {
const next: Record<string, unknown> = {};
for (const field of form?.fields ?? []) next[field.id] = values?.[field.id] ?? initialConfigValue(commandFieldToConfigField(field));
return next;
}
export function materializeListItemDefaults(schema: PluginConfigSchema, value: Record<string, unknown> = {}): Record<string, unknown> {
const next: Record<string, unknown> = {};
for (const [key, field] of Object.entries(schema)) next[key] = materializeConfigValue(field, value[key]);
return next;
}
export function materializeConfigValue(field: PluginConfigField, value: unknown): unknown {
if (field.type === "list" && field.itemSchema) {
const items = Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => item !== null && typeof item === "object" && !Array.isArray(item)) : [];
return items.map((item) => materializeListItemDefaults(field.itemSchema ?? {}, item));
}
return value ?? initialConfigValue(field);
}
export function materializeConfigDraft(schema: PluginConfigSchema | undefined, config: PluginConfig | undefined): PluginConfig {
const next: PluginConfig = {};
for (const [key, field] of Object.entries(schema ?? {})) next[key] = materializeConfigValue(field, config?.[key]);
return next;
}
export function ConfigFieldEditor({ pluginId, fieldKey, field, value, onChange, onPickSound }: { pluginId?: string; fieldKey: string; field: PluginConfigField; value: unknown; onChange: (value: unknown) => void; onPickSound?: (pluginId: string) => Promise<void> }) {
const { t } = useI18n();
const label = field.label || fieldKey;
const description = field.description;
const textValue = typeof value === "string" ? value : typeof field.default === "string" ? field.default : "";
if (field.type === "boolean") {
return <label className="plugin-config-row plugin-config-row-boolean">
<span><strong>{label}</strong>{description && <small>{description}</small>}</span>
<input className="settings-toggle" type="checkbox" checked={Boolean(value)} onChange={(event) => onChange(event.target.checked)} />
</label>;
}
if (field.type === "list" && field.itemSchema) {
const items = Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => item !== null && typeof item === "object" && !Array.isArray(item)) : [];
const maxed = typeof field.maxItems === "number" && items.length >= field.maxItems;
const isReminders = (pluginId === "familiaros.break-buddy" || fieldKey === "reminders") && ["reminders", "breaks"].includes(fieldKey);
const addLabel = isReminders ? t("plugins.config.addReminder") : t("plugins.config.addItem");
return <div className="plugin-config-row">
<span><strong>{label}</strong>{description && <small>{description}</small>}</span>
<div className="plugin-list-editor">
{items.map((item, index) => {
let itemTitle = t("plugins.config.item", { index: index + 1 });
let removeLabel = t("plugins.config.remove");
if (isReminders) {
removeLabel = t("plugins.config.removeReminder");
const id = String(item.id || "").trim();
const scheduleType = item.scheduleType;
if (scheduleType === "daily") {
const time = String(item.time || "09:00");
itemTitle = t("plugins.config.dailyAt", { id: id || t("plugins.config.reminder"), time });
} else if (scheduleType === "interval") {
const mins = Number(item.intervalMinutes) || 60;
itemTitle = t("plugins.config.everyMin", { id: id || t("plugins.config.reminder"), mins });
} else if (id) {
itemTitle = id;
}
}
const schemaEntries = Object.entries(field.itemSchema ?? {});
const messageField = schemaEntries.find(([k]) => k === "message");
const scheduleFields = schemaEntries.filter(([k]) => ["scheduleType", "time", "days", "intervalMinutes"].includes(k));
const behaviorFields = schemaEntries.filter(([k]) => ["id", "enabled", "reaction"].includes(k));
const otherFields = schemaEntries.filter(([k]) => !["message", "scheduleType", "time", "days", "intervalMinutes", "id", "enabled", "reaction"].includes(k));
const renderField = ([childKey, childField]: [string, PluginConfigField]) => {
if (isReminders) {
const scheduleType = item.scheduleType;
if (scheduleType === "daily" && childKey === "intervalMinutes") return null;
if (scheduleType === "interval" && (childKey === "time" || childKey === "days")) return null;
}
return <ConfigFieldEditor key={childKey} pluginId={pluginId} fieldKey={childKey} field={childField} value={item[childKey] ?? initialConfigValue(childField)} onChange={(nextValue) => onChange(items.map((existing, itemIndex) => itemIndex === index ? { ...existing, [childKey]: nextValue } : existing))} />;
};
return (
<div className="plugin-list-item" key={index}>
<div className="plugin-list-item-header">
<span className="truncate mr-2">{itemTitle}</span>
<Button variant="danger" size="compact" onClick={() => onChange(items.filter((_, itemIndex) => itemIndex !== index))}>{removeLabel}</Button>
</div>
<div className="flex flex-col gap-3">
{isReminders ? (
<>
{behaviorFields.length > 0 && (
<div className="plugin-config-group">
<div className="plugin-config-group-title">{t("plugins.config.group.identity")}</div>
{behaviorFields.map(renderField)}
</div>
)}
{messageField && (
<div className="plugin-config-group">
<div className="plugin-config-group-title">{t("plugins.config.group.message")}</div>
{renderField(messageField)}
</div>
)}
{scheduleFields.length > 0 && (
<div className="plugin-config-group">
<div className="plugin-config-group-title">{t("plugins.config.group.schedule")}</div>
{scheduleFields.map(renderField)}
</div>
)}
{otherFields.map(renderField)}
</>
) : (
schemaEntries.map(renderField)
)}
</div>
</div>
);
})}
<Button variant="secondary" size="compact" disabled={maxed} onClick={() => onChange([...items, materializeListItemDefaults(field.itemSchema ?? {})])}>{addLabel}</Button>
</div>
</div>;
}
if (field.type === "sound") {
const soundLabel = typeof value === "string" ? (value || t("plugins.config.defaultSound")) : value && typeof value === "object" && "name" in value && typeof value.name === "string" ? value.name : t("plugins.config.defaultSound");
return <label className="plugin-config-row">
<span><strong>{label}</strong>{description && <small>{description}</small>}</span>
<span className="flex items-center gap-2">
<span className="plugin-input flex-1 truncate" aria-live="polite">{soundLabel}</span>
<Button variant="secondary" size="compact" disabled={!pluginId || !onPickSound} onClick={() => { if (pluginId) void onPickSound?.(pluginId); }}>{t("plugins.config.browseSound")}</Button>
<Button variant="secondary" size="compact" onClick={() => onChange("alert")}>{t("plugins.config.useDefaultSound")}</Button>
<Button variant="secondary" size="compact" onClick={() => onChange("")}>{t("plugins.config.clearSound")}</Button>
</span>
</label>;
}
return <label className="plugin-config-row">
<span><strong>{label}</strong>{description && <small>{description}</small>}</span>
{field.type === "textarea" ? (
<textarea className="plugin-input plugin-textarea" value={textValue} maxLength={field.maxLength} onChange={(event) => onChange(event.target.value)} />
) : field.type === "select" ? (
<select className="settings-select plugin-select" value={textValue} onChange={(event) => onChange(event.target.value)}>
{(field.options ?? []).map((option) => <option key={option.value} value={option.value}>{option.label || option.value}</option>)}
</select>
) : field.type === "multiSelect" ? (
<span className="plugin-chip-list">
{(field.options ?? []).map((option) => {
const selected = Array.isArray(value) && value.includes(option.value);
return <button type="button" key={option.value} className={`plugin-chip ${selected ? "active" : ""}`} onClick={() => {
const current = Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
onChange(selected ? current.filter((item) => item !== option.value) : [...current, option.value]);
}}>{option.label || option.value}</button>;
})}
</span>
) : (
<input className="plugin-input" type={field.type === "number" ? "number" : field.type === "time" ? "time" : field.type === "date" ? "date" : field.type === "secret" ? "password" : "text"} autoComplete={field.type === "secret" ? "off" : undefined} value={field.type === "number" && typeof value === "number" ? String(value) : textValue} min={field.min} max={field.max} step={field.step} maxLength={field.maxLength} onChange={(event) => onChange(field.type === "number" ? Number(event.target.value) : event.target.value)} />
)}
</label>;
}
export function PluginsView() {
const { t } = useI18n();
const [snapshot, setSnapshot] = useState<PluginServiceSnapshot | null>(null);
const [catalog, setCatalog] = useState<PluginCatalogSnapshot | null>(null);
const [selectedId, setSelectedId] = useState("");
const [filter, setFilter] = useState<PluginFilter>("all");
const [busy, setBusy] = useState("");
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [configDraft, setConfigDraft] = useState<PluginConfig>({});
const [commandDrafts, setCommandDrafts] = useState<Record<string, Record<string, unknown>>>({});
const [activeCommandId, setActiveCommandId] = useState("");
async function load(refreshCatalog = false, clearMessages = true) {
if (clearMessages) setError("");
const [nextSnapshot, nextCatalog] = await Promise.all([
api.getPluginsSnapshot(),
api.getPluginCatalogSnapshot(refreshCatalog).catch(() => ({ plugins: [] } as PluginCatalogSnapshot)),
]);
setSnapshot(nextSnapshot);
setCatalog(nextCatalog);
const entries = mergePluginEntries(nextSnapshot, nextCatalog);
setSelectedId((current) => entries.some((entry) => entry.id === current) ? current : "");
}
useEffect(() => { void load().catch((err) => setError(String(err?.message ?? err))); }, []);
// Re-fetch plugin records when the host locale changes so `$t:` labels re-render translated.
useEffect(() => api.onPluginsRefresh(() => { void load(false, false).catch((err) => setError(String(err?.message ?? err))); }), []);
useEffect(() => {
if (!message) return;
const timeout = window.setTimeout(() => setMessage(""), 2200);
return () => window.clearTimeout(timeout);
}, [message]);
const entries = useMemo(() => mergePluginEntries(snapshot, catalog), [snapshot, catalog]);
const selected = entries.find((entry) => entry.id === selectedId);
const installed = selected?.installed;
const catalogPlugin = selected?.catalog;
const hasConfigFields = Boolean(installed?.configSchema && Object.keys(installed.configSchema).length > 0);
const activeCommand = installed?.commands?.find((command) => command.id === activeCommandId);
useEffect(() => { setConfigDraft(materializeConfigDraft(installed?.configSchema, installed?.effectiveConfig)); }, [installed?.id, installed?.configSchema, installed?.effectiveConfig]);
useEffect(() => { setCommandDrafts({}); setActiveCommandId(""); }, [installed?.id]);
const filteredEntries = useMemo(() => {
return entries.filter((entry) => {
if (filter === "installed" && !entry.installed) return false;
if (filter === "catalog" && entry.installed) return false;
if (filter === "local" && entry.installed?.source !== "local") return false;
if (filter === "broken" && !entry.installed?.brokenReason) return false;
return true;
});
}, [entries, filter]);
async function run(label: string, fn: () => Promise<void>) {
try { setBusy(label); setError(""); setMessage(""); await fn(); }
catch (err) { setError(String((err as Error)?.message ?? err)); }
finally { setBusy(""); }
}
function applyResult(result: PluginServiceResult, success?: string) {
setSnapshot(result.snapshot);
if (!result.ok) { setError(result.error); return false; }
if (success) setMessage(success);
return true;
}
function updateDraft(key: string, value: unknown) {
setConfigDraft((current) => ({ ...current, [key]: value }));
}
function updateCommandDraft(commandId: string, key: string, value: unknown) {
setCommandDrafts((current) => ({ ...current, [commandId]: { ...(current[commandId] ?? {}), [key]: value } }));
}
async function pickConfigSound(pluginId: string, key: string) {
try {
setError("");
const result = await api.pickPluginConfigSound(pluginId);
if (!result.ok) { setSnapshot(result.snapshot); setError(result.error); return; }
if (!result.sound.id) { setSnapshot(result.snapshot); return; }
updateDraft(key, result.sound);
setMessage(t("plugins.toast.soundImported"));
} catch (err) { setError(String((err as Error)?.message ?? err)); }
}
async function installCatalogEntry(entry: PluginEntry) {
const result = await api.installCatalogPlugin(entry.id);
if (!applyResult(result)) return;
const installedPlugin = result.snapshot.plugins.find((plugin) => plugin.id === entry.id);
if (!installedPlugin) { setMessage(t("plugins.toast.noPluginInstalled")); return; }
await load(false, false);
setMessage(t("plugins.toast.pluginInstalled"));
}
async function updateCatalogEntry(plugin: SafePluginRecord) {
const previousVersion = plugin.version;
const result = await api.updateCatalogPlugin(plugin.id);
if (!applyResult(result)) return;
const updatedPlugin = result.snapshot.plugins.find((nextPlugin) => nextPlugin.id === plugin.id);
setMessage(updatedPlugin && updatedPlugin.version !== previousVersion ? t("plugins.toast.pluginUpdated") : t("plugins.toast.noPluginUpdate"));
}
return (
<div className="plugins-layout">
{error && <div className="error settings-message">{error}</div>}
{message && <div className="settings-success settings-message">{message}</div>}
<GlassCard className="plugins-hub">
<div className="filters">
{(["all", "installed", "catalog", "local", "broken"] as PluginFilter[]).map((nextFilter) => (
<button key={nextFilter} className={`filter ${filter === nextFilter ? "active" : ""}`} onClick={() => setFilter(nextFilter)}>{t(pluginFilterLabelKeys[nextFilter])}</button>
))}
</div>
<div className="plugin-grid">
{filteredEntries.map((entry) => (
<article key={entry.id} className={`plugin-card ${entry.installed?.brokenReason ? "broken" : ""}`}>
<div className="plugin-card-body">
<span className="plugin-card-icon"><PluginIconImage entry={entry} /></span>
<div className="plugin-card-content">
<strong>{pluginName(entry)}</strong>
<small>{pluginDescription(entry, t)}</small>
<div className="badges mt-1">
<StatusPill tone={pluginPrimaryTone(entry)}>{pluginPrimaryLabel(entry, t)}</StatusPill>
{entry.installed?.bundled && <StatusPill tone="blue">{t("plugins.badge.bundled")}</StatusPill>}
{entry.installed?.source === "local" && <StatusPill tone="orange">{t("plugins.badge.local")}</StatusPill>}
{entry.installed?.runtime === "javascript" || entry.catalog?.runtime === "javascript" ? <StatusPill tone="purple">{t("plugins.badge.js")}</StatusPill> : <StatusPill tone="slate">{t("plugins.badge.declarative")}</StatusPill>}
</div>
</div>
</div>
<div className="plugin-card-footer">
<div className="plugin-card-meta">
<span className="text-[10px] font-bold text-slatecopy/50 uppercase tracking-tight">v{entry.installed?.version || entry.catalog?.version}</span>
</div>
<div className="plugin-card-actions">
{entry.installed && (
<div className="plugin-card-toggle-zone">
<span className="plugin-card-toggle-label">{entry.installed.enabled ? t("plugins.card.active") : t("plugins.card.off")}</span>
<input
className="settings-toggle plugin-card-toggle"
type="checkbox"
checked={entry.installed.enabled}
disabled={!!busy || entry.installed.catalogDisabled || Boolean(entry.installed.brokenReason)}
onChange={(event) => void run(t("plugins.busy.saving"), async () => {
applyResult(await api.setPluginEnabled(entry.id, event.target.checked), event.target.checked ? t("plugins.toast.pluginEnabled") : t("plugins.toast.pluginDisabled"));
})}
/>
</div>
)}
{entry.installed ? (
<Button variant="secondary" size="compact" icon={<ConfigureIcon />} disabled={!!busy} onClick={() => setSelectedId(entry.id)}>{t("plugins.card.configure")}</Button>
) : (
<Button variant="primary" size="compact" icon={<InstallIcon />} disabled={!!busy || entry.catalog?.deprecated} onClick={() => void run(t("plugins.busy.installing"), async () => { await installCatalogEntry(entry); })}>{t("plugins.card.installPlugin")}</Button>
)}
</div>
</div>
</article>
))}
{!filteredEntries.length && <div className="plugin-empty"><PluginGlyph /><strong>{t("plugins.empty.title")}</strong><small>{t("plugins.empty.description")}</small></div>}
</div>
<div className="plugin-hub-footer">
<span><strong>{snapshot?.plugins.length ?? 0}</strong> {t("plugins.footer.installed")} · <strong>{catalog?.plugins.length ?? 0}</strong> {t("plugins.footer.catalog")}</span>
<span className="plugin-hub-actions">
<Button variant="secondary" size="compact" disabled={!!busy} icon={<RefreshIcon />} onClick={() => void run(t("plugins.busy.refreshing"), async () => { await load(true); setMessage(t("plugins.toast.catalogRefreshed")); })}>{t("plugins.footer.refresh")}</Button>
<Button variant="secondary" size="compact" icon={<FolderPlusIcon />} disabled={!!busy} onClick={() => void run(t("plugins.busy.loading"), async () => {
const beforeIds = new Set(snapshot?.plugins.map((plugin) => plugin.id) ?? []);
const result = await api.loadLocalPlugin();
if (!applyResult(result)) return;
const loadedPlugin = result.snapshot.plugins.find((plugin) => plugin.source === "local" && !beforeIds.has(plugin.id));
setMessage(loadedPlugin ? t("plugins.toast.localLoaded") : t("plugins.toast.noLocalLoaded"));
})}>{t("plugins.footer.loadLocal")}</Button>
</span>
</div>
</GlassCard>
{selected && <div className="plugin-config-overlay" role="dialog" aria-modal="true" aria-label={t("plugins.inspector.configAria", { name: pluginName(selected) })}>
<button className="plugin-config-backdrop" type="button" aria-label={t("plugins.inspector.closeAria")} onClick={() => setSelectedId("")} />
<GlassCard className="plugin-inspector">
{selected ? <>
<div className="plugin-inspector-head">
<span className="plugin-inspector-icon"><PluginIconImage entry={selected} /></span>
<div className="flex-1 min-w-0"><p className="eyebrow">{t("plugins.inspector.details")}</p><h2>{pluginName(selected)}</h2><p className="desc">{pluginDescription(selected, t)}</p></div>
<Button variant="secondary" size="compact" icon={<CloseIcon />} onClick={() => setSelectedId("")}>{t("plugins.inspector.close")}</Button>
</div>
<div className="meta">
<StatusPill tone={pluginPrimaryTone(selected)}>{pluginPrimaryLabel(selected, t)}</StatusPill>
<StatusPill tone="slate">v{installed?.version ?? catalogPlugin?.version}</StatusPill>
{installed?.bundled && <StatusPill tone="blue">{t("plugins.badge.bundled")}</StatusPill>}
{installed?.source === "local" && <StatusPill tone="orange">{t("plugins.badge.local")}</StatusPill>}
{(installed?.catalogDeprecated || catalogPlugin?.deprecated) && <StatusPill tone="orange">{t("plugins.badge.deprecated")}</StatusPill>}
</div>
{(installed?.catalogStatusReason || catalogPlugin?.statusReason || installed?.status?.text) && <div className="plugin-status-strip">
{installed?.status?.text && <StatusPill tone={installed.status.tone ? pluginStatusTone[installed.status.tone] : "blue"}>{installed.status.text}</StatusPill>}
<span>{installed?.catalogStatusReason || catalogPlugin?.statusReason}</span>
</div>}
{installed ? <>
<section className="plugin-section">
<div className="plugin-section-title"><small>{t("plugins.inspector.runtime")}</small><strong>{t("plugins.inspector.statePermissions")}</strong></div>
<label className="settings-row plugin-toggle-row">
<div className="settings-row-info"><strong>{installed.enabled ? t("plugins.inspector.enabled") : t("plugins.inspector.disabled")}</strong><small>{installed.brokenReason || (installed.catalogDisabled ? t("plugins.inspector.catalogDisabledNote") : t("plugins.inspector.toggleNote"))}</small></div>
<input className="settings-toggle" type="checkbox" checked={installed.enabled} disabled={!!busy || installed.catalogDisabled || Boolean(installed.brokenReason)} onChange={(event) => void run(t("plugins.busy.saving"), async () => { applyResult(await api.setPluginEnabled(installed.id, event.target.checked), event.target.checked ? t("plugins.toast.pluginEnabled") : t("plugins.toast.pluginDisabled")); })} />
</label>
<div className="badges plugin-permissions">{installed.approvedPermissions.length ? installed.approvedPermissions.map((permission) => <StatusPill key={permission} tone={sensitivePermissionSet.has(permission) ? "red" : permission === "network" || permission === "network:write" ? "orange" : "blue"}>{t(pluginPermissionLabelKeys[permission])}</StatusPill>) : <StatusPill tone="slate">{t("plugins.inspector.noPermissions")}</StatusPill>}</div>
</section>
{!!installed.configErrors?.length && <section className="plugin-section plugin-section-danger"><div className="plugin-section-title"><small>{t("plugins.inspector.configuration")}</small><strong>{t("plugins.inspector.needsAttention")}</strong></div><ul>{installed.configErrors.map((configError, index) => <li key={index}>{configError.message || String(configError)}</li>)}</ul></section>}
{hasConfigFields && <section className="plugin-section">
<div className="plugin-section-title"><small>{t("plugins.inspector.settings")}</small><strong>{t("plugins.inspector.configuration")}</strong></div>
<div className="plugin-config-form">{Object.entries(installed.configSchema ?? {}).map(([key, field]) => <ConfigFieldEditor key={key} pluginId={installed.id} fieldKey={key} field={field} value={configDraft[key] ?? initialConfigValue(field)} onChange={(value) => updateDraft(key, value)} onPickSound={(pluginId) => pickConfigSound(pluginId, key)} />)}</div>
<Button variant="primary" fullWidth icon={<SaveIcon />} disabled={!!busy} onClick={() => void run(t("plugins.busy.saving"), async () => { applyResult(await api.savePluginConfig(installed.id, configDraft), t("plugins.toast.configSaved")); })}>{t("plugins.inspector.saveConfiguration")}</Button>
</section>}
{!!installed.commands?.length && <section className="plugin-section">
<div className="plugin-section-title"><small>{t("plugins.inspector.commands")}</small><strong>{t("plugins.inspector.quickActions")}</strong></div>
<div className="plugin-command-list">
{installed.commands.map((command) => (
<div key={command.id} className="flex flex-col gap-2">
<Button variant="secondary" size="compact" disabled={!!busy} onClick={() => {
if (command.form) { setActiveCommandId((current) => current === command.id ? "" : command.id); return; }
void run(t("plugins.busy.running"), async () => { applyResult(await api.executePluginCommand(installed.id, command.id), t("plugins.toast.commandRan")); });
}}>
{command.title}
</Button>
{command.description && <small className="text-[10px] text-slatecopy px-1 leading-tight">{command.description}</small>}
</div>
))}
</div>
{activeCommand?.form && (() => {
const formDraft = materializeCommandDraft(activeCommand.form, commandDrafts[activeCommand.id]);
return <div className="plugin-command-form-panel">
<div className="plugin-section-title"><small>{activeCommand.title}</small><strong>{activeCommand.form.submitLabel || activeCommand.title}</strong></div>
<div className="plugin-command-form">
{activeCommand.form.fields.map((field) => <ConfigFieldEditor key={field.id} pluginId={installed.id} fieldKey={field.id} field={commandFieldToConfigField(field)} value={formDraft[field.id]} onChange={(value) => updateCommandDraft(activeCommand.id, field.id, value)} />)}
</div>
<div className="flex gap-2">
<Button variant="primary" size="compact" disabled={!!busy} onClick={() => void run(t("plugins.busy.running"), async () => { if (applyResult(await api.executePluginCommand(installed.id, activeCommand.id, formDraft), t("plugins.toast.commandRan"))) setActiveCommandId(""); })}>{activeCommand.form.submitLabel || activeCommand.title}</Button>
<Button variant="secondary" size="compact" disabled={!!busy} onClick={() => setActiveCommandId("")}>{t("plugins.inspector.close")}</Button>
</div>
</div>;
})()}
</section>}
<section className="plugin-section plugin-actions-section">
<Button variant="secondary" disabled={!!busy} icon={<RefreshIcon />} onClick={() => void run(t("plugins.busy.reloading"), async () => { applyResult(await api.reloadPlugin(installed.id), t("plugins.toast.pluginReloaded")); })}>{t("plugins.inspector.reload")}</Button>
{installed.source === "catalog" && !installed.bundled && catalogPlugin && catalogPlugin.version !== installed.version && <Button variant="primary" icon={<InstallIcon />} disabled={!!busy} onClick={() => void run(t("plugins.busy.updating"), async () => { await updateCatalogEntry(installed); })}>{t("plugins.inspector.update")}</Button>}
{!installed.bundled && <Button variant="danger" icon={<RemoveIcon />} disabled={!!busy} onClick={() => { if (window.confirm(t("plugins.inspector.uninstallConfirm", { name: pluginName(selected) }))) void run(t("plugins.busy.uninstalling"), async () => { if (applyResult(await api.uninstallPlugin(installed.id), t("plugins.toast.pluginUninstalled"))) setSelectedId(""); }); }}>{t("plugins.inspector.uninstall")}</Button>}
</section>
</> : <section className="plugin-section">
<div className="plugin-section-title"><small>{t("plugins.inspector.catalog")}</small><strong>{t("plugins.inspector.readyToInstall")}</strong></div>
<p className="desc">{t("plugins.inspector.catalogDescription")}</p>
<div className="badges plugin-permissions">{catalogPlugin?.permissions.map((permission) => <StatusPill key={permission} tone={sensitivePermissionSet.has(permission) ? "red" : permission === "network" || permission === "network:write" ? "orange" : "blue"}>{t(pluginPermissionLabelKeys[permission])}</StatusPill>)}</div>
<Button variant="primary" fullWidth icon={<InstallIcon />} disabled={!!busy || catalogPlugin?.deprecated} onClick={() => void run(t("plugins.busy.installing"), async () => { await installCatalogEntry(selected); })}>{t("plugins.inspector.installPlugin")}</Button>
</section>}
</> : <div className="plugin-empty plugin-empty-detail"><PluginGlyph /><strong>{t("plugins.emptyDetail.title")}</strong><small>{t("plugins.emptyDetail.description")}</small></div>}
</GlassCard>
</div>}
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,967 @@
import React from "react";
import { useI18n, type I18nSnapshot } from "../i18n";
import { getMcpToolkitEntry } from "../mcp-toolkit-catalog";
import openPetsLogoUrl from "../../../../assets/familiaros.webp";
import defaultThumbUrl from "../../../../assets/default-familiar-thumbnail.png";
export { openPetsLogoUrl, defaultThumbUrl };
export type Filter = "all" | "installed" | "featured" | "originals" | "codex";
export type InstalledPet = { id: string; displayName: string; description?: string; builtIn: boolean; protected: boolean; installed: boolean; broken?: boolean; brokenReason?: string; source?: { kind?: "catalog"; preview?: string } | { kind: "codex"; path: string } };
export type PetEntry = { id: string; displayName: string; description?: string; searchText?: string; preview?: string; thumbnail?: string; spritesheet?: string; category?: "western" | "asian"; original?: boolean; featured?: boolean; catalogPage?: number; sourceKind?: "installed" | "catalog" | "codex"; installed?: boolean; builtIn?: boolean; protected?: boolean; broken?: boolean; brokenReason?: string };
export type SearchPetEntry = Pick<PetEntry, "id" | "displayName" | "category" | "original" | "featured"> & { searchText?: string; catalogPage?: number };
export type StateSnapshot = { preferences: { defaultPetId: string }; familiars: { installed: InstalledPet[] } };
export type CatalogState = { familiars: PetEntry[]; source: string; error?: string; page?: number; pageCount?: number; total?: number; categories?: { id: "western" | "asian"; label: string; count: number }[]; originalsCount?: number; featuredCount?: number };
export type CodexState = { familiars: PetEntry[]; error?: string };
export type PetScaleOption = { label: string; value: number };
export type ThemeMode = "system" | "light" | "dark";
export type UserSelectableAnimationState = "idle" | "review" | "running" | "waiting" | "waving" | "jumping" | "failed";
export type ReactionAnimationOverrides = Record<string, UserSelectableAnimationState>;
export type OpenApiChatSettings = { model: string; endpoint: string; defaultEndpoint: string; usingDefaultEndpoint: boolean; hasCredential: boolean; storageMode: "encrypted" | "plain" };
export type SettingsState = { preferences: { openDefaultPetOnLaunch: boolean; locale?: "system" | string; petScale: number; reactionAnimationOverrides?: ReactionAnimationOverrides; openApiChatModel?: string; openApiChatSystemPrompt?: string; openApiChatEndpoint?: string; openApiChatTheme?: ThemeMode; openApiChatBaseInstructionsEnabled?: boolean; ttsProvider?: TtsProviderId; ttsVoice?: string; ttsSpeed?: number; ttsModel?: string; ttsEndpointPreset?: "openrouter" | "litellm" | "wavespeedai" | "custom"; ttsEndpoint?: string; familiarName?: string }; petScaleOptions: PetScaleOption[]; openApiChat: OpenApiChatSettings };
export type LaunchAtLoginState = { supported: boolean; enabled: boolean };
export type UpdateStatus = { state: "idle" | "checking" | "available" | "current" | "error"; currentVersion: string; latestVersion?: string; releaseUrl?: string; checkedAt?: number; error?: string };
export type DashboardActivity = { messagesSent: number; reactionsSent: number; reactionCounts: Record<string, number>; perPetActivityCounts: Record<string, number>; lastActivityAt?: number };
export type DashboardSnapshot = { defaultPet: { id: string; displayName: string; previewSpriteUrl: string }; installedPetCount: number; catalog: { source: string; total?: number; page?: number; pageCount?: number; error?: string }; plugins: { installed: number; enabled: number; broken: number }; updateStatus: UpdateStatus; activity: DashboardActivity };
export type ReactionAnimationSettings = { reactions: { id: string; label: string; description: string; defaultAnimation: UserSelectableAnimationState }[]; animations: { id: UserSelectableAnimationState; label: string; description: string }[]; sprite: { frameWidth: number; frameHeight: number; columns: number; rows: number; states: Record<UserSelectableAnimationState, { row: number; frames: number; durationMs: number; iterations?: number | "infinite" }> }; overrides: ReactionAnimationOverrides; previewSpriteUrl: string };
export type PluginFilter = "all" | "installed" | "catalog" | "local" | "broken";
export type PluginPermission =
| "familiar:speak" | "familiar:reaction" | "familiar:move" | "timer" | "schedule" | "storage" | "status" | "commands" | "network"
| "familiar:interact" | "familiar:pin" | "familiar:animate" | "familiar:speak:dynamic" | "familiar:drop" | "familiars:read" | "familiars:manage"
| "audio" | "events" | "ui:toast" | "ui:panel" | "notify" | "bus" | "ai" | "secrets" | "voice:speak" | "voice:listen"
| "auth" | "files" | "system:openExternal" | "system:metrics" | "clipboard" | "network:write";
export type PluginPlatformSettings = {
allowPluginAudio: boolean;
allowDynamicSpeech: boolean;
allowPluginVoice: boolean;
allowMicrophone: boolean;
quietHours: { enabled: boolean; start: string; end: string };
ai: { provider: "none" | "anthropic" | "openai" | "ollama"; model: string; baseUrl?: string };
};
export type PluginInspectorState = { schedules: Array<{ id: string; type: string; nextRunMs: number }>; commands: PluginCommand[]; menuItems: Array<{ id: string; title: string }>; status?: PluginStatus; activeBubbles: number; activePanels: number; eventSubscriptions: number; lastError?: string; quotaCounters: Record<string, number> };
export type PluginIconName = "plugin" | "bell" | "timer" | "github" | "heart" | "sparkles" | "coffee" | "focus" | "droplet";
export type PluginConfigField = { type: "text" | "textarea" | "number" | "boolean" | "select" | "time" | "date" | "multiSelect" | "list" | "secret" | "sound"; label?: string; description?: string; default?: string | number | boolean | string[] | Array<Record<string, unknown>>; options?: Array<{ label: string; value: string }>; min?: number; max?: number; step?: number; maxLength?: number; maxItems?: number; itemSchema?: Record<string, PluginConfigField> };
export type PluginConfigSchema = Record<string, PluginConfigField>;
export type PluginConfig = Record<string, unknown>;
export type PluginCommandFormField = { id: string; type: "text" | "textarea" | "number" | "boolean" | "select" | "multiSelect" | "time" | "date" | "list"; label: string; default?: string | number | boolean | string[]; options?: Array<{ label: string; value: string }>; min?: number; max?: number; maxLength?: number; required?: boolean };
export type PluginCommandForm = { fields: PluginCommandFormField[]; submitLabel?: string };
export type PluginCommand = { id: string; title: string; description?: string; form?: PluginCommandForm };
export type PluginStatus = { text: string; tone?: "info" | "success" | "warning" | "error" };
export type PluginConfigError = { path?: string; code?: string; message?: string };
export type PluginCategory = "Companion" | "Wellness" | "Focus" | "Developer" | "Advanced";
export type SafePluginRecord = { id: string; name?: string; description?: string; version: string; icon?: PluginIconName; iconDataUrl?: string; source: "catalog" | "local"; bundled?: boolean; category?: PluginCategory; enabled: boolean; brokenReason?: string; approvedPermissions: PluginPermission[]; runtime?: "declarative" | "javascript"; sdkVersion?: string; catalogDisabled?: boolean; catalogDeprecated?: boolean; catalogStatusReason?: string; configSchema?: PluginConfigSchema; effectiveConfig?: PluginConfig; configErrors?: PluginConfigError[]; commands?: PluginCommand[]; status?: PluginStatus };
export type SafeCatalogPluginRecord = { id: string; name: string; version: string; description: string; runtime: "declarative" | "javascript"; icon?: PluginIconName; iconDataUrl?: string; sdkVersion?: string; permissions: PluginPermission[]; installed: boolean; bundled?: boolean; category?: PluginCategory; deprecated?: boolean; statusReason?: string };
export type PluginServiceSnapshot = { plugins: SafePluginRecord[] };
export type PluginCatalogSnapshot = { plugins: SafeCatalogPluginRecord[] };
export type PluginServiceResult = { ok: true; snapshot: PluginServiceSnapshot } | { ok: false; error: string; snapshot: PluginServiceSnapshot };
export type PluginConfigSoundPickResult = { ok: true; sound: { kind: "user-sound"; id: string; name?: string }; snapshot: PluginServiceSnapshot } | { ok: false; error: string; snapshot: PluginServiceSnapshot };
export type PluginEntry = { id: string; installed?: SafePluginRecord; catalog?: SafeCatalogPluginRecord };
export type KnowledgeFile = {
id: string;
name: string;
originalName: string;
mimeType: string;
size: number;
isText: boolean;
extractedText: string | undefined;
extractedTextLength: number;
addedAt: number;
};
export type KnowledgeSearchResult = {
files: Array<{ file: KnowledgeFile; score: number }>;
memories: Array<{ entry: { id: string; text: string; kind: string; tags: string[]; importance: number; createdAt: number; updatedAt: number }; score: number }>;
};
export type ControlCenterApi = {
getPetsState(): Promise<StateSnapshot>;
getDashboardSnapshot(): Promise<DashboardSnapshot>;
getSettingsState(): Promise<SettingsState>;
getOpenApiChatSettings(): Promise<OpenApiChatSettings>;
getI18n(): Promise<I18nSnapshot>;
updatePreferences(patch: Partial<SettingsState["preferences"]>): Promise<SettingsState>;
saveOpenApiCredential(apiKey: string): Promise<OpenApiChatSettings>;
clearOpenApiCredential(): Promise<OpenApiChatSettings>;
getReactionAnimationSettings(): Promise<ReactionAnimationSettings>;
getLaunchAtLogin(): Promise<LaunchAtLoginState>;
setLaunchAtLogin(enabled: boolean): Promise<LaunchAtLoginState>;
getUpdateStatus(): Promise<UpdateStatus>;
checkForUpdates(): Promise<UpdateStatus>;
openUpdateReleasePage(): Promise<void>;
resetDefaultPetPosition(): Promise<SettingsState>;
getPluginsSnapshot(): Promise<PluginServiceSnapshot>;
getPluginCatalogSnapshot(refresh?: boolean): Promise<PluginCatalogSnapshot>;
setPluginEnabled(id: string, enabled: boolean): Promise<PluginServiceResult>;
savePluginConfig(id: string, config: PluginConfig): Promise<PluginServiceResult>;
pickPluginConfigSound(id: string): Promise<PluginConfigSoundPickResult>;
reloadPlugin(id: string): Promise<PluginServiceResult>;
executePluginCommand(id: string, commandId: string, args?: Record<string, unknown>): Promise<PluginServiceResult>;
loadLocalPlugin(): Promise<PluginServiceResult>;
installCatalogPlugin(id: string): Promise<PluginServiceResult>;
updateCatalogPlugin(id: string): Promise<PluginServiceResult>;
uninstallPlugin(id: string): Promise<PluginServiceResult>;
getPluginInspector(id: string): Promise<PluginInspectorState>;
getPluginPlatformSettings(): Promise<PluginPlatformSettings>;
updatePluginPlatformSettings(patch: Partial<PluginPlatformSettings>): Promise<PluginPlatformSettings>;
setPluginAiApiKey(key: string | null): Promise<{ ok: boolean; hasKey: boolean }>;
getPluginAiApiKeyStatus(): Promise<{ hasKey: boolean }>;
getCatalog(): Promise<CatalogState>;
getCatalogPage(page: number): Promise<CatalogState>;
getCatalogSearch(): Promise<{ familiars: SearchPetEntry[]; error?: string }>;
getCodexPets(): Promise<CodexState>;
setDefaultPet(petId: string): Promise<StateSnapshot>;
installPet(petId: string): Promise<unknown>;
installLocalPet(): Promise<unknown>;
importCodexPet(petId: string): Promise<unknown>;
openGallery(): Promise<void>;
removePet(petId: string): Promise<StateSnapshot>;
onRouteChange(callback: (route: Route) => void): () => void;
onPluginsRefresh(callback: () => void): () => void;
getIntegrationsState(selectedPetId?: string, commandMode?: "published" | "local" | "bundled"): Promise<AgentSetupSnapshot>;
runIntegrationAction(action: AgentSetupAction, selectedPetId?: string, commandMode?: "published" | "local" | "bundled"): Promise<AgentSetupSnapshot>;
updateIntegrationCommandPaths(patch: Partial<AgentSetupCommandPaths>): Promise<AgentSetupCommandPaths>;
getFamiliarOSMcpServerPreview(selectedPetId?: string, commandMode?: "published" | "local" | "bundled"): Promise<FamiliarOSMcpServerPreview>;
testFamiliarOSMcpServer(selectedPetId?: string, commandMode?: "published" | "local" | "bundled"): Promise<FamiliarOSMcpServerHealth>;
getTtsSettings(): Promise<TtsSettingsSnapshot>;
getTtsVoices(provider: TtsProviderId): Promise<TtsVoice[]>;
saveTtsCredential(provider: TtsProviderId, credential: string): Promise<TtsCredentialStatus>;
clearTtsCredential(provider: TtsProviderId): Promise<TtsCredentialStatus>;
testTtsSpeak(text: string): Promise<void>;
stopTts(): Promise<void>;
copyText(text: string): Promise<void>;
openExternalUrl(url: string): Promise<void>;
installMcpToolkit(target: McpToolkitPersistentTarget): Promise<McpToolkitInstallResult>;
getVanillaChatMcpTools(): Promise<{ enabled: string[]; available: string[]; active: string[] }>;
setVanillaChatMcpTools(toolIds: string[]): Promise<string[]>;
getMemories(query?: string, limit?: number): Promise<Array<{ id: string; text: string; kind: string; tags: string[]; importance: number; createdAt: number; updatedAt: number }>>;
storeMemory(text: string, kind?: string, tags?: string[], importance?: number): Promise<{ id: string; text: string; kind: string; tags: string[]; importance: number }>;
updateMemory(id: string, text: string, kind?: string, tags?: string[], importance?: number): Promise<{ id: string; text: string; kind: string; tags: string[]; importance: number }>;
deleteMemory(id: string): Promise<boolean>;
knowledgeList(limit?: number): Promise<KnowledgeFile[]>;
knowledgeSearch(query?: string, limit?: number): Promise<KnowledgeSearchResult>;
knowledgeDeleteFile(id: string): Promise<boolean>;
knowledgeAddMemory(text: string, kind?: string, tags?: string[], importance?: number): Promise<{ id: string; text: string; kind: string; tags: string[]; importance: number }>;
knowledgeStoreFile(): Promise<KnowledgeFile | null>;
};
export type AgentSetupAction = "configure" | "replace" | "remove" | "install-memory" | "doctor-hooks" | "install-hooks" | "uninstall-hooks" | "opencode-install" | "opencode-remove" | "cursor-install" | "cursor-replace" | "cursor-remove";
export type AgentSetupPetOption = { id: string; displayName: string; default: boolean };
export type ClaudeCodeStatus = { state: "detected" | "not_detected" | "configured" | "needs_setup" | "error"; label: string; details: string; claudeCommand?: string; version?: string; mcpListWorks: boolean; openPetsEntry: { present: boolean; verified: boolean; matchesExpected: boolean }; canConfigure: boolean; canReplace: boolean; canRemove: boolean };
export type ClaudeHookDoctorResult = { status: "installed" | "needs_setup" | "error" | "custom" | "conflict"; settingsPath: string; exists: boolean; valid: boolean; message: string; preview: Record<string, unknown>; asyncSupported: boolean; backupPath?: string };
export type ClaudeFamiliarOSMemoryStatus = { state: "installed" | "needs_setup" | "error"; label: string; details: string; claudeMdPath: string; openPetsMemoryPath: string; canInstall: boolean };
export type OpenCodeSetupStatus = { state: "configured" | "needs_setup" | "not_detected" | "error"; label: string; details: string; configDir: string; canInstall: boolean; canRemove: boolean };
export type OpenCodeSetupPreview = { global: true; configDir: string; configPath: string; cleanupConfigPaths: string[]; mcpCommand: string[]; plugin: unknown[] | string; instructionPath: string; configPreview: Record<string, unknown> };
export type CursorSetupStatus = { state: "configured" | "needs_setup" | "not_detected" | "error" | "conflict" | "needs_update"; label: string; details: string; configPath: string; canInstall: boolean; canReplace: boolean; canRemove: boolean };
export type CursorSetupPreview = { global: true; configPath: string; mcpEntry: Record<string, unknown>; rulesPath: string; rulesContent: string; commandMode: "published" | "local" | "bundled" };
export type AgentSetupCommandPaths = { claude: string; node: string; opencode: string };
export type AgentSetupActionResult = { ok: boolean; action: AgentSetupAction; message: string; changed: boolean };
export type AgentSetupSnapshot = { selectedPetId?: string; commandMode: "published" | "local" | "bundled"; localDevAvailable: boolean; petOptions: AgentSetupPetOption[]; preview: { displayCommand: string; mcpJson: Record<string, unknown> }; status: ClaudeCodeStatus; hookStatus: ClaudeHookDoctorResult; memoryStatus: ClaudeFamiliarOSMemoryStatus; opencodeStatus: OpenCodeSetupStatus; opencodePreview: OpenCodeSetupPreview; cursorStatus: CursorSetupStatus; cursorPreview: CursorSetupPreview; commandPaths: AgentSetupCommandPaths; busy: boolean; lastAction?: AgentSetupActionResult };
export type FamiliarOSMcpServerPreview = { commandMode: "published" | "local" | "bundled"; command: string; args: readonly string[]; displayCommand: string; mcpJson: { mcpServers: { familiaros: { type: "stdio"; command: string; args: readonly string[] } } } };
export type FamiliarOSMcpServerHealth = { ok: boolean; output: string; error?: string };
export type TtsProviderId = "system" | "openai" | "elevenlabs" | "piper" | "openai-compatible";
export type TtsEndpointPreset = "openrouter" | "litellm" | "wavespeedai" | "custom";
export type TtsVoice = { id: string; label: string };
export type TtsCredentialStatus = { hasCredential: boolean; storageMode: "encrypted" | "plain"; provider: string };
export type TtsSettingsSnapshot = { provider: TtsProviderId; voice: string; speed: number; model: string; endpointPreset: TtsEndpointPreset; endpoint: string; hasCredential: boolean; providers: { id: TtsProviderId; label: string; defaultModel: string; defaultVoice: string; voices: readonly TtsVoice[] }[] };
export type StatusTone = keyof typeof statusPillToneClass;
export type OpenApiEndpointPreset = {
id: string;
label: string;
endpoint: string;
applyMode: "save" | "draft";
description: string;
};
export type OpenApiCredentialUi = {
label: string;
placeholder: string;
replacementPlaceholder: string;
savedMessage: string;
clearedMessage: string;
};
export type McpToolkitInstallMode = "manual" | "persistent";
export type McpToolkitPersistentTarget = "claude-user" | "codex-global";
export type McpToolkitBundle = {
readonly label: string;
readonly language: "bash" | "json" | "text";
readonly value: string;
readonly description: string;
};
export type McpToolkitInstallResult = {
readonly target: McpToolkitPersistentTarget;
readonly label: string;
readonly installed: { id: string; name: string; status: "installed" | "skipped"; detail: string }[];
readonly skipped: { id: string; name: string; status: "installed" | "skipped"; detail: string }[];
readonly notes: string[];
};
export const apiBridge = window as unknown as {
familiarOSControlCenter?: ControlCenterApi;
openPetsControlCenter?: ControlCenterApi;
};
export const api = resolveControlCenterApi(apiBridge);
export function resolveControlCenterApi(bridge: {
familiarOSControlCenter?: ControlCenterApi;
openPetsControlCenter?: ControlCenterApi;
}): ControlCenterApi {
const resolved = bridge.familiarOSControlCenter ?? bridge.openPetsControlCenter;
if (!resolved) {
throw new Error("FamiliarOS Control Center bridge is unavailable.");
}
return resolved;
}
export const openApiEndpointPresets: readonly OpenApiEndpointPreset[] = [
{
id: "openai",
label: "OpenAI",
endpoint: "",
applyMode: "save",
description: "Uses the built-in OpenAI Responses endpoint immediately.",
},
{
id: "openrouter",
label: "OpenRouter",
endpoint: "https://openrouter.ai/api/v1",
applyMode: "save",
description: "Uses the OpenRouter OpenAPI base so FamiliarOS can choose the best supported chat route automatically.",
},
{
id: "azure",
label: "Azure Template",
endpoint: "https://YOUR-RESOURCE-NAME.openai.azure.com/openai/v1",
applyMode: "draft",
description: "Prefills the Azure OpenAI OpenAPI base. Replace YOUR-RESOURCE-NAME, then save.",
},
{
id: "litellm-local",
label: "LiteLLM Local",
endpoint: "http://localhost:4000/v1",
applyMode: "save",
description: "Common local LiteLLM OpenAPI-compatible gateway port.",
},
{
id: "vllm-local",
label: "vLLM Local",
endpoint: "http://localhost:8000/v1",
applyMode: "save",
description: "Common local vLLM OpenAPI-compatible server port.",
},
{
id: "localhost-template",
label: "Custom Local Template",
endpoint: "http://localhost:11434/v1",
applyMode: "draft",
description: "Template for a local gateway. Edit the host, port, or path if needed, then save.",
},
{
id: "https-template",
label: "Generic HTTPS Template",
endpoint: "https://YOUR-HOSTNAME.example.com/v1",
applyMode: "draft",
description: "Template for hosted OpenAPI-compatible gateways and proxies.",
},
{
id: "moonshot",
label: "Moonshot (Kimi)",
endpoint: "https://api.moonshot.cn/v1",
applyMode: "save",
description: "Moonshot AI OpenAPI-compatible endpoint for Kimi models.",
},
] as const;
export function getOpenApiCredentialUi(presetId: string | undefined): OpenApiCredentialUi {
switch (presetId) {
case "openai":
return {
label: "OpenAI API key",
placeholder: "Enter your OpenAI API key",
replacementPlaceholder: "Replace saved OpenAI API key",
savedMessage: "OpenAI API key saved.",
clearedMessage: "OpenAI API key cleared.",
};
case "openrouter":
return {
label: "OpenRouter API key",
placeholder: "Enter your OpenRouter API key",
replacementPlaceholder: "Replace saved OpenRouter API key",
savedMessage: "OpenRouter API key saved.",
clearedMessage: "OpenRouter API key cleared.",
};
case "azure":
return {
label: "Azure API key",
placeholder: "Enter your Azure API key",
replacementPlaceholder: "Replace saved Azure API key",
savedMessage: "Azure API key saved.",
clearedMessage: "Azure API key cleared.",
};
case "moonshot":
return {
label: "Moonshot API key",
placeholder: "Enter your Moonshot API key",
replacementPlaceholder: "Replace saved Moonshot API key",
savedMessage: "Moonshot API key saved.",
clearedMessage: "Moonshot API key cleared.",
};
default:
return {
label: "API key or token",
placeholder: "Enter your provider API key or token",
replacementPlaceholder: "Replace saved API key or token",
savedMessage: "Chat credential saved.",
clearedMessage: "Chat credential cleared.",
};
}
}
export function normalizeComparableEndpoint(value: string): string {
return value.replace(/\/(?:responses|chat\/completions)$/, "/v1");
}
export const themeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
export function normalizeThemeMode(value: unknown): ThemeMode {
return value === "light" || value === "dark" ? value : "system";
}
export function resolveThemeMode(mode: ThemeMode): "light" | "dark" {
if (mode === "light" || mode === "dark") return mode;
return themeMediaQuery.matches ? "dark" : "light";
}
export function applyDocumentTheme(mode: ThemeMode): void {
document.documentElement.dataset.theme = resolveThemeMode(mode);
}
// Inline SVG Icons for actions, pagination, and filters
export const InstallIcon = () => (
<svg className="btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
);
export const ImportIcon = () => (
<svg className="btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
<path d="M12 18v-6" />
<path d="m9 15 3 3 3-3" />
</svg>
);
export const SetDefaultIcon = () => (
<svg className="btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
);
export const ReplaceIcon = () => (
<svg className="btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M21 21v-5h-5" />
</svg>
);
export const HookIcon = () => (
<svg className="btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="m18 15-6-6-6 6" />
</svg>
);
export const MemoryIcon = () => (
<svg className="btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 10v6" />
<path d="M9 13h6" />
<rect width="18" height="18" x="3" y="3" rx="2" />
</svg>
);
export const RemoveIcon = () => (
<svg className="btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
);
export const RefreshIcon = () => (
<svg className="btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M3 21v-5h5" />
</svg>
);
export const Spinner = ({ className = "", label = "Loading" }: { className?: string; label?: string }) => (
<span className="inline-flex items-center justify-center" role="status" aria-live="polite" aria-label={label}>
<svg className={`animate-spin motion-reduce:animate-none h-5 w-5 text-cyan-400 ${className}`} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="sr-only">{label}</span>
</span>
);
export const ConfigureIcon = () => (
<svg className="btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="21" x2="14" y1="4" y2="4" />
<line x1="10" x2="3" y1="4" y2="4" />
<line x1="21" x2="12" y1="12" y2="12" />
<line x1="8" x2="3" y1="12" y2="12" />
<line x1="21" x2="16" y1="20" y2="20" />
<line x1="12" x2="3" y1="20" y2="20" />
<line x1="14" x2="14" y1="2" y2="6" />
<line x1="8" x2="8" y1="10" y2="14" />
<line x1="16" x2="16" y1="18" y2="22" />
</svg>
);
export const EyeIcon = () => (
<svg className="btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
export const FolderPlusIcon = () => (
<svg className="btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 10v6" />
<path d="M9 13h6" />
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>
);
export const SaveIcon = () => (
<svg className="btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8A2 2 0 0 1 21 8.8V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z" />
<path d="M17 21v-7H7v7" />
<path d="M7 3v5h8" />
</svg>
);
export const CloseIcon = () => (
<svg className="btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
);
export const CopyIcon = () => (
<svg className="btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
);
export const ExternalLinkIcon = () => (
<svg className="btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 3h6v6" />
<path d="M10 14 21 3" />
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
</svg>
);
export const PrevIcon = () => (
<svg className="btn-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<path d="m15 18-6-6 6-6" />
</svg>
);
export const NextIcon = () => (
<svg className="btn-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<path d="m9 18 6-6-6-6" />
</svg>
);
export const FilterAllIcon = () => (
<svg className="filter-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<rect width="7" height="7" x="3" y="3" rx="1" />
<rect width="7" height="7" x="14" y="3" rx="1" />
<rect width="7" height="7" x="14" y="14" rx="1" />
<rect width="7" height="7" x="3" y="14" rx="1" />
</svg>
);
export const FilterInstalledIcon = () => (
<svg className="filter-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</svg>
);
export const FilterFeaturedIcon = () => (
<svg className="filter-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3q1 4 4 6.5t3 5.5a7 7 0 0 1-14 0 5 5 0 0 1 1-3 3 3 0 0 0 5 0c0-2-1.5-3-1.5-5q0-2 2.5-4" />
</svg>
);
export const FilterOriginalIcon = () => (
<svg className="filter-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275Z" />
</svg>
);
export const FilterWesternIcon = () => (
<svg className="filter-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
<path d="M2 12h20" />
</svg>
);
export const FilterAsianIcon = () => (
<svg className="filter-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>
);
export const FilterCodexIcon = () => (
<svg className="filter-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg>
);
export const MessageIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
);
export const HeartIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" />
</svg>
);
export const StarIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
);
export const ZapIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg>
);
export const ActivityIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
);
export const BoxIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
<path d="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" />
</svg>
);
export const WrenchIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
);
export const ServerIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2" />
<rect x="2" y="14" width="20" height="8" rx="2" ry="2" />
<line x1="6" y1="6" x2="6.01" y2="6" />
<line x1="6" y1="18" x2="6.01" y2="18" />
</svg>
);
export const ShieldIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.5 3.8 17 5 19 5a1 1 0 0 1 1 1Z" />
</svg>
);
// Navigation Shell Types and Icons
export type Route = "dashboard" | "familiars" | "settings" | "plugins" | "integrations";
export const DashboardIcon = () => (
<svg className="nav-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect fill="currentColor" width="7" height="9" x="3" y="3" rx="1" />
<rect fill="currentColor" width="7" height="5" x="14" y="3" rx="1" />
<rect fill="currentColor" width="7" height="9" x="14" y="12" rx="1" />
<rect fill="currentColor" width="7" height="5" x="3" y="16" rx="1" />
</svg>
);
export const PetsIcon = () => (
<svg className="nav-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle fill="currentColor" cx="11" cy="4" r="2" />
<circle fill="currentColor" cx="18" cy="8" r="2" />
<circle fill="currentColor" cx="20" cy="16" r="2" />
<path fill="currentColor" d="M9 10a5 5 0 0 1 5 5v3.5a3.5 3.5 0 0 1-6.84 1.045q-.64-2.065-2.7-2.705A3.5 3.5 0 0 1 5.5 10Z" />
</svg>
);
export const SettingsIcon = () => (
<svg className="nav-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="21" x2="14" y1="4" y2="4" />
<line x1="10" x2="3" y1="4" y2="4" />
<line x1="21" x2="12" y1="12" y2="12" />
<line x1="8" x2="3" y1="12" y2="12" />
<line x1="21" x2="16" y1="20" y2="20" />
<line x1="12" x2="3" y1="20" y2="20" />
<line x1="14" x2="14" y1="2" y2="6" />
<line x1="8" x2="8" y1="10" y2="14" />
<line x1="16" x2="16" y1="18" y2="22" />
</svg>
);
export const PluginsIcon = () => (
<svg className="nav-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path fill="currentColor" d="M10 22V7a1 1 0 0 0-1-1H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5a1 1 0 0 0-1-1H2" />
<rect fill="currentColor" width="8" height="8" x="14" y="2" rx="1" />
</svg>
);
export const IntegrationsIcon = () => (
<svg className="nav-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="6" cy="6" r="3" />
<circle cx="18" cy="6" r="3" />
<circle cx="12" cy="18" r="3" />
<path d="M8.6 7.5 10.8 15" />
<path d="M15.4 7.5 13.2 15" />
<path d="M9 6h6" />
</svg>
);
export const navTabs = [
{ id: "dashboard" as const, labelKey: "nav.dashboard", icon: <DashboardIcon /> },
{ id: "familiars" as const, labelKey: "nav.familiars", icon: <PetsIcon /> },
{ id: "settings" as const, labelKey: "nav.settings", icon: <SettingsIcon /> },
{ id: "plugins" as const, labelKey: "nav.plugins", icon: <PluginsIcon /> },
{ id: "integrations" as const, labelKey: "nav.integrations", icon: <IntegrationsIcon /> },
];
export const routeMetadata: Record<Route, { titleKey: string; descKey: string }> = {
dashboard: {
titleKey: "route.dashboard.title",
descKey: "route.dashboard.description",
},
familiars: {
titleKey: "route.familiars.title",
descKey: "route.familiars.description",
},
settings: {
titleKey: "route.settings.title",
descKey: "route.settings.description",
},
plugins: {
titleKey: "route.plugins.title",
descKey: "route.plugins.description",
},
integrations: {
titleKey: "route.integrations.title",
descKey: "route.integrations.description",
},
};
export const filterIcons: Record<Filter, React.ReactNode> = {
all: <FilterAllIcon />,
installed: <FilterInstalledIcon />,
featured: <FilterFeaturedIcon />,
originals: <FilterOriginalIcon />,
codex: <FilterCodexIcon />,
};
export const filterLabelKeys: Record<Filter, string> = {
all: "familiars.filter.all",
installed: "familiars.filter.installed",
featured: "familiars.filter.featured",
originals: "familiars.filter.originals",
codex: "familiars.filter.codex",
};
export const buttonVariantClass = {
primary: "btn-primary",
secondary: "btn-secondary",
danger: "btn-danger",
success: "btn-success",
warning: "btn-warning",
} as const;
export const statusPillToneClass = {
blue: "pill-blue",
green: "pill-green",
orange: "pill-orange",
purple: "pill-purple",
yellow: "pill-yellow",
red: "pill-red",
slate: "pill-slate",
} as const;
export function isRoute(value: string | null | undefined): value is Route {
return value === "dashboard" || value === "familiars" || value === "settings" || value === "plugins" || value === "integrations";
}
export function initialControlCenterRoute(): Route {
try {
const params = new URLSearchParams(window.location.search);
const route = params.get("route");
return isRoute(route) ? route : "dashboard";
} catch {
return "dashboard";
}
}
export const commandModeLabelKeys: Record<AgentSetupSnapshot["commandMode"], string> = {
published: "integrations.commandMode.published",
bundled: "integrations.commandMode.bundled",
local: "integrations.commandMode.local",
};
export const persistentToolkitUnavailable = [
"github",
"databases",
"docker",
"shell",
"process-logs",
"system-info",
"ssh",
"package-manager",
"tmux",
"kubernetes-cloud",
"ci-cd",
"ghidra",
"binary-analysis",
"network-analysis",
] as const;
export function buildPersistentToolkitBundle(target: McpToolkitPersistentTarget): McpToolkitBundle {
if (target === "claude-user") {
return {
label: "Claude Code user-scope install bundle",
language: "bash",
description: "This mirrors FamiliarOS' supported Install Now baseline for Claude Code. It keeps the same managed server names so reruns stay predictable.",
value: `claude mcp add --scope user familiaros-filesystem -- npx -y @modelcontextprotocol/server-filesystem "$HOME"
claude mcp add --scope user familiaros-playwright -- npx @playwright/mcp@latest
claude mcp add --scope user familiaros-memory -- npx -y @modelcontextprotocol/server-memory
claude mcp add --scope user familiaros-context7 -- npx -y @upstash/context7-mcp
claude mcp add --scope user familiaros-fetch -- uvx mcp-server-fetch
claude mcp add --scope user familiaros-sequential-thinking -- npx -y @modelcontextprotocol/server-sequential-thinking
claude mcp add --scope user familiaros-browser-use -- uvx --from browser-use[cli] browser-use --mcp`,
};
}
return {
label: "Codex CLI persistent install bundle",
language: "bash",
description: "This mirrors FamiliarOS' supported Install Now baseline for Codex CLI and keeps the same managed server names.",
value: `codex mcp add familiaros-filesystem -- npx -y @modelcontextprotocol/server-filesystem "$HOME"
codex mcp add familiaros-playwright -- npx @playwright/mcp@latest
codex mcp add familiaros-memory -- npx -y @modelcontextprotocol/server-memory
codex mcp add familiaros-context7 -- npx -y @upstash/context7-mcp
codex mcp add familiaros-fetch -- uvx mcp-server-fetch
codex mcp add familiaros-sequential-thinking -- npx -y @modelcontextprotocol/server-sequential-thinking
codex mcp add familiaros-browser-use -- uvx --from browser-use[cli] browser-use --mcp`,
};
}
export function getPersistentToolkitFollowUps(): string[] {
return persistentToolkitUnavailable
.map((id) => getMcpToolkitEntry(id))
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry))
.map((entry) => entry.name);
}
export function Button({
children,
variant = "primary",
size = "normal",
onClick,
disabled,
icon,
iconPosition = "left",
fullWidth,
ariaLabel,
}: {
children: React.ReactNode;
variant?: "primary" | "secondary" | "danger" | "success" | "warning";
size?: "normal" | "compact";
onClick?: () => void;
disabled?: boolean;
icon?: React.ReactNode;
iconPosition?: "left" | "right";
fullWidth?: boolean;
ariaLabel?: string;
}) {
return (
<button
className={`btn ${buttonVariantClass[variant]} ${size === "compact" ? "btn-compact" : ""} ${fullWidth ? "w-full" : ""} ${icon ? "has-icon" : ""}`}
onClick={onClick}
disabled={disabled}
aria-label={ariaLabel}
>
{icon && iconPosition === "left" && <span className="btn-icon-wrapper mr-1.5 inline-flex items-center justify-center">{icon}</span>}
<span className="btn-text">{children}</span>
{icon && iconPosition === "right" && <span className="btn-icon-wrapper ml-1.5 inline-flex items-center justify-center">{icon}</span>}
</button>
);
}
export function GlassCard({ children, className = "" }: { children: React.ReactNode; className?: string }) { return <section className={`glass ${className}`}>{children}</section>; }
export function StatusPill({ children, tone = "blue" }: { children: React.ReactNode; tone?: keyof typeof statusPillToneClass }) { return <span className={`pill ${statusPillToneClass[tone]}`}>{children}</span>; }
export function SearchInput(props: React.InputHTMLAttributes<HTMLInputElement>) { const { t } = useI18n(); return <input className="search" placeholder={t("familiars.search.placeholder")} {...props} />; }
export function isAllowedCatalogPreview(value: string | undefined): value is string {
if (!value) return false;
try {
const url = new URL(value);
return url.protocol === "https:" &&
url.hostname === "familiaros.dev" &&
url.port === "" &&
url.username === "" &&
url.password === "" &&
url.pathname.startsWith("/familiars/") &&
url.pathname.endsWith(".webp");
} catch {
return false;
}
}
export function isAllowedCodexPreview(value: string | undefined): value is string {
return typeof value === "string" && /^familiaros-codex:\/\/spritesheet\/[a-zA-Z0-9%][a-zA-Z0-9%_-]{0,128}$/u.test(value);
}
export function isAllowedInstalledPetPreview(value: string | undefined): value is string {
return typeof value === "string" && /^familiaros-installed:\/\/spritesheet\/[a-zA-Z0-9%][a-zA-Z0-9%_-]{0,128}$/u.test(value);
}
export function isAllowedDefaultPetPreview(value: string | undefined): value is string {
return typeof value === "string" && /^familiaros-familiar-preview:\/\/spritesheet\/default\?v=[a-z0-9_-]+-\d+-\d+$/u.test(value);
}
export function isAllowedDataUrl(value: string | undefined): value is string {
return typeof value === "string" && /^data:image\/(?:png|webp|jpeg|jpg);base64,[a-z0-9+/=]+$/iu.test(value);
}
export function safePetImage(value: string | undefined): string | undefined {
return isAllowedCatalogPreview(value) || isAllowedCodexPreview(value) || isAllowedInstalledPetPreview(value) || isAllowedDefaultPetPreview(value) || isAllowedDataUrl(value) ? value : undefined;
}
export function installedPetSpritesheetUrl(petId: string): string {
return `familiaros-installed://spritesheet/${encodeURIComponent(petId)}`;
}
export function imageDebug(value: string | undefined): string {
if (!value) return "missing";
if (value.startsWith("data:image/")) return `data:${value.slice(5, 16)}`;
try {
const url = new URL(value);
return `${url.protocol}//${url.hostname}${url.pathname}`;
} catch {
return "invalid-url";
}
}
export function logPetsEvent(event: string, fields: Record<string, unknown>): void {
console.info(`[ControlCenterPets] ${JSON.stringify({ event, ...fields })}`);
}
export function logPetsError(event: string, fields: Record<string, unknown>): void {
console.error(`[ControlCenterPets] ${JSON.stringify({ event, ...fields })}`);
}
export const spriteFrameSizes = {
thumb: { width: 54, height: 58 },
detail: { width: 144, height: 156 },
mini: { width: 56, height: 61 },
} as const;
export const spriteStates = {
idle: { row: 0, frames: 6, duration: "1.65s" },
thinking: { row: 8, frames: 6, duration: "1.55s" },
wave: { row: 3, frames: 4, duration: "1.25s" },
happy: { row: 4, frames: 5, duration: "1.35s" },
} as const;
export function SpriteFrame({ src, label, state = "idle", size = "detail" }: { src?: string; label: string; state?: "idle" | "thinking" | "happy" | "wave"; size?: "thumb" | "detail" | "mini" }) {
const safeSrc = safePetImage(src);
if (!safeSrc) return <img src={defaultThumbUrl} alt="" />;
const frame = spriteFrameSizes[size];
const sprite = spriteStates[state];
const xValues = Array.from({ length: sprite.frames }, (_, index) => String(-index * frame.width)).join(";");
const y = -sprite.row * frame.height;
return <svg className={`sprite-frame sprite-${state} sprite-${size}`} width={frame.width} height={frame.height} viewBox={`0 0 ${frame.width} ${frame.height}`} role="img" aria-label={label}>
<image href={safeSrc} x="0" y={y} width={frame.width * 8} height={frame.height * 9} preserveAspectRatio="none" onError={() => logPetsError("sprite-failed", { label, state, size, src: imageDebug(safeSrc) })}>
<animate attributeName="x" values={xValues} dur={sprite.duration} repeatCount="indefinite" calcMode="discrete" />
</image>
</svg>;
}
export function PetImage({ src, alt = "", debugLabel }: { src?: string; alt?: string; debugLabel: string }) {
const safeSrc = safePetImage(src) || defaultThumbUrl;
return <img src={safeSrc} alt={alt} draggable="false" onError={() => logPetsError("image-failed", { label: debugLabel, src: imageDebug(safeSrc) })} />;
}
export function ToggleRow({ title, description, checked, disabled, onChange }: { title: string; description: string; checked: boolean; disabled?: boolean; onChange: (checked: boolean) => void }) {
return <label className={`settings-row ${disabled ? "opacity-60" : ""}`}>
<div className="settings-row-info"><strong>{title}</strong><small>{description}</small></div>
<input className="settings-toggle" type="checkbox" checked={checked} disabled={disabled} onChange={(event) => onChange(event.target.checked)} />
</label>;
}
export function formatUpdateStatus(status: UpdateStatus | null, t: (key: string, vars?: Record<string, string | number>) => string): string {
if (!status) return t("settings.update.notLoaded");
if (status.state === "checking") return t("settings.update.checking");
if (status.state === "available") return t("settings.update.available", { version: status.latestVersion ?? t("common.latest") });
if (status.state === "current") return t("settings.update.current");
if (status.state === "error") return status.error || t("settings.update.failed");
return t("settings.update.version", { version: status.currentVersion });
}
export function ReactionPreviewSprite({ settings, state }: { settings: ReactionAnimationSettings; state: UserSelectableAnimationState }) {
const { t } = useI18n();
const frame = { width: settings.sprite.frameWidth, height: settings.sprite.frameHeight };
const sprite = settings.sprite.states[state] ?? settings.sprite.states.idle;
const xValues = Array.from({ length: sprite.frames }, (_, index) => String(-index * frame.width)).join(";");
const y = -sprite.row * frame.height;
return (
<div className="reaction-preview-sprite-shell">
<svg className="reaction-preview-sprite" width={frame.width} height={frame.height} viewBox={`0 0 ${frame.width} ${frame.height}`} role="img" aria-label={t("settings.reactions.previewAria", { state })}>
<image href={settings.previewSpriteUrl} x="0" y={y} width={frame.width * settings.sprite.columns} height={frame.height * settings.sprite.rows} preserveAspectRatio="none">
<animate attributeName="x" values={xValues} dur={`${sprite.durationMs}ms`} repeatCount="indefinite" calcMode="discrete" />
</image>
</svg>
</div>
);
}
export function PluginGlyph({ className = "plugin-glyph" }: { className?: string }) {
return <svg className={className} width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2 3 6.5l9 4.5 9-4.5z" />
<path d="m3 12 9 4.5 9-4.5" />
<path d="m3 17.5 9 4.5 9-4.5" />
</svg>;
}

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,8 @@ const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileU
const windowsSource = readFileSync(resolve(desktopRoot, "src/windows.ts"), "utf8");
const controlCenterPreloadSource = readFileSync(resolve(desktopRoot, "control-center-preload.cjs"), "utf8");
const controlCenterRendererSource = readFileSync(resolve(desktopRoot, "src/renderer/src/main.tsx"), "utf8");
const controlCenterSharedSource = readFileSync(resolve(desktopRoot, "src/renderer/src/control-center/shared.tsx"), "utf8");
const pluginsViewSource = readFileSync(resolve(desktopRoot, "src/renderer/src/control-center/plugins-view.tsx"), "utf8");
const familiarWindowSource = readFileSync(resolve(desktopRoot, "src/familiar-window.ts"), "utf8");
const jsHostSource = readFileSync(resolve(desktopRoot, "src/plugin-js-host.ts"), "utf8");
const panelPreloadSource = readFileSync(resolve(desktopRoot, "panel-preload.cjs"), "utf8");
@ -39,15 +41,17 @@ assert.doesNotMatch(controlCenterPreloadSource, /manifestPath/);
assert.doesNotMatch(controlCenterPreloadSource, /installPath/);
assert.doesNotMatch(controlCenterPreloadSource, /familiaros:plugins-install-catalog",\s*[^)]+,\s*[^)]/);
assert.match(controlCenterRendererSource, /function PluginsView\(\)/);
assert.match(controlCenterRendererSource, /from "\.\/control-center\/plugins-view"/);
assert.match(controlCenterRendererSource, /from "\.\/control-center\/familiars-view"/);
assert.match(controlCenterRendererSource, /currentRoute === "plugins"[\s\S]*<PluginsView \/>/);
assert.doesNotMatch(controlCenterRendererSource, /OnboardingView|currentRoute === "onboarding"/);
assert.match(controlCenterRendererSource, /materializeListItemDefaults/);
assert.match(controlCenterRendererSource, /updateCatalogEntry[\s\S]*api\.updateCatalogPlugin/);
assert.match(controlCenterRendererSource, /installed\.source === "catalog"[\s\S]*updateCatalogEntry/);
assert.match(controlCenterRendererSource, /const api = resolveControlCenterApi\(apiBridge\);/);
assert.match(controlCenterRendererSource, /function resolveControlCenterApi\(/);
assert.match(controlCenterRendererSource, /bridge\.familiarOSControlCenter \?\? bridge\.openPetsControlCenter/);
assert.match(pluginsViewSource, /export function PluginsView\(\)/);
assert.match(pluginsViewSource, /materializeListItemDefaults/);
assert.match(pluginsViewSource, /updateCatalogEntry[\s\S]*api\.updateCatalogPlugin/);
assert.match(pluginsViewSource, /installed\.source === "catalog"[\s\S]*updateCatalogEntry/);
assert.match(controlCenterSharedSource, /const api = resolveControlCenterApi\(apiBridge\);/);
assert.match(controlCenterSharedSource, /function resolveControlCenterApi\(/);
assert.match(controlCenterSharedSource, /bridge\.familiarOSControlCenter \?\? bridge\.openPetsControlCenter/);
assert.match(jsHostSource, /FamiliarOSPlugin[\s\S]*register/);
assert.match(jsHostSource, /start\(sdk\)/);