import { html, nothing, type TemplateResult } from "lit"; import { icons } from "../icons.ts"; import type { ThemeTransitionContext } from "../theme-transition.ts"; import type { ThemeMode, ThemeName } from "../theme.ts"; import type { ConfigUiHints } from "../types.ts"; import { countSensitiveConfigValues, humanize, pathKey, REDACTED_PLACEHOLDER, schemaType, type JsonSchema, } from "./config-form.shared.ts"; import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form.ts"; export type ConfigProps = { raw: string; originalRaw: string; valid: boolean | null; issues: unknown[]; loading: boolean; saving: boolean; applying: boolean; updating: boolean; connected: boolean; schema: unknown; schemaLoading: boolean; uiHints: ConfigUiHints; formMode: "form" | "raw"; showModeToggle?: boolean; formValue: Record | null; originalValue: Record | null; searchQuery: string; activeSection: string | null; activeSubsection: string | null; onRawChange: (next: string) => void; onFormModeChange: (mode: "form" | "raw") => void; onFormPatch: (path: Array, value: unknown) => void; onSearchChange: (query: string) => void; onSectionChange: (section: string | null) => void; onSubsectionChange: (section: string | null) => void; onReload: () => void; onSave: () => void; onApply: () => void; onUpdate: () => void; onOpenFile?: () => void; version: string; theme: ThemeName; themeMode: ThemeMode; setTheme: (theme: ThemeName, context?: ThemeTransitionContext) => void; setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void; gatewayUrl: string; assistantName: string; configPath?: string | null; navRootLabel?: string; includeSections?: string[]; excludeSections?: string[]; includeVirtualSections?: boolean; }; // SVG Icons for sidebar (Lucide-style) const sidebarIcons = { all: html` `, env: html` `, update: html` `, agents: html` `, auth: html` `, channels: html` `, messages: html` `, commands: html` `, hooks: html` `, skills: html` `, tools: html` `, gateway: html` `, wizard: html` `, // Additional sections meta: html` `, logging: html` `, browser: html` `, ui: html` `, models: html` `, bindings: html` `, broadcast: html` `, audio: html` `, session: html` `, cron: html` `, web: html` `, discovery: html` `, canvasHost: html` `, talk: html` `, plugins: html` `, __appearance__: html` `, default: html` `, }; // Categorised section definitions type SectionCategory = { id: string; label: string; sections: Array<{ key: string; label: string }>; }; const SECTION_CATEGORIES: SectionCategory[] = [ { id: "core", label: "Core", sections: [ { key: "env", label: "Environment" }, { key: "auth", label: "Authentication" }, { key: "update", label: "Updates" }, { key: "meta", label: "Meta" }, { key: "logging", label: "Logging" }, ], }, { id: "ai", label: "AI & Agents", sections: [ { key: "agents", label: "Agents" }, { key: "models", label: "Models" }, { key: "skills", label: "Skills" }, { key: "tools", label: "Tools" }, { key: "memory", label: "Memory" }, { key: "session", label: "Session" }, ], }, { id: "communication", label: "Communication", sections: [ { key: "channels", label: "Channels" }, { key: "messages", label: "Messages" }, { key: "broadcast", label: "Broadcast" }, { key: "talk", label: "Talk" }, { key: "audio", label: "Audio" }, ], }, { id: "automation", label: "Automation", sections: [ { key: "commands", label: "Commands" }, { key: "hooks", label: "Hooks" }, { key: "bindings", label: "Bindings" }, { key: "cron", label: "Cron" }, { key: "approvals", label: "Approvals" }, { key: "plugins", label: "Plugins" }, ], }, { id: "infrastructure", label: "Infrastructure", sections: [ { key: "gateway", label: "Gateway" }, { key: "web", label: "Web" }, { key: "browser", label: "Browser" }, { key: "nodeHost", label: "NodeHost" }, { key: "canvasHost", label: "CanvasHost" }, { key: "discovery", label: "Discovery" }, { key: "media", label: "Media" }, ], }, { id: "appearance", label: "Appearance", sections: [ { key: "__appearance__", label: "Appearance" }, { key: "ui", label: "UI" }, { key: "wizard", label: "Setup Wizard" }, ], }, ]; // Flat lookup: all categorised keys const CATEGORISED_KEYS = new Set(SECTION_CATEGORIES.flatMap((c) => c.sections.map((s) => s.key))); function getSectionIcon(key: string) { return sidebarIcons[key as keyof typeof sidebarIcons] ?? sidebarIcons.default; } function scopeSchemaSections( schema: JsonSchema | null, params: { include?: ReadonlySet | null; exclude?: ReadonlySet | null }, ): JsonSchema | null { if (!schema || schemaType(schema) !== "object" || !schema.properties) { return schema; } const include = params.include; const exclude = params.exclude; const nextProps: Record = {}; for (const [key, value] of Object.entries(schema.properties)) { if (include && include.size > 0 && !include.has(key)) { continue; } if (exclude && exclude.size > 0 && exclude.has(key)) { continue; } nextProps[key] = value; } return { ...schema, properties: nextProps }; } function scopeUnsupportedPaths( unsupportedPaths: string[], params: { include?: ReadonlySet | null; exclude?: ReadonlySet | null }, ): string[] { const include = params.include; const exclude = params.exclude; if ((!include || include.size === 0) && (!exclude || exclude.size === 0)) { return unsupportedPaths; } return unsupportedPaths.filter((entry) => { if (entry === "") { return true; } const [top] = entry.split("."); if (include && include.size > 0) { return include.has(top); } if (exclude && exclude.size > 0) { return !exclude.has(top); } return true; }); } function resolveSectionMeta( key: string, schema?: JsonSchema, ): { label: string; description?: string; } { const meta = SECTION_META[key]; if (meta) { return meta; } return { label: schema?.title ?? humanize(key), description: schema?.description ?? "", }; } function computeDiff( original: Record | null, current: Record | null, ): Array<{ path: string; from: unknown; to: unknown }> { if (!original || !current) { return []; } const changes: Array<{ path: string; from: unknown; to: unknown }> = []; function compare(orig: unknown, curr: unknown, path: string) { if (orig === curr) { return; } if (typeof orig !== typeof curr) { changes.push({ path, from: orig, to: curr }); return; } if (typeof orig !== "object" || orig === null || curr === null) { if (orig !== curr) { changes.push({ path, from: orig, to: curr }); } return; } if (Array.isArray(orig) && Array.isArray(curr)) { if (JSON.stringify(orig) !== JSON.stringify(curr)) { changes.push({ path, from: orig, to: curr }); } return; } const origObj = orig as Record; const currObj = curr as Record; const allKeys = new Set([...Object.keys(origObj), ...Object.keys(currObj)]); for (const key of allKeys) { compare(origObj[key], currObj[key], path ? `${path}.${key}` : key); } } compare(original, current, ""); return changes; } function truncateValue(value: unknown, maxLen = 40): string { let str: string; try { const json = JSON.stringify(value); str = json ?? String(value); } catch { str = String(value); } if (str.length <= maxLen) { return str; } return str.slice(0, maxLen - 3) + "..."; } function renderDiffValue(path: string, value: unknown, _uiHints: ConfigUiHints): string { return truncateValue(value); } type ThemeOption = { id: ThemeName; label: string; description: string; icon: TemplateResult }; const THEME_OPTIONS: ThemeOption[] = [ { id: "claw", label: "Claw", description: "Chroma family", icon: icons.zap }, { id: "knot", label: "Knot", description: "Knot family", icon: icons.link }, { id: "dash", label: "Dash", description: "Field family", icon: icons.barChart }, ]; function renderAppearanceSection(props: ConfigProps) { const MODE_OPTIONS: Array<{ id: ThemeMode; label: string; description: string; icon: TemplateResult; }> = [ { id: "system", label: "System", description: "Follow OS light or dark", icon: icons.monitor }, { id: "light", label: "Light", description: "Force light mode", icon: icons.sun }, { id: "dark", label: "Dark", description: "Force dark mode", icon: icons.moon }, ]; return html`

