diff --git a/cli/src/components/ConfigView.tsx b/cli/src/components/ConfigView.tsx index b3438ab5a8..254720f673 100644 --- a/cli/src/components/ConfigView.tsx +++ b/cli/src/components/ConfigView.tsx @@ -13,6 +13,7 @@ import { import { Box, Text, useApp, useInput } from "ink" import React, { useMemo, useState } from "react" import { useStdinContext } from "../context/StdinContext" +import { fuzzyFilter } from "../utils/fuzzy-search" import { BooleanSelect, buildConfigEntries, @@ -21,6 +22,8 @@ import { HookInfo, HookRow, MAX_VISIBLE, + ObjectEditorPanel, + ObjectEditorState, parseValue, SEPARATOR, SectionHeader, @@ -105,6 +108,8 @@ export const ConfigView: React.FC = ({ const [isEditing, setIsEditing] = useState(false) const [selectedIndex, setSelectedIndex] = useState(0) const [editValue, setEditValue] = useState("") + const [searchQuery, setSearchQuery] = useState("") + const [objectEditor, setObjectEditor] = useState(null) // Build entries for settings tab const configEntries = useMemo( @@ -112,6 +117,13 @@ export const ConfigView: React.FC = ({ [globalState, workspaceState], ) + const filteredConfigEntries = useMemo(() => { + if (!searchQuery.trim()) { + return configEntries + } + return fuzzyFilter(configEntries, searchQuery, (entry) => `${entry.key} ${String(entry.value ?? "")}`) + }, [configEntries, searchQuery]) + // Build entries for rules tab const ruleEntries = useMemo(() => { const entries: ToggleEntry[] = [] @@ -159,7 +171,7 @@ export const ConfigView: React.FC = ({ const currentListLength = useMemo(() => { switch (currentTab) { case "settings": - return configEntries.length + return filteredConfigEntries.length case "rules": return ruleEntries.length case "workflows": @@ -171,7 +183,14 @@ export const ConfigView: React.FC = ({ default: return 0 } - }, [currentTab, configEntries.length, ruleEntries.length, workflowEntries.length, hookEntries.length, skillEntries.length]) + }, [ + currentTab, + filteredConfigEntries.length, + ruleEntries.length, + workflowEntries.length, + hookEntries.length, + skillEntries.length, + ]) // Get available tabs const availableTabs = useMemo(() => { @@ -191,10 +210,11 @@ export const ConfigView: React.FC = ({ setCurrentTab(newTab) setSelectedIndex(0) setIsEditing(false) + setObjectEditor(null) } // Settings tab handlers - const selectedConfigEntry = configEntries[selectedIndex] + const selectedConfigEntry = filteredConfigEntries[selectedIndex] const handleSettingsSave = (value: string | boolean) => { if (!selectedConfigEntry) { @@ -210,6 +230,43 @@ export const ConfigView: React.FC = ({ setIsEditing(false) } + const getObjectAtPath = (root: Record, path: string[]): Record => { + let current: unknown = root + for (const segment of path) { + if (!current || typeof current !== "object") { + return {} + } + current = (current as Record)[segment] + } + return current && typeof current === "object" ? (current as Record) : {} + } + + const setObjectValueAtPath = ( + root: Record, + path: string[], + key: string, + value: unknown, + ): Record => { + if (path.length === 0) { + return { ...root, [key]: value } + } + const [head, ...rest] = path + const child = root[head] + const childObj = child && typeof child === "object" ? (child as Record) : {} + return { + ...root, + [head]: setObjectValueAtPath(childObj, rest, key, value), + } + } + + const persistObjectEditor = (nextObject: Record, source: "global" | "workspace", key: string) => { + if (source === "global" && onUpdateGlobal) { + onUpdateGlobal(key as GlobalStateAndSettingsKey, nextObject as never) + } else if (source === "workspace" && onUpdateWorkspace) { + onUpdateWorkspace(key as LocalStateKey, nextObject as never) + } + } + const handleSettingsReset = () => { if (!selectedConfigEntry?.isEditable || selectedConfigEntry.source !== "global") { return @@ -240,15 +297,22 @@ export const ConfigView: React.FC = ({ // Input handling useInput( (input, key) => { - if (input.toLowerCase() === "q" || key.escape) { + if (objectEditor) { + return + } + + if (key.escape) { exit() } - // Tab navigation with Tab key or number keys - if (key.tab || (input >= "1" && input <= "5")) { - const targetIdx = key.tab - ? (availableTabs.findIndex((t) => t.key === currentTab) + 1) % availableTabs.length - : parseInt(input) - 1 + if (key.leftArrow || key.rightArrow || (input >= "1" && input <= "5")) { + const currentTabIndex = availableTabs.findIndex((t) => t.key === currentTab) + const targetIdx = + input >= "1" && input <= "5" + ? Number.parseInt(input) - 1 + : key.leftArrow + ? (currentTabIndex - 1 + availableTabs.length) % availableTabs.length + : (currentTabIndex + 1) % availableTabs.length if (targetIdx >= 0 && targetIdx < availableTabs.length) { handleTabChange(availableTabs[targetIdx].key) } @@ -256,21 +320,45 @@ export const ConfigView: React.FC = ({ } // List navigation (arrow keys and vim-style j/k) - if (key.upArrow || input === "k") { + if (key.upArrow) { setSelectedIndex((i) => (i > 0 ? i - 1 : currentListLength - 1)) - } else if (key.downArrow || input === "j") { + } else if (key.downArrow) { setSelectedIndex((i) => (i < currentListLength - 1 ? i + 1 : 0)) } // Tab-specific actions if (currentTab === "settings") { - if ((key.return || input === "e") && selectedConfigEntry?.isEditable) { + if ((key.return || key.tab) && selectedConfigEntry?.isEditable) { + if (selectedConfigEntry.type === "boolean") { + handleSettingsSave(!selectedConfigEntry.value) + return + } + if (selectedConfigEntry.type === "object") { + const value = + selectedConfigEntry.value && typeof selectedConfigEntry.value === "object" + ? (selectedConfigEntry.value as Record) + : {} + setObjectEditor({ + source: selectedConfigEntry.source, + key: selectedConfigEntry.key, + path: [], + value, + selectedIndex: 0, + isEditingValue: false, + editValue: "", + }) + return + } setEditValue(selectedConfigEntry.value !== undefined ? String(selectedConfigEntry.value) : "") setIsEditing(true) - } else if (input === "r") { + } else if (key.ctrl && input.toLowerCase() === "r") { handleSettingsReset() + } else if (key.backspace || key.delete) { + setSearchQuery((prev) => prev.slice(0, -1)) + } else if (input && !key.ctrl && !key.meta && !key.escape && !key.upArrow && !key.downArrow) { + setSearchQuery((prev) => prev + input) } - } else if (key.return || input === " ") { + } else if (key.return || key.tab || input === " ") { // Toggle for rules/workflows/hooks/skills handleToggle() } @@ -338,13 +426,31 @@ export const ConfigView: React.FC = ({ ) } + if (objectEditor && currentTab === "settings") { + return ( + setObjectEditor(null)} + onPersist={(nextObject) => persistObjectEditor(nextObject, objectEditor.source, objectEditor.key)} + setObjectValueAtPath={setObjectValueAtPath} + setState={setObjectEditor} + state={objectEditor} + /> + ) + } + // Render tab content const renderTabContent = () => { switch (currentTab) { case "settings": { - const visibleEntries = configEntries.slice(startIndex, startIndex + MAX_VISIBLE) + const visibleEntries = filteredConfigEntries.slice(startIndex, startIndex + MAX_VISIBLE) return ( + + Search: + {searchQuery} + + Data directory: @@ -507,12 +613,12 @@ export const ConfigView: React.FC = ({ // Help text based on current tab const getHelpText = () => { - const base = "↑/↓/j/k Navigate • Tab/1-5 Switch tabs • q/Esc Exit" + const base = "↑/↓ Navigate • ←/→ tabs • 1-5 tabs • Esc Exit" if (currentTab === "settings") { - return `${base} • Enter/e Edit • r Reset` + return `${base} • Type to search • Enter/Tab Edit (booleans toggle) • Backspace clear search • Ctrl+R Reset` } const openFolder = onOpenFolder ? " • o Open folder" : "" - return `${base} • Enter/Space Toggle${openFolder}` + return `${base} • Enter/Tab/Space Toggle${openFolder}` } return ( diff --git a/cli/src/components/ConfigViewComponents.tsx b/cli/src/components/ConfigViewComponents.tsx index 925b8b8778..513e016b5a 100644 --- a/cli/src/components/ConfigViewComponents.tsx +++ b/cli/src/components/ConfigViewComponents.tsx @@ -46,6 +46,16 @@ export interface SkillInfo { enabled: boolean } +export interface ObjectEditorState { + source: "global" | "workspace" + key: string + path: string[] + value: Record + selectedIndex: number + isEditingValue: boolean + editValue: string +} + export const EXCLUDED_KEYS = new Set([ "taskHistory", "primaryRootIndex", @@ -55,7 +65,7 @@ export const EXCLUDED_KEYS = new Set([ "isNewUser", ]) -export const EDITABLE_TYPES: Set = new Set(["string", "number", "boolean"]) +export const EDITABLE_TYPES: Set = new Set(["string", "number", "boolean", "object"]) export const MAX_VISIBLE = 12 export const SEPARATOR = "─".repeat(80) @@ -135,7 +145,7 @@ export function parseValue(input: string, type: ValueType): unknown { return input.toLowerCase() === "true" || input === "1" } if (type === "number") { - const num = parseFloat(input) + const num = Number.parseFloat(input) return Number.isNaN(num) ? 0 : num } if (type === "object") { @@ -217,7 +227,7 @@ export const TextInput: React.FC = ({ label, onChange, onCancel, {value} - + | Type: {type} • Enter to save • Esc to cancel @@ -379,3 +389,169 @@ export const SectionHeader: React.FC<{ title: string }> = ({ title }) => ( ) + +interface ObjectEditorPanelProps { + state: ObjectEditorState + setState: React.Dispatch> + onClose: () => void + onPersist: (nextObject: Record) => void + getObjectAtPath: (root: Record, path: string[]) => Record + setObjectValueAtPath: (root: Record, path: string[], key: string, value: unknown) => Record +} + +export const ObjectEditorPanel: React.FC = ({ + state, + setState, + onClose, + onPersist, + getObjectAtPath, + setObjectValueAtPath, +}) => { + const { isRawModeSupported } = useStdinContext() + const currentNode = getObjectAtPath(state.value, state.path) + const objectEntries = Object.entries(currentNode).sort(([a], [b]) => a.localeCompare(b)) + const selectedEntry = objectEntries[state.selectedIndex] + const breadcrumb = [state.key, ...state.path].join(" › ") + + useInput( + (input, key) => { + if (state.isEditingValue) { + if (key.escape) { + setState((prev) => (prev ? { ...prev, isEditingValue: false, editValue: "" } : prev)) + return + } + if (key.return) { + if (!selectedEntry) { + setState((prev) => (prev ? { ...prev, isEditingValue: false, editValue: "" } : prev)) + return + } + const [entryKey, entryValue] = selectedEntry + let parsed: unknown = state.editValue + if (typeof entryValue === "boolean") { + parsed = state.editValue.toLowerCase() === "true" || state.editValue === "1" + } else if (typeof entryValue === "number") { + const maybeNum = Number(state.editValue) + parsed = Number.isNaN(maybeNum) ? 0 : maybeNum + } + const nextObject = setObjectValueAtPath(state.value, state.path, entryKey, parsed) + onPersist(nextObject) + setState((prev) => (prev ? { ...prev, value: nextObject, isEditingValue: false, editValue: "" } : prev)) + return + } + if (key.backspace || key.delete) { + setState((prev) => (prev ? { ...prev, editValue: prev.editValue.slice(0, -1) } : prev)) + return + } + if (input && !key.ctrl && !key.meta) { + setState((prev) => (prev ? { ...prev, editValue: prev.editValue + input } : prev)) + } + return + } + + if (key.escape) { + if (state.path.length > 0) { + setState((prev) => (prev ? { ...prev, path: prev.path.slice(0, -1), selectedIndex: 0 } : prev)) + } else { + onClose() + } + return + } + + if (key.upArrow || input === "k") { + setState((prev) => + prev + ? { + ...prev, + selectedIndex: + objectEntries.length > 0 + ? prev.selectedIndex > 0 + ? prev.selectedIndex - 1 + : objectEntries.length - 1 + : 0, + } + : prev, + ) + return + } + if (key.downArrow || input === "j") { + setState((prev) => + prev + ? { + ...prev, + selectedIndex: + objectEntries.length > 0 + ? prev.selectedIndex < objectEntries.length - 1 + ? prev.selectedIndex + 1 + : 0 + : 0, + } + : prev, + ) + return + } + + if (key.return || key.tab) { + if (!selectedEntry) { + return + } + const [entryKey, entryValue] = selectedEntry + if (typeof entryValue === "boolean") { + const nextObject = setObjectValueAtPath(state.value, state.path, entryKey, !entryValue) + onPersist(nextObject) + setState((prev) => (prev ? { ...prev, value: nextObject } : prev)) + return + } + if (entryValue && typeof entryValue === "object" && !Array.isArray(entryValue)) { + setState((prev) => (prev ? { ...prev, path: [...prev.path, entryKey], selectedIndex: 0 } : prev)) + return + } + setState((prev) => + prev + ? { ...prev, isEditingValue: true, editValue: entryValue !== undefined ? String(entryValue) : "" } + : prev, + ) + } + }, + { isActive: isRawModeSupported }, + ) + + return ( + + + ⚙️ Edit Nested Object + + {SEPARATOR} + {breadcrumb} + {state.isEditingValue ? ( + + + {state.editValue} + | + + Enter to save • Esc to cancel + + ) : ( + + {objectEntries.length === 0 ? ( + No nested keys at this level. + ) : ( + objectEntries.map(([key, value], idx) => { + const isSelected = idx === state.selectedIndex + const valueText = + value && typeof value === "object" && !Array.isArray(value) ? "{...}" : String(value) + return ( + + {isSelected ? "❯ " : " "} + {key} + : + {valueText} + + ) + }) + )} + ↑/↓ Navigate • Enter/Tab Edit or drill in • Esc Back/Close + + )} + + ) +}