/** * @file Settings.tsx * @description Provides a settings page for managing model pricing rules, notification preferences, and system information with real-time updates and actionable controls for data management and hook configuration. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. * * ## Design constraints * - Local-first: no telemetry leaves the machine unless the user configures webhooks. * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). * - Destructive flows stay behind explicit confirmation modals and server-side gates. * - Internationalization: user-visible strings belong in i18n JSON, not literals here. * * ## Remote data & SSH * Remote Data Sources let operators aggregate multiple machines. SSH entries describe * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every * scoped GET via `?sources=`. Health checks and import history surface in Settings. * * ## Internal dependencies * - `../lib/api` * - `../lib/eventBus` * - `../components/Sagi/prefs` * - `../lib/format` * - `../lib/push` * - `../components/Tip` * - `../components/ImportHistory` * - `../components/RemoteSources` * - `../components/Skeleton` * - `../components/AlertsNotifications` * - `../lib/types` * * ## Public surface * - `Settings` — exported API; see TSDoc on the symbol for behavior. * * ## Testing pointers * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. * - Server contract changes require `npm run test:server` and OpenAPI sync. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **Settings** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * ----------------------------------------------------------------------------- */ import { useEffect, useState, useCallback, useRef, useSyncExternalStore, Fragment } from "react"; import { useTranslation } from "react-i18next"; import { DollarSign, Palette, Bot, Plus, Pencil, Trash2, Check, X, RefreshCw, Database, Plug, HardDrive, AlertTriangle, RotateCcw, CheckCircle, HelpCircle, MessageSquare, XCircle, Server, Bell, BellOff, BellRing, FileDown, Eraser, Play, Zap, AlertCircle, GitBranch, ShieldCheck, ShieldAlert, ShieldX, Clock, Cpu, Globe, Wifi, Activity, Users, Layers, Coins, BarChart3, Settings as SettingsIcon, FolderOpen, Info, PiggyBank, History, ChevronLeft, ChevronRight, Cloud, } from "lucide-react"; import { api } from "../lib/api"; import { eventBus } from "../lib/eventBus"; import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents"; import { sagiPrefs } from "../components/Sagi/prefs"; import { fmt, fmtCost, getCurrentLocale } from "../lib/format"; import { subscribeToPush, unsubscribeFromPush } from "../lib/push"; import { loadNotifPrefs, saveNotifPrefs, type NotifPrefs } from "../lib/notifPrefs"; import { notify } from "../lib/notify"; import { Tip } from "../components/Tip"; import { ImportHistory } from "../components/ImportHistory"; import { RemoteSources } from "../components/RemoteSources"; import { Skeleton } from "../components/Skeleton"; import { AlertsNotifications } from "../components/AlertsNotifications"; import { AppearanceSettings } from "../components/settings/AppearanceSettings"; import { AiToolsSettings } from "../components/settings/AiToolsSettings"; import type { ModelPricing, WSMessage } from "../lib/types"; // In-page navigation for the (dense) Settings screen. Each entry maps to a // `
` rendered below; the TOC scroll-spies the active one. const SETTINGS_SECTIONS: { id: string; labelKey: string; fallback?: string; Icon: typeof DollarSign; }[] = [ { id: "appearance", labelKey: "appearance.title", Icon: Palette }, { id: "ai-tools", labelKey: "aiTools.title", Icon: Bot }, { id: "pricing", labelKey: "pricing.title", Icon: DollarSign }, { id: "hooks", labelKey: "hooks.title", Icon: Plug }, { id: "lanes", labelKey: "lanesSection.title", Icon: GitBranch }, { id: "claude-home", labelKey: "claudeHome.title", Icon: FolderOpen }, { id: "import", labelKey: "import.title", fallback: "Import", Icon: History }, { id: "remote-sources", labelKey: "remoteSources.title", fallback: "Remote Data Sources", Icon: Cloud, }, { id: "sagi", labelKey: "sagi.title", fallback: "Sagi", Icon: PiggyBank }, { id: "notifications", labelKey: "notifications.title", Icon: Bell }, { id: "alerts", labelKey: "alertsHub.title", Icon: BellRing }, { id: "data", labelKey: "data.title", Icon: Database }, { id: "about", labelKey: "about.title", Icon: Server }, ]; // ─── Helpers ─── interface EditRow { model_pattern: string; display_name: string; input_per_mtok: string; output_per_mtok: string; cache_read_per_mtok: string; cache_write_per_mtok: string; cache_write_1h_per_mtok: string; fast_input_per_mtok: string; fast_output_per_mtok: string; // Time-limited introductory rates. intro_until empty ⇒ no promo (the intro_* // values are ignored). Generic: any model can carry a promo window. intro_until: string; intro_input_per_mtok: string; intro_output_per_mtok: string; intro_cache_read_per_mtok: string; intro_cache_write_per_mtok: string; intro_cache_write_1h_per_mtok: string; } const emptyRow: EditRow = { model_pattern: "", display_name: "", input_per_mtok: "0", output_per_mtok: "0", cache_read_per_mtok: "0", cache_write_per_mtok: "0", cache_write_1h_per_mtok: "0", fast_input_per_mtok: "0", fast_output_per_mtok: "0", intro_until: "", intro_input_per_mtok: "0", intro_output_per_mtok: "0", intro_cache_read_per_mtok: "0", intro_cache_write_per_mtok: "0", intro_cache_write_1h_per_mtok: "0", }; interface SystemInfo { db: { path: string; size: number; counts: Record }; hooks: { installed: boolean; path: string; hooks: Record }; server: { version: string; uptime: number; node_version: string; platform: string; ws_connections: number; }; } function formatTimestamp(iso: string): string { const normalized = /[Zz]$/.test(iso) || /[+-]\d{2}:\d{2}$/.test(iso) ? iso : iso.replace(" ", "T") + "Z"; const d = new Date(normalized); return d.toLocaleString(getCurrentLocale(), { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function formatUptime(seconds: number): string { const d = Math.floor(seconds / 86400); const h = Math.floor((seconds % 86400) / 3600); const m = Math.floor((seconds % 3600) / 60); if (d > 0) return `${d}d ${h}h ${m}m`; if (h > 0) return `${h}h ${m}m`; return `${m}m`; } function useCountUp(end: number | null, durationMs = 1000) { const [count, setCount] = useState(0); useEffect(() => { if (end === null) { setCount(0); return; } let startTimestamp: number | null = null; let animationFrameId: number; const startValue = count; const step = (timestamp: number) => { if (!startTimestamp) startTimestamp = timestamp; const progress = Math.min((timestamp - startTimestamp) / durationMs, 1); // easeOutQuart const easeProgress = 1 - Math.pow(1 - progress, 4); setCount(startValue + (end - startValue) * easeProgress); if (progress < 1) { animationFrameId = window.requestAnimationFrame(step); } else { setCount(end); } }; animationFrameId = window.requestAnimationFrame(step); return () => window.cancelAnimationFrame(animationFrameId); }, [end, durationMs]); return count; } // ─── Toggle component ─── function Toggle({ checked, onChange, label, description, }: { checked: boolean; onChange: (v: boolean) => void; label: string; description?: string; }) { return ( ); } /** * Info popover for the Model Pricing section. Hover or focus the icon to see a * three-section explanation: how prices are applied, how pattern matching * works, and a reminder that prices must be edited manually when Anthropic * publishes new rates. All copy is i18n-driven (settings.pricing.tooltip.*). * * The popover is fixed-positioned and clamped to the viewport so it never * gets clipped by the sidebar or screen edges, mirroring the pattern used by * the Workflows stat tooltips. */ function PricingInfoTooltip() { const { t } = useTranslation("settings"); const [open, setOpen] = useState(false); const buttonRef = useRef(null); const popoverRef = useRef(null); const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 }); const positionPopover = useCallback(() => { const btn = buttonRef.current; const pop = popoverRef.current; if (!btn) return; const r = btn.getBoundingClientRect(); const w = pop?.offsetWidth ?? 320; const h = pop?.offsetHeight ?? 240; const margin = 8; let left = r.right - w; // right-align with the icon if (left < margin) left = margin; if (left + w > window.innerWidth - margin) left = window.innerWidth - w - margin; let top = r.bottom + 8; if (top + h > window.innerHeight - margin) { top = Math.max(margin, r.top - h - 8); } setPos({ left, top }); }, []); useEffect(() => { if (!open) return; positionPopover(); const onScroll = () => positionPopover(); const onResize = () => positionPopover(); window.addEventListener("scroll", onScroll, true); window.addEventListener("resize", onResize); const raf = requestAnimationFrame(positionPopover); return () => { cancelAnimationFrame(raf); window.removeEventListener("scroll", onScroll, true); window.removeEventListener("resize", onResize); }; }, [open, positionPopover]); return ( <> {open && (

{t("pricing.tooltip.title")}

{t("pricing.tooltip.howItWorks")}

{t("pricing.tooltip.howItWorksBody")}

{t("pricing.tooltip.patternsTitle")}

{t("pricing.tooltip.patternsBody")}

{t("pricing.tooltip.manualUpdates")}

{t("pricing.tooltip.manualUpdatesBody")}

{t("pricing.tooltip.apiPricing")}

{t("pricing.tooltip.apiPricingBody")}

)} ); } // ─── Main component ─── export function Settings() { const { t } = useTranslation("settings"); const [pricing, setPricing] = useState([]); const [loading, setLoading] = useState(true); const [editingPattern, setEditingPattern] = useState(null); const [editRow, setEditRow] = useState(emptyRow); const [adding, setAdding] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [totalCost, setTotalCost] = useState(null); const [sysInfo, setSysInfo] = useState(null); const [actionLoading, setActionLoading] = useState(null); const [actionResult, setActionResult] = useState<{ key: string; message: string; isError: boolean; } | null>(null); const [confirmAction, setConfirmAction] = useState(null); const [notifPrefs, setNotifPrefs] = useState(loadNotifPrefs); const [sagiEnabled, setSagiEnabled] = useState(() => sagiPrefs.getEnabled()); const setSagi = useCallback((v: boolean) => { sagiPrefs.setEnabled(v); setSagiEnabled(v); }, []); const [abandonHours, setAbandonHours] = useState("24"); const [purgeDays, setPurgeDays] = useState("90"); const [gcDryRun, setGcDryRun] = useState(false); const [claudeHome, setClaudeHomeState] = useState(""); const [claudeHomeInput, setClaudeHomeInput] = useState(""); const [claudeHomeSaving, setClaudeHomeSaving] = useState(false); const [claudeHomeError, setClaudeHomeError] = useState(null); const [activeSection, setActiveSection] = useState("pricing"); const tocRef = useRef(null); const [tocOverflow, setTocOverflow] = useState({ left: false, right: false }); const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected); const animatedTotalCost = useCountUp(totalCost); // Scroll-spy: highlight the TOC entry for the section nearest the top. useEffect(() => { if (loading) return; const els = SETTINGS_SECTIONS.map((s) => document.getElementById(s.id)).filter( (e): e is HTMLElement => !!e ); if (els.length === 0) return; const observer = new IntersectionObserver( (entries) => { const visible = entries .filter((e) => e.isIntersecting) .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top); if (visible[0]) setActiveSection(visible[0].target.id); }, { rootMargin: "-88px 0px -65% 0px", threshold: 0 } ); els.forEach((el) => observer.observe(el)); return () => observer.disconnect(); }, [loading]); // Show chevron affordances on the TOC when its chips overflow horizontally. const recomputeTocOverflow = useCallback(() => { const el = tocRef.current; if (!el) return; const left = el.scrollLeft > 1; const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 1; setTocOverflow((prev) => (prev.left === left && prev.right === right ? prev : { left, right })); }, []); useEffect(() => { if (loading) return; recomputeTocOverflow(); const el = tocRef.current; if (!el) return; const onScroll = () => recomputeTocOverflow(); el.addEventListener("scroll", onScroll, { passive: true }); const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(recomputeTocOverflow) : null; ro?.observe(el); window.addEventListener("resize", recomputeTocOverflow); return () => { el.removeEventListener("scroll", onScroll); ro?.disconnect(); window.removeEventListener("resize", recomputeTocOverflow); }; }, [loading, recomputeTocOverflow]); const scrollTocBy = useCallback((delta: number) => { tocRef.current?.scrollBy({ left: delta, behavior: "smooth" }); }, []); const load = useCallback(async () => { try { const [pricingRes, costRes, infoRes, claudeHomeRes] = await Promise.all([ api.pricing.list(), api.pricing.totalCost(), api.settings.info(), api.settings.claudeHome.get(), ]); setPricing(pricingRes.pricing); setTotalCost(costRes.total_cost); setSysInfo(infoRes); setClaudeHomeState(claudeHomeRes.claude_home); setClaudeHomeInput(claudeHomeRes.claude_home); setError(null); } catch (err) { setError(err instanceof Error ? err.message : t("messages.failedLoad")); } finally { setLoading(false); } }, [t]); useEffect(() => { load(); }, [load]); useEffect(() => { const refreshInfo = () => api.settings .info() .then(setSysInfo) .catch(() => {}); const interval = setInterval(refreshInfo, 10000); return () => clearInterval(interval); }, []); useEffect(() => { return eventBus.subscribe((msg: WSMessage) => { if ( msg.type === "session_created" || msg.type === "session_updated" || msg.type === "agent_created" || msg.type === "agent_updated" || msg.type === "new_event" ) { api.settings .info() .then(setSysInfo) .catch(() => {}); } if (isRemoteDataRefreshMessage(msg)) { load(); } }); }, [load]); useEffect(() => { if (!actionResult) return; const timeout = setTimeout(() => setActionResult(null), 5000); return () => clearTimeout(timeout); }, [actionResult]); const updateNotifPrefs = (patch: Partial) => { setNotifPrefs((prev) => { const next = { ...prev, ...patch }; saveNotifPrefs(next); return next; }); }; const requestNotifPermission = async () => { if (!("Notification" in window)) return; const perm = await Notification.requestPermission(); if (perm === "granted") { updateNotifPrefs({ enabled: true }); // A browser with no service worker / on a non-secure origin rejects here; // notifications still work through the local fallback in useNotifications, // so this must not blow up the enable flow. await subscribeToPush().catch(() => {}); } }; const startEdit = (rule: ModelPricing) => { setAdding(false); setEditingPattern(rule.model_pattern); setEditRow({ model_pattern: rule.model_pattern, display_name: rule.display_name, input_per_mtok: String(rule.input_per_mtok), output_per_mtok: String(rule.output_per_mtok), cache_read_per_mtok: String(rule.cache_read_per_mtok), cache_write_per_mtok: String(rule.cache_write_per_mtok), cache_write_1h_per_mtok: String(rule.cache_write_1h_per_mtok), fast_input_per_mtok: String(rule.fast_input_per_mtok), fast_output_per_mtok: String(rule.fast_output_per_mtok), intro_until: rule.intro_until ?? "", intro_input_per_mtok: String(rule.intro_input_per_mtok ?? 0), intro_output_per_mtok: String(rule.intro_output_per_mtok ?? 0), intro_cache_read_per_mtok: String(rule.intro_cache_read_per_mtok ?? 0), intro_cache_write_per_mtok: String(rule.intro_cache_write_per_mtok ?? 0), intro_cache_write_1h_per_mtok: String(rule.intro_cache_write_1h_per_mtok ?? 0), }); }; const startAdd = () => { setEditingPattern(null); setAdding(true); setEditRow({ ...emptyRow }); }; const cancelEdit = () => { setEditingPattern(null); setAdding(false); setError(null); }; const saveEdit = async () => { if (!editRow.model_pattern.trim() || !editRow.display_name.trim()) { setError(t("pricing.validationRequired")); return; } const introUntil = editRow.intro_until.trim(); if (introUntil && !/^\d{4}-\d{2}-\d{2}$/.test(introUntil)) { setError(t("pricing.introUntilInvalid")); return; } setSaving(true); setError(null); try { await api.pricing.upsert({ model_pattern: editRow.model_pattern.trim(), display_name: editRow.display_name.trim(), input_per_mtok: parseFloat(editRow.input_per_mtok) || 0, output_per_mtok: parseFloat(editRow.output_per_mtok) || 0, cache_read_per_mtok: parseFloat(editRow.cache_read_per_mtok) || 0, cache_write_per_mtok: parseFloat(editRow.cache_write_per_mtok) || 0, cache_write_1h_per_mtok: parseFloat(editRow.cache_write_1h_per_mtok) || 0, fast_input_per_mtok: parseFloat(editRow.fast_input_per_mtok) || 0, fast_output_per_mtok: parseFloat(editRow.fast_output_per_mtok) || 0, // Always send the intro block so the UI is authoritative for it: an // empty date clears the promo, a valid date persists the intro rates. intro_until: introUntil || null, intro_input_per_mtok: parseFloat(editRow.intro_input_per_mtok) || 0, intro_output_per_mtok: parseFloat(editRow.intro_output_per_mtok) || 0, intro_cache_read_per_mtok: parseFloat(editRow.intro_cache_read_per_mtok) || 0, intro_cache_write_per_mtok: parseFloat(editRow.intro_cache_write_per_mtok) || 0, intro_cache_write_1h_per_mtok: parseFloat(editRow.intro_cache_write_1h_per_mtok) || 0, }); setEditingPattern(null); setAdding(false); await load(); } catch (err) { setError(err instanceof Error ? err.message : t("messages.failedSave")); } finally { setSaving(false); } }; const deleteRule = async (pattern: string) => { try { await api.pricing.delete(pattern); await load(); } catch (err) { setError(err instanceof Error ? err.message : t("messages.failedDelete")); } }; const runAction = async (key: string, fn: () => Promise) => { setActionLoading(key); setActionResult(null); setConfirmAction(null); try { const message = await fn(); setActionResult({ key, message, isError: false }); await load(); } catch (err) { setActionResult({ key, message: t("messages.actionFailed", { message: err instanceof Error ? err.message : t("messages.unknownError"), }), isError: true, }); } finally { setActionLoading(null); } }; const handleClearData = () => runAction("clear", async () => { const res = await api.settings.clearData(); const total = Object.values(res.cleared).reduce((s, n) => s + n, 0); return t("danger.clearedResult", { count: total }); }); const handleReinstallHooks = () => runAction("hooks", async () => { const res = await api.settings.reinstallHooks(); return res.ok ? t("hooks.success") : t("hooks.failed"); }); const handleGc = () => runAction("gc", async () => { const res = await api.settings.gc({ dryRun: gcDryRun }); return t("lanesSection.gcResult", { reaped: res.reaped.length, capped: res.capped.length }); }); const handleResetPricing = () => runAction("reset-pricing", async () => { const res = await api.settings.resetPricing(); return t("pricing.resetResult", { count: res.pricing.length }); }); const handleCleanup = () => runAction("cleanup", async () => { const params: { abandon_hours?: number; purge_days?: number } = {}; const ah = parseFloat(abandonHours); const pd = parseFloat(purgeDays); if (ah > 0) params.abandon_hours = ah; if (pd > 0) params.purge_days = pd; const res = await api.settings.cleanup(params); const parts = []; if (res.abandoned > 0) parts.push(`${res.abandoned}${t("data.abandonedResult")}`); if (res.purged_sessions > 0) parts.push( `${res.purged_sessions}${t("data.purgedResult", { events: res.purged_events, agents: res.purged_agents })}` ); return parts.length > 0 ? parts.join(". ") : t("data.nothingToClean"); }); const handleSaveClaudeHome = async () => { if (claudeHomeInput === claudeHome) return; setClaudeHomeSaving(true); setClaudeHomeError(null); try { const res = await api.settings.claudeHome.set(claudeHomeInput); setClaudeHomeState(res.claude_home); setClaudeHomeInput(res.claude_home); } catch (err) { setClaudeHomeError(err instanceof Error ? err.message : t("claudeHome.saveFailed")); } finally { setClaudeHomeSaving(false); } }; const lastUpdated = pricing.length > 0 ? pricing.reduce( (latest, p) => (p.updated_at > latest ? p.updated_at : latest), pricing[0]!.updated_at ) : null; const isEditing = editingPattern !== null || adding; const renderEditCells = () => ( <> setEditRow((r) => ({ ...r, model_pattern: e.target.value }))} placeholder={t("pricing.patternPlaceholder")} disabled={editingPattern !== null} className="input w-full text-sm font-mono disabled:opacity-50" autoFocus={adding} /> setEditRow((r) => ({ ...r, display_name: e.target.value }))} placeholder={t("pricing.namePlaceholder")} className="input w-full text-sm" /> setEditRow((r) => ({ ...r, input_per_mtok: e.target.value }))} className="input w-full text-sm text-right font-mono" /> setEditRow((r) => ({ ...r, output_per_mtok: e.target.value }))} className="input w-full text-sm text-right font-mono" /> setEditRow((r) => ({ ...r, cache_read_per_mtok: e.target.value }))} className="input w-full text-sm text-right font-mono" /> setEditRow((r) => ({ ...r, cache_write_per_mtok: e.target.value }))} className="input w-full text-sm text-right font-mono" /> setEditRow((r) => ({ ...r, cache_write_1h_per_mtok: e.target.value }))} className="input w-full text-sm text-right font-mono" /> setEditRow((r) => ({ ...r, fast_input_per_mtok: e.target.value }))} className="input w-full text-sm text-right font-mono" /> setEditRow((r) => ({ ...r, fast_output_per_mtok: e.target.value }))} className="input w-full text-sm text-right font-mono" />
); // Second edit row: the time-limited introductory-rate block. Rendered under // the standard-rate cells whenever a row is being edited/added. Leaving the // date empty means "no promo" — the rate inputs are then ignored. This is the // ONLY place intro rates are entered, and it works for any model pattern (not // just Sonnet 5), so a future model with a launch promo needs no code change. const introField = (key: keyof EditRow, labelKey: string, opts: { date?: boolean } = {}) => ( ); const renderIntroEditRow = () => (
{t("pricing.introRatesTitle")} {t("pricing.introRatesHint")}
{introField("intro_until", "pricing.introUntil", { date: true })} {introField("intro_input_per_mtok", "common:token.input")} {introField("intro_output_per_mtok", "common:token.output")} {introField("intro_cache_read_per_mtok", "common:token.cacheRead")} {introField("intro_cache_write_per_mtok", "pricing.cacheWrite5m")} {introField("intro_cache_write_1h_per_mtok", "pricing.cacheWrite1h")}
); const actionBanner = (keys: string[]) => { const match = actionResult && keys.includes(actionResult.key) ? actionResult : null; if (!match) return null; return (
{match.message}
); }; if (loading) { return (
{Array.from({ length: 3 }).map((_, i) => (
))}
); } return (
{/* Header */}

{t("title")}

{wsConnected ? ( {t("common:live")} ) : ( {t("common:offline")} )}

{t("subtitle")}

{t("exportData")}
{/* In-page section navigation - Settings is dense, so this TOC jumps to and scroll-spies each section. */} {/* Cost summary card */}

{t("common:cost.totalEstimatedCost")}

{totalCost !== null ? fmtCost(animatedTotalCost) : "$-.--"}

{t("acrossSessions")}

{t("basedOnUsage")}

{/* ─── MODEL PRICING ─── */}

{t("pricing.title")}

{t("pricing.description")}

{error && (
{error}
)} {actionBanner(["reset-pricing"])}
{pricing.map((rule) => editingPattern === rule.model_pattern ? ( {renderEditCells()} {renderIntroEditRow()} ) : ( ) )} {adding && ( <> {renderEditCells()} {renderIntroEditRow()} )}
{t("pricing.pattern")} {t("common:cost.model")} {t("common:token.input")} {t("common:token.output")} {t("common:token.cacheRead")} {t("pricing.cacheWrite5m")} {t("pricing.cacheWrite1h")} {t("pricing.fastInput")} {t("pricing.fastOutput")} {t("common:actions")}
{rule.model_pattern} {rule.display_name} {rule.intro_until && (
{t("pricing.introNote", { input: rule.intro_input_per_mtok, output: rule.intro_output_per_mtok, until: rule.intro_until, })}
)}
${rule.input_per_mtok} {rule.intro_until && ( ${rule.intro_input_per_mtok} )} ${rule.output_per_mtok} {rule.intro_until && ( ${rule.intro_output_per_mtok} )} ${rule.cache_read_per_mtok} {rule.intro_until && ( ${rule.intro_cache_read_per_mtok} )} ${rule.cache_write_per_mtok} {rule.intro_until && ( ${rule.intro_cache_write_per_mtok} )} ${rule.cache_write_1h_per_mtok} {rule.intro_until && ( ${rule.intro_cache_write_1h_per_mtok} )} {rule.fast_input_per_mtok ? `$${rule.fast_input_per_mtok}` : "-"} {rule.fast_output_per_mtok ? `$${rule.fast_output_per_mtok}` : "-"}
{lastUpdated && (

{t("pricing.lastUpdated")} {formatTimestamp(lastUpdated)}

)}
{/* ─── HOOK CONFIGURATION ─── */}

{t("hooks.title")}

{t("hooks.description")}

{sysInfo?.hooks.installed ? ( {t("hooks.allInstalled")} ) : ( {t("hooks.incomplete")} )}
{actionBanner(["hooks"])} {sysInfo && ( <>
{Object.entries(sysInfo.hooks.hooks).map(([hook, active]) => (
{active ? ( ) : ( )} {hook}
))}

{sysInfo.hooks.path}

)}
{/* ─── LANES ─── */}

{t("lanesSection.title")}

{t("lanesSection.description")}

{actionBanner(["gc"])}
{/* ─── CLAUDE HOME ─── */}

{t("claudeHome.title")}

{t("claudeHome.description")}

{t("cursorPathsNote")}

{ setClaudeHomeInput(e.target.value); setClaudeHomeError(null); }} className="flex-1 bg-surface-4 border border-surface-3 rounded-lg px-3 py-2 text-sm text-fg-secondary font-mono focus:outline-none focus:border-violet-500/50" placeholder={t("claudeHome.placeholder")} />
{claudeHomeError &&

{claudeHomeError}

} {claudeHome && (

{t("claudeHome.current")} {claudeHome}

)}
{/* ─── IMPORT HISTORY ─── */}
{/* ─── REMOTE DATA SOURCES ─── */}
{/* ─── SAGI COMPANION ─── */}

🦸 {t("sagi.title", "Sagi companion")}

{t("sagi.description", "A floating mascot that reacts to your live sessions.")}

🦸
{/* ─── NOTIFICATIONS ─── */}

{t("notifications.title")}

{t("notifications.description")}

{notifPrefs.enabled ? ( ) : ( )}
{ if (v) { if ("Notification" in window && Notification.permission !== "granted") { requestNotifPermission(); } else { updateNotifPrefs({ enabled: true }); await subscribeToPush(); } } else { updateNotifPrefs({ enabled: false }); await unsubscribeFromPush(); } }} label={t("notifications.enable")} />
{"Notification" in window && ( {Notification.permission === "granted" ? ( ) : Notification.permission === "denied" ? ( ) : ( )} {Notification.permission === "granted" ? t("notifications.granted") : Notification.permission === "denied" ? t("notifications.blocked") : t("notifications.required")} )}
{notifPrefs.enabled && (

{t("notifications.notifyWhen")}

updateNotifPrefs({ onAwaitingInput: v })} label={t("notifications.awaitingInput")} />
updateNotifPrefs({ onNewSession: v })} label={t("notifications.newSession")} />
updateNotifPrefs({ onTurnComplete: v })} label={t("notifications.turnComplete")} />
updateNotifPrefs({ onSessionComplete: v })} label={t("notifications.sessionComplete")} />
updateNotifPrefs({ onSessionError: v })} label={t("notifications.sessionError")} />
updateNotifPrefs({ onSubagentSpawn: v })} label={t("notifications.subagentSpawned")} />
{actionBanner(["notif-test"])}
)} {!notifPrefs.enabled && (
{t("notifications.disabledInfo")}
)}
{/* ─── ALERTS ─── */}

{t("alertsHub.title")}

{t("alertsHub.description")}

{/* ─── DATA MANAGEMENT ─── */}

{t("data.title")}

{t("data.description")}

{t("data.dbOverview")}

{sysInfo && (
{sysInfo.db.path}
)}
{sysInfo ? (
{(() => { const tableIcons: Record = { sessions: , agents: , events: , token_usage: , model_pricing: ( ), }; const tableLabels: Record = { sessions: t("tables.sessions"), agents: t("tables.agents"), events: t("tables.events"), token_usage: t("tables.sessionsWithCost"), model_pricing: t("tables.pricingRules"), }; const tableColors: Record = { sessions: "border-blue-600/20", agents: "border-status-success/20", events: "border-violet-500/20", token_usage: "border-status-warning/20", model_pricing: "border-cyan-500/20", }; return Object.entries(sysInfo.db.counts).map(([table, count]) => (
{tableIcons[table] || }

{tableLabels[table] || table.replace(/_/g, " ")}

{fmt(count)}

)); })()}

{t("data.dbSize")}

{formatBytes(sysInfo.db.size)}

) : (

{t("data.loadingDb")}

)}
{/* Session Cleanup */}

{t("data.sessionCleanup")}

{t("data.cleanupDesc")}

setAbandonHours(e.target.value)} className="input w-20 text-sm text-right font-mono" /> {t("common:hours")}
setPurgeDays(e.target.value)} className="input w-20 text-sm text-right font-mono" /> {t("common:days")}
{actionBanner(["cleanup"])}
{/* Danger zone */}

{t("danger.title")}

{t("danger.description")}

{confirmAction === "clear" ? (
{t("danger.warning")}
) : ( )} {actionBanner(["clear"])}
{/* ─── ABOUT ─── */}

{t("about.title")}

{t("about.description")}

{sysInfo ? (

{t("about.release")}

v{sysInfo.server.version}

{sysInfo.server.version !== __APP_VERSION__ && (

{t("about.uiBuild", { version: __APP_VERSION__ })}

)}

{t("about.uptime")}

{formatUptime(sysInfo.server.uptime)}

{t("about.nodejs")}

{sysInfo.server.node_version}

{t("about.platform")}

{sysInfo.server.platform}

{t("about.wsClients")}

{sysInfo.server.ws_connections}

) : (

{t("about.loadingInfo")}

)}
); }