Theme

Choose a theme family.

${THEME_OPTIONS.map( (opt) => html` `, )}

Mode

Choose light or dark mode for the selected theme.

${MODE_OPTIONS.map( (opt) => html` `, )}

Connection

Gateway ${props.gatewayUrl || "-"}
Status ${props.connected ? "Connected" : "Offline"}
${ props.assistantName ? html`
Assistant ${props.assistantName}
` : nothing }
`; } interface ConfigEphemeralState { rawRevealed: boolean; envRevealed: boolean; validityDismissed: boolean; revealedSensitivePaths: Set; } function createConfigEphemeralState(): ConfigEphemeralState { return { rawRevealed: false, envRevealed: false, validityDismissed: false, revealedSensitivePaths: new Set(), }; } const cvs = createConfigEphemeralState(); function isSensitivePathRevealed(path: Array): boolean { const key = pathKey(path); return key ? cvs.revealedSensitivePaths.has(key) : false; } function toggleSensitivePathReveal(path: Array) { const key = pathKey(path); if (!key) { return; } if (cvs.revealedSensitivePaths.has(key)) { cvs.revealedSensitivePaths.delete(key); } else { cvs.revealedSensitivePaths.add(key); } } export function resetConfigViewStateForTests() { Object.assign(cvs, createConfigEphemeralState()); } export function renderConfig(props: ConfigProps) { const showModeToggle = props.showModeToggle ?? false; const validity = props.valid == null ? "unknown" : props.valid ? "valid" : "invalid"; const includeVirtualSections = props.includeVirtualSections ?? true; const include = props.includeSections?.length ? new Set(props.includeSections) : null; const exclude = props.excludeSections?.length ? new Set(props.excludeSections) : null; const rawAnalysis = analyzeConfigSchema(props.schema); const analysis = { schema: scopeSchemaSections(rawAnalysis.schema, { include, exclude }), unsupportedPaths: scopeUnsupportedPaths(rawAnalysis.unsupportedPaths, { include, exclude }), }; const formUnsafe = analysis.schema ? analysis.unsupportedPaths.length > 0 : false; const formMode = showModeToggle ? props.formMode : "form"; const envSensitiveVisible = cvs.envRevealed; // Build categorised nav from schema - only include sections that exist in the schema const schemaProps = analysis.schema?.properties ?? {}; const VIRTUAL_SECTIONS = new Set(["__appearance__"]); const visibleCategories = SECTION_CATEGORIES.map((cat) => ({ ...cat, sections: cat.sections.filter( (s) => (includeVirtualSections && VIRTUAL_SECTIONS.has(s.key)) || s.key in schemaProps, ), })).filter((cat) => cat.sections.length > 0); // Catch any schema keys not in our categories const extraSections = Object.keys(schemaProps) .filter((k) => !CATEGORISED_KEYS.has(k)) .map((k) => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1) })); const otherCategory: SectionCategory | null = extraSections.length > 0 ? { id: "other", label: "Other", sections: extraSections } : null; const isVirtualSection = includeVirtualSections && props.activeSection != null && VIRTUAL_SECTIONS.has(props.activeSection); const activeSectionSchema = props.activeSection && !isVirtualSection && analysis.schema && schemaType(analysis.schema) === "object" ? analysis.schema.properties?.[props.activeSection] : undefined; const activeSectionMeta = props.activeSection && !isVirtualSection ? resolveSectionMeta(props.activeSection, activeSectionSchema) : null; // Config subsections are always rendered as a single page per section. const effectiveSubsection = null; const topTabs = [ { key: null as string | null, label: props.navRootLabel ?? "Settings" }, ...[...visibleCategories, ...(otherCategory ? [otherCategory] : [])].flatMap((cat) => cat.sections.map((s) => ({ key: s.key, label: s.label })), ), ]; // Compute diff for showing changes (works for both form and raw modes) const diff = formMode === "form" ? computeDiff(props.originalValue, props.formValue) : []; const hasRawChanges = formMode === "raw" && props.raw !== props.originalRaw; const hasChanges = formMode === "form" ? diff.length > 0 : hasRawChanges; // Save/apply buttons require actual changes to be enabled. // Note: formUnsafe warns about unsupported schema paths but shouldn't block saving. const canSaveForm = Boolean(props.formValue) && !props.loading && Boolean(analysis.schema); const canSave = props.connected && !props.saving && hasChanges && (formMode === "raw" ? true : canSaveForm); const canApply = props.connected && !props.applying && !props.updating && hasChanges && (formMode === "raw" ? true : canSaveForm); const canUpdate = props.connected && !props.applying && !props.updating; const showAppearanceOnRoot = includeVirtualSections && formMode === "form" && props.activeSection === null && Boolean(include?.has("__appearance__")); return html`
${ hasChanges ? html` ${ formMode === "raw" ? "Unsaved changes" : `${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}` } ` : html` No changes ` }
${ props.onOpenFile ? html` ` : nothing }
${ formMode === "form" ? html` ` : nothing }
${topTabs.map( (tab) => html` `, )}
${ showModeToggle ? html`
` : nothing }
${ validity === "invalid" && !cvs.validityDismissed ? html`
Your configuration is invalid. Some settings may not work as expected.
` : nothing } ${ hasChanges && formMode === "form" ? html`
View ${diff.length} pending change${diff.length !== 1 ? "s" : ""}
${diff.map( (change) => html`
${change.path}
${renderDiffValue(change.path, change.from, props.uiHints)} ${renderDiffValue(change.path, change.to, props.uiHints)}
`, )}
` : nothing } ${ activeSectionMeta && formMode === "form" ? html`
${getSectionIcon(props.activeSection ?? "")}
${activeSectionMeta.label}
${ activeSectionMeta.description ? html`
${activeSectionMeta.description}
` : nothing }
${ props.activeSection === "env" ? html` ` : nothing }
` : nothing }
${ props.activeSection === "__appearance__" ? includeVirtualSections ? renderAppearanceSection(props) : nothing : formMode === "form" ? html` ${showAppearanceOnRoot ? renderAppearanceSection(props) : nothing} ${ props.schemaLoading ? html`
Loading schema…
` : renderConfigForm({ schema: analysis.schema, uiHints: props.uiHints, value: props.formValue, disabled: props.loading || !props.formValue, unsupportedPaths: analysis.unsupportedPaths, onPatch: props.onFormPatch, searchQuery: props.searchQuery, activeSection: props.activeSection, activeSubsection: effectiveSubsection, revealSensitive: props.activeSection === "env" ? envSensitiveVisible : false, isSensitivePathRevealed, onToggleSensitivePath: (path) => { toggleSensitivePathReveal(path); props.onRawChange(props.raw); }, }) } ` : (() => { const sensitiveCount = countSensitiveConfigValues( props.formValue, [], props.uiHints, ); const blurred = sensitiveCount > 0 && !cvs.rawRevealed; return html` ${ formUnsafe ? html`
Your config contains fields the form editor can't safely represent. Use Raw mode to edit those entries.
` : nothing } `; })() }
${ props.issues.length > 0 ? html`
${JSON.stringify(props.issues, null, 2)}
` : nothing }
`; }