/** * Interactive TUI Settings Overlay for pi-crew. * Mirrors Pi's built-in /settings selector: tab bar, settings list with * label/value alignment, inline toggle, select submenu, and text input. */ import * as piTui from "@earendil-works/pi-tui"; import { truncateToWidth, visibleWidth } from "../utils/visual.ts"; import { keyOf, matchesKey } from "./key-utils.ts"; import { CURSOR, canopyLine, formatHint, overflowHint, RAIL, railLine } from "./rail.ts"; import type { CrewTheme } from "./theme-adapter.ts"; import { discoverPiThemes, getActivePiTheme } from "./theme-discovery.ts"; // --------------------------------------------------------------------------- // pi-tui CURSOR_MARKER โ€” defensive resolve (L8, policy W4/G22) // --------------------------------------------------------------------------- /** Resolve pi-tui's zero-width IME cursor anchor (APC `ESC _ pi:c BEL`) from * a pi-tui namespace object. Modeled on the hyperlink() typeof-guard at * widget/widget-renderer.ts:118-128: the peer range is `*`, so a host * running a pi-tui build without the export must still render instead of * failing the named import at link time. Non-string exports are treated as * missing (contract drift, not a marker). Exported for the fallback pin in * test/unit/ui/settings-overlay-cursor-marker.test.ts. */ export function resolveCursorMarker(mod: unknown): string { const candidate = (mod as { CURSOR_MARKER?: unknown }).CURSOR_MARKER; return typeof candidate === "string" ? candidate : ""; } /** "" on hosts whose pi-tui predates CURSOR_MARKER โ€” text-input rows then * render without the IME anchor (the marker is an enhancement, never a * layout dependency: zero-width, stripped by visibleWidth). */ const CURSOR_MARKER = resolveCursorMarker(piTui); // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type SettingType = "boolean" | "enum" | "number" | "string" | "agent" | "action"; export interface SettingDef { id: string; label: string; description?: string; type: SettingType; /** For enum: list of allowed values */ values?: string[]; /** Tab grouping */ tab: string; /** For type="action": identifier dispatched to onAction (e.g. "piTheme"). */ action?: string; } export interface SettingsOverlayCallbacks { onChange: (id: string, value: unknown) => void; onClose: () => void; /** Dispatched for type="action" settings (e.g. Pi theme switch, which * writes to settings.json rather than pi-crew config). Optional. */ onAction?: (action: string, value: unknown) => void; } interface TabDef { id: string; label: string; icon: string; } // --------------------------------------------------------------------------- // Setting Definitions โ€” mirrors config schema // --------------------------------------------------------------------------- const TABS: TabDef[] = [ { id: "runtime", label: "Runtime", icon: "โš™" }, { id: "limits", label: "Limits", icon: "๐Ÿ“" }, { id: "agents", label: "Agents", icon: "๐Ÿค–" }, { id: "ui", label: "UI", icon: "๐Ÿ–ฅ" }, { id: "themes", label: "Themes", icon: "๐ŸŽจ" }, { id: "autonomous", label: "Auto", icon: "๐Ÿš€" }, { id: "advanced", label: "Advanced", icon: "๐Ÿ”ง" }, ]; const SETTINGS: SettingDef[] = [ // Runtime { id: "runtime.mode", label: "Runtime Mode", type: "enum", values: ["auto", "scaffold", "child-process", "live-session"], tab: "runtime", description: "How workers execute. 'auto' picks best available. 'scaffold' = dry-run.", }, { id: "runtime.maxTurns", label: "Max Turns", type: "number", tab: "runtime", description: "Maximum agent turns per task.", }, { id: "runtime.graceTurns", label: "Grace Turns", type: "number", tab: "runtime", description: "Extra turns allowed after completion.", }, { id: "runtime.inheritContext", label: "Inherit Context", type: "boolean", tab: "runtime", description: "Pass parent conversation context to workers.", }, { id: "runtime.promptMode", label: "Prompt Mode", type: "enum", values: ["compact", "full", "minimal"], tab: "runtime", description: "How much prompt detail to send to workers.", }, { id: "runtime.completionMutationGuard", label: "Mutation Guard", type: "enum", values: ["off", "warn", "block"], tab: "runtime", description: "Guard against tasks completing without file mutations.", }, { id: "runtime.isolationPolicy", label: "Isolation Policy", type: "enum", values: ["workspace", "none"], tab: "runtime", description: "Workspace isolation between agents.", }, // Limits { id: "limits.maxConcurrentWorkers", label: "Max Concurrent", type: "number", tab: "limits", description: "Max number of workers running simultaneously.", }, { id: "limits.maxTaskDepth", label: "Max Task Depth", type: "number", tab: "limits", description: "Maximum depth of nested task spawning.", }, { id: "limits.maxRunMinutes", label: "Max Run Minutes", type: "number", tab: "limits", description: "Maximum total run time in minutes.", }, { id: "limits.maxRetriesPerTask", label: "Max Retries", type: "number", tab: "limits", description: "Max retry attempts per failed task.", }, { id: "limits.maxTasksPerRun", label: "Max Tasks", type: "number", tab: "limits", description: "Maximum number of tasks per run.", }, { id: "limits.heartbeatStaleMs", label: "Heartbeat Stale", type: "number", tab: "limits", description: "Milliseconds before a worker is considered stale.", }, // Agents { id: "agents.overrides", label: "Agent Model Overrides", type: "agent", tab: "agents", description: "Model and thinking overrides per agent role.", }, { id: "agents.disableBuiltins", label: "Disable Builtins", type: "boolean", tab: "agents", description: "Disable built-in agent definitions.", }, // UI { id: "ui.showModel", label: "Show Model", type: "boolean", tab: "ui", description: "Show model name in widget/dashboard.", }, { id: "ui.showTokens", label: "Show Tokens", type: "boolean", tab: "ui", description: "Show token counts in dashboard.", }, { id: "ui.showTools", label: "Show Tools", type: "boolean", tab: "ui", description: "Show tool usage in dashboard.", }, { id: "ui.dashboardPlacement", label: "Dashboard Placement", type: "enum", values: ["center", "right"], tab: "ui", description: "Where to place the dashboard overlay.", }, { id: "ui.dashboardWidth", label: "Dashboard Width", type: "number", tab: "ui", description: "Dashboard width as percentage or pixels.", }, { id: "ui.autoOpenDashboard", label: "Auto Open Dashboard", type: "boolean", tab: "ui", description: "Auto-open dashboard when a run starts.", }, { id: "ui.widgetPlacement", label: "Widget Placement", type: "enum", values: ["bottom", "hidden"], tab: "ui", description: "Where to place the crew widget.", }, // โ”€โ”€ Themes tab โ”€โ”€ { id: "__piTheme__", label: "Pi UI Theme", type: "action", action: "piTheme", values: discoverPiThemes().map((t) => t.name), tab: "themes", description: "Overall terminal theme. Switches live (no restart). Currently: " + (getActivePiTheme() ?? "dark (default)"), }, // Autonomous { id: "autonomous.enabled", label: "Enabled", type: "boolean", tab: "autonomous", description: "Enable autonomous pi-crew delegation.", }, { id: "autonomous.injectPolicy", label: "Inject Policy", type: "boolean", tab: "autonomous", description: "Inject delegation policy into agent context.", }, { id: "autonomous.preferAsyncForLongTasks", label: "Prefer Async", type: "boolean", tab: "autonomous", description: "Prefer async execution for long tasks.", }, { id: "autonomous.allowWorktreeSuggestion", label: "Allow Worktree", type: "boolean", tab: "autonomous", description: "Allow suggesting worktree isolation.", }, // Advanced { id: "executeWorkers", label: "Execute Workers", type: "boolean", tab: "advanced", description: "Allow real child Pi workers. false = scaffold only.", }, { id: "asyncByDefault", label: "Async By Default", type: "boolean", tab: "advanced", description: "Run teams asynchronously by default.", }, { id: "notifierIntervalMs", label: "Notifier Interval", type: "number", tab: "advanced", description: "Async run notifier check interval in ms.", }, { id: "reliability.autoRetry", label: "Auto Retry", type: "boolean", tab: "advanced", description: "Automatically retry failed tasks.", }, { id: "reliability.autoRecover", label: "Auto Recover", type: "boolean", tab: "advanced", description: "Automatically recover from crashes.", }, { id: "reliability.cleanupOrphanedTempDirs", label: "Cleanup Orphaned Temp Dirs", type: "boolean", tab: "advanced", description: "Remove /tmp/pi-crew-* directories after reconciliation (1h age threshold).", }, { id: "reliability.loopGuard", label: "Tool Loop Guard", type: "boolean", tab: "advanced", description: "Warn at 3 / block at 5 identical consecutive read-only tool results (ARCH-1).", }, { id: "reliability.perWriteValidation", label: "Per-Write Validation", type: "boolean", tab: "advanced", description: "Validate state writes as they are made (default on).", }, { id: "reliability.ambientStatusInjection", label: "Ambient Status Injection", type: "boolean", tab: "advanced", description: "Inject a compact crew-status note into context on every LLM call while runs are in-flight.", }, { id: "reliability.forcePreflight", label: "Force Preflight (audit override)", type: "boolean", tab: "advanced", description: "Skip usage-threshold BLOCK/WARN preflight. Default false (enforce). Audit/debug only.", }, { id: "reliability.scopeModels", label: "Scope Models (F7)", type: "boolean", tab: "advanced", description: "Enforce user enabledModels allowlist on subagent model choices. Default false.", }, { id: "telemetry.enabled", label: "Telemetry", type: "boolean", tab: "advanced", description: "Enable telemetry collection.", }, { id: "notifications.enabled", label: "Notifications", type: "boolean", tab: "advanced", description: "Enable run notifications.", }, ]; // --------------------------------------------------------------------------- // Effective defaults โ€” values used when config key is not set // --------------------------------------------------------------------------- const EFFECTIVE_DEFAULTS: Record = { "runtime.mode": "auto", "runtime.maxTurns": 10000, "runtime.graceTurns": 5, "runtime.inheritContext": true, "runtime.promptMode": "replace", "runtime.completionMutationGuard": "warn", "runtime.isolationPolicy": undefined, "limits.maxConcurrentWorkers": 1024, "limits.maxTaskDepth": 100, "limits.maxRunMinutes": 1440, "limits.maxRetriesPerTask": 100, "limits.maxTasksPerRun": 10000, "limits.heartbeatStaleMs": 86400000, "agents.disableBuiltins": false, "ui.showModel": true, "ui.showTokens": true, "ui.showTools": true, // L3 (2026-10-07): mirrors DEFAULT_UI.dashboardPlacement = "right" // (defaults.ts) โ€” G17: duplicated EFFECTIVE_DEFAULTS maps must track the // canonical default or the overlay shows a stale value to unset keys. "ui.dashboardPlacement": "right", "ui.dashboardWidth": 72, "ui.autoOpenDashboard": false, // G17 sync (2026-09-10 review): canonical DEFAULT_UI.widgetPlacement = // "bottom" (defaults.ts / install.mjs / project-init) โ€” was drifted // "aboveEditor" in this duplicated EFFECTIVE_DEFAULTS map. "ui.widgetPlacement": "bottom", "autonomous.enabled": true, "autonomous.injectPolicy": true, "autonomous.preferAsyncForLongTasks": false, "autonomous.allowWorktreeSuggestion": true, executeWorkers: true, asyncByDefault: false, notifierIntervalMs: 5000, "reliability.autoRetry": false, "reliability.autoRecover": false, "reliability.cleanupOrphanedTempDirs": true, "reliability.loopGuard": true, "reliability.perWriteValidation": true, "reliability.ambientStatusInjection": true, "reliability.forcePreflight": false, "reliability.scopeModels": false, "telemetry.enabled": false, "notifications.enabled": false, }; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Visible character width โ€” delegated to the Unicode-aware shared util (M-11 fix). */ /** Pad string to exactly maxVis visible width. */ function padToWidth(text: string, maxVis: number, padChar = " "): string { const vw = visibleWidth(text); if (vw >= maxVis) return truncateToWidth(text, maxVis); return text + padChar.repeat(maxVis - vw); } function formatValue(value: unknown, id: string): string { if (value === undefined || value === null) { const def = EFFECTIVE_DEFAULTS[id]; if (def !== undefined) return `${String(def)}`; return ""; } if (typeof value === "boolean") return value ? "true" : "false"; if (typeof value === "number") return String(value); if (typeof value === "object") return JSON.stringify(value); return String(value); } function getNestedValue(obj: Record, path: string): unknown { const keys = path.split("."); let current: unknown = obj; for (const key of keys) { if (!current || typeof current !== "object") return undefined; current = (current as Record)[key]; } return current; } function isExplicitlySet(config: Record, id: string): boolean { return getNestedValue(config, id) !== undefined; } /** Resolve the live current value for a setting, including special-case IDs * (e.g. __piTheme__ which lives in Pi's settings.json, not pi-crew config). */ function currentValueFor(config: Record, id: string): unknown { if (id === "__piTheme__") return getActivePiTheme() ?? "dark"; return getNestedValue(config, id); } // --------------------------------------------------------------------------- // Submenu: Select from list (enum picker) // --------------------------------------------------------------------------- class SelectSubmenu { public focused = false; private selectedIndex = 0; private scrollOffset = 0; private readonly maxVisible = 14; private readonly items: string[]; private readonly theme: CrewTheme; private readonly onSelect: (value: string) => void; private readonly onCancel: () => void; private readonly title: string; private readonly description: string; constructor( title: string, description: string, options: string[], current: string, theme: CrewTheme, onSelect: (value: string) => void, onCancel: () => void, ) { this.title = title; this.description = description; this.items = options; this.theme = theme; this.onSelect = onSelect; this.onCancel = onCancel; this.selectedIndex = Math.max(0, options.indexOf(current)); // Center the cursor in the visible window on open. this.scrollOffset = Math.max(0, this.selectedIndex - Math.floor(this.maxVisible / 2)); } invalidate(): void { /* no-op */ } private ensureVisible(): void { if (this.selectedIndex < this.scrollOffset) { this.scrollOffset = this.selectedIndex; } else if (this.selectedIndex >= this.scrollOffset + this.maxVisible) { this.scrollOffset = this.selectedIndex - this.maxVisible + 1; } } render(width: number): string[] { void width; const lines: string[] = []; lines.push(this.theme.bold(this.theme.fg("accent", this.title))); if (this.description) { lines.push(this.theme.fg("muted", this.description)); } lines.push(""); const start = this.scrollOffset; const end = Math.min(start + this.maxVisible, this.items.length); const needsScroll = this.items.length > this.maxVisible; if (needsScroll && start > 0) { lines.push(` ${overflowHint(start, 0, this.theme)}`); } for (let i = start; i < end; i++) { const isSelected = i === this.selectedIndex; // `โ€บ` is the ONLY selection marker (RAIL): `โ†’`-as-cursor is retired. const prefix = isSelected ? ` ${CURSOR} ` : " "; const line = `${prefix}${this.items[i]}`; lines.push(isSelected ? (this.theme.inverse?.(line) ?? line) : line); } if (needsScroll && end < this.items.length) { lines.push(` ${overflowHint(0, this.items.length - end, this.theme)}`); } lines.push(""); lines.push( this.theme.fg( "dim", formatHint([ [["up", "down"], "navigate"], ["enter", "to select"], ["esc", "to go back"], ]), ), ); return lines; } handleInput(data: string): void { // Use keyOf() so that upstream Arrow / Esc encodings (legacy CSI, // application cursor mode, Kitty protocol) all dispatch correctly. // See key-utils.ts for details. const k = keyOf(data); if (k === "up" || k === "k") { this.selectedIndex = (this.selectedIndex - 1 + this.items.length) % this.items.length; this.ensureVisible(); return; } if (k === "down" || k === "j") { this.selectedIndex = (this.selectedIndex + 1) % this.items.length; this.ensureVisible(); return; } if (k === "enter") { this.onSelect(this.items[this.selectedIndex]!); return; } if (k === "escape" || k === "q") { this.onCancel(); return; } } } // --------------------------------------------------------------------------- // Submenu: Text input (number / string) // --------------------------------------------------------------------------- class TextinputSubmenu { public focused = false; private buffer = ""; private readonly title: string; private readonly description: string; private readonly theme: CrewTheme; private readonly onSubmit: (value: string) => void; private readonly onCancel: () => void; constructor( title: string, description: string, initialValue: string, theme: CrewTheme, onSubmit: (value: string) => void, onCancel: () => void, ) { this.title = title; this.description = description; this.buffer = initialValue; this.theme = theme; this.onSubmit = onSubmit; this.onCancel = onCancel; } invalidate(): void { /* no-op */ } render(width: number): string[] { const lines: string[] = []; lines.push(this.theme.bold(this.theme.fg("accent", this.title))); if (this.description) { lines.push(this.theme.fg("muted", this.description)); } lines.push(""); // R3-6 (IME): the marker before the fake `โ–ˆ` cursor is pi-tui's // zero-width CURSOR_MARKER โ€” the host TUI strips it and positions the // hardware cursor there so the IME candidate window tracks the buffer // end (this submenu's buffer appends/erases at end only). Emitted // unconditionally: the row renders only while this input owns keys. lines.push(` ${this.buffer}${CURSOR_MARKER}โ–ˆ`); lines.push(""); lines.push( this.theme.fg( "dim", formatHint([ ["enter", "to save"], ["esc", "to cancel"], ["clear", "to unset"], ]), ), ); return lines; } handleInput(data: string): void { const k = keyOf(data); if (k === "enter") { this.onSubmit(this.buffer); return; } if (k === "escape" || k === "q") { this.onCancel(); return; } // Backspace if (matchesKey(data, "backspace") || k === "backspace") { this.buffer = this.buffer.slice(0, -1); return; } // Printable character (accept any non-control character, including Unicode) if (data.length === 1 && data >= " ") { this.buffer += data; return; } } } // --------------------------------------------------------------------------- // Submenu: Agent overrides editor // --------------------------------------------------------------------------- class AgentOverridesSubmenu { public focused = false; private readonly overrides: Record; private readonly theme: CrewTheme; private readonly agents: string[]; private selectedIndex = 0; private editField: "model" | "thinking" | null = null; private editBuffer = ""; private readonly onCancel: () => void; constructor( config: Record, theme: CrewTheme, onApply: (overrides: Record) => void, onCancel: () => void, ) { this.theme = theme; this.onCancel = onCancel; const existing = (config.agents as Record)?.overrides as | Record | undefined; this.overrides = existing ? structuredClone(existing) : {}; this.agents = [ "explorer", "planner", "analyst", "critic", "executor", "reviewer", "security-reviewer", "test-engineer", "verifier", "cold-verifier", "writer", ]; } invalidate(): void { /* no-op */ } render(width: number): string[] { if (this.editField) return this.renderEdit(width); const lines: string[] = []; lines.push(this.theme.bold(this.theme.fg("accent", "Agent Model Overrides"))); lines.push(""); const labelWidth = 22; for (const [i, agent] of this.agents.entries()) { const isSelected = i === this.selectedIndex; const ov = this.overrides[agent]; const model = ov?.model ?? ""; const thinking = ov?.thinking ?? ""; const label = padToWidth(agent, labelWidth); const modelPart = model ? `model=${model}` : ""; const thinkingPart = thinking ? `thinking=${thinking}` : ""; const valueParts = [modelPart, thinkingPart].filter(Boolean).join(", "); const valueText = valueParts || this.theme.fg("dim", "(default)"); const prefix = isSelected ? ` ${CURSOR} ` : " "; const line = `${prefix}${label} ${valueText}`; lines.push( isSelected ? (this.theme.inverse?.(truncateToWidth(line, width - 2)) ?? truncateToWidth(line, width - 2)) : truncateToWidth(line, width - 2), ); } lines.push(""); lines.push( this.theme.fg( "dim", formatHint([ ["enter", "to edit model"], ["e", "to edit thinking"], ["esc", "to go back"], ]), ), ); return lines; } private renderEdit(width: number): string[] { // GUARD (M4 ยง4): an out-of-range cursor used to print `Edit undefined model`. const agent = this.agents[this.selectedIndex] ?? "?"; const field = this.editField === "model" ? "model" : "thinking"; const lines: string[] = []; lines.push(this.theme.bold(this.theme.fg("accent", `Edit ${agent} ${field}`))); lines.push(""); // R3-6 (IME): same CURSOR_MARKER contract as TextinputSubmenu โ€” the // edit buffer is end-append only, so the marker rides immediately before // the fake `โ–ˆ` cursor (pi-tui Input's marker+cursorChar pattern). lines.push(` ${this.editBuffer}${CURSOR_MARKER}โ–ˆ`); lines.push(""); lines.push( this.theme.fg( "dim", formatHint([ ["enter", "to save"], ["clear", "to unset"], ["esc", "to cancel"], ]), ), ); return lines; } handleInput(data: string): void { if (this.editField) { this.handleEditInput(data); return; } const k = keyOf(data); if (k === "up" || k === "k") { this.selectedIndex = (this.selectedIndex - 1 + this.agents.length) % this.agents.length; return; } if (k === "down" || k === "j") { this.selectedIndex = (this.selectedIndex + 1) % this.agents.length; return; } if (k === "enter") { const agent = this.agents[this.selectedIndex]!; this.editField = "model"; this.editBuffer = this.overrides[agent]?.model ?? ""; return; } if (data === "e") { const agent = this.agents[this.selectedIndex]!; this.editField = "thinking"; this.editBuffer = this.overrides[agent]?.thinking ?? ""; return; } if (k === "escape") { this.onCancel(); return; } } private handleEditInput(data: string): void { const k = keyOf(data); if (k === "enter") { const agent = this.agents[this.selectedIndex]!; if (!this.overrides[agent]) this.overrides[agent] = {}; if (this.editField === "model") { this.overrides[agent]!.model = this.editBuffer || undefined; } else { this.overrides[agent]!.thinking = this.editBuffer || undefined; } // Clean up empty overrides if (!this.overrides[agent]!.model && !this.overrides[agent]!.thinking) { delete this.overrides[agent]; } this.editField = null; return; } if (k === "escape") { this.editField = null; return; } if (matchesKey(data, "backspace") || k === "backspace") { this.editBuffer = this.editBuffer.slice(0, -1); return; } if (data.length === 1 && data >= " ") { this.editBuffer += data; } } } // --------------------------------------------------------------------------- // Main Overlay // --------------------------------------------------------------------------- class SettingsOverlay { private config: Record; private theme: CrewTheme; private callbacks: SettingsOverlayCallbacks; // Pi 0.81+ Focusable contract โ€” see RunDashboard. Same reasoning: without // `focused` as an own property, isFocusable() returns false and keybind // dispatch may skip the overlay. public focused = false; private currentTabIndex = 0; private selectedIndex = 0; private scrollOffset = 0; private maxVisible = 10; private submenu: SelectSubmenu | TextinputSubmenu | AgentOverridesSubmenu | null = null; private changedValues = new Map(); constructor(config: Record, theme: CrewTheme, callbacks: SettingsOverlayCallbacks) { this.config = config; this.theme = theme; this.callbacks = callbacks; } invalidate(): void { this.submenu?.invalidate(); } render(width: number): string[] { // RAIL frame (M4, 2026-09-16) โ€” the rounded box, the `โ”œ โ”ค` rule and the // `โ”‚` tab separator are retired: canopy `โ” SETTINGS โ–ธ pi-crew`, `โ”ƒ` body // rows (tab bar + content) and a `โ”— ` cap. railLine owns the glyph + // space and the pad/truncate to `budget`, so no line can exceed `width`. const budget = Math.max(0, width - 2); const row = (text: string) => railLine(RAIL.body, "border", text, this.theme, budget); const lines: string[] = []; // โ”€โ”€ Title bar (canopy) โ”€โ”€ lines.push(canopyLine({ word: "SETTINGS", subject: "pi-crew", theme: this.theme, budget })); // โ”€โ”€ Tab bar (a rail body row; tabs joined by ` ยท `, never `โ”‚`) โ”€โ”€ lines.push(row(this.renderTabBarContent(budget))); // โ”€โ”€ Content โ”€โ”€ const bodyBudget = Math.max(0, budget - 2); const content = this.submenu ? this.renderSubmenuContent(bodyBudget) : this.renderSettingsContent(bodyBudget); for (const line of content) { lines.push(row(` ${truncateToWidth(line, Math.max(0, budget - 1))}`)); } // โ”€โ”€ Cap (close action LAST) โ”€โ”€ lines.push(railLine(RAIL.close, "border", formatHint([["esc", "close"]]), this.theme, budget)); return lines; } private renderTabBarContent(innerWidth: number): string { const parts: string[] = []; for (const [i, tab] of TABS.entries()) { const isActive = i === this.currentTabIndex; const text = `${tab.icon} ${tab.label}`; parts.push(isActive ? this.theme.bold(this.theme.fg("accent", text)) : this.theme.fg("dim", text)); } return truncateToWidth(parts.join(` ${this.theme.fg("dim", "ยท")} `), innerWidth); } private renderSettingsContent(innerWidth: number): string[] { const tabId = TABS[this.currentTabIndex]?.id ?? "runtime"; const settings = SETTINGS.filter((s) => s.tab === tabId); const lines: string[] = []; // Calculate max label width for alignment const maxLabelWidth = Math.min(28, Math.max(...settings.map((s) => visibleWidth(s.label)))); // Render visible items const startIdx = this.scrollOffset; const endIdx = Math.min(startIdx + this.maxVisible, settings.length); for (let i = startIdx; i < endIdx; i++) { const def = settings[i]; if (!def) continue; const isSelected = i === this.selectedIndex; const effective = this.changedValues.has(def.id) ? this.changedValues.get(def.id) : currentValueFor(this.config, def.id); const isDefault = !this.changedValues.has(def.id) && !isExplicitlySet(this.config, def.id) && def.id !== "__piTheme__"; const valueStr = formatValue(effective, def.id); const suffix = isDefault && (effective !== undefined || EFFECTIVE_DEFAULTS[def.id] !== undefined) ? " (default)" : ""; // Cursor: `โ€บ` is the ONLY selection marker (RAIL). const prefix = isSelected ? ` ${CURSOR} ` : " "; const labelPad = padToWidth(def.label, maxLabelWidth); const valueMax = innerWidth - maxLabelWidth - 6 - prefix.length - suffix.length; const valueText = truncateToWidth(valueStr, Math.max(10, valueMax)); const line = `${prefix}${labelPad} ${this.theme.fg(isSelected ? "accent" : "muted", valueText)}${suffix ? this.theme.fg("dim", suffix) : ""}`; if (isSelected) { lines.push(this.theme.inverse?.(truncateToWidth(line, innerWidth)) ?? truncateToWidth(line, innerWidth)); } else { lines.push(truncateToWidth(line, innerWidth)); } } // Scroll indicator โ€” RAIL overflow dialect (`โ–ฒ n above ยท โ–ผ m below`). if (startIdx > 0 || endIdx < settings.length) { const remaining = settings.length - endIdx; lines.push( `${this.theme.fg("dim", ` (${this.selectedIndex + 1}/${settings.length})`)} ${overflowHint(startIdx, remaining, this.theme)}`, ); } // Description const selectedDef = settings[this.selectedIndex]; if (selectedDef?.description) { lines.push(""); lines.push(this.theme.fg("muted", ` ${selectedDef.description}`)); } // Hints โ€” one format (`formatHint`); the close action lives in the cap. lines.push(""); lines.push( this.theme.fg( "dim", ` ${formatHint([ [["up", "down"], "Navigate"], [["enter", "space"], "Change"], ["tab", "switch"], ])}`, ), ); return lines; } private renderSubmenuContent(innerWidth: number): string[] { if (!this.submenu) return []; return this.submenu.render(innerWidth); } handleInput(data: string): void { // Route through keyOf() so upstream Arrow/Esc/Tab/Enter encodings // (legacy CSI, application cursor mode, Kitty protocol, shift-modifier // variants) are all recognized. See key-utils.ts. const k = keyOf(data); // Submenu takes priority if (this.submenu) { this.submenu.handleInput(data); return; } // Escape closes overlay if (k === "escape" || k === "q") { this.callbacks.onClose(); return; } // Tab navigation โ€” tab (forward) + shift+tab (backtab) if (k === "tab") { this.currentTabIndex = (this.currentTabIndex + 1) % TABS.length; this.selectedIndex = 0; this.scrollOffset = 0; return; } if (k === "shift+tab") { this.currentTabIndex = (this.currentTabIndex - 1 + TABS.length) % TABS.length; this.selectedIndex = 0; this.scrollOffset = 0; return; } // Item navigation const tabId = TABS[this.currentTabIndex]?.id ?? "runtime"; const settings = SETTINGS.filter((s) => s.tab === tabId); if (k === "up" || k === "k") { this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.ensureVisible(settings.length); return; } if (k === "down" || k === "j") { this.selectedIndex = Math.min(settings.length - 1, this.selectedIndex + 1); this.ensureVisible(settings.length); return; } // Activate item โ€” Enter, Space, or Space-bar; matchesKey unifies carriage // return / newline / spacebar as the same key family. if (k === "enter" || k === "space") { this.activateItem(settings); } } private activateItem(settings: SettingDef[]): void { const def = settings[this.selectedIndex]; if (!def) return; const current = this.changedValues.has(def.id) ? this.changedValues.get(def.id) : getNestedValue(this.config, def.id); switch (def.type) { case "boolean": { const newVal = current !== true; this.changedValues.set(def.id, newVal); this.callbacks.onChange(def.id, newVal); break; } case "enum": { if (!def.values?.length) return; this.submenu = new SelectSubmenu( def.label, def.description ?? "", def.values, typeof current === "string" ? current : def.values[0]!, this.theme, (value: string) => { this.changedValues.set(def.id, value); this.callbacks.onChange(def.id, value); this.submenu = null; }, () => { this.submenu = null; }, ); break; } case "number": { this.submenu = new TextinputSubmenu( def.label, def.description ?? "", typeof current === "number" ? String(current) : "", this.theme, (value: string) => { const num = value === "" ? undefined : Number(value); if (num !== undefined && !Number.isNaN(num)) { this.changedValues.set(def.id, num); this.callbacks.onChange(def.id, num); } else if (value === "") { this.changedValues.set(def.id, undefined); this.callbacks.onChange(def.id, undefined); } this.submenu = null; }, () => { this.submenu = null; }, ); break; } case "string": { this.submenu = new TextinputSubmenu( def.label, def.description ?? "", typeof current === "string" ? current : "", this.theme, (value: string) => { this.changedValues.set(def.id, value || undefined); this.callbacks.onChange(def.id, value || undefined); this.submenu = null; }, () => { this.submenu = null; }, ); break; } case "agent": { this.submenu = new AgentOverridesSubmenu( this.config, this.theme, (overrides: Record) => { this.changedValues.set("agents.overrides", overrides); this.callbacks.onChange("agents.overrides", overrides); this.submenu = null; }, () => { this.submenu = null; }, ); break; } case "action": { if (!def.values?.length || !def.action) break; const actionCurrent = typeof this.changedValues.get(def.id) === "string" ? (this.changedValues.get(def.id) as string) : ((currentValueFor(this.config, def.id) as string | undefined) ?? ""); this.submenu = new SelectSubmenu( def.label, def.description ?? "", def.values, actionCurrent, this.theme, (value: string) => { this.changedValues.set(def.id, value); this.callbacks.onAction?.(def.action!, value); this.submenu = null; }, () => { this.submenu = null; }, ); break; } } } private ensureVisible(count: number): void { if (this.selectedIndex < this.scrollOffset) { this.scrollOffset = this.selectedIndex; } else if (this.selectedIndex >= this.scrollOffset + this.maxVisible) { this.scrollOffset = Math.max(0, this.selectedIndex - this.maxVisible + 1); } } } // --------------------------------------------------------------------------- // Public factory // --------------------------------------------------------------------------- export function createSettingsOverlay( config: Record, theme: CrewTheme, onChange: (id: string, value: unknown) => void, done: () => void, onAction?: (action: string, value: unknown) => void, ) { const overlay = new SettingsOverlay(config, theme, { onChange, onClose: done, onAction, }); return { overlay, component: overlay }; }