import { getSettingsListTheme } from "@earendil-works/pi-coding-agent"; import { Key, matchesKey, truncateToWidth, visibleWidth, type Component, type Focusable, } from "@earendil-works/pi-tui"; import type { ChiModuleStatus, JsonObject, Scope } from "../contract"; import type { RegistryConfigView } from "./registry"; import { buildModuleSettingsItems, moduleDisplayName, settingChoiceToStoredValue, type ChiSettingItem, type SettingChoice, } from "./settings"; import { SettingsListComponent, type SettingsListItem, } from "./ui/settings-list"; type NotificationType = "info" | "warning" | "error"; export interface SettingsUIRegistry { list(): readonly ChiModuleStatus[]; getConfigViews(): readonly RegistryConfigView[]; setConfigValue( id: string, scope: Scope, key: string, value: unknown | undefined, ): Promise; } export interface ChiSettingsUIOptions { registry: SettingsUIRegistry; projectTrusted: boolean; theme: { bold(value: string): string }; notify(message: string, type?: NotificationType): void; requestRender(): void; done(): void; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } function choiceId(choice: SettingChoice): string { return choice.kind === "value" ? "value:" + choice.value : choice.kind; } function displayChoice(choice: SettingChoice, row: ChiSettingItem): string { if (choice.kind === "use-default") return `use default (${row.fallbackValue})`; if (choice.kind === "use-global") return `use global (${row.fallbackValue})`; return choice.value; } function fallbackChoice(row: ChiSettingItem): SettingChoice { return row.scope === "global" ? { kind: "use-default" } : { kind: "use-global" }; } export function createChiSettingsUI(options: ChiSettingsUIOptions): Component & Focusable { let activeTab = 0; const hasProjectOverrides = options.registry.getConfigViews().some( (view) => Object.keys(view.projectData).length > 0, ); let scope: Scope = options.projectTrusted && hasProjectOverrides ? "project" : "global"; let selectedKey: string | undefined; let activeRows: readonly ChiSettingItem[] = []; let rowById = new Map(); let settingsList: SettingsListComponent | undefined; let focused = false; const statuses = (): readonly ChiModuleStatus[] => options.registry.list(); function rebuild(preferredKey = selectedKey): void { const currentStatuses = statuses(); if (currentStatuses.length === 0) { activeRows = []; rowById = new Map(); settingsList = undefined; return; } activeTab = Math.max(0, Math.min(activeTab, currentStatuses.length - 1)); const status = currentStatuses[activeTab]; const view = options.registry.getConfigViews().find((candidate) => candidate.id === status?.id); if (status?.state !== "ready" || !view) { activeRows = []; rowById = new Map(); settingsList = undefined; return; } activeRows = buildModuleSettingsItems(view, scope); rowById = new Map(activeRows.map((row) => [row.id, row])); const items: SettingsListItem[] = activeRows.map((row) => { return { id: row.id, label: row.label, type: row.type === "string" ? "string" : "choice", value: row.type === "string" ? row.currentChoice.kind === "value" ? row.currentChoice.value : row.fallbackValue : choiceId(row.currentChoice), choices: row.type === "string" ? [] : row.choices.map((choice) => ({ value: choiceId(choice), label: displayChoice(choice, row), })), }; }); const preferredId = activeRows.find((row) => row.key === preferredKey)?.id; if (!settingsList) { settingsList = new SettingsListComponent(items, { maxVisible: Math.min(items.length + 2, 15), theme: getSettingsListTheme(), onChange: (id, newValue) => { const row = rowById.get(id); if (row?.type === "string") { void persist(row, newValue === undefined ? fallbackChoice(row) : { kind: "value", value: newValue }); return; } const choice = row?.choices.find((candidate) => choiceId(candidate) === newValue); if (row && choice) void persist(row, choice); }, onCancel: options.done, }); settingsList.focused = focused; } else { settingsList.setItems(items, preferredId); } } async function persist(row: ChiSettingItem, choice: SettingChoice): Promise { try { await options.registry.setConfigValue( row.moduleId, row.scope, row.key, settingChoiceToStoredValue(choice), ); rebuild(row.key); } catch (error) { options.notify(errorMessage(error), "error"); rebuild(row.key); } finally { options.requestRender(); } } function rememberSelection(): void { const selected = settingsList?.getSelectedItem(); if (selected) selectedKey = rowById.get(selected.id)?.key; } function moveTab(delta: number): void { const count = statuses().length; if (count === 0) return; rememberSelection(); activeTab = (activeTab + delta + count) % count; rebuild(); options.requestRender(); } function toggleScope(): void { if (!options.projectTrusted) { options.notify("Project settings require a trusted project", "error"); return; } rememberSelection(); scope = scope === "global" ? "project" : "global"; rebuild(); options.requestRender(); } function resetSelected(): void { const selected = settingsList?.getSelectedItem(); const row = selected ? rowById.get(selected.id) : undefined; if (!row) return; void persist(row, row.scope === "global" ? { kind: "use-default" } : { kind: "use-global" }); } rebuild(); return { get focused(): boolean { return focused; }, set focused(value: boolean) { focused = value; if (settingsList) settingsList.focused = value; }, render(width: number): string[] { const currentStatuses = statuses(); const current = currentStatuses[activeTab]; const tabLine = currentStatuses.map((status, index) => { const label = moduleDisplayName(status.id); return index === activeTab ? options.theme.bold("[" + label + "]") : " " + label + " "; }).join(" "); const lines = [ options.theme.bold(`Chi Configuration (${scope} scope, toggle with p)`), "", truncateToWidth("Settings (←/→): " + tabLine, width), "", ]; if (!current) lines.push("No modules registered"); else if (current.state !== "ready") lines.push("Error loading settings"); else if (activeRows.length === 0) lines.push("No parameters"); else lines.push(...(settingsList?.render(width) ?? [])); lines.push("", "p toggle scope · x reset · Enter/Space change · Esc close"); return lines.map((line) => visibleWidth(line) > width ? truncateToWidth(line, Math.max(0, width), "") : line); }, invalidate() { settingsList?.invalidate(); }, handleInput(data: string) { if (settingsList?.isEditing()) { settingsList.handleInput(data); rememberSelection(); options.requestRender(); return; } if (matchesKey(data, Key.left) || matchesKey(data, "h")) { moveTab(-1); return; } if (matchesKey(data, Key.right) || matchesKey(data, "l")) { moveTab(1); return; } if (matchesKey(data, "p")) { toggleScope(); return; } if (matchesKey(data, "x")) { resetSelected(); return; } settingsList?.handleInput(data); rememberSelection(); options.requestRender(); }, }; }