import { useState, useRef, useEffect } from "react";
import { useConfigStore } from "@/store/config-store";
import { useTranslation } from "@/lib/i18n";
import { Modal } from "@/components/ui/Modal";
import {
exportConfig,
exportConfigToDirectory,
importConfigFromFile,
parseImportFile,
saveLocalBackup,
} from "@/lib/config";
import { packageSource, type PiConfig, type PiSettings, type UpdateCheckResult } from "@/types";
import { cn } from "@/lib/utils";
import { RECOMMENDED_PACKAGES } from "@/data/recommended-packages";
import { PackageBrowser } from "./PackageBrowser";
import { SkillsPage } from "./SkillsPage";
import { CommandsPage } from "./CommandsPage";
import { PiCliSettingsPage } from "./PiCliSettingsPage";
import {
Download,
Upload,
RotateCcw,
Package,
Plus,
X,
Check,
Palette,
Type,
LayoutGrid,
Wrench,
Settings as SettingsIcon,
CloudDownload,
RefreshCw,
ZoomIn,
Sparkles,
Command,
Terminal,
Monitor,
} from "lucide-react";
type SettingsTab = "appearance" | "models" | "cli" | "skills" | "commands" | "advanced";
// Visual palette previews for the theme swatch picker (mirrors pi-desktop).
const THEME_SWATCHES: {
value: "dark" | "light" | "light/dark";
labelKey: string;
bg: string;
dots: string[];
}[] = [
{ value: "dark", labelKey: "settings.dark", bg: "linear-gradient(145deg, #14141c, #0a0a0f)", dots: ["#00d4aa", "#7c5cfc", "#2a2a35"] },
{ value: "light", labelKey: "settings.light", bg: "linear-gradient(145deg, #ffffff, #e9ecf2)", dots: ["#00b894", "#7c5cfc", "#c9ced8"] },
{ value: "light/dark", labelKey: "settings.system", bg: "linear-gradient(105deg, #0a0a0f 49.5%, #f1f2f6 50.5%)", dots: ["#00d4aa", "#7c5cfc", "#9aa0ab"] },
];
const FONT_SIZE_KEY = "pi-font-size";
const UI_ZOOM_KEY = "pi-ui-zoom";
function Card({
icon: Icon,
title,
desc,
children,
}: {
icon: typeof Palette;
title: string;
desc?: string;
children: React.ReactNode;
}) {
return (
);
}
function SettingRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
{label}
{children}
);
}
export function SettingsPage() {
const { t } = useTranslation();
const {
settings,
auth,
modelsJson,
allProviders,
updateSettings,
setTheme,
addPackage,
removePackage,
importConfig: importConfigAction,
resetToDefaults,
} = useConfigStore();
const fileInputRef = useRef(null);
const [activeTab, setActiveTab] = useState("appearance");
const [newPackage, setNewPackage] = useState("");
const [importError, setImportError] = useState("");
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [showPackageBrowser, setShowPackageBrowser] = useState(false);
const [fontSize, setFontSize] = useState(() => {
const saved = Number(localStorage.getItem(FONT_SIZE_KEY));
return saved >= 12 && saved <= 24 ? saved : 16;
});
const [uiZoom, setUiZoom] = useState(() => {
const saved = Number(localStorage.getItem(UI_ZOOM_KEY));
return saved >= 50 && saved <= 200 ? saved : 100;
});
// pi core / extensions update check (Advanced tab).
const [updateResult, setUpdateResult] = useState(null);
const [checkingUpdates, setCheckingUpdates] = useState(false);
const [updateError, setUpdateError] = useState(false);
const [applying, setApplying] = useState(false);
const [applyMessage, setApplyMessage] = useState<{ ok: number; failures: { name: string; message?: string }[] } | null>(null);
const handleCheckUpdates = async () => {
setCheckingUpdates(true);
setUpdateError(false);
setApplyMessage(null);
try {
const res = await fetch("/api/pi/check-updates");
if (!res.ok) throw new Error("check failed");
setUpdateResult(await res.json());
} catch {
setUpdateError(true);
} finally {
setCheckingUpdates(false);
}
};
// Names of everything that can be updated: pi core (routed to `pi update`
// server-side) plus every outdated extension.
const updatableNames = [
...(updateResult?.pi?.hasUpdate ? [updateResult.pi.name] : []),
...(updateResult?.extensions ?? []).filter((e) => e.hasUpdate).map((e) => e.name),
];
// One-click update, then re-check so the list reflects the new installed versions.
const handleApplyUpdates = async () => {
const names = updatableNames;
if (names.length === 0) return;
setApplying(true);
setApplyMessage(null);
try {
const res = await fetch("/api/pi/apply-updates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ names }),
});
if (!res.ok) throw new Error("apply failed");
const { results } = (await res.json()) as {
results: { name: string; success: boolean; message?: string }[];
};
const failures = results.filter((r) => !r.success).map(({ name, message }) => ({ name, message }));
setApplyMessage({ ok: results.length - failures.length, failures });
const check = await fetch("/api/pi/check-updates");
if (check.ok) setUpdateResult(await check.json());
} catch {
setUpdateError(true);
} finally {
setApplying(false);
}
};
// Apply + persist UI font size (root rem scaling).
useEffect(() => {
document.documentElement.style.fontSize = `${fontSize}px`;
localStorage.setItem(FONT_SIZE_KEY, String(fontSize));
}, [fontSize]);
// Apply + persist UI zoom (whole-interface percentage scaling).
useEffect(() => {
// Migrate away from the old browser zoom implementation. Keeping the
// legacy `zoom` property would compound scaling with the new layout-aware
// transform and make dense pages appear clipped or unexpectedly tiny.
document.documentElement.style.zoom = "";
document.documentElement.style.setProperty("--ui-zoom", String(uiZoom / 100));
localStorage.setItem(UI_ZOOM_KEY, String(uiZoom));
}, [uiZoom]);
const handleImportFromInput = async (e: React.ChangeEvent) => {
const file = e.target.files?.[0];
if (!file) return;
const text = await file.text();
const result = parseImportFile(text);
if (result) {
await importConfigAction(result);
setImportError("");
} else {
setImportError(t("settings.import_error"));
}
// Reset input so the same file can be selected again
e.target.value = "";
};
const handleImportClick = async () => {
const { config, cancelled } = await importConfigFromFile();
if (config) {
await importConfigAction(config);
setImportError("");
return;
}
if (!cancelled) {
// API unavailable or parse failed → fallback to hidden file input
fileInputRef.current?.click();
}
};
const handleExport = async () => {
const cfg: PiConfig = {
settings: settings ?? { theme: "dark", packages: [], enabledModels: [] },
auth: auth ?? {},
modelsJson: modelsJson ?? { providers: {} },
};
saveLocalBackup(cfg);
const { ok, cancelled } = await exportConfigToDirectory(cfg);
if (!ok && !cancelled) {
// API unavailable or write failed → fallback to download
exportConfig(cfg);
}
};
// ── Default model select: composite `providerId/modelId` values, but the
// settings file stores the bare model id (that's what pi expects on disk).
const modelValue = (providerId: string, modelId: string) => `${providerId}/${modelId}`;
// Only providers that are actually usable belong in the defaults lists: a saved
// API key (auth.json / models.json override) or a custom provider. A stale
// saved value is kept so the selects don't render blank.
const providerOptions = allProviders.filter(
(p) =>
p.type === "custom" ||
p.hasAuth ||
!!p.apiKey ||
!!auth?.[p.id]?.key ||
p.id === settings?.defaultProvider
);
const modelProviders = providerOptions.filter((p) => p.models.length > 0);
const scopedId = settings?.defaultProvider;
const groupedModelOptions = scopedId
? [...modelProviders.filter((p) => p.id === scopedId), ...modelProviders.filter((p) => p.id !== scopedId)]
: modelProviders;
const selectedModelValue = (() => {
const id = settings?.defaultModel;
if (!id) return "";
const dp = settings?.defaultProvider;
if (dp && allProviders.find((p) => p.id === dp)?.models.some((m) => m.id === id)) {
return modelValue(dp, id);
}
const owner = allProviders.find((p) => p.models.some((m) => m.id === id));
return owner ? modelValue(owner.id, id) : id; // stale ids display as-is
})();
const handleDefaultModelChange = (v: string) => {
if (!v) {
updateSettings({ defaultModel: undefined });
return;
}
const idx = v.indexOf("/"); // model ids may themselves contain '/'
const providerId = idx >= 0 ? v.slice(0, idx) : "";
const modelId = idx >= 0 ? v.slice(idx + 1) : v;
// Keep the provider in sync so the (provider, model) pair stays consistent.
updateSettings(providerId ? { defaultProvider: providerId, defaultModel: modelId } : { defaultModel: modelId });
};
const handleDefaultProviderChange = (v: string) => {
// Drop a default model that doesn't belong to the newly picked provider.
let clearModel = false;
if (v && settings?.defaultModel) {
const provider = allProviders.find((p) => p.id === v);
if (provider && !provider.models.some((m) => m.id === settings.defaultModel)) {
clearModel = true;
}
}
updateSettings(clearModel ? { defaultProvider: v, defaultModel: undefined } : { defaultProvider: v || undefined });
};
const tabs: { key: SettingsTab; icon: typeof Palette; label: string }[] = [
{ key: "appearance", icon: Palette, label: t("settings.appearance") },
{ key: "models", icon: LayoutGrid, label: t("settings.tab_models") },
{ key: "cli", icon: Terminal, label: "CLI 设置" },
{ key: "skills", icon: Sparkles, label: "Skills" },
{ key: "commands", icon: Command, label: "命令" },
{ key: "advanced", icon: Wrench, label: t("settings.tab_advanced") },
];
const selectCls =
"rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-white";
const installedPackages = settings?.packages ?? [];
const installedPackageIds = new Set(installedPackages.map(packageSource));
return (
{/* ── Hero ─────────────────────────────────────────── */}
pi · workspace
{t("settings.title")}
{t("settings.subtitle")}
{/* ── Tab nav ──────────────────────────────────────── */}
{/* ── Appearance ───────────────────────────────────── */}
{activeTab === "appearance" && (
)}
{/* ── Models ───────────────────────────────────────── */}
{activeTab === "models" && (
)}
{activeTab === "skills" &&
}
{activeTab === "commands" &&
}
{activeTab === "cli" &&
}
{/* ── Advanced ─────────────────────────────────────── */}
{activeTab === "advanced" && (
{updatableNames.length > 0 && (
)}
{updateError && {t("settings.updates_failed")}
}
{applyMessage && (
{applyMessage.ok > 0 && (
{t("settings.update_success", String(applyMessage.ok))}
)}
{applyMessage.failures.length > 0 && (
{t("settings.update_failed_names", String(applyMessage.failures.length), applyMessage.failures.map((failure) => failure.name).join(", "))}
{applyMessage.failures.map((failure) => failure.message && (
{failure.name}: {failure.message}
))}
)}
)}
{updateResult && (() => {
const rows = [...(updateResult.pi ? [updateResult.pi] : []), ...updateResult.extensions];
const updatable = rows.filter((r) => r.hasUpdate).length;
return (
0 ? "text-amber-400" : "text-emerald-400")}>
{updatable > 0
? t("settings.updates_summary", String(updatable))
: t("settings.updates_all_latest")}
{rows.map((r) => (
{r.name}
{updateResult.pi && r.name === updateResult.pi.name && (
core
)}
{r.latest === null ? `${r.installed} → ?` : r.hasUpdate ? `${r.installed} → ${r.latest}` : r.installed}
{r.latest === null ? (
{t("settings.updates_lookup_failed")}
) : r.hasUpdate ? (
{t("settings.update_available")}
) : (
{t("settings.updates_up_to_date")}
)}
))}
{updatable > 0 && (
{t("settings.updates_hint")}
)}
);
})()}
{installedPackages.length > 0 && (
{installedPackages.map((pkg) => {
const id = packageSource(pkg);
return (
{id}
);
})}
)}
{(settings?.packages ?? []).length === 0 && (
{t("settings.no_packages")}
)}
{/* Recommended packages — one-click install */}
{(() => {
const installed = installedPackageIds;
const recommended = RECOMMENDED_PACKAGES.filter((p) => !installed.has(p.id));
if (recommended.length === 0) return null;
return (
{t("settings.recommended_packages")}
{recommended.map((pkg) => (
{pkg.name}
{t(pkg.descKey)}
))}
);
})()}
{t("settings.add_custom_package")}
{importError && {importError}
}
)}
setShowPackageBrowser(false)}
installed={installedPackageIds}
onInstall={(id) => addPackage(id)}
/>
{/* Reset Confirm */}
setShowResetConfirm(false)}
title={t("settings.reset_title")}
>
{t("settings.reset_confirm")}
);
}