diff --git a/pkg/commands/lab.go b/pkg/commands/lab.go index 9679315..e925e3c 100644 --- a/pkg/commands/lab.go +++ b/pkg/commands/lab.go @@ -53,6 +53,7 @@ Use 'xcli lab [command] --help' for more information about a command.`, cmd.AddCommand(NewLabRestartCommand(log, configPath)) cmd.AddCommand(NewLabModeCommand(log, configPath)) cmd.AddCommand(NewLabConfigCommand(log, configPath)) + cmd.AddCommand(NewLabOverridesCommand(configPath)) cmd.AddCommand(NewLabTUICommand(log, configPath)) cmd.AddCommand(NewLabDiagnoseCommand(log, configPath)) cmd.AddCommand(NewLabReleaseCommand(log, configPath)) diff --git a/pkg/commands/lab_overrides.go b/pkg/commands/lab_overrides.go new file mode 100644 index 0000000..bd11a16 --- /dev/null +++ b/pkg/commands/lab_overrides.go @@ -0,0 +1,41 @@ +package commands + +import ( + "fmt" + "path/filepath" + + "github.com/ethpandaops/xcli/pkg/config" + "github.com/ethpandaops/xcli/pkg/configtui" + "github.com/ethpandaops/xcli/pkg/constants" + "github.com/spf13/cobra" +) + +// NewLabOverridesCommand creates the lab overrides command. +func NewLabOverridesCommand(configPath string) *cobra.Command { + return &cobra.Command{ + Use: "overrides", + Short: "Manage CBT model overrides interactively", + Long: `Launch an interactive TUI to manage .cbt-overrides.yaml. + +The TUI allows you to: + - Enable/disable external models (from models/external/) + - Enable/disable transformation models (from models/transformations/) + - Set environment variables for backfill limits: + - EXTERNAL_MODEL_MIN_TIMESTAMP: Consensus layer backfill timestamp + - EXTERNAL_MODEL_MIN_BLOCK: Execution layer backfill block number + +Changes are saved to .cbt-overrides.yaml. Run 'xcli lab config regenerate' +to apply changes to CBT configuration.`, + RunE: func(cmd *cobra.Command, args []string) error { + labCfg, cfgPath, err := config.LoadLabConfig(configPath) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + // Derive overrides path (same directory as .xcli.yaml). + overridesPath := filepath.Join(filepath.Dir(cfgPath), constants.CBTOverridesFile) + + return configtui.Run(labCfg.Repos.XatuCBT, overridesPath) + }, + } +} diff --git a/pkg/configtui/command.go b/pkg/configtui/command.go new file mode 100644 index 0000000..788f7b9 --- /dev/null +++ b/pkg/configtui/command.go @@ -0,0 +1,167 @@ +package configtui + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/ethpandaops/xcli/pkg/seeddata" +) + +// Run starts the config TUI. +func Run(xatuCBTPath, overridesPath string) error { + // Check if terminal is a TTY. + if !isatty() { + return fmt.Errorf("config TUI requires an interactive terminal") + } + + // Discover models. + externalModels, transformModels, err := discoverModels(xatuCBTPath) + if err != nil { + return fmt.Errorf("failed to discover models: %w", err) + } + + // Load existing overrides. + overrides, fileExists, err := LoadOverrides(overridesPath) + if err != nil { + return fmt.Errorf("failed to load overrides: %w", err) + } + + // Create the model. + m := NewModel(xatuCBTPath, overridesPath) + m.existingOverrides = overrides + + // Initialize external models. + // If no overrides file exists, default all models to disabled. + m.externalModels = make([]ModelEntry, 0, len(externalModels)) + for _, name := range externalModels { + enabled := fileExists && !IsModelDisabled(overrides, name) + m.externalModels = append(m.externalModels, ModelEntry{ + Name: name, + Enabled: enabled, + }) + } + + // Initialize transformation models. + // If no overrides file exists, default all models to disabled. + m.transformationModels = make([]ModelEntry, 0, len(transformModels)) + for _, name := range transformModels { + enabled := fileExists && !IsModelDisabled(overrides, name) + m.transformationModels = append(m.transformationModels, ModelEntry{ + Name: name, + Enabled: enabled, + }) + } + + // Initialize env vars from loaded overrides. + m.envMinTimestamp = overrides.Models.Env["EXTERNAL_MODEL_MIN_TIMESTAMP"] + m.envMinBlock = overrides.Models.Env["EXTERNAL_MODEL_MIN_BLOCK"] + m.envTimestampEnabled = m.envMinTimestamp != "" + m.envBlockEnabled = m.envMinBlock != "" + + // Load model dependencies for dependency warnings. + m.dependencies = loadDependencies(xatuCBTPath, transformModels) + + // Run the TUI. + p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) + + _, err = p.Run() + if err != nil { + return fmt.Errorf("failed to run TUI: %w", err) + } + + return nil +} + +// isatty checks if stdout is a terminal. +func isatty() bool { + fileInfo, err := os.Stdout.Stat() + if err != nil { + return false + } + + return (fileInfo.Mode() & os.ModeCharDevice) != 0 +} + +// discoverModels discovers external and transformation models from the xatu-cbt repo. +func discoverModels(xatuCBTPath string) (external []string, transformation []string, err error) { + // Discover external models. + externalDir := filepath.Join(xatuCBTPath, "models", "external") + + entries, err := os.ReadDir(externalDir) + if err != nil { + return nil, nil, fmt.Errorf("failed to read external models directory: %w", err) + } + + external = make([]string, 0, len(entries)) + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + if strings.HasSuffix(name, ".sql") { + external = append(external, strings.TrimSuffix(name, ".sql")) + } + } + + sort.Strings(external) + + // Discover transformation models. + transformDir := filepath.Join(xatuCBTPath, "models", "transformations") + + entries, err = os.ReadDir(transformDir) + if err != nil { + return nil, nil, fmt.Errorf("failed to read transformations directory: %w", err) + } + + transformation = make([]string, 0, len(entries)) + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + + // Support .sql, .yml, and .yaml extensions. + for _, ext := range []string{".sql", ".yml", ".yaml"} { + if strings.HasSuffix(name, ext) { + transformation = append(transformation, strings.TrimSuffix(name, ext)) + + break + } + } + } + + sort.Strings(transformation) + + return external, transformation, nil +} + +// loadDependencies loads the dependency graph for all transformation models. +// Returns a map of model name -> list of all dependencies (recursive, flattened). +func loadDependencies(xatuCBTPath string, transformModels []string) map[string][]string { + deps := make(map[string][]string, len(transformModels)) + + for _, model := range transformModels { + tree, err := seeddata.ResolveDependencyTree(model, xatuCBTPath, nil) + if err != nil { + // Skip models with dependency resolution errors. + continue + } + + // Get all dependencies (external and intermediate). + allDeps := make([]string, 0) + allDeps = append(allDeps, tree.GetExternalDependencies()...) + allDeps = append(allDeps, tree.GetIntermediateDependencies()...) + deps[model] = allDeps + } + + return deps +} diff --git a/pkg/configtui/model.go b/pkg/configtui/model.go new file mode 100644 index 0000000..d28493e --- /dev/null +++ b/pkg/configtui/model.go @@ -0,0 +1,271 @@ +package configtui + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +// Section constants for navigation. +const ( + sectionEnv = "env" + sectionExternal = "external" + sectionTransformation = "transformation" +) + +// ModelEntry represents a single model (external or transformation). +type ModelEntry struct { + Name string + Enabled bool // true = default (omit from overrides), false = disabled +} + +// Model is the Bubbletea application state. +type Model struct { + // Config paths. + xatuCBTPath string + overridesPath string + + // Data. + externalModels []ModelEntry + transformationModels []ModelEntry + existingOverrides *CBTOverrides // Original loaded overrides for preserving config blocks. + + // Dependencies: model -> list of models it depends on (direct deps only). + dependencies map[string][]string + + // Environment variables. + envMinTimestamp string // Value (empty = use default) + envMinBlock string // Value (empty = use default) + envTimestampEnabled bool // Whether to include in output (uncommented) + envBlockEnabled bool // Whether to include in output (uncommented) + + // UI State. + activeSection string // "env", "external", "transformation" + selectedIndex int // Current cursor position within section + externalScroll int // Scroll offset for external models + transformScroll int // Scroll offset for transformation models + + // Dimensions. + width int + height int + + // Status. + dirty bool // Has unsaved changes + statusMsg string // Status message to display + quitting bool // Whether we're quitting + + // Confirm quit state. + confirmQuit bool +} + +// NewModel creates a new Model with initial state. +func NewModel(xatuCBTPath, overridesPath string) Model { + return Model{ + xatuCBTPath: xatuCBTPath, + overridesPath: overridesPath, + externalModels: make([]ModelEntry, 0), + transformationModels: make([]ModelEntry, 0), + activeSection: sectionTransformation, // Start on left panel + selectedIndex: 0, + } +} + +// Init is called when the program starts. +func (m Model) Init() tea.Cmd { + return nil +} + +// GetCurrentSectionModels returns the models for the currently active section. +func (m *Model) GetCurrentSectionModels() []ModelEntry { + switch m.activeSection { + case sectionExternal: + return m.externalModels + case sectionTransformation: + return m.transformationModels + default: + return nil + } +} + +// GetCurrentScroll returns the scroll offset for the current section. +func (m *Model) GetCurrentScroll() int { + switch m.activeSection { + case sectionExternal: + return m.externalScroll + case sectionTransformation: + return m.transformScroll + default: + return 0 + } +} + +// SetCurrentScroll sets the scroll offset for the current section. +func (m *Model) SetCurrentScroll(offset int) { + switch m.activeSection { + case sectionExternal: + m.externalScroll = offset + case sectionTransformation: + m.transformScroll = offset + } +} + +// ToggleCurrentModel toggles the enabled state of the currently selected model. +func (m *Model) ToggleCurrentModel() { + switch m.activeSection { + case sectionExternal: + if m.selectedIndex < len(m.externalModels) { + m.externalModels[m.selectedIndex].Enabled = !m.externalModels[m.selectedIndex].Enabled + m.dirty = true + } + case sectionTransformation: + if m.selectedIndex < len(m.transformationModels) { + m.transformationModels[m.selectedIndex].Enabled = !m.transformationModels[m.selectedIndex].Enabled + m.dirty = true + } + } +} + +// EnableAllInSection enables all models in the current section. +func (m *Model) EnableAllInSection() { + switch m.activeSection { + case sectionExternal: + for i := range m.externalModels { + m.externalModels[i].Enabled = true + } + + m.dirty = true + case sectionTransformation: + for i := range m.transformationModels { + m.transformationModels[i].Enabled = true + } + + m.dirty = true + } +} + +// DisableAllInSection disables all models in the current section. +func (m *Model) DisableAllInSection() { + switch m.activeSection { + case sectionExternal: + for i := range m.externalModels { + m.externalModels[i].Enabled = false + } + + m.dirty = true + case sectionTransformation: + for i := range m.transformationModels { + m.transformationModels[i].Enabled = false + } + + m.dirty = true + } +} + +// GetSectionLength returns the number of items in the current section. +func (m *Model) GetSectionLength() int { + switch m.activeSection { + case sectionEnv: + return 2 // Two env vars + case sectionExternal: + return len(m.externalModels) + case sectionTransformation: + return len(m.transformationModels) + default: + return 0 + } +} + +// NextSection moves to the next section. +// Order: env -> transformation (left panel) -> external (right panel) -> env. +func (m *Model) NextSection() { + switch m.activeSection { + case sectionEnv: + m.activeSection = sectionTransformation + case sectionTransformation: + m.activeSection = sectionExternal + case sectionExternal: + m.activeSection = sectionEnv + } + + m.selectedIndex = 0 +} + +// PrevSection moves to the previous section. +// Order: env -> external (right panel) -> transformation (left panel) -> env. +func (m *Model) PrevSection() { + switch m.activeSection { + case sectionEnv: + m.activeSection = sectionExternal + case sectionExternal: + m.activeSection = sectionTransformation + case sectionTransformation: + m.activeSection = sectionEnv + } + + m.selectedIndex = 0 +} + +// EnableMissingDependencies enables all disabled models that are needed by enabled models. +// Returns the number of models enabled. +func (m *Model) EnableMissingDependencies() int { + count := 0 + + // Check external models. + for i := range m.externalModels { + if !m.externalModels[i].Enabled && m.IsModelNeededByEnabled(m.externalModels[i].Name) { + m.externalModels[i].Enabled = true + count++ + } + } + + // Check transformation models. + for i := range m.transformationModels { + if !m.transformationModels[i].Enabled && m.IsModelNeededByEnabled(m.transformationModels[i].Name) { + m.transformationModels[i].Enabled = true + count++ + } + } + + if count > 0 { + m.dirty = true + } + + return count +} + +// IsModelNeededByEnabled checks if a disabled model is needed by any enabled model. +// Returns true if the model is a dependency of at least one enabled model. +func (m *Model) IsModelNeededByEnabled(modelName string) bool { + if m.dependencies == nil { + return false + } + + // Check all enabled transformation models. + for _, tm := range m.transformationModels { + if !tm.Enabled { + continue + } + + // Check if this enabled model depends on the given model. + deps := m.dependencies[tm.Name] + for _, dep := range deps { + if dep == modelName { + return true + } + } + } + + // Also check enabled external models (they might depend on other externals via transformations). + for _, em := range m.externalModels { + if !em.Enabled { + continue + } + + deps := m.dependencies[em.Name] + for _, dep := range deps { + if dep == modelName { + return true + } + } + } + + return false +} diff --git a/pkg/configtui/overrides.go b/pkg/configtui/overrides.go new file mode 100644 index 0000000..859ce59 --- /dev/null +++ b/pkg/configtui/overrides.go @@ -0,0 +1,176 @@ +// Package configtui provides a TUI for managing CBT model overrides. +package configtui + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +// CBTOverrides represents the .cbt-overrides.yaml structure. +type CBTOverrides struct { + Models ModelsConfig `yaml:"models"` +} + +// ModelsConfig holds the models configuration section. +type ModelsConfig struct { + Env map[string]string `yaml:"env,omitempty"` + Overrides map[string]ModelOverride `yaml:"overrides,omitempty"` +} + +// ModelOverride holds configuration for a single model override. +type ModelOverride struct { + Enabled *bool `yaml:"enabled,omitempty"` + Config map[string]any `yaml:"config,omitempty"` +} + +// LoadOverrides reads .cbt-overrides.yaml if it exists. +// Returns (overrides, fileExists, error). +// Returns a default structure if the file doesn't exist (fileExists=false). +func LoadOverrides(path string) (*CBTOverrides, bool, error) { + overrides := &CBTOverrides{ + Models: ModelsConfig{ + Env: make(map[string]string, 2), + Overrides: make(map[string]ModelOverride), + }, + } + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return overrides, false, nil + } + + return nil, false, fmt.Errorf("failed to read overrides file: %w", err) + } + + if err := yaml.Unmarshal(data, overrides); err != nil { + return nil, true, fmt.Errorf("failed to parse overrides file: %w", err) + } + + // Ensure maps are initialized. + if overrides.Models.Env == nil { + overrides.Models.Env = make(map[string]string, 2) + } + + if overrides.Models.Overrides == nil { + overrides.Models.Overrides = make(map[string]ModelOverride) + } + + return overrides, true, nil +} + +// SaveOverrides writes the configuration to .cbt-overrides.yaml. +// Only writes models that are disabled (enabled: false). +// Preserves existing config blocks for models. +func SaveOverrides(path string, m *Model, existingOverrides *CBTOverrides) error { + // Ensure the directory exists. + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + // Build the YAML manually to handle comments for env vars. + var sb strings.Builder + + sb.WriteString("# CBT Configuration Overrides\n") + sb.WriteString("# Generated by: xcli lab config tui\n") + sb.WriteString("#\n") + sb.WriteString("# Docs: https://github.com/ethpandaops/cbt\n") + sb.WriteString("\n") + sb.WriteString("models:\n") + sb.WriteString(" env:\n") + + // Write EXTERNAL_MODEL_MIN_TIMESTAMP. + if m.envTimestampEnabled && m.envMinTimestamp != "" { + sb.WriteString(fmt.Sprintf(" EXTERNAL_MODEL_MIN_TIMESTAMP: \"%s\"\n", m.envMinTimestamp)) + } else { + value := m.envMinTimestamp + if value == "" { + value = "0" + } + + sb.WriteString(fmt.Sprintf(" # EXTERNAL_MODEL_MIN_TIMESTAMP: \"%s\"\n", value)) + } + + // Write EXTERNAL_MODEL_MIN_BLOCK. + if m.envBlockEnabled && m.envMinBlock != "" { + sb.WriteString(fmt.Sprintf(" EXTERNAL_MODEL_MIN_BLOCK: \"%s\"\n", m.envMinBlock)) + } else { + value := m.envMinBlock + if value == "" { + value = "0" + } + + sb.WriteString(fmt.Sprintf(" # EXTERNAL_MODEL_MIN_BLOCK: \"%s\"\n", value)) + } + + // Collect all disabled models. + disabledModels := make([]string, 0) + + for _, model := range m.externalModels { + if !model.Enabled { + disabledModels = append(disabledModels, model.Name) + } + } + + for _, model := range m.transformationModels { + if !model.Enabled { + disabledModels = append(disabledModels, model.Name) + } + } + + // Sort for consistent output. + sort.Strings(disabledModels) + + // Write overrides section. + sb.WriteString(" overrides:\n") + + if len(disabledModels) == 0 { + sb.WriteString(" {} # No disabled models\n") + } else { + for _, name := range disabledModels { + sb.WriteString(fmt.Sprintf(" %s:\n", name)) + sb.WriteString(" enabled: false\n") + + // Preserve any existing config for this model. + if existingOverrides != nil { + if existing, ok := existingOverrides.Models.Overrides[name]; ok && existing.Config != nil { + configYAML, err := yaml.Marshal(map[string]any{"config": existing.Config}) + if err == nil { + // Indent and add the config section. + lines := strings.Split(strings.TrimSpace(string(configYAML)), "\n") + for _, line := range lines { + sb.WriteString(fmt.Sprintf(" %s\n", line)) + } + } + } + } + } + } + + if err := os.WriteFile(path, []byte(sb.String()), 0600); err != nil { + return fmt.Errorf("failed to write overrides file: %w", err) + } + + return nil +} + +// IsModelDisabled checks if a model is disabled in the overrides. +func IsModelDisabled(overrides *CBTOverrides, modelName string) bool { + if overrides == nil || overrides.Models.Overrides == nil { + return false + } + + if override, ok := overrides.Models.Overrides[modelName]; ok { + if override.Enabled != nil && !*override.Enabled { + return true + } + } + + return false +} diff --git a/pkg/configtui/update.go b/pkg/configtui/update.go new file mode 100644 index 0000000..328845c --- /dev/null +++ b/pkg/configtui/update.go @@ -0,0 +1,283 @@ +package configtui + +import ( + "fmt" + "unicode" + + tea "github.com/charmbracelet/bubbletea" +) + +// Update handles all messages and updates the model. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m.handleKeyPress(msg) + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + return m, nil + } + + return m, nil +} + +// handleKeyPress handles keyboard input. +func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Handle confirm quit state. + if m.confirmQuit { + return m.handleConfirmQuit(msg) + } + + // Handle env section - direct typing for values. + if m.activeSection == sectionEnv { + if handled, newModel, cmd := m.handleEnvInput(msg); handled { + return newModel, cmd + } + } + + // Normal key handling. + switch msg.String() { + case "ctrl+c": + m.quitting = true + + return m, tea.Quit + + case "q": + if m.dirty { + m.confirmQuit = true + + return m, nil + } + + m.quitting = true + + return m, tea.Quit + + case "tab": + m.NextSection() + + return m, nil + + case "shift+tab": + m.PrevSection() + + return m, nil + + case "up", "k": + return m.handleUp(), nil + + case "down", "j": + return m.handleDown(), nil + + case "left", "h": + m.PrevSection() + + return m, nil + + case "right", "l": + m.NextSection() + + return m, nil + + case " ", "enter": + return m.handleToggle(), nil + + case "a": + if m.activeSection != sectionEnv { + m.EnableAllInSection() + m.statusMsg = "Enabled all models in section" + + return m, nil + } + + case "n": + if m.activeSection != sectionEnv { + m.DisableAllInSection() + m.statusMsg = "Disabled all models in section" + + return m, nil + } + + case "d": + count := m.EnableMissingDependencies() + if count > 0 { + m.statusMsg = fmt.Sprintf("Enabled %d missing dependencies", count) + } else { + m.statusMsg = "No missing dependencies" + } + + return m, nil + + case "s": + err := SaveOverrides(m.overridesPath, &m, m.existingOverrides) + if err != nil { + m.statusMsg = "Error: " + err.Error() + + return m, nil + } + + m.dirty = false + m.statusMsg = "Saved to " + m.overridesPath + + return m, nil + + case "r": + // Reload from file. + return m.reload() + } + + return m, nil +} + +// handleConfirmQuit handles the confirm quit dialog. +func (m Model) handleConfirmQuit(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "y", "Y": + m.quitting = true + + return m, tea.Quit + case "n", "N", "esc": + m.confirmQuit = false + + return m, nil + } + + return m, nil +} + +// handleEnvInput handles direct typing in env var fields. +// Returns (handled, model, cmd) - if handled is true, the input was processed. +func (m Model) handleEnvInput(msg tea.KeyMsg) (bool, tea.Model, tea.Cmd) { + key := msg.String() + + // Handle backspace - delete last character. + if key == "backspace" { + if m.selectedIndex == 0 && len(m.envMinTimestamp) > 0 { + m.envMinTimestamp = m.envMinTimestamp[:len(m.envMinTimestamp)-1] + m.envTimestampEnabled = m.envMinTimestamp != "" + m.dirty = true + + return true, m, nil + } else if m.selectedIndex == 1 && len(m.envMinBlock) > 0 { + m.envMinBlock = m.envMinBlock[:len(m.envMinBlock)-1] + m.envBlockEnabled = m.envMinBlock != "" + m.dirty = true + + return true, m, nil + } + + return true, m, nil + } + + // Handle digit input - collect all digits from the input (supports paste). + var digits string + + for _, r := range key { + if unicode.IsDigit(r) { + digits += string(r) + } + } + + if digits != "" { + if m.selectedIndex == 0 { + m.envMinTimestamp += digits + m.envTimestampEnabled = true + } else { + m.envMinBlock += digits + m.envBlockEnabled = true + } + + m.dirty = true + + return true, m, nil + } + + // Not handled - let normal key handling take over. + return false, m, nil +} + +// handleUp moves the cursor up. +func (m Model) handleUp() Model { + if m.selectedIndex > 0 { + m.selectedIndex-- + + // Adjust scroll if needed. + scroll := m.GetCurrentScroll() + if m.selectedIndex < scroll { + m.SetCurrentScroll(m.selectedIndex) + } + } + + return m +} + +// handleDown moves the cursor down. +func (m Model) handleDown() Model { + maxIndex := m.GetSectionLength() - 1 + if m.selectedIndex < maxIndex { + m.selectedIndex++ + + // Adjust scroll if needed (assuming visible height of ~15). + visibleHeight := 15 + scroll := m.GetCurrentScroll() + + if m.selectedIndex >= scroll+visibleHeight { + m.SetCurrentScroll(m.selectedIndex - visibleHeight + 1) + } + } + + return m +} + +// handleToggle toggles the current item. +func (m Model) handleToggle() Model { + switch m.activeSection { + case sectionEnv: + // Toggle env var enabled state. + if m.selectedIndex == 0 { + m.envTimestampEnabled = !m.envTimestampEnabled + } else { + m.envBlockEnabled = !m.envBlockEnabled + } + + m.dirty = true + default: + m.ToggleCurrentModel() + } + + return m +} + +// reload reloads the configuration from file. +func (m Model) reload() (Model, tea.Cmd) { + overrides, fileExists, err := LoadOverrides(m.overridesPath) + if err != nil { + m.statusMsg = "Error reloading: " + err.Error() + + return m, nil + } + + m.existingOverrides = overrides + + // Reset env vars. + m.envMinTimestamp = overrides.Models.Env["EXTERNAL_MODEL_MIN_TIMESTAMP"] + m.envMinBlock = overrides.Models.Env["EXTERNAL_MODEL_MIN_BLOCK"] + m.envTimestampEnabled = m.envMinTimestamp != "" + m.envBlockEnabled = m.envMinBlock != "" + + // Reset model enabled states. + // If no file exists, default all models to disabled. + for i := range m.externalModels { + m.externalModels[i].Enabled = fileExists && !IsModelDisabled(overrides, m.externalModels[i].Name) + } + + for i := range m.transformationModels { + m.transformationModels[i].Enabled = fileExists && !IsModelDisabled(overrides, m.transformationModels[i].Name) + } + + m.dirty = false + m.statusMsg = "Reloaded from file" + + return m, nil +} diff --git a/pkg/configtui/view.go b/pkg/configtui/view.go new file mode 100644 index 0000000..657c61b --- /dev/null +++ b/pkg/configtui/view.go @@ -0,0 +1,348 @@ +package configtui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// Colors. +var ( + colorGreen = lipgloss.Color("10") + colorRed = lipgloss.Color("9") + colorYellow = lipgloss.Color("11") + colorCyan = lipgloss.Color("14") + colorGray = lipgloss.Color("8") + colorWhite = lipgloss.Color("15") +) + +// Styles. +var ( + styleTitle = lipgloss.NewStyle(). + Bold(true). + Foreground(colorCyan). + MarginBottom(1) + + styleEnabled = lipgloss.NewStyle(). + Foreground(colorGreen) + + styleDisabled = lipgloss.NewStyle(). + Foreground(colorRed) + + styleSelected = lipgloss.NewStyle(). + Background(lipgloss.Color("237")). + Foreground(colorWhite) + + stylePanel = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorCyan). + Padding(0, 1) + + stylePanelActive = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorYellow). + Padding(0, 1) + + styleHelp = lipgloss.NewStyle(). + Foreground(colorWhite). + Bold(true). + Background(lipgloss.Color("#3A3A3A")). + Padding(0, 1) + + styleStatusBar = lipgloss.NewStyle(). + Foreground(colorCyan). + Bold(true). + Background(lipgloss.Color("#3A3A3A")). + Padding(0, 1) + + styleDirty = lipgloss.NewStyle(). + Foreground(colorYellow). + Bold(true) + + styleEnvLabel = lipgloss.NewStyle(). + Foreground(colorGray) + + styleEnvValue = lipgloss.NewStyle(). + Foreground(colorWhite) + + styleNeeded = lipgloss.NewStyle(). + Foreground(colorYellow). + Bold(true) + + styleSectionHeader = lipgloss.NewStyle(). + Bold(true). + Foreground(colorCyan). + Underline(true) +) + +// View renders the TUI. +func (m Model) View() string { + if m.quitting { + return "" + } + + var sb strings.Builder + + // Title. + title := styleTitle.Render("xcli CBT Model Configuration") + sb.WriteString(title) + sb.WriteString("\n\n") + + // Env section. + sb.WriteString(m.renderEnvSection()) + sb.WriteString("\n") + + // Model panels (side by side). + sb.WriteString(m.renderModelPanels()) + sb.WriteString("\n") + + // Help bar. + sb.WriteString(m.renderHelp()) + sb.WriteString("\n") + + // Status bar. + sb.WriteString(m.renderStatusBar()) + + return sb.String() +} + +// Env var descriptions. +const ( + envTimestampDesc = "Consensus layer backfill to (unix timestamp, 0 for unlimited)" + envBlockDesc = "Execution layer backfill to (block number, 0 for unlimited)" +) + +// renderEnvSection renders the environment variables section. +func (m Model) renderEnvSection() string { + var sb strings.Builder + + isActive := m.activeSection == sectionEnv + header := styleSectionHeader.Render("ENVIRONMENT VARIABLES") + sb.WriteString(header) + sb.WriteString("\n") + + // EXTERNAL_MODEL_MIN_TIMESTAMP. + row1 := m.renderEnvRow(0, "EXTERNAL_MODEL_MIN_TIMESTAMP", m.envMinTimestamp, m.envTimestampEnabled, isActive, envTimestampDesc) + sb.WriteString(row1) + sb.WriteString("\n") + + // EXTERNAL_MODEL_MIN_BLOCK. + row2 := m.renderEnvRow(1, "EXTERNAL_MODEL_MIN_BLOCK", m.envMinBlock, m.envBlockEnabled, isActive, envBlockDesc) + sb.WriteString(row2) + + return sb.String() +} + +// renderEnvRow renders a single environment variable row with description. +func (m Model) renderEnvRow(index int, name, value string, enabled, sectionActive bool, description string) string { + checkbox := "[ ]" + if enabled { + checkbox = "[x]" + } + + displayValue := value + if displayValue == "" { + displayValue = "" + } + + // Show cursor when this field is selected. + if sectionActive && m.selectedIndex == index { + displayValue = displayValue + "_" + } + + // Show placeholder if empty and not selected. + if displayValue == "" { + displayValue = "(empty)" + } + + line := fmt.Sprintf("%s %s: %s %s", + checkbox, + styleEnvLabel.Render(name), + styleEnvValue.Render(displayValue), + styleEnvLabel.Render("- "+description)) + + if sectionActive && m.selectedIndex == index { + return styleSelected.Render(line) + } + + return line +} + +// renderModelPanels renders the two model panels side by side. +func (m Model) renderModelPanels() string { + // Calculate panel width (roughly half the terminal, or a reasonable default). + panelWidth := 40 + if m.width > 0 { + panelWidth = (m.width - 6) / 2 // Account for borders and spacing + if panelWidth < 30 { + panelWidth = 30 + } + } + + // Determine visible height for model lists - stretch to fill available space. + // Reserve space for: title (3), env section (4), help bar (2), status bar (1), panel borders (2). + reservedHeight := 12 + visibleHeight := 15 // Default if no height info + + if m.height > 0 { + visibleHeight = m.height - reservedHeight + if visibleHeight < 5 { + visibleHeight = 5 + } + } + + // Render external models panel. + externalContent := m.renderModelList(m.externalModels, m.externalScroll, visibleHeight, + m.activeSection == sectionExternal, panelWidth-4) + externalHeader := fmt.Sprintf("EXTERNAL MODELS (%d)", len(m.externalModels)) + + // Calculate panel height (content height + header + padding). + panelHeight := visibleHeight + 3 + + var externalPanel string + if m.activeSection == sectionExternal { + externalPanel = stylePanelActive.Width(panelWidth).Height(panelHeight).Render(externalHeader + "\n" + externalContent) + } else { + externalPanel = stylePanel.Width(panelWidth).Height(panelHeight).Render(externalHeader + "\n" + externalContent) + } + + // Render transformation models panel. + transformContent := m.renderModelList(m.transformationModels, m.transformScroll, visibleHeight, + m.activeSection == sectionTransformation, panelWidth-4) + transformHeader := fmt.Sprintf("TRANSFORMATION MODELS (%d)", len(m.transformationModels)) + + var transformPanel string + if m.activeSection == sectionTransformation { + transformPanel = stylePanelActive.Width(panelWidth).Height(panelHeight).Render(transformHeader + "\n" + transformContent) + } else { + transformPanel = stylePanel.Width(panelWidth).Height(panelHeight).Render(transformHeader + "\n" + transformContent) + } + + // Join panels horizontally (transformations on left, external on right). + return lipgloss.JoinHorizontal(lipgloss.Top, transformPanel, " ", externalPanel) +} + +// renderModelList renders a list of models with scrolling. +func (m Model) renderModelList(models []ModelEntry, scroll, visibleHeight int, isActive bool, width int) string { + if len(models) == 0 { + return styleEnvLabel.Render("(no models found)") + } + + var lines []string + + // Determine visible range. + start := scroll + end := scroll + visibleHeight + + if end > len(models) { + end = len(models) + } + + for i := start; i < end; i++ { + model := models[i] + line := m.renderModelEntry(model, i, isActive, width) + lines = append(lines, line) + } + + // Add scroll indicators if needed. + result := strings.Join(lines, "\n") + + if scroll > 0 { + result = styleEnvLabel.Render("▲ (more above)") + "\n" + result + } + + if end < len(models) { + result = result + "\n" + styleEnvLabel.Render("▼ (more below)") + } + + return result +} + +// renderModelEntry renders a single model entry. +func (m Model) renderModelEntry(model ModelEntry, index int, isActive bool, maxWidth int) string { + checkbox := "[ ]" + checkStyle := styleDisabled + + // Check if this disabled model is needed by an enabled model. + isNeeded := !model.Enabled && m.IsModelNeededByEnabled(model.Name) + + if model.Enabled { + checkbox = "[x]" + checkStyle = styleEnabled + } else if isNeeded { + // Highlight as warning - disabled but needed. + checkStyle = styleNeeded + } + + // Truncate name if too long (account for warning indicator). + name := model.Name + maxNameLen := maxWidth - 5 // Account for checkbox and spacing + + if isNeeded { + maxNameLen -= 3 // Account for warning indicator. + } + + if maxNameLen < 10 { + maxNameLen = 10 + } + + if len(name) > maxNameLen { + name = name[:maxNameLen-2] + ".." + } + + // Add warning indicator for needed but disabled models. + line := fmt.Sprintf("%s %s", checkStyle.Render(checkbox), name) + if isNeeded { + line += " " + styleNeeded.Render("!") + } + + if isActive && m.selectedIndex == index { + return styleSelected.Render(line) + } + + return line +} + +// renderHelp renders the help bar. +func (m Model) renderHelp() string { + var help string + + if m.confirmQuit { + help = "[y] Confirm quit [n] Cancel" + } else { + switch m.activeSection { + case sectionEnv: + help = "[←/→] Switch section [↑/↓] Navigate [0-9] Type value [Backspace] Delete [s] Save [q] Quit" + default: + help = "[←/→] Switch section [↑/↓] Navigate [Space] Toggle [a] All on [n] All off [d] Enable deps [s] Save [q] Quit" + } + } + + width := m.width + if width < 80 { + width = 80 + } + + return styleHelp.Width(width).Render(help) +} + +// renderStatusBar renders the status bar. +func (m Model) renderStatusBar() string { + status := fmt.Sprintf("Section: %s", m.activeSection) + + if m.dirty { + status += " " + styleDirty.Render("* Unsaved changes") + } + + if m.statusMsg != "" { + status += " " + m.statusMsg + } + + width := m.width + if width < 80 { + width = 80 + } + + return styleStatusBar.Width(width).Render(status) +}