From fffba591b1875c28687f59e0c134547e5395e1f2 Mon Sep 17 00:00:00 2001 From: Roy Zhu Date: Tue, 18 Nov 2025 01:47:31 -0500 Subject: [PATCH 1/4] Refactor TUI into modular views with search, forms - Add sectioned navigation with subviews and help overlay - Implement search filtering, secret input, provider and binding forms - Extract rendering into internal/ui/dashboard/views package - Introduce key constants and update onboarding/tui usage - Update help text and move docs to spec/ with new plans - Add .gocache to .gitignore --- .gitignore | 1 + internal/ui/dashboard.go | 2728 ++++++++++------- internal/ui/dashboard/views/config_view.go | 95 + internal/ui/dashboard/views/dashboard_view.go | 51 + internal/ui/dashboard/views/help_view.go | 113 + internal/ui/dashboard/views/hooks_view.go | 99 + internal/ui/dashboard/views/providers_view.go | 140 + internal/ui/dashboard/views/proxy_view.go | 85 + internal/ui/dashboard/views/reports_view.go | 91 + internal/ui/dashboard/views/routing_view.go | 74 + internal/ui/dashboard/views/secrets_view.go | 118 + internal/ui/dashboard/views/stats_view.go | 121 + .../ui/dashboard/views/suggestions_view.go | 136 + internal/ui/dashboard/views/theme.go | 13 + internal/ui/dashboard/views/tools_view.go | 120 + internal/ui/keys.go | 7 + internal/ui/onboarding.go | 2 +- internal/ui/tui.go | 6 +- {docs => spec}/CLI_REDESIGN.md | 0 {docs => spec}/PHASE1_FEATURES.md | 0 {docs => spec}/PHASE2_FEATURES.md | 0 {docs => spec}/PHASE3_FEATURES.md | 0 {docs => spec}/TUI_ENHANCEMENT_PLAN.md | 0 spec/TUI_SPRINT4_PLAN.md | 62 + spec/roadmap-progress.md | 65 + spec/ui-refactor_spec.md | 294 ++ 26 files changed, 3298 insertions(+), 1123 deletions(-) create mode 100644 internal/ui/dashboard/views/config_view.go create mode 100644 internal/ui/dashboard/views/dashboard_view.go create mode 100644 internal/ui/dashboard/views/help_view.go create mode 100644 internal/ui/dashboard/views/hooks_view.go create mode 100644 internal/ui/dashboard/views/providers_view.go create mode 100644 internal/ui/dashboard/views/proxy_view.go create mode 100644 internal/ui/dashboard/views/reports_view.go create mode 100644 internal/ui/dashboard/views/routing_view.go create mode 100644 internal/ui/dashboard/views/secrets_view.go create mode 100644 internal/ui/dashboard/views/stats_view.go create mode 100644 internal/ui/dashboard/views/suggestions_view.go create mode 100644 internal/ui/dashboard/views/theme.go create mode 100644 internal/ui/dashboard/views/tools_view.go create mode 100644 internal/ui/keys.go rename {docs => spec}/CLI_REDESIGN.md (100%) rename {docs => spec}/PHASE1_FEATURES.md (100%) rename {docs => spec}/PHASE2_FEATURES.md (100%) rename {docs => spec}/PHASE3_FEATURES.md (100%) rename {docs => spec}/TUI_ENHANCEMENT_PLAN.md (100%) create mode 100644 spec/TUI_SPRINT4_PLAN.md create mode 100644 spec/roadmap-progress.md create mode 100644 spec/ui-refactor_spec.md diff --git a/.gitignore b/.gitignore index a7926e6..bc64f93 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ docs/.vitepress/cache # Node node_modules/ +.gocache/ diff --git a/internal/ui/dashboard.go b/internal/ui/dashboard.go index 1afa534..f740609 100644 --- a/internal/ui/dashboard.go +++ b/internal/ui/dashboard.go @@ -9,6 +9,7 @@ import ( "time" "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/royisme/bobamixer/internal/domain/core" @@ -16,6 +17,7 @@ import ( "github.com/royisme/bobamixer/internal/domain/suggestions" "github.com/royisme/bobamixer/internal/proxy" "github.com/royisme/bobamixer/internal/store/sqlite" + dashboardviews "github.com/royisme/bobamixer/internal/ui/dashboard/views" ) // viewMode represents the current view in the dashboard @@ -39,19 +41,549 @@ const ( // UI constants for repeated strings const ( - proxyStatusRunning = "running" - proxyStatusStopped = "stopped" - proxyStatusChecking = "checking" - proxyStateOn = "ON" - proxyStateOff = "OFF" - iconCircleFilled = "●" - iconCircleEmpty = "○" - iconCheckmark = "✓" - iconCross = "✗" - helpTextNavigation = "[1-9,0,H,C,?] Switch View [↑/↓] Navigate [Tab] Next View [Q] Quit" + proxyStatusRunning = "running" + proxyStatusStopped = "stopped" + proxyStatusChecking = "checking" + proxyStateOn = "ON" + proxyStateOff = "OFF" + iconCircleFilled = "●" + iconCircleEmpty = "○" + iconCheckmark = "✓" + iconCross = "✗" + helpTextNavigation = "[1-5] Switch Section [Tab] Next Section [[ / ]] Cycle Views [/] Search [?] Help [Q] Quit" + msgNoProviderSelected = "No provider selected" + msgInvalidProvider = "Invalid provider selection" + promptPrefix = "│ " ) -const totalViews viewMode = viewHelp + 1 +type providerFormField int + +const ( + providerFieldID providerFormField = iota + providerFieldKind + providerFieldDisplayName + providerFieldBaseURL + providerFieldDefaultModel + providerFieldAPIKeySource + providerFieldAPIKeyEnv +) + +var providerFieldSequence = []providerFormField{ + providerFieldID, + providerFieldKind, + providerFieldDisplayName, + providerFieldBaseURL, + providerFieldDefaultModel, + providerFieldAPIKeySource, + providerFieldAPIKeyEnv, +} + +type bindingFormField int + +const ( + bindingFieldToolID bindingFormField = iota + bindingFieldProviderID + bindingFieldModel + bindingFieldUseProxy +) + +var bindingFieldSequence = []bindingFormField{ + bindingFieldToolID, + bindingFieldProviderID, + bindingFieldModel, + bindingFieldUseProxy, +} + +type viewSection struct { + name string + shortcut string + views []viewMode +} + +func (m *DashboardModel) initSections() { + m.sections = []viewSection{ + { + name: "Dashboard", + shortcut: "1", + views: []viewMode{viewDashboard}, + }, + { + name: "Control Plane", + shortcut: "2", + views: []viewMode{viewProviders, viewTools, viewBindings, viewSecrets, viewProxy}, + }, + { + name: "Usage", + shortcut: "3", + views: []viewMode{viewStats, viewReports}, + }, + { + name: "Optimization", + shortcut: "4", + views: []viewMode{viewSuggestions}, + }, + { + name: "DevOps", + shortcut: "5", + views: []viewMode{viewRouting, viewHooks, viewConfig}, + }, + } + m.currentSection = 0 + m.sectionViewIndex = 0 + m.updateViewFromSection() +} + +func (m *DashboardModel) updateViewFromSection() { + if len(m.sections) == 0 { + m.currentView = viewDashboard + return + } + + if m.currentSection < 0 { + m.currentSection = 0 + } + if m.currentSection >= len(m.sections) { + m.currentSection = 0 + } + + section := m.sections[m.currentSection] + if len(section.views) == 0 { + m.currentView = viewDashboard + return + } + + if m.sectionViewIndex < 0 { + m.sectionViewIndex = 0 + } + if m.sectionViewIndex >= len(section.views) { + m.sectionViewIndex = 0 + } + + nextView := section.views[m.sectionViewIndex] + if m.currentView != nextView { + m.currentView = nextView + m.selectedIndex = 0 + } + if m.searchContextView != m.currentView { + m.searchActive = false + m.searchQuery = "" + m.searchContextView = m.currentView + } +} + +func (m *DashboardModel) moveToSection(idx int) tea.Cmd { + if idx < 0 || idx >= len(m.sections) { + return nil + } + m.currentSection = idx + m.sectionViewIndex = 0 + m.updateViewFromSection() + return m.sectionEnterCmd() +} + +func (m *DashboardModel) cycleSection(delta int) tea.Cmd { + m.currentSection = (m.currentSection + delta + len(m.sections)) % len(m.sections) + m.sectionViewIndex = 0 + m.updateViewFromSection() + return m.sectionEnterCmd() +} + +func (m *DashboardModel) cycleSubview(delta int) tea.Cmd { + section := m.sections[m.currentSection] + if len(section.views) == 0 { + return nil + } + m.sectionViewIndex = (m.sectionViewIndex + delta + len(section.views)) % len(section.views) + m.updateViewFromSection() + return m.sectionEnterCmd() +} + +func (m *DashboardModel) sectionEnterCmd() tea.Cmd { + switch m.currentView { + case viewStats: + return m.loadStatsData + case viewProxy: + return checkProxyStatus + case viewSuggestions: + return m.loadSuggestions + default: + return nil + } +} + +func (m *DashboardModel) supportsSearch(view viewMode) bool { + switch view { + case viewProviders, viewTools, viewBindings, viewSecrets: + return true + default: + return false + } +} + +func (m *DashboardModel) activateSearch() { + m.searchActive = true + m.searchInput.SetValue(m.searchQuery) + m.searchInput.CursorEnd() + m.searchContextView = m.currentView +} + +func (m *DashboardModel) clearSearch() { + m.searchActive = false + m.searchQuery = "" +} + +func (m *DashboardModel) startSecretInput() { + indexes := m.filteredProviderIndexes() + if len(indexes) == 0 || m.selectedIndex < 0 || m.selectedIndex >= len(indexes) { + m.secretMessage = msgNoProviderSelected + return + } + + targetIdx := indexes[m.selectedIndex] + if targetIdx < 0 || targetIdx >= len(m.providers.Providers) { + m.secretMessage = msgInvalidProvider + return + } + + provider := m.providers.Providers[targetIdx] + m.secretTargetIndex = targetIdx + m.secretInput.SetValue("") + m.secretInput.Placeholder = fmt.Sprintf("API key for %s", provider.DisplayName) + m.secretInput.CursorEnd() + m.secretInput.Focus() + m.secretInputActive = true + m.searchActive = false + m.secretMessage = "" +} + +func (m *DashboardModel) ensureSecretsConfig() { + if m.secrets == nil { + m.secrets = &core.SecretsConfig{ + Version: 1, + Secrets: make(map[string]core.Secret), + } + } + if m.secrets.Secrets == nil { + m.secrets.Secrets = make(map[string]core.Secret) + } +} + +func (m *DashboardModel) saveSecretInput() { + if m.secretTargetIndex < 0 || m.secretTargetIndex >= len(m.providers.Providers) { + m.secretMessage = msgInvalidProvider + m.secretInputActive = false + return + } + + apiKey := strings.TrimSpace(m.secretInput.Value()) + if apiKey == "" { + m.secretMessage = "API key cannot be empty" + return + } + + provider := m.providers.Providers[m.secretTargetIndex] + m.ensureSecretsConfig() + m.secrets.Secrets[provider.ID] = core.Secret{ + ProviderID: provider.ID, + APIKey: apiKey, + } + + if err := core.SaveSecrets(m.home, m.secrets); err != nil { + m.secretMessage = fmt.Sprintf("Failed to save API key: %v", err) + } else { + m.secretMessage = fmt.Sprintf("API key saved for %s", provider.DisplayName) + } + + m.secretInputActive = false + m.secretInput.Blur() + m.secretInput.SetValue("") +} + +func (m *DashboardModel) handleSecretRemove() { + indexes := m.filteredProviderIndexes() + if len(indexes) == 0 || m.selectedIndex < 0 || m.selectedIndex >= len(indexes) { + m.secretMessage = msgNoProviderSelected + return + } + targetIdx := indexes[m.selectedIndex] + if targetIdx < 0 || targetIdx >= len(m.providers.Providers) { + m.secretMessage = msgInvalidProvider + return + } + provider := m.providers.Providers[targetIdx] + + m.ensureSecretsConfig() + if _, ok := m.secrets.Secrets[provider.ID]; !ok { + m.secretMessage = fmt.Sprintf("No API key found for %s", provider.DisplayName) + return + } + + delete(m.secrets.Secrets, provider.ID) + if err := core.SaveSecrets(m.home, m.secrets); err != nil { + m.secretMessage = fmt.Sprintf("Failed to remove API key: %v", err) + return + } + m.secretMessage = fmt.Sprintf("Removed API key for %s", provider.DisplayName) +} + +func (m *DashboardModel) handleSecretTest() { + indexes := m.filteredProviderIndexes() + if len(indexes) == 0 || m.selectedIndex < 0 || m.selectedIndex >= len(indexes) { + m.secretMessage = msgNoProviderSelected + return + } + targetIdx := indexes[m.selectedIndex] + if targetIdx < 0 || targetIdx >= len(m.providers.Providers) { + m.secretMessage = msgInvalidProvider + return + } + provider := m.providers.Providers[targetIdx] + + if _, err := core.ResolveAPIKey(&provider, m.secrets); err != nil { + m.secretMessage = fmt.Sprintf("API key missing: %v", err) + return + } + m.secretMessage = fmt.Sprintf("API key available for %s", provider.DisplayName) +} + +func (m *DashboardModel) startProviderForm(add bool) { + indexes := m.filteredProviderIndexes() + if !add { + if len(indexes) == 0 || m.selectedIndex < 0 || m.selectedIndex >= len(indexes) { + m.providerFormMessage = msgNoProviderSelected + return + } + targetIdx := indexes[m.selectedIndex] + if targetIdx < 0 || targetIdx >= len(m.providers.Providers) { + m.providerFormMessage = msgInvalidProvider + return + } + m.providerFormProvider = m.providers.Providers[targetIdx] + m.providerFormIndex = targetIdx + } else { + m.providerFormProvider = core.Provider{ + Enabled: true, + APIKey: core.APIKeyConfig{ + Source: core.APIKeySourceEnv, + }, + } + m.providerFormIndex = -1 + } + + m.providerFormAdd = add + m.providerFormActive = true + m.providerFormField = 0 + if !add { + // Skip ID when editing existing provider + m.providerFormField = 1 + } + m.prepareProviderFormInput() + m.providerFormInput.Focus() + m.providerFormMessage = "" + m.searchActive = false +} + +func (m *DashboardModel) providerFieldEnabled(field providerFormField) bool { + if !m.providerFormAdd && field == providerFieldID { + return false + } + if field == providerFieldAPIKeyEnv { + return strings.ToLower(string(m.providerFormProvider.APIKey.Source)) == string(core.APIKeySourceEnv) + } + return true +} + +func (m *DashboardModel) prepareProviderFormInput() { + if m.providerFormField >= len(providerFieldSequence) { + return + } + field := providerFieldSequence[m.providerFormField] + m.providerFormInput.Placeholder = m.providerFieldPrompt(field) + switch field { + case providerFieldID: + m.providerFormInput.SetValue(m.providerFormProvider.ID) + case providerFieldKind: + if m.providerFormProvider.Kind != "" { + m.providerFormInput.SetValue(string(m.providerFormProvider.Kind)) + } else { + m.providerFormInput.SetValue("") + } + case providerFieldDisplayName: + m.providerFormInput.SetValue(m.providerFormProvider.DisplayName) + case providerFieldBaseURL: + m.providerFormInput.SetValue(m.providerFormProvider.BaseURL) + case providerFieldDefaultModel: + m.providerFormInput.SetValue(m.providerFormProvider.DefaultModel) + case providerFieldAPIKeySource: + if m.providerFormProvider.APIKey.Source != "" { + m.providerFormInput.SetValue(string(m.providerFormProvider.APIKey.Source)) + } else { + m.providerFormInput.SetValue("") + } + case providerFieldAPIKeyEnv: + m.providerFormInput.SetValue(m.providerFormProvider.APIKey.EnvVar) + } +} + +func (m *DashboardModel) providerFieldPrompt(field providerFormField) string { + switch field { + case providerFieldID: + return "provider id (e.g. openai-official)" + case providerFieldKind: + return "provider kind (openai, anthropic, ...)" + case providerFieldDisplayName: + return "display name" + case providerFieldBaseURL: + return "base URL" + case providerFieldDefaultModel: + return "default model" + case providerFieldAPIKeySource: + return "api key source (env or secrets)" + case providerFieldAPIKeyEnv: + return "env var name (if source=env)" + default: + return "" + } +} + +func (m *DashboardModel) submitProviderFormValue() { + if m.providerFormField >= len(providerFieldSequence) { + return + } + field := providerFieldSequence[m.providerFormField] + value := strings.TrimSpace(m.providerFormInput.Value()) + if err := m.setProviderFieldValue(field, value); err != nil { + m.providerFormMessage = err.Error() + return + } + m.providerFormMessage = "" + m.providerFormInput.SetValue("") + for { + m.providerFormField++ + if m.providerFormField >= len(providerFieldSequence) { + m.finishProviderForm() + return + } + if m.providerFieldEnabled(providerFieldSequence[m.providerFormField]) { + m.prepareProviderFormInput() + return + } + } +} + +func (m *DashboardModel) setProviderFieldValue(field providerFormField, value string) error { + value = strings.TrimSpace(value) + switch field { + case providerFieldID: + return m.setProviderID(value) + case providerFieldKind: + return m.setProviderKind(value) + case providerFieldDisplayName: + return m.setProviderDisplayName(value) + case providerFieldBaseURL: + return m.setProviderBaseURL(value) + case providerFieldDefaultModel: + return m.setProviderDefaultModel(value) + case providerFieldAPIKeySource: + return m.setProviderAPIKeySource(value) + case providerFieldAPIKeyEnv: + return m.setProviderAPIKeyEnv(value) + default: + return nil + } +} + +func (m *DashboardModel) setProviderID(value string) error { + if value == "" { + return fmt.Errorf("provider ID cannot be empty") + } + for i := range m.providers.Providers { + if strings.EqualFold(m.providers.Providers[i].ID, value) { + return fmt.Errorf("provider ID already exists") + } + } + m.providerFormProvider.ID = value + return nil +} + +func (m *DashboardModel) setProviderKind(value string) error { + if value == "" { + return fmt.Errorf("provider kind cannot be empty") + } + m.providerFormProvider.Kind = core.ProviderKind(value) + return nil +} + +func (m *DashboardModel) setProviderDisplayName(value string) error { + if value == "" { + return fmt.Errorf("display name cannot be empty") + } + m.providerFormProvider.DisplayName = value + return nil +} + +func (m *DashboardModel) setProviderBaseURL(value string) error { + if value == "" { + return fmt.Errorf("base URL cannot be empty") + } + m.providerFormProvider.BaseURL = value + return nil +} + +func (m *DashboardModel) setProviderDefaultModel(value string) error { + if value == "" { + return fmt.Errorf("default model cannot be empty") + } + m.providerFormProvider.DefaultModel = value + return nil +} + +func (m *DashboardModel) setProviderAPIKeySource(value string) error { + v := strings.ToLower(value) + switch v { + case "env": + m.providerFormProvider.APIKey.Source = core.APIKeySourceEnv + case "secrets": + m.providerFormProvider.APIKey.Source = core.APIKeySourceSecrets + m.providerFormProvider.APIKey.EnvVar = "" + default: + return fmt.Errorf("api key source must be 'env' or 'secrets'") + } + return nil +} + +func (m *DashboardModel) setProviderAPIKeyEnv(value string) error { + if m.providerFormProvider.APIKey.Source == core.APIKeySourceEnv && value == "" { + return fmt.Errorf("env var is required when source=env") + } + m.providerFormProvider.APIKey.EnvVar = value + return nil +} + +func (m *DashboardModel) finishProviderForm() { + if m.providerFormProvider.ID == "" { + m.providerFormMessage = "provider ID is required" + return + } + + if m.providerFormAdd { + m.providers.Providers = append(m.providers.Providers, m.providerFormProvider) + } else if m.providerFormIndex >= 0 && m.providerFormIndex < len(m.providers.Providers) { + m.providers.Providers[m.providerFormIndex] = m.providerFormProvider + } + + if err := core.SaveProviders(m.home, m.providers); err != nil { + m.providerFormMessage = fmt.Sprintf("failed to save provider: %v", err) + } else if m.providerFormAdd { + m.providerFormMessage = fmt.Sprintf("provider %s created", m.providerFormProvider.DisplayName) + } else { + m.providerFormMessage = fmt.Sprintf("provider %s updated", m.providerFormProvider.DisplayName) + } + + m.providerFormActive = false + m.providerFormAdd = false + m.providerFormInput.Blur() +} type reportOption struct { label string @@ -110,13 +642,39 @@ type DashboardModel struct { table table.Model // State - currentView viewMode - selectedIndex int // Currently selected item in list views - width int - height int - quitting bool - proxyStatus string // proxyStatusRunning, proxyStatusStopped, proxyStatusChecking - message string // Status message to display + currentView viewMode + selectedIndex int // Currently selected item in list views + width int + height int + quitting bool + proxyStatus string // proxyStatusRunning, proxyStatusStopped, proxyStatusChecking + message string // Status message to display + sections []viewSection + currentSection int + sectionViewIndex int + showHelpOverlay bool + searchActive bool + searchInput textinput.Model + searchQuery string + searchContextView viewMode + secretInputActive bool + secretInput textinput.Model + secretTargetIndex int + secretMessage string + providerFormActive bool + providerFormAdd bool + providerFormIndex int + providerFormField int + providerFormInput textinput.Model + providerFormProvider core.Provider + providerFormMessage string + bindingFormActive bool + bindingFormAdd bool + bindingFormIndex int + bindingFormField int + bindingFormInput textinput.Model + bindingFormBinding core.Binding + bindingFormMessage string } // NewDashboard creates a new dashboard model @@ -151,6 +709,34 @@ func NewDashboard(home string) (*DashboardModel, error) { currentView: viewDashboard, } + m.initSections() + searchInput := textinput.New() + searchInput.Placeholder = "Search..." + searchInput.CharLimit = 100 + searchInput.Width = 30 + m.searchInput = searchInput + + secretInput := textinput.New() + secretInput.Placeholder = "Enter API key" + secretInput.CharLimit = 200 + secretInput.Width = 40 + secretInput.Prompt = promptPrefix + secretInput.EchoMode = textinput.EchoPassword + secretInput.EchoCharacter = '•' + m.secretInput = secretInput + + providerInput := textinput.New() + providerInput.CharLimit = 200 + providerInput.Width = 50 + providerInput.Prompt = promptPrefix + m.providerFormInput = providerInput + + bindingInput := textinput.New() + bindingInput.CharLimit = 200 + bindingInput.Width = 40 + bindingInput.Prompt = promptPrefix + m.bindingFormInput = bindingInput + m.initializeTable() return m, nil @@ -329,10 +915,10 @@ func (m *DashboardModel) buildTableRows() []table.Row { } // Proxy status - proxyStatus := proxyStateOff - if binding.UseProxy { - proxyStatus = proxyStateOn - } + proxyStatus := proxyStateOff + if binding.UseProxy { + proxyStatus = proxyStateOn + } rows = append(rows, table.Row{ tool.Name, @@ -405,88 +991,98 @@ func (m DashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "q": + key := msg.String() + if key == "ctrl+c" || key == "q" { m.quitting = true return m, tea.Quit + } - case "1": - m.currentView = viewDashboard - m.selectedIndex = 0 - return m, nil - - case "2": - m.currentView = viewProviders - m.selectedIndex = 0 - return m, nil - - case "3": - m.currentView = viewTools - m.selectedIndex = 0 - return m, nil - - case "4": - m.currentView = viewBindings - m.selectedIndex = 0 - return m, nil - - case "5": - m.currentView = viewSecrets - m.selectedIndex = 0 - return m, nil - - case "6", "v": - // Stats view - m.currentView = viewStats - m.selectedIndex = 0 - // Reload stats when switching to stats view - return m, m.loadStatsData - - case "7": - m.currentView = viewProxy - m.selectedIndex = 0 - return m, checkProxyStatus - - case "8": - m.currentView = viewRouting - m.selectedIndex = 0 - return m, nil - - case "9": - m.currentView = viewSuggestions - m.selectedIndex = 0 - return m, m.loadSuggestions + if m.providerFormActive { + switch key { + case keyEsc: + m.providerFormActive = false + m.providerFormInput.Blur() + m.providerFormMessage = "Provider edit canceled" + return m, nil + case keyEnter: + m.submitProviderFormValue() + return m, nil + default: + var cmd tea.Cmd + m.providerFormInput, cmd = m.providerFormInput.Update(msg) + return m, cmd + } + } - case "0": - m.currentView = viewReports - m.selectedIndex = 0 - return m, nil + if m.bindingFormActive { + switch key { + case keyEsc: + m.bindingFormActive = false + m.bindingFormInput.Blur() + m.bindingFormMessage = "Binding edit canceled" + return m, nil + case keyEnter: + m.submitBindingFormValue() + return m, nil + default: + var cmd tea.Cmd + m.bindingFormInput, cmd = m.bindingFormInput.Update(msg) + return m, cmd + } + } - case "h", "H": - m.currentView = viewHooks - m.selectedIndex = 0 - return m, nil + if m.searchActive { + switch key { + case keyEsc: + m.clearSearch() + return m, nil + case keyEnter: + m.searchQuery = strings.TrimSpace(m.searchInput.Value()) + m.searchActive = false + return m, nil + default: + var cmd tea.Cmd + m.searchInput, cmd = m.searchInput.Update(msg) + m.searchQuery = m.searchInput.Value() + return m, cmd + } + } - case "c", "C": - m.currentView = viewConfig - m.selectedIndex = 0 - return m, nil + if m.secretInputActive { + switch key { + case keyEsc: + m.secretInputActive = false + m.secretInput.Blur() + m.secretInput.SetValue("") + return m, nil + case keyEnter: + m.saveSecretInput() + return m, nil + default: + var cmd tea.Cmd + m.secretInput, cmd = m.secretInput.Update(msg) + return m, cmd + } + } + switch key { case "tab": - // Cycle through views - m.currentView = (m.currentView + 1) % totalViews - m.selectedIndex = 0 - switch m.currentView { - case viewStats: - return m, m.loadStatsData - case viewProxy: - return m, checkProxyStatus - case viewSuggestions: - return m, m.loadSuggestions - } - return m, nil + return m, m.cycleSection(1) + case "shift+tab": + return m, m.cycleSection(-1) + case "[": + return m, m.cycleSubview(-1) + case "]": + return m, m.cycleSubview(1) + case "1", "2", "3", "4", "5": + sectionIndex := int(key[0] - '1') + return m, m.moveToSection(sectionIndex) case "r": + if m.currentView == viewSecrets { + m.handleSecretRemove() + return m, nil + } // Run selected tool (only in dashboard view) if m.currentView == viewDashboard { return m.handleRun() @@ -510,18 +1106,65 @@ func (m DashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case "s": + if m.currentView == viewSecrets { + m.startSecretInput() + return m, nil + } // Check proxy status m.proxyStatus = proxyStatusChecking return m, checkProxyStatus - case "p": - // View providers (placeholder for now) - // In future, this would open provider management view + case "e": + if m.currentView == viewProviders { + m.startProviderForm(false) + return m, nil + } + if m.currentView == viewBindings { + m.startBindingForm(false) + return m, nil + } + return m, nil + + case "n": + if m.currentView == viewBindings { + m.startBindingForm(true) + return m, nil + } + return m, nil + + case "a": + if m.currentView == viewProviders { + m.startProviderForm(true) + return m, nil + } + return m, nil + + case "t": + if m.currentView == viewSecrets { + m.handleSecretTest() + return m, nil + } + return m, nil + + case "/": + if m.supportsSearch(m.currentView) { + m.activateSearch() + } return m, nil case "?": - m.currentView = viewHelp - m.selectedIndex = 0 + m.showHelpOverlay = !m.showHelpOverlay + return m, nil + + case "esc": + if m.showHelpOverlay { + m.showHelpOverlay = false + return m, nil + } + if m.viewHasSearch(m.currentView) { + m.clearSearch() + return m, nil + } return m, nil case "up", "k": @@ -557,13 +1200,13 @@ func (m DashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m DashboardModel) maxSelectableIndex() int { switch m.currentView { case viewProviders: - return len(m.providers.Providers) - 1 + return len(m.filteredProviderIndexes()) - 1 case viewTools: - return len(m.tools.Tools) - 1 + return len(m.filteredToolIndexes()) - 1 case viewBindings: - return len(m.bindings.Bindings) - 1 + return len(m.filteredBindingIndexes()) - 1 case viewSecrets: - return len(m.providers.Providers) - 1 // Secrets are per-provider + return len(m.filteredProviderIndexes()) - 1 // Secrets are per-provider case viewSuggestions: return len(m.suggestions) - 1 case viewReports: @@ -575,27 +1218,99 @@ func (m DashboardModel) maxSelectableIndex() int { } } -// updateTableSize adjusts table dimensions based on window size -func (m *DashboardModel) updateTableSize() { - // Calculate available height for table - headerHeight := 3 - footerHeight := 2 - availableHeight := m.height - headerHeight - footerHeight +func (m *DashboardModel) viewHasSearch(view viewMode) bool { + return strings.TrimSpace(m.searchQuery) != "" && m.searchContextView == view +} - if availableHeight < 5 { - availableHeight = 5 +func (m *DashboardModel) filteredProviderIndexes() []int { + if m.providers == nil { + return nil + } + total := len(m.providers.Providers) + indexes := make([]int, 0, total) + query := strings.ToLower(strings.TrimSpace(m.searchQuery)) + for i, provider := range m.providers.Providers { + if !m.viewHasSearch(viewProviders) || + strings.Contains(strings.ToLower(provider.DisplayName), query) || + strings.Contains(strings.ToLower(provider.ID), query) || + strings.Contains(strings.ToLower(provider.BaseURL), query) { + indexes = append(indexes, i) + } } + return indexes +} - // Update column widths based on width - columns := m.table.Columns() - if m.width > 100 { - columns[0].Width = 15 // Tool - columns[1].Width = 25 // Provider - columns[2].Width = 28 // Model - columns[3].Width = 10 // Proxy - columns[4].Width = 15 // Status - } else if m.width < 80 { - columns[0].Width = 10 // Tool +func (m *DashboardModel) filteredToolIndexes() []int { + if m.tools == nil { + return nil + } + total := len(m.tools.Tools) + indexes := make([]int, 0, total) + query := strings.ToLower(strings.TrimSpace(m.searchQuery)) + for i, tool := range m.tools.Tools { + if !m.viewHasSearch(viewTools) || + strings.Contains(strings.ToLower(tool.Name), query) || + strings.Contains(strings.ToLower(tool.ID), query) || + strings.Contains(strings.ToLower(tool.Exec), query) { + indexes = append(indexes, i) + } + } + return indexes +} + +func (m *DashboardModel) filteredBindingIndexes() []int { + if m.bindings == nil { + return nil + } + total := len(m.bindings.Bindings) + indexes := make([]int, 0, total) + query := strings.ToLower(strings.TrimSpace(m.searchQuery)) + for i, binding := range m.bindings.Bindings { + if !m.viewHasSearch(viewBindings) || + strings.Contains(strings.ToLower(binding.ToolID), query) || + strings.Contains(strings.ToLower(binding.ProviderID), query) { + indexes = append(indexes, i) + } + } + return indexes +} + +func (m DashboardModel) renderSearchBar(view viewMode) string { + if !m.supportsSearch(view) { + return "" + } + style := lipgloss.NewStyle().Foreground(m.theme.Muted).Padding(0, 2) + switch { + case m.searchActive && m.searchContextView == view: + return style.Render("Search: " + m.searchInput.View()) + case m.viewHasSearch(view): + return style.Render(fmt.Sprintf("Filter: %s (Esc to clear)", m.searchQuery)) + default: + return style.Render("Press '/' to search") + } +} + +// updateTableSize adjusts table dimensions based on window size +func (m *DashboardModel) updateTableSize() { + // Calculate available height for table + headerHeight := 3 + footerHeight := 2 + availableHeight := m.height - headerHeight - footerHeight + + if availableHeight < 5 { + availableHeight = 5 + } + + // Update column widths based on width + columns := m.table.Columns() + if m.width > 100 { + columns[0].Width = 15 // Tool + columns[1].Width = 25 // Provider + columns[2].Width = 28 // Model + columns[3].Width = 10 // Proxy + columns[4].Width = 15 // Status + } else if m.width < 80 { + columns[0].Width = 10 // Tool columns[1].Width = 18 // Provider columns[2].Width = 20 // Model columns[3].Width = 8 // Proxy @@ -657,28 +1372,30 @@ func (m DashboardModel) handleToggleProxy() (tea.Model, tea.Cmd) { m.table.SetRows(m.buildTableRows()) // Set success message - proxyState := proxyStateOff - if binding.UseProxy { - proxyState = proxyStateOn - } - m.message = fmt.Sprintf("Proxy %s for %s", proxyState, tool.Name) + proxyState := proxyStateOff + if binding.UseProxy { + proxyState = proxyStateOn + } + m.message = fmt.Sprintf("Proxy %s for %s", proxyState, tool.Name) return m, nil } // handleToggleBindingProxy toggles proxy usage for the selected binding in the bindings view func (m DashboardModel) handleToggleBindingProxy() (tea.Model, tea.Cmd) { - if len(m.bindings.Bindings) == 0 { + indexes := m.filteredBindingIndexes() + + if len(indexes) == 0 { m.message = "No bindings configured" return m, nil } - if m.selectedIndex < 0 || m.selectedIndex >= len(m.bindings.Bindings) { + if m.selectedIndex < 0 || m.selectedIndex >= len(indexes) { m.message = "No binding selected" return m, nil } - binding := &m.bindings.Bindings[m.selectedIndex] + binding := &m.bindings.Bindings[indexes[m.selectedIndex]] toolName := binding.ToolID if tool, err := m.tools.FindTool(binding.ToolID); err == nil { @@ -692,10 +1409,10 @@ func (m DashboardModel) handleToggleBindingProxy() (tea.Model, tea.Cmd) { return m, nil } - proxyState := proxyStateOff - if binding.UseProxy { - proxyState = proxyStateOn - } + proxyState := proxyStateOff + if binding.UseProxy { + proxyState = proxyStateOn + } // Update dashboard table rows to keep views consistent m.table.SetRows(m.buildTableRows()) @@ -710,6 +1427,10 @@ func (m DashboardModel) View() string { return "" } + if m.showHelpOverlay { + return m.renderHelpView() + } + switch m.currentView { case viewProviders: return m.renderProvidersView() @@ -742,351 +1463,345 @@ func (m DashboardModel) View() string { // renderDashboardView renders the main dashboard view func (m DashboardModel) renderDashboardView() string { - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(m.theme.Primary). - Padding(0, 2) - - proxyStyle := lipgloss.NewStyle(). - Foreground(m.theme.Text). - Padding(0, 2) - - helpStyle := lipgloss.NewStyle(). - Foreground(m.theme.Muted). - Padding(1, 2) - - messageStyle := lipgloss.NewStyle(). - Foreground(m.theme.Success). - Padding(0, 2) - - var content strings.Builder - - // Header - title := "BobaMixer - AI CLI Control Plane" - content.WriteString(titleStyle.Render(title)) - - // Proxy status - proxyStatusIcon := iconCircleEmpty - proxyStatusText := "Checking..." + proxyIcon := iconCircleEmpty + proxyText := "Checking..." switch m.proxyStatus { case proxyStatusRunning: - proxyStatusIcon = iconCircleFilled - proxyStatusText = "Running" + proxyIcon = iconCircleFilled + proxyText = "Running" case proxyStatusStopped: - proxyStatusIcon = iconCircleEmpty - proxyStatusText = "Stopped" + proxyIcon = iconCircleEmpty + proxyText = "Stopped" } - proxyInfo := fmt.Sprintf(" Proxy: %s %s", proxyStatusIcon, proxyStatusText) - content.WriteString(proxyStyle.Render(proxyInfo)) - content.WriteString("\n\n") - // Table - content.WriteString(m.table.View()) - content.WriteString("\n") - - // Message - if m.message != "" { - content.WriteString(messageStyle.Render(" " + m.message)) - content.WriteString("\n") + props := dashboardviews.DashboardViewProps{ + Theme: newDashboardViewTheme(m.theme), + TableView: m.table.View(), + Message: m.message, + ProxyIcon: proxyIcon, + ProxyStatus: proxyText, + NavigationHelp: helpTextNavigation, + HelpCommands: "[R] Run Tool [X] Toggle Proxy", } - // Footer/Help - helpText := "[1-9,0,H,C,?] Switch View [R] Run Tool [X] Toggle Proxy [Tab] Next View [Q] Quit" - content.WriteString(helpStyle.Render(helpText)) - - return content.String() + return dashboardviews.RenderDashboardView(props) } // renderStatsView renders the usage statistics view func (m DashboardModel) renderStatsView() string { - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(m.theme.Primary). - Padding(0, 2) - - sectionStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(m.theme.Success). - Padding(1, 2) - - dataStyle := lipgloss.NewStyle(). - Foreground(m.theme.Text). - Padding(0, 2) - - helpStyle := lipgloss.NewStyle(). - Foreground(m.theme.Muted). - Padding(1, 2) - - errorStyle := lipgloss.NewStyle(). - Foreground(m.theme.Danger). - Padding(0, 2) - - var content strings.Builder - - // Header - title := "BobaMixer - Usage Statistics" - content.WriteString(titleStyle.Render(title)) - content.WriteString("\n\n") - - // Check if stats are loaded - if !m.statsLoaded { - if m.statsError != "" { - content.WriteString(errorStyle.Render(fmt.Sprintf("Error loading stats: %s", m.statsError))) - } else { - content.WriteString(dataStyle.Render("Loading stats...")) - } - content.WriteString("\n\n") - helpText := "[V] Back to Dashboard [Q] Quit" - content.WriteString(helpStyle.Render(helpText)) - return content.String() + props := dashboardviews.StatsViewProps{ + Theme: newDashboardViewTheme(m.theme), + Loaded: m.statsLoaded, + Error: m.statsError, + LoadingMessage: "Loading stats...", + Today: convertStatsSummaryToView("📅 Today's Usage", m.todayStats, false), + Week: convertStatsSummaryToView("📊 Last 7 Days", m.weekStats, true), + Profiles: convertProfileStatsToView(m.profileStats), + NavigationHelp: "[V] Back to Dashboard [S] Refresh [Q] Quit", + LoadingHelp: "[V] Back to Dashboard [Q] Quit", + ProfileSubtitle: "🎯 By Profile (7d)", } - // Today's Stats - content.WriteString(sectionStyle.Render("📅 Today's Usage")) - content.WriteString("\n") - content.WriteString(dataStyle.Render(fmt.Sprintf(" Tokens: %d", m.todayStats.TotalTokens))) - content.WriteString("\n") - content.WriteString(dataStyle.Render(fmt.Sprintf(" Cost: $%.4f", m.todayStats.TotalCost))) - content.WriteString("\n") - content.WriteString(dataStyle.Render(fmt.Sprintf(" Sessions: %d", m.todayStats.TotalSessions))) - content.WriteString("\n\n") - - // Last 7 Days Stats - content.WriteString(sectionStyle.Render("📊 Last 7 Days")) - content.WriteString("\n") - content.WriteString(dataStyle.Render(fmt.Sprintf(" Total Tokens: %d", m.weekStats.TotalTokens))) - content.WriteString("\n") - content.WriteString(dataStyle.Render(fmt.Sprintf(" Total Cost: $%.4f", m.weekStats.TotalCost))) - content.WriteString("\n") - content.WriteString(dataStyle.Render(fmt.Sprintf(" Total Sessions: %d", m.weekStats.TotalSessions))) - content.WriteString("\n") - content.WriteString(dataStyle.Render(fmt.Sprintf(" Avg Daily Tokens: %.0f", m.weekStats.AvgDailyTokens))) - content.WriteString("\n") - content.WriteString(dataStyle.Render(fmt.Sprintf(" Avg Daily Cost: $%.4f", m.weekStats.AvgDailyCost))) - content.WriteString("\n\n") + return dashboardviews.RenderStatsView(props) +} - // Profile Breakdown - if len(m.profileStats) > 0 { - content.WriteString(sectionStyle.Render("🎯 By Profile (7d)")) - content.WriteString("\n") - for _, ps := range m.profileStats { - line := fmt.Sprintf(" • %s: tokens=%d cost=$%.4f sessions=%d latency=%.0fms usage=%.1f%% cost=%.1f%%", - ps.ProfileName, - ps.TotalTokens, - ps.TotalCost, - ps.SessionCount, - ps.AvgLatencyMS, - ps.UsagePercent, - ps.CostPercent, - ) - content.WriteString(dataStyle.Render(line)) - content.WriteString("\n") - } - content.WriteString("\n") +// renderProvidersView renders the AI providers management view +func (m DashboardModel) renderProvidersView() string { + indexes := m.filteredProviderIndexes() + if len(indexes) > 0 && m.selectedIndex >= len(indexes) { + m.selectedIndex = len(indexes) - 1 } - // Footer/Help - helpText := "[V] Back to Dashboard [S] Refresh [Q] Quit" - content.WriteString(helpStyle.Render(helpText)) + props := dashboardviews.ProvidersViewProps{ + Theme: newDashboardViewTheme(m.theme), + ProviderForm: m.renderProviderForm(), + ShowProviderForm: m.providerFormActive, + ProviderFormMessage: strings.TrimSpace(m.providerFormMessage), + SearchBar: m.renderSearchBar(viewProviders), + EmptyStateMessage: providersEmptyStateMessage(len(indexes) == 0, m.viewHasSearch(viewProviders)), + Providers: convertProvidersToView(indexes, m.providers, m.secrets), + SelectedIndex: m.selectedIndex, + Details: convertProviderDetailsToView(indexes, m.selectedIndex, m.providers), + NavigationHelp: helpTextNavigation, + HelpCommands: "[E] Edit provider [A] Add provider", + EnabledIcon: iconCheckmark, + DisabledIcon: iconCross, + KeyPresentIcon: "🔑", + KeyMissingIcon: "⚠", + } - return content.String() + return dashboardviews.RenderProvidersView(props) } -// renderProvidersView renders the AI providers management view -func (m DashboardModel) renderProvidersView() string { - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(m.theme.Primary). - Padding(0, 2) - - headerStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(m.theme.Success). - Padding(1, 2) +func (m DashboardModel) renderProviderForm() string { + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(m.theme.Primary). + Padding(1, 2). + Width(70) - selectedStyle := lipgloss.NewStyle(). - Foreground(m.theme.Text). - Background(m.theme.Primary). + titleStyle := lipgloss.NewStyle(). Bold(true). - Padding(0, 1) - - normalStyle := lipgloss.NewStyle(). - Foreground(m.theme.Text). - Padding(0, 1) + Foreground(m.theme.Primary) - mutedStyle := lipgloss.NewStyle(). - Foreground(m.theme.Muted). - Padding(0, 1) + infoStyle := lipgloss.NewStyle(). + Foreground(m.theme.Muted) - helpStyle := lipgloss.NewStyle(). - Foreground(m.theme.Muted). - Padding(1, 2) + field := providerFieldSequence[m.providerFormField] + title := "Edit Provider" + if m.providerFormAdd { + title = "Add Provider" + } - var content strings.Builder + var currentName string + if m.providerFormProvider.DisplayName != "" { + currentName = fmt.Sprintf(" (%s)", m.providerFormProvider.DisplayName) + } - // Header - title := "BobaMixer - AI Providers Management" - content.WriteString(titleStyle.Render(title)) - content.WriteString("\n\n") + body := strings.Builder{} + body.WriteString(titleStyle.Render(title + currentName)) + body.WriteString("\n\n") + body.WriteString(infoStyle.Render(fmt.Sprintf("Field: %s", m.providerFieldPrompt(field)))) + body.WriteString("\n") + body.WriteString(m.providerFormInput.View()) + body.WriteString("\n\n") + body.WriteString(infoStyle.Render("Enter to confirm • Esc to cancel")) + if strings.TrimSpace(m.providerFormMessage) != "" { + body.WriteString("\n") + body.WriteString(infoStyle.Render(m.providerFormMessage)) + } - // Section header - content.WriteString(headerStyle.Render("📡 Available Providers")) - content.WriteString("\n\n") + return boxStyle.Render(body.String()) +} - // Provider list - if len(m.providers.Providers) == 0 { - content.WriteString(mutedStyle.Render(" No providers configured.")) - content.WriteString("\n") +func (m *DashboardModel) startBindingForm(add bool) { + indexes := m.filteredBindingIndexes() + if !add { + if len(indexes) == 0 || m.selectedIndex < 0 || m.selectedIndex >= len(indexes) { + m.bindingFormMessage = msgNoProviderSelected + return + } + targetIdx := indexes[m.selectedIndex] + if targetIdx < 0 || targetIdx >= len(m.bindings.Bindings) { + m.bindingFormMessage = msgInvalidProvider + return + } + m.bindingFormBinding = m.bindings.Bindings[targetIdx] + m.bindingFormIndex = targetIdx } else { - for i, provider := range m.providers.Providers { - // Status indicators - enabledIcon := iconCheckmark - if !provider.Enabled { - enabledIcon = iconCross - } - - // Check if API key is configured - keyStatus := "⚠" - if _, err := core.ResolveAPIKey(&provider, m.secrets); err == nil { - keyStatus = "🔑" - } - - line := fmt.Sprintf(" %s %s %-25s %-35s %s", - enabledIcon, - keyStatus, - provider.DisplayName, - provider.BaseURL, - provider.DefaultModel, - ) - - if i == m.selectedIndex { - content.WriteString(selectedStyle.Render("▶ " + line)) - } else { - content.WriteString(normalStyle.Render(" " + line)) - } - content.WriteString("\n") + m.bindingFormBinding = core.Binding{ + Options: core.BindingOptions{}, } + m.bindingFormIndex = -1 } - content.WriteString("\n") + m.bindingFormAdd = add + m.bindingFormActive = true + m.bindingFormField = 0 + if !add { + m.bindingFormField = 1 // skip tool ID when editing + } + m.prepareBindingFormInput() + m.bindingFormInput.Focus() + m.bindingFormMessage = "" + m.searchActive = false +} + +func (m *DashboardModel) bindingFieldEnabled(field bindingFormField) bool { + if !m.bindingFormAdd && field == bindingFieldToolID { + return false + } + return true +} - // Selected provider details - if m.selectedIndex < len(m.providers.Providers) { - provider := m.providers.Providers[m.selectedIndex] - content.WriteString(headerStyle.Render("Details")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" ID: %s", provider.ID))) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" Kind: %s", provider.Kind))) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" API Key Source: %s", provider.APIKey.Source))) - content.WriteString("\n") - if provider.APIKey.Source == core.APIKeySourceEnv { - content.WriteString(normalStyle.Render(fmt.Sprintf(" Env Var: %s", provider.APIKey.EnvVar))) - content.WriteString("\n") +func (m *DashboardModel) prepareBindingFormInput() { + if m.bindingFormField >= len(bindingFieldSequence) { + return + } + field := bindingFieldSequence[m.bindingFormField] + m.bindingFormInput.Placeholder = m.bindingFieldPrompt(field) + switch field { + case bindingFieldToolID: + m.bindingFormInput.SetValue(m.bindingFormBinding.ToolID) + case bindingFieldProviderID: + m.bindingFormInput.SetValue(m.bindingFormBinding.ProviderID) + case bindingFieldModel: + m.bindingFormInput.SetValue(m.bindingFormBinding.Options.Model) + case bindingFieldUseProxy: + if m.bindingFormBinding.UseProxy { + m.bindingFormInput.SetValue("on") + } else { + m.bindingFormInput.SetValue("off") } - content.WriteString("\n") } +} - // Footer/Help - content.WriteString(helpStyle.Render(helpTextNavigation)) +func (m *DashboardModel) bindingFieldPrompt(field bindingFormField) string { + switch field { + case bindingFieldToolID: + return "tool id (e.g. claude)" + case bindingFieldProviderID: + return "provider id (e.g. openai-official)" + case bindingFieldModel: + return "model override (optional)" + case bindingFieldUseProxy: + return "use proxy? (on/off)" + default: + return "" + } +} - return content.String() +func (m *DashboardModel) submitBindingFormValue() { + if m.bindingFormField >= len(bindingFieldSequence) { + return + } + field := bindingFieldSequence[m.bindingFormField] + value := strings.TrimSpace(m.bindingFormInput.Value()) + if err := m.setBindingFieldValue(field, value); err != nil { + m.bindingFormMessage = err.Error() + return + } + m.bindingFormMessage = "" + m.bindingFormInput.SetValue("") + for { + m.bindingFormField++ + if m.bindingFormField >= len(bindingFieldSequence) { + m.finishBindingForm() + return + } + if m.bindingFieldEnabled(bindingFieldSequence[m.bindingFormField]) { + m.prepareBindingFormInput() + return + } + } } -// renderToolsView renders the CLI tools management view -func (m DashboardModel) renderToolsView() string { - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(m.theme.Primary). - Padding(0, 2) +func (m *DashboardModel) setBindingFieldValue(field bindingFormField, value string) error { + switch field { + case bindingFieldToolID: + if value == "" { + return fmt.Errorf("tool id cannot be empty") + } + if _, err := m.tools.FindTool(value); err != nil { + return fmt.Errorf("tool %s not found", value) + } + if _, err := m.bindings.FindBinding(value); err == nil { + return fmt.Errorf("binding for %s already exists", value) + } + m.bindingFormBinding.ToolID = value + case bindingFieldProviderID: + if value == "" { + return fmt.Errorf("provider id cannot be empty") + } + if _, err := m.providers.FindProvider(value); err != nil { + return fmt.Errorf("provider %s not found", value) + } + m.bindingFormBinding.ProviderID = value + case bindingFieldModel: + m.bindingFormBinding.Options.Model = value + case bindingFieldUseProxy: + val := strings.ToLower(value) + switch val { + case "on", "true", "yes", "y": + m.bindingFormBinding.UseProxy = true + case "off", "false", "no", "n": + m.bindingFormBinding.UseProxy = false + default: + return fmt.Errorf("use proxy must be on/off") + } + } + return nil +} - headerStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(m.theme.Success). - Padding(1, 2) +func (m *DashboardModel) finishBindingForm() { + if m.bindingFormBinding.ToolID == "" { + m.bindingFormMessage = "tool id is required" + return + } + if m.bindingFormBinding.ProviderID == "" { + m.bindingFormMessage = "provider id is required" + return + } - selectedStyle := lipgloss.NewStyle(). - Foreground(m.theme.Text). - Background(m.theme.Primary). - Bold(true). - Padding(0, 1) + if m.bindingFormAdd { + m.bindings.Bindings = append(m.bindings.Bindings, m.bindingFormBinding) + } else if m.bindingFormIndex >= 0 && m.bindingFormIndex < len(m.bindings.Bindings) { + m.bindings.Bindings[m.bindingFormIndex] = m.bindingFormBinding + } - normalStyle := lipgloss.NewStyle(). - Foreground(m.theme.Text). - Padding(0, 1) + if err := core.SaveBindings(m.home, m.bindings); err != nil { + m.bindingFormMessage = fmt.Sprintf("failed to save binding: %v", err) + return + } - mutedStyle := lipgloss.NewStyle(). - Foreground(m.theme.Muted). - Padding(0, 1) + m.table.SetRows(m.buildTableRows()) + if m.bindingFormAdd { + m.bindingFormMessage = fmt.Sprintf("binding created for %s", m.bindingFormBinding.ToolID) + } else { + m.bindingFormMessage = fmt.Sprintf("binding updated for %s", m.bindingFormBinding.ToolID) + } - helpStyle := lipgloss.NewStyle(). - Foreground(m.theme.Muted). - Padding(1, 2) + m.bindingFormActive = false + m.bindingFormAdd = false + m.bindingFormInput.Blur() +} - var content strings.Builder +func (m DashboardModel) renderBindingForm() string { + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(m.theme.Primary). + Padding(1, 2). + Width(70) - // Header - title := "BobaMixer - CLI Tools Management" - content.WriteString(titleStyle.Render(title)) - content.WriteString("\n\n") + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(m.theme.Primary) - // Section header - content.WriteString(headerStyle.Render("🛠 Detected Tools")) - content.WriteString("\n\n") + infoStyle := lipgloss.NewStyle(). + Foreground(m.theme.Muted) - // Tools list - if len(m.tools.Tools) == 0 { - content.WriteString(mutedStyle.Render(" No tools configured.")) - content.WriteString("\n") - } else { - for i, tool := range m.tools.Tools { - // Check if tool has a binding - boundIcon := iconCircleEmpty - if _, err := m.bindings.FindBinding(tool.ID); err == nil { - boundIcon = iconCircleFilled - } + field := bindingFieldSequence[m.bindingFormField] + title := "Edit Binding" + if m.bindingFormAdd { + title = "Add Binding" + } - line := fmt.Sprintf(" %s %-15s %-30s %s", - boundIcon, - tool.Name, - tool.Exec, - tool.Kind, - ) - - if i == m.selectedIndex { - content.WriteString(selectedStyle.Render("▶ " + line)) - } else { - content.WriteString(normalStyle.Render(" " + line)) - } - content.WriteString("\n") - } + body := strings.Builder{} + body.WriteString(titleStyle.Render(fmt.Sprintf("%s (%s)", title, m.bindingFormBinding.ToolID))) + body.WriteString("\n\n") + body.WriteString(infoStyle.Render(fmt.Sprintf("Field: %s", m.bindingFieldPrompt(field)))) + body.WriteString("\n") + body.WriteString(m.bindingFormInput.View()) + body.WriteString("\n\n") + body.WriteString(infoStyle.Render("Enter to confirm • Esc to cancel")) + if strings.TrimSpace(m.bindingFormMessage) != "" { + body.WriteString("\n") + body.WriteString(infoStyle.Render(m.bindingFormMessage)) } - content.WriteString("\n") + return boxStyle.Render(body.String()) +} - // Selected tool details - if m.selectedIndex < len(m.tools.Tools) { - tool := m.tools.Tools[m.selectedIndex] - content.WriteString(headerStyle.Render("Details")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" ID: %s", tool.ID))) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" Config Type: %s", tool.ConfigType))) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" Config Path: %s", tool.ConfigPath))) - content.WriteString("\n") - if tool.Description != "" { - content.WriteString(normalStyle.Render(fmt.Sprintf(" Description: %s", tool.Description))) - content.WriteString("\n") - } - content.WriteString("\n") +// renderToolsView renders the CLI tools management view +func (m DashboardModel) renderToolsView() string { + indexes := m.filteredToolIndexes() + if len(indexes) > 0 && m.selectedIndex >= len(indexes) { + m.selectedIndex = len(indexes) - 1 } - // Footer/Help - content.WriteString(helpStyle.Render(helpTextNavigation)) + props := dashboardviews.ToolsViewProps{ + Theme: newDashboardViewTheme(m.theme), + SearchBar: m.renderSearchBar(viewTools), + EmptyStateMessage: toolsEmptyStateMessage(len(indexes) == 0, m.viewHasSearch(viewTools)), + Tools: convertToolsToView(indexes, m.tools, m.bindings), + SelectedIndex: m.selectedIndex, + Details: convertToolDetailsToView(m.selectedIndex, m.tools), + NavigationHelp: helpTextNavigation, + BoundIcon: iconCircleFilled, + UnboundIcon: iconCircleEmpty, + } - return content.String() + return dashboardviews.RenderToolsView(props) } // renderBindingsView renders the tool-to-provider bindings view @@ -1130,499 +1845,225 @@ func (m DashboardModel) renderBindingsView() string { content.WriteString(headerStyle.Render("🔗 Active Bindings")) content.WriteString("\n\n") - // Bindings list - if len(m.bindings.Bindings) == 0 { - content.WriteString(mutedStyle.Render(" No bindings configured.")) - content.WriteString("\n") - } else { - for i, binding := range m.bindings.Bindings { - // Get tool name - toolName := binding.ToolID - if tool, err := m.tools.FindTool(binding.ToolID); err == nil { - toolName = tool.Name - } - - // Get provider name - providerName := binding.ProviderID - if provider, err := m.providers.FindProvider(binding.ProviderID); err == nil { - providerName = provider.DisplayName - } - - // Proxy status - proxyIcon := iconCircleEmpty - if binding.UseProxy { - proxyIcon = iconCircleFilled - } - - line := fmt.Sprintf(" %-15s → %-25s Proxy: %s", - toolName, - providerName, - proxyIcon, - ) + content.WriteString(m.renderBindingFormSection(mutedStyle)) - if i == m.selectedIndex { - content.WriteString(selectedStyle.Render("▶ " + line)) - } else { - content.WriteString(normalStyle.Render(" " + line)) - } - content.WriteString("\n") - } + if searchBar := m.renderSearchBar(viewBindings); searchBar != "" { + content.WriteString(searchBar) + content.WriteString("\n\n") } + indexes := m.filteredBindingIndexes() + content.WriteString(m.renderBindingList(indexes, selectedStyle, normalStyle, mutedStyle)) content.WriteString("\n") - - // Selected binding details - if m.selectedIndex < len(m.bindings.Bindings) { - binding := m.bindings.Bindings[m.selectedIndex] - content.WriteString(headerStyle.Render("Details")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" Tool ID: %s", binding.ToolID))) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" Provider ID: %s", binding.ProviderID))) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" Use Proxy: %t", binding.UseProxy))) - content.WriteString("\n") - if binding.Options.Model != "" { - content.WriteString(normalStyle.Render(fmt.Sprintf(" Model Override: %s", binding.Options.Model))) - content.WriteString("\n") - } - content.WriteString("\n") - } + content.WriteString(m.renderBindingDetails(indexes, headerStyle, normalStyle)) // Footer/Help - helpText := "[1-6] Switch View [↑/↓] Navigate [X] Toggle Proxy [Tab] Next View [Q] Quit" + helpText := helpTextNavigation + " [E] Edit binding [N] New binding [X] Toggle Proxy" content.WriteString(helpStyle.Render(helpText)) return content.String() } -// renderSecretsView renders the API keys/secrets management view -func (m DashboardModel) renderSecretsView() string { - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(m.theme.Primary). - Padding(0, 2) - - headerStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(m.theme.Success). - Padding(1, 2) +func (m DashboardModel) renderBindingFormSection(mutedStyle lipgloss.Style) string { + var b strings.Builder + if m.bindingFormActive { + b.WriteString(m.renderBindingForm()) + b.WriteString("\n\n") + return b.String() + } + if msg := strings.TrimSpace(m.bindingFormMessage); msg != "" { + b.WriteString(mutedStyle.Render(" " + msg)) + b.WriteString("\n\n") + } + return b.String() +} - selectedStyle := lipgloss.NewStyle(). - Foreground(m.theme.Text). - Background(m.theme.Primary). - Bold(true). - Padding(0, 1) +func (m *DashboardModel) renderBindingList(indexes []int, selectedStyle, normalStyle, mutedStyle lipgloss.Style) string { + var b strings.Builder + if len(indexes) == 0 { + if m.viewHasSearch(viewBindings) { + b.WriteString(mutedStyle.Render(" No bindings match the current filter.")) + } else { + b.WriteString(mutedStyle.Render(" No bindings configured.")) + } + b.WriteString("\n") + return b.String() + } - normalStyle := lipgloss.NewStyle(). - Foreground(m.theme.Text). - Padding(0, 1) + if m.selectedIndex >= len(indexes) { + m.selectedIndex = len(indexes) - 1 + } - mutedStyle := lipgloss.NewStyle(). - Foreground(m.theme.Muted). - Padding(0, 1) + for displayIdx, bindingIdx := range indexes { + binding := m.bindings.Bindings[bindingIdx] + toolName := binding.ToolID + if tool, err := m.tools.FindTool(binding.ToolID); err == nil { + toolName = tool.Name + } - dangerStyle := lipgloss.NewStyle(). - Foreground(m.theme.Danger). - Padding(0, 1) + providerName := binding.ProviderID + if provider, err := m.providers.FindProvider(binding.ProviderID); err == nil { + providerName = provider.DisplayName + } - successStyle := lipgloss.NewStyle(). - Foreground(m.theme.Success). - Padding(0, 1) + proxyIcon := iconCircleEmpty + if binding.UseProxy { + proxyIcon = iconCircleFilled + } - helpStyle := lipgloss.NewStyle(). - Foreground(m.theme.Muted). - Padding(1, 2) + line := fmt.Sprintf(" %-15s → %-25s Proxy: %s", toolName, providerName, proxyIcon) - var content strings.Builder + if displayIdx == m.selectedIndex { + b.WriteString(selectedStyle.Render("▶ " + line)) + } else { + b.WriteString(normalStyle.Render(" " + line)) + } + b.WriteString("\n") + } + return b.String() +} - // Header - title := "BobaMixer - Secrets Management (API Keys)" - content.WriteString(titleStyle.Render(title)) - content.WriteString("\n\n") +func (m DashboardModel) renderBindingDetails(indexes []int, headerStyle, normalStyle lipgloss.Style) string { + if len(indexes) == 0 || m.selectedIndex < 0 || m.selectedIndex >= len(indexes) { + return "" + } - // Section header - content.WriteString(headerStyle.Render("🔒 API Key Status")) - content.WriteString("\n\n") + var b strings.Builder + binding := m.bindings.Bindings[indexes[m.selectedIndex]] + b.WriteString(headerStyle.Render("Details")) + b.WriteString("\n") + b.WriteString(normalStyle.Render(fmt.Sprintf(" Tool ID: %s", binding.ToolID))) + b.WriteString("\n") + b.WriteString(normalStyle.Render(fmt.Sprintf(" Provider ID: %s", binding.ProviderID))) + b.WriteString("\n") + b.WriteString(normalStyle.Render(fmt.Sprintf(" Use Proxy: %t", binding.UseProxy))) + b.WriteString("\n") + if binding.Options.Model != "" { + b.WriteString(normalStyle.Render(fmt.Sprintf(" Model Override: %s", binding.Options.Model))) + b.WriteString("\n") + } + b.WriteString("\n") + return b.String() +} - // Provider secrets list - if len(m.providers.Providers) == 0 { - content.WriteString(mutedStyle.Render(" No providers configured.")) - content.WriteString("\n") - } else { - for i, provider := range m.providers.Providers { - // Check if API key is configured - hasKey := false - keySource := "(not set)" - if _, err := core.ResolveAPIKey(&provider, m.secrets); err == nil { - hasKey = true - keySource = string(provider.APIKey.Source) - } +// renderSecretsView renders the API keys/secrets management view +func (m DashboardModel) renderSecretsView() string { + searchBar := m.renderSearchBar(viewSecrets) + indexes := m.filteredProviderIndexes() - var statusIcon, statusText string - var keyStatusStyle lipgloss.Style - if hasKey { - statusIcon = iconCheckmark - statusText = "Configured" - keyStatusStyle = successStyle - } else { - statusIcon = iconCross - statusText = "Missing" - keyStatusStyle = dangerStyle - } + if len(indexes) == 0 { + m.selectedIndex = 0 + } else if m.selectedIndex >= len(indexes) { + m.selectedIndex = len(indexes) - 1 + } - line := fmt.Sprintf(" %-25s %s %-15s [%s]", - provider.DisplayName, - statusIcon, - statusText, - keySource, - ) - - var fullLine string - if i == m.selectedIndex { - fullLine = selectedStyle.Render("▶ " + line) - } else { - fullLine = normalStyle.Render(" "+line[:len(" ")+len(provider.DisplayName)+1]) + - keyStatusStyle.Render(line[len(" ")+len(provider.DisplayName)+1:]) - } - content.WriteString(fullLine) - content.WriteString("\n") - } + secretForm := "" + if m.secretInputActive { + secretForm = m.renderSecretInputForm() } - content.WriteString("\n") + props := dashboardviews.SecretsViewProps{ + Theme: newDashboardViewTheme(m.theme), + SecretForm: secretForm, + ShowSecretForm: m.secretInputActive, + SearchBar: searchBar, + EmptyStateMessage: secretsEmptyStateMessage(len(indexes) == 0, m.viewHasSearch(viewSecrets)), + Providers: convertSecretProvidersToView(indexes, m.providers, m.secrets), + SelectedIndex: m.selectedIndex, + SecretMessage: strings.TrimSpace(m.secretMessage), + NavigationHelp: helpTextNavigation, + HelpCommands: "[S] Set [R] Remove [T] Test", + SuccessIcon: iconCheckmark, + FailureIcon: iconCross, + } - // Security notice - content.WriteString(headerStyle.Render("🔐 Security")) - content.WriteString("\n") - content.WriteString(mutedStyle.Render(" • API keys are stored encrypted in ~/.boba/secrets.yaml")) - content.WriteString("\n") - content.WriteString(mutedStyle.Render(" • Keys can also be loaded from environment variables")) - content.WriteString("\n") - content.WriteString(mutedStyle.Render(" • Use 'boba edit secrets' to manage keys manually")) - content.WriteString("\n\n") + return dashboardviews.RenderSecretsView(props) +} - // Footer/Help - content.WriteString(helpStyle.Render(helpTextNavigation)) +func (m DashboardModel) renderSecretInputForm() string { + if !m.secretInputActive || m.secretTargetIndex < 0 || m.secretTargetIndex >= len(m.providers.Providers) { + return "" + } - return content.String() -} + provider := m.providers.Providers[m.secretTargetIndex] + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(m.theme.Primary). + Padding(1, 2). + Width(60) -// renderProxyView renders the proxy server control panel -func (m DashboardModel) renderProxyView() string { titleStyle := lipgloss.NewStyle(). Bold(true). - Foreground(m.theme.Primary). - Padding(0, 2) - - headerStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(m.theme.Success). - Padding(1, 2) - - normalStyle := lipgloss.NewStyle(). - Foreground(m.theme.Text). - Padding(0, 1) - - successStyle := lipgloss.NewStyle(). - Foreground(m.theme.Success). - Padding(0, 1) - - dangerStyle := lipgloss.NewStyle(). - Foreground(m.theme.Danger). - Padding(0, 1) - - helpStyle := lipgloss.NewStyle(). - Foreground(m.theme.Muted). - Padding(1, 2) + Foreground(m.theme.Primary) - var content strings.Builder + infoStyle := lipgloss.NewStyle(). + Foreground(m.theme.Muted) - // Header - title := "BobaMixer - Proxy Server Control" - content.WriteString(titleStyle.Render(title)) - content.WriteString("\n\n") + body := strings.Builder{} + body.WriteString(titleStyle.Render(fmt.Sprintf("Set API key for %s", provider.DisplayName))) + body.WriteString("\n\n") + body.WriteString(m.secretInput.View()) + body.WriteString("\n\n") + body.WriteString(infoStyle.Render("Enter to save • Esc to cancel")) - // Proxy status section - content.WriteString(headerStyle.Render("🌐 Proxy Status")) - content.WriteString("\n\n") + return boxStyle.Render(body.String()) +} - var statusStyle lipgloss.Style - var statusIcon, statusText string +// renderProxyView renders the proxy server control panel +func (m DashboardModel) renderProxyView() string { + props := dashboardviews.ProxyViewProps{ + Theme: newDashboardViewTheme(m.theme), + StatusState: m.proxyStatus, + Address: proxy.DefaultAddr, + NavigationHelp: helpTextNavigation, + CommandHelpLine: " [S] Refresh Status", + } switch m.proxyStatus { case proxyStatusRunning: - statusIcon = iconCircleFilled - statusText = "Running" - statusStyle = successStyle + props.StatusIcon = iconCircleFilled + props.StatusText = "Running" + props.ShowConfig = true case proxyStatusStopped: - statusIcon = iconCircleEmpty - statusText = "Stopped" - statusStyle = dangerStyle + props.StatusIcon = iconCircleEmpty + props.StatusText = "Stopped" + props.AdditionalNote = " Note: Use 'boba proxy serve' in terminal to start the proxy server" default: - statusIcon = "⋯" - statusText = "Checking..." - statusStyle = normalStyle - } - - content.WriteString(normalStyle.Render(fmt.Sprintf(" Status: %s", statusStyle.Render(statusIcon+" "+statusText)))) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" Address: %s", proxy.DefaultAddr))) - content.WriteString("\n\n") - - // Information section - content.WriteString(headerStyle.Render("ℹ️ Information")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" The proxy server intercepts AI API requests from CLI tools")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" and routes them through BobaMixer for tracking and control.")) - content.WriteString("\n\n") - - // Usage - if m.proxyStatus == proxyStatusRunning { - content.WriteString(headerStyle.Render("📝 Configuration")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" Tools with proxy enabled will automatically use:")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" • HTTP_PROXY=%s", proxy.DefaultAddr))) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" • HTTPS_PROXY=%s", proxy.DefaultAddr))) - content.WriteString("\n\n") + props.StatusIcon = "⋯" + props.StatusText = "Checking..." } - // Footer/Help - var helpText string - if m.proxyStatus == proxyStatusRunning { - helpText = "[1-9,0,H,C,?] Switch View [S] Refresh Status [Tab] Next View [Q] Quit" - } else { - helpText = "[1-9,0,H,C,?] Switch View [S] Refresh Status [Tab] Next View [Q] Quit\n Note: Use 'boba proxy serve' in terminal to start the proxy server" - } - content.WriteString(helpStyle.Render(helpText)) - - return content.String() + return dashboardviews.RenderProxyView(props) } // renderRoutingView renders the routing rules tester func (m DashboardModel) renderRoutingView() string { - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(m.theme.Primary). - Padding(0, 2) - - headerStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(m.theme.Success). - Padding(1, 2) - - normalStyle := lipgloss.NewStyle(). - Foreground(m.theme.Text). - Padding(0, 1) - - mutedStyle := lipgloss.NewStyle(). - Foreground(m.theme.Muted). - Padding(0, 1) - - helpStyle := lipgloss.NewStyle(). - Foreground(m.theme.Muted). - Padding(1, 2) - - var content strings.Builder - - // Header - title := "BobaMixer - Routing Rules Tester" - content.WriteString(titleStyle.Render(title)) - content.WriteString("\n\n") - - // Description - content.WriteString(headerStyle.Render("🧪 Test Routing Rules")) - content.WriteString("\n") - content.WriteString(mutedStyle.Render(" Test how routing rules would apply to different queries.")) - content.WriteString("\n\n") - - // Example usage - content.WriteString(headerStyle.Render("💡 How to Use")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" 1. Prepare a test query (text or file)")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" 2. Run: boba route test \"your query text\"")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" 3. Or: boba route test @path/to/file.txt")) - content.WriteString("\n\n") - - // Example - content.WriteString(headerStyle.Render("📋 Example")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" $ boba route test \"Write a Python function\"")) - content.WriteString("\n") - content.WriteString(mutedStyle.Render(" → Profile: claude-sonnet-3.5")) - content.WriteString("\n") - content.WriteString(mutedStyle.Render(" → Rule: short-query-fast-model")) - content.WriteString("\n") - content.WriteString(mutedStyle.Render(" → Reason: Query < 100 chars")) - content.WriteString("\n\n") - - // Info - content.WriteString(headerStyle.Render("ℹ️ Context Detection")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" Routing considers:")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • Query length and complexity")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • Current project and branch")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • Time of day (day/evening/night)")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • Project type (go, web, etc.)")) - content.WriteString("\n\n") - - // Footer/Help - helpText := "[1-9,0,H,C,?] Switch View [Tab] Next View [Q] Quit\n Use CLI: boba route test " - content.WriteString(helpStyle.Render(helpText)) + props := dashboardviews.RoutingViewProps{ + Theme: newDashboardViewTheme(m.theme), + NavigationHelp: helpTextNavigation, + CommandHelpLine: " Use CLI: boba route test ", + } - return content.String() + return dashboardviews.RenderRoutingView(props) } // renderSuggestionsView renders the optimization suggestions view func (m DashboardModel) renderSuggestionsView() string { - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(m.theme.Primary). - Padding(0, 2) - - headerStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(m.theme.Success). - Padding(1, 2) - - selectedStyle := lipgloss.NewStyle(). - Foreground(m.theme.Text). - Background(m.theme.Primary). - Bold(true). - Padding(0, 1) - - normalStyle := lipgloss.NewStyle(). - Foreground(m.theme.Text). - Padding(0, 1) - - mutedStyle := lipgloss.NewStyle(). - Foreground(m.theme.Muted). - Padding(0, 1) - - warningStyle := lipgloss.NewStyle(). - Foreground(m.theme.Warning). - Padding(0, 1) - - dangerStyle := lipgloss.NewStyle(). - Foreground(m.theme.Danger). - Padding(0, 1) - - helpStyle := lipgloss.NewStyle(). - Foreground(m.theme.Muted). - Padding(1, 2) - - var content strings.Builder - - // Header - title := "BobaMixer - Optimization Suggestions" - content.WriteString(titleStyle.Render(title)) - content.WriteString("\n\n") - - // Check for errors - if m.suggestionsError != "" { - content.WriteString(dangerStyle.Render(fmt.Sprintf(" Error: %s", m.suggestionsError))) - content.WriteString("\n\n") - helpText := "[1-9,0,H,C,?] Switch View [R] Retry [Tab] Next View [Q] Quit" - content.WriteString(helpStyle.Render(helpText)) - return content.String() + selectedIndex := m.selectedIndex + if len(m.suggestions) > 0 && selectedIndex >= len(m.suggestions) { + selectedIndex = 0 } - // Section header - content.WriteString(headerStyle.Render("💡 Recommendations (Last 7 Days)")) - content.WriteString("\n\n") - - // Suggestions list - if len(m.suggestions) == 0 { - content.WriteString(mutedStyle.Render(" ✓ No suggestions - your usage is optimized!")) - content.WriteString("\n") - } else { - for i, sugg := range m.suggestions { - // Priority indicator - var priorityStyle lipgloss.Style - var priorityIcon string - switch sugg.Priority { - case 5: - priorityStyle = dangerStyle - priorityIcon = "🔴" - case 4: - priorityStyle = warningStyle - priorityIcon = "🟠" - case 3: - priorityStyle = normalStyle - priorityIcon = "🟡" - default: - priorityStyle = mutedStyle - priorityIcon = "🟢" - } - - // Type icon - var typeIcon string - switch sugg.Type { - case suggestions.SuggestionCostOptimization: - typeIcon = "💰" - case suggestions.SuggestionProfileSwitch: - typeIcon = "🔄" - case suggestions.SuggestionBudgetAdjust: - typeIcon = "📊" - case suggestions.SuggestionAnomaly: - typeIcon = "⚠️ " - default: - typeIcon = "📈" - } - - line := fmt.Sprintf(" %s %s [P%d] %s", - priorityIcon, - typeIcon, - sugg.Priority, - sugg.Title, - ) - - if i == m.selectedIndex { - content.WriteString(selectedStyle.Render("▶ " + line)) - } else { - content.WriteString(priorityStyle.Render(line)) - } - content.WriteString("\n") - } - - // Selected suggestion details - if m.selectedIndex < len(m.suggestions) { - sugg := m.suggestions[m.selectedIndex] - content.WriteString("\n") - content.WriteString(headerStyle.Render("Details")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" %s", sugg.Description))) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" Impact: %s", sugg.Impact))) - content.WriteString("\n\n") - - if len(sugg.ActionItems) > 0 { - content.WriteString(headerStyle.Render("Recommended Actions")) - content.WriteString("\n") - for idx, action := range sugg.ActionItems { - content.WriteString(normalStyle.Render(fmt.Sprintf(" %d. %s", idx+1, action))) - content.WriteString("\n") - } - } - } + props := dashboardviews.SuggestionsViewProps{ + Theme: newDashboardViewTheme(m.theme), + Suggestions: convertSuggestionsToView(m.suggestions), + SelectedIndex: selectedIndex, + Error: m.suggestionsError, + NavigationHelp: helpTextNavigation, + CommandHelpLine: " Use CLI: boba action [--auto] to apply suggestions", } - content.WriteString("\n") - - // Footer/Help - helpText := "[1-9,0,H,C,?] Switch View [↑/↓] Navigate [Tab] Next View [Q] Quit\n Use CLI: boba action [--auto] to apply suggestions" - content.WriteString(helpStyle.Render(helpText)) - - return content.String() + return dashboardviews.RenderSuggestionsView(props) } // loadSuggestions loads optimization suggestions @@ -1644,319 +2085,372 @@ func (m *DashboardModel) loadSuggestions() tea.Msg { // renderReportsView renders the report generation interface func (m DashboardModel) renderReportsView() string { - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Primary).Padding(0, 2) - headerStyle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Success).Padding(1, 2) - normalStyle := lipgloss.NewStyle().Foreground(m.theme.Text).Padding(0, 2) - selectedStyle := lipgloss.NewStyle().Foreground(m.theme.Text).Background(m.theme.Primary).Bold(true).Padding(0, 1) - helpStyle := lipgloss.NewStyle().Foreground(m.theme.Muted).Padding(1, 2) - - var content strings.Builder - - // Header - content.WriteString(titleStyle.Render("📊 Generate Usage Report")) - content.WriteString("\n\n") - if m.selectedIndex >= len(reportOptions) { m.selectedIndex = 0 } - content.WriteString(headerStyle.Render("Report Options")) - content.WriteString("\n") - - for i, opt := range reportOptions { - line := fmt.Sprintf(" %s", opt.label) - if i == m.selectedIndex { - content.WriteString(selectedStyle.Render("▶ " + line)) - } else { - content.WriteString(normalStyle.Render(" " + line)) - } - content.WriteString("\n") - - // Show description for selected item - if i == m.selectedIndex { - content.WriteString(lipgloss.NewStyle().Foreground(m.theme.Muted).Padding(0, 4).Render(" → " + opt.desc)) - content.WriteString("\n") - } + props := dashboardviews.ReportsViewProps{ + Theme: newDashboardViewTheme(m.theme), + Options: convertReportOptionsToView(reportOptions), + SelectedIndex: m.selectedIndex, + Home: m.home, + NavigationHelp: helpTextNavigation, + CommandHelpLine: " Use CLI: boba report --format --days --out ", } - content.WriteString("\n") - content.WriteString(headerStyle.Render("Output Configuration")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" Default path: %s/reports/", m.home))) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" Filename: bobamixer-.")) - content.WriteString("\n\n") + return dashboardviews.RenderReportsView(props) +} - content.WriteString(headerStyle.Render("Report Contents")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" ✓ Summary statistics (tokens, costs, sessions)")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" ✓ Daily trends and usage patterns")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" ✓ Profile breakdown and comparison")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" ✓ Cost analysis and optimization opportunities")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" ✓ Peak usage times and anomalies")) - content.WriteString("\n\n") +// renderHooksView renders the Git hooks management interface +func (m DashboardModel) renderHooksView() string { + repoPath := "(Not in a git repository)" + hooksInstalled := false - // Footer/Help - helpText := "[1-9,0,H,C,?] Switch View [↑/↓] Navigate Options [Tab] Next View [Q] Quit\n Use CLI: boba report --format --days --out " - content.WriteString(helpStyle.Render(helpText)) + props := dashboardviews.HooksViewProps{ + Theme: newDashboardViewTheme(m.theme), + RepoPath: repoPath, + HooksInstalled: hooksInstalled, + Hooks: []dashboardviews.HookInfo{ + {Name: "post-checkout", Desc: "Track branch switches and suggest optimal profiles", Active: hooksInstalled}, + {Name: "post-commit", Desc: "Record commit events for usage tracking", Active: hooksInstalled}, + {Name: "post-merge", Desc: "Track merge events and repository changes", Active: hooksInstalled}, + }, + NavigationHelp: helpTextNavigation, + CommandHelpLine: " Use CLI: boba hooks install (to install hooks) | boba hooks remove (to uninstall)", + ActiveIcon: iconCheckmark, + InactiveIcon: iconCross, + } - return content.String() + return dashboardviews.RenderHooksView(props) } -// renderHooksView renders the Git hooks management interface -func (m DashboardModel) renderHooksView() string { - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Primary).Padding(0, 2) - headerStyle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Success).Padding(1, 2) - normalStyle := lipgloss.NewStyle().Foreground(m.theme.Text).Padding(0, 2) - successStyle := lipgloss.NewStyle().Foreground(m.theme.Success).Padding(0, 2) - dangerStyle := lipgloss.NewStyle().Foreground(m.theme.Danger).Padding(0, 2) - helpStyle := lipgloss.NewStyle().Foreground(m.theme.Muted).Padding(1, 2) +func (m DashboardModel) renderConfigView() string { + props := dashboardviews.ConfigViewProps{ + Theme: newDashboardViewTheme(m.theme), + SelectedIndex: m.selectedIndex, + ConfigFiles: convertConfigFilesToView(configFiles), + Home: m.home, + HelpTextNavigation: helpTextNavigation, + } - var content strings.Builder + return dashboardviews.RenderConfigView(props) +} - // Header - content.WriteString(titleStyle.Render("🪝 Git Hooks Management")) - content.WriteString("\n\n") +func (m DashboardModel) renderHelpView() string { + props := dashboardviews.HelpViewProps{ + Theme: newDashboardViewTheme(m.theme), + Sections: convertSectionsToView(m.sections), + NavigationHelp: helpTextNavigation, + } - // Repository detection - content.WriteString(headerStyle.Render("Current Repository")) - content.WriteString("\n") + return dashboardviews.RenderHelpView(props) +} - // Try to detect current git repo - repoPath := "(Not in a git repository)" - hooksInstalled := false +func newDashboardViewTheme(theme Theme) dashboardviews.ThemePalette { + return dashboardviews.ThemePalette{ + Primary: theme.Primary, + Success: theme.Success, + Danger: theme.Danger, + Warning: theme.Warning, + Text: theme.Text, + Muted: theme.Muted, + } +} - // Simple check - in real implementation this would call git commands - content.WriteString(normalStyle.Render(fmt.Sprintf(" Path: %s", repoPath))) - content.WriteString("\n") +func convertSuggestionsToView(suggs []suggestions.Suggestion) []dashboardviews.Suggestion { + result := make([]dashboardviews.Suggestion, len(suggs)) + for i, sugg := range suggs { + result[i] = dashboardviews.Suggestion{ + Title: sugg.Title, + Description: sugg.Description, + Impact: sugg.Impact, + ActionItems: append([]string(nil), sugg.ActionItems...), + Priority: sugg.Priority, + Type: suggestionTypeToView(sugg.Type), + } + } + return result +} - if hooksInstalled { - content.WriteString(successStyle.Render(" Status: ✓ Hooks Installed")) - } else { - content.WriteString(dangerStyle.Render(" Status: ✗ Hooks Not Installed")) +func convertSecretProvidersToView(indexes []int, providers *core.ProvidersConfig, secretStore *core.SecretsConfig) []dashboardviews.SecretProviderRow { + if providers == nil || secretStore == nil || len(indexes) == 0 { + return nil } - content.WriteString("\n\n") - // Hook types - content.WriteString(headerStyle.Render("Available Hooks")) - content.WriteString("\n") + result := make([]dashboardviews.SecretProviderRow, 0, len(indexes)) + for _, idx := range indexes { + if idx < 0 || idx >= len(providers.Providers) { + continue + } - hookTypes := []struct { - name string - desc string - active bool - }{ - {"post-checkout", "Track branch switches and suggest optimal profiles", hooksInstalled}, - {"post-commit", "Record commit events for usage tracking", hooksInstalled}, - {"post-merge", "Track merge events and repository changes", hooksInstalled}, - } - - for _, hook := range hookTypes { - var statusStyle lipgloss.Style - var statusIcon string - if hook.active { - statusStyle = successStyle - statusIcon = iconCheckmark - } else { - statusStyle = dangerStyle - statusIcon = iconCross + provider := providers.Providers[idx] + + hasKey := false + keySource := "(not set)" + if _, err := core.ResolveAPIKey(&provider, secretStore); err == nil { + hasKey = true + keySource = string(provider.APIKey.Source) } - content.WriteString(normalStyle.Render(fmt.Sprintf(" %s", hook.name))) - content.WriteString(statusStyle.Render(fmt.Sprintf(" %s", statusIcon))) - content.WriteString("\n") - content.WriteString(lipgloss.NewStyle().Foreground(m.theme.Muted).Padding(0, 4).Render(fmt.Sprintf(" → %s", hook.desc))) - content.WriteString("\n") + result = append(result, dashboardviews.SecretProviderRow{ + DisplayName: provider.DisplayName, + HasKey: hasKey, + KeySource: keySource, + }) } - content.WriteString("\n") - content.WriteString(headerStyle.Render("Benefits")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • Automatic profile suggestions based on branch/project")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • Track repository events for better usage analytics")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • Context-aware AI model selection")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • Zero-overhead tracking (async logging)")) - content.WriteString("\n\n") - - // Recent activity (placeholder) - content.WriteString(headerStyle.Render("Recent Hook Activity")) - content.WriteString("\n") - content.WriteString(lipgloss.NewStyle().Foreground(m.theme.Muted).Padding(0, 2).Render(" No recent activity recorded")) - content.WriteString("\n\n") + return result +} - // Footer/Help - helpText := "[1-9,0,H,C,?] Switch View [Tab] Next View [Q] Quit\n Use CLI: boba hooks install (to install hooks) | boba hooks remove (to uninstall)" - content.WriteString(helpStyle.Render(helpText)) +func secretsEmptyStateMessage(isEmpty bool, hasSearch bool) string { + if !isEmpty { + return "" + } + if hasSearch { + return "No providers match the current filter." + } + return "No providers configured." +} - return content.String() +func convertStatsSummaryToView(title string, summary stats.Summary, includeAverages bool) dashboardviews.StatsSummary { + return dashboardviews.StatsSummary{ + Title: title, + Tokens: summary.TotalTokens, + Cost: summary.TotalCost, + Sessions: summary.TotalSessions, + AvgDailyTokens: summary.AvgDailyTokens, + AvgDailyCost: summary.AvgDailyCost, + ShowAverages: includeAverages, + } } -// renderConfigView renders the configuration file selector -func (m DashboardModel) renderConfigView() string { - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Primary).Padding(0, 2) - headerStyle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Success).Padding(1, 2) - normalStyle := lipgloss.NewStyle().Foreground(m.theme.Text).Padding(0, 2) - selectedStyle := lipgloss.NewStyle().Foreground(m.theme.Text).Background(m.theme.Primary).Bold(true).Padding(0, 1) - mutedStyle := lipgloss.NewStyle().Foreground(m.theme.Muted).Padding(0, 2) - helpStyle := lipgloss.NewStyle().Foreground(m.theme.Muted).Padding(1, 2) +func convertProfileStatsToView(statsList []stats.ProfileStats) []dashboardviews.StatsProfile { + if len(statsList) == 0 { + return nil + } - var content strings.Builder + result := make([]dashboardviews.StatsProfile, 0, len(statsList)) + for _, ps := range statsList { + result = append(result, dashboardviews.StatsProfile{ + Name: ps.ProfileName, + Tokens: ps.TotalTokens, + Cost: ps.TotalCost, + Sessions: ps.SessionCount, + AvgLatency: ps.AvgLatencyMS, + UsagePct: ps.UsagePercent, + CostPct: ps.CostPercent, + }) + } + return result +} - // Header - content.WriteString(titleStyle.Render("⚙️ Configuration Editor")) - content.WriteString("\n\n") +func providersEmptyStateMessage(isEmpty bool, hasSearch bool) string { + if !isEmpty { + return "" + } + if hasSearch { + return "No providers match the current filter." + } + return "No providers configured." +} - content.WriteString(headerStyle.Render("Configuration Files")) - content.WriteString("\n") +func toolsEmptyStateMessage(isEmpty bool, hasSearch bool) string { + if !isEmpty { + return "" + } + if hasSearch { + return "No tools match the current filter." + } + return "No tools configured." +} - if m.selectedIndex >= len(configFiles) { - m.selectedIndex = 0 +func suggestionTypeToView(t suggestions.SuggestionType) string { + switch t { + case suggestions.SuggestionCostOptimization: + return "cost" + case suggestions.SuggestionProfileSwitch: + return "profile" + case suggestions.SuggestionBudgetAdjust: + return "budget" + case suggestions.SuggestionAnomaly: + return "anomaly" + case suggestions.SuggestionUsagePattern: + return "usage" + default: + return "usage" } +} - for i, cfg := range configFiles { - line := fmt.Sprintf(" %s", cfg.name) - filePath := lipgloss.NewStyle().Foreground(m.theme.Muted).Render(fmt.Sprintf(" (%s)", cfg.file)) +func convertReportOptionsToView(options []reportOption) []dashboardviews.ReportOption { + result := make([]dashboardviews.ReportOption, len(options)) + for i, opt := range options { + result[i] = dashboardviews.ReportOption{ + Label: opt.label, + Desc: opt.desc, + } + } + return result +} - if i == m.selectedIndex { - content.WriteString(selectedStyle.Render("▶ " + line)) - content.WriteString(filePath) - } else { - content.WriteString(normalStyle.Render(" " + line)) - content.WriteString(filePath) +func convertConfigFilesToView(files []configFile) []dashboardviews.ConfigFile { + result := make([]dashboardviews.ConfigFile, len(files)) + for i, cfg := range files { + result[i] = dashboardviews.ConfigFile{ + Name: cfg.name, + File: cfg.file, + Desc: cfg.desc, } - content.WriteString("\n") - - // Show description for selected item - if i == m.selectedIndex { - content.WriteString(mutedStyle.Render(fmt.Sprintf(" %s", cfg.desc))) - content.WriteString("\n") - content.WriteString(mutedStyle.Render(fmt.Sprintf(" Full path: %s/%s", m.home, cfg.file))) - content.WriteString("\n") + } + return result +} + +func convertSectionsToView(sections []viewSection) []dashboardviews.HelpSection { + result := make([]dashboardviews.HelpSection, 0, len(sections)) + for _, section := range sections { + viewNames := make([]string, 0, len(section.views)) + for _, v := range section.views { + viewNames = append(viewNames, viewName(v)) } + result = append(result, dashboardviews.HelpSection{ + Name: section.name, + Shortcut: section.shortcut, + Views: viewNames, + }) } + return result +} - content.WriteString("\n") - content.WriteString(headerStyle.Render("Editor Settings")) - content.WriteString("\n") +func convertProvidersToView(indexes []int, providers *core.ProvidersConfig, secrets *core.SecretsConfig) []dashboardviews.ProviderRow { + if providers == nil || len(indexes) == 0 { + return nil + } - editor := "vim" // Default, in real implementation check $EDITOR - content.WriteString(normalStyle.Render(fmt.Sprintf(" Editor: $EDITOR (%s)", editor))) - content.WriteString("\n") - content.WriteString(mutedStyle.Render(" Tip: Set $EDITOR environment variable to use your preferred editor")) - content.WriteString("\n\n") + result := make([]dashboardviews.ProviderRow, 0, len(indexes)) + for _, idx := range indexes { + if idx < 0 || idx >= len(providers.Providers) { + continue + } - content.WriteString(headerStyle.Render("Safety Features")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • Automatic backup before editing")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • YAML syntax validation after save")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • Rollback support if validation fails")) - content.WriteString("\n\n") + provider := providers.Providers[idx] + hasKey := false + if secrets != nil { + if _, err := core.ResolveAPIKey(&provider, secrets); err == nil { + hasKey = true + } + } - // Footer/Help - helpText := "[1-9,0,H,C,?] Switch View [↑/↓] Navigate [Tab] Next View [Q] Quit\n Use CLI: boba edit (to open in editor)" - content.WriteString(helpStyle.Render(helpText)) + result = append(result, dashboardviews.ProviderRow{ + DisplayName: provider.DisplayName, + BaseURL: provider.BaseURL, + DefaultModel: provider.DefaultModel, + Enabled: provider.Enabled, + HasAPIKey: hasKey, + }) + } - return content.String() + return result } -// renderHelpView renders comprehensive help and shortcuts -func (m DashboardModel) renderHelpView() string { - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Primary).Padding(0, 2) - headerStyle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Success).Padding(1, 2) - normalStyle := lipgloss.NewStyle().Foreground(m.theme.Text).Padding(0, 2) - keyStyle := lipgloss.NewStyle().Foreground(m.theme.Primary).Bold(true) - helpStyle := lipgloss.NewStyle().Foreground(m.theme.Muted).Padding(1, 2) +func convertProviderDetailsToView(indexes []int, selectedIndex int, providers *core.ProvidersConfig) *dashboardviews.ProviderDetails { + if providers == nil || len(indexes) == 0 || selectedIndex < 0 || selectedIndex >= len(indexes) { + return nil + } - var content strings.Builder + idx := indexes[selectedIndex] + if idx < 0 || idx >= len(providers.Providers) { + return nil + } - // Header - content.WriteString(titleStyle.Render("❓ BobaMixer Help & Shortcuts")) - content.WriteString("\n\n") + provider := providers.Providers[idx] + details := dashboardviews.ProviderDetails{ + ID: provider.ID, + Kind: string(provider.Kind), + APIKeySource: string(provider.APIKey.Source), + } - // Navigation - content.WriteString(headerStyle.Render("View Navigation")) - content.WriteString("\n") - shortcuts := []struct { - key string - desc string - }{ - {"1", "Dashboard - Overview and tool bindings"}, - {"2", "Providers - Manage AI providers"}, - {"3", "Tools - Manage CLI tools"}, - {"4", "Bindings - Tool-to-provider bindings"}, - {"5", "Secrets - API key configuration"}, - {"6", "Stats - Usage statistics"}, - {"7", "Proxy - Proxy server control"}, - {"8", "Routing - Routing rules tester"}, - {"9", "Suggestions - Optimization suggestions"}, - {"0", "Reports - Generate usage reports"}, - {"H", "Hooks - Git hooks management"}, - {"C", "Config - Configuration editor"}, - {"?", "Help - This screen"}, - } - - for _, sc := range shortcuts { - content.WriteString(normalStyle.Render(" ")) - content.WriteString(keyStyle.Render(fmt.Sprintf("[%s]", sc.key))) - content.WriteString(normalStyle.Render(fmt.Sprintf(" %s", sc.desc))) - content.WriteString("\n") + if provider.APIKey.Source == core.APIKeySourceEnv && provider.APIKey.EnvVar != "" { + details.EnvVar = provider.APIKey.EnvVar + details.ShowEnvVar = true } - content.WriteString("\n") - content.WriteString(headerStyle.Render("Global Shortcuts")) - content.WriteString("\n") + return &details +} - globalShortcuts := []struct { - key string - desc string - }{ - {"Tab", "Cycle to next view"}, - {"↑/↓ or k/j", "Navigate in lists"}, - {"R", "Run selected tool (Dashboard view)"}, - {"X", "Toggle proxy (Dashboard view)"}, - {"Q or Ctrl+C", "Quit BobaMixer"}, +func convertToolsToView(indexes []int, tools *core.ToolsConfig, bindings *core.BindingsConfig) []dashboardviews.ToolRow { + if tools == nil || len(indexes) == 0 { + return nil } - for _, sc := range globalShortcuts { - content.WriteString(normalStyle.Render(" ")) - content.WriteString(keyStyle.Render(fmt.Sprintf("[%s]", sc.key))) - content.WriteString(normalStyle.Render(fmt.Sprintf(" %s", sc.desc))) - content.WriteString("\n") + result := make([]dashboardviews.ToolRow, 0, len(indexes)) + for _, idx := range indexes { + if idx < 0 || idx >= len(tools.Tools) { + continue + } + + tool := tools.Tools[idx] + bound := false + if bindings != nil { + if _, err := bindings.FindBinding(tool.ID); err == nil { + bound = true + } + } + + result = append(result, dashboardviews.ToolRow{ + Name: tool.Name, + Exec: tool.Exec, + Kind: string(tool.Kind), + Bound: bound, + }) } - content.WriteString("\n") - content.WriteString(headerStyle.Render("Quick Tips")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • Use number keys (1-9, 0) for fast view switching")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • All interactive features are in the TUI")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • CLI commands available for automation")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • Press ? anytime to return to this help screen")) - content.WriteString("\n\n") + return result +} - content.WriteString(headerStyle.Render("Documentation")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" Full docs: https://royisme.github.io/BobaMixer/")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" GitHub: https://github.com/royisme/BobaMixer")) - content.WriteString("\n\n") +func convertToolDetailsToView(selectedIndex int, tools *core.ToolsConfig) *dashboardviews.ToolDetails { + if tools == nil || selectedIndex < 0 || selectedIndex >= len(tools.Tools) { + return nil + } - // Footer/Help - helpText := "Use navigation keys (1-9, 0, H, C, ?) to switch views | [Tab] Next View | [Q] Quit" - content.WriteString(helpStyle.Render(helpText)) + tool := tools.Tools[selectedIndex] + return &dashboardviews.ToolDetails{ + ID: tool.ID, + ConfigType: string(tool.ConfigType), + ConfigPath: tool.ConfigPath, + Description: tool.Description, + } +} - return content.String() +func viewName(view viewMode) string { + switch view { + case viewDashboard: + return "Dashboard" + case viewProviders: + return "Providers" + case viewTools: + return "Tools" + case viewBindings: + return "Bindings" + case viewSecrets: + return "Secrets" + case viewStats: + return "Usage Stats" + case viewProxy: + return "Proxy" + case viewRouting: + return "Routing Tester" + case viewSuggestions: + return "Suggestions" + case viewReports: + return "Reports" + case viewHooks: + return "Hooks" + case viewConfig: + return "Config Editor" + case viewHelp: + return "Help" + default: + return "View" + } } // RunDashboard starts the dashboard TUI diff --git a/internal/ui/dashboard/views/config_view.go b/internal/ui/dashboard/views/config_view.go new file mode 100644 index 0000000..ccf42ff --- /dev/null +++ b/internal/ui/dashboard/views/config_view.go @@ -0,0 +1,95 @@ +package views + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// ConfigFile describes a single editable configuration file. +type ConfigFile struct { + Name string + File string + Desc string +} + +// ConfigViewProps carries the data required to render the configuration view. +type ConfigViewProps struct { + Theme ThemePalette + SelectedIndex int + ConfigFiles []ConfigFile + Home string + HelpTextNavigation string +} + +// RenderConfigView renders the configuration editor view. +func RenderConfigView(props ConfigViewProps) string { + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Primary).Padding(0, 2) + headerStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Success).Padding(1, 2) + normalStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Padding(0, 2) + selectedStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Background(props.Theme.Primary).Bold(true).Padding(0, 1) + mutedStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(0, 2) + helpStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(1, 2) + + var content strings.Builder + + // Header + content.WriteString(titleStyle.Render("⚙️ Configuration Editor")) + content.WriteString("\n\n") + + content.WriteString(headerStyle.Render("Configuration Files")) + content.WriteString("\n") + + selectedIndex := props.SelectedIndex + if selectedIndex >= len(props.ConfigFiles) { + selectedIndex = 0 + } + + for i, cfg := range props.ConfigFiles { + line := fmt.Sprintf(" %s", cfg.Name) + filePath := lipgloss.NewStyle().Foreground(props.Theme.Muted).Render(fmt.Sprintf(" (%s)", cfg.File)) + + if i == selectedIndex { + content.WriteString(selectedStyle.Render("▶ " + line)) + content.WriteString(filePath) + } else { + content.WriteString(normalStyle.Render(" " + line)) + content.WriteString(filePath) + } + content.WriteString("\n") + + // Show description for selected item + if i == selectedIndex { + content.WriteString(mutedStyle.Render(fmt.Sprintf(" %s", cfg.Desc))) + content.WriteString("\n") + content.WriteString(mutedStyle.Render(fmt.Sprintf(" Full path: %s/%s", props.Home, cfg.File))) + content.WriteString("\n") + } + } + + content.WriteString("\n") + content.WriteString(headerStyle.Render("Editor Settings")) + content.WriteString("\n") + + editor := "vim" // Default, in real implementation check $EDITOR + content.WriteString(normalStyle.Render(fmt.Sprintf(" Editor: $EDITOR (%s)", editor))) + content.WriteString("\n") + content.WriteString(mutedStyle.Render(" Tip: Set $EDITOR environment variable to use your preferred editor")) + content.WriteString("\n\n") + + content.WriteString(headerStyle.Render("Safety Features")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" • Automatic backup before editing")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" • YAML syntax validation after save")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" • Rollback support if validation fails")) + content.WriteString("\n\n") + + // Footer/Help + helpText := props.HelpTextNavigation + "\n Use CLI: boba edit (to open in editor)" + content.WriteString(helpStyle.Render(helpText)) + + return content.String() +} diff --git a/internal/ui/dashboard/views/dashboard_view.go b/internal/ui/dashboard/views/dashboard_view.go new file mode 100644 index 0000000..55844d4 --- /dev/null +++ b/internal/ui/dashboard/views/dashboard_view.go @@ -0,0 +1,51 @@ +package views + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// DashboardViewProps contains the information rendered in the dashboard view. +type DashboardViewProps struct { + Theme ThemePalette + TableView string + Message string + ProxyIcon string + ProxyStatus string + NavigationHelp string + HelpCommands string +} + +// RenderDashboardView renders the high-level dashboard summary. +func RenderDashboardView(props DashboardViewProps) string { + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Primary).Padding(0, 2) + proxyStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Padding(0, 2) + messageStyle := lipgloss.NewStyle().Foreground(props.Theme.Success).Padding(0, 2) + helpStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(1, 2) + + var content strings.Builder + + content.WriteString(titleStyle.Render("BobaMixer - AI CLI Control Plane")) + content.WriteString("\n") + + statusLine := proxyStyle.Render(" Proxy: " + props.ProxyIcon + " " + props.ProxyStatus) + content.WriteString(statusLine) + content.WriteString("\n\n") + + content.WriteString(props.TableView) + content.WriteString("\n") + + if msg := strings.TrimSpace(props.Message); msg != "" { + content.WriteString(messageStyle.Render(" " + msg)) + content.WriteString("\n") + } + + helpText := props.NavigationHelp + if strings.TrimSpace(props.HelpCommands) != "" { + helpText += " " + props.HelpCommands + } + content.WriteString(helpStyle.Render(helpText)) + + return content.String() +} diff --git a/internal/ui/dashboard/views/help_view.go b/internal/ui/dashboard/views/help_view.go new file mode 100644 index 0000000..2d198ab --- /dev/null +++ b/internal/ui/dashboard/views/help_view.go @@ -0,0 +1,113 @@ +package views + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// HelpSection describes a high-level dashboard section. +type HelpSection struct { + Name string + Shortcut string + Views []string +} + +// HelpViewProps contains the data necessary to render the help view. +type HelpViewProps struct { + Theme ThemePalette + Sections []HelpSection + NavigationHelp string + ShowcaseShortcuts []Shortcut +} + +// Shortcut describes a keybinding and its behavior. +type Shortcut struct { + Key string + Desc string +} + +// RenderHelpView renders the help overlay content. +func RenderHelpView(props HelpViewProps) string { + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Primary).Padding(0, 2) + headerStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Success).Padding(1, 2) + normalStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Padding(0, 2) + keyStyle := lipgloss.NewStyle().Foreground(props.Theme.Primary).Bold(true) + helpStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(1, 2) + + var content strings.Builder + + // Header + content.WriteString(titleStyle.Render("❓ BobaMixer Help & Shortcuts")) + content.WriteString("\n\n") + + // Navigation + content.WriteString(headerStyle.Render("Section Navigation")) + content.WriteString("\n") + for _, section := range props.Sections { + content.WriteString(normalStyle.Render(" ")) + content.WriteString(keyStyle.Render(fmt.Sprintf("[%s]", section.Shortcut))) + content.WriteString(normalStyle.Render(fmt.Sprintf(" %s → %s", section.Name, strings.Join(section.Views, ", ")))) + content.WriteString("\n") + } + content.WriteString(normalStyle.Render(" ")) + content.WriteString(keyStyle.Render("[?]")) + content.WriteString(normalStyle.Render(" Toggle this help overlay")) + content.WriteString("\n") + + content.WriteString("\n") + content.WriteString(headerStyle.Render("Global Shortcuts")) + content.WriteString("\n") + + shortcuts := defaultShortcuts() + if len(props.ShowcaseShortcuts) > 0 { + shortcuts = props.ShowcaseShortcuts + } + + for _, sc := range shortcuts { + content.WriteString(normalStyle.Render(" ")) + content.WriteString(keyStyle.Render(fmt.Sprintf("[%s]", sc.Key))) + content.WriteString(normalStyle.Render(fmt.Sprintf(" %s", sc.Desc))) + content.WriteString("\n") + } + + content.WriteString("\n") + content.WriteString(headerStyle.Render("Quick Tips")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" • Use number keys (1-5) to jump between sections")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" • All interactive features are in the TUI")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" • CLI commands available for automation")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" • Press ? anytime to toggle this help overlay")) + content.WriteString("\n\n") + + content.WriteString(headerStyle.Render("Documentation")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" Full docs: https://royisme.github.io/BobaMixer/")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" GitHub: https://github.com/royisme/BobaMixer")) + content.WriteString("\n\n") + + // Footer/Help + helpText := "Press Esc to close this overlay | " + props.NavigationHelp + content.WriteString(helpStyle.Render(helpText)) + + return content.String() +} + +func defaultShortcuts() []Shortcut { + return []Shortcut{ + {"Tab / Shift+Tab", "Cycle sections"}, + {"[ / ]", "Cycle views within a section"}, + {"↑/↓ or k/j", "Navigate in lists"}, + {"/", "Search within supported lists"}, + {"Esc", "Clear search / close dialogs"}, + {"R", "Run selected tool (Dashboard view)"}, + {"X", "Toggle proxy (Dashboard view)"}, + {"S", "Refresh proxy status (Proxy view)"}, + {"Q or Ctrl+C", "Quit BobaMixer"}, + } +} diff --git a/internal/ui/dashboard/views/hooks_view.go b/internal/ui/dashboard/views/hooks_view.go new file mode 100644 index 0000000..c4bf48a --- /dev/null +++ b/internal/ui/dashboard/views/hooks_view.go @@ -0,0 +1,99 @@ +package views + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// HookInfo represents a supported git hook entry. +type HookInfo struct { + Name string + Desc string + Active bool +} + +// HooksViewProps contains the data necessary to render the git hooks view. +type HooksViewProps struct { + Theme ThemePalette + RepoPath string + HooksInstalled bool + Hooks []HookInfo + NavigationHelp string + CommandHelpLine string + ActiveIcon string + InactiveIcon string +} + +// RenderHooksView renders the git hooks management view. +func RenderHooksView(props HooksViewProps) string { + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Primary).Padding(0, 2) + headerStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Success).Padding(1, 2) + normalStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Padding(0, 2) + successStyle := lipgloss.NewStyle().Foreground(props.Theme.Success).Padding(0, 2) + dangerStyle := lipgloss.NewStyle().Foreground(props.Theme.Danger).Padding(0, 2) + helpStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(1, 2) + + var content strings.Builder + + // Header + content.WriteString(titleStyle.Render("🪝 Git Hooks Management")) + content.WriteString("\n\n") + + // Repository detection + content.WriteString(headerStyle.Render("Current Repository")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(fmt.Sprintf(" Path: %s", props.RepoPath))) + content.WriteString("\n") + + if props.HooksInstalled { + content.WriteString(successStyle.Render(" Status: ✓ Hooks Installed")) + } else { + content.WriteString(dangerStyle.Render(" Status: ✗ Hooks Not Installed")) + } + content.WriteString("\n\n") + + content.WriteString(headerStyle.Render("Available Hooks")) + content.WriteString("\n") + + for _, hook := range props.Hooks { + statusStyle := dangerStyle + statusIcon := props.InactiveIcon + if hook.Active { + statusStyle = successStyle + statusIcon = props.ActiveIcon + } + + content.WriteString(normalStyle.Render(fmt.Sprintf(" %s", hook.Name))) + content.WriteString(statusStyle.Render(fmt.Sprintf(" %s", statusIcon))) + content.WriteString("\n") + content.WriteString(lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(0, 4).Render(fmt.Sprintf(" → %s", hook.Desc))) + content.WriteString("\n") + } + + content.WriteString("\n") + content.WriteString(headerStyle.Render("Benefits")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" • Automatic profile suggestions based on branch/project")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" • Track repository events for better usage analytics")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" • Context-aware AI model selection")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" • Zero-overhead tracking (async logging)")) + content.WriteString("\n\n") + + content.WriteString(headerStyle.Render("Recent Hook Activity")) + content.WriteString("\n") + content.WriteString(lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(0, 2).Render(" No recent activity recorded")) + content.WriteString("\n\n") + + helpText := props.NavigationHelp + if props.CommandHelpLine != "" { + helpText += "\n" + props.CommandHelpLine + } + content.WriteString(helpStyle.Render(helpText)) + + return content.String() +} diff --git a/internal/ui/dashboard/views/providers_view.go b/internal/ui/dashboard/views/providers_view.go new file mode 100644 index 0000000..ef98ff0 --- /dev/null +++ b/internal/ui/dashboard/views/providers_view.go @@ -0,0 +1,140 @@ +package views + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// ProviderRow represents a compact provider entry for the list. +type ProviderRow struct { + DisplayName string + BaseURL string + DefaultModel string + Enabled bool + HasAPIKey bool +} + +// ProviderDetails captures the selected provider metadata. +type ProviderDetails struct { + ID string + Kind string + APIKeySource string + EnvVar string + ShowEnvVar bool +} + +// ProvidersViewProps carries all data required for rendering the providers view. +type ProvidersViewProps struct { + Theme ThemePalette + ProviderForm string + ShowProviderForm bool + ProviderFormMessage string + SearchBar string + EmptyStateMessage string + Providers []ProviderRow + SelectedIndex int + Details *ProviderDetails + NavigationHelp string + HelpCommands string + EnabledIcon string + DisabledIcon string + KeyPresentIcon string + KeyMissingIcon string +} + +// RenderProvidersView renders the providers management UI. +func RenderProvidersView(props ProvidersViewProps) string { + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Primary).Padding(0, 2) + headerStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Success).Padding(1, 2) + selectedStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Background(props.Theme.Primary).Bold(true).Padding(0, 1) + normalStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Padding(0, 1) + mutedStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(0, 1) + helpStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(1, 2) + + var content strings.Builder + content.WriteString(titleStyle.Render("BobaMixer - AI Providers Management")) + content.WriteString("\n\n") + + content.WriteString(headerStyle.Render("📡 Available Providers")) + content.WriteString("\n\n") + + if props.ShowProviderForm && strings.TrimSpace(props.ProviderForm) != "" { + content.WriteString(props.ProviderForm) + content.WriteString("\n\n") + } else if msg := strings.TrimSpace(props.ProviderFormMessage); msg != "" { + content.WriteString(mutedStyle.Render(" " + msg)) + content.WriteString("\n\n") + } + + if bar := strings.TrimSpace(props.SearchBar); bar != "" { + content.WriteString(bar) + content.WriteString("\n\n") + } + + rows := props.Providers + if len(rows) == 0 { + if msg := strings.TrimSpace(props.EmptyStateMessage); msg != "" { + content.WriteString(mutedStyle.Render(" " + msg)) + content.WriteString("\n") + } + } else { + selectedIndex := props.SelectedIndex + if selectedIndex >= len(rows) { + selectedIndex = len(rows) - 1 + } + + for idx, row := range rows { + enabledIcon := props.EnabledIcon + if !row.Enabled { + enabledIcon = props.DisabledIcon + } + + keyIcon := props.KeyMissingIcon + if row.HasAPIKey { + keyIcon = props.KeyPresentIcon + } + + line := fmt.Sprintf(" %s %s %-25s %-35s %s", + enabledIcon, + keyIcon, + row.DisplayName, + row.BaseURL, + row.DefaultModel, + ) + + if idx == selectedIndex { + content.WriteString(selectedStyle.Render("▶ " + line)) + } else { + content.WriteString(normalStyle.Render(" " + line)) + } + content.WriteString("\n") + } + } + + content.WriteString("\n") + if props.Details != nil { + content.WriteString(headerStyle.Render("Details")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(fmt.Sprintf(" ID: %s", props.Details.ID))) + content.WriteString("\n") + content.WriteString(normalStyle.Render(fmt.Sprintf(" Kind: %s", props.Details.Kind))) + content.WriteString("\n") + content.WriteString(normalStyle.Render(fmt.Sprintf(" API Key Source: %s", props.Details.APIKeySource))) + content.WriteString("\n") + if props.Details.ShowEnvVar { + content.WriteString(normalStyle.Render(fmt.Sprintf(" Env Var: %s", props.Details.EnvVar))) + content.WriteString("\n") + } + content.WriteString("\n") + } + + helpText := props.NavigationHelp + if cmds := strings.TrimSpace(props.HelpCommands); cmds != "" { + helpText += " " + cmds + } + content.WriteString(helpStyle.Render(helpText)) + + return content.String() +} diff --git a/internal/ui/dashboard/views/proxy_view.go b/internal/ui/dashboard/views/proxy_view.go new file mode 100644 index 0000000..a3720a2 --- /dev/null +++ b/internal/ui/dashboard/views/proxy_view.go @@ -0,0 +1,85 @@ +package views + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// ProxyViewProps describes the proxy status view. +type ProxyViewProps struct { + Theme ThemePalette + StatusState string + StatusText string + StatusIcon string + Address string + ShowConfig bool + NavigationHelp string + AdditionalNote string + CommandHelpLine string +} + +// RenderProxyView renders the proxy server control view. +func RenderProxyView(props ProxyViewProps) string { + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Primary).Padding(0, 2) + headerStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Success).Padding(1, 2) + normalStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Padding(0, 1) + successStyle := lipgloss.NewStyle().Foreground(props.Theme.Success).Padding(0, 1) + dangerStyle := lipgloss.NewStyle().Foreground(props.Theme.Danger).Padding(0, 1) + helpStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(1, 2) + + var content strings.Builder + + content.WriteString(titleStyle.Render("BobaMixer - Proxy Server Control")) + content.WriteString("\n\n") + + content.WriteString(headerStyle.Render("🌐 Proxy Status")) + content.WriteString("\n\n") + + var statusStyle lipgloss.Style + switch props.StatusState { + case "running": + statusStyle = successStyle + case "stopped": + statusStyle = dangerStyle + default: + statusStyle = normalStyle + } + + statusLine := fmt.Sprintf(" Status: %s", statusStyle.Render(props.StatusIcon+" "+props.StatusText)) + content.WriteString(normalStyle.Render(statusLine)) + content.WriteString("\n") + content.WriteString(normalStyle.Render(fmt.Sprintf(" Address: %s", props.Address))) + content.WriteString("\n\n") + + content.WriteString(headerStyle.Render("ℹ️ Information")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" The proxy server intercepts AI API requests from CLI tools")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" and routes them through BobaMixer for tracking and control.")) + content.WriteString("\n\n") + + if props.ShowConfig { + content.WriteString(headerStyle.Render("📝 Configuration")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" Tools with proxy enabled will automatically use:")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(fmt.Sprintf(" • HTTP_PROXY=%s", props.Address))) + content.WriteString("\n") + content.WriteString(normalStyle.Render(fmt.Sprintf(" • HTTPS_PROXY=%s", props.Address))) + content.WriteString("\n\n") + } + + helpLines := []string{props.NavigationHelp} + if props.CommandHelpLine != "" { + helpLines = append(helpLines, props.CommandHelpLine) + } + if props.AdditionalNote != "" { + helpLines = append(helpLines, props.AdditionalNote) + } + + content.WriteString(helpStyle.Render(strings.Join(helpLines, "\n"))) + + return content.String() +} diff --git a/internal/ui/dashboard/views/reports_view.go b/internal/ui/dashboard/views/reports_view.go new file mode 100644 index 0000000..2e78a2c --- /dev/null +++ b/internal/ui/dashboard/views/reports_view.go @@ -0,0 +1,91 @@ +package views + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// ReportOption describes a report configuration entry. +type ReportOption struct { + Label string + Desc string +} + +// ReportsViewProps carries the data required to render the reports view. +type ReportsViewProps struct { + Theme ThemePalette + Options []ReportOption + SelectedIndex int + Home string + NavigationHelp string + CommandHelpLine string +} + +// RenderReportsView renders the usage reports view. +func RenderReportsView(props ReportsViewProps) string { + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Primary).Padding(0, 2) + headerStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Success).Padding(1, 2) + normalStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Padding(0, 2) + selectedStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Background(props.Theme.Primary).Bold(true).Padding(0, 1) + helpStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(1, 2) + + var content strings.Builder + + // Header + content.WriteString(titleStyle.Render("📊 Generate Usage Report")) + content.WriteString("\n\n") + + selectedIndex := props.SelectedIndex + if len(props.Options) > 0 && selectedIndex >= len(props.Options) { + selectedIndex = 0 + } + + content.WriteString(headerStyle.Render("Report Options")) + content.WriteString("\n") + + for i, opt := range props.Options { + line := fmt.Sprintf(" %s", opt.Label) + if i == selectedIndex { + content.WriteString(selectedStyle.Render("▶ " + line)) + } else { + content.WriteString(normalStyle.Render(" " + line)) + } + content.WriteString("\n") + + if i == selectedIndex { + content.WriteString(lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(0, 4).Render(" → " + opt.Desc)) + content.WriteString("\n") + } + } + + content.WriteString("\n") + content.WriteString(headerStyle.Render("Output Configuration")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(fmt.Sprintf(" Default path: %s/reports/", props.Home))) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" Filename: bobamixer-.")) + content.WriteString("\n\n") + + content.WriteString(headerStyle.Render("Report Contents")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" ✓ Summary statistics (tokens, costs, sessions)")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" ✓ Daily trends and usage patterns")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" ✓ Profile breakdown and comparison")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" ✓ Cost analysis and optimization opportunities")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" ✓ Peak usage times and anomalies")) + content.WriteString("\n\n") + + helpText := props.NavigationHelp + if props.CommandHelpLine != "" { + helpText += "\n" + props.CommandHelpLine + } + content.WriteString(helpStyle.Render(helpText)) + + return content.String() +} diff --git a/internal/ui/dashboard/views/routing_view.go b/internal/ui/dashboard/views/routing_view.go new file mode 100644 index 0000000..321ff36 --- /dev/null +++ b/internal/ui/dashboard/views/routing_view.go @@ -0,0 +1,74 @@ +package views + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// RoutingViewProps contains static text fragments for the routing view. +type RoutingViewProps struct { + Theme ThemePalette + NavigationHelp string + CommandHelpLine string +} + +// RenderRoutingView renders the routing tester information view. +func RenderRoutingView(props RoutingViewProps) string { + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Primary).Padding(0, 2) + headerStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Success).Padding(1, 2) + normalStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Padding(0, 1) + mutedStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(0, 1) + helpStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(1, 2) + + var content strings.Builder + + content.WriteString(titleStyle.Render("BobaMixer - Routing Rules Tester")) + content.WriteString("\n\n") + + content.WriteString(headerStyle.Render("🧪 Test Routing Rules")) + content.WriteString("\n") + content.WriteString(mutedStyle.Render(" Test how routing rules would apply to different queries.")) + content.WriteString("\n\n") + + content.WriteString(headerStyle.Render("💡 How to Use")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" 1. Prepare a test query (text or file)")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" 2. Run: boba route test \"your query text\"")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" 3. Or: boba route test @path/to/file.txt")) + content.WriteString("\n\n") + + content.WriteString(headerStyle.Render("📋 Example")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" $ boba route test \"Write a Python function\"")) + content.WriteString("\n") + content.WriteString(mutedStyle.Render(" → Profile: claude-sonnet-3.5")) + content.WriteString("\n") + content.WriteString(mutedStyle.Render(" → Rule: short-query-fast-model")) + content.WriteString("\n") + content.WriteString(mutedStyle.Render(" → Reason: Query < 100 chars")) + content.WriteString("\n\n") + + content.WriteString(headerStyle.Render("ℹ️ Context Detection")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" Routing considers:")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" • Query length and complexity")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" • Current project and branch")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" • Time of day (day/evening/night)")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(" • Project type (go, web, etc.)")) + content.WriteString("\n\n") + + helpText := props.NavigationHelp + if props.CommandHelpLine != "" { + helpText += "\n" + props.CommandHelpLine + } + content.WriteString(helpStyle.Render(helpText)) + + return content.String() +} diff --git a/internal/ui/dashboard/views/secrets_view.go b/internal/ui/dashboard/views/secrets_view.go new file mode 100644 index 0000000..40844b6 --- /dev/null +++ b/internal/ui/dashboard/views/secrets_view.go @@ -0,0 +1,118 @@ +package views + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// SecretProviderRow represents a provider and its key status. +type SecretProviderRow struct { + DisplayName string + HasKey bool + KeySource string +} + +// SecretsViewProps carries the data required for the secrets view. +type SecretsViewProps struct { + Theme ThemePalette + SecretForm string + ShowSecretForm bool + SearchBar string + EmptyStateMessage string + Providers []SecretProviderRow + SelectedIndex int + SecretMessage string + NavigationHelp string + HelpCommands string + SuccessIcon string + FailureIcon string +} + +// RenderSecretsView renders the secrets management view. +func RenderSecretsView(props SecretsViewProps) string { + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Primary).Padding(0, 2) + headerStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Success).Padding(1, 2) + selectedStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Background(props.Theme.Primary).Bold(true).Padding(0, 1) + normalStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Padding(0, 1) + mutedStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(0, 1) + successStyle := lipgloss.NewStyle().Foreground(props.Theme.Success).Padding(0, 1) + dangerStyle := lipgloss.NewStyle().Foreground(props.Theme.Danger).Padding(0, 1) + helpStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(1, 2) + + var content strings.Builder + + content.WriteString(titleStyle.Render("BobaMixer - Secrets Management (API Keys)")) + content.WriteString("\n\n") + content.WriteString(headerStyle.Render("🔒 API Key Status")) + content.WriteString("\n\n") + + if props.ShowSecretForm && props.SecretForm != "" { + content.WriteString(props.SecretForm) + content.WriteString("\n\n") + } + + if props.SearchBar != "" { + content.WriteString(props.SearchBar) + content.WriteString("\n\n") + } + + if len(props.Providers) == 0 { + content.WriteString(mutedStyle.Render(" " + props.EmptyStateMessage)) + content.WriteString("\n\n") + } else { + index := props.SelectedIndex + if index >= len(props.Providers) { + index = len(props.Providers) - 1 + } else if index < 0 { + index = 0 + } + + for i, row := range props.Providers { + statusText := "Missing" + statusIcon := props.FailureIcon + statusStyle := dangerStyle + if row.HasKey { + statusText = "Configured" + statusIcon = props.SuccessIcon + statusStyle = successStyle + } + + namePart := fmt.Sprintf(" %-25s ", row.DisplayName) + statusPart := fmt.Sprintf("%s %-15s [%s]", statusIcon, statusText, row.KeySource) + if i == index { + line := fmt.Sprintf("%s%s", namePart, statusPart) + content.WriteString(selectedStyle.Render("▶ " + line)) + } else { + content.WriteString(normalStyle.Render(namePart)) + content.WriteString(statusStyle.Render(statusPart)) + } + content.WriteString("\n") + } + content.WriteString("\n") + } + + content.WriteString(headerStyle.Render("🔐 Security")) + content.WriteString("\n") + content.WriteString(mutedStyle.Render(" • API keys are stored encrypted in ~/.boba/secrets.yaml")) + content.WriteString("\n") + content.WriteString(mutedStyle.Render(" • Keys can also be loaded from environment variables")) + content.WriteString("\n") + content.WriteString(mutedStyle.Render(" • Use 'boba edit secrets' to manage keys manually")) + content.WriteString("\n\n") + + if msg := strings.TrimSpace(props.SecretMessage); msg != "" { + content.WriteString(normalStyle.Render(" " + msg)) + content.WriteString("\n\n") + } + + helpLines := []string{props.NavigationHelp} + if props.HelpCommands != "" { + helpLines = append(helpLines, props.HelpCommands) + } + + content.WriteString(helpStyle.Render(strings.Join(helpLines, " "))) + + return content.String() +} diff --git a/internal/ui/dashboard/views/stats_view.go b/internal/ui/dashboard/views/stats_view.go new file mode 100644 index 0000000..d80facc --- /dev/null +++ b/internal/ui/dashboard/views/stats_view.go @@ -0,0 +1,121 @@ +package views + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// StatsSummary represents aggregate statistics for a period. +type StatsSummary struct { + Title string + Tokens int + Cost float64 + Sessions int + AvgDailyTokens float64 + AvgDailyCost float64 + ShowAverages bool + DisplayCurrency bool +} + +// StatsProfile represents a per-profile stats entry. +type StatsProfile struct { + Name string + Tokens int + Cost float64 + Sessions int + AvgLatency float64 + UsagePct float64 + CostPct float64 +} + +// StatsViewProps carries all data required for the stats screen. +type StatsViewProps struct { + Theme ThemePalette + Loaded bool + Error string + LoadingMessage string + Today StatsSummary + Week StatsSummary + Profiles []StatsProfile + NavigationHelp string + LoadingHelp string + ProfileSubtitle string +} + +// RenderStatsView renders the usage statistics page. +func RenderStatsView(props StatsViewProps) string { + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Primary).Padding(0, 2) + sectionStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Success).Padding(1, 2) + dataStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Padding(0, 2) + helpStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(1, 2) + errorStyle := lipgloss.NewStyle().Foreground(props.Theme.Danger).Padding(0, 2) + + var content strings.Builder + content.WriteString(titleStyle.Render("BobaMixer - Usage Statistics")) + content.WriteString("\n\n") + + if !props.Loaded { + if strings.TrimSpace(props.Error) != "" { + content.WriteString(errorStyle.Render(fmt.Sprintf("Error loading stats: %s", props.Error))) + } else { + content.WriteString(dataStyle.Render(props.LoadingMessage)) + } + content.WriteString("\n\n") + content.WriteString(helpStyle.Render(props.LoadingHelp)) + return content.String() + } + + renderSummary(&content, sectionStyle, dataStyle, props.Today) + content.WriteString("\n") + renderSummary(&content, sectionStyle, dataStyle, props.Week) + + if len(props.Profiles) > 0 { + content.WriteString("\n") + title := props.ProfileSubtitle + if strings.TrimSpace(title) == "" { + title = "🎯 By Profile (7d)" + } + content.WriteString(sectionStyle.Render(title)) + content.WriteString("\n") + for _, ps := range props.Profiles { + line := fmt.Sprintf(" • %s: tokens=%d cost=$%.4f sessions=%d latency=%.0fms usage=%.1f%% cost=%.1f%%", + ps.Name, + ps.Tokens, + ps.Cost, + ps.Sessions, + ps.AvgLatency, + ps.UsagePct, + ps.CostPct, + ) + content.WriteString(dataStyle.Render(line)) + content.WriteString("\n") + } + content.WriteString("\n") + } + + content.WriteString(helpStyle.Render(props.NavigationHelp)) + return content.String() +} + +func renderSummary(content *strings.Builder, sectionStyle, dataStyle lipgloss.Style, summary StatsSummary) { + if strings.TrimSpace(summary.Title) != "" { + content.WriteString(sectionStyle.Render(summary.Title)) + content.WriteString("\n") + } + + content.WriteString(dataStyle.Render(fmt.Sprintf(" Tokens: %d", summary.Tokens))) + content.WriteString("\n") + content.WriteString(dataStyle.Render(fmt.Sprintf(" Cost: $%.4f", summary.Cost))) + content.WriteString("\n") + content.WriteString(dataStyle.Render(fmt.Sprintf(" Sessions: %d", summary.Sessions))) + content.WriteString("\n") + + if summary.ShowAverages { + content.WriteString(dataStyle.Render(fmt.Sprintf(" Avg Daily Tokens: %.0f", summary.AvgDailyTokens))) + content.WriteString("\n") + content.WriteString(dataStyle.Render(fmt.Sprintf(" Avg Daily Cost: $%.4f", summary.AvgDailyCost))) + content.WriteString("\n") + } +} diff --git a/internal/ui/dashboard/views/suggestions_view.go b/internal/ui/dashboard/views/suggestions_view.go new file mode 100644 index 0000000..1b941b8 --- /dev/null +++ b/internal/ui/dashboard/views/suggestions_view.go @@ -0,0 +1,136 @@ +package views + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// Suggestion represents a view-friendly suggestion entry. +type Suggestion struct { + Title string + Description string + Impact string + ActionItems []string + Priority int + Type string +} + +// SuggestionsViewProps carries data necessary to render suggestions. +type SuggestionsViewProps struct { + Theme ThemePalette + Suggestions []Suggestion + SelectedIndex int + Error string + NavigationHelp string + CommandHelpLine string +} + +// RenderSuggestionsView renders the optimization suggestions view. +func RenderSuggestionsView(props SuggestionsViewProps) string { + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Primary).Padding(0, 2) + headerStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Success).Padding(1, 2) + selectedStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Background(props.Theme.Primary).Bold(true).Padding(0, 1) + normalStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Padding(0, 1) + mutedStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(0, 1) + warningStyle := lipgloss.NewStyle().Foreground(props.Theme.Warning).Padding(0, 1) + dangerStyle := lipgloss.NewStyle().Foreground(props.Theme.Danger).Padding(0, 1) + helpStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(1, 2) + + var content strings.Builder + + content.WriteString(titleStyle.Render("BobaMixer - Optimization Suggestions")) + content.WriteString("\n\n") + + if props.Error != "" { + content.WriteString(dangerStyle.Render(fmt.Sprintf(" Error: %s", props.Error))) + content.WriteString("\n\n") + content.WriteString(helpStyle.Render(props.NavigationHelp + " [R] Retry")) + return content.String() + } + + content.WriteString(headerStyle.Render("💡 Recommendations (Last 7 Days)")) + content.WriteString("\n\n") + + if len(props.Suggestions) == 0 { + content.WriteString(mutedStyle.Render(" ✓ No suggestions - your usage is optimized!")) + content.WriteString("\n\n") + } else { + index := props.SelectedIndex + if index >= len(props.Suggestions) { + index = 0 + } + + for i, sugg := range props.Suggestions { + priorityStyle, priorityIcon := priorityPresentation(sugg.Priority, normalStyle, warningStyle, dangerStyle, mutedStyle) + typeIcon := suggestionTypeIcon(sugg.Type) + line := fmt.Sprintf(" %s %s [P%d] %s", priorityIcon, typeIcon, sugg.Priority, sugg.Title) + if i == index { + content.WriteString(selectedStyle.Render("▶ " + line)) + } else { + content.WriteString(priorityStyle.Render(line)) + } + content.WriteString("\n") + } + + if len(props.Suggestions) > 0 { + sugg := props.Suggestions[index] + content.WriteString("\n") + content.WriteString(headerStyle.Render("Details")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(fmt.Sprintf(" %s", sugg.Description))) + content.WriteString("\n") + content.WriteString(normalStyle.Render(fmt.Sprintf(" Impact: %s", sugg.Impact))) + content.WriteString("\n\n") + + if len(sugg.ActionItems) > 0 { + content.WriteString(headerStyle.Render("Recommended Actions")) + content.WriteString("\n") + for idx, action := range sugg.ActionItems { + content.WriteString(normalStyle.Render(fmt.Sprintf(" %d. %s", idx+1, action))) + content.WriteString("\n") + } + } + } + } + + content.WriteString("\n") + helpText := props.NavigationHelp + if props.CommandHelpLine != "" { + helpText += "\n" + props.CommandHelpLine + } + content.WriteString(helpStyle.Render(helpText)) + + return content.String() +} + +func priorityPresentation(priority int, normalStyle, warningStyle, dangerStyle, mutedStyle lipgloss.Style) (lipgloss.Style, string) { + switch priority { + case 5: + return dangerStyle, "🔴" + case 4: + return warningStyle, "🟠" + case 3: + return normalStyle, "🟡" + default: + return mutedStyle, "🟢" + } +} + +func suggestionTypeIcon(t string) string { + switch t { + case "cost": + return "💰" + case "profile": + return "🔄" + case "budget": + return "📊" + case "anomaly": + return "⚠️" + case "usage": + return "📈" + default: + return "📈" + } +} diff --git a/internal/ui/dashboard/views/theme.go b/internal/ui/dashboard/views/theme.go new file mode 100644 index 0000000..80f6b4f --- /dev/null +++ b/internal/ui/dashboard/views/theme.go @@ -0,0 +1,13 @@ +package views + +import "github.com/charmbracelet/lipgloss" + +// ThemePalette contains the minimal colors required by dashboard views. +type ThemePalette struct { + Primary lipgloss.AdaptiveColor + Success lipgloss.AdaptiveColor + Danger lipgloss.AdaptiveColor + Warning lipgloss.AdaptiveColor + Text lipgloss.AdaptiveColor + Muted lipgloss.AdaptiveColor +} diff --git a/internal/ui/dashboard/views/tools_view.go b/internal/ui/dashboard/views/tools_view.go new file mode 100644 index 0000000..d7d0cc0 --- /dev/null +++ b/internal/ui/dashboard/views/tools_view.go @@ -0,0 +1,120 @@ +package views + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// ToolRow represents a compact tool entry. +type ToolRow struct { + Name string + Exec string + Kind string + Bound bool +} + +// ToolDetails contains metadata for the selected tool. +type ToolDetails struct { + ID string + ConfigType string + ConfigPath string + Description string +} + +// ToolsViewProps carries all data required to render the tools view. +type ToolsViewProps struct { + Theme ThemePalette + SearchBar string + EmptyStateMessage string + Tools []ToolRow + SelectedIndex int + Details *ToolDetails + NavigationHelp string + HelpCommands string + BoundIcon string + UnboundIcon string +} + +// RenderToolsView renders the CLI tools management UI. +func RenderToolsView(props ToolsViewProps) string { + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Primary).Padding(0, 2) + headerStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Success).Padding(1, 2) + selectedStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Background(props.Theme.Primary).Bold(true).Padding(0, 1) + normalStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Padding(0, 1) + mutedStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(0, 1) + helpStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(1, 2) + + var content strings.Builder + + content.WriteString(titleStyle.Render("BobaMixer - CLI Tools Management")) + content.WriteString("\n\n") + + content.WriteString(headerStyle.Render("🛠 Detected Tools")) + content.WriteString("\n\n") + + if bar := strings.TrimSpace(props.SearchBar); bar != "" { + content.WriteString(bar) + content.WriteString("\n\n") + } + + rows := props.Tools + if len(rows) == 0 { + if msg := strings.TrimSpace(props.EmptyStateMessage); msg != "" { + content.WriteString(mutedStyle.Render(" " + msg)) + content.WriteString("\n") + } + } else { + selectedIndex := props.SelectedIndex + if selectedIndex >= len(rows) { + selectedIndex = len(rows) - 1 + } + + for idx, row := range rows { + icon := props.UnboundIcon + if row.Bound { + icon = props.BoundIcon + } + + line := fmt.Sprintf(" %s %-15s %-30s %s", + icon, + row.Name, + row.Exec, + row.Kind, + ) + + if idx == selectedIndex { + content.WriteString(selectedStyle.Render("▶ " + line)) + } else { + content.WriteString(normalStyle.Render(" " + line)) + } + content.WriteString("\n") + } + } + + content.WriteString("\n") + if props.Details != nil { + content.WriteString(headerStyle.Render("Details")) + content.WriteString("\n") + content.WriteString(normalStyle.Render(fmt.Sprintf(" ID: %s", props.Details.ID))) + content.WriteString("\n") + content.WriteString(normalStyle.Render(fmt.Sprintf(" Config Type: %s", props.Details.ConfigType))) + content.WriteString("\n") + content.WriteString(normalStyle.Render(fmt.Sprintf(" Config Path: %s", props.Details.ConfigPath))) + content.WriteString("\n") + if strings.TrimSpace(props.Details.Description) != "" { + content.WriteString(normalStyle.Render(fmt.Sprintf(" Description: %s", props.Details.Description))) + content.WriteString("\n") + } + content.WriteString("\n") + } + + helpText := props.NavigationHelp + if cmds := strings.TrimSpace(props.HelpCommands); cmds != "" { + helpText += " " + cmds + } + content.WriteString(helpStyle.Render(helpText)) + + return content.String() +} diff --git a/internal/ui/keys.go b/internal/ui/keys.go new file mode 100644 index 0000000..dcfcaa0 --- /dev/null +++ b/internal/ui/keys.go @@ -0,0 +1,7 @@ +package ui + +const ( + keyCtrlC = "ctrl+c" + keyEsc = "esc" + keyEnter = "enter" +) diff --git a/internal/ui/onboarding.go b/internal/ui/onboarding.go index e2cb5e0..ad98191 100644 --- a/internal/ui/onboarding.go +++ b/internal/ui/onboarding.go @@ -138,7 +138,7 @@ func (m OnboardingModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.quitting = true return m, tea.Quit - case "esc": + case keyEsc: // Allow going back in most stages if m.stage > StageWelcome && m.stage < StageComplete { m.stage-- diff --git a/internal/ui/tui.go b/internal/ui/tui.go index de5677a..e83e15f 100644 --- a/internal/ui/tui.go +++ b/internal/ui/tui.go @@ -21,10 +21,6 @@ import ( "github.com/royisme/bobamixer/internal/store/sqlite" ) -const ( - keyCtrlC = "ctrl+c" -) - // ViewMode represents different views in the TUI type ViewMode int @@ -141,7 +137,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.selectedIdx++ } - case "enter": + case keyEnter: if m.viewMode == ViewProfiles && m.selectedIdx < len(m.profileList) { m.activeProfile = m.profileList[m.selectedIdx] return m, m.saveActiveProfile diff --git a/docs/CLI_REDESIGN.md b/spec/CLI_REDESIGN.md similarity index 100% rename from docs/CLI_REDESIGN.md rename to spec/CLI_REDESIGN.md diff --git a/docs/PHASE1_FEATURES.md b/spec/PHASE1_FEATURES.md similarity index 100% rename from docs/PHASE1_FEATURES.md rename to spec/PHASE1_FEATURES.md diff --git a/docs/PHASE2_FEATURES.md b/spec/PHASE2_FEATURES.md similarity index 100% rename from docs/PHASE2_FEATURES.md rename to spec/PHASE2_FEATURES.md diff --git a/docs/PHASE3_FEATURES.md b/spec/PHASE3_FEATURES.md similarity index 100% rename from docs/PHASE3_FEATURES.md rename to spec/PHASE3_FEATURES.md diff --git a/docs/TUI_ENHANCEMENT_PLAN.md b/spec/TUI_ENHANCEMENT_PLAN.md similarity index 100% rename from docs/TUI_ENHANCEMENT_PLAN.md rename to spec/TUI_ENHANCEMENT_PLAN.md diff --git a/spec/TUI_SPRINT4_PLAN.md b/spec/TUI_SPRINT4_PLAN.md new file mode 100644 index 0000000..4feaeec --- /dev/null +++ b/spec/TUI_SPRINT4_PLAN.md @@ -0,0 +1,62 @@ +# TUI Sprint 4 Plan + +目标:在保持现有 Phase 1-3 功能的基础上,进一步提升可发现性、交互深度与后端准确性,让控制平面的体验更连贯、更可信。 + +范围包含三条主线:导航与发现、交互式编辑、Proxy/Routing/Hooks 的数据闭环。 + +--- + +## 1. 导航与发现(Navigation & Discoverability) + +| 子任务 | 描述 | 产出 | 验收标准 | +|--------|------|------|----------| +| 视图分组 | 将 13 个顶层视图压缩为 4-6 个领域入口(例如 Dashboard、Control Plane、Usage、Optimization、DevOps),内部通过 list/tab 切换子模块。 | 新的 `viewMode` 枚举与视图调度逻辑;更新帮助文案。 | 数字快捷键 1-6 对应领域视图,Tab 循环遵循新顺序;各子模块仍可访问。 | +| 状态栏与帮助 | 在底部统一显示当前视图可用快捷键;实现 `?` 弹窗集中呈现所有全局/局部操作;为分组后的子视图补充文案。 | Footer 模块、`renderHelpOverlay` 逻辑。 | 任意视图按 `?` 可看到帮助弹窗,Esc 关闭;底部提示实时更新。 | +| 搜索/过滤 | 在列表型视图(Providers/Tools/Sessions 等)启用 `/` 搜索过滤;Tab 或箭头键在搜索输入与列表之间切换。 | 复用 bubbles/textinput 实现的搜索框。 | 输入关键字后只显示匹配条目,Esc 清空;不破坏原有导航。 | +| 主题与布局统一 | 调整 lipgloss 主题,使 Dashboard/TUI 旧视图与 Control Plane 新视图风格一致;处理最小宽度提示。 | 主题配置、全局空态文案。 | 80 列下仍可用;所有视图标题/分隔符一致。 | + +--- + +## 2. 交互式编辑能力(Inline Editing Flows) + +| 子任务 | 描述 | 产出 | 验收标准 | +|--------|------|------|----------| +| Secrets 视图增强 | 使用 bubbles/textinput 提供 `Set API key`、`Remove`、`Test` 操作;输入框支持 masked、长度校验。 | `handleSecretInput`、`SecretFormState` 等辅助结构。 | 按 `s` 弹出输入框,Enter 保存;错误提示以 toast/消息形式出现。 | +| Providers 编辑 | 在 Control Plane 子视图中支持 `a` 新增、`e` 编辑 Provider:字段包括 display_name、base_url、default_model、api_key source 等;保存前校验。 | Form 流程 + YAML 写回逻辑(沿用 core 配置写入)。 | 操作后 `providers.yaml` 更新,视图刷新;校验失败时提示。 | +| Bindings 编辑 | 支持在 TUI 中编辑绑定:选择 Provider、model、proxy 开关,必要时可新建绑定;引用已有 providers/tools。 | 简化选择器(list+enter);binding 更新逻辑。 | 成功保存后 Dashboard 行同步变更;proxy 切换即时显示。 | +| 工具路径管理 | Tools 列表中允许 `r` 重新检测、`e` 编辑路径(文本输入,自动补全可选);保存后触发检测。 | CLI 兼容的检测函数 + TUI 表单。 | 修改路径后 `tools.yaml` 更新且状态刷新。 | +| 轻量 Form 基础 | 在多处使用 textinput/list/modal 后,如确实出现重复,再抽取最小共用层(例如 `PromptModal`)。 | 可选:`ui/components/form.go`。 | 仅在观察到重复模式后实现,避免过早抽象。 | + +--- + +## 3. Proxy / Routing / Hooks 的数据闭环(Backend Alignment) + +| 子任务 | 描述 | 产出 | 验收标准 | +|--------|------|------|----------| +| Proxy 状态与日志 | 将 Proxy 运行信息(端口、请求数、最近错误)写入 sqlite/logs,TUI 读取后实时刷新;可选提供 `Start/Stop` 调用。 | Proxy telemetry 结构 + TUI 读取逻辑。 | Proxy 视图显示真实状态;若 Proxy 未运行,支持一键启动或指引。 | +| Routing 决策真实数据 | Routing 视图从 `routes.yaml` + 最近路由日志生成示例,运行 `boba route test` 时将结果写入 DB 供 TUI 展示;可提供最近规则命中统计。 | Routing 日志 schema、`routing.Engine` 更新、TUI 渲染。 | 输入文本后能看到真实匹配的 profile/rule/explanation;统计信息与 CLI 一致。 | +| Hooks Telemetry | Hooks 安装后把事件写入 sqlite;TUI 视图展示当前 repo 状态与最近事件;提供 install/uninstall 快捷操作(调用 `boba hooks`)。 | hooks manager 更新 + TUI UI。 | TUI 中能检测当前仓库并显示 Hooks 状态;执行 install/uninstall 后状态同步;最近活动列表不为空时显示。 | +| 文档同步 | 更新 `spec/TUI_ENHANCEMENT_PLAN.md` 与用户 Facing 文档,说明 TUI 已覆盖的功能、CLI 转换路径、剩余高级命令。 | 文档 PR。 | README/帮助信息与实际功能一致;CLI 输出提示用户首选 TUI。 | + +--- + +## 时间与里程碑(建议) + +| Sprint | 目标 | 主要交付 | +|--------|------|----------| +| Sprint 4-A | 完成视图分组 + 帮助/搜索 | 新导航结构、`?`、`/` | +| Sprint 4-B | Secrets + Providers + Bindings 编辑流程 | 三大编辑流可在 TUI 内完成 | +| Sprint 4-C | Proxy/Routing/Hooks 数据闭环 + 文档 | Proxy 状态可信、Routing/Hook 视图展示真实数据 | + +每个 Sprint 结束前需运行 `go build ./...`、`go test ./...`、`golangci-lint run`,并在 TUI 中手动验证 80/120/200 列下的渲染效果。 + +--- + +## 成功标准 + +1. 用户无需记忆大量快捷键即可通过顶层视图进入目标功能,`?` 提示覆盖所有操作。 +2. 核心 Control Plane 配置(Provider/Tool/Binding/Secret)在 TUI 内即可新增/编辑/测试,无需回落 CLI。 +3. Proxy/Routing/Hooks 视图显示的都是可信实时数据,且与 CLI 输出一致。 +4. 文档与帮助明确宣告「TUI 优先、CLI 辅助」的最新状态,避免用户混淆。 + +达到这些标准后即可认为 Sprint 4 完成,进入后续更细分的优化阶段(例如高级过滤、主题自定义、自动化工作流等)。 diff --git a/spec/roadmap-progress.md b/spec/roadmap-progress.md new file mode 100644 index 0000000..56e2215 --- /dev/null +++ b/spec/roadmap-progress.md @@ -0,0 +1,65 @@ +# BobaMixer Roadmap & Progress Review + +This document consolidates the roadmap intent and actual delivery progress using the key specs inside `spec/`: + +- `spec/boba-control-plane.md` - Control plane baseline (architecture, CLI expectations, delivery phases). +- `spec/CLI_REDESIGN.md` - CLI redesign narrative that commits us to a TUI-first product philosophy. +- `spec/TUI_ENHANCEMENT_PLAN.md` - Migration plan that maps legacy CLI behaviors to Bubble Tea views. +- `spec/PHASE1_FEATURES.md`, `spec/PHASE2_FEATURES.md`, `spec/PHASE3_FEATURES.md` - Execution logs for the three delivery phases. + +## Vision Recap (Baseline Spec) + +- **Product posture**: BobaMixer is the control plane for local AI CLIs. It manages providers, tools, bindings, secrets, optional profiles, and can host a local proxy (`spec/boba-control-plane.md` Sections 1-6). +- **CLI contract**: Keep a small set of core commands (`boba`, `boba run`, `boba providers/tools/bind`, `boba doctor`, `boba proxy serve`) and treat everything else as advanced or deprecated (`spec/boba-control-plane.md` Section 4). +- **Runtime behavior**: `boba run` resolves tool/provider bindings, injects env/config, optionally routes through the proxy, and launches the downstream CLI unmodified (Section 5). +- **Architecture**: Bubble Tea root model switches between onboarding and dashboard modes, with the dashboard ultimately hosting the full control plane (Section 7). +- **Baseline phases**: Phase 1 (core control plane), Phase 2 (proxy plus monitoring), Phase 3 (advanced routing/budget/stats) (Section 8). + +## TUI-First Strategy (CLI Redesign Spec) + +- **Problem diagnosis**: Users were forced to memorize 20+ commands even though the binary booted a TUI by default, creating a "mixed paradigm" experience (`spec/CLI_REDESIGN.md` Problem Analysis). +- **Strategy**: Treat the TUI as the default interface, keep only non-interactive commands (`boba`, `boba run`, `boba init`, `boba doctor`, `boba call`, `boba stats`, `boba version`), and migrate every other management workflow into dedicated views. +- **Navigation model**: Tabbed layout with discoverable tabs (Dashboard, Providers, Tools, Bindings, Secrets, Stats, Proxy, Routing, Budget, Suggestions, Configuration, Help) and consistent global shortcuts. +- **Implementation plan**: Three phases - (1) simplify Help output, add missing management views, and retire redundant commands; (2) add Provider/Tool/Binding/Secrets/Proxy screens; (3) deliver routing tester, config editor, reports, and a comprehensive help screen - with CLI kept only for automation. + +## Roadmap Snapshot + +| Phase | Baseline Scope (`spec/boba-control-plane.md`) | TUI Plan Targets (`spec/TUI_ENHANCEMENT_PLAN.md`) | Delivery Status | +|-------|----------------------------------------------|---------------------------------------------------|-----------------| +| **Phase 1 - Control Plane Core** | Parse `providers.yaml`, `tools.yaml`, `bindings.yaml`; ship `boba providers/tools/bind/run/doctor`; deliver a minimal dashboard with binding edits (Section 8.1). | Add Providers, Tools, Bindings, Secrets views with consistent navigation and state indicators (Sections 18-115). | Complete. `spec/PHASE1_FEATURES.md` shows the four core views, numeric navigation (`1`-`6`), and lipgloss-based state cues fully implemented. | +| **Phase 2 - Proxy plus Operational Visibility** | Introduce proxy server, `use_proxy` bindings, and usage tracking (Section 8.2). | Add Proxy control, Routing tester, and Suggestions views to surface operational data (Sections 116-287). | Complete. `spec/PHASE2_FEATURES.md` documents the proxy status panel, routing education screen, and data-backed suggestions list plus expanded navigation (`7`-`9`). | +| **Phase 3 - Advanced Features** | Add routing strategies, budgets, stats, reports, hooks, and richer dashboards (Section 8.3). | Deliver Reports generator, Hooks manager, Config editor, Help view, and finish the 13-view navigation loop (Sections 287-485). | Complete. `spec/PHASE3_FEATURES.md` confirms the four advanced views (`0`, `H`, `C`, `?`), unified shortcut map, and mapping of every legacy CLI command to a TUI destination. | + +## Phase Highlights and Reflections + +### Phase 1 - Control Plane Core +- Replaced the CLI-only workflows (`boba providers/tools/bind/secrets`) with live tables, state glyphs, and in-place proxy toggles (`spec/PHASE1_FEATURES.md`). +- Established the navigation idioms - number keys, Tab cycling, vim-style list navigation - and lipgloss theming reused by later phases. +- Remaining gaps noted in the spec: inline provider/tool editing forms, masked secret entry, full binding creation dialogs, and deeper proxy controls (see the "future enhancements" section in `spec/PHASE1_FEATURES.md`), which flow into later sprints of the TUI plan. + +### Phase 2 - Operational and Optimization Views +- Surfaced runtime visibility: Proxy status (with instructions for `boba proxy serve`), routing tester instructions, and a data-driven Suggestions list that interprets cost trends, profile usage, anomaly detection, and budget drift (`spec/PHASE2_FEATURES.md` Sections 1-3, 232-272). +- Extended navigation to nine interactive panes and wired lazy data loading for Suggestions (`spec/PHASE2_FEATURES.md` Section 156 onwards). +- Reflections: While proxy metrics now have a TUI home, the underlying proxy implementation (usage logging, routing policies) still depends on the domain/proxy packages catching up with the spec ambitions (cost-aware routing, rate limiting). These technical milestones remain part of the broader roadmap even though the TUI scaffolding is ready. + +### Phase 3 - Advanced Experience Layer +- Finalized the TUI-first promise with four capstone views: Report generator, Git hooks dashboard, Config editor, and a persistent Help overlay (`spec/PHASE3_FEATURES.md` Sections 1-4, 196-280). +- Completed the 13-view navigation loop and established letter shortcuts (`0`, `H`, `C`, `?`) alongside digits. +- Reflections: Reports/Hooks/Config views currently guide users back to CLI commands (`boba report`, `boba hooks install`, `boba edit`) for execution/editing, which satisfies the "TUI-first but CLI for automation" split. Remaining enhancements include richer inline forms (edit-in-place, YAML previews) and wiring hooks telemetry into the stats pipeline, as suggested in `spec/TUI_ENHANCEMENT_PLAN.md` (Sprint 4 and the Form Components section). + +## Cross-Cutting Observations + +- **Documentation-to-implementation alignment**: Every CLI workflow enumerated in the redesign spec now has a TUI waypoint, and Help output steers users toward the interface rather than subcommands (`spec/PHASE3_FEATURES.md` Section 4). +- **Navigation maturity**: Thirteen total views (Dashboard, Providers, Tools, Bindings, Secrets, Stats, Proxy, Routing, Suggestions, Reports, Hooks, Config, Help) with numeric/alphabetic shortcuts reflect the TUI Enhancement Plan roadmap (Section 316 onwards) and remove ambiguity about where tasks live. +- **CLI surface area**: Core commands remain (`boba`, `boba run`, `boba init`, `boba doctor`, `boba call`, `boba stats`, `boba version`). All other commands are either deprecated or directly linked from TUI help cards, honoring the CLI redesign contract. +- **Proxy and routing backend**: The front-end views are ready, but the control-plane spec still calls for sophisticated routing (cost/latency weighting), budgeting, and hook-driven project context (Sections 6 and 8.3 of `spec/boba-control-plane.md`). These backend capabilities should be validated against real usage data to ensure the TUI indicators remain truthful. + +## Outstanding Work and Next Steps + +1. **Finish Sprint 4 of the TUI plan** - Implement search/filter, enhanced theming, quick-help overlays, and reusable form components (`spec/TUI_ENHANCEMENT_PLAN.md` Sections 316-401, 412-435). +2. **Inline editing flows** - Upgrade Providers/Tools/Bindings views with the planned text input / select / confirm components so edits can happen without shelling out (`spec/TUI_ENHANCEMENT_PLAN.md` Form Components section). +3. **Proxy/routing depth** - Build out the routing engine (time-of-day, cost-aware strategies) and proxy observability promised in the baseline spec to match what the Phase 2 UI exposes. +4. **Hook telemetry loop** - Connect Hooks reports to concrete automation (auto profile suggestions, repository-scoped analytics) so the `Hooks` view reflects live status rather than static instructions. +5. **Documentation refresh** - Update public docs/help to reflect the new TUI-first command surface and ensure advanced CLI commands are clearly labeled as automation-only usages. + +By keeping this summary updated, we can quickly validate whether new feature requests fit into the TUI-first roadmap, ensure backend investments keep pace with the UI, and identify when it is safe to fully retire the remaining legacy CLI paths. diff --git a/spec/ui-refactor_spec.md b/spec/ui-refactor_spec.md new file mode 100644 index 0000000..fe1985f --- /dev/null +++ b/spec/ui-refactor_spec.md @@ -0,0 +1,294 @@ + +--- + +# 【AI 重构与编码指导方案:Bubble Tea + Lipgloss 组件化架构】 + +**目的:** +指导 AI 按照统一标准,对 Bubble Tea + Lipgloss 项目进行组件化重构,避免不必要的结构发明、过度抽象或偏离设计方向。 + +本规范**必须被严格遵守**。任何 AI 在生成代码或修改代码前,都要先验证是否符合本规范。 + +--- + +# 0. 总体目标 + +将现有 Bubble Tea 代码重构为: + +1. 拆分为 **组件层 (components)** +2. 引入 **轻量布局 DSL (layouts)** +3. 页面的 UI 逻辑分离为 **pages** +4. 样式集中到 **theme** +5. 主 Model 只负责分发消息与管理子组件 + +不引入: + +* 虚拟 DOM +* 复杂 runtime +* 不属于 Bubble Tea 的“控件系统” +* AI 自己杜撰的新框架 + +--- + +# 1. 目录结构规范(必须严格遵守) + +AI 必须将所有文件按以下结构组织: + +``` +ui/ + components/ + xxx_component.go + layouts/ + layout.go + row.go + column.go + section.go + pages/ + xxx_page.go + theme/ + style.go + color.go + +model/ + model.go + msg.go + +main.go +``` + +## 禁止 + +* 将 UI 与 model 混在同一文件 +* 在组件内写 style 定义 +* 在 page 内写布局 DSL +* 写任意未在本规范中定义的目录 + +--- + +# 2. Bubble Tea 组件模式规范(必须遵守) + +每个组件文件 **必须** 遵循如下模式: + +``` +type XxxComponent struct { + // 组件状态 +} + +func NewXxxComponent(...) XxxComponent { +} + +func (c XxxComponent) Update(msg tea.Msg) (XxxComponent, tea.Cmd) { +} + +func (c XxxComponent) View() string { +} +``` + +### 组件原则: + +1. **组件只能处理自己的状态** +2. **组件不允许访问 Model 的外部字段** +3. **组件的 View() 只能返回 string** +4. **组件的样式必须从 /ui/theme 引入** +5. **组件不允许自行处理 layout(Row/Column)** + + * 布局由 pages 或主 view 处理 + * 组件必须“只关心内容,不关心布局” + +--- + +# 3. Page(页面)规范(必须遵守) + +Page 是由组件组合形成的 UI 单元: + +``` +type Page interface { + Init() tea.Cmd + Update(msg tea.Msg) (Page, tea.Cmd) + View() string +} +``` + +示例结构: + +``` +type HomePage struct { + Tasks TaskList + Stats StatsPanel +} + +func NewHomePage() HomePage { + return HomePage{ + Tasks: NewTaskList(), + Stats: NewStatsPanel(), + } +} +``` + +### Page 必须满足: + +* 字段只能是组件或简单状态 +* View() 只能使用 `/ui/layouts` 提供的布局方法 +* 不允许在页面中写具体样式 +* 不允许直接构建 strings.Builder + +--- + +# 4. 布局 DSL 规范(必须遵守) + +布局只能通过 `/ui/layouts` 中的函数实现,不允许 AI 自己发明布局 API。 + +布局 DSL 必须包含: + +``` +Row(blocks ...string) string +Column(blocks ...string) string +Section(title string, body string) string +``` + +可选(如需要): + +``` +Gap(n int) string +Pad(padding int, content string) string +``` + +### 布局系统规则: + +1. **布局只表示关系,不负责样式** +2. **布局不能包含业务逻辑** +3. **布局不得写入硬编码 lipgloss style** + + * 正确:style 统一在 theme 定义 + * 错误:Row() 中出现 `.Border()` + +--- + +# 5. UI 主题规范(必须遵守) + +所有 Style **必须在 `/ui/theme`** 中定义。 + +例如: + +``` +var HeaderStyle = lipgloss.NewStyle(). + Foreground(colorPrimary). + Bold(true). + Padding(1, 2) +``` + +AI 不得: + +* 在组件中创建 `lipgloss.NewStyle()` +* 在页面中写颜色、padding、border 等 +* 在布局 DSL 中写任何 style + +所有 style 都必须统一从 theme import。 + +--- + +# 6. 主 Model 规范(必须遵守) + +主 Model 负责: + +* 管理当前 Page +* 将消息分发给当前 Page +* 整体 View 使用 layout DSL 进行组合 + +典型模式: + +``` +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + newPage, cmd := m.Page.Update(msg) + m.Page = newPage + return m, cmd +} + +func (m Model) View() string { + return Column( + Header(m), + m.Page.View(), + Footer(m), + ) +} +``` + +禁止: + +* 主 Model 中拼接字符串 +* 在主 View 中出现任何 lipgloss style +* 在主 Model 中访问组件内部状态 + +--- + +# 7. 修改现有项目时 AI 必须遵循的流程(必须遵守) + +1. **扫描现有视图代码** +2. 标记可拆分的组件(列表、卡片、状态栏、日志区、按钮行等) +3. 在 `/ui/components` 中为每一个组件创建文件 +4. 将视图逻辑从主 Model 转移到组件 +5. 将布局逻辑移动到 `/ui/layouts` +6. 将样式提取到 `/ui/theme` +7. 将业务状态与 UI 页面解耦(pages) +8. 主 Model 最终只负责页面切换与 msg 分发 +9. 不允许生成超出规范的新 API、结构或文件 + +--- + +# 8. AI 必须遵守的风格验证 Checklist + +在提交任何代码前,AI 必须检查: + +### 文件结构 OK? + +* 组件在 components +* 页面在 pages +* 布局在 layouts +* 样式在 theme +* 主 Model 在 model + +### 组件是否合格? + +* 拆了 View? +* 没写 style? +* 没写布局? +* 有 Update()? +* 有 View()? + +### 页面是否合格? + +* 只使用布局 DSL? +* 不出现 lipgloss style? +* 不直接 string 操作? + +### theme 是否唯一? + +* 没有组件偷偷创建 style? + +### 主 Model 是否“瘦”? + +* 没写 UI? +* 没写 lipgloss? +* 只负责 Page 切换? + +**如果任一项失败,AI 必须重新修改,而不是继续生成。** + +--- + +# 9. 强制性禁止条目(AI 不允许做的) + +AI 绝不能做以下事情: + +* 发明新的组件系统(比如 Tabs、TreeView) +* 引入虚拟 DOM 或 diff +* 混合 model 和 view +* 在 layout 或 component 内写 style +* 在 component 内写布局逻辑 +* 在页面中拼字符串 +* 在 component 中创建 models +* 改变项目结构(不得创建新的目录) +* 使用反射、接口泛滥、不必要抽象 +* 引入非 Bubble Tea 技术(cespare/tui、termui 等) + +任何违反以上规则的代码都必须拒绝生成。 + +--- From 8bbef415777cb179973437da5904dd35de0c0f60 Mon Sep 17 00:00:00 2001 From: Roy Zhu Date: Tue, 18 Nov 2025 13:03:31 -0500 Subject: [PATCH 2/4] docs: Add GEMINI.md for project overview This commit introduces GEMINI.md, a comprehensive project overview generated to provide instructional context for AI interactions. It summarizes the BobaMixer project's purpose, technologies, architecture, building/running instructions, and development conventions based on the existing README.md. --- GEMINI.md | 94 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 GEMINI.md diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..0ab9c1f --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,94 @@ +# BobaMixer Project Overview for Gemini + +This document provides a comprehensive overview of the BobaMixer project, detailing its purpose, technologies, architecture, and development practices, derived from its `README.md` file. This information is intended to serve as instructional context for future interactions with Gemini. + +## Project Overview + +BobaMixer is an intelligent router and cost optimizer designed for AI workflows. It acts as a control plane for managing various AI service providers, local CLI tools, and their bindings. Its core functionalities include auto-injecting credentials, running local AI CLI tools, and providing an optional local HTTP proxy to consolidate requests. Advanced features encompass intelligent routing based on task characteristics, multi-level budget management with alerts, and precise usage analytics for cost tracking. The project is primarily written in Go (version 1.25 or newer) and leverages SQLite for local storage, and the Bubble Tea framework for its terminal user interface (TUI). It emphasizes a modular design, robust error handling, and adherence to Go best practices. + +## Building and Running + +To get started with BobaMixer, follow these steps: + +### Installation + +* **Using Go:** + ```bash + go install github.com/royisme/bobamixer/cmd/boba@latest + ``` +* **Using Homebrew:** + ```bash + brew tap royisme/tap + brew install bobamixer + ``` + +### First Time Setup (Interactive Onboarding) + +* Run `boba` in your terminal. The onboarding wizard will automatically guide you through configuration, API key input, and verification. + +### Alternative CLI Setup (for power users) + +1. **Initialize config directory:** + ```bash + boba init + ``` +2. **Configure API Key:** (e.g., for Anthropic) + ```bash + boba secrets set claude-anthropic-official + ``` +3. **Bind tool to Provider:** (e.g., bind `claude` tool to `claude-anthropic-official` provider) + ```bash + boba bind claude claude-anthropic-official + ``` +4. **Verify configuration:** + ```bash + boba doctor + ``` +5. **Run a tool:** (e.g., run the `claude` tool to check its version) + ```bash + boba run claude --version + ``` + +### Using Environment Variables + +* Set API keys as environment variables (e.g., `export ANTHROPIC_API_KEY="sk-ant..."`). BobaMixer prioritizes environment variables. + +### Build from Source (for Developers) + +1. **Clone the repository:** + ```bash + git clone https://github.com/royisme/BobaMixer.git + ``` +2. **Navigate into the directory:** + ```bash + cd BobaMixer + ``` +3. **Install dependencies:** + ```bash + go mod download + ``` +4. **Build:** + ```bash + make build + ``` +5. **Run tests:** + ```bash + make test + ``` +6. **Lint check:** + ```bash + make lint + ``` + +## Development Conventions + +The BobaMixer project adheres to strict Go language standards and best practices: + +* **Code Quality:** + * All exported types and functions must have documentation comments. + * `golangci-lint` is used for static analysis, with a target of 0 issues. + * Follow the [Effective Go](https://go.dev/doc/effective_go) guide. + * Run `make test && make lint` before committing changes. +* **Error Handling:** Complete error wrapping and graceful degradation are implemented. +* **Concurrency Safety:** `sync.RWMutex` is used to protect shared state, ensuring thread-safe operations. +* **Security:** All exceptions are marked with `#nosec` after an audit. From 6472b705ab59c4f4a697e1f1e64f3fbd0232af9e Mon Sep 17 00:00:00 2001 From: Roy Zhu Date: Tue, 18 Nov 2025 15:30:23 -0500 Subject: [PATCH 3/4] UI Root Model Refactor --- .gitignore | 1 + internal/ui/components/binding_details.go | 63 + internal/ui/components/binding_list.go | 80 + internal/ui/components/bullet_list.go | 47 + internal/ui/components/config_file_list.go | 78 + internal/ui/components/help_bar.go | 30 + internal/ui/components/help_footer.go | 30 + internal/ui/components/help_header.go | 36 + internal/ui/components/help_links.go | 63 + internal/ui/components/help_section_list.go | 63 + internal/ui/components/help_shortcut_list.go | 55 + internal/ui/components/help_tips.go | 43 + internal/ui/components/hook_list.go | 65 + internal/ui/components/info_message.go | 36 + internal/ui/components/paragraph.go | 43 + internal/ui/components/provider_details.go | 63 + internal/ui/components/provider_list.go | 103 + internal/ui/components/proxy_status.go | 36 + internal/ui/components/proxy_status_panel.go | 60 + internal/ui/components/report_options_list.go | 69 + .../ui/components/secret_provider_list.go | 87 + internal/ui/components/stats_profiles.go | 66 + internal/ui/components/stats_summary.go | 65 + internal/ui/components/status_message.go | 36 + internal/ui/components/suggestion_details.go | 58 + internal/ui/components/suggestion_list.go | 99 + internal/ui/components/title_bar.go | 30 + internal/ui/components/tool_details.go | 62 + internal/ui/components/tool_list.go | 84 + internal/ui/dashboard.go | 2466 ----------------- internal/ui/dashboard/views/config_view.go | 95 - internal/ui/dashboard/views/dashboard_view.go | 51 - internal/ui/dashboard/views/help_view.go | 113 - internal/ui/dashboard/views/hooks_view.go | 99 - internal/ui/dashboard/views/providers_view.go | 140 - internal/ui/dashboard/views/proxy_view.go | 85 - internal/ui/dashboard/views/reports_view.go | 91 - internal/ui/dashboard/views/routing_view.go | 74 - internal/ui/dashboard/views/secrets_view.go | 118 - internal/ui/dashboard/views/stats_view.go | 121 - .../ui/dashboard/views/suggestions_view.go | 136 - internal/ui/dashboard/views/theme.go | 13 - internal/ui/dashboard/views/tools_view.go | 120 - internal/ui/features/bindings/service.go | 187 ++ internal/ui/features/bindings/service_test.go | 364 +++ internal/ui/features/config/service.go | 72 + internal/ui/features/config/service_test.go | 247 ++ internal/ui/features/dashboard/service.go | 152 + .../ui/features/dashboard/service_test.go | 523 ++++ internal/ui/features/help/service.go | 64 + internal/ui/features/help/service_test.go | 259 ++ internal/ui/features/hooks/service.go | 75 + internal/ui/features/hooks/service_test.go | 262 ++ internal/ui/features/providers/service.go | 178 ++ .../ui/features/providers/service_test.go | 474 ++++ internal/ui/features/proxy/service.go | 71 + internal/ui/features/proxy/service_test.go | 225 ++ internal/ui/features/reports/service.go | 53 + internal/ui/features/reports/service_test.go | 212 ++ internal/ui/features/routing/service.go | 54 + internal/ui/features/routing/service_test.go | 168 ++ internal/ui/features/secrets/service.go | 212 ++ internal/ui/features/secrets/service_test.go | 288 ++ internal/ui/features/stats/service.go | 121 + internal/ui/features/stats/service_test.go | 283 ++ internal/ui/features/suggestions/service.go | 79 + .../ui/features/suggestions/service_test.go | 237 ++ internal/ui/features/tools/service.go | 83 + internal/ui/features/tools/service_test.go | 331 +++ internal/ui/forms/binding_form.go | 319 +++ internal/ui/forms/provider_form.go | 341 +++ internal/ui/forms/secret_form.go | 127 + internal/ui/{ => i18n}/i18n.go | 4 +- internal/ui/{ => i18n}/i18n_test.go | 2 +- internal/ui/{ => i18n}/locales/en.json | 0 internal/ui/{ => i18n}/locales/zh-CN.json | 0 internal/ui/layouts/layouts.go | 75 + internal/ui/onboarding.go | 7 +- internal/ui/pages/bindings_page.go | 126 + internal/ui/pages/config_page.go | 96 + internal/ui/pages/dashboard_page.go | 84 + internal/ui/pages/help_page.go | 95 + internal/ui/pages/hooks_page.go | 112 + internal/ui/pages/page.go | 10 + internal/ui/pages/providers_page.go | 124 + internal/ui/pages/proxy_page.go | 104 + internal/ui/pages/reports_page.go | 98 + internal/ui/pages/routing_page.go | 99 + internal/ui/pages/secrets_page.go | 122 + internal/ui/pages/stats_page.go | 115 + internal/ui/pages/suggestions_page.go | 101 + internal/ui/pages/tools_page.go | 101 + internal/ui/root/messages.go | 81 + internal/ui/root/model.go | 214 ++ internal/ui/root/search.go | 109 + internal/ui/root/sections.go | 181 ++ internal/ui/root/table.go | 75 + internal/ui/root/update.go | 420 +++ internal/ui/root/view.go | 477 ++++ internal/ui/theme.go | 132 - internal/ui/theme/color.go | 129 + internal/ui/theme/style.go | 55 + internal/ui/theme_alias.go | 34 + internal/ui/tui.go | 138 +- 104 files changed, 11006 insertions(+), 3948 deletions(-) create mode 100644 internal/ui/components/binding_details.go create mode 100644 internal/ui/components/binding_list.go create mode 100644 internal/ui/components/bullet_list.go create mode 100644 internal/ui/components/config_file_list.go create mode 100644 internal/ui/components/help_bar.go create mode 100644 internal/ui/components/help_footer.go create mode 100644 internal/ui/components/help_header.go create mode 100644 internal/ui/components/help_links.go create mode 100644 internal/ui/components/help_section_list.go create mode 100644 internal/ui/components/help_shortcut_list.go create mode 100644 internal/ui/components/help_tips.go create mode 100644 internal/ui/components/hook_list.go create mode 100644 internal/ui/components/info_message.go create mode 100644 internal/ui/components/paragraph.go create mode 100644 internal/ui/components/provider_details.go create mode 100644 internal/ui/components/provider_list.go create mode 100644 internal/ui/components/proxy_status.go create mode 100644 internal/ui/components/proxy_status_panel.go create mode 100644 internal/ui/components/report_options_list.go create mode 100644 internal/ui/components/secret_provider_list.go create mode 100644 internal/ui/components/stats_profiles.go create mode 100644 internal/ui/components/stats_summary.go create mode 100644 internal/ui/components/status_message.go create mode 100644 internal/ui/components/suggestion_details.go create mode 100644 internal/ui/components/suggestion_list.go create mode 100644 internal/ui/components/title_bar.go create mode 100644 internal/ui/components/tool_details.go create mode 100644 internal/ui/components/tool_list.go delete mode 100644 internal/ui/dashboard.go delete mode 100644 internal/ui/dashboard/views/config_view.go delete mode 100644 internal/ui/dashboard/views/dashboard_view.go delete mode 100644 internal/ui/dashboard/views/help_view.go delete mode 100644 internal/ui/dashboard/views/hooks_view.go delete mode 100644 internal/ui/dashboard/views/providers_view.go delete mode 100644 internal/ui/dashboard/views/proxy_view.go delete mode 100644 internal/ui/dashboard/views/reports_view.go delete mode 100644 internal/ui/dashboard/views/routing_view.go delete mode 100644 internal/ui/dashboard/views/secrets_view.go delete mode 100644 internal/ui/dashboard/views/stats_view.go delete mode 100644 internal/ui/dashboard/views/suggestions_view.go delete mode 100644 internal/ui/dashboard/views/theme.go delete mode 100644 internal/ui/dashboard/views/tools_view.go create mode 100644 internal/ui/features/bindings/service.go create mode 100644 internal/ui/features/bindings/service_test.go create mode 100644 internal/ui/features/config/service.go create mode 100644 internal/ui/features/config/service_test.go create mode 100644 internal/ui/features/dashboard/service.go create mode 100644 internal/ui/features/dashboard/service_test.go create mode 100644 internal/ui/features/help/service.go create mode 100644 internal/ui/features/help/service_test.go create mode 100644 internal/ui/features/hooks/service.go create mode 100644 internal/ui/features/hooks/service_test.go create mode 100644 internal/ui/features/providers/service.go create mode 100644 internal/ui/features/providers/service_test.go create mode 100644 internal/ui/features/proxy/service.go create mode 100644 internal/ui/features/proxy/service_test.go create mode 100644 internal/ui/features/reports/service.go create mode 100644 internal/ui/features/reports/service_test.go create mode 100644 internal/ui/features/routing/service.go create mode 100644 internal/ui/features/routing/service_test.go create mode 100644 internal/ui/features/secrets/service.go create mode 100644 internal/ui/features/secrets/service_test.go create mode 100644 internal/ui/features/stats/service.go create mode 100644 internal/ui/features/stats/service_test.go create mode 100644 internal/ui/features/suggestions/service.go create mode 100644 internal/ui/features/suggestions/service_test.go create mode 100644 internal/ui/features/tools/service.go create mode 100644 internal/ui/features/tools/service_test.go create mode 100644 internal/ui/forms/binding_form.go create mode 100644 internal/ui/forms/provider_form.go create mode 100644 internal/ui/forms/secret_form.go rename internal/ui/{ => i18n}/i18n.go (97%) rename internal/ui/{ => i18n}/i18n_test.go (99%) rename internal/ui/{ => i18n}/locales/en.json (100%) rename internal/ui/{ => i18n}/locales/zh-CN.json (100%) create mode 100644 internal/ui/layouts/layouts.go create mode 100644 internal/ui/pages/bindings_page.go create mode 100644 internal/ui/pages/config_page.go create mode 100644 internal/ui/pages/dashboard_page.go create mode 100644 internal/ui/pages/help_page.go create mode 100644 internal/ui/pages/hooks_page.go create mode 100644 internal/ui/pages/page.go create mode 100644 internal/ui/pages/providers_page.go create mode 100644 internal/ui/pages/proxy_page.go create mode 100644 internal/ui/pages/reports_page.go create mode 100644 internal/ui/pages/routing_page.go create mode 100644 internal/ui/pages/secrets_page.go create mode 100644 internal/ui/pages/stats_page.go create mode 100644 internal/ui/pages/suggestions_page.go create mode 100644 internal/ui/pages/tools_page.go create mode 100644 internal/ui/root/messages.go create mode 100644 internal/ui/root/model.go create mode 100644 internal/ui/root/search.go create mode 100644 internal/ui/root/sections.go create mode 100644 internal/ui/root/table.go create mode 100644 internal/ui/root/update.go create mode 100644 internal/ui/root/view.go delete mode 100644 internal/ui/theme.go create mode 100644 internal/ui/theme/color.go create mode 100644 internal/ui/theme/style.go create mode 100644 internal/ui/theme_alias.go diff --git a/.gitignore b/.gitignore index bc64f93..a5d246f 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ go.work.sum .works/ AGENTS.md CLAUDE.md +GEMINI.md .serena/ .claude/ # VitePress diff --git a/internal/ui/components/binding_details.go b/internal/ui/components/binding_details.go new file mode 100644 index 0000000..148fa3a --- /dev/null +++ b/internal/ui/components/binding_details.go @@ -0,0 +1,63 @@ +// Package components provides reusable UI components for the BobaMixer TUI. +package components + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// BindingDetails represents the selected binding metadata. +type BindingDetails struct { + ToolID string + ProviderID string + UseProxy bool + ModelOverride string +} + +// BindingDetailsPanel renders the binding metadata. +type BindingDetailsPanel struct { + details *BindingDetails + styles theme.Styles +} + +// NewBindingDetailsPanel constructs the panel. +func NewBindingDetailsPanel(details *BindingDetails, styles theme.Styles) BindingDetailsPanel { + return BindingDetailsPanel{ + details: details, + styles: styles, + } +} + +// Update satisfies the Bubble Tea component interface. +func (c BindingDetailsPanel) Update(_ tea.Msg) (BindingDetailsPanel, tea.Cmd) { + return c, nil +} + +// View renders the binding details if available. +func (c BindingDetailsPanel) View() string { + if c.details == nil { + return "" + } + + lines := []string{ + fmt.Sprintf("Tool ID: %s", c.details.ToolID), + fmt.Sprintf("Provider ID: %s", c.details.ProviderID), + fmt.Sprintf("Use Proxy: %t", c.details.UseProxy), + } + + if model := strings.TrimSpace(c.details.ModelOverride); model != "" { + lines = append(lines, fmt.Sprintf("Model Override: %s", model)) + } + + var b strings.Builder + for _, line := range lines { + normalStyle := c.styles.Normal + b.WriteString(normalStyle.PaddingLeft(2).Render(line)) + b.WriteString("\n") + } + + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/ui/components/binding_list.go b/internal/ui/components/binding_list.go new file mode 100644 index 0000000..b844240 --- /dev/null +++ b/internal/ui/components/binding_list.go @@ -0,0 +1,80 @@ +package components + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// BindingRow represents a binding entry in the list. +type BindingRow struct { + ToolName string + ProviderName string + UseProxy bool +} + +// BindingList renders tool-provider bindings with proxy status. +type BindingList struct { + rows []BindingRow + selected int + proxyEnabled string + proxyDisabled string + emptyState string + styles theme.Styles +} + +// NewBindingList constructs the bindings list component. +func NewBindingList(rows []BindingRow, selected int, emptyState string, proxyEnabled string, proxyDisabled string, styles theme.Styles) BindingList { + return BindingList{ + rows: rows, + selected: selected, + proxyEnabled: proxyEnabled, + proxyDisabled: proxyDisabled, + emptyState: strings.TrimSpace(emptyState), + styles: styles, + } +} + +// Update satisfies the Bubble Tea component interface. +func (c BindingList) Update(_ tea.Msg) (BindingList, tea.Cmd) { + return c, nil +} + +// View renders the bindings list or the empty state message. +func (c BindingList) View() string { + if len(c.rows) == 0 { + if c.emptyState == "" { + return "" + } + normalStyle := c.styles.Normal + return normalStyle.PaddingLeft(2).Render(c.emptyState) + } + + var b strings.Builder + selected := c.selected + if selected >= len(c.rows) { + selected = len(c.rows) - 1 + } + if selected < 0 { + selected = 0 + } + + for idx, row := range c.rows { + icon := c.proxyDisabled + if row.UseProxy { + icon = c.proxyEnabled + } + + line := fmt.Sprintf(" %-15s → %-25s Proxy: %s", row.ToolName, row.ProviderName, icon) + if idx == selected { + b.WriteString(c.styles.Selected.Render("▶ " + line)) + } else { + b.WriteString(c.styles.Normal.Render(" " + line)) + } + b.WriteString("\n") + } + + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/ui/components/bullet_list.go b/internal/ui/components/bullet_list.go new file mode 100644 index 0000000..c65a472 --- /dev/null +++ b/internal/ui/components/bullet_list.go @@ -0,0 +1,47 @@ +package components + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// BulletList renders bullet points using the muted text style. +type BulletList struct { + items []string + styles theme.Styles +} + +// NewBulletList constructs a bullet list component. +func NewBulletList(items []string, styles theme.Styles) BulletList { + return BulletList{ + items: items, + styles: styles, + } +} + +// Update satisfies the Bubble Tea component interface. +func (c BulletList) Update(_ tea.Msg) (BulletList, tea.Cmd) { + return c, nil +} + +// View renders each bullet with muted styling. +func (c BulletList) View() string { + if len(c.items) == 0 { + return "" + } + + var b strings.Builder + style := c.styles.Normal + style = style.PaddingLeft(2) + for _, item := range c.items { + text := strings.TrimSpace(item) + if text == "" { + continue + } + b.WriteString(style.Render("• " + text)) + b.WriteString("\n") + } + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/ui/components/config_file_list.go b/internal/ui/components/config_file_list.go new file mode 100644 index 0000000..935b1b5 --- /dev/null +++ b/internal/ui/components/config_file_list.go @@ -0,0 +1,78 @@ +package components + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// ConfigFile represents an editable configuration file entry. +type ConfigFile struct { + Name string + File string + Desc string +} + +// ConfigFileList renders selectable config files. +type ConfigFileList struct { + files []ConfigFile + selected int + home string + styles theme.Styles +} + +// NewConfigFileList constructs the list component. +func NewConfigFileList(files []ConfigFile, selected int, home string, styles theme.Styles) ConfigFileList { + return ConfigFileList{ + files: files, + selected: selected, + home: home, + styles: styles, + } +} + +// Update satisfies the Bubble Tea component interface. +func (c ConfigFileList) Update(_ tea.Msg) (ConfigFileList, tea.Cmd) { + return c, nil +} + +// View renders the config file list with descriptions for the selected item. +func (c ConfigFileList) View() string { + if len(c.files) == 0 { + normalStyle := c.styles.Normal + return normalStyle.PaddingLeft(2).Render("No configuration files detected.") + } + + var b strings.Builder + selected := c.selected + if selected >= len(c.files) { + selected = 0 + } + + for idx, cfg := range c.files { + fileLabel := fmt.Sprintf(" (%s)", cfg.File) + if idx == selected { + b.WriteString(c.styles.Selected.Render("▶ " + cfg.Name)) + b.WriteString(c.styles.Help.Render(fileLabel)) + } else { + b.WriteString(c.styles.Normal.Render(" " + cfg.Name)) + b.WriteString(c.styles.Help.Render(fileLabel)) + } + b.WriteString("\n") + + if idx == selected { + normalStyle := c.styles.Normal + descLine := normalStyle.PaddingLeft(4).Render(cfg.Desc) + helpStyle := c.styles.Help + pathLine := helpStyle.PaddingLeft(4).Render(fmt.Sprintf("Full path: %s/%s", strings.TrimSpace(c.home), cfg.File)) + b.WriteString(descLine) + b.WriteString("\n") + b.WriteString(pathLine) + b.WriteString("\n") + } + } + + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/ui/components/help_bar.go b/internal/ui/components/help_bar.go new file mode 100644 index 0000000..083685f --- /dev/null +++ b/internal/ui/components/help_bar.go @@ -0,0 +1,30 @@ +package components + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// HelpBar renders the navigation help line. +type HelpBar struct { + text string + styles theme.Styles +} + +// NewHelpBar constructs a HelpBar component. +func NewHelpBar(text string, styles theme.Styles) HelpBar { + return HelpBar{ + text: text, + styles: styles, + } +} + +// Update satisfies the Bubble Tea component contract (no mutations needed). +func (c HelpBar) Update(_ tea.Msg) (HelpBar, tea.Cmd) { + return c, nil +} + +// View renders the help text with the shared style. +func (c HelpBar) View() string { + return c.styles.Help.Render(c.text) +} diff --git a/internal/ui/components/help_footer.go b/internal/ui/components/help_footer.go new file mode 100644 index 0000000..ceb9989 --- /dev/null +++ b/internal/ui/components/help_footer.go @@ -0,0 +1,30 @@ +package components + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// HelpFooter renders the closing hint for the help overlay. +type HelpFooter struct { + message string + styles theme.Styles +} + +// NewHelpFooter constructs the footer component. +func NewHelpFooter(message string, styles theme.Styles) HelpFooter { + return HelpFooter{ + message: message, + styles: styles, + } +} + +// Update keeps the component immutable because the footer has no state. +func (c HelpFooter) Update(_ tea.Msg) (HelpFooter, tea.Cmd) { + return c, nil +} + +// View renders the footer hint message. +func (c HelpFooter) View() string { + return c.styles.Help.Render(c.message) +} diff --git a/internal/ui/components/help_header.go b/internal/ui/components/help_header.go new file mode 100644 index 0000000..aa4e7b0 --- /dev/null +++ b/internal/ui/components/help_header.go @@ -0,0 +1,36 @@ +package components + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// HelpHeader renders the main title for the help overlay. +type HelpHeader struct { + title string + subtext string + styles theme.Styles +} + +// NewHelpHeader builds a header component with the provided title and optional subtext. +func NewHelpHeader(title string, subtext string, styles theme.Styles) HelpHeader { + return HelpHeader{ + title: title, + subtext: subtext, + styles: styles, + } +} + +// Update satisfies the component contract but the header has no runtime state to change. +func (c HelpHeader) Update(_ tea.Msg) (HelpHeader, tea.Cmd) { + return c, nil +} + +// View renders the header and subtext using the shared theme styles. +func (c HelpHeader) View() string { + content := c.styles.Title.Render(c.title) + if c.subtext != "" { + content += "\n" + c.styles.Help.Render(c.subtext) + } + return content +} diff --git a/internal/ui/components/help_links.go b/internal/ui/components/help_links.go new file mode 100644 index 0000000..16a1487 --- /dev/null +++ b/internal/ui/components/help_links.go @@ -0,0 +1,63 @@ +package components + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// HelpLink represents a documentation reference. +type HelpLink struct { + Label string + URL string +} + +// HelpLinks renders a list of documentation references. +type HelpLinks struct { + links []HelpLink + styles theme.Styles +} + +// NewHelpLinks constructs the documentation links component. +func NewHelpLinks(links []HelpLink, styles theme.Styles) HelpLinks { + return HelpLinks{ + links: links, + styles: styles, + } +} + +// Update keeps the component immutable because documentation links are static. +func (c HelpLinks) Update(_ tea.Msg) (HelpLinks, tea.Cmd) { + return c, nil +} + +// View renders each documentation link on its own line. +func (c HelpLinks) View() string { + if len(c.links) == 0 { + return "" + } + var b strings.Builder + textStyle := c.styles.Normal + textStyle = textStyle.PaddingLeft(0) + + for _, link := range c.links { + label := strings.TrimSpace(link.Label) + url := strings.TrimSpace(link.URL) + if label == "" && url == "" { + continue + } + var line string + switch { + case label == "": + line = url + case url == "": + line = label + default: + line = label + ": " + url + } + b.WriteString(textStyle.Render(" " + line)) + b.WriteString("\n") + } + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/ui/components/help_section_list.go b/internal/ui/components/help_section_list.go new file mode 100644 index 0000000..51cc912 --- /dev/null +++ b/internal/ui/components/help_section_list.go @@ -0,0 +1,63 @@ +package components + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// HelpSection describes a high-level dashboard grouping. +type HelpSection struct { + Name string + Shortcut string + Views []string +} + +// HelpSectionList renders the list of sections and their view shortcuts. +type HelpSectionList struct { + sections []HelpSection + styles theme.Styles +} + +// NewHelpSectionList constructs a new HelpSectionList component. +func NewHelpSectionList(sections []HelpSection, styles theme.Styles) HelpSectionList { + return HelpSectionList{ + sections: sections, + styles: styles, + } +} + +// Update does not mutate state because section metadata is static. +func (c HelpSectionList) Update(_ tea.Msg) (HelpSectionList, tea.Cmd) { + return c, nil +} + +// View renders each section, its shortcut, and the associated views. +func (c HelpSectionList) View() string { + var b strings.Builder + shortcutStyle := c.styles.Selected + shortcutStyle = shortcutStyle.PaddingLeft(0).PaddingRight(1) + textStyle := c.styles.Normal + textStyle = textStyle.PaddingLeft(0) + + for _, section := range c.sections { + if section.Shortcut == "" { + continue + } + + b.WriteString(textStyle.Render(" ")) + b.WriteString(shortcutStyle.Render(fmt.Sprintf("[%s]", section.Shortcut))) + joinedViews := strings.Join(section.Views, ", ") + b.WriteString(textStyle.Render(fmt.Sprintf(" %s → %s", section.Name, joinedViews))) + b.WriteString("\n") + } + + // Append help overlay toggle instruction. + b.WriteString(textStyle.Render(" ")) + b.WriteString(shortcutStyle.Render("[?]")) + b.WriteString(textStyle.Render(" Toggle this help overlay")) + + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/ui/components/help_shortcut_list.go b/internal/ui/components/help_shortcut_list.go new file mode 100644 index 0000000..6cd302f --- /dev/null +++ b/internal/ui/components/help_shortcut_list.go @@ -0,0 +1,55 @@ +package components + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// Shortcut describes a global keybinding. +type Shortcut struct { + Key string + Description string +} + +// ShortcutList renders a list of shortcuts. +type ShortcutList struct { + shortcuts []Shortcut + styles theme.Styles +} + +// NewShortcutList constructs a shortcut list component. +func NewShortcutList(shortcuts []Shortcut, styles theme.Styles) ShortcutList { + return ShortcutList{ + shortcuts: shortcuts, + styles: styles, + } +} + +// Update keeps the component immutable because shortcut hints are static. +func (c ShortcutList) Update(_ tea.Msg) (ShortcutList, tea.Cmd) { + return c, nil +} + +// View renders the shortcut table-like content. +func (c ShortcutList) View() string { + var b strings.Builder + shortcutStyle := c.styles.Selected + shortcutStyle = shortcutStyle.PaddingLeft(0).PaddingRight(1) + textStyle := c.styles.Normal + textStyle = textStyle.PaddingLeft(0) + + for _, sc := range c.shortcuts { + if strings.TrimSpace(sc.Key) == "" { + continue + } + b.WriteString(textStyle.Render(" ")) + b.WriteString(shortcutStyle.Render(fmt.Sprintf("[%s]", sc.Key))) + b.WriteString(textStyle.Render(" " + sc.Description)) + b.WriteString("\n") + } + + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/ui/components/help_tips.go b/internal/ui/components/help_tips.go new file mode 100644 index 0000000..040b373 --- /dev/null +++ b/internal/ui/components/help_tips.go @@ -0,0 +1,43 @@ +package components + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// HelpTips renders a bullet list of quick tips for the user. +type HelpTips struct { + tips []string + styles theme.Styles +} + +// NewHelpTips constructs the tips component. +func NewHelpTips(tips []string, styles theme.Styles) HelpTips { + return HelpTips{ + tips: tips, + styles: styles, + } +} + +// Update keeps the component immutable because tips are static. +func (c HelpTips) Update(_ tea.Msg) (HelpTips, tea.Cmd) { + return c, nil +} + +// View renders each tip with the shared normal style. +func (c HelpTips) View() string { + if len(c.tips) == 0 { + return "" + } + + var b strings.Builder + textStyle := c.styles.Normal + textStyle = textStyle.PaddingLeft(0) + for _, tip := range c.tips { + b.WriteString(textStyle.Render(" • " + strings.TrimSpace(tip))) + b.WriteString("\n") + } + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/ui/components/hook_list.go b/internal/ui/components/hook_list.go new file mode 100644 index 0000000..8fa5e90 --- /dev/null +++ b/internal/ui/components/hook_list.go @@ -0,0 +1,65 @@ +package components + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// HookInfo represents metadata about a git hook. +type HookInfo struct { + Name string + Desc string + Active bool +} + +// HookList renders hook entries with active/inactive indicators. +type HookList struct { + hooks []HookInfo + activeIcon string + inactiveIcon string + styles theme.Styles +} + +// NewHookList constructs the hook list component. +func NewHookList(hooks []HookInfo, activeIcon string, inactiveIcon string, styles theme.Styles) HookList { + return HookList{ + hooks: hooks, + activeIcon: activeIcon, + inactiveIcon: inactiveIcon, + styles: styles, + } +} + +// Update satisfies the Bubble Tea component interface. +func (c HookList) Update(_ tea.Msg) (HookList, tea.Cmd) { + return c, nil +} + +// View renders the hook list. +func (c HookList) View() string { + if len(c.hooks) == 0 { + normalStyle := c.styles.Normal + return normalStyle.PaddingLeft(2).Render("No hooks available.") + } + + var b strings.Builder + for _, hook := range c.hooks { + statusStyle := c.styles.BudgetDanger + icon := c.inactiveIcon + if hook.Active { + statusStyle = c.styles.BudgetOK + icon = c.activeIcon + } + + normalStyle := c.styles.Normal + b.WriteString(normalStyle.PaddingLeft(2).Render(hook.Name)) + b.WriteString(statusStyle.Render(" " + icon)) + b.WriteString("\n") + b.WriteString(normalStyle.PaddingLeft(6).Render("→ " + hook.Desc)) + b.WriteString("\n") + } + + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/ui/components/info_message.go b/internal/ui/components/info_message.go new file mode 100644 index 0000000..4c9f9df --- /dev/null +++ b/internal/ui/components/info_message.go @@ -0,0 +1,36 @@ +package components + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// InfoMessage renders muted informational text (e.g., form hints). +type InfoMessage struct { + text string + styles theme.Styles +} + +// NewInfoMessage constructs a muted info message component. +func NewInfoMessage(text string, styles theme.Styles) InfoMessage { + return InfoMessage{ + text: strings.TrimSpace(text), + styles: styles, + } +} + +// Update satisfies the Bubble Tea component interface. +func (c InfoMessage) Update(_ tea.Msg) (InfoMessage, tea.Cmd) { + return c, nil +} + +// View renders the info text if provided. +func (c InfoMessage) View() string { + if c.text == "" { + return "" + } + normalStyle := c.styles.Normal + return normalStyle.PaddingLeft(2).Render(c.text) +} diff --git a/internal/ui/components/paragraph.go b/internal/ui/components/paragraph.go new file mode 100644 index 0000000..c7d33c9 --- /dev/null +++ b/internal/ui/components/paragraph.go @@ -0,0 +1,43 @@ +package components + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// Paragraph renders a block of text with the normal style. +type Paragraph struct { + text string + styles theme.Styles +} + +// NewParagraph constructs a paragraph component. +func NewParagraph(text string, styles theme.Styles) Paragraph { + return Paragraph{ + text: strings.TrimSpace(text), + styles: styles, + } +} + +// Update satisfies the Bubble Tea component interface. +func (c Paragraph) Update(_ tea.Msg) (Paragraph, tea.Cmd) { + return c, nil +} + +// View renders the paragraph content if non-empty. +func (c Paragraph) View() string { + if c.text == "" { + return "" + } + lines := strings.Split(c.text, "\n") + var b strings.Builder + style := c.styles.Normal + style = style.PaddingLeft(2) + for _, line := range lines { + b.WriteString(style.Render(line)) + b.WriteString("\n") + } + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/ui/components/provider_details.go b/internal/ui/components/provider_details.go new file mode 100644 index 0000000..5f78bac --- /dev/null +++ b/internal/ui/components/provider_details.go @@ -0,0 +1,63 @@ +package components + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// ProviderDetails represents metadata for the selected provider row. +type ProviderDetails struct { + ID string + Kind string + APIKeySource string + EnvVar string + ShowEnvVar bool +} + +// ProviderDetailsPanel renders a detail block for the selected provider. +type ProviderDetailsPanel struct { + details *ProviderDetails + styles theme.Styles +} + +// NewProviderDetailsPanel constructs the panel with the given details. +func NewProviderDetailsPanel(details *ProviderDetails, styles theme.Styles) ProviderDetailsPanel { + return ProviderDetailsPanel{ + details: details, + styles: styles, + } +} + +// Update satisfies the Bubble Tea component interface. +func (c ProviderDetailsPanel) Update(_ tea.Msg) (ProviderDetailsPanel, tea.Cmd) { + return c, nil +} + +// View renders the details block if data is available. +func (c ProviderDetailsPanel) View() string { + if c.details == nil { + return "" + } + + lines := []string{ + fmt.Sprintf("ID: %s", c.details.ID), + fmt.Sprintf("Kind: %s", c.details.Kind), + fmt.Sprintf("API Key Source: %s", c.details.APIKeySource), + } + + if c.details.ShowEnvVar && strings.TrimSpace(c.details.EnvVar) != "" { + lines = append(lines, fmt.Sprintf("Env Var: %s", c.details.EnvVar)) + } + + var b strings.Builder + normalStyle := c.styles.Normal + for _, line := range lines { + b.WriteString(normalStyle.PaddingLeft(2).Render(line)) + b.WriteString("\n") + } + + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/ui/components/provider_list.go b/internal/ui/components/provider_list.go new file mode 100644 index 0000000..cd18fef --- /dev/null +++ b/internal/ui/components/provider_list.go @@ -0,0 +1,103 @@ +package components + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// ProviderRow represents a compact provider entry for the list component. +type ProviderRow struct { + DisplayName string + BaseURL string + DefaultModel string + Enabled bool + HasAPIKey bool +} + +// ProviderList renders provider rows with selection state and health/status icons. +type ProviderList struct { + rows []ProviderRow + selectedIndex int + enabledIcon string + disabledIcon string + keyPresentIcon string + keyMissingIcon string + emptyState string + styles theme.Styles +} + +// NewProviderList creates a ProviderList component. +func NewProviderList(rows []ProviderRow, selected int, emptyState string, icons ProviderListIcons, styles theme.Styles) ProviderList { + return ProviderList{ + rows: rows, + selectedIndex: selected, + enabledIcon: icons.Enabled, + disabledIcon: icons.Disabled, + keyPresentIcon: icons.KeyPresent, + keyMissingIcon: icons.KeyMissing, + emptyState: strings.TrimSpace(emptyState), + styles: styles, + } +} + +// ProviderListIcons groups the glyphs used in the provider list. +type ProviderListIcons struct { + Enabled string + Disabled string + KeyPresent string + KeyMissing string +} + +// Update satisfies the Bubble Tea component interface. +func (c ProviderList) Update(_ tea.Msg) (ProviderList, tea.Cmd) { + return c, nil +} + +// View renders either the empty state or the list of providers. +func (c ProviderList) View() string { + if len(c.rows) == 0 { + if c.emptyState == "" { + return "" + } + normalStyle := c.styles.Normal + return normalStyle.PaddingLeft(2).Render(c.emptyState) + } + + var b strings.Builder + selected := c.selectedIndex + if selected >= len(c.rows) { + selected = len(c.rows) - 1 + } + + for idx, row := range c.rows { + enabledIcon := c.enabledIcon + if !row.Enabled { + enabledIcon = c.disabledIcon + } + + keyIcon := c.keyMissingIcon + if row.HasAPIKey { + keyIcon = c.keyPresentIcon + } + + line := fmt.Sprintf(" %s %s %-25s %-35s %s", + enabledIcon, + keyIcon, + row.DisplayName, + row.BaseURL, + row.DefaultModel, + ) + + if idx == selected { + b.WriteString(c.styles.Selected.Render("▶ " + line)) + } else { + b.WriteString(c.styles.Normal.Render(" " + line)) + } + b.WriteString("\n") + } + + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/ui/components/proxy_status.go b/internal/ui/components/proxy_status.go new file mode 100644 index 0000000..b1623eb --- /dev/null +++ b/internal/ui/components/proxy_status.go @@ -0,0 +1,36 @@ +package components + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// ProxyStatus renders the proxy indicator line. +type ProxyStatus struct { + icon string + status string + styles theme.Styles +} + +// NewProxyStatus constructs a ProxyStatus component. +func NewProxyStatus(icon string, status string, styles theme.Styles) ProxyStatus { + return ProxyStatus{ + icon: icon, + status: status, + styles: styles, + } +} + +// Update satisfies the Bubble Tea component contract (no mutations needed). +func (c ProxyStatus) Update(_ tea.Msg) (ProxyStatus, tea.Cmd) { + return c, nil +} + +// View renders the proxy indicator using the shared typography. +func (c ProxyStatus) View() string { + text := fmt.Sprintf("Proxy: %s %s", c.icon, c.status) + normalStyle := c.styles.Normal + return normalStyle.PaddingLeft(0).Render(text) +} diff --git a/internal/ui/components/proxy_status_panel.go b/internal/ui/components/proxy_status_panel.go new file mode 100644 index 0000000..cd397ec --- /dev/null +++ b/internal/ui/components/proxy_status_panel.go @@ -0,0 +1,60 @@ +package components + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// ProxyStatusPanel renders proxy server status details. +type ProxyStatusPanel struct { + state string + statusText string + statusIcon string + address string + styles theme.Styles +} + +// NewProxyStatusPanel constructs the panel with the provided state metadata. +func NewProxyStatusPanel(state string, statusText string, statusIcon string, address string, styles theme.Styles) ProxyStatusPanel { + return ProxyStatusPanel{ + state: state, + statusText: statusText, + statusIcon: statusIcon, + address: address, + styles: styles, + } +} + +// Update satisfies the Bubble Tea component interface. +func (c ProxyStatusPanel) Update(_ tea.Msg) (ProxyStatusPanel, tea.Cmd) { + return c, nil +} + +// View renders the status and address lines. +func (c ProxyStatusPanel) View() string { + var statusStyle string + switch strings.ToLower(c.state) { + case "running": + statusStyle = c.styles.BudgetOK.Render(c.statusIcon + " " + c.statusText) + case "stopped": + statusStyle = c.styles.BudgetDanger.Render(c.statusIcon + " " + c.statusText) + default: + statusStyle = c.styles.Normal.Render(c.statusIcon + " " + c.statusText) + } + + lines := []string{ + fmt.Sprintf("Status: %s", statusStyle), + fmt.Sprintf("Address: %s", c.address), + } + + var b strings.Builder + normalStyle := c.styles.Normal + for _, line := range lines { + b.WriteString(normalStyle.PaddingLeft(2).Render(line)) + b.WriteString("\n") + } + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/ui/components/report_options_list.go b/internal/ui/components/report_options_list.go new file mode 100644 index 0000000..5567716 --- /dev/null +++ b/internal/ui/components/report_options_list.go @@ -0,0 +1,69 @@ +package components + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// ReportOption represents a report configuration entry. +type ReportOption struct { + Label string + Desc string +} + +// ReportOptionsList renders selectable report options. +type ReportOptionsList struct { + options []ReportOption + selected int + styles theme.Styles +} + +// NewReportOptionsList constructs the component. +func NewReportOptionsList(options []ReportOption, selected int, styles theme.Styles) ReportOptionsList { + return ReportOptionsList{ + options: options, + selected: selected, + styles: styles, + } +} + +// Update satisfies the Bubble Tea component interface. +func (c ReportOptionsList) Update(_ tea.Msg) (ReportOptionsList, tea.Cmd) { + return c, nil +} + +// View renders the selectable options with descriptions. +func (c ReportOptionsList) View() string { + if len(c.options) == 0 { + normalStyle := c.styles.Normal + return normalStyle.PaddingLeft(2).Render("No report templates configured.") + } + + var b strings.Builder + selected := c.selected + if selected >= len(c.options) { + selected = 0 + } + + for idx, option := range c.options { + line := fmt.Sprintf(" %s", option.Label) + if idx == selected { + b.WriteString(c.styles.Selected.Render("▶ " + line)) + } else { + b.WriteString(c.styles.Normal.Render(" " + line)) + } + b.WriteString("\n") + + if idx == selected && strings.TrimSpace(option.Desc) != "" { + normalStyle := c.styles.Normal + desc := normalStyle.PaddingLeft(6).Render("→ " + option.Desc) + b.WriteString(desc) + b.WriteString("\n") + } + } + + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/ui/components/secret_provider_list.go b/internal/ui/components/secret_provider_list.go new file mode 100644 index 0000000..9688664 --- /dev/null +++ b/internal/ui/components/secret_provider_list.go @@ -0,0 +1,87 @@ +package components + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// SecretProviderRow represents provider secret status for the secrets page. +type SecretProviderRow struct { + DisplayName string + HasKey bool + KeySource string +} + +// SecretProviderList renders rows with configured/missing key states. +type SecretProviderList struct { + rows []SecretProviderRow + selected int + successIcon string + failureIcon string + emptyState string + styles theme.Styles +} + +// NewSecretProviderList constructs the list component. +func NewSecretProviderList(rows []SecretProviderRow, selected int, emptyState string, successIcon string, failureIcon string, styles theme.Styles) SecretProviderList { + return SecretProviderList{ + rows: rows, + selected: selected, + successIcon: successIcon, + failureIcon: failureIcon, + emptyState: strings.TrimSpace(emptyState), + styles: styles, + } +} + +// Update satisfies the Bubble Tea component interface. +func (c SecretProviderList) Update(_ tea.Msg) (SecretProviderList, tea.Cmd) { + return c, nil +} + +// View renders the providers or the empty state message. +func (c SecretProviderList) View() string { + if len(c.rows) == 0 { + if c.emptyState == "" { + return "" + } + normalStyle := c.styles.Normal + return normalStyle.PaddingLeft(2).Render(c.emptyState) + } + + var b strings.Builder + selected := c.selected + if selected >= len(c.rows) { + selected = len(c.rows) - 1 + } + if selected < 0 { + selected = 0 + } + + for idx, row := range c.rows { + statusText := "Missing" + statusIcon := c.failureIcon + statusStyle := c.styles.BudgetDanger + if row.HasKey { + statusText = "Configured" + statusIcon = c.successIcon + statusStyle = c.styles.BudgetOK + } + + namePart := fmt.Sprintf(" %-25s ", row.DisplayName) + statusPart := fmt.Sprintf("%s %-15s [%s]", statusIcon, statusText, row.KeySource) + + if idx == selected { + b.WriteString(c.styles.Selected.Render("▶ " + namePart + statusPart)) + } else { + b.WriteString(c.styles.Normal.Render(namePart)) + b.WriteString(statusStyle.Render(statusPart)) + } + b.WriteString("\n") + } + + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/ui/components/stats_profiles.go b/internal/ui/components/stats_profiles.go new file mode 100644 index 0000000..f4eb623 --- /dev/null +++ b/internal/ui/components/stats_profiles.go @@ -0,0 +1,66 @@ +package components + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// StatsProfile represents per-profile usage metrics. +type StatsProfile struct { + Name string + Tokens int + Cost float64 + Sessions int + AvgLatency float64 + UsagePct float64 + CostPct float64 +} + +// StatsProfilesList renders the list of profile stats. +type StatsProfilesList struct { + profiles []StatsProfile + styles theme.Styles +} + +// NewStatsProfilesList constructs the list component. +func NewStatsProfilesList(profiles []StatsProfile, styles theme.Styles) StatsProfilesList { + return StatsProfilesList{ + profiles: profiles, + styles: styles, + } +} + +// Update satisfies the Bubble Tea component interface. +func (c StatsProfilesList) Update(_ tea.Msg) (StatsProfilesList, tea.Cmd) { + return c, nil +} + +// View renders the formatted profile rows. +func (c StatsProfilesList) View() string { + if len(c.profiles) == 0 { + return "" + } + + var b strings.Builder + style := c.styles.Normal + style = style.PaddingLeft(2) + + for _, ps := range c.profiles { + line := fmt.Sprintf("• %s: tokens=%d cost=$%.4f sessions=%d latency=%.0fms usage=%.1f%% cost=%.1f%%", + ps.Name, + ps.Tokens, + ps.Cost, + ps.Sessions, + ps.AvgLatency, + ps.UsagePct, + ps.CostPct, + ) + b.WriteString(style.Render(line)) + b.WriteString("\n") + } + + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/ui/components/stats_summary.go b/internal/ui/components/stats_summary.go new file mode 100644 index 0000000..3817584 --- /dev/null +++ b/internal/ui/components/stats_summary.go @@ -0,0 +1,65 @@ +package components + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// StatsSummary represents aggregate statistics for a time range. +type StatsSummary struct { + Title string + Tokens int + Cost float64 + Sessions int + AvgDailyTokens float64 + AvgDailyCost float64 + ShowAverages bool +} + +// StatsSummaryPanel renders usage metrics in a simple block. +type StatsSummaryPanel struct { + summary StatsSummary + styles theme.Styles +} + +// NewStatsSummaryPanel builds a panel for the provided summary data. +func NewStatsSummaryPanel(summary StatsSummary, styles theme.Styles) StatsSummaryPanel { + return StatsSummaryPanel{ + summary: summary, + styles: styles, + } +} + +// Update satisfies the Bubble Tea component interface. +func (c StatsSummaryPanel) Update(_ tea.Msg) (StatsSummaryPanel, tea.Cmd) { + return c, nil +} + +// View renders the formatted metrics list. +func (c StatsSummaryPanel) View() string { + lines := []string{ + fmt.Sprintf("Tokens: %d", c.summary.Tokens), + fmt.Sprintf("Cost: $%.4f", c.summary.Cost), + fmt.Sprintf("Sessions: %d", c.summary.Sessions), + } + + if c.summary.ShowAverages { + lines = append(lines, + fmt.Sprintf("Avg Daily Tokens: %.0f", c.summary.AvgDailyTokens), + fmt.Sprintf("Avg Daily Cost: $%.4f", c.summary.AvgDailyCost), + ) + } + + var b strings.Builder + style := c.styles.Normal + style = style.PaddingLeft(2) + for _, line := range lines { + b.WriteString(style.Render(line)) + b.WriteString("\n") + } + + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/ui/components/status_message.go b/internal/ui/components/status_message.go new file mode 100644 index 0000000..6333e87 --- /dev/null +++ b/internal/ui/components/status_message.go @@ -0,0 +1,36 @@ +package components + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// StatusMessage renders a highlighted status line. +type StatusMessage struct { + message string + color lipgloss.AdaptiveColor +} + +// NewStatusMessage constructs a StatusMessage component. +func NewStatusMessage(message string, color lipgloss.AdaptiveColor) StatusMessage { + return StatusMessage{ + message: strings.TrimSpace(message), + color: color, + } +} + +// Update satisfies the Bubble Tea component contract (no mutations needed). +func (c StatusMessage) Update(_ tea.Msg) (StatusMessage, tea.Cmd) { + return c, nil +} + +// View renders the status message in the configured color. +func (c StatusMessage) View() string { + if c.message == "" { + return "" + } + return theme.Colorize(c.color, c.message) +} diff --git a/internal/ui/components/suggestion_details.go b/internal/ui/components/suggestion_details.go new file mode 100644 index 0000000..3f42a7f --- /dev/null +++ b/internal/ui/components/suggestion_details.go @@ -0,0 +1,58 @@ +package components + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// SuggestionDetails renders the details for the currently selected suggestion. +type SuggestionDetails struct { + suggestion *Suggestion + styles theme.Styles +} + +// NewSuggestionDetails constructs the details component. +func NewSuggestionDetails(suggestion *Suggestion, styles theme.Styles) SuggestionDetails { + return SuggestionDetails{ + suggestion: suggestion, + styles: styles, + } +} + +// Update satisfies the Bubble Tea component interface. +func (c SuggestionDetails) Update(_ tea.Msg) (SuggestionDetails, tea.Cmd) { + return c, nil +} + +// View renders description, impact, and action items. +func (c SuggestionDetails) View() string { + if c.suggestion == nil { + return "" + } + + var b strings.Builder + normal := c.styles.Normal + normal = normal.PaddingLeft(2) + + if desc := strings.TrimSpace(c.suggestion.Description); desc != "" { + b.WriteString(normal.Render(desc)) + b.WriteString("\n") + } + + if impact := strings.TrimSpace(c.suggestion.Impact); impact != "" { + b.WriteString(normal.Render(fmt.Sprintf("Impact: %s", impact))) + b.WriteString("\n") + } + + if len(c.suggestion.ActionItems) > 0 { + for idx, action := range c.suggestion.ActionItems { + b.WriteString(normal.Render(fmt.Sprintf("%d. %s", idx+1, action))) + b.WriteString("\n") + } + } + + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/ui/components/suggestion_list.go b/internal/ui/components/suggestion_list.go new file mode 100644 index 0000000..fbf898f --- /dev/null +++ b/internal/ui/components/suggestion_list.go @@ -0,0 +1,99 @@ +package components + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// Suggestion represents a recommendation entry. +type Suggestion struct { + Title string + Description string + Impact string + ActionItems []string + Priority int + Type string +} + +// SuggestionList renders the suggestion overview entries. +type SuggestionList struct { + items []Suggestion + selected int + styles theme.Styles +} + +// NewSuggestionList constructs the list component. +func NewSuggestionList(items []Suggestion, selected int, styles theme.Styles) SuggestionList { + return SuggestionList{ + items: items, + selected: selected, + styles: styles, + } +} + +// Update satisfies the Bubble Tea component interface. +func (c SuggestionList) Update(_ tea.Msg) (SuggestionList, tea.Cmd) { + return c, nil +} + +// View renders the list of suggestions with priority highlighting. +func (c SuggestionList) View() string { + if len(c.items) == 0 { + normalStyle := c.styles.Normal + return normalStyle.PaddingLeft(2).Render("✓ No suggestions - your usage is optimized!") + } + + var b strings.Builder + selected := c.selected + if selected >= len(c.items) { + selected = 0 + } + + for idx, sugg := range c.items { + style, icon := c.priorityStyle(sugg.Priority) + typeIcon := suggestionTypeIcon(sugg.Type) + line := fmt.Sprintf(" %s %s [P%d] %s", icon, typeIcon, sugg.Priority, sugg.Title) + if idx == selected { + b.WriteString(c.styles.Selected.Render("▶ " + line)) + } else { + b.WriteString(style.Render(line)) + } + b.WriteString("\n") + } + + return strings.TrimRight(b.String(), "\n") +} + +func (c SuggestionList) priorityStyle(priority int) (lipgloss.Style, string) { + switch priority { + case 5: + return c.styles.BudgetDanger, "🔴" + case 4: + return c.styles.BudgetWarn, "🟠" + case 3: + return c.styles.Normal, "🟡" + default: + return c.styles.Normal, "🟢" + } +} + +func suggestionTypeIcon(t string) string { + switch t { + case "cost": + return "💰" + case "profile": + return "🔄" + case "budget": + return "📊" + case "anomaly": + return "⚠️" + case "usage": + return "📈" + default: + return "📈" + } +} diff --git a/internal/ui/components/title_bar.go b/internal/ui/components/title_bar.go new file mode 100644 index 0000000..4fe624f --- /dev/null +++ b/internal/ui/components/title_bar.go @@ -0,0 +1,30 @@ +package components + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// TitleBar renders the primary page title. +type TitleBar struct { + text string + styles theme.Styles +} + +// NewTitleBar constructs a TitleBar component. +func NewTitleBar(text string, styles theme.Styles) TitleBar { + return TitleBar{ + text: text, + styles: styles, + } +} + +// Update satisfies the Bubble Tea component contract (no state changes needed). +func (c TitleBar) Update(_ tea.Msg) (TitleBar, tea.Cmd) { + return c, nil +} + +// View renders the title using the shared style definitions. +func (c TitleBar) View() string { + return c.styles.Title.Render(c.text) +} diff --git a/internal/ui/components/tool_details.go b/internal/ui/components/tool_details.go new file mode 100644 index 0000000..4f28346 --- /dev/null +++ b/internal/ui/components/tool_details.go @@ -0,0 +1,62 @@ +package components + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// ToolDetails represents metadata of the selected CLI tool. +type ToolDetails struct { + ID string + ConfigType string + ConfigPath string + Description string +} + +// ToolDetailsPanel renders the details block for a tool. +type ToolDetailsPanel struct { + details *ToolDetails + styles theme.Styles +} + +// NewToolDetailsPanel constructs the panel. +func NewToolDetailsPanel(details *ToolDetails, styles theme.Styles) ToolDetailsPanel { + return ToolDetailsPanel{ + details: details, + styles: styles, + } +} + +// Update satisfies the Bubble Tea component interface. +func (c ToolDetailsPanel) Update(_ tea.Msg) (ToolDetailsPanel, tea.Cmd) { + return c, nil +} + +// View renders the tool details if available. +func (c ToolDetailsPanel) View() string { + if c.details == nil { + return "" + } + + lines := []string{ + fmt.Sprintf("ID: %s", c.details.ID), + fmt.Sprintf("Config Type: %s", c.details.ConfigType), + fmt.Sprintf("Config Path: %s", c.details.ConfigPath), + } + + if desc := strings.TrimSpace(c.details.Description); desc != "" { + lines = append(lines, fmt.Sprintf("Description: %s", desc)) + } + + var b strings.Builder + normalStyle := c.styles.Normal + for _, line := range lines { + b.WriteString(normalStyle.PaddingLeft(2).Render(line)) + b.WriteString("\n") + } + + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/ui/components/tool_list.go b/internal/ui/components/tool_list.go new file mode 100644 index 0000000..e3487c9 --- /dev/null +++ b/internal/ui/components/tool_list.go @@ -0,0 +1,84 @@ +package components + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// ToolRow represents a CLI tool entry in the list. +type ToolRow struct { + Name string + Exec string + Kind string + Bound bool +} + +// ToolList renders tool rows along with their bound status. +type ToolList struct { + rows []ToolRow + selected int + boundIcon string + unboundIcon string + emptyState string + styles theme.Styles +} + +// NewToolList constructs the tool list component. +func NewToolList(rows []ToolRow, selected int, emptyState string, boundIcon string, unboundIcon string, styles theme.Styles) ToolList { + return ToolList{ + rows: rows, + selected: selected, + boundIcon: boundIcon, + unboundIcon: unboundIcon, + emptyState: strings.TrimSpace(emptyState), + styles: styles, + } +} + +// Update satisfies the Bubble Tea component interface. +func (c ToolList) Update(_ tea.Msg) (ToolList, tea.Cmd) { + return c, nil +} + +// View renders the tools list or the empty state message. +func (c ToolList) View() string { + if len(c.rows) == 0 { + if c.emptyState == "" { + return "" + } + normalStyle := c.styles.Normal + return normalStyle.PaddingLeft(2).Render(c.emptyState) + } + + var b strings.Builder + selected := c.selected + if selected >= len(c.rows) { + selected = len(c.rows) - 1 + } + + for idx, row := range c.rows { + icon := c.unboundIcon + if row.Bound { + icon = c.boundIcon + } + + line := fmt.Sprintf(" %s %-15s %-30s %s", + icon, + row.Name, + row.Exec, + row.Kind, + ) + + if idx == selected { + b.WriteString(c.styles.Selected.Render("▶ " + line)) + } else { + b.WriteString(c.styles.Normal.Render(" " + line)) + } + b.WriteString("\n") + } + + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/ui/dashboard.go b/internal/ui/dashboard.go deleted file mode 100644 index f740609..0000000 --- a/internal/ui/dashboard.go +++ /dev/null @@ -1,2466 +0,0 @@ -package ui - -import ( - "context" - "fmt" - "net/http" - "path/filepath" - "strings" - "time" - - "github.com/charmbracelet/bubbles/table" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/royisme/bobamixer/internal/domain/core" - "github.com/royisme/bobamixer/internal/domain/stats" - "github.com/royisme/bobamixer/internal/domain/suggestions" - "github.com/royisme/bobamixer/internal/proxy" - "github.com/royisme/bobamixer/internal/store/sqlite" - dashboardviews "github.com/royisme/bobamixer/internal/ui/dashboard/views" -) - -// viewMode represents the current view in the dashboard -type viewMode int - -const ( - viewDashboard viewMode = iota - viewProviders - viewTools - viewBindings - viewSecrets - viewStats - viewProxy - viewRouting - viewSuggestions - viewReports - viewHooks - viewConfig - viewHelp -) - -// UI constants for repeated strings -const ( - proxyStatusRunning = "running" - proxyStatusStopped = "stopped" - proxyStatusChecking = "checking" - proxyStateOn = "ON" - proxyStateOff = "OFF" - iconCircleFilled = "●" - iconCircleEmpty = "○" - iconCheckmark = "✓" - iconCross = "✗" - helpTextNavigation = "[1-5] Switch Section [Tab] Next Section [[ / ]] Cycle Views [/] Search [?] Help [Q] Quit" - msgNoProviderSelected = "No provider selected" - msgInvalidProvider = "Invalid provider selection" - promptPrefix = "│ " -) - -type providerFormField int - -const ( - providerFieldID providerFormField = iota - providerFieldKind - providerFieldDisplayName - providerFieldBaseURL - providerFieldDefaultModel - providerFieldAPIKeySource - providerFieldAPIKeyEnv -) - -var providerFieldSequence = []providerFormField{ - providerFieldID, - providerFieldKind, - providerFieldDisplayName, - providerFieldBaseURL, - providerFieldDefaultModel, - providerFieldAPIKeySource, - providerFieldAPIKeyEnv, -} - -type bindingFormField int - -const ( - bindingFieldToolID bindingFormField = iota - bindingFieldProviderID - bindingFieldModel - bindingFieldUseProxy -) - -var bindingFieldSequence = []bindingFormField{ - bindingFieldToolID, - bindingFieldProviderID, - bindingFieldModel, - bindingFieldUseProxy, -} - -type viewSection struct { - name string - shortcut string - views []viewMode -} - -func (m *DashboardModel) initSections() { - m.sections = []viewSection{ - { - name: "Dashboard", - shortcut: "1", - views: []viewMode{viewDashboard}, - }, - { - name: "Control Plane", - shortcut: "2", - views: []viewMode{viewProviders, viewTools, viewBindings, viewSecrets, viewProxy}, - }, - { - name: "Usage", - shortcut: "3", - views: []viewMode{viewStats, viewReports}, - }, - { - name: "Optimization", - shortcut: "4", - views: []viewMode{viewSuggestions}, - }, - { - name: "DevOps", - shortcut: "5", - views: []viewMode{viewRouting, viewHooks, viewConfig}, - }, - } - m.currentSection = 0 - m.sectionViewIndex = 0 - m.updateViewFromSection() -} - -func (m *DashboardModel) updateViewFromSection() { - if len(m.sections) == 0 { - m.currentView = viewDashboard - return - } - - if m.currentSection < 0 { - m.currentSection = 0 - } - if m.currentSection >= len(m.sections) { - m.currentSection = 0 - } - - section := m.sections[m.currentSection] - if len(section.views) == 0 { - m.currentView = viewDashboard - return - } - - if m.sectionViewIndex < 0 { - m.sectionViewIndex = 0 - } - if m.sectionViewIndex >= len(section.views) { - m.sectionViewIndex = 0 - } - - nextView := section.views[m.sectionViewIndex] - if m.currentView != nextView { - m.currentView = nextView - m.selectedIndex = 0 - } - if m.searchContextView != m.currentView { - m.searchActive = false - m.searchQuery = "" - m.searchContextView = m.currentView - } -} - -func (m *DashboardModel) moveToSection(idx int) tea.Cmd { - if idx < 0 || idx >= len(m.sections) { - return nil - } - m.currentSection = idx - m.sectionViewIndex = 0 - m.updateViewFromSection() - return m.sectionEnterCmd() -} - -func (m *DashboardModel) cycleSection(delta int) tea.Cmd { - m.currentSection = (m.currentSection + delta + len(m.sections)) % len(m.sections) - m.sectionViewIndex = 0 - m.updateViewFromSection() - return m.sectionEnterCmd() -} - -func (m *DashboardModel) cycleSubview(delta int) tea.Cmd { - section := m.sections[m.currentSection] - if len(section.views) == 0 { - return nil - } - m.sectionViewIndex = (m.sectionViewIndex + delta + len(section.views)) % len(section.views) - m.updateViewFromSection() - return m.sectionEnterCmd() -} - -func (m *DashboardModel) sectionEnterCmd() tea.Cmd { - switch m.currentView { - case viewStats: - return m.loadStatsData - case viewProxy: - return checkProxyStatus - case viewSuggestions: - return m.loadSuggestions - default: - return nil - } -} - -func (m *DashboardModel) supportsSearch(view viewMode) bool { - switch view { - case viewProviders, viewTools, viewBindings, viewSecrets: - return true - default: - return false - } -} - -func (m *DashboardModel) activateSearch() { - m.searchActive = true - m.searchInput.SetValue(m.searchQuery) - m.searchInput.CursorEnd() - m.searchContextView = m.currentView -} - -func (m *DashboardModel) clearSearch() { - m.searchActive = false - m.searchQuery = "" -} - -func (m *DashboardModel) startSecretInput() { - indexes := m.filteredProviderIndexes() - if len(indexes) == 0 || m.selectedIndex < 0 || m.selectedIndex >= len(indexes) { - m.secretMessage = msgNoProviderSelected - return - } - - targetIdx := indexes[m.selectedIndex] - if targetIdx < 0 || targetIdx >= len(m.providers.Providers) { - m.secretMessage = msgInvalidProvider - return - } - - provider := m.providers.Providers[targetIdx] - m.secretTargetIndex = targetIdx - m.secretInput.SetValue("") - m.secretInput.Placeholder = fmt.Sprintf("API key for %s", provider.DisplayName) - m.secretInput.CursorEnd() - m.secretInput.Focus() - m.secretInputActive = true - m.searchActive = false - m.secretMessage = "" -} - -func (m *DashboardModel) ensureSecretsConfig() { - if m.secrets == nil { - m.secrets = &core.SecretsConfig{ - Version: 1, - Secrets: make(map[string]core.Secret), - } - } - if m.secrets.Secrets == nil { - m.secrets.Secrets = make(map[string]core.Secret) - } -} - -func (m *DashboardModel) saveSecretInput() { - if m.secretTargetIndex < 0 || m.secretTargetIndex >= len(m.providers.Providers) { - m.secretMessage = msgInvalidProvider - m.secretInputActive = false - return - } - - apiKey := strings.TrimSpace(m.secretInput.Value()) - if apiKey == "" { - m.secretMessage = "API key cannot be empty" - return - } - - provider := m.providers.Providers[m.secretTargetIndex] - m.ensureSecretsConfig() - m.secrets.Secrets[provider.ID] = core.Secret{ - ProviderID: provider.ID, - APIKey: apiKey, - } - - if err := core.SaveSecrets(m.home, m.secrets); err != nil { - m.secretMessage = fmt.Sprintf("Failed to save API key: %v", err) - } else { - m.secretMessage = fmt.Sprintf("API key saved for %s", provider.DisplayName) - } - - m.secretInputActive = false - m.secretInput.Blur() - m.secretInput.SetValue("") -} - -func (m *DashboardModel) handleSecretRemove() { - indexes := m.filteredProviderIndexes() - if len(indexes) == 0 || m.selectedIndex < 0 || m.selectedIndex >= len(indexes) { - m.secretMessage = msgNoProviderSelected - return - } - targetIdx := indexes[m.selectedIndex] - if targetIdx < 0 || targetIdx >= len(m.providers.Providers) { - m.secretMessage = msgInvalidProvider - return - } - provider := m.providers.Providers[targetIdx] - - m.ensureSecretsConfig() - if _, ok := m.secrets.Secrets[provider.ID]; !ok { - m.secretMessage = fmt.Sprintf("No API key found for %s", provider.DisplayName) - return - } - - delete(m.secrets.Secrets, provider.ID) - if err := core.SaveSecrets(m.home, m.secrets); err != nil { - m.secretMessage = fmt.Sprintf("Failed to remove API key: %v", err) - return - } - m.secretMessage = fmt.Sprintf("Removed API key for %s", provider.DisplayName) -} - -func (m *DashboardModel) handleSecretTest() { - indexes := m.filteredProviderIndexes() - if len(indexes) == 0 || m.selectedIndex < 0 || m.selectedIndex >= len(indexes) { - m.secretMessage = msgNoProviderSelected - return - } - targetIdx := indexes[m.selectedIndex] - if targetIdx < 0 || targetIdx >= len(m.providers.Providers) { - m.secretMessage = msgInvalidProvider - return - } - provider := m.providers.Providers[targetIdx] - - if _, err := core.ResolveAPIKey(&provider, m.secrets); err != nil { - m.secretMessage = fmt.Sprintf("API key missing: %v", err) - return - } - m.secretMessage = fmt.Sprintf("API key available for %s", provider.DisplayName) -} - -func (m *DashboardModel) startProviderForm(add bool) { - indexes := m.filteredProviderIndexes() - if !add { - if len(indexes) == 0 || m.selectedIndex < 0 || m.selectedIndex >= len(indexes) { - m.providerFormMessage = msgNoProviderSelected - return - } - targetIdx := indexes[m.selectedIndex] - if targetIdx < 0 || targetIdx >= len(m.providers.Providers) { - m.providerFormMessage = msgInvalidProvider - return - } - m.providerFormProvider = m.providers.Providers[targetIdx] - m.providerFormIndex = targetIdx - } else { - m.providerFormProvider = core.Provider{ - Enabled: true, - APIKey: core.APIKeyConfig{ - Source: core.APIKeySourceEnv, - }, - } - m.providerFormIndex = -1 - } - - m.providerFormAdd = add - m.providerFormActive = true - m.providerFormField = 0 - if !add { - // Skip ID when editing existing provider - m.providerFormField = 1 - } - m.prepareProviderFormInput() - m.providerFormInput.Focus() - m.providerFormMessage = "" - m.searchActive = false -} - -func (m *DashboardModel) providerFieldEnabled(field providerFormField) bool { - if !m.providerFormAdd && field == providerFieldID { - return false - } - if field == providerFieldAPIKeyEnv { - return strings.ToLower(string(m.providerFormProvider.APIKey.Source)) == string(core.APIKeySourceEnv) - } - return true -} - -func (m *DashboardModel) prepareProviderFormInput() { - if m.providerFormField >= len(providerFieldSequence) { - return - } - field := providerFieldSequence[m.providerFormField] - m.providerFormInput.Placeholder = m.providerFieldPrompt(field) - switch field { - case providerFieldID: - m.providerFormInput.SetValue(m.providerFormProvider.ID) - case providerFieldKind: - if m.providerFormProvider.Kind != "" { - m.providerFormInput.SetValue(string(m.providerFormProvider.Kind)) - } else { - m.providerFormInput.SetValue("") - } - case providerFieldDisplayName: - m.providerFormInput.SetValue(m.providerFormProvider.DisplayName) - case providerFieldBaseURL: - m.providerFormInput.SetValue(m.providerFormProvider.BaseURL) - case providerFieldDefaultModel: - m.providerFormInput.SetValue(m.providerFormProvider.DefaultModel) - case providerFieldAPIKeySource: - if m.providerFormProvider.APIKey.Source != "" { - m.providerFormInput.SetValue(string(m.providerFormProvider.APIKey.Source)) - } else { - m.providerFormInput.SetValue("") - } - case providerFieldAPIKeyEnv: - m.providerFormInput.SetValue(m.providerFormProvider.APIKey.EnvVar) - } -} - -func (m *DashboardModel) providerFieldPrompt(field providerFormField) string { - switch field { - case providerFieldID: - return "provider id (e.g. openai-official)" - case providerFieldKind: - return "provider kind (openai, anthropic, ...)" - case providerFieldDisplayName: - return "display name" - case providerFieldBaseURL: - return "base URL" - case providerFieldDefaultModel: - return "default model" - case providerFieldAPIKeySource: - return "api key source (env or secrets)" - case providerFieldAPIKeyEnv: - return "env var name (if source=env)" - default: - return "" - } -} - -func (m *DashboardModel) submitProviderFormValue() { - if m.providerFormField >= len(providerFieldSequence) { - return - } - field := providerFieldSequence[m.providerFormField] - value := strings.TrimSpace(m.providerFormInput.Value()) - if err := m.setProviderFieldValue(field, value); err != nil { - m.providerFormMessage = err.Error() - return - } - m.providerFormMessage = "" - m.providerFormInput.SetValue("") - for { - m.providerFormField++ - if m.providerFormField >= len(providerFieldSequence) { - m.finishProviderForm() - return - } - if m.providerFieldEnabled(providerFieldSequence[m.providerFormField]) { - m.prepareProviderFormInput() - return - } - } -} - -func (m *DashboardModel) setProviderFieldValue(field providerFormField, value string) error { - value = strings.TrimSpace(value) - switch field { - case providerFieldID: - return m.setProviderID(value) - case providerFieldKind: - return m.setProviderKind(value) - case providerFieldDisplayName: - return m.setProviderDisplayName(value) - case providerFieldBaseURL: - return m.setProviderBaseURL(value) - case providerFieldDefaultModel: - return m.setProviderDefaultModel(value) - case providerFieldAPIKeySource: - return m.setProviderAPIKeySource(value) - case providerFieldAPIKeyEnv: - return m.setProviderAPIKeyEnv(value) - default: - return nil - } -} - -func (m *DashboardModel) setProviderID(value string) error { - if value == "" { - return fmt.Errorf("provider ID cannot be empty") - } - for i := range m.providers.Providers { - if strings.EqualFold(m.providers.Providers[i].ID, value) { - return fmt.Errorf("provider ID already exists") - } - } - m.providerFormProvider.ID = value - return nil -} - -func (m *DashboardModel) setProviderKind(value string) error { - if value == "" { - return fmt.Errorf("provider kind cannot be empty") - } - m.providerFormProvider.Kind = core.ProviderKind(value) - return nil -} - -func (m *DashboardModel) setProviderDisplayName(value string) error { - if value == "" { - return fmt.Errorf("display name cannot be empty") - } - m.providerFormProvider.DisplayName = value - return nil -} - -func (m *DashboardModel) setProviderBaseURL(value string) error { - if value == "" { - return fmt.Errorf("base URL cannot be empty") - } - m.providerFormProvider.BaseURL = value - return nil -} - -func (m *DashboardModel) setProviderDefaultModel(value string) error { - if value == "" { - return fmt.Errorf("default model cannot be empty") - } - m.providerFormProvider.DefaultModel = value - return nil -} - -func (m *DashboardModel) setProviderAPIKeySource(value string) error { - v := strings.ToLower(value) - switch v { - case "env": - m.providerFormProvider.APIKey.Source = core.APIKeySourceEnv - case "secrets": - m.providerFormProvider.APIKey.Source = core.APIKeySourceSecrets - m.providerFormProvider.APIKey.EnvVar = "" - default: - return fmt.Errorf("api key source must be 'env' or 'secrets'") - } - return nil -} - -func (m *DashboardModel) setProviderAPIKeyEnv(value string) error { - if m.providerFormProvider.APIKey.Source == core.APIKeySourceEnv && value == "" { - return fmt.Errorf("env var is required when source=env") - } - m.providerFormProvider.APIKey.EnvVar = value - return nil -} - -func (m *DashboardModel) finishProviderForm() { - if m.providerFormProvider.ID == "" { - m.providerFormMessage = "provider ID is required" - return - } - - if m.providerFormAdd { - m.providers.Providers = append(m.providers.Providers, m.providerFormProvider) - } else if m.providerFormIndex >= 0 && m.providerFormIndex < len(m.providers.Providers) { - m.providers.Providers[m.providerFormIndex] = m.providerFormProvider - } - - if err := core.SaveProviders(m.home, m.providers); err != nil { - m.providerFormMessage = fmt.Sprintf("failed to save provider: %v", err) - } else if m.providerFormAdd { - m.providerFormMessage = fmt.Sprintf("provider %s created", m.providerFormProvider.DisplayName) - } else { - m.providerFormMessage = fmt.Sprintf("provider %s updated", m.providerFormProvider.DisplayName) - } - - m.providerFormActive = false - m.providerFormAdd = false - m.providerFormInput.Blur() -} - -type reportOption struct { - label string - desc string -} - -var reportOptions = []reportOption{ - {"Last 7 Days Report", "Generate usage report for the past 7 days"}, - {"Last 30 Days Report", "Generate monthly usage report"}, - {"Custom Date Range", "Specify custom start and end dates"}, - {"JSON Format", "Export report as JSON (default)"}, - {"CSV Format", "Export report as CSV for spreadsheet tools"}, - {"HTML Format", "Generate visual HTML report with charts"}, -} - -type configFile struct { - name string - file string - desc string -} - -var configFiles = []configFile{ - {"Providers", "providers.yaml", "AI provider configurations and API endpoints"}, - {"Tools", "tools.yaml", "CLI tool detection and management"}, - {"Bindings", "bindings.yaml", "Tool-to-provider bindings and proxy settings"}, - {"Secrets", "secrets.yaml", "Encrypted API keys (edit with caution!)"}, - {"Routes", "routes.yaml", "Context-based routing rules"}, - {"Pricing", "pricing.yaml", "Token pricing for cost calculations"}, - {"Settings", "settings.yaml", "Global application settings"}, -} - -// DashboardModel represents the control plane dashboard -type DashboardModel struct { - home string - theme Theme - localizer *Localizer - - // Data - providers *core.ProvidersConfig - tools *core.ToolsConfig - bindings *core.BindingsConfig - secrets *core.SecretsConfig - - // Stats data - todayStats stats.Summary - weekStats stats.Summary - profileStats []stats.ProfileStats - statsLoaded bool - statsError string - - // Suggestions data - suggestions []suggestions.Suggestion - suggestionsError string - - // UI components - table table.Model - - // State - currentView viewMode - selectedIndex int // Currently selected item in list views - width int - height int - quitting bool - proxyStatus string // proxyStatusRunning, proxyStatusStopped, proxyStatusChecking - message string // Status message to display - sections []viewSection - currentSection int - sectionViewIndex int - showHelpOverlay bool - searchActive bool - searchInput textinput.Model - searchQuery string - searchContextView viewMode - secretInputActive bool - secretInput textinput.Model - secretTargetIndex int - secretMessage string - providerFormActive bool - providerFormAdd bool - providerFormIndex int - providerFormField int - providerFormInput textinput.Model - providerFormProvider core.Provider - providerFormMessage string - bindingFormActive bool - bindingFormAdd bool - bindingFormIndex int - bindingFormField int - bindingFormInput textinput.Model - bindingFormBinding core.Binding - bindingFormMessage string -} - -// NewDashboard creates a new dashboard model -func NewDashboard(home string) (*DashboardModel, error) { - // Load theme and localizer - theme := loadTheme(home) - localizer, err := NewLocalizer(GetUserLanguage()) - if err != nil { - // Fallback to English if user language is not available - localizer, err = NewLocalizer("en") - if err != nil { - // Should not happen with English, but handle it - return nil, fmt.Errorf("failed to load localizer: %w", err) - } - } - - // Load all configurations - providers, tools, bindings, secrets, err := core.LoadAll(home) - if err != nil { - return nil, fmt.Errorf("failed to load configurations: %w", err) - } - - m := &DashboardModel{ - home: home, - theme: theme, - localizer: localizer, - providers: providers, - tools: tools, - bindings: bindings, - secrets: secrets, - proxyStatus: proxyStatusChecking, - currentView: viewDashboard, - } - - m.initSections() - searchInput := textinput.New() - searchInput.Placeholder = "Search..." - searchInput.CharLimit = 100 - searchInput.Width = 30 - m.searchInput = searchInput - - secretInput := textinput.New() - secretInput.Placeholder = "Enter API key" - secretInput.CharLimit = 200 - secretInput.Width = 40 - secretInput.Prompt = promptPrefix - secretInput.EchoMode = textinput.EchoPassword - secretInput.EchoCharacter = '•' - m.secretInput = secretInput - - providerInput := textinput.New() - providerInput.CharLimit = 200 - providerInput.Width = 50 - providerInput.Prompt = promptPrefix - m.providerFormInput = providerInput - - bindingInput := textinput.New() - bindingInput.CharLimit = 200 - bindingInput.Width = 40 - bindingInput.Prompt = promptPrefix - m.bindingFormInput = bindingInput - - m.initializeTable() - - return m, nil -} - -// proxyStatusMsg is sent when proxy status is checked -type proxyStatusMsg struct { - running bool -} - -// statsLoadedMsg is sent when stats are loaded -type statsLoadedMsg struct { - today stats.Summary - week stats.Summary - profileStats []stats.ProfileStats - err error -} - -// suggestionsLoadedMsg is sent when suggestions are loaded -type suggestionsLoadedMsg struct { - suggestions []suggestions.Suggestion - err error -} - -// checkProxyStatus checks if the proxy server is running -func checkProxyStatus() tea.Msg { - addr := proxy.DefaultAddr - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+addr+"/health", nil) - if err != nil { - return proxyStatusMsg{running: false} - } - - client := &http.Client{Timeout: 500 * time.Millisecond} - resp, err := client.Do(req) - if err != nil { - return proxyStatusMsg{running: false} - } - defer func() { - // Close response body; error ignored as it doesn't affect proxy status check - //nolint:errcheck,gosec // Error on close is not critical for status check - resp.Body.Close() - }() - - return proxyStatusMsg{running: resp.StatusCode == http.StatusOK} -} - -// loadStatsData loads usage statistics from the database -func (m *DashboardModel) loadStatsData() tea.Msg { - dbPath := filepath.Join(m.home, "usage.db") - db, err := sqlite.Open(dbPath) - if err != nil { - return statsLoadedMsg{err: err} - } - // Note: sqlite.DB uses CLI-based approach, no Close() needed - - ctx := context.Background() - - // Load today's stats - today, err := stats.Today(ctx, db) - if err != nil { - return statsLoadedMsg{err: fmt.Errorf("load today stats: %w", err)} - } - - // Load 7-day stats - to := time.Now() - from := to.AddDate(0, 0, -7) - week, err := stats.Window(ctx, db, from, to) - if err != nil { - return statsLoadedMsg{err: fmt.Errorf("load week stats: %w", err)} - } - - // Load profile stats - analyzer := stats.NewAnalyzer(db) - profileStats, err := analyzer.GetProfileStats(7) - if err != nil { - // Don't fail if profile stats can't be loaded - profileStats = []stats.ProfileStats{} - } - - return statsLoadedMsg{ - today: today, - week: week, - profileStats: profileStats, - } -} - -// initializeTable sets up the table with current data -func (m *DashboardModel) initializeTable() { - columns := []table.Column{ - {Title: "Tool", Width: 12}, - {Title: "Provider", Width: 22}, - {Title: "Model", Width: 25}, - {Title: "Proxy", Width: 8}, - {Title: "Status", Width: 13}, - } - - rows := m.buildTableRows() - - t := table.New( - table.WithColumns(columns), - table.WithRows(rows), - table.WithFocused(true), - table.WithHeight(10), - ) - - // Style the table - s := table.DefaultStyles() - s.Header = s.Header. - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(m.theme.Border). - BorderBottom(true). - Bold(true). - Foreground(m.theme.Primary) - - s.Selected = s.Selected. - Foreground(m.theme.Text). - Background(m.theme.Primary). - Bold(false) - - t.SetStyles(s) - - m.table = t -} - -// buildTableRows creates table rows from current configuration -func (m *DashboardModel) buildTableRows() []table.Row { - rows := make([]table.Row, 0) - - for _, tool := range m.tools.Tools { - // Find binding for this tool - binding, err := m.bindings.FindBinding(tool.ID) - if err != nil { - // No binding, show as not configured - rows = append(rows, table.Row{ - tool.Name, - "(not bound)", - "-", - "-", - "⚠ Not configured", - }) - continue - } - - // Find provider - provider, err := m.providers.FindProvider(binding.ProviderID) - if err != nil { - // Provider not found - rows = append(rows, table.Row{ - tool.Name, - fmt.Sprintf("(missing: %s)", binding.ProviderID), - "-", - "-", - "❌ Error", - }) - continue - } - - // Check API key status - keyStatus := "✓ Ready" - if _, err := core.ResolveAPIKey(provider, m.secrets); err != nil { - keyStatus = "⚠ No API key" - } - - // Determine model - model := provider.DefaultModel - if binding.Options.Model != "" { - model = binding.Options.Model - } - - // Truncate if too long - if len(model) > 23 { - model = model[:20] + "..." - } - - // Proxy status - proxyStatus := proxyStateOff - if binding.UseProxy { - proxyStatus = proxyStateOn - } - - rows = append(rows, table.Row{ - tool.Name, - provider.DisplayName, - model, - proxyStatus, - keyStatus, - }) - } - - if len(rows) == 0 { - rows = append(rows, table.Row{ - "No tools configured", - "-", - "-", - "-", - "-", - }) - } - - return rows -} - -// Init initializes the dashboard -func (m DashboardModel) Init() tea.Cmd { - // Check proxy status on startup and load stats - return tea.Batch( - checkProxyStatus, - m.loadStatsData, - ) -} - -// Update handles messages -// -//nolint:gocyclo // UI event handlers are inherently complex -func (m DashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - switch msg := msg.(type) { - case proxyStatusMsg: - // Update proxy status based on check - if msg.running { - m.proxyStatus = proxyStatusRunning - } else { - m.proxyStatus = proxyStatusStopped - } - return m, nil - - case statsLoadedMsg: - // Update stats data - if msg.err != nil { - m.statsError = msg.err.Error() - } else { - m.todayStats = msg.today - m.weekStats = msg.week - m.profileStats = msg.profileStats - m.statsLoaded = true - m.statsError = "" - } - return m, nil - - case suggestionsLoadedMsg: - if msg.err != nil { - m.suggestionsError = msg.err.Error() - return m, nil - } - - m.suggestions = msg.suggestions - m.suggestionsError = "" - return m, nil - - case tea.KeyMsg: - key := msg.String() - if key == "ctrl+c" || key == "q" { - m.quitting = true - return m, tea.Quit - } - - if m.providerFormActive { - switch key { - case keyEsc: - m.providerFormActive = false - m.providerFormInput.Blur() - m.providerFormMessage = "Provider edit canceled" - return m, nil - case keyEnter: - m.submitProviderFormValue() - return m, nil - default: - var cmd tea.Cmd - m.providerFormInput, cmd = m.providerFormInput.Update(msg) - return m, cmd - } - } - - if m.bindingFormActive { - switch key { - case keyEsc: - m.bindingFormActive = false - m.bindingFormInput.Blur() - m.bindingFormMessage = "Binding edit canceled" - return m, nil - case keyEnter: - m.submitBindingFormValue() - return m, nil - default: - var cmd tea.Cmd - m.bindingFormInput, cmd = m.bindingFormInput.Update(msg) - return m, cmd - } - } - - if m.searchActive { - switch key { - case keyEsc: - m.clearSearch() - return m, nil - case keyEnter: - m.searchQuery = strings.TrimSpace(m.searchInput.Value()) - m.searchActive = false - return m, nil - default: - var cmd tea.Cmd - m.searchInput, cmd = m.searchInput.Update(msg) - m.searchQuery = m.searchInput.Value() - return m, cmd - } - } - - if m.secretInputActive { - switch key { - case keyEsc: - m.secretInputActive = false - m.secretInput.Blur() - m.secretInput.SetValue("") - return m, nil - case keyEnter: - m.saveSecretInput() - return m, nil - default: - var cmd tea.Cmd - m.secretInput, cmd = m.secretInput.Update(msg) - return m, cmd - } - } - - switch key { - case "tab": - return m, m.cycleSection(1) - case "shift+tab": - return m, m.cycleSection(-1) - case "[": - return m, m.cycleSubview(-1) - case "]": - return m, m.cycleSubview(1) - case "1", "2", "3", "4", "5": - sectionIndex := int(key[0] - '1') - return m, m.moveToSection(sectionIndex) - - case "r": - if m.currentView == viewSecrets { - m.handleSecretRemove() - return m, nil - } - // Run selected tool (only in dashboard view) - if m.currentView == viewDashboard { - return m.handleRun() - } - return m, nil - - case "b": - // Change binding (placeholder for now) - // In future, this would open a binding edit view - return m, nil - - case "x": - // Toggle proxy for selected tool or binding depending on view - switch m.currentView { - case viewDashboard: - return m.handleToggleProxy() - case viewBindings: - return m.handleToggleBindingProxy() - default: - return m, nil - } - - case "s": - if m.currentView == viewSecrets { - m.startSecretInput() - return m, nil - } - // Check proxy status - m.proxyStatus = proxyStatusChecking - return m, checkProxyStatus - - case "e": - if m.currentView == viewProviders { - m.startProviderForm(false) - return m, nil - } - if m.currentView == viewBindings { - m.startBindingForm(false) - return m, nil - } - return m, nil - - case "n": - if m.currentView == viewBindings { - m.startBindingForm(true) - return m, nil - } - return m, nil - - case "a": - if m.currentView == viewProviders { - m.startProviderForm(true) - return m, nil - } - return m, nil - - case "t": - if m.currentView == viewSecrets { - m.handleSecretTest() - return m, nil - } - return m, nil - - case "/": - if m.supportsSearch(m.currentView) { - m.activateSearch() - } - return m, nil - - case "?": - m.showHelpOverlay = !m.showHelpOverlay - return m, nil - - case "esc": - if m.showHelpOverlay { - m.showHelpOverlay = false - return m, nil - } - if m.viewHasSearch(m.currentView) { - m.clearSearch() - return m, nil - } - return m, nil - - case "up", "k": - // Navigate up in list views - if m.currentView != viewDashboard && m.selectedIndex > 0 { - m.selectedIndex-- - } - return m, nil - - case "down", "j": - // Navigate down in list views - maxIndex := m.maxSelectableIndex() - if m.currentView != viewDashboard && m.selectedIndex < maxIndex { - m.selectedIndex++ - } - return m, nil - } - - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - m.updateTableSize() - return m, nil - } - - // Update table (only in dashboard view) - if m.currentView == viewDashboard { - m.table, cmd = m.table.Update(msg) - } - return m, cmd -} - -func (m DashboardModel) maxSelectableIndex() int { - switch m.currentView { - case viewProviders: - return len(m.filteredProviderIndexes()) - 1 - case viewTools: - return len(m.filteredToolIndexes()) - 1 - case viewBindings: - return len(m.filteredBindingIndexes()) - 1 - case viewSecrets: - return len(m.filteredProviderIndexes()) - 1 // Secrets are per-provider - case viewSuggestions: - return len(m.suggestions) - 1 - case viewReports: - return len(reportOptions) - 1 - case viewConfig: - return len(configFiles) - 1 - default: - return 0 - } -} - -func (m *DashboardModel) viewHasSearch(view viewMode) bool { - return strings.TrimSpace(m.searchQuery) != "" && m.searchContextView == view -} - -func (m *DashboardModel) filteredProviderIndexes() []int { - if m.providers == nil { - return nil - } - total := len(m.providers.Providers) - indexes := make([]int, 0, total) - query := strings.ToLower(strings.TrimSpace(m.searchQuery)) - for i, provider := range m.providers.Providers { - if !m.viewHasSearch(viewProviders) || - strings.Contains(strings.ToLower(provider.DisplayName), query) || - strings.Contains(strings.ToLower(provider.ID), query) || - strings.Contains(strings.ToLower(provider.BaseURL), query) { - indexes = append(indexes, i) - } - } - return indexes -} - -func (m *DashboardModel) filteredToolIndexes() []int { - if m.tools == nil { - return nil - } - total := len(m.tools.Tools) - indexes := make([]int, 0, total) - query := strings.ToLower(strings.TrimSpace(m.searchQuery)) - for i, tool := range m.tools.Tools { - if !m.viewHasSearch(viewTools) || - strings.Contains(strings.ToLower(tool.Name), query) || - strings.Contains(strings.ToLower(tool.ID), query) || - strings.Contains(strings.ToLower(tool.Exec), query) { - indexes = append(indexes, i) - } - } - return indexes -} - -func (m *DashboardModel) filteredBindingIndexes() []int { - if m.bindings == nil { - return nil - } - total := len(m.bindings.Bindings) - indexes := make([]int, 0, total) - query := strings.ToLower(strings.TrimSpace(m.searchQuery)) - for i, binding := range m.bindings.Bindings { - if !m.viewHasSearch(viewBindings) || - strings.Contains(strings.ToLower(binding.ToolID), query) || - strings.Contains(strings.ToLower(binding.ProviderID), query) { - indexes = append(indexes, i) - } - } - return indexes -} - -func (m DashboardModel) renderSearchBar(view viewMode) string { - if !m.supportsSearch(view) { - return "" - } - style := lipgloss.NewStyle().Foreground(m.theme.Muted).Padding(0, 2) - switch { - case m.searchActive && m.searchContextView == view: - return style.Render("Search: " + m.searchInput.View()) - case m.viewHasSearch(view): - return style.Render(fmt.Sprintf("Filter: %s (Esc to clear)", m.searchQuery)) - default: - return style.Render("Press '/' to search") - } -} - -// updateTableSize adjusts table dimensions based on window size -func (m *DashboardModel) updateTableSize() { - // Calculate available height for table - headerHeight := 3 - footerHeight := 2 - availableHeight := m.height - headerHeight - footerHeight - - if availableHeight < 5 { - availableHeight = 5 - } - - // Update column widths based on width - columns := m.table.Columns() - if m.width > 100 { - columns[0].Width = 15 // Tool - columns[1].Width = 25 // Provider - columns[2].Width = 28 // Model - columns[3].Width = 10 // Proxy - columns[4].Width = 15 // Status - } else if m.width < 80 { - columns[0].Width = 10 // Tool - columns[1].Width = 18 // Provider - columns[2].Width = 20 // Model - columns[3].Width = 8 // Proxy - columns[4].Width = 12 // Status - } - - m.table.SetColumns(columns) - m.table.SetHeight(availableHeight) -} - -// handleRun attempts to run the selected tool -func (m DashboardModel) handleRun() (tea.Model, tea.Cmd) { - // Get selected row index - selectedIdx := m.table.Cursor() - - if selectedIdx < 0 || selectedIdx >= len(m.tools.Tools) { - return m, nil - } - - tool := m.tools.Tools[selectedIdx] - - // Exit TUI and run the command - // We'll quit and let the shell run `boba run ` - m.quitting = true - - // Print command hint - fmt.Printf("\nRun: boba run %s\n", tool.ID) - - return m, tea.Quit -} - -// handleToggleProxy toggles the proxy setting for the selected tool -func (m DashboardModel) handleToggleProxy() (tea.Model, tea.Cmd) { - selectedIdx := m.table.Cursor() - - if selectedIdx < 0 || selectedIdx >= len(m.tools.Tools) { - return m, nil - } - - tool := m.tools.Tools[selectedIdx] - - // Find and toggle the binding - binding, err := m.bindings.FindBinding(tool.ID) - if err != nil { - m.message = fmt.Sprintf("Tool %s is not bound to any provider", tool.Name) - return m, nil - } - - // Toggle proxy setting - binding.UseProxy = !binding.UseProxy - - // Save the bindings - if err := core.SaveBindings(m.home, m.bindings); err != nil { - m.message = fmt.Sprintf("Failed to save binding: %v", err) - return m, nil - } - - // Update table rows to reflect the change - m.table.SetRows(m.buildTableRows()) - - // Set success message - proxyState := proxyStateOff - if binding.UseProxy { - proxyState = proxyStateOn - } - m.message = fmt.Sprintf("Proxy %s for %s", proxyState, tool.Name) - - return m, nil -} - -// handleToggleBindingProxy toggles proxy usage for the selected binding in the bindings view -func (m DashboardModel) handleToggleBindingProxy() (tea.Model, tea.Cmd) { - indexes := m.filteredBindingIndexes() - - if len(indexes) == 0 { - m.message = "No bindings configured" - return m, nil - } - - if m.selectedIndex < 0 || m.selectedIndex >= len(indexes) { - m.message = "No binding selected" - return m, nil - } - - binding := &m.bindings.Bindings[indexes[m.selectedIndex]] - - toolName := binding.ToolID - if tool, err := m.tools.FindTool(binding.ToolID); err == nil { - toolName = tool.Name - } - - binding.UseProxy = !binding.UseProxy - - if err := core.SaveBindings(m.home, m.bindings); err != nil { - m.message = fmt.Sprintf("Failed to save binding: %v", err) - return m, nil - } - - proxyState := proxyStateOff - if binding.UseProxy { - proxyState = proxyStateOn - } - - // Update dashboard table rows to keep views consistent - m.table.SetRows(m.buildTableRows()) - m.message = fmt.Sprintf("Proxy %s for %s", proxyState, toolName) - - return m, nil -} - -// View renders the dashboard -func (m DashboardModel) View() string { - if m.quitting { - return "" - } - - if m.showHelpOverlay { - return m.renderHelpView() - } - - switch m.currentView { - case viewProviders: - return m.renderProvidersView() - case viewTools: - return m.renderToolsView() - case viewBindings: - return m.renderBindingsView() - case viewSecrets: - return m.renderSecretsView() - case viewStats: - return m.renderStatsView() - case viewProxy: - return m.renderProxyView() - case viewRouting: - return m.renderRoutingView() - case viewSuggestions: - return m.renderSuggestionsView() - case viewReports: - return m.renderReportsView() - case viewHooks: - return m.renderHooksView() - case viewConfig: - return m.renderConfigView() - case viewHelp: - return m.renderHelpView() - default: - return m.renderDashboardView() - } -} - -// renderDashboardView renders the main dashboard view -func (m DashboardModel) renderDashboardView() string { - proxyIcon := iconCircleEmpty - proxyText := "Checking..." - switch m.proxyStatus { - case proxyStatusRunning: - proxyIcon = iconCircleFilled - proxyText = "Running" - case proxyStatusStopped: - proxyIcon = iconCircleEmpty - proxyText = "Stopped" - } - - props := dashboardviews.DashboardViewProps{ - Theme: newDashboardViewTheme(m.theme), - TableView: m.table.View(), - Message: m.message, - ProxyIcon: proxyIcon, - ProxyStatus: proxyText, - NavigationHelp: helpTextNavigation, - HelpCommands: "[R] Run Tool [X] Toggle Proxy", - } - - return dashboardviews.RenderDashboardView(props) -} - -// renderStatsView renders the usage statistics view -func (m DashboardModel) renderStatsView() string { - props := dashboardviews.StatsViewProps{ - Theme: newDashboardViewTheme(m.theme), - Loaded: m.statsLoaded, - Error: m.statsError, - LoadingMessage: "Loading stats...", - Today: convertStatsSummaryToView("📅 Today's Usage", m.todayStats, false), - Week: convertStatsSummaryToView("📊 Last 7 Days", m.weekStats, true), - Profiles: convertProfileStatsToView(m.profileStats), - NavigationHelp: "[V] Back to Dashboard [S] Refresh [Q] Quit", - LoadingHelp: "[V] Back to Dashboard [Q] Quit", - ProfileSubtitle: "🎯 By Profile (7d)", - } - - return dashboardviews.RenderStatsView(props) -} - -// renderProvidersView renders the AI providers management view -func (m DashboardModel) renderProvidersView() string { - indexes := m.filteredProviderIndexes() - if len(indexes) > 0 && m.selectedIndex >= len(indexes) { - m.selectedIndex = len(indexes) - 1 - } - - props := dashboardviews.ProvidersViewProps{ - Theme: newDashboardViewTheme(m.theme), - ProviderForm: m.renderProviderForm(), - ShowProviderForm: m.providerFormActive, - ProviderFormMessage: strings.TrimSpace(m.providerFormMessage), - SearchBar: m.renderSearchBar(viewProviders), - EmptyStateMessage: providersEmptyStateMessage(len(indexes) == 0, m.viewHasSearch(viewProviders)), - Providers: convertProvidersToView(indexes, m.providers, m.secrets), - SelectedIndex: m.selectedIndex, - Details: convertProviderDetailsToView(indexes, m.selectedIndex, m.providers), - NavigationHelp: helpTextNavigation, - HelpCommands: "[E] Edit provider [A] Add provider", - EnabledIcon: iconCheckmark, - DisabledIcon: iconCross, - KeyPresentIcon: "🔑", - KeyMissingIcon: "⚠", - } - - return dashboardviews.RenderProvidersView(props) -} - -func (m DashboardModel) renderProviderForm() string { - boxStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(m.theme.Primary). - Padding(1, 2). - Width(70) - - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(m.theme.Primary) - - infoStyle := lipgloss.NewStyle(). - Foreground(m.theme.Muted) - - field := providerFieldSequence[m.providerFormField] - title := "Edit Provider" - if m.providerFormAdd { - title = "Add Provider" - } - - var currentName string - if m.providerFormProvider.DisplayName != "" { - currentName = fmt.Sprintf(" (%s)", m.providerFormProvider.DisplayName) - } - - body := strings.Builder{} - body.WriteString(titleStyle.Render(title + currentName)) - body.WriteString("\n\n") - body.WriteString(infoStyle.Render(fmt.Sprintf("Field: %s", m.providerFieldPrompt(field)))) - body.WriteString("\n") - body.WriteString(m.providerFormInput.View()) - body.WriteString("\n\n") - body.WriteString(infoStyle.Render("Enter to confirm • Esc to cancel")) - if strings.TrimSpace(m.providerFormMessage) != "" { - body.WriteString("\n") - body.WriteString(infoStyle.Render(m.providerFormMessage)) - } - - return boxStyle.Render(body.String()) -} - -func (m *DashboardModel) startBindingForm(add bool) { - indexes := m.filteredBindingIndexes() - if !add { - if len(indexes) == 0 || m.selectedIndex < 0 || m.selectedIndex >= len(indexes) { - m.bindingFormMessage = msgNoProviderSelected - return - } - targetIdx := indexes[m.selectedIndex] - if targetIdx < 0 || targetIdx >= len(m.bindings.Bindings) { - m.bindingFormMessage = msgInvalidProvider - return - } - m.bindingFormBinding = m.bindings.Bindings[targetIdx] - m.bindingFormIndex = targetIdx - } else { - m.bindingFormBinding = core.Binding{ - Options: core.BindingOptions{}, - } - m.bindingFormIndex = -1 - } - - m.bindingFormAdd = add - m.bindingFormActive = true - m.bindingFormField = 0 - if !add { - m.bindingFormField = 1 // skip tool ID when editing - } - m.prepareBindingFormInput() - m.bindingFormInput.Focus() - m.bindingFormMessage = "" - m.searchActive = false -} - -func (m *DashboardModel) bindingFieldEnabled(field bindingFormField) bool { - if !m.bindingFormAdd && field == bindingFieldToolID { - return false - } - return true -} - -func (m *DashboardModel) prepareBindingFormInput() { - if m.bindingFormField >= len(bindingFieldSequence) { - return - } - field := bindingFieldSequence[m.bindingFormField] - m.bindingFormInput.Placeholder = m.bindingFieldPrompt(field) - switch field { - case bindingFieldToolID: - m.bindingFormInput.SetValue(m.bindingFormBinding.ToolID) - case bindingFieldProviderID: - m.bindingFormInput.SetValue(m.bindingFormBinding.ProviderID) - case bindingFieldModel: - m.bindingFormInput.SetValue(m.bindingFormBinding.Options.Model) - case bindingFieldUseProxy: - if m.bindingFormBinding.UseProxy { - m.bindingFormInput.SetValue("on") - } else { - m.bindingFormInput.SetValue("off") - } - } -} - -func (m *DashboardModel) bindingFieldPrompt(field bindingFormField) string { - switch field { - case bindingFieldToolID: - return "tool id (e.g. claude)" - case bindingFieldProviderID: - return "provider id (e.g. openai-official)" - case bindingFieldModel: - return "model override (optional)" - case bindingFieldUseProxy: - return "use proxy? (on/off)" - default: - return "" - } -} - -func (m *DashboardModel) submitBindingFormValue() { - if m.bindingFormField >= len(bindingFieldSequence) { - return - } - field := bindingFieldSequence[m.bindingFormField] - value := strings.TrimSpace(m.bindingFormInput.Value()) - if err := m.setBindingFieldValue(field, value); err != nil { - m.bindingFormMessage = err.Error() - return - } - m.bindingFormMessage = "" - m.bindingFormInput.SetValue("") - for { - m.bindingFormField++ - if m.bindingFormField >= len(bindingFieldSequence) { - m.finishBindingForm() - return - } - if m.bindingFieldEnabled(bindingFieldSequence[m.bindingFormField]) { - m.prepareBindingFormInput() - return - } - } -} - -func (m *DashboardModel) setBindingFieldValue(field bindingFormField, value string) error { - switch field { - case bindingFieldToolID: - if value == "" { - return fmt.Errorf("tool id cannot be empty") - } - if _, err := m.tools.FindTool(value); err != nil { - return fmt.Errorf("tool %s not found", value) - } - if _, err := m.bindings.FindBinding(value); err == nil { - return fmt.Errorf("binding for %s already exists", value) - } - m.bindingFormBinding.ToolID = value - case bindingFieldProviderID: - if value == "" { - return fmt.Errorf("provider id cannot be empty") - } - if _, err := m.providers.FindProvider(value); err != nil { - return fmt.Errorf("provider %s not found", value) - } - m.bindingFormBinding.ProviderID = value - case bindingFieldModel: - m.bindingFormBinding.Options.Model = value - case bindingFieldUseProxy: - val := strings.ToLower(value) - switch val { - case "on", "true", "yes", "y": - m.bindingFormBinding.UseProxy = true - case "off", "false", "no", "n": - m.bindingFormBinding.UseProxy = false - default: - return fmt.Errorf("use proxy must be on/off") - } - } - return nil -} - -func (m *DashboardModel) finishBindingForm() { - if m.bindingFormBinding.ToolID == "" { - m.bindingFormMessage = "tool id is required" - return - } - if m.bindingFormBinding.ProviderID == "" { - m.bindingFormMessage = "provider id is required" - return - } - - if m.bindingFormAdd { - m.bindings.Bindings = append(m.bindings.Bindings, m.bindingFormBinding) - } else if m.bindingFormIndex >= 0 && m.bindingFormIndex < len(m.bindings.Bindings) { - m.bindings.Bindings[m.bindingFormIndex] = m.bindingFormBinding - } - - if err := core.SaveBindings(m.home, m.bindings); err != nil { - m.bindingFormMessage = fmt.Sprintf("failed to save binding: %v", err) - return - } - - m.table.SetRows(m.buildTableRows()) - if m.bindingFormAdd { - m.bindingFormMessage = fmt.Sprintf("binding created for %s", m.bindingFormBinding.ToolID) - } else { - m.bindingFormMessage = fmt.Sprintf("binding updated for %s", m.bindingFormBinding.ToolID) - } - - m.bindingFormActive = false - m.bindingFormAdd = false - m.bindingFormInput.Blur() -} - -func (m DashboardModel) renderBindingForm() string { - boxStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(m.theme.Primary). - Padding(1, 2). - Width(70) - - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(m.theme.Primary) - - infoStyle := lipgloss.NewStyle(). - Foreground(m.theme.Muted) - - field := bindingFieldSequence[m.bindingFormField] - title := "Edit Binding" - if m.bindingFormAdd { - title = "Add Binding" - } - - body := strings.Builder{} - body.WriteString(titleStyle.Render(fmt.Sprintf("%s (%s)", title, m.bindingFormBinding.ToolID))) - body.WriteString("\n\n") - body.WriteString(infoStyle.Render(fmt.Sprintf("Field: %s", m.bindingFieldPrompt(field)))) - body.WriteString("\n") - body.WriteString(m.bindingFormInput.View()) - body.WriteString("\n\n") - body.WriteString(infoStyle.Render("Enter to confirm • Esc to cancel")) - if strings.TrimSpace(m.bindingFormMessage) != "" { - body.WriteString("\n") - body.WriteString(infoStyle.Render(m.bindingFormMessage)) - } - - return boxStyle.Render(body.String()) -} - -// renderToolsView renders the CLI tools management view -func (m DashboardModel) renderToolsView() string { - indexes := m.filteredToolIndexes() - if len(indexes) > 0 && m.selectedIndex >= len(indexes) { - m.selectedIndex = len(indexes) - 1 - } - - props := dashboardviews.ToolsViewProps{ - Theme: newDashboardViewTheme(m.theme), - SearchBar: m.renderSearchBar(viewTools), - EmptyStateMessage: toolsEmptyStateMessage(len(indexes) == 0, m.viewHasSearch(viewTools)), - Tools: convertToolsToView(indexes, m.tools, m.bindings), - SelectedIndex: m.selectedIndex, - Details: convertToolDetailsToView(m.selectedIndex, m.tools), - NavigationHelp: helpTextNavigation, - BoundIcon: iconCircleFilled, - UnboundIcon: iconCircleEmpty, - } - - return dashboardviews.RenderToolsView(props) -} - -// renderBindingsView renders the tool-to-provider bindings view -func (m DashboardModel) renderBindingsView() string { - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(m.theme.Primary). - Padding(0, 2) - - headerStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(m.theme.Success). - Padding(1, 2) - - selectedStyle := lipgloss.NewStyle(). - Foreground(m.theme.Text). - Background(m.theme.Primary). - Bold(true). - Padding(0, 1) - - normalStyle := lipgloss.NewStyle(). - Foreground(m.theme.Text). - Padding(0, 1) - - mutedStyle := lipgloss.NewStyle(). - Foreground(m.theme.Muted). - Padding(0, 1) - - helpStyle := lipgloss.NewStyle(). - Foreground(m.theme.Muted). - Padding(1, 2) - - var content strings.Builder - - // Header - title := "BobaMixer - Tool ↔ Provider Bindings" - content.WriteString(titleStyle.Render(title)) - content.WriteString("\n\n") - - // Section header - content.WriteString(headerStyle.Render("🔗 Active Bindings")) - content.WriteString("\n\n") - - content.WriteString(m.renderBindingFormSection(mutedStyle)) - - if searchBar := m.renderSearchBar(viewBindings); searchBar != "" { - content.WriteString(searchBar) - content.WriteString("\n\n") - } - - indexes := m.filteredBindingIndexes() - content.WriteString(m.renderBindingList(indexes, selectedStyle, normalStyle, mutedStyle)) - content.WriteString("\n") - content.WriteString(m.renderBindingDetails(indexes, headerStyle, normalStyle)) - - // Footer/Help - helpText := helpTextNavigation + " [E] Edit binding [N] New binding [X] Toggle Proxy" - content.WriteString(helpStyle.Render(helpText)) - - return content.String() -} - -func (m DashboardModel) renderBindingFormSection(mutedStyle lipgloss.Style) string { - var b strings.Builder - if m.bindingFormActive { - b.WriteString(m.renderBindingForm()) - b.WriteString("\n\n") - return b.String() - } - if msg := strings.TrimSpace(m.bindingFormMessage); msg != "" { - b.WriteString(mutedStyle.Render(" " + msg)) - b.WriteString("\n\n") - } - return b.String() -} - -func (m *DashboardModel) renderBindingList(indexes []int, selectedStyle, normalStyle, mutedStyle lipgloss.Style) string { - var b strings.Builder - if len(indexes) == 0 { - if m.viewHasSearch(viewBindings) { - b.WriteString(mutedStyle.Render(" No bindings match the current filter.")) - } else { - b.WriteString(mutedStyle.Render(" No bindings configured.")) - } - b.WriteString("\n") - return b.String() - } - - if m.selectedIndex >= len(indexes) { - m.selectedIndex = len(indexes) - 1 - } - - for displayIdx, bindingIdx := range indexes { - binding := m.bindings.Bindings[bindingIdx] - toolName := binding.ToolID - if tool, err := m.tools.FindTool(binding.ToolID); err == nil { - toolName = tool.Name - } - - providerName := binding.ProviderID - if provider, err := m.providers.FindProvider(binding.ProviderID); err == nil { - providerName = provider.DisplayName - } - - proxyIcon := iconCircleEmpty - if binding.UseProxy { - proxyIcon = iconCircleFilled - } - - line := fmt.Sprintf(" %-15s → %-25s Proxy: %s", toolName, providerName, proxyIcon) - - if displayIdx == m.selectedIndex { - b.WriteString(selectedStyle.Render("▶ " + line)) - } else { - b.WriteString(normalStyle.Render(" " + line)) - } - b.WriteString("\n") - } - return b.String() -} - -func (m DashboardModel) renderBindingDetails(indexes []int, headerStyle, normalStyle lipgloss.Style) string { - if len(indexes) == 0 || m.selectedIndex < 0 || m.selectedIndex >= len(indexes) { - return "" - } - - var b strings.Builder - binding := m.bindings.Bindings[indexes[m.selectedIndex]] - b.WriteString(headerStyle.Render("Details")) - b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" Tool ID: %s", binding.ToolID))) - b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" Provider ID: %s", binding.ProviderID))) - b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" Use Proxy: %t", binding.UseProxy))) - b.WriteString("\n") - if binding.Options.Model != "" { - b.WriteString(normalStyle.Render(fmt.Sprintf(" Model Override: %s", binding.Options.Model))) - b.WriteString("\n") - } - b.WriteString("\n") - return b.String() -} - -// renderSecretsView renders the API keys/secrets management view -func (m DashboardModel) renderSecretsView() string { - searchBar := m.renderSearchBar(viewSecrets) - indexes := m.filteredProviderIndexes() - - if len(indexes) == 0 { - m.selectedIndex = 0 - } else if m.selectedIndex >= len(indexes) { - m.selectedIndex = len(indexes) - 1 - } - - secretForm := "" - if m.secretInputActive { - secretForm = m.renderSecretInputForm() - } - - props := dashboardviews.SecretsViewProps{ - Theme: newDashboardViewTheme(m.theme), - SecretForm: secretForm, - ShowSecretForm: m.secretInputActive, - SearchBar: searchBar, - EmptyStateMessage: secretsEmptyStateMessage(len(indexes) == 0, m.viewHasSearch(viewSecrets)), - Providers: convertSecretProvidersToView(indexes, m.providers, m.secrets), - SelectedIndex: m.selectedIndex, - SecretMessage: strings.TrimSpace(m.secretMessage), - NavigationHelp: helpTextNavigation, - HelpCommands: "[S] Set [R] Remove [T] Test", - SuccessIcon: iconCheckmark, - FailureIcon: iconCross, - } - - return dashboardviews.RenderSecretsView(props) -} - -func (m DashboardModel) renderSecretInputForm() string { - if !m.secretInputActive || m.secretTargetIndex < 0 || m.secretTargetIndex >= len(m.providers.Providers) { - return "" - } - - provider := m.providers.Providers[m.secretTargetIndex] - boxStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(m.theme.Primary). - Padding(1, 2). - Width(60) - - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(m.theme.Primary) - - infoStyle := lipgloss.NewStyle(). - Foreground(m.theme.Muted) - - body := strings.Builder{} - body.WriteString(titleStyle.Render(fmt.Sprintf("Set API key for %s", provider.DisplayName))) - body.WriteString("\n\n") - body.WriteString(m.secretInput.View()) - body.WriteString("\n\n") - body.WriteString(infoStyle.Render("Enter to save • Esc to cancel")) - - return boxStyle.Render(body.String()) -} - -// renderProxyView renders the proxy server control panel -func (m DashboardModel) renderProxyView() string { - props := dashboardviews.ProxyViewProps{ - Theme: newDashboardViewTheme(m.theme), - StatusState: m.proxyStatus, - Address: proxy.DefaultAddr, - NavigationHelp: helpTextNavigation, - CommandHelpLine: " [S] Refresh Status", - } - - switch m.proxyStatus { - case proxyStatusRunning: - props.StatusIcon = iconCircleFilled - props.StatusText = "Running" - props.ShowConfig = true - case proxyStatusStopped: - props.StatusIcon = iconCircleEmpty - props.StatusText = "Stopped" - props.AdditionalNote = " Note: Use 'boba proxy serve' in terminal to start the proxy server" - default: - props.StatusIcon = "⋯" - props.StatusText = "Checking..." - } - - return dashboardviews.RenderProxyView(props) -} - -// renderRoutingView renders the routing rules tester -func (m DashboardModel) renderRoutingView() string { - props := dashboardviews.RoutingViewProps{ - Theme: newDashboardViewTheme(m.theme), - NavigationHelp: helpTextNavigation, - CommandHelpLine: " Use CLI: boba route test ", - } - - return dashboardviews.RenderRoutingView(props) -} - -// renderSuggestionsView renders the optimization suggestions view -func (m DashboardModel) renderSuggestionsView() string { - selectedIndex := m.selectedIndex - if len(m.suggestions) > 0 && selectedIndex >= len(m.suggestions) { - selectedIndex = 0 - } - - props := dashboardviews.SuggestionsViewProps{ - Theme: newDashboardViewTheme(m.theme), - Suggestions: convertSuggestionsToView(m.suggestions), - SelectedIndex: selectedIndex, - Error: m.suggestionsError, - NavigationHelp: helpTextNavigation, - CommandHelpLine: " Use CLI: boba action [--auto] to apply suggestions", - } - - return dashboardviews.RenderSuggestionsView(props) -} - -// loadSuggestions loads optimization suggestions -func (m *DashboardModel) loadSuggestions() tea.Msg { - dbPath := filepath.Join(m.home, "usage.db") - db, err := sqlite.Open(dbPath) - if err != nil { - return suggestionsLoadedMsg{err: err} - } - - engine := suggestions.NewEngine(db) - suggs, err := engine.GenerateSuggestions(7) - if err != nil { - return suggestionsLoadedMsg{err: err} - } - - return suggestionsLoadedMsg{suggestions: suggs} -} - -// renderReportsView renders the report generation interface -func (m DashboardModel) renderReportsView() string { - if m.selectedIndex >= len(reportOptions) { - m.selectedIndex = 0 - } - - props := dashboardviews.ReportsViewProps{ - Theme: newDashboardViewTheme(m.theme), - Options: convertReportOptionsToView(reportOptions), - SelectedIndex: m.selectedIndex, - Home: m.home, - NavigationHelp: helpTextNavigation, - CommandHelpLine: " Use CLI: boba report --format --days --out ", - } - - return dashboardviews.RenderReportsView(props) -} - -// renderHooksView renders the Git hooks management interface -func (m DashboardModel) renderHooksView() string { - repoPath := "(Not in a git repository)" - hooksInstalled := false - - props := dashboardviews.HooksViewProps{ - Theme: newDashboardViewTheme(m.theme), - RepoPath: repoPath, - HooksInstalled: hooksInstalled, - Hooks: []dashboardviews.HookInfo{ - {Name: "post-checkout", Desc: "Track branch switches and suggest optimal profiles", Active: hooksInstalled}, - {Name: "post-commit", Desc: "Record commit events for usage tracking", Active: hooksInstalled}, - {Name: "post-merge", Desc: "Track merge events and repository changes", Active: hooksInstalled}, - }, - NavigationHelp: helpTextNavigation, - CommandHelpLine: " Use CLI: boba hooks install (to install hooks) | boba hooks remove (to uninstall)", - ActiveIcon: iconCheckmark, - InactiveIcon: iconCross, - } - - return dashboardviews.RenderHooksView(props) -} - -func (m DashboardModel) renderConfigView() string { - props := dashboardviews.ConfigViewProps{ - Theme: newDashboardViewTheme(m.theme), - SelectedIndex: m.selectedIndex, - ConfigFiles: convertConfigFilesToView(configFiles), - Home: m.home, - HelpTextNavigation: helpTextNavigation, - } - - return dashboardviews.RenderConfigView(props) -} - -func (m DashboardModel) renderHelpView() string { - props := dashboardviews.HelpViewProps{ - Theme: newDashboardViewTheme(m.theme), - Sections: convertSectionsToView(m.sections), - NavigationHelp: helpTextNavigation, - } - - return dashboardviews.RenderHelpView(props) -} - -func newDashboardViewTheme(theme Theme) dashboardviews.ThemePalette { - return dashboardviews.ThemePalette{ - Primary: theme.Primary, - Success: theme.Success, - Danger: theme.Danger, - Warning: theme.Warning, - Text: theme.Text, - Muted: theme.Muted, - } -} - -func convertSuggestionsToView(suggs []suggestions.Suggestion) []dashboardviews.Suggestion { - result := make([]dashboardviews.Suggestion, len(suggs)) - for i, sugg := range suggs { - result[i] = dashboardviews.Suggestion{ - Title: sugg.Title, - Description: sugg.Description, - Impact: sugg.Impact, - ActionItems: append([]string(nil), sugg.ActionItems...), - Priority: sugg.Priority, - Type: suggestionTypeToView(sugg.Type), - } - } - return result -} - -func convertSecretProvidersToView(indexes []int, providers *core.ProvidersConfig, secretStore *core.SecretsConfig) []dashboardviews.SecretProviderRow { - if providers == nil || secretStore == nil || len(indexes) == 0 { - return nil - } - - result := make([]dashboardviews.SecretProviderRow, 0, len(indexes)) - for _, idx := range indexes { - if idx < 0 || idx >= len(providers.Providers) { - continue - } - - provider := providers.Providers[idx] - - hasKey := false - keySource := "(not set)" - if _, err := core.ResolveAPIKey(&provider, secretStore); err == nil { - hasKey = true - keySource = string(provider.APIKey.Source) - } - - result = append(result, dashboardviews.SecretProviderRow{ - DisplayName: provider.DisplayName, - HasKey: hasKey, - KeySource: keySource, - }) - } - - return result -} - -func secretsEmptyStateMessage(isEmpty bool, hasSearch bool) string { - if !isEmpty { - return "" - } - if hasSearch { - return "No providers match the current filter." - } - return "No providers configured." -} - -func convertStatsSummaryToView(title string, summary stats.Summary, includeAverages bool) dashboardviews.StatsSummary { - return dashboardviews.StatsSummary{ - Title: title, - Tokens: summary.TotalTokens, - Cost: summary.TotalCost, - Sessions: summary.TotalSessions, - AvgDailyTokens: summary.AvgDailyTokens, - AvgDailyCost: summary.AvgDailyCost, - ShowAverages: includeAverages, - } -} - -func convertProfileStatsToView(statsList []stats.ProfileStats) []dashboardviews.StatsProfile { - if len(statsList) == 0 { - return nil - } - - result := make([]dashboardviews.StatsProfile, 0, len(statsList)) - for _, ps := range statsList { - result = append(result, dashboardviews.StatsProfile{ - Name: ps.ProfileName, - Tokens: ps.TotalTokens, - Cost: ps.TotalCost, - Sessions: ps.SessionCount, - AvgLatency: ps.AvgLatencyMS, - UsagePct: ps.UsagePercent, - CostPct: ps.CostPercent, - }) - } - return result -} - -func providersEmptyStateMessage(isEmpty bool, hasSearch bool) string { - if !isEmpty { - return "" - } - if hasSearch { - return "No providers match the current filter." - } - return "No providers configured." -} - -func toolsEmptyStateMessage(isEmpty bool, hasSearch bool) string { - if !isEmpty { - return "" - } - if hasSearch { - return "No tools match the current filter." - } - return "No tools configured." -} - -func suggestionTypeToView(t suggestions.SuggestionType) string { - switch t { - case suggestions.SuggestionCostOptimization: - return "cost" - case suggestions.SuggestionProfileSwitch: - return "profile" - case suggestions.SuggestionBudgetAdjust: - return "budget" - case suggestions.SuggestionAnomaly: - return "anomaly" - case suggestions.SuggestionUsagePattern: - return "usage" - default: - return "usage" - } -} - -func convertReportOptionsToView(options []reportOption) []dashboardviews.ReportOption { - result := make([]dashboardviews.ReportOption, len(options)) - for i, opt := range options { - result[i] = dashboardviews.ReportOption{ - Label: opt.label, - Desc: opt.desc, - } - } - return result -} - -func convertConfigFilesToView(files []configFile) []dashboardviews.ConfigFile { - result := make([]dashboardviews.ConfigFile, len(files)) - for i, cfg := range files { - result[i] = dashboardviews.ConfigFile{ - Name: cfg.name, - File: cfg.file, - Desc: cfg.desc, - } - } - return result -} - -func convertSectionsToView(sections []viewSection) []dashboardviews.HelpSection { - result := make([]dashboardviews.HelpSection, 0, len(sections)) - for _, section := range sections { - viewNames := make([]string, 0, len(section.views)) - for _, v := range section.views { - viewNames = append(viewNames, viewName(v)) - } - result = append(result, dashboardviews.HelpSection{ - Name: section.name, - Shortcut: section.shortcut, - Views: viewNames, - }) - } - return result -} - -func convertProvidersToView(indexes []int, providers *core.ProvidersConfig, secrets *core.SecretsConfig) []dashboardviews.ProviderRow { - if providers == nil || len(indexes) == 0 { - return nil - } - - result := make([]dashboardviews.ProviderRow, 0, len(indexes)) - for _, idx := range indexes { - if idx < 0 || idx >= len(providers.Providers) { - continue - } - - provider := providers.Providers[idx] - hasKey := false - if secrets != nil { - if _, err := core.ResolveAPIKey(&provider, secrets); err == nil { - hasKey = true - } - } - - result = append(result, dashboardviews.ProviderRow{ - DisplayName: provider.DisplayName, - BaseURL: provider.BaseURL, - DefaultModel: provider.DefaultModel, - Enabled: provider.Enabled, - HasAPIKey: hasKey, - }) - } - - return result -} - -func convertProviderDetailsToView(indexes []int, selectedIndex int, providers *core.ProvidersConfig) *dashboardviews.ProviderDetails { - if providers == nil || len(indexes) == 0 || selectedIndex < 0 || selectedIndex >= len(indexes) { - return nil - } - - idx := indexes[selectedIndex] - if idx < 0 || idx >= len(providers.Providers) { - return nil - } - - provider := providers.Providers[idx] - details := dashboardviews.ProviderDetails{ - ID: provider.ID, - Kind: string(provider.Kind), - APIKeySource: string(provider.APIKey.Source), - } - - if provider.APIKey.Source == core.APIKeySourceEnv && provider.APIKey.EnvVar != "" { - details.EnvVar = provider.APIKey.EnvVar - details.ShowEnvVar = true - } - - return &details -} - -func convertToolsToView(indexes []int, tools *core.ToolsConfig, bindings *core.BindingsConfig) []dashboardviews.ToolRow { - if tools == nil || len(indexes) == 0 { - return nil - } - - result := make([]dashboardviews.ToolRow, 0, len(indexes)) - for _, idx := range indexes { - if idx < 0 || idx >= len(tools.Tools) { - continue - } - - tool := tools.Tools[idx] - bound := false - if bindings != nil { - if _, err := bindings.FindBinding(tool.ID); err == nil { - bound = true - } - } - - result = append(result, dashboardviews.ToolRow{ - Name: tool.Name, - Exec: tool.Exec, - Kind: string(tool.Kind), - Bound: bound, - }) - } - - return result -} - -func convertToolDetailsToView(selectedIndex int, tools *core.ToolsConfig) *dashboardviews.ToolDetails { - if tools == nil || selectedIndex < 0 || selectedIndex >= len(tools.Tools) { - return nil - } - - tool := tools.Tools[selectedIndex] - return &dashboardviews.ToolDetails{ - ID: tool.ID, - ConfigType: string(tool.ConfigType), - ConfigPath: tool.ConfigPath, - Description: tool.Description, - } -} - -func viewName(view viewMode) string { - switch view { - case viewDashboard: - return "Dashboard" - case viewProviders: - return "Providers" - case viewTools: - return "Tools" - case viewBindings: - return "Bindings" - case viewSecrets: - return "Secrets" - case viewStats: - return "Usage Stats" - case viewProxy: - return "Proxy" - case viewRouting: - return "Routing Tester" - case viewSuggestions: - return "Suggestions" - case viewReports: - return "Reports" - case viewHooks: - return "Hooks" - case viewConfig: - return "Config Editor" - case viewHelp: - return "Help" - default: - return "View" - } -} - -// RunDashboard starts the dashboard TUI -func RunDashboard(home string) error { - dashboard, err := NewDashboard(home) - if err != nil { - return fmt.Errorf("failed to create dashboard: %w", err) - } - - p := tea.NewProgram(dashboard, tea.WithAltScreen()) - _, err = p.Run() - return err -} diff --git a/internal/ui/dashboard/views/config_view.go b/internal/ui/dashboard/views/config_view.go deleted file mode 100644 index ccf42ff..0000000 --- a/internal/ui/dashboard/views/config_view.go +++ /dev/null @@ -1,95 +0,0 @@ -package views - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss" -) - -// ConfigFile describes a single editable configuration file. -type ConfigFile struct { - Name string - File string - Desc string -} - -// ConfigViewProps carries the data required to render the configuration view. -type ConfigViewProps struct { - Theme ThemePalette - SelectedIndex int - ConfigFiles []ConfigFile - Home string - HelpTextNavigation string -} - -// RenderConfigView renders the configuration editor view. -func RenderConfigView(props ConfigViewProps) string { - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Primary).Padding(0, 2) - headerStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Success).Padding(1, 2) - normalStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Padding(0, 2) - selectedStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Background(props.Theme.Primary).Bold(true).Padding(0, 1) - mutedStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(0, 2) - helpStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(1, 2) - - var content strings.Builder - - // Header - content.WriteString(titleStyle.Render("⚙️ Configuration Editor")) - content.WriteString("\n\n") - - content.WriteString(headerStyle.Render("Configuration Files")) - content.WriteString("\n") - - selectedIndex := props.SelectedIndex - if selectedIndex >= len(props.ConfigFiles) { - selectedIndex = 0 - } - - for i, cfg := range props.ConfigFiles { - line := fmt.Sprintf(" %s", cfg.Name) - filePath := lipgloss.NewStyle().Foreground(props.Theme.Muted).Render(fmt.Sprintf(" (%s)", cfg.File)) - - if i == selectedIndex { - content.WriteString(selectedStyle.Render("▶ " + line)) - content.WriteString(filePath) - } else { - content.WriteString(normalStyle.Render(" " + line)) - content.WriteString(filePath) - } - content.WriteString("\n") - - // Show description for selected item - if i == selectedIndex { - content.WriteString(mutedStyle.Render(fmt.Sprintf(" %s", cfg.Desc))) - content.WriteString("\n") - content.WriteString(mutedStyle.Render(fmt.Sprintf(" Full path: %s/%s", props.Home, cfg.File))) - content.WriteString("\n") - } - } - - content.WriteString("\n") - content.WriteString(headerStyle.Render("Editor Settings")) - content.WriteString("\n") - - editor := "vim" // Default, in real implementation check $EDITOR - content.WriteString(normalStyle.Render(fmt.Sprintf(" Editor: $EDITOR (%s)", editor))) - content.WriteString("\n") - content.WriteString(mutedStyle.Render(" Tip: Set $EDITOR environment variable to use your preferred editor")) - content.WriteString("\n\n") - - content.WriteString(headerStyle.Render("Safety Features")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • Automatic backup before editing")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • YAML syntax validation after save")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • Rollback support if validation fails")) - content.WriteString("\n\n") - - // Footer/Help - helpText := props.HelpTextNavigation + "\n Use CLI: boba edit (to open in editor)" - content.WriteString(helpStyle.Render(helpText)) - - return content.String() -} diff --git a/internal/ui/dashboard/views/dashboard_view.go b/internal/ui/dashboard/views/dashboard_view.go deleted file mode 100644 index 55844d4..0000000 --- a/internal/ui/dashboard/views/dashboard_view.go +++ /dev/null @@ -1,51 +0,0 @@ -package views - -import ( - "strings" - - "github.com/charmbracelet/lipgloss" -) - -// DashboardViewProps contains the information rendered in the dashboard view. -type DashboardViewProps struct { - Theme ThemePalette - TableView string - Message string - ProxyIcon string - ProxyStatus string - NavigationHelp string - HelpCommands string -} - -// RenderDashboardView renders the high-level dashboard summary. -func RenderDashboardView(props DashboardViewProps) string { - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Primary).Padding(0, 2) - proxyStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Padding(0, 2) - messageStyle := lipgloss.NewStyle().Foreground(props.Theme.Success).Padding(0, 2) - helpStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(1, 2) - - var content strings.Builder - - content.WriteString(titleStyle.Render("BobaMixer - AI CLI Control Plane")) - content.WriteString("\n") - - statusLine := proxyStyle.Render(" Proxy: " + props.ProxyIcon + " " + props.ProxyStatus) - content.WriteString(statusLine) - content.WriteString("\n\n") - - content.WriteString(props.TableView) - content.WriteString("\n") - - if msg := strings.TrimSpace(props.Message); msg != "" { - content.WriteString(messageStyle.Render(" " + msg)) - content.WriteString("\n") - } - - helpText := props.NavigationHelp - if strings.TrimSpace(props.HelpCommands) != "" { - helpText += " " + props.HelpCommands - } - content.WriteString(helpStyle.Render(helpText)) - - return content.String() -} diff --git a/internal/ui/dashboard/views/help_view.go b/internal/ui/dashboard/views/help_view.go deleted file mode 100644 index 2d198ab..0000000 --- a/internal/ui/dashboard/views/help_view.go +++ /dev/null @@ -1,113 +0,0 @@ -package views - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss" -) - -// HelpSection describes a high-level dashboard section. -type HelpSection struct { - Name string - Shortcut string - Views []string -} - -// HelpViewProps contains the data necessary to render the help view. -type HelpViewProps struct { - Theme ThemePalette - Sections []HelpSection - NavigationHelp string - ShowcaseShortcuts []Shortcut -} - -// Shortcut describes a keybinding and its behavior. -type Shortcut struct { - Key string - Desc string -} - -// RenderHelpView renders the help overlay content. -func RenderHelpView(props HelpViewProps) string { - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Primary).Padding(0, 2) - headerStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Success).Padding(1, 2) - normalStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Padding(0, 2) - keyStyle := lipgloss.NewStyle().Foreground(props.Theme.Primary).Bold(true) - helpStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(1, 2) - - var content strings.Builder - - // Header - content.WriteString(titleStyle.Render("❓ BobaMixer Help & Shortcuts")) - content.WriteString("\n\n") - - // Navigation - content.WriteString(headerStyle.Render("Section Navigation")) - content.WriteString("\n") - for _, section := range props.Sections { - content.WriteString(normalStyle.Render(" ")) - content.WriteString(keyStyle.Render(fmt.Sprintf("[%s]", section.Shortcut))) - content.WriteString(normalStyle.Render(fmt.Sprintf(" %s → %s", section.Name, strings.Join(section.Views, ", ")))) - content.WriteString("\n") - } - content.WriteString(normalStyle.Render(" ")) - content.WriteString(keyStyle.Render("[?]")) - content.WriteString(normalStyle.Render(" Toggle this help overlay")) - content.WriteString("\n") - - content.WriteString("\n") - content.WriteString(headerStyle.Render("Global Shortcuts")) - content.WriteString("\n") - - shortcuts := defaultShortcuts() - if len(props.ShowcaseShortcuts) > 0 { - shortcuts = props.ShowcaseShortcuts - } - - for _, sc := range shortcuts { - content.WriteString(normalStyle.Render(" ")) - content.WriteString(keyStyle.Render(fmt.Sprintf("[%s]", sc.Key))) - content.WriteString(normalStyle.Render(fmt.Sprintf(" %s", sc.Desc))) - content.WriteString("\n") - } - - content.WriteString("\n") - content.WriteString(headerStyle.Render("Quick Tips")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • Use number keys (1-5) to jump between sections")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • All interactive features are in the TUI")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • CLI commands available for automation")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • Press ? anytime to toggle this help overlay")) - content.WriteString("\n\n") - - content.WriteString(headerStyle.Render("Documentation")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" Full docs: https://royisme.github.io/BobaMixer/")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" GitHub: https://github.com/royisme/BobaMixer")) - content.WriteString("\n\n") - - // Footer/Help - helpText := "Press Esc to close this overlay | " + props.NavigationHelp - content.WriteString(helpStyle.Render(helpText)) - - return content.String() -} - -func defaultShortcuts() []Shortcut { - return []Shortcut{ - {"Tab / Shift+Tab", "Cycle sections"}, - {"[ / ]", "Cycle views within a section"}, - {"↑/↓ or k/j", "Navigate in lists"}, - {"/", "Search within supported lists"}, - {"Esc", "Clear search / close dialogs"}, - {"R", "Run selected tool (Dashboard view)"}, - {"X", "Toggle proxy (Dashboard view)"}, - {"S", "Refresh proxy status (Proxy view)"}, - {"Q or Ctrl+C", "Quit BobaMixer"}, - } -} diff --git a/internal/ui/dashboard/views/hooks_view.go b/internal/ui/dashboard/views/hooks_view.go deleted file mode 100644 index c4bf48a..0000000 --- a/internal/ui/dashboard/views/hooks_view.go +++ /dev/null @@ -1,99 +0,0 @@ -package views - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss" -) - -// HookInfo represents a supported git hook entry. -type HookInfo struct { - Name string - Desc string - Active bool -} - -// HooksViewProps contains the data necessary to render the git hooks view. -type HooksViewProps struct { - Theme ThemePalette - RepoPath string - HooksInstalled bool - Hooks []HookInfo - NavigationHelp string - CommandHelpLine string - ActiveIcon string - InactiveIcon string -} - -// RenderHooksView renders the git hooks management view. -func RenderHooksView(props HooksViewProps) string { - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Primary).Padding(0, 2) - headerStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Success).Padding(1, 2) - normalStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Padding(0, 2) - successStyle := lipgloss.NewStyle().Foreground(props.Theme.Success).Padding(0, 2) - dangerStyle := lipgloss.NewStyle().Foreground(props.Theme.Danger).Padding(0, 2) - helpStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(1, 2) - - var content strings.Builder - - // Header - content.WriteString(titleStyle.Render("🪝 Git Hooks Management")) - content.WriteString("\n\n") - - // Repository detection - content.WriteString(headerStyle.Render("Current Repository")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" Path: %s", props.RepoPath))) - content.WriteString("\n") - - if props.HooksInstalled { - content.WriteString(successStyle.Render(" Status: ✓ Hooks Installed")) - } else { - content.WriteString(dangerStyle.Render(" Status: ✗ Hooks Not Installed")) - } - content.WriteString("\n\n") - - content.WriteString(headerStyle.Render("Available Hooks")) - content.WriteString("\n") - - for _, hook := range props.Hooks { - statusStyle := dangerStyle - statusIcon := props.InactiveIcon - if hook.Active { - statusStyle = successStyle - statusIcon = props.ActiveIcon - } - - content.WriteString(normalStyle.Render(fmt.Sprintf(" %s", hook.Name))) - content.WriteString(statusStyle.Render(fmt.Sprintf(" %s", statusIcon))) - content.WriteString("\n") - content.WriteString(lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(0, 4).Render(fmt.Sprintf(" → %s", hook.Desc))) - content.WriteString("\n") - } - - content.WriteString("\n") - content.WriteString(headerStyle.Render("Benefits")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • Automatic profile suggestions based on branch/project")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • Track repository events for better usage analytics")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • Context-aware AI model selection")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • Zero-overhead tracking (async logging)")) - content.WriteString("\n\n") - - content.WriteString(headerStyle.Render("Recent Hook Activity")) - content.WriteString("\n") - content.WriteString(lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(0, 2).Render(" No recent activity recorded")) - content.WriteString("\n\n") - - helpText := props.NavigationHelp - if props.CommandHelpLine != "" { - helpText += "\n" + props.CommandHelpLine - } - content.WriteString(helpStyle.Render(helpText)) - - return content.String() -} diff --git a/internal/ui/dashboard/views/providers_view.go b/internal/ui/dashboard/views/providers_view.go deleted file mode 100644 index ef98ff0..0000000 --- a/internal/ui/dashboard/views/providers_view.go +++ /dev/null @@ -1,140 +0,0 @@ -package views - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss" -) - -// ProviderRow represents a compact provider entry for the list. -type ProviderRow struct { - DisplayName string - BaseURL string - DefaultModel string - Enabled bool - HasAPIKey bool -} - -// ProviderDetails captures the selected provider metadata. -type ProviderDetails struct { - ID string - Kind string - APIKeySource string - EnvVar string - ShowEnvVar bool -} - -// ProvidersViewProps carries all data required for rendering the providers view. -type ProvidersViewProps struct { - Theme ThemePalette - ProviderForm string - ShowProviderForm bool - ProviderFormMessage string - SearchBar string - EmptyStateMessage string - Providers []ProviderRow - SelectedIndex int - Details *ProviderDetails - NavigationHelp string - HelpCommands string - EnabledIcon string - DisabledIcon string - KeyPresentIcon string - KeyMissingIcon string -} - -// RenderProvidersView renders the providers management UI. -func RenderProvidersView(props ProvidersViewProps) string { - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Primary).Padding(0, 2) - headerStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Success).Padding(1, 2) - selectedStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Background(props.Theme.Primary).Bold(true).Padding(0, 1) - normalStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Padding(0, 1) - mutedStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(0, 1) - helpStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(1, 2) - - var content strings.Builder - content.WriteString(titleStyle.Render("BobaMixer - AI Providers Management")) - content.WriteString("\n\n") - - content.WriteString(headerStyle.Render("📡 Available Providers")) - content.WriteString("\n\n") - - if props.ShowProviderForm && strings.TrimSpace(props.ProviderForm) != "" { - content.WriteString(props.ProviderForm) - content.WriteString("\n\n") - } else if msg := strings.TrimSpace(props.ProviderFormMessage); msg != "" { - content.WriteString(mutedStyle.Render(" " + msg)) - content.WriteString("\n\n") - } - - if bar := strings.TrimSpace(props.SearchBar); bar != "" { - content.WriteString(bar) - content.WriteString("\n\n") - } - - rows := props.Providers - if len(rows) == 0 { - if msg := strings.TrimSpace(props.EmptyStateMessage); msg != "" { - content.WriteString(mutedStyle.Render(" " + msg)) - content.WriteString("\n") - } - } else { - selectedIndex := props.SelectedIndex - if selectedIndex >= len(rows) { - selectedIndex = len(rows) - 1 - } - - for idx, row := range rows { - enabledIcon := props.EnabledIcon - if !row.Enabled { - enabledIcon = props.DisabledIcon - } - - keyIcon := props.KeyMissingIcon - if row.HasAPIKey { - keyIcon = props.KeyPresentIcon - } - - line := fmt.Sprintf(" %s %s %-25s %-35s %s", - enabledIcon, - keyIcon, - row.DisplayName, - row.BaseURL, - row.DefaultModel, - ) - - if idx == selectedIndex { - content.WriteString(selectedStyle.Render("▶ " + line)) - } else { - content.WriteString(normalStyle.Render(" " + line)) - } - content.WriteString("\n") - } - } - - content.WriteString("\n") - if props.Details != nil { - content.WriteString(headerStyle.Render("Details")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" ID: %s", props.Details.ID))) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" Kind: %s", props.Details.Kind))) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" API Key Source: %s", props.Details.APIKeySource))) - content.WriteString("\n") - if props.Details.ShowEnvVar { - content.WriteString(normalStyle.Render(fmt.Sprintf(" Env Var: %s", props.Details.EnvVar))) - content.WriteString("\n") - } - content.WriteString("\n") - } - - helpText := props.NavigationHelp - if cmds := strings.TrimSpace(props.HelpCommands); cmds != "" { - helpText += " " + cmds - } - content.WriteString(helpStyle.Render(helpText)) - - return content.String() -} diff --git a/internal/ui/dashboard/views/proxy_view.go b/internal/ui/dashboard/views/proxy_view.go deleted file mode 100644 index a3720a2..0000000 --- a/internal/ui/dashboard/views/proxy_view.go +++ /dev/null @@ -1,85 +0,0 @@ -package views - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss" -) - -// ProxyViewProps describes the proxy status view. -type ProxyViewProps struct { - Theme ThemePalette - StatusState string - StatusText string - StatusIcon string - Address string - ShowConfig bool - NavigationHelp string - AdditionalNote string - CommandHelpLine string -} - -// RenderProxyView renders the proxy server control view. -func RenderProxyView(props ProxyViewProps) string { - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Primary).Padding(0, 2) - headerStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Success).Padding(1, 2) - normalStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Padding(0, 1) - successStyle := lipgloss.NewStyle().Foreground(props.Theme.Success).Padding(0, 1) - dangerStyle := lipgloss.NewStyle().Foreground(props.Theme.Danger).Padding(0, 1) - helpStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(1, 2) - - var content strings.Builder - - content.WriteString(titleStyle.Render("BobaMixer - Proxy Server Control")) - content.WriteString("\n\n") - - content.WriteString(headerStyle.Render("🌐 Proxy Status")) - content.WriteString("\n\n") - - var statusStyle lipgloss.Style - switch props.StatusState { - case "running": - statusStyle = successStyle - case "stopped": - statusStyle = dangerStyle - default: - statusStyle = normalStyle - } - - statusLine := fmt.Sprintf(" Status: %s", statusStyle.Render(props.StatusIcon+" "+props.StatusText)) - content.WriteString(normalStyle.Render(statusLine)) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" Address: %s", props.Address))) - content.WriteString("\n\n") - - content.WriteString(headerStyle.Render("ℹ️ Information")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" The proxy server intercepts AI API requests from CLI tools")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" and routes them through BobaMixer for tracking and control.")) - content.WriteString("\n\n") - - if props.ShowConfig { - content.WriteString(headerStyle.Render("📝 Configuration")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" Tools with proxy enabled will automatically use:")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" • HTTP_PROXY=%s", props.Address))) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" • HTTPS_PROXY=%s", props.Address))) - content.WriteString("\n\n") - } - - helpLines := []string{props.NavigationHelp} - if props.CommandHelpLine != "" { - helpLines = append(helpLines, props.CommandHelpLine) - } - if props.AdditionalNote != "" { - helpLines = append(helpLines, props.AdditionalNote) - } - - content.WriteString(helpStyle.Render(strings.Join(helpLines, "\n"))) - - return content.String() -} diff --git a/internal/ui/dashboard/views/reports_view.go b/internal/ui/dashboard/views/reports_view.go deleted file mode 100644 index 2e78a2c..0000000 --- a/internal/ui/dashboard/views/reports_view.go +++ /dev/null @@ -1,91 +0,0 @@ -package views - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss" -) - -// ReportOption describes a report configuration entry. -type ReportOption struct { - Label string - Desc string -} - -// ReportsViewProps carries the data required to render the reports view. -type ReportsViewProps struct { - Theme ThemePalette - Options []ReportOption - SelectedIndex int - Home string - NavigationHelp string - CommandHelpLine string -} - -// RenderReportsView renders the usage reports view. -func RenderReportsView(props ReportsViewProps) string { - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Primary).Padding(0, 2) - headerStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Success).Padding(1, 2) - normalStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Padding(0, 2) - selectedStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Background(props.Theme.Primary).Bold(true).Padding(0, 1) - helpStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(1, 2) - - var content strings.Builder - - // Header - content.WriteString(titleStyle.Render("📊 Generate Usage Report")) - content.WriteString("\n\n") - - selectedIndex := props.SelectedIndex - if len(props.Options) > 0 && selectedIndex >= len(props.Options) { - selectedIndex = 0 - } - - content.WriteString(headerStyle.Render("Report Options")) - content.WriteString("\n") - - for i, opt := range props.Options { - line := fmt.Sprintf(" %s", opt.Label) - if i == selectedIndex { - content.WriteString(selectedStyle.Render("▶ " + line)) - } else { - content.WriteString(normalStyle.Render(" " + line)) - } - content.WriteString("\n") - - if i == selectedIndex { - content.WriteString(lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(0, 4).Render(" → " + opt.Desc)) - content.WriteString("\n") - } - } - - content.WriteString("\n") - content.WriteString(headerStyle.Render("Output Configuration")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" Default path: %s/reports/", props.Home))) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" Filename: bobamixer-.")) - content.WriteString("\n\n") - - content.WriteString(headerStyle.Render("Report Contents")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" ✓ Summary statistics (tokens, costs, sessions)")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" ✓ Daily trends and usage patterns")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" ✓ Profile breakdown and comparison")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" ✓ Cost analysis and optimization opportunities")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" ✓ Peak usage times and anomalies")) - content.WriteString("\n\n") - - helpText := props.NavigationHelp - if props.CommandHelpLine != "" { - helpText += "\n" + props.CommandHelpLine - } - content.WriteString(helpStyle.Render(helpText)) - - return content.String() -} diff --git a/internal/ui/dashboard/views/routing_view.go b/internal/ui/dashboard/views/routing_view.go deleted file mode 100644 index 321ff36..0000000 --- a/internal/ui/dashboard/views/routing_view.go +++ /dev/null @@ -1,74 +0,0 @@ -package views - -import ( - "strings" - - "github.com/charmbracelet/lipgloss" -) - -// RoutingViewProps contains static text fragments for the routing view. -type RoutingViewProps struct { - Theme ThemePalette - NavigationHelp string - CommandHelpLine string -} - -// RenderRoutingView renders the routing tester information view. -func RenderRoutingView(props RoutingViewProps) string { - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Primary).Padding(0, 2) - headerStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Success).Padding(1, 2) - normalStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Padding(0, 1) - mutedStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(0, 1) - helpStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(1, 2) - - var content strings.Builder - - content.WriteString(titleStyle.Render("BobaMixer - Routing Rules Tester")) - content.WriteString("\n\n") - - content.WriteString(headerStyle.Render("🧪 Test Routing Rules")) - content.WriteString("\n") - content.WriteString(mutedStyle.Render(" Test how routing rules would apply to different queries.")) - content.WriteString("\n\n") - - content.WriteString(headerStyle.Render("💡 How to Use")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" 1. Prepare a test query (text or file)")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" 2. Run: boba route test \"your query text\"")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" 3. Or: boba route test @path/to/file.txt")) - content.WriteString("\n\n") - - content.WriteString(headerStyle.Render("📋 Example")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" $ boba route test \"Write a Python function\"")) - content.WriteString("\n") - content.WriteString(mutedStyle.Render(" → Profile: claude-sonnet-3.5")) - content.WriteString("\n") - content.WriteString(mutedStyle.Render(" → Rule: short-query-fast-model")) - content.WriteString("\n") - content.WriteString(mutedStyle.Render(" → Reason: Query < 100 chars")) - content.WriteString("\n\n") - - content.WriteString(headerStyle.Render("ℹ️ Context Detection")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" Routing considers:")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • Query length and complexity")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • Current project and branch")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • Time of day (day/evening/night)")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(" • Project type (go, web, etc.)")) - content.WriteString("\n\n") - - helpText := props.NavigationHelp - if props.CommandHelpLine != "" { - helpText += "\n" + props.CommandHelpLine - } - content.WriteString(helpStyle.Render(helpText)) - - return content.String() -} diff --git a/internal/ui/dashboard/views/secrets_view.go b/internal/ui/dashboard/views/secrets_view.go deleted file mode 100644 index 40844b6..0000000 --- a/internal/ui/dashboard/views/secrets_view.go +++ /dev/null @@ -1,118 +0,0 @@ -package views - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss" -) - -// SecretProviderRow represents a provider and its key status. -type SecretProviderRow struct { - DisplayName string - HasKey bool - KeySource string -} - -// SecretsViewProps carries the data required for the secrets view. -type SecretsViewProps struct { - Theme ThemePalette - SecretForm string - ShowSecretForm bool - SearchBar string - EmptyStateMessage string - Providers []SecretProviderRow - SelectedIndex int - SecretMessage string - NavigationHelp string - HelpCommands string - SuccessIcon string - FailureIcon string -} - -// RenderSecretsView renders the secrets management view. -func RenderSecretsView(props SecretsViewProps) string { - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Primary).Padding(0, 2) - headerStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Success).Padding(1, 2) - selectedStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Background(props.Theme.Primary).Bold(true).Padding(0, 1) - normalStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Padding(0, 1) - mutedStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(0, 1) - successStyle := lipgloss.NewStyle().Foreground(props.Theme.Success).Padding(0, 1) - dangerStyle := lipgloss.NewStyle().Foreground(props.Theme.Danger).Padding(0, 1) - helpStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(1, 2) - - var content strings.Builder - - content.WriteString(titleStyle.Render("BobaMixer - Secrets Management (API Keys)")) - content.WriteString("\n\n") - content.WriteString(headerStyle.Render("🔒 API Key Status")) - content.WriteString("\n\n") - - if props.ShowSecretForm && props.SecretForm != "" { - content.WriteString(props.SecretForm) - content.WriteString("\n\n") - } - - if props.SearchBar != "" { - content.WriteString(props.SearchBar) - content.WriteString("\n\n") - } - - if len(props.Providers) == 0 { - content.WriteString(mutedStyle.Render(" " + props.EmptyStateMessage)) - content.WriteString("\n\n") - } else { - index := props.SelectedIndex - if index >= len(props.Providers) { - index = len(props.Providers) - 1 - } else if index < 0 { - index = 0 - } - - for i, row := range props.Providers { - statusText := "Missing" - statusIcon := props.FailureIcon - statusStyle := dangerStyle - if row.HasKey { - statusText = "Configured" - statusIcon = props.SuccessIcon - statusStyle = successStyle - } - - namePart := fmt.Sprintf(" %-25s ", row.DisplayName) - statusPart := fmt.Sprintf("%s %-15s [%s]", statusIcon, statusText, row.KeySource) - if i == index { - line := fmt.Sprintf("%s%s", namePart, statusPart) - content.WriteString(selectedStyle.Render("▶ " + line)) - } else { - content.WriteString(normalStyle.Render(namePart)) - content.WriteString(statusStyle.Render(statusPart)) - } - content.WriteString("\n") - } - content.WriteString("\n") - } - - content.WriteString(headerStyle.Render("🔐 Security")) - content.WriteString("\n") - content.WriteString(mutedStyle.Render(" • API keys are stored encrypted in ~/.boba/secrets.yaml")) - content.WriteString("\n") - content.WriteString(mutedStyle.Render(" • Keys can also be loaded from environment variables")) - content.WriteString("\n") - content.WriteString(mutedStyle.Render(" • Use 'boba edit secrets' to manage keys manually")) - content.WriteString("\n\n") - - if msg := strings.TrimSpace(props.SecretMessage); msg != "" { - content.WriteString(normalStyle.Render(" " + msg)) - content.WriteString("\n\n") - } - - helpLines := []string{props.NavigationHelp} - if props.HelpCommands != "" { - helpLines = append(helpLines, props.HelpCommands) - } - - content.WriteString(helpStyle.Render(strings.Join(helpLines, " "))) - - return content.String() -} diff --git a/internal/ui/dashboard/views/stats_view.go b/internal/ui/dashboard/views/stats_view.go deleted file mode 100644 index d80facc..0000000 --- a/internal/ui/dashboard/views/stats_view.go +++ /dev/null @@ -1,121 +0,0 @@ -package views - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss" -) - -// StatsSummary represents aggregate statistics for a period. -type StatsSummary struct { - Title string - Tokens int - Cost float64 - Sessions int - AvgDailyTokens float64 - AvgDailyCost float64 - ShowAverages bool - DisplayCurrency bool -} - -// StatsProfile represents a per-profile stats entry. -type StatsProfile struct { - Name string - Tokens int - Cost float64 - Sessions int - AvgLatency float64 - UsagePct float64 - CostPct float64 -} - -// StatsViewProps carries all data required for the stats screen. -type StatsViewProps struct { - Theme ThemePalette - Loaded bool - Error string - LoadingMessage string - Today StatsSummary - Week StatsSummary - Profiles []StatsProfile - NavigationHelp string - LoadingHelp string - ProfileSubtitle string -} - -// RenderStatsView renders the usage statistics page. -func RenderStatsView(props StatsViewProps) string { - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Primary).Padding(0, 2) - sectionStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Success).Padding(1, 2) - dataStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Padding(0, 2) - helpStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(1, 2) - errorStyle := lipgloss.NewStyle().Foreground(props.Theme.Danger).Padding(0, 2) - - var content strings.Builder - content.WriteString(titleStyle.Render("BobaMixer - Usage Statistics")) - content.WriteString("\n\n") - - if !props.Loaded { - if strings.TrimSpace(props.Error) != "" { - content.WriteString(errorStyle.Render(fmt.Sprintf("Error loading stats: %s", props.Error))) - } else { - content.WriteString(dataStyle.Render(props.LoadingMessage)) - } - content.WriteString("\n\n") - content.WriteString(helpStyle.Render(props.LoadingHelp)) - return content.String() - } - - renderSummary(&content, sectionStyle, dataStyle, props.Today) - content.WriteString("\n") - renderSummary(&content, sectionStyle, dataStyle, props.Week) - - if len(props.Profiles) > 0 { - content.WriteString("\n") - title := props.ProfileSubtitle - if strings.TrimSpace(title) == "" { - title = "🎯 By Profile (7d)" - } - content.WriteString(sectionStyle.Render(title)) - content.WriteString("\n") - for _, ps := range props.Profiles { - line := fmt.Sprintf(" • %s: tokens=%d cost=$%.4f sessions=%d latency=%.0fms usage=%.1f%% cost=%.1f%%", - ps.Name, - ps.Tokens, - ps.Cost, - ps.Sessions, - ps.AvgLatency, - ps.UsagePct, - ps.CostPct, - ) - content.WriteString(dataStyle.Render(line)) - content.WriteString("\n") - } - content.WriteString("\n") - } - - content.WriteString(helpStyle.Render(props.NavigationHelp)) - return content.String() -} - -func renderSummary(content *strings.Builder, sectionStyle, dataStyle lipgloss.Style, summary StatsSummary) { - if strings.TrimSpace(summary.Title) != "" { - content.WriteString(sectionStyle.Render(summary.Title)) - content.WriteString("\n") - } - - content.WriteString(dataStyle.Render(fmt.Sprintf(" Tokens: %d", summary.Tokens))) - content.WriteString("\n") - content.WriteString(dataStyle.Render(fmt.Sprintf(" Cost: $%.4f", summary.Cost))) - content.WriteString("\n") - content.WriteString(dataStyle.Render(fmt.Sprintf(" Sessions: %d", summary.Sessions))) - content.WriteString("\n") - - if summary.ShowAverages { - content.WriteString(dataStyle.Render(fmt.Sprintf(" Avg Daily Tokens: %.0f", summary.AvgDailyTokens))) - content.WriteString("\n") - content.WriteString(dataStyle.Render(fmt.Sprintf(" Avg Daily Cost: $%.4f", summary.AvgDailyCost))) - content.WriteString("\n") - } -} diff --git a/internal/ui/dashboard/views/suggestions_view.go b/internal/ui/dashboard/views/suggestions_view.go deleted file mode 100644 index 1b941b8..0000000 --- a/internal/ui/dashboard/views/suggestions_view.go +++ /dev/null @@ -1,136 +0,0 @@ -package views - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss" -) - -// Suggestion represents a view-friendly suggestion entry. -type Suggestion struct { - Title string - Description string - Impact string - ActionItems []string - Priority int - Type string -} - -// SuggestionsViewProps carries data necessary to render suggestions. -type SuggestionsViewProps struct { - Theme ThemePalette - Suggestions []Suggestion - SelectedIndex int - Error string - NavigationHelp string - CommandHelpLine string -} - -// RenderSuggestionsView renders the optimization suggestions view. -func RenderSuggestionsView(props SuggestionsViewProps) string { - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Primary).Padding(0, 2) - headerStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Success).Padding(1, 2) - selectedStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Background(props.Theme.Primary).Bold(true).Padding(0, 1) - normalStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Padding(0, 1) - mutedStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(0, 1) - warningStyle := lipgloss.NewStyle().Foreground(props.Theme.Warning).Padding(0, 1) - dangerStyle := lipgloss.NewStyle().Foreground(props.Theme.Danger).Padding(0, 1) - helpStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(1, 2) - - var content strings.Builder - - content.WriteString(titleStyle.Render("BobaMixer - Optimization Suggestions")) - content.WriteString("\n\n") - - if props.Error != "" { - content.WriteString(dangerStyle.Render(fmt.Sprintf(" Error: %s", props.Error))) - content.WriteString("\n\n") - content.WriteString(helpStyle.Render(props.NavigationHelp + " [R] Retry")) - return content.String() - } - - content.WriteString(headerStyle.Render("💡 Recommendations (Last 7 Days)")) - content.WriteString("\n\n") - - if len(props.Suggestions) == 0 { - content.WriteString(mutedStyle.Render(" ✓ No suggestions - your usage is optimized!")) - content.WriteString("\n\n") - } else { - index := props.SelectedIndex - if index >= len(props.Suggestions) { - index = 0 - } - - for i, sugg := range props.Suggestions { - priorityStyle, priorityIcon := priorityPresentation(sugg.Priority, normalStyle, warningStyle, dangerStyle, mutedStyle) - typeIcon := suggestionTypeIcon(sugg.Type) - line := fmt.Sprintf(" %s %s [P%d] %s", priorityIcon, typeIcon, sugg.Priority, sugg.Title) - if i == index { - content.WriteString(selectedStyle.Render("▶ " + line)) - } else { - content.WriteString(priorityStyle.Render(line)) - } - content.WriteString("\n") - } - - if len(props.Suggestions) > 0 { - sugg := props.Suggestions[index] - content.WriteString("\n") - content.WriteString(headerStyle.Render("Details")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" %s", sugg.Description))) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" Impact: %s", sugg.Impact))) - content.WriteString("\n\n") - - if len(sugg.ActionItems) > 0 { - content.WriteString(headerStyle.Render("Recommended Actions")) - content.WriteString("\n") - for idx, action := range sugg.ActionItems { - content.WriteString(normalStyle.Render(fmt.Sprintf(" %d. %s", idx+1, action))) - content.WriteString("\n") - } - } - } - } - - content.WriteString("\n") - helpText := props.NavigationHelp - if props.CommandHelpLine != "" { - helpText += "\n" + props.CommandHelpLine - } - content.WriteString(helpStyle.Render(helpText)) - - return content.String() -} - -func priorityPresentation(priority int, normalStyle, warningStyle, dangerStyle, mutedStyle lipgloss.Style) (lipgloss.Style, string) { - switch priority { - case 5: - return dangerStyle, "🔴" - case 4: - return warningStyle, "🟠" - case 3: - return normalStyle, "🟡" - default: - return mutedStyle, "🟢" - } -} - -func suggestionTypeIcon(t string) string { - switch t { - case "cost": - return "💰" - case "profile": - return "🔄" - case "budget": - return "📊" - case "anomaly": - return "⚠️" - case "usage": - return "📈" - default: - return "📈" - } -} diff --git a/internal/ui/dashboard/views/theme.go b/internal/ui/dashboard/views/theme.go deleted file mode 100644 index 80f6b4f..0000000 --- a/internal/ui/dashboard/views/theme.go +++ /dev/null @@ -1,13 +0,0 @@ -package views - -import "github.com/charmbracelet/lipgloss" - -// ThemePalette contains the minimal colors required by dashboard views. -type ThemePalette struct { - Primary lipgloss.AdaptiveColor - Success lipgloss.AdaptiveColor - Danger lipgloss.AdaptiveColor - Warning lipgloss.AdaptiveColor - Text lipgloss.AdaptiveColor - Muted lipgloss.AdaptiveColor -} diff --git a/internal/ui/dashboard/views/tools_view.go b/internal/ui/dashboard/views/tools_view.go deleted file mode 100644 index d7d0cc0..0000000 --- a/internal/ui/dashboard/views/tools_view.go +++ /dev/null @@ -1,120 +0,0 @@ -package views - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss" -) - -// ToolRow represents a compact tool entry. -type ToolRow struct { - Name string - Exec string - Kind string - Bound bool -} - -// ToolDetails contains metadata for the selected tool. -type ToolDetails struct { - ID string - ConfigType string - ConfigPath string - Description string -} - -// ToolsViewProps carries all data required to render the tools view. -type ToolsViewProps struct { - Theme ThemePalette - SearchBar string - EmptyStateMessage string - Tools []ToolRow - SelectedIndex int - Details *ToolDetails - NavigationHelp string - HelpCommands string - BoundIcon string - UnboundIcon string -} - -// RenderToolsView renders the CLI tools management UI. -func RenderToolsView(props ToolsViewProps) string { - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Primary).Padding(0, 2) - headerStyle := lipgloss.NewStyle().Bold(true).Foreground(props.Theme.Success).Padding(1, 2) - selectedStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Background(props.Theme.Primary).Bold(true).Padding(0, 1) - normalStyle := lipgloss.NewStyle().Foreground(props.Theme.Text).Padding(0, 1) - mutedStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(0, 1) - helpStyle := lipgloss.NewStyle().Foreground(props.Theme.Muted).Padding(1, 2) - - var content strings.Builder - - content.WriteString(titleStyle.Render("BobaMixer - CLI Tools Management")) - content.WriteString("\n\n") - - content.WriteString(headerStyle.Render("🛠 Detected Tools")) - content.WriteString("\n\n") - - if bar := strings.TrimSpace(props.SearchBar); bar != "" { - content.WriteString(bar) - content.WriteString("\n\n") - } - - rows := props.Tools - if len(rows) == 0 { - if msg := strings.TrimSpace(props.EmptyStateMessage); msg != "" { - content.WriteString(mutedStyle.Render(" " + msg)) - content.WriteString("\n") - } - } else { - selectedIndex := props.SelectedIndex - if selectedIndex >= len(rows) { - selectedIndex = len(rows) - 1 - } - - for idx, row := range rows { - icon := props.UnboundIcon - if row.Bound { - icon = props.BoundIcon - } - - line := fmt.Sprintf(" %s %-15s %-30s %s", - icon, - row.Name, - row.Exec, - row.Kind, - ) - - if idx == selectedIndex { - content.WriteString(selectedStyle.Render("▶ " + line)) - } else { - content.WriteString(normalStyle.Render(" " + line)) - } - content.WriteString("\n") - } - } - - content.WriteString("\n") - if props.Details != nil { - content.WriteString(headerStyle.Render("Details")) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" ID: %s", props.Details.ID))) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" Config Type: %s", props.Details.ConfigType))) - content.WriteString("\n") - content.WriteString(normalStyle.Render(fmt.Sprintf(" Config Path: %s", props.Details.ConfigPath))) - content.WriteString("\n") - if strings.TrimSpace(props.Details.Description) != "" { - content.WriteString(normalStyle.Render(fmt.Sprintf(" Description: %s", props.Details.Description))) - content.WriteString("\n") - } - content.WriteString("\n") - } - - helpText := props.NavigationHelp - if cmds := strings.TrimSpace(props.HelpCommands); cmds != "" { - helpText += " " + cmds - } - content.WriteString(helpStyle.Render(helpText)) - - return content.String() -} diff --git a/internal/ui/features/bindings/service.go b/internal/ui/features/bindings/service.go new file mode 100644 index 0000000..2adaf0e --- /dev/null +++ b/internal/ui/features/bindings/service.go @@ -0,0 +1,187 @@ +// Package bindings provides the service layer for bindings view data and logic. +package bindings + +import ( + "fmt" + + "github.com/royisme/bobamixer/internal/domain/core" + "github.com/royisme/bobamixer/internal/ui/components" + "github.com/royisme/bobamixer/internal/ui/forms" +) + +// Service encapsulates bindings-related UI logic so the root model can focus on orchestration. +type Service struct { + bindings *core.BindingsConfig + tools *core.ToolsConfig + providers *core.ProvidersConfig + form *forms.BindingForm + msgNoSelection string + msgInvalid string +} + +// NewService returns a helper that wires configs, form, and shared messages for bindings. +func NewService( + bindings *core.BindingsConfig, + tools *core.ToolsConfig, + providers *core.ProvidersConfig, + form *forms.BindingForm, + noSelectionMsg string, + invalidMsg string, +) *Service { + return &Service{ + bindings: bindings, + tools: tools, + providers: providers, + form: form, + msgNoSelection: noSelectionMsg, + msgInvalid: invalidMsg, + } +} + +// StartForm prepares the binding form for either creating or editing a binding. +func (s *Service) StartForm(add bool, indexes []int, selectedIndex int) bool { + if s.form == nil { + return false + } + + var ( + binding core.Binding + index = -1 + ) + + if add { + binding = core.Binding{ + UseProxy: true, + Options: core.BindingOptions{}, + } + } else { + if len(indexes) == 0 || selectedIndex < 0 || selectedIndex >= len(indexes) { + s.form.SetMessage(s.msgNoSelection) + return false + } + if s.bindings == nil { + s.form.SetMessage(s.msgInvalid) + return false + } + targetIdx := indexes[selectedIndex] + if targetIdx < 0 || targetIdx >= len(s.bindings.Bindings) { + s.form.SetMessage(s.msgInvalid) + return false + } + binding = s.bindings.Bindings[targetIdx] + index = targetIdx + } + + s.form.Start(add, binding, index, s.bindings, s.tools, s.providers) + s.form.SetMessage("") + return true +} + +// Save persists the binding currently captured by the form. +func (s *Service) Save(home string) error { + if s.form == nil || s.bindings == nil { + return fmt.Errorf("binding service not initialized") + } + + binding := s.form.Binding() + index := s.form.Index() + + if binding.ToolID == "" { + s.form.SetMessage("tool id is required") + return fmt.Errorf("tool id is required") + } + if binding.ProviderID == "" { + s.form.SetMessage("provider id is required") + return fmt.Errorf("provider id is required") + } + + if s.form.AddMode() { + s.bindings.Bindings = append(s.bindings.Bindings, binding) + } else if index >= 0 && index < len(s.bindings.Bindings) { + s.bindings.Bindings[index] = binding + } else { + s.form.SetMessage(s.msgInvalid) + return fmt.Errorf("invalid binding index") + } + + if err := core.SaveBindings(home, s.bindings); err != nil { + s.form.SetMessage(fmt.Sprintf("failed to save binding: %v", err)) + return err + } + + if s.form.AddMode() { + s.form.SetMessage(fmt.Sprintf("binding created for %s", binding.ToolID)) + } else { + s.form.SetMessage(fmt.Sprintf("binding updated for %s", binding.ToolID)) + } + + return nil +} + +// Rows converts filtered binding indexes into rows for the bindings page. +func (s *Service) Rows(indexes []int) []components.BindingRow { + if s.bindings == nil || len(indexes) == 0 { + return nil + } + + result := make([]components.BindingRow, 0, len(indexes)) + for _, idx := range indexes { + if idx < 0 || idx >= len(s.bindings.Bindings) { + continue + } + + b := s.bindings.Bindings[idx] + toolName := b.ToolID + if s.tools != nil { + if tool, err := s.tools.FindTool(b.ToolID); err == nil && tool.Name != "" { + toolName = tool.Name + } + } + + providerName := b.ProviderID + if s.providers != nil { + if provider, err := s.providers.FindProvider(b.ProviderID); err == nil && provider.DisplayName != "" { + providerName = provider.DisplayName + } + } + + result = append(result, components.BindingRow{ + ToolName: toolName, + ProviderName: providerName, + UseProxy: b.UseProxy, + }) + } + + return result +} + +// Details returns the sidebar details for the selected binding. +func (s *Service) Details(indexes []int, selectedIndex int) *components.BindingDetails { + if s.bindings == nil || len(indexes) == 0 || selectedIndex < 0 || selectedIndex >= len(indexes) { + return nil + } + + bindingIdx := indexes[selectedIndex] + if bindingIdx < 0 || bindingIdx >= len(s.bindings.Bindings) { + return nil + } + + b := s.bindings.Bindings[bindingIdx] + return &components.BindingDetails{ + ToolID: b.ToolID, + ProviderID: b.ProviderID, + UseProxy: b.UseProxy, + ModelOverride: b.Options.Model, + } +} + +// EmptyStateMessage builds the empty-state text for the bindings table. +func (s *Service) EmptyStateMessage(isEmpty bool, hasSearch bool) string { + if !isEmpty { + return "" + } + if hasSearch { + return "No bindings match the current filter." + } + return "No bindings configured." +} diff --git a/internal/ui/features/bindings/service_test.go b/internal/ui/features/bindings/service_test.go new file mode 100644 index 0000000..812a6e7 --- /dev/null +++ b/internal/ui/features/bindings/service_test.go @@ -0,0 +1,364 @@ +package bindings + +import ( + "testing" + + "github.com/royisme/bobamixer/internal/domain/core" + "github.com/royisme/bobamixer/internal/ui/forms" +) + +func TestNewService(t *testing.T) { + bindings := &core.BindingsConfig{} + tools := &core.ToolsConfig{} + providers := &core.ProvidersConfig{} + form := &forms.BindingForm{} + msgNoSelection := "no selection" + msgInvalid := "invalid" + + svc := NewService(bindings, tools, providers, form, msgNoSelection, msgInvalid) + + if svc == nil { + t.Fatal("expected service to be created") + } + if svc.bindings != bindings { + t.Error("bindings not set correctly") + } + if svc.tools != tools { + t.Error("tools not set correctly") + } + if svc.providers != providers { + t.Error("providers not set correctly") + } + if svc.form != form { + t.Error("form not set correctly") + } + if svc.msgNoSelection != msgNoSelection { + t.Error("msgNoSelection not set correctly") + } + if svc.msgInvalid != msgInvalid { + t.Error("msgInvalid not set correctly") + } +} + +func TestStartForm_NilForm(t *testing.T) { + bindings := &core.BindingsConfig{} + tools := &core.ToolsConfig{} + providers := &core.ProvidersConfig{} + svc := NewService(bindings, tools, providers, nil, "no selection", "invalid") + + result := svc.StartForm(true, []int{}, 0) + + if result { + t.Error("StartForm should return false when form is nil") + } +} + +func TestRows_Success(t *testing.T) { + bindings := &core.BindingsConfig{ + Bindings: []core.Binding{ + { + ToolID: "tool1", + ProviderID: "provider1", + UseProxy: true, + Options: core.BindingOptions{ + Model: "gpt-4", + }, + }, + { + ToolID: "tool2", + ProviderID: "provider2", + UseProxy: false, + Options: core.BindingOptions{ + Model: "claude-3", + }, + }, + }, + } + tools := &core.ToolsConfig{ + Tools: []core.Tool{ + {ID: "tool1", Name: "Tool 1"}, + {ID: "tool2", Name: "Tool 2"}, + }, + } + providers := &core.ProvidersConfig{ + Providers: []core.Provider{ + {ID: "provider1", DisplayName: "Provider 1"}, + {ID: "provider2", DisplayName: "Provider 2"}, + }, + } + svc := NewService(bindings, tools, providers, nil, "", "") + + indexes := []int{0, 1} + rows := svc.Rows(indexes) + + if len(rows) != 2 { + t.Fatalf("Expected 2 rows, got %d", len(rows)) + } + + // Check first row + if rows[0].ToolName != "Tool 1" { + t.Errorf("Row 0 ToolName: got %q, want %q", rows[0].ToolName, "Tool 1") + } + if rows[0].ProviderName != "Provider 1" { + t.Errorf("Row 0 ProviderName: got %q, want %q", rows[0].ProviderName, "Provider 1") + } + if !rows[0].UseProxy { + t.Error("Row 0 UseProxy should be true") + } + + // Check second row + if rows[1].ToolName != "Tool 2" { + t.Errorf("Row 1 ToolName: got %q, want %q", rows[1].ToolName, "Tool 2") + } + if rows[1].UseProxy { + t.Error("Row 1 UseProxy should be false") + } +} + +func TestRows_NilBindings(t *testing.T) { + svc := NewService(nil, &core.ToolsConfig{}, &core.ProvidersConfig{}, nil, "", "") + + indexes := []int{0} + rows := svc.Rows(indexes) + + if rows != nil { + t.Error("Rows should return nil when bindings is nil") + } +} + +func TestRows_EmptyIndexes(t *testing.T) { + bindings := &core.BindingsConfig{ + Bindings: []core.Binding{ + {ToolID: "tool1", ProviderID: "provider1"}, + }, + } + svc := NewService(bindings, &core.ToolsConfig{}, &core.ProvidersConfig{}, nil, "", "") + + rows := svc.Rows([]int{}) + + if rows != nil { + t.Error("Rows should return nil when indexes is empty") + } +} + +func TestRows_InvalidIndex(t *testing.T) { + bindings := &core.BindingsConfig{ + Bindings: []core.Binding{ + {ToolID: "tool1", ProviderID: "provider1"}, + }, + } + tools := &core.ToolsConfig{ + Tools: []core.Tool{ + {ID: "tool1", Name: "Tool 1"}, + }, + } + providers := &core.ProvidersConfig{ + Providers: []core.Provider{ + {ID: "provider1", DisplayName: "Provider 1"}, + }, + } + svc := NewService(bindings, tools, providers, nil, "", "") + + indexes := []int{0, 99, -1} + rows := svc.Rows(indexes) + + // Should only return valid row + if len(rows) != 1 { + t.Fatalf("Expected 1 row (skipping invalid indexes), got %d", len(rows)) + } +} + +func TestRows_ToolNotFound(t *testing.T) { + bindings := &core.BindingsConfig{ + Bindings: []core.Binding{ + {ToolID: "tool1", ProviderID: "provider1"}, + }, + } + tools := &core.ToolsConfig{ + Tools: []core.Tool{}, // Empty tools + } + providers := &core.ProvidersConfig{ + Providers: []core.Provider{ + {ID: "provider1", DisplayName: "Provider 1"}, + }, + } + svc := NewService(bindings, tools, providers, nil, "", "") + + indexes := []int{0} + rows := svc.Rows(indexes) + + if len(rows) != 1 { + t.Fatalf("Expected 1 row, got %d", len(rows)) + } + + if rows[0].ToolName != "tool1" { + t.Errorf("ToolName should be tool1 (ID), got %q", rows[0].ToolName) + } +} + +func TestRows_ProviderNotFound(t *testing.T) { + bindings := &core.BindingsConfig{ + Bindings: []core.Binding{ + {ToolID: "tool1", ProviderID: "provider1"}, + }, + } + tools := &core.ToolsConfig{ + Tools: []core.Tool{ + {ID: "tool1", Name: "Tool 1"}, + }, + } + providers := &core.ProvidersConfig{ + Providers: []core.Provider{}, // Empty providers + } + svc := NewService(bindings, tools, providers, nil, "", "") + + indexes := []int{0} + rows := svc.Rows(indexes) + + if len(rows) != 1 { + t.Fatalf("Expected 1 row, got %d", len(rows)) + } + + if rows[0].ProviderName != "provider1" { + t.Errorf("ProviderName should be provider1 (ID), got %q", rows[0].ProviderName) + } +} + +func TestDetails_Success(t *testing.T) { + bindings := &core.BindingsConfig{ + Bindings: []core.Binding{ + { + ToolID: "tool1", + ProviderID: "provider1", + Options: core.BindingOptions{ + Model: "gpt-4", + }, + }, + }, + } + svc := NewService(bindings, &core.ToolsConfig{}, &core.ProvidersConfig{}, nil, "", "") + + indexes := []int{0} + details := svc.Details(indexes, 0) + + if details == nil { + t.Fatal("Details should not be nil") + } + + if details.ToolID != "tool1" { + t.Errorf("ToolID: got %q, want %q", details.ToolID, "tool1") + } + if details.ProviderID != "provider1" { + t.Errorf("ProviderID: got %q, want %q", details.ProviderID, "provider1") + } +} + +func TestDetails_NilBindings(t *testing.T) { + svc := NewService(nil, &core.ToolsConfig{}, &core.ProvidersConfig{}, nil, "", "") + + indexes := []int{0} + details := svc.Details(indexes, 0) + + if details != nil { + t.Error("Details should return nil when bindings is nil") + } +} + +func TestDetails_EmptyIndexes(t *testing.T) { + bindings := &core.BindingsConfig{ + Bindings: []core.Binding{ + {ToolID: "tool1", ProviderID: "provider1"}, + }, + } + svc := NewService(bindings, &core.ToolsConfig{}, &core.ProvidersConfig{}, nil, "", "") + + details := svc.Details([]int{}, 0) + + if details != nil { + t.Error("Details should return nil when indexes is empty") + } +} + +func TestDetails_InvalidSelectedIndex(t *testing.T) { + bindings := &core.BindingsConfig{ + Bindings: []core.Binding{ + {ToolID: "tool1", ProviderID: "provider1"}, + }, + } + svc := NewService(bindings, &core.ToolsConfig{}, &core.ProvidersConfig{}, nil, "", "") + + indexes := []int{0} + details := svc.Details(indexes, 99) + + if details != nil { + t.Error("Details should return nil for invalid selected index") + } +} + +func TestDetails_NegativeSelectedIndex(t *testing.T) { + bindings := &core.BindingsConfig{ + Bindings: []core.Binding{ + {ToolID: "tool1", ProviderID: "provider1"}, + }, + } + svc := NewService(bindings, &core.ToolsConfig{}, &core.ProvidersConfig{}, nil, "", "") + + indexes := []int{0} + details := svc.Details(indexes, -1) + + if details != nil { + t.Error("Details should return nil for negative selected index") + } +} + +func TestSave_NilForm(t *testing.T) { + bindings := &core.BindingsConfig{} + svc := NewService(bindings, &core.ToolsConfig{}, &core.ProvidersConfig{}, nil, "", "") + + err := svc.Save("/tmp") + + if err == nil { + t.Error("Save should fail when form is nil") + } +} + +func TestSave_NilBindings(t *testing.T) { + form := &forms.BindingForm{} + svc := NewService(nil, &core.ToolsConfig{}, &core.ProvidersConfig{}, form, "", "") + + err := svc.Save("/tmp") + + if err == nil { + t.Error("Save should fail when bindings is nil") + } +} + +func TestEmptyStateMessage_Empty_NoSearch(t *testing.T) { + svc := NewService(nil, nil, nil, nil, "", "") + + msg := svc.EmptyStateMessage(true, false) + + if msg != "No bindings configured." { + t.Errorf("Message: got %q, want %q", msg, "No bindings configured.") + } +} + +func TestEmptyStateMessage_Empty_WithSearch(t *testing.T) { + svc := NewService(nil, nil, nil, nil, "", "") + + msg := svc.EmptyStateMessage(true, true) + + if msg != "No bindings match the current filter." { + t.Errorf("Message: got %q, want %q", msg, "No bindings match the current filter.") + } +} + +func TestEmptyStateMessage_NotEmpty(t *testing.T) { + svc := NewService(nil, nil, nil, nil, "", "") + + msg := svc.EmptyStateMessage(false, false) + + if msg != "" { + t.Errorf("Message: got %q, want empty string", msg) + } +} diff --git a/internal/ui/features/config/service.go b/internal/ui/features/config/service.go new file mode 100644 index 0000000..0eaad0f --- /dev/null +++ b/internal/ui/features/config/service.go @@ -0,0 +1,72 @@ +// Package config provides the service layer for configuration view data and logic. +package config + +import "github.com/royisme/bobamixer/internal/ui/components" + +// Service manages configuration view data and logic. +type Service struct{} + +// NewService creates a new config service. +func NewService() *Service { + return &Service{} +} + +// ViewData returns all static data for the config view. +func (s *Service) ViewData(home string) ViewData { + return ViewData{ + Title: "⚙️ Configuration Editor", + ConfigTitle: "Configuration Files", + EditorTitle: "Editor Settings", + SafetyTitle: "Safety Features", + ConfigFiles: s.GetConfigFiles(), + Home: home, + EditorName: "vim", + CommandHelpLine: "Use CLI: boba edit (to open in editor)", + } +} + +// GetConfigFiles returns the list of configuration files. +func (s *Service) GetConfigFiles() []ConfigFile { + return []ConfigFile{ + {Name: "Providers", File: "providers.yaml", Desc: "AI provider configurations and API endpoints"}, + {Name: "Tools", File: "tools.yaml", Desc: "CLI tool detection and management"}, + {Name: "Bindings", File: "bindings.yaml", Desc: "Tool-to-provider bindings and proxy settings"}, + {Name: "Secrets", File: "secrets.yaml", Desc: "Encrypted API keys (edit with caution!)"}, + {Name: "Routes", File: "routes.yaml", Desc: "Context-based routing rules"}, + {Name: "Pricing", File: "pricing.yaml", Desc: "Token pricing for cost calculations"}, + {Name: "Settings", File: "settings.yaml", Desc: "Global application settings"}, + } +} + +// ConvertToComponents converts config files to component format. +func (s *Service) ConvertToComponents() []components.ConfigFile { + files := s.GetConfigFiles() + result := make([]components.ConfigFile, len(files)) + for i, cfg := range files { + result[i] = components.ConfigFile{ + Name: cfg.Name, + File: cfg.File, + Desc: cfg.Desc, + } + } + return result +} + +// ConfigFile represents a configuration file entry. +type ConfigFile struct { + Name string + File string + Desc string +} + +// ViewData holds all data needed to render the config view. +type ViewData struct { + Title string + ConfigTitle string + EditorTitle string + SafetyTitle string + ConfigFiles []ConfigFile + Home string + EditorName string + CommandHelpLine string +} diff --git a/internal/ui/features/config/service_test.go b/internal/ui/features/config/service_test.go new file mode 100644 index 0000000..d64f5e8 --- /dev/null +++ b/internal/ui/features/config/service_test.go @@ -0,0 +1,247 @@ +package config + +import ( + "testing" +) + +func TestNewService(t *testing.T) { + svc := NewService() + if svc == nil { + t.Fatal("expected service to be created") + } +} + +func TestGetConfigFiles(t *testing.T) { + svc := NewService() + files := svc.GetConfigFiles() + + if len(files) == 0 { + t.Fatal("GetConfigFiles should return non-empty list") + } + + expectedFiles := []ConfigFile{ + {Name: "Providers", File: "providers.yaml", Desc: "AI provider configurations and API endpoints"}, + {Name: "Tools", File: "tools.yaml", Desc: "CLI tool detection and management"}, + {Name: "Bindings", File: "bindings.yaml", Desc: "Tool-to-provider bindings and proxy settings"}, + {Name: "Secrets", File: "secrets.yaml", Desc: "Encrypted API keys (edit with caution!)"}, + {Name: "Routes", File: "routes.yaml", Desc: "Context-based routing rules"}, + {Name: "Pricing", File: "pricing.yaml", Desc: "Token pricing for cost calculations"}, + {Name: "Settings", File: "settings.yaml", Desc: "Global application settings"}, + } + + if len(files) != len(expectedFiles) { + t.Fatalf("GetConfigFiles length: got %d, want %d", len(files), len(expectedFiles)) + } + + for i, expected := range expectedFiles { + if files[i].Name != expected.Name { + t.Errorf("File[%d].Name: got %q, want %q", i, files[i].Name, expected.Name) + } + if files[i].File != expected.File { + t.Errorf("File[%d].File: got %q, want %q", i, files[i].File, expected.File) + } + if files[i].Desc != expected.Desc { + t.Errorf("File[%d].Desc: got %q, want %q", i, files[i].Desc, expected.Desc) + } + } +} + +func TestGetConfigFiles_AllFilesHaveYamlExtension(t *testing.T) { + svc := NewService() + files := svc.GetConfigFiles() + + for i, file := range files { + if len(file.File) < 5 || file.File[len(file.File)-5:] != ".yaml" { + t.Errorf("File[%d].File should end with .yaml, got %q", i, file.File) + } + } +} + +func TestGetConfigFiles_AllFieldsNonEmpty(t *testing.T) { + svc := NewService() + files := svc.GetConfigFiles() + + for i, file := range files { + if file.Name == "" { + t.Errorf("File[%d].Name should not be empty", i) + } + if file.File == "" { + t.Errorf("File[%d].File should not be empty", i) + } + if file.Desc == "" { + t.Errorf("File[%d].Desc should not be empty", i) + } + } +} + +func TestConvertToComponents(t *testing.T) { + svc := NewService() + components := svc.ConvertToComponents() + + files := svc.GetConfigFiles() + + if len(components) != len(files) { + t.Fatalf("ConvertToComponents length: got %d, want %d", len(components), len(files)) + } + + for i, file := range files { + if components[i].Name != file.Name { + t.Errorf("Component[%d].Name: got %q, want %q", i, components[i].Name, file.Name) + } + if components[i].File != file.File { + t.Errorf("Component[%d].File: got %q, want %q", i, components[i].File, file.File) + } + if components[i].Desc != file.Desc { + t.Errorf("Component[%d].Desc: got %q, want %q", i, components[i].Desc, file.Desc) + } + } +} + +func TestConvertToComponents_ReturnType(t *testing.T) { + svc := NewService() + result := svc.ConvertToComponents() + + // Verify return type is []components.ConfigFile + var _ = result +} + +func TestViewData(t *testing.T) { + svc := NewService() + home := "/home/test" + data := svc.ViewData(home) + + // Test title fields + if data.Title == "" { + t.Error("Title should not be empty") + } + if data.Title != "⚙️ Configuration Editor" { + t.Errorf("Title: got %q, want %q", data.Title, "⚙️ Configuration Editor") + } + + if data.ConfigTitle == "" { + t.Error("ConfigTitle should not be empty") + } + if data.ConfigTitle != "Configuration Files" { + t.Errorf("ConfigTitle: got %q, want %q", data.ConfigTitle, "Configuration Files") + } + + if data.EditorTitle == "" { + t.Error("EditorTitle should not be empty") + } + if data.EditorTitle != "Editor Settings" { + t.Errorf("EditorTitle: got %q, want %q", data.EditorTitle, "Editor Settings") + } + + if data.SafetyTitle == "" { + t.Error("SafetyTitle should not be empty") + } + if data.SafetyTitle != "Safety Features" { + t.Errorf("SafetyTitle: got %q, want %q", data.SafetyTitle, "Safety Features") + } + + if data.CommandHelpLine == "" { + t.Error("CommandHelpLine should not be empty") + } + if data.CommandHelpLine != "Use CLI: boba edit (to open in editor)" { + t.Errorf("CommandHelpLine: got %q, want %q", data.CommandHelpLine, "Use CLI: boba edit (to open in editor)") + } + + if data.EditorName == "" { + t.Error("EditorName should not be empty") + } + if data.EditorName != "vim" { + t.Errorf("EditorName: got %q, want %q", data.EditorName, "vim") + } + + if data.Home != home { + t.Errorf("Home: got %q, want %q", data.Home, home) + } + + if len(data.ConfigFiles) == 0 { + t.Error("ConfigFiles should not be empty") + } +} + +func TestViewData_ConfigFilesPopulated(t *testing.T) { + svc := NewService() + data := svc.ViewData("/test") + + expectedFiles := svc.GetConfigFiles() + + if len(data.ConfigFiles) != len(expectedFiles) { + t.Fatalf("ConfigFiles length: got %d, want %d", len(data.ConfigFiles), len(expectedFiles)) + } + + for i, expected := range expectedFiles { + if data.ConfigFiles[i].Name != expected.Name { + t.Errorf("ConfigFiles[%d].Name: got %q, want %q", i, data.ConfigFiles[i].Name, expected.Name) + } + if data.ConfigFiles[i].File != expected.File { + t.Errorf("ConfigFiles[%d].File: got %q, want %q", i, data.ConfigFiles[i].File, expected.File) + } + if data.ConfigFiles[i].Desc != expected.Desc { + t.Errorf("ConfigFiles[%d].Desc: got %q, want %q", i, data.ConfigFiles[i].Desc, expected.Desc) + } + } +} + +func TestViewData_DifferentHomePaths(t *testing.T) { + svc := NewService() + + testCases := []string{ + "/home/user1", + "/home/user2", + "/var/data", + "", + } + + for _, home := range testCases { + data := svc.ViewData(home) + if data.Home != home { + t.Errorf("ViewData with home %q: got %q", home, data.Home) + } + } +} + +func TestViewData_Consistency(t *testing.T) { + svc := NewService() + + // Call ViewData multiple times to ensure consistency + data1 := svc.ViewData("/test") + data2 := svc.ViewData("/test") + + if data1.Title != data2.Title { + t.Error("ViewData should return consistent Title") + } + + if data1.ConfigTitle != data2.ConfigTitle { + t.Error("ViewData should return consistent ConfigTitle") + } + + if data1.EditorName != data2.EditorName { + t.Error("ViewData should return consistent EditorName") + } + + if len(data1.ConfigFiles) != len(data2.ConfigFiles) { + t.Error("ViewData should return consistent ConfigFiles length") + } +} + +func TestConfigFile_Structure(t *testing.T) { + // Test that ConfigFile struct is properly defined + file := ConfigFile{ + Name: "Test", + File: "test.yaml", + Desc: "Test description", + } + + if file.Name != "Test" { + t.Errorf("Name: got %q, want %q", file.Name, "Test") + } + if file.File != "test.yaml" { + t.Errorf("File: got %q, want %q", file.File, "test.yaml") + } + if file.Desc != "Test description" { + t.Errorf("Desc: got %q, want %q", file.Desc, "Test description") + } +} diff --git a/internal/ui/features/dashboard/service.go b/internal/ui/features/dashboard/service.go new file mode 100644 index 0000000..5a2ad27 --- /dev/null +++ b/internal/ui/features/dashboard/service.go @@ -0,0 +1,152 @@ +// Package dashboard provides the service layer for dashboard view data and logic. +package dashboard + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/table" + "github.com/royisme/bobamixer/internal/domain/core" +) + +const ( + // Icon constants for status display + IconCircleFilled = "●" + IconCircleEmpty = "○" + IconCheckmark = "✓" + IconCross = "❌" + IconWarning = "⚠" + + // Proxy state indicators + ProxyStateOn = "🟢 ON" + ProxyStateOff = "🔴 OFF" + + // Help text + HelpTextNavigation = "[↑↓] Navigate [Tab] Next Section [V] Views [Q] Quit" + HelpTextActions = "[R] Run Tool [X] Toggle Proxy" + + // Messages + MsgNoProviderSelected = "No provider selected" + MsgInvalidProvider = "Invalid provider configuration" +) + +// Service manages dashboard table data construction. +type Service struct { + tools *core.ToolsConfig + bindings *core.BindingsConfig + providers *core.ProvidersConfig + secrets *core.SecretsConfig +} + +// NewService creates a new dashboard service. +func NewService( + tools *core.ToolsConfig, + bindings *core.BindingsConfig, + providers *core.ProvidersConfig, + secrets *core.SecretsConfig, +) *Service { + return &Service{ + tools: tools, + bindings: bindings, + providers: providers, + secrets: secrets, + } +} + +// BuildTableRows creates table rows from current configuration. +func (s *Service) BuildTableRows() []table.Row { + rows := make([]table.Row, 0) + + for _, tool := range s.tools.Tools { + row := s.buildRowForTool(tool) + rows = append(rows, row) + } + + if len(rows) == 0 { + rows = append(rows, table.Row{ + "No tools configured", + "-", + "-", + "-", + "-", + }) + } + + return rows +} + +// buildRowForTool builds a single table row for a given tool. +func (s *Service) buildRowForTool(tool core.Tool) table.Row { + // Find binding for this tool + binding, err := s.bindings.FindBinding(tool.ID) + if err != nil { + // No binding, show as not configured + return table.Row{ + tool.Name, + "(not bound)", + "-", + "-", + IconWarning + " Not configured", + } + } + + // Find provider + provider, err := s.providers.FindProvider(binding.ProviderID) + if err != nil { + // Provider not found + return table.Row{ + tool.Name, + fmt.Sprintf("(missing: %s)", binding.ProviderID), + "-", + "-", + IconCross + " Error", + } + } + + // Check API key status + keyStatus := IconCheckmark + " Ready" + if _, err := core.ResolveAPIKey(provider, s.secrets); err != nil { + keyStatus = IconWarning + " No API key" + } + + // Determine model + model := s.determineModel(provider, binding) + + // Proxy status + proxyStatus := ProxyStateOff + if binding.UseProxy { + proxyStatus = ProxyStateOn + } + + return table.Row{ + tool.Name, + provider.DisplayName, + model, + proxyStatus, + keyStatus, + } +} + +// determineModel determines the model to display for a binding. +func (s *Service) determineModel(provider *core.Provider, binding *core.Binding) string { + model := provider.DefaultModel + if binding.Options.Model != "" { + model = binding.Options.Model + } + + // Truncate if too long + if len(model) > 23 { + model = model[:20] + "..." + } + + return model +} + +// GetNavigationHelp returns the navigation help text. +func (s *Service) GetNavigationHelp() string { + return HelpTextNavigation +} + +// GetActionHelp returns the action help text. +func (s *Service) GetActionHelp() string { + return HelpTextActions +} diff --git a/internal/ui/features/dashboard/service_test.go b/internal/ui/features/dashboard/service_test.go new file mode 100644 index 0000000..a768267 --- /dev/null +++ b/internal/ui/features/dashboard/service_test.go @@ -0,0 +1,523 @@ +package dashboard + +import ( + "testing" + + "github.com/royisme/bobamixer/internal/domain/core" +) + +const ( + testToolName = "Test Tool" +) + +func TestNewService(t *testing.T) { + tools := &core.ToolsConfig{} + bindings := &core.BindingsConfig{} + providers := &core.ProvidersConfig{} + secrets := &core.SecretsConfig{} + + svc := NewService(tools, bindings, providers, secrets) + if svc == nil { + t.Fatal("expected service to be created") + } + if svc.tools != tools { + t.Error("tools not set correctly") + } + if svc.bindings != bindings { + t.Error("bindings not set correctly") + } + if svc.providers != providers { + t.Error("providers not set correctly") + } + if svc.secrets != secrets { + t.Error("secrets not set correctly") + } +} + +func TestBuildTableRows_NoTools(t *testing.T) { + svc := NewService( + &core.ToolsConfig{Tools: []core.Tool{}}, + &core.BindingsConfig{Bindings: []core.Binding{}}, + &core.ProvidersConfig{Providers: []core.Provider{}}, + &core.SecretsConfig{}, + ) + + rows := svc.BuildTableRows() + + if len(rows) != 1 { + t.Fatalf("expected 1 row (empty message), got %d", len(rows)) + } + + if rows[0][0] != "No tools configured" { + t.Errorf("expected empty message, got %q", rows[0][0]) + } +} + +func TestBuildTableRows_ToolNotBound(t *testing.T) { + svc := NewService( + &core.ToolsConfig{ + Tools: []core.Tool{ + {ID: "tool1", Name: "Test Tool"}, + }, + }, + &core.BindingsConfig{Bindings: []core.Binding{}}, + &core.ProvidersConfig{Providers: []core.Provider{}}, + &core.SecretsConfig{}, + ) + + rows := svc.BuildTableRows() + + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + + row := rows[0] + if row[0] != testToolName { + t.Errorf("Tool name: got %q, want %q", row[0], testToolName) + } + if row[1] != "(not bound)" { + t.Errorf("Provider: got %q, want %q", row[1], "(not bound)") + } + if row[4] != IconWarning+" Not configured" { + t.Errorf("Status: got %q, want %q", row[4], IconWarning+" Not configured") + } +} + +func TestBuildTableRows_ProviderMissing(t *testing.T) { + svc := NewService( + &core.ToolsConfig{ + Tools: []core.Tool{ + {ID: "tool1", Name: testToolName}, + }, + }, + &core.BindingsConfig{ + Bindings: []core.Binding{ + {ToolID: "tool1", ProviderID: "missing-provider"}, + }, + }, + &core.ProvidersConfig{Providers: []core.Provider{}}, + &core.SecretsConfig{}, + ) + + rows := svc.BuildTableRows() + + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + + row := rows[0] + if row[0] != testToolName { + t.Errorf("Tool name: got %q, want %q", row[0], testToolName) + } + if row[1] != "(missing: missing-provider)" { + t.Errorf("Provider: got %q, want %q", row[1], "(missing: missing-provider)") + } + if row[4] != IconCross+" Error" { + t.Errorf("Status: got %q, want %q", row[4], IconCross+" Error") + } +} + +func TestBuildTableRows_FullConfiguration(t *testing.T) { + svc := NewService( + &core.ToolsConfig{ + Tools: []core.Tool{ + {ID: "tool1", Name: "Test Tool"}, + }, + }, + &core.BindingsConfig{ + Bindings: []core.Binding{ + { + ToolID: "tool1", + ProviderID: "provider1", + UseProxy: true, + Options: core.BindingOptions{ + Model: "gpt-4", + }, + }, + }, + }, + &core.ProvidersConfig{ + Providers: []core.Provider{ + { + ID: "provider1", + DisplayName: "Test Provider", + Kind: "openai", + BaseURL: "https://api.openai.com/v1", + DefaultModel: "gpt-3.5-turbo", + APIKey: core.APIKeyConfig{ + Source: core.APIKeySourceSecrets, + }, + }, + }, + }, + &core.SecretsConfig{ + Secrets: map[string]core.Secret{ + "provider1": { + APIKey: "sk-test123", + }, + }, + }, + ) + + rows := svc.BuildTableRows() + + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + + row := rows[0] + if row[0] != "Test Tool" { + t.Errorf("Tool name: got %q, want %q", row[0], "Test Tool") + } + if row[1] != "Test Provider" { + t.Errorf("Provider: got %q, want %q", row[1], "Test Provider") + } + if row[2] != "gpt-4" { + t.Errorf("Model: got %q, want %q", row[2], "gpt-4") + } + if row[3] != ProxyStateOn { + t.Errorf("Proxy: got %q, want %q", row[3], ProxyStateOn) + } + if row[4] != IconCheckmark+" Ready" { + t.Errorf("Status: got %q, want %q", row[4], IconCheckmark+" Ready") + } +} + +func TestBuildTableRows_DefaultModel(t *testing.T) { + svc := NewService( + &core.ToolsConfig{ + Tools: []core.Tool{ + {ID: "tool1", Name: "Test Tool"}, + }, + }, + &core.BindingsConfig{ + Bindings: []core.Binding{ + { + ToolID: "tool1", + ProviderID: "provider1", + Options: core.BindingOptions{}, // No model override + }, + }, + }, + &core.ProvidersConfig{ + Providers: []core.Provider{ + { + ID: "provider1", + DisplayName: "Test Provider", + Kind: "openai", + BaseURL: "https://api.openai.com/v1", + DefaultModel: "gpt-3.5-turbo", + APIKey: core.APIKeyConfig{ + Source: core.APIKeySourceSecrets, + }, + }, + }, + }, + &core.SecretsConfig{ + Secrets: map[string]core.Secret{ + "provider1": { + APIKey: "sk-test123", + }, + }, + }, + ) + + rows := svc.BuildTableRows() + + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + + row := rows[0] + if row[2] != "gpt-3.5-turbo" { + t.Errorf("Model: got %q, want %q (should use default)", row[2], "gpt-3.5-turbo") + } +} + +func TestBuildTableRows_NoAPIKey(t *testing.T) { + svc := NewService( + &core.ToolsConfig{ + Tools: []core.Tool{ + {ID: "tool1", Name: "Test Tool"}, + }, + }, + &core.BindingsConfig{ + Bindings: []core.Binding{ + { + ToolID: "tool1", + ProviderID: "provider1", + }, + }, + }, + &core.ProvidersConfig{ + Providers: []core.Provider{ + { + ID: "provider1", + DisplayName: "Test Provider", + Kind: "openai", + BaseURL: "https://api.openai.com/v1", + DefaultModel: "gpt-3.5-turbo", + APIKey: core.APIKeyConfig{ + Source: core.APIKeySourceSecrets, + }, + }, + }, + }, + &core.SecretsConfig{ + Secrets: map[string]core.Secret{}, + }, + ) + + rows := svc.BuildTableRows() + + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + + row := rows[0] + if row[4] != IconWarning+" No API key" { + t.Errorf("Status: got %q, want %q", row[4], IconWarning+" No API key") + } +} + +func TestBuildTableRows_ProxyOff(t *testing.T) { + svc := NewService( + &core.ToolsConfig{ + Tools: []core.Tool{ + {ID: "tool1", Name: "Test Tool"}, + }, + }, + &core.BindingsConfig{ + Bindings: []core.Binding{ + { + ToolID: "tool1", + ProviderID: "provider1", + UseProxy: false, // Proxy disabled + }, + }, + }, + &core.ProvidersConfig{ + Providers: []core.Provider{ + { + ID: "provider1", + DisplayName: "Test Provider", + Kind: "openai", + BaseURL: "https://api.openai.com/v1", + DefaultModel: "gpt-3.5-turbo", + APIKey: core.APIKeyConfig{ + Source: core.APIKeySourceSecrets, + }, + }, + }, + }, + &core.SecretsConfig{ + Secrets: map[string]core.Secret{ + "provider1": { + APIKey: "sk-test123", + }, + }, + }, + ) + + rows := svc.BuildTableRows() + + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + + row := rows[0] + if row[3] != ProxyStateOff { + t.Errorf("Proxy: got %q, want %q", row[3], ProxyStateOff) + } +} + +func TestBuildTableRows_MultipleTools(t *testing.T) { + svc := NewService( + &core.ToolsConfig{ + Tools: []core.Tool{ + {ID: "tool1", Name: "Tool 1"}, + {ID: "tool2", Name: "Tool 2"}, + {ID: "tool3", Name: "Tool 3"}, + }, + }, + &core.BindingsConfig{ + Bindings: []core.Binding{ + {ToolID: "tool1", ProviderID: "provider1"}, + {ToolID: "tool2", ProviderID: "provider1"}, + // tool3 not bound + }, + }, + &core.ProvidersConfig{ + Providers: []core.Provider{ + { + ID: "provider1", + DisplayName: "Provider", + Kind: "openai", + BaseURL: "https://api.openai.com/v1", + DefaultModel: "gpt-4", + APIKey: core.APIKeyConfig{ + Source: core.APIKeySourceSecrets, + }, + }, + }, + }, + &core.SecretsConfig{ + Secrets: map[string]core.Secret{ + "provider1": { + APIKey: "sk-test", + }, + }, + }, + ) + + rows := svc.BuildTableRows() + + if len(rows) != 3 { + t.Fatalf("expected 3 rows, got %d", len(rows)) + } + + // Tool 1 and 2 should be configured + if rows[0][0] != "Tool 1" { + t.Errorf("Row 0 tool name: got %q, want %q", rows[0][0], "Tool 1") + } + if rows[1][0] != "Tool 2" { + t.Errorf("Row 1 tool name: got %q, want %q", rows[1][0], "Tool 2") + } + + // Tool 3 should show as not configured + if rows[2][0] != "Tool 3" { + t.Errorf("Row 2 tool name: got %q, want %q", rows[2][0], "Tool 3") + } + if rows[2][1] != "(not bound)" { + t.Errorf("Row 2 should be not bound, got %q", rows[2][1]) + } +} + +func TestDetermineModel_LongModel(t *testing.T) { + svc := NewService( + &core.ToolsConfig{}, + &core.BindingsConfig{}, + &core.ProvidersConfig{}, + &core.SecretsConfig{}, + ) + + provider := &core.Provider{ + DefaultModel: "short", + } + binding := &core.Binding{ + Options: core.BindingOptions{ + Model: "this-is-a-very-long-model-name-that-exceeds-twenty-three-characters", + }, + } + + model := svc.determineModel(provider, binding) + + if len(model) > 23 { + t.Errorf("Model should be truncated to 23 chars, got %d chars: %q", len(model), model) + } + if model[len(model)-3:] != "..." { + t.Errorf("Truncated model should end with '...', got %q", model) + } + expectedPrefix := "this-is-a-very-long-" + if model[:20] != expectedPrefix { + t.Errorf("Model prefix: got %q, want %q", model[:20], expectedPrefix) + } +} + +func TestGetNavigationHelp(t *testing.T) { + svc := NewService(nil, nil, nil, nil) + help := svc.GetNavigationHelp() + if help == "" { + t.Error("GetNavigationHelp should return non-empty string") + } + if help != HelpTextNavigation { + t.Errorf("GetNavigationHelp: got %q, want %q", help, HelpTextNavigation) + } +} + +func TestGetActionHelp(t *testing.T) { + svc := NewService(nil, nil, nil, nil) + help := svc.GetActionHelp() + if help == "" { + t.Error("GetActionHelp should return non-empty string") + } + if help != HelpTextActions { + t.Errorf("GetActionHelp: got %q, want %q", help, HelpTextActions) + } +} + +func TestConstants(t *testing.T) { + // Verify constants are defined + tests := []struct { + name string + value string + }{ + {"IconCircleFilled", IconCircleFilled}, + {"IconCircleEmpty", IconCircleEmpty}, + {"IconCheckmark", IconCheckmark}, + {"IconCross", IconCross}, + {"IconWarning", IconWarning}, + {"ProxyStateOn", ProxyStateOn}, + {"ProxyStateOff", ProxyStateOff}, + {"HelpTextNavigation", HelpTextNavigation}, + {"HelpTextActions", HelpTextActions}, + {"MsgNoProviderSelected", MsgNoProviderSelected}, + {"MsgInvalidProvider", MsgInvalidProvider}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.value == "" { + t.Errorf("%s constant is empty", tt.name) + } + }) + } +} + +func TestBuildTableRows_ReturnType(t *testing.T) { + svc := NewService( + &core.ToolsConfig{ + Tools: []core.Tool{ + {ID: "tool1", Name: "Test Tool"}, + }, + }, + &core.BindingsConfig{ + Bindings: []core.Binding{ + {ToolID: "tool1", ProviderID: "provider1"}, + }, + }, + &core.ProvidersConfig{ + Providers: []core.Provider{ + { + ID: "provider1", + DisplayName: "Provider", + Kind: "openai", + BaseURL: "https://api.openai.com/v1", + DefaultModel: "gpt-4", + APIKey: core.APIKeyConfig{ + Source: core.APIKeySourceSecrets, + }, + }, + }, + }, + &core.SecretsConfig{ + Secrets: map[string]core.Secret{ + "provider1": { + APIKey: "sk-test", + }, + }, + }, + ) + + rows := svc.BuildTableRows() + + // Verify return type is []table.Row + var _ = rows + + // Verify each row has 5 columns + for i, row := range rows { + if len(row) != 5 { + t.Errorf("Row %d has %d columns, want 5", i, len(row)) + } + } +} diff --git a/internal/ui/features/help/service.go b/internal/ui/features/help/service.go new file mode 100644 index 0000000..6dff10c --- /dev/null +++ b/internal/ui/features/help/service.go @@ -0,0 +1,64 @@ +// Package help provides the service layer for help view data and logic. +package help + +import "github.com/royisme/bobamixer/internal/ui/components" + +// Service manages help view data and logic. +type Service struct{} + +// NewService creates a new help service. +func NewService() *Service { + return &Service{} +} + +// ViewData returns all static data for the help view. +func (s *Service) ViewData() ViewData { + return ViewData{ + Title: "❓ BobaMixer Help & Shortcuts", + Subtitle: "", + NavigationHint: "Press Esc to close this overlay", + } +} + +// GetDefaultTips returns the default help tips. +func (s *Service) GetDefaultTips() []string { + return []string{ + "Use number keys (1-5) to jump between sections", + "All interactive features live in the TUI", + "CLI commands remain available for automation", + "Press ? anytime to toggle this help overlay", + } +} + +// GetDefaultLinks returns the default help links. +func (s *Service) GetDefaultLinks() []HelpLink { + return []HelpLink{ + {Label: "Full docs", URL: "https://royisme.github.io/BobaMixer/"}, + {Label: "GitHub", URL: "https://github.com/royisme/BobaMixer"}, + } +} + +// ConvertLinksToComponents converts help links to component format. +func (s *Service) ConvertLinksToComponents(links []HelpLink) []components.HelpLink { + result := make([]components.HelpLink, len(links)) + for i, link := range links { + result[i] = components.HelpLink{ + Label: link.Label, + URL: link.URL, + } + } + return result +} + +// HelpLink represents a help documentation link. +type HelpLink struct { + Label string + URL string +} + +// ViewData holds all data needed to render the help view. +type ViewData struct { + Title string + Subtitle string + NavigationHint string +} diff --git a/internal/ui/features/help/service_test.go b/internal/ui/features/help/service_test.go new file mode 100644 index 0000000..0a85266 --- /dev/null +++ b/internal/ui/features/help/service_test.go @@ -0,0 +1,259 @@ +package help + +import ( + "testing" +) + +func TestNewService(t *testing.T) { + svc := NewService() + if svc == nil { + t.Fatal("expected service to be created") + } +} + +func TestViewData(t *testing.T) { + svc := NewService() + data := svc.ViewData() + + // Test title fields + if data.Title == "" { + t.Error("Title should not be empty") + } + if data.Title != "❓ BobaMixer Help & Shortcuts" { + t.Errorf("Title: got %q, want %q", data.Title, "❓ BobaMixer Help & Shortcuts") + } + + if data.Subtitle != "" { + t.Errorf("Subtitle: got %q, want empty string", data.Subtitle) + } + + if data.NavigationHint == "" { + t.Error("NavigationHint should not be empty") + } + if data.NavigationHint != "Press Esc to close this overlay" { + t.Errorf("NavigationHint: got %q, want %q", data.NavigationHint, "Press Esc to close this overlay") + } +} + +func TestGetDefaultTips(t *testing.T) { + svc := NewService() + tips := svc.GetDefaultTips() + + if len(tips) == 0 { + t.Fatal("GetDefaultTips should return non-empty list") + } + + expectedTips := []string{ + "Use number keys (1-5) to jump between sections", + "All interactive features live in the TUI", + "CLI commands remain available for automation", + "Press ? anytime to toggle this help overlay", + } + + if len(tips) != len(expectedTips) { + t.Fatalf("GetDefaultTips length: got %d, want %d", len(tips), len(expectedTips)) + } + + for i, expected := range expectedTips { + if tips[i] != expected { + t.Errorf("Tip[%d]: got %q, want %q", i, tips[i], expected) + } + } +} + +func TestGetDefaultTips_AllNonEmpty(t *testing.T) { + svc := NewService() + tips := svc.GetDefaultTips() + + for i, tip := range tips { + if tip == "" { + t.Errorf("Tip[%d] should not be empty", i) + } + } +} + +func TestGetDefaultLinks(t *testing.T) { + svc := NewService() + links := svc.GetDefaultLinks() + + if len(links) == 0 { + t.Fatal("GetDefaultLinks should return non-empty list") + } + + expectedLinks := []HelpLink{ + {Label: "Full docs", URL: "https://royisme.github.io/BobaMixer/"}, + {Label: "GitHub", URL: "https://github.com/royisme/BobaMixer"}, + } + + if len(links) != len(expectedLinks) { + t.Fatalf("GetDefaultLinks length: got %d, want %d", len(links), len(expectedLinks)) + } + + for i, expected := range expectedLinks { + if links[i].Label != expected.Label { + t.Errorf("Link[%d].Label: got %q, want %q", i, links[i].Label, expected.Label) + } + if links[i].URL != expected.URL { + t.Errorf("Link[%d].URL: got %q, want %q", i, links[i].URL, expected.URL) + } + } +} + +func TestGetDefaultLinks_AllFieldsNonEmpty(t *testing.T) { + svc := NewService() + links := svc.GetDefaultLinks() + + for i, link := range links { + if link.Label == "" { + t.Errorf("Link[%d].Label should not be empty", i) + } + if link.URL == "" { + t.Errorf("Link[%d].URL should not be empty", i) + } + } +} + +func TestGetDefaultLinks_ValidURLs(t *testing.T) { + svc := NewService() + links := svc.GetDefaultLinks() + + for i, link := range links { + if len(link.URL) < 8 || (link.URL[:7] != "http://" && link.URL[:8] != "https://") { + t.Errorf("Link[%d].URL should start with http:// or https://, got %q", i, link.URL) + } + } +} + +func TestConvertLinksToComponents(t *testing.T) { + svc := NewService() + links := svc.GetDefaultLinks() + components := svc.ConvertLinksToComponents(links) + + if len(components) != len(links) { + t.Fatalf("ConvertLinksToComponents length: got %d, want %d", len(components), len(links)) + } + + for i, link := range links { + if components[i].Label != link.Label { + t.Errorf("Component[%d].Label: got %q, want %q", i, components[i].Label, link.Label) + } + if components[i].URL != link.URL { + t.Errorf("Component[%d].URL: got %q, want %q", i, components[i].URL, link.URL) + } + } +} + +func TestConvertLinksToComponents_EmptyList(t *testing.T) { + svc := NewService() + components := svc.ConvertLinksToComponents([]HelpLink{}) + + if len(components) != 0 { + t.Errorf("ConvertLinksToComponents with empty input: got %d, want 0", len(components)) + } +} + +func TestConvertLinksToComponents_ReturnType(t *testing.T) { + svc := NewService() + links := svc.GetDefaultLinks() + result := svc.ConvertLinksToComponents(links) + + // Verify return type is []components.HelpLink + var _ = result +} + +func TestConvertLinksToComponents_CustomLinks(t *testing.T) { + svc := NewService() + customLinks := []HelpLink{ + {Label: "Custom1", URL: "https://example.com/1"}, + {Label: "Custom2", URL: "https://example.com/2"}, + } + + components := svc.ConvertLinksToComponents(customLinks) + + if len(components) != len(customLinks) { + t.Fatalf("Length: got %d, want %d", len(components), len(customLinks)) + } + + for i, link := range customLinks { + if components[i].Label != link.Label { + t.Errorf("Component[%d].Label: got %q, want %q", i, components[i].Label, link.Label) + } + if components[i].URL != link.URL { + t.Errorf("Component[%d].URL: got %q, want %q", i, components[i].URL, link.URL) + } + } +} + +func TestViewData_Consistency(t *testing.T) { + svc := NewService() + + // Call ViewData multiple times to ensure consistency + data1 := svc.ViewData() + data2 := svc.ViewData() + + if data1.Title != data2.Title { + t.Error("ViewData should return consistent Title") + } + + if data1.Subtitle != data2.Subtitle { + t.Error("ViewData should return consistent Subtitle") + } + + if data1.NavigationHint != data2.NavigationHint { + t.Error("ViewData should return consistent NavigationHint") + } +} + +func TestGetDefaultTips_Consistency(t *testing.T) { + svc := NewService() + + // Call multiple times + tips1 := svc.GetDefaultTips() + tips2 := svc.GetDefaultTips() + + if len(tips1) != len(tips2) { + t.Error("GetDefaultTips should return consistent results") + } + + for i := range tips1 { + if tips1[i] != tips2[i] { + t.Errorf("Inconsistent tip at index %d", i) + } + } +} + +func TestGetDefaultLinks_Consistency(t *testing.T) { + svc := NewService() + + // Call multiple times + links1 := svc.GetDefaultLinks() + links2 := svc.GetDefaultLinks() + + if len(links1) != len(links2) { + t.Error("GetDefaultLinks should return consistent results") + } + + for i := range links1 { + if links1[i].Label != links2[i].Label { + t.Errorf("Inconsistent Label at index %d", i) + } + if links1[i].URL != links2[i].URL { + t.Errorf("Inconsistent URL at index %d", i) + } + } +} + +func TestHelpLink_Structure(t *testing.T) { + // Test that HelpLink struct is properly defined + link := HelpLink{ + Label: "Test Link", + URL: "https://test.example.com", + } + + if link.Label != "Test Link" { + t.Errorf("Label: got %q, want %q", link.Label, "Test Link") + } + if link.URL != "https://test.example.com" { + t.Errorf("URL: got %q, want %q", link.URL, "https://test.example.com") + } +} diff --git a/internal/ui/features/hooks/service.go b/internal/ui/features/hooks/service.go new file mode 100644 index 0000000..59146b7 --- /dev/null +++ b/internal/ui/features/hooks/service.go @@ -0,0 +1,75 @@ +// Package hooks provides the service layer for hooks view data and logic. +package hooks + +import "github.com/royisme/bobamixer/internal/ui/components" + +// Service manages hooks view data and logic. +type Service struct{} + +// NewService creates a new hooks service. +func NewService() *Service { + return &Service{} +} + +// ViewData returns all static data for the hooks view. +func (s *Service) ViewData() ViewData { + return ViewData{ + Title: "🪝 Git Hooks Management", + RepoTitle: "Current Repository", + HooksTitle: "Available Hooks", + BenefitsTitle: "Benefits", + ActivityTitle: "Recent Hook Activity", + CommandHelpLine: "Use CLI: boba hooks install (install) | boba hooks remove (uninstall)", + } +} + +// GetAvailableHooks returns the list of available git hooks. +func (s *Service) GetAvailableHooks(installed bool) []HookInfo { + return []HookInfo{ + { + Name: "post-checkout", + Desc: "Track branch switches and suggest optimal profiles", + Active: installed, + }, + { + Name: "post-commit", + Desc: "Record commit events for usage tracking", + Active: installed, + }, + { + Name: "post-merge", + Desc: "Track merge events and repository changes", + Active: installed, + }, + } +} + +// ConvertToComponents converts hooks to component format. +func (s *Service) ConvertToComponents(hooks []HookInfo) []components.HookInfo { + result := make([]components.HookInfo, len(hooks)) + for i, hook := range hooks { + result[i] = components.HookInfo{ + Name: hook.Name, + Desc: hook.Desc, + Active: hook.Active, + } + } + return result +} + +// HookInfo represents a git hook entry. +type HookInfo struct { + Name string + Desc string + Active bool +} + +// ViewData holds all data needed to render the hooks view. +type ViewData struct { + Title string + RepoTitle string + HooksTitle string + BenefitsTitle string + ActivityTitle string + CommandHelpLine string +} diff --git a/internal/ui/features/hooks/service_test.go b/internal/ui/features/hooks/service_test.go new file mode 100644 index 0000000..f620c72 --- /dev/null +++ b/internal/ui/features/hooks/service_test.go @@ -0,0 +1,262 @@ +package hooks + +import ( + "testing" +) + +func TestNewService(t *testing.T) { + svc := NewService() + if svc == nil { + t.Fatal("expected service to be created") + } +} + +func TestViewData(t *testing.T) { + svc := NewService() + data := svc.ViewData() + + // Test title fields + if data.Title == "" { + t.Error("Title should not be empty") + } + if data.Title != "🪝 Git Hooks Management" { + t.Errorf("Title: got %q, want %q", data.Title, "🪝 Git Hooks Management") + } + + if data.RepoTitle == "" { + t.Error("RepoTitle should not be empty") + } + if data.RepoTitle != "Current Repository" { + t.Errorf("RepoTitle: got %q, want %q", data.RepoTitle, "Current Repository") + } + + if data.HooksTitle == "" { + t.Error("HooksTitle should not be empty") + } + if data.HooksTitle != "Available Hooks" { + t.Errorf("HooksTitle: got %q, want %q", data.HooksTitle, "Available Hooks") + } + + if data.BenefitsTitle == "" { + t.Error("BenefitsTitle should not be empty") + } + if data.BenefitsTitle != "Benefits" { + t.Errorf("BenefitsTitle: got %q, want %q", data.BenefitsTitle, "Benefits") + } + + if data.ActivityTitle == "" { + t.Error("ActivityTitle should not be empty") + } + if data.ActivityTitle != "Recent Hook Activity" { + t.Errorf("ActivityTitle: got %q, want %q", data.ActivityTitle, "Recent Hook Activity") + } + + if data.CommandHelpLine == "" { + t.Error("CommandHelpLine should not be empty") + } + expectedCmd := "Use CLI: boba hooks install (install) | boba hooks remove (uninstall)" + if data.CommandHelpLine != expectedCmd { + t.Errorf("CommandHelpLine: got %q, want %q", data.CommandHelpLine, expectedCmd) + } +} + +func TestGetAvailableHooks_Installed(t *testing.T) { + svc := NewService() + hooks := svc.GetAvailableHooks(true) + + if len(hooks) == 0 { + t.Fatal("GetAvailableHooks should return non-empty list") + } + + expectedHooks := []HookInfo{ + { + Name: "post-checkout", + Desc: "Track branch switches and suggest optimal profiles", + Active: true, + }, + { + Name: "post-commit", + Desc: "Record commit events for usage tracking", + Active: true, + }, + { + Name: "post-merge", + Desc: "Track merge events and repository changes", + Active: true, + }, + } + + if len(hooks) != len(expectedHooks) { + t.Fatalf("GetAvailableHooks length: got %d, want %d", len(hooks), len(expectedHooks)) + } + + for i, expected := range expectedHooks { + if hooks[i].Name != expected.Name { + t.Errorf("Hook[%d].Name: got %q, want %q", i, hooks[i].Name, expected.Name) + } + if hooks[i].Desc != expected.Desc { + t.Errorf("Hook[%d].Desc: got %q, want %q", i, hooks[i].Desc, expected.Desc) + } + if hooks[i].Active != expected.Active { + t.Errorf("Hook[%d].Active: got %v, want %v", i, hooks[i].Active, expected.Active) + } + } +} + +func TestGetAvailableHooks_NotInstalled(t *testing.T) { + svc := NewService() + hooks := svc.GetAvailableHooks(false) + + if len(hooks) == 0 { + t.Fatal("GetAvailableHooks should return non-empty list") + } + + // All hooks should have Active = false + for i, hook := range hooks { + if hook.Active != false { + t.Errorf("Hook[%d].Active: got %v, want false (not installed)", i, hook.Active) + } + } +} + +func TestGetAvailableHooks_AllFieldsNonEmpty(t *testing.T) { + svc := NewService() + hooks := svc.GetAvailableHooks(true) + + for i, hook := range hooks { + if hook.Name == "" { + t.Errorf("Hook[%d].Name should not be empty", i) + } + if hook.Desc == "" { + t.Errorf("Hook[%d].Desc should not be empty", i) + } + } +} + +func TestGetAvailableHooks_StandardGitHooks(t *testing.T) { + svc := NewService() + hooks := svc.GetAvailableHooks(true) + + expectedNames := []string{ + "post-checkout", + "post-commit", + "post-merge", + } + + if len(hooks) != len(expectedNames) { + t.Fatalf("Expected %d hooks, got %d", len(expectedNames), len(hooks)) + } + + for i, expectedName := range expectedNames { + if hooks[i].Name != expectedName { + t.Errorf("Hook[%d].Name: got %q, want %q", i, hooks[i].Name, expectedName) + } + } +} + +func TestConvertToComponents(t *testing.T) { + svc := NewService() + hooks := svc.GetAvailableHooks(true) + components := svc.ConvertToComponents(hooks) + + if len(components) != len(hooks) { + t.Fatalf("ConvertToComponents length: got %d, want %d", len(components), len(hooks)) + } + + for i, hook := range hooks { + if components[i].Name != hook.Name { + t.Errorf("Component[%d].Name: got %q, want %q", i, components[i].Name, hook.Name) + } + if components[i].Desc != hook.Desc { + t.Errorf("Component[%d].Desc: got %q, want %q", i, components[i].Desc, hook.Desc) + } + if components[i].Active != hook.Active { + t.Errorf("Component[%d].Active: got %v, want %v", i, components[i].Active, hook.Active) + } + } +} + +func TestConvertToComponents_EmptyList(t *testing.T) { + svc := NewService() + components := svc.ConvertToComponents([]HookInfo{}) + + if len(components) != 0 { + t.Errorf("ConvertToComponents with empty input: got %d, want 0", len(components)) + } +} + +func TestConvertToComponents_ReturnType(t *testing.T) { + svc := NewService() + hooks := svc.GetAvailableHooks(true) + result := svc.ConvertToComponents(hooks) + + // Verify return type is []components.HookInfo + var _ = result +} + +func TestViewData_Consistency(t *testing.T) { + svc := NewService() + + // Call ViewData multiple times to ensure consistency + data1 := svc.ViewData() + data2 := svc.ViewData() + + if data1.Title != data2.Title { + t.Error("ViewData should return consistent Title") + } + + if data1.RepoTitle != data2.RepoTitle { + t.Error("ViewData should return consistent RepoTitle") + } + + if data1.HooksTitle != data2.HooksTitle { + t.Error("ViewData should return consistent HooksTitle") + } + + if data1.CommandHelpLine != data2.CommandHelpLine { + t.Error("ViewData should return consistent CommandHelpLine") + } +} + +func TestGetAvailableHooks_Consistency(t *testing.T) { + svc := NewService() + + // Call with same parameter multiple times + hooks1 := svc.GetAvailableHooks(true) + hooks2 := svc.GetAvailableHooks(true) + + if len(hooks1) != len(hooks2) { + t.Error("GetAvailableHooks should return consistent results") + } + + for i := range hooks1 { + if hooks1[i].Name != hooks2[i].Name { + t.Errorf("Inconsistent Name at index %d", i) + } + if hooks1[i].Desc != hooks2[i].Desc { + t.Errorf("Inconsistent Desc at index %d", i) + } + if hooks1[i].Active != hooks2[i].Active { + t.Errorf("Inconsistent Active at index %d", i) + } + } +} + +func TestHookInfo_Structure(t *testing.T) { + // Test that HookInfo struct is properly defined + hook := HookInfo{ + Name: "test-hook", + Desc: "Test description", + Active: true, + } + + if hook.Name != "test-hook" { + t.Errorf("Name: got %q, want %q", hook.Name, "test-hook") + } + if hook.Desc != "Test description" { + t.Errorf("Desc: got %q, want %q", hook.Desc, "Test description") + } + if hook.Active != true { + t.Errorf("Active: got %v, want true", hook.Active) + } +} diff --git a/internal/ui/features/providers/service.go b/internal/ui/features/providers/service.go new file mode 100644 index 0000000..6365d27 --- /dev/null +++ b/internal/ui/features/providers/service.go @@ -0,0 +1,178 @@ +// Package providers provides the service layer for providers view data and logic. +package providers + +import ( + "fmt" + + "github.com/royisme/bobamixer/internal/domain/core" + "github.com/royisme/bobamixer/internal/ui/components" + "github.com/royisme/bobamixer/internal/ui/forms" +) + +// Service encapsulates provider-related UI logic such as form handling and +// table data preparation so the root model can stay focused on orchestration. +type Service struct { + providers *core.ProvidersConfig + secrets *core.SecretsConfig + form *forms.ProviderForm + msgNoSelection string + msgInvalid string +} + +// NewService wires the provider config, secrets config, and backing form into +// a dedicated helper for the providers view. +func NewService( + providers *core.ProvidersConfig, + secrets *core.SecretsConfig, + form *forms.ProviderForm, + noSelectionMsg string, + invalidMsg string, +) *Service { + return &Service{ + providers: providers, + secrets: secrets, + form: form, + msgNoSelection: noSelectionMsg, + msgInvalid: invalidMsg, + } +} + +// StartForm starts the provider form either in add mode or edit mode depending +// on the provided flags and selection indexes. +func (s *Service) StartForm(add bool, indexes []int, selectedIndex int) bool { + if s.form == nil { + return false + } + + if add { + s.form.Start(true, core.Provider{}, -1, s.providers) + s.form.SetMessage("") + return true + } + + if len(indexes) == 0 || selectedIndex < 0 || selectedIndex >= len(indexes) { + s.form.SetMessage(s.msgNoSelection) + return false + } + + if s.providers == nil { + s.form.SetMessage(s.msgInvalid) + return false + } + + targetIdx := indexes[selectedIndex] + if targetIdx < 0 || targetIdx >= len(s.providers.Providers) { + s.form.SetMessage(s.msgInvalid) + return false + } + + provider := s.providers.Providers[targetIdx] + s.form.Start(false, provider, targetIdx, s.providers) + s.form.SetMessage("") + return true +} + +// Save commits the provider currently captured by the form to disk. +func (s *Service) Save(home string) error { + if s.form == nil || s.providers == nil { + return fmt.Errorf("provider service not initialized") + } + + provider := s.form.Provider() + index := s.form.Index() + + if provider.ID == "" { + s.form.SetMessage("provider ID is required") + return fmt.Errorf("provider ID is required") + } + + if s.form.AddMode() { + s.providers.Providers = append(s.providers.Providers, provider) + } else if index >= 0 && index < len(s.providers.Providers) { + s.providers.Providers[index] = provider + } else { + s.form.SetMessage(s.msgInvalid) + return fmt.Errorf("invalid provider index") + } + + if err := core.SaveProviders(home, s.providers); err != nil { + s.form.SetMessage(fmt.Sprintf("failed to save provider: %v", err)) + return err + } + + if s.form.AddMode() { + s.form.SetMessage(fmt.Sprintf("provider %s created", provider.DisplayName)) + } else { + s.form.SetMessage(fmt.Sprintf("provider %s updated", provider.DisplayName)) + } + return nil +} + +// Rows converts the filtered provider indexes into table rows for the page. +func (s *Service) Rows(indexes []int) []components.ProviderRow { + if s.providers == nil || len(indexes) == 0 { + return nil + } + + result := make([]components.ProviderRow, 0, len(indexes)) + for _, idx := range indexes { + if idx < 0 || idx >= len(s.providers.Providers) { + continue + } + + provider := s.providers.Providers[idx] + hasKey := false + if s.secrets != nil { + if _, err := core.ResolveAPIKey(&provider, s.secrets); err == nil { + hasKey = true + } + } + + result = append(result, components.ProviderRow{ + DisplayName: provider.DisplayName, + BaseURL: provider.BaseURL, + DefaultModel: provider.DefaultModel, + Enabled: provider.Enabled, + HasAPIKey: hasKey, + }) + } + + return result +} + +// Details returns the sidebar details for the currently selected provider. +func (s *Service) Details(indexes []int, selectedIndex int) *components.ProviderDetails { + if s.providers == nil || len(indexes) == 0 || selectedIndex < 0 || selectedIndex >= len(indexes) { + return nil + } + + idx := indexes[selectedIndex] + if idx < 0 || idx >= len(s.providers.Providers) { + return nil + } + + provider := s.providers.Providers[idx] + details := components.ProviderDetails{ + ID: provider.ID, + Kind: string(provider.Kind), + APIKeySource: string(provider.APIKey.Source), + } + + if provider.APIKey.Source == core.APIKeySourceEnv && provider.APIKey.EnvVar != "" { + details.EnvVar = provider.APIKey.EnvVar + details.ShowEnvVar = true + } + + return &details +} + +// EmptyStateMessage builds the empty-state hint for the providers page. +func (s *Service) EmptyStateMessage(isEmpty bool, hasSearch bool) string { + if !isEmpty { + return "" + } + if hasSearch { + return "No providers match the current filter." + } + return "No providers configured." +} diff --git a/internal/ui/features/providers/service_test.go b/internal/ui/features/providers/service_test.go new file mode 100644 index 0000000..dc34cea --- /dev/null +++ b/internal/ui/features/providers/service_test.go @@ -0,0 +1,474 @@ +package providers + +import ( + "testing" + + "github.com/royisme/bobamixer/internal/domain/core" + "github.com/royisme/bobamixer/internal/ui/forms" +) + +func TestNewService(t *testing.T) { + providers := &core.ProvidersConfig{} + secrets := &core.SecretsConfig{} + form := &forms.ProviderForm{} + msgNoSelection := "no selection" + msgInvalid := "invalid" + + svc := NewService(providers, secrets, form, msgNoSelection, msgInvalid) + + if svc == nil { + t.Fatal("expected service to be created") + } + if svc.providers != providers { + t.Error("providers not set correctly") + } + if svc.secrets != secrets { + t.Error("secrets not set correctly") + } + if svc.form != form { + t.Error("form not set correctly") + } + if svc.msgNoSelection != msgNoSelection { + t.Error("msgNoSelection not set correctly") + } + if svc.msgInvalid != msgInvalid { + t.Error("msgInvalid not set correctly") + } +} + +func TestStartForm_AddMode(t *testing.T) { + providers := &core.ProvidersConfig{} + form := forms.NewProviderForm("> ") + svc := NewService(providers, &core.SecretsConfig{}, &form, "no selection", "invalid") + + result := svc.StartForm(true, []int{}, 0) + + if !result { + t.Error("StartForm should return true in add mode") + } + if !form.AddMode() { + t.Error("Form should be in add mode") + } +} + +func TestStartForm_EditMode_Success(t *testing.T) { + providers := &core.ProvidersConfig{ + Providers: []core.Provider{ + { + ID: "provider1", + DisplayName: "Provider 1", + Kind: "openai", + BaseURL: "https://api.openai.com/v1", + }, + }, + } + form := forms.NewProviderForm("> ") + svc := NewService(providers, &core.SecretsConfig{}, &form, "no selection", "invalid") + + indexes := []int{0} + result := svc.StartForm(false, indexes, 0) + + if !result { + t.Error("StartForm should return true for valid edit") + } + if form.AddMode() { + t.Error("Form should not be in add mode") + } +} + +func TestStartForm_EditMode_NoSelection(t *testing.T) { + providers := &core.ProvidersConfig{} + form := forms.NewProviderForm("> ") + svc := NewService(providers, &core.SecretsConfig{}, &form, "no selection", "invalid") + + result := svc.StartForm(false, []int{}, 0) + + if result { + t.Error("StartForm should return false when no selection") + } +} + +func TestStartForm_EditMode_InvalidIndex(t *testing.T) { + providers := &core.ProvidersConfig{ + Providers: []core.Provider{ + {ID: "provider1", DisplayName: "Provider 1"}, + }, + } + form := forms.NewProviderForm("> ") + svc := NewService(providers, &core.SecretsConfig{}, &form, "no selection", "invalid") + + indexes := []int{0} + result := svc.StartForm(false, indexes, 5) + + if result { + t.Error("StartForm should return false for invalid index") + } +} + +func TestStartForm_EditMode_NilProviders(t *testing.T) { + form := forms.NewProviderForm("> ") + svc := NewService(nil, &core.SecretsConfig{}, &form, "no selection", "invalid") + + indexes := []int{0} + result := svc.StartForm(false, indexes, 0) + + if result { + t.Error("StartForm should return false when providers is nil") + } +} + +func TestStartForm_NilForm(t *testing.T) { + providers := &core.ProvidersConfig{} + svc := NewService(providers, &core.SecretsConfig{}, nil, "no selection", "invalid") + + result := svc.StartForm(true, []int{}, 0) + + if result { + t.Error("StartForm should return false when form is nil") + } +} + +func TestSave_AddMode(t *testing.T) { + t.Skip("Skipping form-dependent test - requires form mock or refactor") +} + +func TestSave_EditMode(t *testing.T) { + t.Skip("Skipping form-dependent test - requires form mock or refactor") +} + +func TestSave_EmptyID(t *testing.T) { + t.Skip("Skipping form-dependent test - requires form mock or refactor") +} + +func TestSave_NilForm(t *testing.T) { + tmpDir := t.TempDir() + providers := &core.ProvidersConfig{} + svc := NewService(providers, &core.SecretsConfig{}, nil, "no selection", "invalid") + + err := svc.Save(tmpDir) + + if err == nil { + t.Error("Save should fail when form is nil") + } +} + +func TestSave_NilProviders(t *testing.T) { + tmpDir := t.TempDir() + form := forms.NewProviderForm("> ") + svc := NewService(nil, &core.SecretsConfig{}, &form, "no selection", "invalid") + + err := svc.Save(tmpDir) + + if err == nil { + t.Error("Save should fail when providers is nil") + } +} + +func TestSave_InvalidIndex(t *testing.T) { + t.Skip("Skipping form-dependent test - requires form mock or refactor") +} + +func TestRows_Success(t *testing.T) { + providers := &core.ProvidersConfig{ + Providers: []core.Provider{ + { + ID: "provider1", + DisplayName: "Provider 1", + BaseURL: "https://api1.com", + DefaultModel: "model-1", + Enabled: true, + APIKey: core.APIKeyConfig{ + Source: core.APIKeySourceSecrets, + }, + }, + { + ID: "provider2", + DisplayName: "Provider 2", + BaseURL: "https://api2.com", + DefaultModel: "model-2", + Enabled: false, + APIKey: core.APIKeyConfig{ + Source: core.APIKeySourceSecrets, + }, + }, + }, + } + secrets := &core.SecretsConfig{ + Secrets: map[string]core.Secret{ + "provider1": {APIKey: "key1"}, + }, + } + svc := NewService(providers, secrets, nil, "", "") + + indexes := []int{0, 1} + rows := svc.Rows(indexes) + + if len(rows) != 2 { + t.Fatalf("Expected 2 rows, got %d", len(rows)) + } + + // Check first row + if rows[0].DisplayName != "Provider 1" { + t.Errorf("Row 0 DisplayName: got %q, want %q", rows[0].DisplayName, "Provider 1") + } + if rows[0].BaseURL != "https://api1.com" { + t.Errorf("Row 0 BaseURL: got %q, want %q", rows[0].BaseURL, "https://api1.com") + } + if rows[0].DefaultModel != "model-1" { + t.Errorf("Row 0 DefaultModel: got %q, want %q", rows[0].DefaultModel, "model-1") + } + if !rows[0].Enabled { + t.Error("Row 0 should be enabled") + } + if !rows[0].HasAPIKey { + t.Error("Row 0 should have API key") + } + + // Check second row + if rows[1].DisplayName != "Provider 2" { + t.Errorf("Row 1 DisplayName: got %q, want %q", rows[1].DisplayName, "Provider 2") + } + if rows[1].Enabled { + t.Error("Row 1 should not be enabled") + } + if rows[1].HasAPIKey { + t.Error("Row 1 should not have API key") + } +} + +func TestRows_NilProviders(t *testing.T) { + svc := NewService(nil, &core.SecretsConfig{}, nil, "", "") + + indexes := []int{0} + rows := svc.Rows(indexes) + + if rows != nil { + t.Error("Rows should return nil when providers is nil") + } +} + +func TestRows_EmptyIndexes(t *testing.T) { + providers := &core.ProvidersConfig{ + Providers: []core.Provider{ + {ID: "provider1", DisplayName: "Provider 1"}, + }, + } + svc := NewService(providers, &core.SecretsConfig{}, nil, "", "") + + rows := svc.Rows([]int{}) + + if rows != nil { + t.Error("Rows should return nil when indexes is empty") + } +} + +func TestRows_InvalidIndex(t *testing.T) { + providers := &core.ProvidersConfig{ + Providers: []core.Provider{ + {ID: "provider1", DisplayName: "Provider 1"}, + }, + } + svc := NewService(providers, &core.SecretsConfig{}, nil, "", "") + + indexes := []int{0, 99, -1} + rows := svc.Rows(indexes) + + // Should only return valid row + if len(rows) != 1 { + t.Fatalf("Expected 1 row (skipping invalid indexes), got %d", len(rows)) + } +} + +func TestRows_NilSecrets(t *testing.T) { + providers := &core.ProvidersConfig{ + Providers: []core.Provider{ + {ID: "provider1", DisplayName: "Provider 1"}, + }, + } + svc := NewService(providers, nil, nil, "", "") + + indexes := []int{0} + rows := svc.Rows(indexes) + + if len(rows) != 1 { + t.Fatalf("Expected 1 row, got %d", len(rows)) + } + + // Should have no API key when secrets is nil + if rows[0].HasAPIKey { + t.Error("Row should not have API key when secrets is nil") + } +} + +func TestDetails_Success(t *testing.T) { + providers := &core.ProvidersConfig{ + Providers: []core.Provider{ + { + ID: "provider1", + Kind: "openai", + BaseURL: "https://api.openai.com/v1", + APIKey: core.APIKeyConfig{ + Source: core.APIKeySourceEnv, + EnvVar: "OPENAI_API_KEY", + }, + }, + }, + } + svc := NewService(providers, &core.SecretsConfig{}, nil, "", "") + + indexes := []int{0} + details := svc.Details(indexes, 0) + + if details == nil { + t.Fatal("Details should not be nil") + } + + if details.ID != "provider1" { + t.Errorf("ID: got %q, want %q", details.ID, "provider1") + } + if details.Kind != "openai" { + t.Errorf("Kind: got %q, want %q", details.Kind, "openai") + } + if details.APIKeySource != string(core.APIKeySourceEnv) { + t.Errorf("APIKeySource: got %q, want %q", details.APIKeySource, string(core.APIKeySourceEnv)) + } + if !details.ShowEnvVar { + t.Error("ShowEnvVar should be true") + } + if details.EnvVar != "OPENAI_API_KEY" { + t.Errorf("EnvVar: got %q, want %q", details.EnvVar, "OPENAI_API_KEY") + } +} + +func TestDetails_SecretsSource(t *testing.T) { + providers := &core.ProvidersConfig{ + Providers: []core.Provider{ + { + ID: "provider1", + Kind: "openai", + APIKey: core.APIKeyConfig{ + Source: core.APIKeySourceSecrets, + }, + }, + }, + } + svc := NewService(providers, &core.SecretsConfig{}, nil, "", "") + + indexes := []int{0} + details := svc.Details(indexes, 0) + + if details == nil { + t.Fatal("Details should not be nil") + } + + if details.ShowEnvVar { + t.Error("ShowEnvVar should be false for secrets source") + } + if details.EnvVar != "" { + t.Error("EnvVar should be empty for secrets source") + } +} + +func TestDetails_NilProviders(t *testing.T) { + svc := NewService(nil, &core.SecretsConfig{}, nil, "", "") + + indexes := []int{0} + details := svc.Details(indexes, 0) + + if details != nil { + t.Error("Details should return nil when providers is nil") + } +} + +func TestDetails_EmptyIndexes(t *testing.T) { + providers := &core.ProvidersConfig{ + Providers: []core.Provider{ + {ID: "provider1"}, + }, + } + svc := NewService(providers, &core.SecretsConfig{}, nil, "", "") + + details := svc.Details([]int{}, 0) + + if details != nil { + t.Error("Details should return nil when indexes is empty") + } +} + +func TestDetails_InvalidSelectedIndex(t *testing.T) { + providers := &core.ProvidersConfig{ + Providers: []core.Provider{ + {ID: "provider1"}, + }, + } + svc := NewService(providers, &core.SecretsConfig{}, nil, "", "") + + indexes := []int{0} + details := svc.Details(indexes, 99) + + if details != nil { + t.Error("Details should return nil for invalid selected index") + } +} + +func TestDetails_NegativeSelectedIndex(t *testing.T) { + providers := &core.ProvidersConfig{ + Providers: []core.Provider{ + {ID: "provider1"}, + }, + } + svc := NewService(providers, &core.SecretsConfig{}, nil, "", "") + + indexes := []int{0} + details := svc.Details(indexes, -1) + + if details != nil { + t.Error("Details should return nil for negative selected index") + } +} + +func TestDetails_InvalidProviderIndex(t *testing.T) { + providers := &core.ProvidersConfig{ + Providers: []core.Provider{ + {ID: "provider1"}, + }, + } + svc := NewService(providers, &core.SecretsConfig{}, nil, "", "") + + indexes := []int{99} + details := svc.Details(indexes, 0) + + if details != nil { + t.Error("Details should return nil for invalid provider index") + } +} + +func TestEmptyStateMessage_Empty_NoSearch(t *testing.T) { + svc := NewService(nil, nil, nil, "", "") + + msg := svc.EmptyStateMessage(true, false) + + if msg != "No providers configured." { + t.Errorf("Message: got %q, want %q", msg, "No providers configured.") + } +} + +func TestEmptyStateMessage_Empty_WithSearch(t *testing.T) { + svc := NewService(nil, nil, nil, "", "") + + msg := svc.EmptyStateMessage(true, true) + + if msg != "No providers match the current filter." { + t.Errorf("Message: got %q, want %q", msg, "No providers match the current filter.") + } +} + +func TestEmptyStateMessage_NotEmpty(t *testing.T) { + svc := NewService(nil, nil, nil, "", "") + + msg := svc.EmptyStateMessage(false, false) + + if msg != "" { + t.Errorf("Message: got %q, want empty string", msg) + } +} diff --git a/internal/ui/features/proxy/service.go b/internal/ui/features/proxy/service.go new file mode 100644 index 0000000..78dede0 --- /dev/null +++ b/internal/ui/features/proxy/service.go @@ -0,0 +1,71 @@ +// Package proxy provides the service layer for proxy view data and logic. +package proxy + +import ( + "fmt" +) + +// Status values used by the proxy service. +const ( + StatusRunning = "running" + StatusStopped = "stopped" + StatusChecking = "checking" +) + +// ViewData holds all state needed to render the proxy page. +type ViewData struct { + StatusIcon string + StatusText string + AdditionalNote string + ShowConfig bool + InfoLines []string + ConfigLines []string + CommandHelp string + Address string +} + +// Service produces proxy-specific view data. +type Service struct { + address string + infoLines []string +} + +// NewService creates a proxy service with the provided address. +func NewService(address string) *Service { + return &Service{ + address: address, + infoLines: []string{ + "The proxy server intercepts AI API requests from CLI tools", + "and routes them through BobaMixer for tracking and control.", + }, + } +} + +// ViewData builds all proxy page content based on the proxy status. +func (s *Service) ViewData(status string) ViewData { + data := ViewData{ + InfoLines: s.infoLines, + ConfigLines: []string{ + fmt.Sprintf("Tools with proxy enabled automatically use HTTP_PROXY=%s", s.address), + fmt.Sprintf("and HTTPS_PROXY=%s", s.address), + }, + CommandHelp: "[S] Refresh Status", + Address: s.address, + } + + switch status { + case StatusRunning: + data.StatusIcon = "●" + data.StatusText = "Running" + data.ShowConfig = true + case StatusStopped: + data.StatusIcon = "○" + data.StatusText = "Stopped" + data.AdditionalNote = "Note: Use 'boba proxy serve' in terminal to start the proxy server" + default: + data.StatusIcon = "⋯" + data.StatusText = "Checking..." + } + + return data +} diff --git a/internal/ui/features/proxy/service_test.go b/internal/ui/features/proxy/service_test.go new file mode 100644 index 0000000..0f7eb61 --- /dev/null +++ b/internal/ui/features/proxy/service_test.go @@ -0,0 +1,225 @@ +package proxy + +import ( + "testing" +) + +const ( + testAddress = "http://localhost:8080" + testStatusIcon = "⋯" + testStatusText = "Checking..." +) + +func TestNewService(t *testing.T) { + address := testAddress + svc := NewService(address) + + if svc == nil { + t.Fatal("expected service to be created") + } + if svc.address != address { + t.Errorf("address: got %q, want %q", svc.address, address) + } + if len(svc.infoLines) == 0 { + t.Error("infoLines should not be empty") + } +} + +func TestViewData_StatusRunning(t *testing.T) { + address := testAddress + svc := NewService(address) + + data := svc.ViewData(StatusRunning) + + if data.StatusIcon != "●" { + t.Errorf("StatusIcon: got %q, want %q", data.StatusIcon, "●") + } + if data.StatusText != "Running" { + t.Errorf("StatusText: got %q, want %q", data.StatusText, "Running") + } + if !data.ShowConfig { + t.Error("ShowConfig should be true when running") + } + if data.AdditionalNote != "" { + t.Error("AdditionalNote should be empty when running") + } + if data.Address != address { + t.Errorf("Address: got %q, want %q", data.Address, address) + } + if data.CommandHelp != "[S] Refresh Status" { + t.Errorf("CommandHelp: got %q, want %q", data.CommandHelp, "[S] Refresh Status") + } +} + +func TestViewData_StatusStopped(t *testing.T) { + address := testAddress + svc := NewService(address) + + data := svc.ViewData(StatusStopped) + + if data.StatusIcon != "○" { + t.Errorf("StatusIcon: got %q, want %q", data.StatusIcon, "○") + } + if data.StatusText != "Stopped" { + t.Errorf("StatusText: got %q, want %q", data.StatusText, "Stopped") + } + if data.ShowConfig { + t.Error("ShowConfig should be false when stopped") + } + if data.AdditionalNote == "" { + t.Error("AdditionalNote should not be empty when stopped") + } + if data.Address != address { + t.Errorf("Address: got %q, want %q", data.Address, address) + } +} + +func TestViewData_StatusChecking(t *testing.T) { + address := testAddress + svc := NewService(address) + + data := svc.ViewData(StatusChecking) + + if data.StatusIcon != testStatusIcon { + t.Errorf("StatusIcon: got %q, want %q", data.StatusIcon, testStatusIcon) + } + if data.StatusText != testStatusText { + t.Errorf("StatusText: got %q, want %q", data.StatusText, testStatusText) + } + if data.ShowConfig { + t.Error("ShowConfig should be false when checking") + } +} + +func TestViewData_UnknownStatus(t *testing.T) { + svc := NewService(testAddress) + + data := svc.ViewData("unknown") + + if data.StatusIcon != testStatusIcon { + t.Errorf("StatusIcon: got %q, want %q", data.StatusIcon, testStatusIcon) + } + if data.StatusText != testStatusText { + t.Errorf("StatusText: got %q, want %q", data.StatusText, testStatusText) + } +} + +func TestViewData_InfoLines(t *testing.T) { + svc := NewService("http://localhost:8080") + + data := svc.ViewData(StatusRunning) + + if len(data.InfoLines) == 0 { + t.Error("InfoLines should not be empty") + } + + if len(svc.infoLines) != len(data.InfoLines) { + t.Errorf("InfoLines length: got %d, want %d", len(data.InfoLines), len(svc.infoLines)) + } + + for i, line := range svc.infoLines { + if data.InfoLines[i] != line { + t.Errorf("InfoLines[%d]: got %q, want %q", i, data.InfoLines[i], line) + } + } +} + +func TestViewData_ConfigLines(t *testing.T) { + address := "http://localhost:8080" + svc := NewService(address) + + data := svc.ViewData(StatusRunning) + + if len(data.ConfigLines) == 0 { + t.Error("ConfigLines should not be empty") + } + + // Check that config lines contain the address + foundAddress := false + for _, line := range data.ConfigLines { + if len(line) > 0 { + foundAddress = true + break + } + } + if !foundAddress { + t.Error("ConfigLines should contain address information") + } +} + +func TestViewData_DifferentAddresses(t *testing.T) { + testCases := []string{ + "http://localhost:8080", + "http://127.0.0.1:9090", + "http://proxy.local:3128", + } + + for _, addr := range testCases { + svc := NewService(addr) + data := svc.ViewData(StatusRunning) + + if data.Address != addr { + t.Errorf("Address: got %q, want %q", data.Address, addr) + } + } +} + +func TestViewData_Consistency(t *testing.T) { + svc := NewService("http://localhost:8080") + + // Call ViewData multiple times with same status + data1 := svc.ViewData(StatusRunning) + data2 := svc.ViewData(StatusRunning) + + if data1.StatusIcon != data2.StatusIcon { + t.Error("ViewData should return consistent StatusIcon") + } + if data1.StatusText != data2.StatusText { + t.Error("ViewData should return consistent StatusText") + } + if data1.ShowConfig != data2.ShowConfig { + t.Error("ViewData should return consistent ShowConfig") + } +} + +func TestConstants(t *testing.T) { + if StatusRunning != "running" { + t.Errorf("StatusRunning: got %q, want %q", StatusRunning, "running") + } + if StatusStopped != "stopped" { + t.Errorf("StatusStopped: got %q, want %q", StatusStopped, "stopped") + } + if StatusChecking != "checking" { + t.Errorf("StatusChecking: got %q, want %q", StatusChecking, "checking") + } +} + +func TestViewData_AllStatuses(t *testing.T) { + svc := NewService("http://localhost:8080") + + statuses := []struct { + status string + wantIcon string + wantText string + wantConfig bool + }{ + {StatusRunning, "●", "Running", true}, + {StatusStopped, "○", "Stopped", false}, + {StatusChecking, "⋯", "Checking...", false}, + {"invalid", "⋯", "Checking...", false}, + } + + for _, tc := range statuses { + data := svc.ViewData(tc.status) + + if data.StatusIcon != tc.wantIcon { + t.Errorf("Status %q - StatusIcon: got %q, want %q", tc.status, data.StatusIcon, tc.wantIcon) + } + if data.StatusText != tc.wantText { + t.Errorf("Status %q - StatusText: got %q, want %q", tc.status, data.StatusText, tc.wantText) + } + if data.ShowConfig != tc.wantConfig { + t.Errorf("Status %q - ShowConfig: got %v, want %v", tc.status, data.ShowConfig, tc.wantConfig) + } + } +} diff --git a/internal/ui/features/reports/service.go b/internal/ui/features/reports/service.go new file mode 100644 index 0000000..13d82d8 --- /dev/null +++ b/internal/ui/features/reports/service.go @@ -0,0 +1,53 @@ +// Package reports provides the service layer for reports view data and logic. +package reports + +import "github.com/royisme/bobamixer/internal/ui/components" + +// Option represents a selectable report configuration. +type Option struct { + Label string + Desc string +} + +// Service manages read-only report metadata for the reports view. +type Service struct { + options []Option + commandHelp string +} + +// NewService returns a service seeded with default report options. +func NewService() *Service { + return &Service{ + options: []Option{ + {Label: "Last 7 Days Report", Desc: "Generate usage report for the past 7 days"}, + {Label: "Last 30 Days Report", Desc: "Generate monthly usage report"}, + {Label: "Custom Date Range", Desc: "Specify custom start and end dates"}, + {Label: "JSON Format", Desc: "Export report as JSON (default)"}, + {Label: "CSV Format", Desc: "Export report as CSV for spreadsheet tools"}, + {Label: "HTML Format", Desc: "Generate visual HTML report with charts"}, + }, + commandHelp: "Use CLI: boba report --format --days --out ", + } +} + +// OptionCount returns the number of available report options. +func (s *Service) OptionCount() int { + return len(s.options) +} + +// Options converts the configured options into UI component data. +func (s *Service) Options() []components.ReportOption { + result := make([]components.ReportOption, len(s.options)) + for i, opt := range s.options { + result[i] = components.ReportOption{ + Label: opt.Label, + Desc: opt.Desc, + } + } + return result +} + +// CommandHelp returns the CLI instructions for generating reports. +func (s *Service) CommandHelp() string { + return s.commandHelp +} diff --git a/internal/ui/features/reports/service_test.go b/internal/ui/features/reports/service_test.go new file mode 100644 index 0000000..324d093 --- /dev/null +++ b/internal/ui/features/reports/service_test.go @@ -0,0 +1,212 @@ +package reports + +import ( + "testing" +) + +func TestNewService(t *testing.T) { + svc := NewService() + + if svc == nil { + t.Fatal("expected service to be created") + } + if len(svc.options) == 0 { + t.Error("options should not be empty") + } + if svc.commandHelp == "" { + t.Error("commandHelp should not be empty") + } +} + +func TestOptionCount(t *testing.T) { + svc := NewService() + + count := svc.OptionCount() + + if count == 0 { + t.Fatal("OptionCount should return non-zero value") + } + + expectedCount := 6 + if count != expectedCount { + t.Errorf("OptionCount: got %d, want %d", count, expectedCount) + } +} + +func TestOptions(t *testing.T) { + svc := NewService() + + options := svc.Options() + + if len(options) == 0 { + t.Fatal("Options should return non-empty list") + } + + if len(options) != len(svc.options) { + t.Errorf("Options length: got %d, want %d", len(options), len(svc.options)) + } + + for i, opt := range svc.options { + if options[i].Label != opt.Label { + t.Errorf("Option[%d].Label: got %q, want %q", i, options[i].Label, opt.Label) + } + if options[i].Desc != opt.Desc { + t.Errorf("Option[%d].Desc: got %q, want %q", i, options[i].Desc, opt.Desc) + } + } +} + +func TestOptions_AllFieldsNonEmpty(t *testing.T) { + svc := NewService() + + options := svc.Options() + + for i, opt := range options { + if opt.Label == "" { + t.Errorf("Option[%d].Label should not be empty", i) + } + if opt.Desc == "" { + t.Errorf("Option[%d].Desc should not be empty", i) + } + } +} + +func TestCommandHelp(t *testing.T) { + svc := NewService() + + help := svc.CommandHelp() + + if help == "" { + t.Error("CommandHelp should return non-empty string") + } + + expectedHelp := "Use CLI: boba report --format --days --out " + if help != expectedHelp { + t.Errorf("CommandHelp: got %q, want %q", help, expectedHelp) + } +} + +func TestOptions_ExpectedOptions(t *testing.T) { + svc := NewService() + + options := svc.Options() + + expectedLabels := []string{ + "Last 7 Days Report", + "Last 30 Days Report", + "Custom Date Range", + "JSON Format", + "CSV Format", + "HTML Format", + } + + if len(options) != len(expectedLabels) { + t.Fatalf("Expected %d options, got %d", len(expectedLabels), len(options)) + } + + for i, expected := range expectedLabels { + if options[i].Label != expected { + t.Errorf("Option[%d].Label: got %q, want %q", i, options[i].Label, expected) + } + } +} + +func TestOptions_Consistency(t *testing.T) { + svc := NewService() + + // Call Options multiple times + options1 := svc.Options() + options2 := svc.Options() + + if len(options1) != len(options2) { + t.Error("Options should return consistent results") + } + + for i := range options1 { + if options1[i].Label != options2[i].Label { + t.Errorf("Inconsistent Label at index %d", i) + } + if options1[i].Desc != options2[i].Desc { + t.Errorf("Inconsistent Desc at index %d", i) + } + } +} + +func TestOptionCount_MatchesOptionsLength(t *testing.T) { + svc := NewService() + + count := svc.OptionCount() + options := svc.Options() + + if count != len(options) { + t.Errorf("OptionCount (%d) should match Options length (%d)", count, len(options)) + } +} + +func TestNewService_DefaultOptions(t *testing.T) { + svc := NewService() + + // Verify specific expected options exist + expectedOptions := map[string]bool{ + "Last 7 Days Report": false, + "Last 30 Days Report": false, + "JSON Format": false, + "CSV Format": false, + "HTML Format": false, + } + + for _, opt := range svc.options { + if _, exists := expectedOptions[opt.Label]; exists { + expectedOptions[opt.Label] = true + } + } + + for label, found := range expectedOptions { + if !found { + t.Errorf("Expected option %q not found", label) + } + } +} + +func TestOptions_TimeRangeOptions(t *testing.T) { + svc := NewService() + + options := svc.Options() + + // Check that time range options exist + hasTimeRange := false + for _, opt := range options { + if opt.Label == "Last 7 Days Report" || opt.Label == "Last 30 Days Report" || opt.Label == "Custom Date Range" { + hasTimeRange = true + if opt.Desc == "" { + t.Errorf("Time range option %q should have description", opt.Label) + } + } + } + + if !hasTimeRange { + t.Error("Should have time range options") + } +} + +func TestOptions_FormatOptions(t *testing.T) { + svc := NewService() + + options := svc.Options() + + // Check that format options exist + formats := []string{"JSON Format", "CSV Format", "HTML Format"} + foundFormats := make(map[string]bool) + + for _, opt := range options { + for _, format := range formats { + if opt.Label == format { + foundFormats[format] = true + } + } + } + + if len(foundFormats) == 0 { + t.Error("Should have format options") + } +} diff --git a/internal/ui/features/routing/service.go b/internal/ui/features/routing/service.go new file mode 100644 index 0000000..d1bfa4b --- /dev/null +++ b/internal/ui/features/routing/service.go @@ -0,0 +1,54 @@ +// Package routing provides the service layer for routing view data and logic. +package routing + +// Service manages routing view data and logic. +type Service struct{} + +// NewService creates a new routing service. +func NewService() *Service { + return &Service{} +} + +// ViewData returns all static data for the routing view. +func (s *Service) ViewData() ViewData { + return ViewData{ + Title: "BobaMixer - Routing Rules Tester", + TestTitle: "🧪 Test Routing Rules", + HowToTitle: "💡 How to Use", + ExampleTitle: "📋 Example", + ContextTitle: "ℹ️ Context Detection", + TestDescription: "Test how routing rules would apply to different queries.", + HowToSteps: []string{ + "1. Prepare a test query (text or file)", + "2. Run: boba route test \"your query text\"", + "3. Or: boba route test @path/to/file.txt", + }, + ExampleLines: []string{ + "$ boba route test \"Write a Python function\"", + "→ Profile: claude-sonnet-3.5", + "→ Rule: short-query-fast-model", + "→ Reason: Query < 100 chars", + }, + ContextLines: []string{ + "Query length and complexity", + "Current project and branch", + "Time of day (day/evening/night)", + "Project type (go, web, etc.)", + }, + CommandHelpLine: "Use CLI: boba route test ", + } +} + +// ViewData holds all data needed to render the routing view. +type ViewData struct { + Title string + TestTitle string + HowToTitle string + ExampleTitle string + ContextTitle string + TestDescription string + HowToSteps []string + ExampleLines []string + ContextLines []string + CommandHelpLine string +} diff --git a/internal/ui/features/routing/service_test.go b/internal/ui/features/routing/service_test.go new file mode 100644 index 0000000..6ef71a1 --- /dev/null +++ b/internal/ui/features/routing/service_test.go @@ -0,0 +1,168 @@ +package routing + +import ( + "testing" +) + +func TestNewService(t *testing.T) { + svc := NewService() + if svc == nil { + t.Fatal("expected service to be created") + } +} + +func TestViewData(t *testing.T) { + svc := NewService() + data := svc.ViewData() + + // Test title fields + if data.Title == "" { + t.Error("Title should not be empty") + } + if data.Title != "BobaMixer - Routing Rules Tester" { + t.Errorf("Title: got %q, want %q", data.Title, "BobaMixer - Routing Rules Tester") + } + + if data.TestTitle == "" { + t.Error("TestTitle should not be empty") + } + if data.TestTitle != "🧪 Test Routing Rules" { + t.Errorf("TestTitle: got %q, want %q", data.TestTitle, "🧪 Test Routing Rules") + } + + if data.HowToTitle == "" { + t.Error("HowToTitle should not be empty") + } + if data.HowToTitle != "💡 How to Use" { + t.Errorf("HowToTitle: got %q, want %q", data.HowToTitle, "💡 How to Use") + } + + if data.ExampleTitle == "" { + t.Error("ExampleTitle should not be empty") + } + if data.ExampleTitle != "📋 Example" { + t.Errorf("ExampleTitle: got %q, want %q", data.ExampleTitle, "📋 Example") + } + + if data.ContextTitle == "" { + t.Error("ContextTitle should not be empty") + } + if data.ContextTitle != "ℹ️ Context Detection" { + t.Errorf("ContextTitle: got %q, want %q", data.ContextTitle, "ℹ️ Context Detection") + } + + if data.TestDescription == "" { + t.Error("TestDescription should not be empty") + } + if data.TestDescription != "Test how routing rules would apply to different queries." { + t.Errorf("TestDescription: got %q, want %q", data.TestDescription, "Test how routing rules would apply to different queries.") + } + + if data.CommandHelpLine == "" { + t.Error("CommandHelpLine should not be empty") + } + if data.CommandHelpLine != "Use CLI: boba route test " { + t.Errorf("CommandHelpLine: got %q, want %q", data.CommandHelpLine, "Use CLI: boba route test ") + } +} + +func TestViewData_HowToSteps(t *testing.T) { + svc := NewService() + data := svc.ViewData() + + if len(data.HowToSteps) == 0 { + t.Fatal("HowToSteps should not be empty") + } + + expectedSteps := []string{ + "1. Prepare a test query (text or file)", + "2. Run: boba route test \"your query text\"", + "3. Or: boba route test @path/to/file.txt", + } + + if len(data.HowToSteps) != len(expectedSteps) { + t.Fatalf("HowToSteps length: got %d, want %d", len(data.HowToSteps), len(expectedSteps)) + } + + for i, expected := range expectedSteps { + if data.HowToSteps[i] != expected { + t.Errorf("HowToSteps[%d]: got %q, want %q", i, data.HowToSteps[i], expected) + } + } +} + +func TestViewData_ExampleLines(t *testing.T) { + svc := NewService() + data := svc.ViewData() + + if len(data.ExampleLines) == 0 { + t.Fatal("ExampleLines should not be empty") + } + + expectedLines := []string{ + "$ boba route test \"Write a Python function\"", + "→ Profile: claude-sonnet-3.5", + "→ Rule: short-query-fast-model", + "→ Reason: Query < 100 chars", + } + + if len(data.ExampleLines) != len(expectedLines) { + t.Fatalf("ExampleLines length: got %d, want %d", len(data.ExampleLines), len(expectedLines)) + } + + for i, expected := range expectedLines { + if data.ExampleLines[i] != expected { + t.Errorf("ExampleLines[%d]: got %q, want %q", i, data.ExampleLines[i], expected) + } + } +} + +func TestViewData_ContextLines(t *testing.T) { + svc := NewService() + data := svc.ViewData() + + if len(data.ContextLines) == 0 { + t.Fatal("ContextLines should not be empty") + } + + expectedLines := []string{ + "Query length and complexity", + "Current project and branch", + "Time of day (day/evening/night)", + "Project type (go, web, etc.)", + } + + if len(data.ContextLines) != len(expectedLines) { + t.Fatalf("ContextLines length: got %d, want %d", len(data.ContextLines), len(expectedLines)) + } + + for i, expected := range expectedLines { + if data.ContextLines[i] != expected { + t.Errorf("ContextLines[%d]: got %q, want %q", i, data.ContextLines[i], expected) + } + } +} + +func TestViewData_Consistency(t *testing.T) { + svc := NewService() + + // Call ViewData multiple times to ensure consistency + data1 := svc.ViewData() + data2 := svc.ViewData() + + if data1.Title != data2.Title { + t.Error("ViewData should return consistent results") + } + + if len(data1.HowToSteps) != len(data2.HowToSteps) { + t.Error("ViewData should return consistent HowToSteps") + } + + if len(data1.ExampleLines) != len(data2.ExampleLines) { + t.Error("ViewData should return consistent ExampleLines") + } + + if len(data1.ContextLines) != len(data2.ContextLines) { + t.Error("ViewData should return consistent ContextLines") + } +} diff --git a/internal/ui/features/secrets/service.go b/internal/ui/features/secrets/service.go new file mode 100644 index 0000000..bb27582 --- /dev/null +++ b/internal/ui/features/secrets/service.go @@ -0,0 +1,212 @@ +// Package secrets provides the service layer for secrets view data and logic. +package secrets + +import ( + "fmt" + "strings" + + "github.com/royisme/bobamixer/internal/domain/core" + "github.com/royisme/bobamixer/internal/ui/components" + "github.com/royisme/bobamixer/internal/ui/forms" +) + +// Service manages secrets-related UI logic independently of the root model. +type Service struct { + Providers *core.ProvidersConfig + secrets **core.SecretsConfig + form *forms.SecretForm + message *string + msgNoSelection string + msgInvalid string +} + +// NewService wires the backing configs, form, and message target for secret management. +func NewService( + providers *core.ProvidersConfig, + secrets **core.SecretsConfig, + form *forms.SecretForm, + message *string, + noSelectionMsg string, + invalidMsg string, +) *Service { + return &Service{ + Providers: providers, + secrets: secrets, + form: form, + message: message, + msgNoSelection: noSelectionMsg, + msgInvalid: invalidMsg, + } +} + +// StartForm prepares the secret form for the currently selected provider. +func (s *Service) StartForm(indexes []int, selectedIndex int) bool { + if len(indexes) == 0 || selectedIndex < 0 || selectedIndex >= len(indexes) { + s.setMessage(s.msgNoSelection) + return false + } + + targetIdx := indexes[selectedIndex] + if targetIdx < 0 || targetIdx >= len(s.Providers.Providers) { + s.setMessage(s.msgInvalid) + return false + } + + provider := s.Providers.Providers[targetIdx] + s.form.Start(targetIdx, provider.DisplayName) + s.form.SetMessage("") + s.setMessage("") + return true +} + +// SaveValue persists the submitted secret for the current provider. +func (s *Service) SaveValue(home string, value string) { + targetIdx := s.form.TargetIndex() + if targetIdx < 0 || targetIdx >= len(s.Providers.Providers) { + s.setMessage(s.msgInvalid) + return + } + + trimmed := strings.TrimSpace(value) + if trimmed == "" { + s.form.SetMessage("API key cannot be empty") + s.setMessage("API key cannot be empty") + return + } + + provider := s.Providers.Providers[targetIdx] + cfg := s.ensureConfig() + cfg.Secrets[provider.ID] = core.Secret{ + ProviderID: provider.ID, + APIKey: trimmed, + } + + if err := core.SaveSecrets(home, cfg); err != nil { + msg := fmt.Sprintf("Failed to save API key: %v", err) + s.form.SetMessage(msg) + s.setMessage(msg) + return + } + + msg := fmt.Sprintf("API key saved for %s", provider.DisplayName) + s.form.SetMessage(msg) + s.setMessage(msg) +} + +// Remove deletes the stored secret for the selected provider. +func (s *Service) Remove(home string, indexes []int, selectedIndex int) { + if len(indexes) == 0 || selectedIndex < 0 || selectedIndex >= len(indexes) { + s.setMessage(s.msgNoSelection) + return + } + + targetIdx := indexes[selectedIndex] + if targetIdx < 0 || targetIdx >= len(s.Providers.Providers) { + s.setMessage(s.msgInvalid) + return + } + + cfg := s.ensureConfig() + provider := s.Providers.Providers[targetIdx] + if _, ok := cfg.Secrets[provider.ID]; !ok { + s.setMessage(fmt.Sprintf("No API key found for %s", provider.DisplayName)) + return + } + + delete(cfg.Secrets, provider.ID) + if err := core.SaveSecrets(home, cfg); err != nil { + s.setMessage(fmt.Sprintf("Failed to remove API key: %v", err)) + return + } + + s.setMessage(fmt.Sprintf("Removed API key for %s", provider.DisplayName)) +} + +// Test validates whether a secret exists for the selected provider. +func (s *Service) Test(indexes []int, selectedIndex int) { + if len(indexes) == 0 || selectedIndex < 0 || selectedIndex >= len(indexes) { + s.setMessage(s.msgNoSelection) + return + } + + targetIdx := indexes[selectedIndex] + if targetIdx < 0 || targetIdx >= len(s.Providers.Providers) { + s.setMessage(s.msgInvalid) + return + } + + provider := s.Providers.Providers[targetIdx] + cfg := *s.secrets + if cfg == nil { + s.setMessage("API key missing: no secrets configured") + return + } + + if _, err := core.ResolveAPIKey(&provider, cfg); err != nil { + s.setMessage(fmt.Sprintf("API key missing: %v", err)) + return + } + + s.setMessage(fmt.Sprintf("API key available for %s", provider.DisplayName)) +} + +// Rows converts providers into UI rows annotated with secret status. +func (s *Service) Rows(indexes []int) []components.SecretProviderRow { + cfg := *s.secrets + if s.Providers == nil || cfg == nil || len(indexes) == 0 { + return nil + } + + result := make([]components.SecretProviderRow, 0, len(indexes)) + for _, idx := range indexes { + if idx < 0 || idx >= len(s.Providers.Providers) { + continue + } + + provider := s.Providers.Providers[idx] + hasKey := false + keySource := "(not set)" + if _, err := core.ResolveAPIKey(&provider, cfg); err == nil { + hasKey = true + keySource = string(provider.APIKey.Source) + } + + result = append(result, components.SecretProviderRow{ + DisplayName: provider.DisplayName, + HasKey: hasKey, + KeySource: keySource, + }) + } + + return result +} + +// EmptyStateMessage describes secrets empty states with optional search context. +func EmptyStateMessage(isEmpty bool, hasSearch bool) string { + if !isEmpty { + return "" + } + if hasSearch { + return "No providers match the current filter." + } + return "No providers configured." +} + +func (s *Service) ensureConfig() *core.SecretsConfig { + if *s.secrets == nil { + *s.secrets = &core.SecretsConfig{ + Version: 1, + Secrets: make(map[string]core.Secret), + } + } + if (*s.secrets).Secrets == nil { + (*s.secrets).Secrets = make(map[string]core.Secret) + } + return *s.secrets +} + +func (s *Service) setMessage(msg string) { + if s.message != nil { + *s.message = msg + } +} diff --git a/internal/ui/features/secrets/service_test.go b/internal/ui/features/secrets/service_test.go new file mode 100644 index 0000000..b50632a --- /dev/null +++ b/internal/ui/features/secrets/service_test.go @@ -0,0 +1,288 @@ +package secrets + +import ( + "testing" + + "github.com/royisme/bobamixer/internal/domain/core" + "github.com/royisme/bobamixer/internal/ui/forms" +) + +func TestNewService(t *testing.T) { + providers := &core.ProvidersConfig{} + secrets := &core.SecretsConfig{} + form := &forms.SecretForm{} + message := "" + msgNoSelection := "no selection" + msgInvalid := "invalid" + + svc := NewService(providers, &secrets, form, &message, msgNoSelection, msgInvalid) + + if svc == nil { + t.Fatal("expected service to be created") + } +} + +func TestStartForm_EmptyIndexes(t *testing.T) { + providers := &core.ProvidersConfig{ + Providers: []core.Provider{ + {ID: "provider1", DisplayName: "Provider 1"}, + }, + } + secrets := &core.SecretsConfig{} + form := &forms.SecretForm{} + message := "" + svc := NewService(providers, &secrets, form, &message, "no selection", "invalid") + + result := svc.StartForm([]int{}, 0) + + if result { + t.Error("StartForm should return false when indexes is empty") + } +} + +func TestStartForm_InvalidSelectedIndex(t *testing.T) { + providers := &core.ProvidersConfig{ + Providers: []core.Provider{ + {ID: "provider1", DisplayName: "Provider 1"}, + }, + } + secrets := &core.SecretsConfig{} + form := &forms.SecretForm{} + message := "" + svc := NewService(providers, &secrets, form, &message, "no selection", "invalid") + + indexes := []int{0} + result := svc.StartForm(indexes, 99) + + if result { + t.Error("StartForm should return false for invalid selected index") + } +} + +func TestStartForm_NegativeIndex(t *testing.T) { + providers := &core.ProvidersConfig{ + Providers: []core.Provider{ + {ID: "provider1", DisplayName: "Provider 1"}, + }, + } + secrets := &core.SecretsConfig{} + form := &forms.SecretForm{} + message := "" + svc := NewService(providers, &secrets, form, &message, "no selection", "invalid") + + indexes := []int{0} + result := svc.StartForm(indexes, -1) + + if result { + t.Error("StartForm should return false for negative index") + } +} + +func TestRows_Success(t *testing.T) { + providers := &core.ProvidersConfig{ + Providers: []core.Provider{ + { + ID: "provider1", + DisplayName: "Provider 1", + APIKey: core.APIKeyConfig{ + Source: core.APIKeySourceSecrets, + }, + }, + { + ID: "provider2", + DisplayName: "Provider 2", + APIKey: core.APIKeyConfig{ + Source: core.APIKeySourceSecrets, + }, + }, + }, + } + secrets := &core.SecretsConfig{ + Secrets: map[string]core.Secret{ + "provider1": {APIKey: "key1"}, + }, + } + svc := NewService(providers, &secrets, nil, nil, "", "") + + indexes := []int{0, 1} + rows := svc.Rows(indexes) + + if len(rows) != 2 { + t.Fatalf("Expected 2 rows, got %d", len(rows)) + } + + // Check first row (has secret) + if rows[0].DisplayName != "Provider 1" { + t.Errorf("Row 0 DisplayName: got %q, want %q", rows[0].DisplayName, "Provider 1") + } + if !rows[0].HasKey { + t.Error("Row 0 should have key") + } + + // Check second row (no secret) + if rows[1].DisplayName != "Provider 2" { + t.Errorf("Row 1 DisplayName: got %q, want %q", rows[1].DisplayName, "Provider 2") + } + if rows[1].HasKey { + t.Error("Row 1 should not have key") + } +} + +func TestRows_NilProviders(t *testing.T) { + secrets := &core.SecretsConfig{} + svc := NewService(nil, &secrets, nil, nil, "", "") + + indexes := []int{0} + rows := svc.Rows(indexes) + + if rows != nil { + t.Error("Rows should return nil when providers is nil") + } +} + +func TestRows_EmptyIndexes(t *testing.T) { + providers := &core.ProvidersConfig{ + Providers: []core.Provider{ + {ID: "provider1", DisplayName: "Provider 1"}, + }, + } + secrets := &core.SecretsConfig{} + svc := NewService(providers, &secrets, nil, nil, "", "") + + rows := svc.Rows([]int{}) + + if rows != nil { + t.Error("Rows should return nil when indexes is empty") + } +} + +func TestRows_InvalidIndex(t *testing.T) { + providers := &core.ProvidersConfig{ + Providers: []core.Provider{ + {ID: "provider1", DisplayName: "Provider 1"}, + }, + } + secrets := &core.SecretsConfig{} + svc := NewService(providers, &secrets, nil, nil, "", "") + + indexes := []int{0, 99, -1} + rows := svc.Rows(indexes) + + // Should only return valid row + if len(rows) != 1 { + t.Fatalf("Expected 1 row (skipping invalid indexes), got %d", len(rows)) + } +} + +func TestRows_NilSecretsConfig(t *testing.T) { + providers := &core.ProvidersConfig{ + Providers: []core.Provider{ + {ID: "provider1", DisplayName: "Provider 1"}, + }, + } + var secrets *core.SecretsConfig + svc := NewService(providers, &secrets, nil, nil, "", "") + + indexes := []int{0} + rows := svc.Rows(indexes) + + if rows != nil { + t.Error("Rows should return nil when secrets pointer is nil") + } +} + +func TestRows_EmptySecrets(t *testing.T) { + providers := &core.ProvidersConfig{ + Providers: []core.Provider{ + { + ID: "provider1", + DisplayName: "Provider 1", + APIKey: core.APIKeyConfig{ + Source: core.APIKeySourceSecrets, + }, + }, + { + ID: "provider2", + DisplayName: "Provider 2", + APIKey: core.APIKeyConfig{ + Source: core.APIKeySourceSecrets, + }, + }, + }, + } + secrets := &core.SecretsConfig{ + Secrets: map[string]core.Secret{}, + } + svc := NewService(providers, &secrets, nil, nil, "", "") + + indexes := []int{0, 1} + rows := svc.Rows(indexes) + + if len(rows) != 2 { + t.Fatalf("Expected 2 rows, got %d", len(rows)) + } + + // Both should not have keys + for i, row := range rows { + if row.HasKey { + t.Errorf("Row %d should not have key when secrets is empty", i) + } + } +} + +func TestRows_MultipleProviders(t *testing.T) { + providers := &core.ProvidersConfig{ + Providers: []core.Provider{ + { + ID: "provider1", + DisplayName: "Provider 1", + APIKey: core.APIKeyConfig{ + Source: core.APIKeySourceSecrets, + }, + }, + { + ID: "provider2", + DisplayName: "Provider 2", + APIKey: core.APIKeyConfig{ + Source: core.APIKeySourceSecrets, + }, + }, + { + ID: "provider3", + DisplayName: "Provider 3", + APIKey: core.APIKeyConfig{ + Source: core.APIKeySourceSecrets, + }, + }, + }, + } + secrets := &core.SecretsConfig{ + Secrets: map[string]core.Secret{ + "provider1": {APIKey: "key1"}, + "provider3": {APIKey: "key3"}, + }, + } + svc := NewService(providers, &secrets, nil, nil, "", "") + + indexes := []int{0, 1, 2} + rows := svc.Rows(indexes) + + if len(rows) != 3 { + t.Fatalf("Expected 3 rows, got %d", len(rows)) + } + + // Provider 1 has key + if !rows[0].HasKey { + t.Error("Provider 1 should have key") + } + + // Provider 2 does not have key + if rows[1].HasKey { + t.Error("Provider 2 should not have key") + } + + // Provider 3 has key + if !rows[2].HasKey { + t.Error("Provider 3 should have key") + } +} diff --git a/internal/ui/features/stats/service.go b/internal/ui/features/stats/service.go new file mode 100644 index 0000000..7c47f65 --- /dev/null +++ b/internal/ui/features/stats/service.go @@ -0,0 +1,121 @@ +// Package stats provides the service layer for stats view data and logic. +package stats + +import ( + "context" + "fmt" + "path/filepath" + "time" + + "github.com/royisme/bobamixer/internal/domain/stats" + "github.com/royisme/bobamixer/internal/store/sqlite" + "github.com/royisme/bobamixer/internal/ui/components" +) + +// Service manages stats data loading and conversion for the stats view. +type Service struct { + home string +} + +// NewService creates a new stats service. +func NewService(home string) *Service { + return &Service{ + home: home, + } +} + +// LoadData loads all stats data (today, week, profiles) from the database. +func (s *Service) LoadData() (StatsData, error) { + dbPath := filepath.Join(s.home, "usage.db") + db, err := sqlite.Open(dbPath) + if err != nil { + return StatsData{}, fmt.Errorf("open database: %w", err) + } + + ctx := context.Background() + + // Load today's stats + today, err := stats.Today(ctx, db) + if err != nil { + return StatsData{}, fmt.Errorf("load today stats: %w", err) + } + + // Load 7-day stats + to := time.Now() + from := to.AddDate(0, 0, -7) + week, err := stats.Window(ctx, db, from, to) + if err != nil { + return StatsData{}, fmt.Errorf("load week stats: %w", err) + } + + // Load profile stats + analyzer := stats.NewAnalyzer(db) + profileStats, err := analyzer.GetProfileStats(7) + if err != nil { + // Don't fail if profile stats can't be loaded + profileStats = []stats.ProfileStats{} + } + + return StatsData{ + Today: today, + Week: week, + ProfileStats: profileStats, + }, nil +} + +// StatsData holds the raw domain data loaded from the database. +type StatsData struct { + Today stats.Summary + Week stats.Summary + ProfileStats []stats.ProfileStats +} + +// ConvertToView converts domain stats data to UI components. +func (s *Service) ConvertToView(data StatsData) ViewData { + return ViewData{ + Today: s.convertSummary("📅 Today's Usage", data.Today, false), + Week: s.convertSummary("📊 Last 7 Days", data.Week, true), + Profiles: s.convertProfiles(data.ProfileStats), + } +} + +// ViewData holds the UI-ready data for rendering. +type ViewData struct { + Today components.StatsSummary + Week components.StatsSummary + Profiles []components.StatsProfile +} + +// convertSummary converts domain Summary to component StatsSummary. +func (s *Service) convertSummary(title string, summary stats.Summary, includeAverages bool) components.StatsSummary { + return components.StatsSummary{ + Title: title, + Tokens: summary.TotalTokens, + Cost: summary.TotalCost, + Sessions: summary.TotalSessions, + AvgDailyTokens: summary.AvgDailyTokens, + AvgDailyCost: summary.AvgDailyCost, + ShowAverages: includeAverages, + } +} + +// convertProfiles converts domain ProfileStats to component StatsProfile. +func (s *Service) convertProfiles(statsList []stats.ProfileStats) []components.StatsProfile { + if len(statsList) == 0 { + return nil + } + + result := make([]components.StatsProfile, 0, len(statsList)) + for _, ps := range statsList { + result = append(result, components.StatsProfile{ + Name: ps.ProfileName, + Tokens: ps.TotalTokens, + Cost: ps.TotalCost, + Sessions: ps.SessionCount, + AvgLatency: ps.AvgLatencyMS, + UsagePct: ps.UsagePercent, + CostPct: ps.CostPercent, + }) + } + return result +} diff --git a/internal/ui/features/stats/service_test.go b/internal/ui/features/stats/service_test.go new file mode 100644 index 0000000..3155b50 --- /dev/null +++ b/internal/ui/features/stats/service_test.go @@ -0,0 +1,283 @@ +package stats + +import ( + "testing" + + "github.com/royisme/bobamixer/internal/domain/stats" + "github.com/royisme/bobamixer/internal/ui/components" +) + +func TestNewService(t *testing.T) { + home := "/test/home" + svc := NewService(home) + if svc == nil { + t.Fatal("expected service to be created") + } + if svc.home != home { + t.Errorf("expected home %q, got %q", home, svc.home) + } +} + +func TestConvertSummary(t *testing.T) { + svc := NewService("/test") + + tests := []struct { + name string + title string + summary stats.Summary + includeAverages bool + want components.StatsSummary + }{ + { + name: "without averages", + title: "Test Summary", + summary: stats.Summary{TotalTokens: 1000, TotalCost: 0.05, TotalSessions: 5}, + includeAverages: false, + want: components.StatsSummary{ + Title: "Test Summary", + Tokens: 1000, + Cost: 0.05, + Sessions: 5, + ShowAverages: false, + }, + }, + { + name: "with averages", + title: "Weekly Stats", + summary: stats.Summary{ + TotalTokens: 7000, + TotalCost: 0.35, + TotalSessions: 35, + AvgDailyTokens: 1000.0, + AvgDailyCost: 0.05, + }, + includeAverages: true, + want: components.StatsSummary{ + Title: "Weekly Stats", + Tokens: 7000, + Cost: 0.35, + Sessions: 35, + AvgDailyTokens: 1000.0, + AvgDailyCost: 0.05, + ShowAverages: true, + }, + }, + { + name: "empty summary", + title: "Empty", + summary: stats.Summary{}, + includeAverages: false, + want: components.StatsSummary{ + Title: "Empty", + ShowAverages: false, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := svc.convertSummary(tt.title, tt.summary, tt.includeAverages) + if got.Title != tt.want.Title { + t.Errorf("Title: got %q, want %q", got.Title, tt.want.Title) + } + if got.Tokens != tt.want.Tokens { + t.Errorf("Tokens: got %d, want %d", got.Tokens, tt.want.Tokens) + } + if got.Cost != tt.want.Cost { + t.Errorf("Cost: got %.4f, want %.4f", got.Cost, tt.want.Cost) + } + if got.Sessions != tt.want.Sessions { + t.Errorf("Sessions: got %d, want %d", got.Sessions, tt.want.Sessions) + } + if got.ShowAverages != tt.want.ShowAverages { + t.Errorf("ShowAverages: got %v, want %v", got.ShowAverages, tt.want.ShowAverages) + } + if tt.includeAverages { + if got.AvgDailyTokens != tt.want.AvgDailyTokens { + t.Errorf("AvgDailyTokens: got %.2f, want %.2f", got.AvgDailyTokens, tt.want.AvgDailyTokens) + } + if got.AvgDailyCost != tt.want.AvgDailyCost { + t.Errorf("AvgDailyCost: got %.4f, want %.4f", got.AvgDailyCost, tt.want.AvgDailyCost) + } + } + }) + } +} + +func TestConvertProfiles(t *testing.T) { + svc := NewService("/test") + + tests := []struct { + name string + stats []stats.ProfileStats + want []components.StatsProfile + }{ + { + name: "empty list", + stats: []stats.ProfileStats{}, + want: nil, + }, + { + name: "single profile", + stats: []stats.ProfileStats{ + { + ProfileName: "default", + TotalTokens: 1000, + TotalCost: 0.05, + SessionCount: 10, + AvgLatencyMS: 250.5, + UsagePercent: 100.0, + CostPercent: 100.0, + }, + }, + want: []components.StatsProfile{ + { + Name: "default", + Tokens: 1000, + Cost: 0.05, + Sessions: 10, + AvgLatency: 250.5, + UsagePct: 100.0, + CostPct: 100.0, + }, + }, + }, + { + name: "multiple profiles", + stats: []stats.ProfileStats{ + { + ProfileName: "fast", + TotalTokens: 500, + TotalCost: 0.03, + SessionCount: 5, + AvgLatencyMS: 100.0, + UsagePercent: 33.3, + CostPercent: 30.0, + }, + { + ProfileName: "accurate", + TotalTokens: 1000, + TotalCost: 0.07, + SessionCount: 15, + AvgLatencyMS: 500.0, + UsagePercent: 66.7, + CostPercent: 70.0, + }, + }, + want: []components.StatsProfile{ + { + Name: "fast", + Tokens: 500, + Cost: 0.03, + Sessions: 5, + AvgLatency: 100.0, + UsagePct: 33.3, + CostPct: 30.0, + }, + { + Name: "accurate", + Tokens: 1000, + Cost: 0.07, + Sessions: 15, + AvgLatency: 500.0, + UsagePct: 66.7, + CostPct: 70.0, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := svc.convertProfiles(tt.stats) + if len(got) != len(tt.want) { + t.Fatalf("length: got %d, want %d", len(got), len(tt.want)) + } + for i := range got { + if got[i].Name != tt.want[i].Name { + t.Errorf("[%d] Name: got %q, want %q", i, got[i].Name, tt.want[i].Name) + } + if got[i].Tokens != tt.want[i].Tokens { + t.Errorf("[%d] Tokens: got %d, want %d", i, got[i].Tokens, tt.want[i].Tokens) + } + if got[i].Cost != tt.want[i].Cost { + t.Errorf("[%d] Cost: got %.4f, want %.4f", i, got[i].Cost, tt.want[i].Cost) + } + if got[i].Sessions != tt.want[i].Sessions { + t.Errorf("[%d] Sessions: got %d, want %d", i, got[i].Sessions, tt.want[i].Sessions) + } + if got[i].AvgLatency != tt.want[i].AvgLatency { + t.Errorf("[%d] AvgLatency: got %.2f, want %.2f", i, got[i].AvgLatency, tt.want[i].AvgLatency) + } + if got[i].UsagePct != tt.want[i].UsagePct { + t.Errorf("[%d] UsagePct: got %.2f, want %.2f", i, got[i].UsagePct, tt.want[i].UsagePct) + } + if got[i].CostPct != tt.want[i].CostPct { + t.Errorf("[%d] CostPct: got %.2f, want %.2f", i, got[i].CostPct, tt.want[i].CostPct) + } + } + }) + } +} + +func TestConvertToView(t *testing.T) { + svc := NewService("/test") + + data := StatsData{ + Today: stats.Summary{ + TotalTokens: 500, + TotalCost: 0.02, + TotalSessions: 3, + }, + Week: stats.Summary{ + TotalTokens: 3500, + TotalCost: 0.15, + TotalSessions: 21, + AvgDailyTokens: 500.0, + AvgDailyCost: 0.021, + }, + ProfileStats: []stats.ProfileStats{ + { + ProfileName: "default", + TotalTokens: 3500, + TotalCost: 0.15, + SessionCount: 21, + AvgLatencyMS: 300.0, + UsagePercent: 100.0, + CostPercent: 100.0, + }, + }, + } + + view := svc.ConvertToView(data) + + // Check Today + if view.Today.Title != "📅 Today's Usage" { + t.Errorf("Today.Title: got %q, want %q", view.Today.Title, "📅 Today's Usage") + } + if view.Today.Tokens != 500 { + t.Errorf("Today.Tokens: got %d, want 500", view.Today.Tokens) + } + if view.Today.ShowAverages { + t.Error("Today.ShowAverages should be false") + } + + // Check Week + if view.Week.Title != "📊 Last 7 Days" { + t.Errorf("Week.Title: got %q, want %q", view.Week.Title, "📊 Last 7 Days") + } + if view.Week.Tokens != 3500 { + t.Errorf("Week.Tokens: got %d, want 3500", view.Week.Tokens) + } + if !view.Week.ShowAverages { + t.Error("Week.ShowAverages should be true") + } + + // Check Profiles + if len(view.Profiles) != 1 { + t.Fatalf("Profiles length: got %d, want 1", len(view.Profiles)) + } + if view.Profiles[0].Name != "default" { + t.Errorf("Profiles[0].Name: got %q, want %q", view.Profiles[0].Name, "default") + } +} diff --git a/internal/ui/features/suggestions/service.go b/internal/ui/features/suggestions/service.go new file mode 100644 index 0000000..cff3bf4 --- /dev/null +++ b/internal/ui/features/suggestions/service.go @@ -0,0 +1,79 @@ +// Package suggestions provides the service layer for suggestions view data and logic. +package suggestions + +import ( + "fmt" + "path/filepath" + + "github.com/royisme/bobamixer/internal/domain/suggestions" + "github.com/royisme/bobamixer/internal/store/sqlite" + "github.com/royisme/bobamixer/internal/ui/components" +) + +// Service manages suggestions data loading and conversion for the suggestions view. +type Service struct { + home string +} + +// NewService creates a new suggestions service. +func NewService(home string) *Service { + return &Service{ + home: home, + } +} + +// LoadData loads optimization suggestions from the database. +func (s *Service) LoadData(days int) ([]suggestions.Suggestion, error) { + dbPath := filepath.Join(s.home, "usage.db") + db, err := sqlite.Open(dbPath) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + engine := suggestions.NewEngine(db) + suggs, err := engine.GenerateSuggestions(days) + if err != nil { + return nil, fmt.Errorf("generate suggestions: %w", err) + } + + return suggs, nil +} + +// ConvertToView converts domain suggestions to UI components. +func (s *Service) ConvertToView(suggs []suggestions.Suggestion) []components.Suggestion { + result := make([]components.Suggestion, len(suggs)) + for i, sugg := range suggs { + result[i] = components.Suggestion{ + Title: sugg.Title, + Description: sugg.Description, + Impact: sugg.Impact, + ActionItems: append([]string(nil), sugg.ActionItems...), + Priority: sugg.Priority, + Type: s.convertType(sugg.Type), + } + } + return result +} + +// convertType converts domain SuggestionType to string for UI. +func (s *Service) convertType(t suggestions.SuggestionType) string { + switch t { + case suggestions.SuggestionCostOptimization: + return "cost" + case suggestions.SuggestionProfileSwitch: + return "profile" + case suggestions.SuggestionBudgetAdjust: + return "budget" + case suggestions.SuggestionAnomaly: + return "anomaly" + case suggestions.SuggestionUsagePattern: + return "usage" + default: + return "usage" + } +} + +// CommandHelp returns the CLI help text for suggestions. +func (s *Service) CommandHelp() string { + return "Use CLI: boba action [--auto] to apply suggestions" +} diff --git a/internal/ui/features/suggestions/service_test.go b/internal/ui/features/suggestions/service_test.go new file mode 100644 index 0000000..6d89eb0 --- /dev/null +++ b/internal/ui/features/suggestions/service_test.go @@ -0,0 +1,237 @@ +package suggestions + +import ( + "testing" + + "github.com/royisme/bobamixer/internal/domain/suggestions" + "github.com/royisme/bobamixer/internal/ui/components" +) + +func TestNewService(t *testing.T) { + home := "/test/home" + svc := NewService(home) + if svc == nil { + t.Fatal("expected service to be created") + } + if svc.home != home { + t.Errorf("expected home %q, got %q", home, svc.home) + } +} + +func TestConvertType(t *testing.T) { + svc := NewService("/test") + + tests := []struct { + name string + typ suggestions.SuggestionType + want string + }{ + { + name: "cost optimization", + typ: suggestions.SuggestionCostOptimization, + want: "cost", + }, + { + name: "profile switch", + typ: suggestions.SuggestionProfileSwitch, + want: "profile", + }, + { + name: "budget adjust", + typ: suggestions.SuggestionBudgetAdjust, + want: "budget", + }, + { + name: "anomaly", + typ: suggestions.SuggestionAnomaly, + want: "anomaly", + }, + { + name: "usage pattern", + typ: suggestions.SuggestionUsagePattern, + want: "usage", + }, + { + name: "unknown type defaults to usage", + typ: suggestions.SuggestionType(999), + want: "usage", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := svc.convertType(tt.typ) + if got != tt.want { + t.Errorf("convertType(%v): got %q, want %q", tt.typ, got, tt.want) + } + }) + } +} + +func TestConvertToView(t *testing.T) { + svc := NewService("/test") + + tests := []struct { + name string + suggs []suggestions.Suggestion + want []components.Suggestion + }{ + { + name: "empty list", + suggs: []suggestions.Suggestion{}, + want: []components.Suggestion{}, + }, + { + name: "single suggestion", + suggs: []suggestions.Suggestion{ + { + Title: "Reduce costs", + Description: "Switch to cheaper model", + Impact: "Save $5/day", + ActionItems: []string{"Update profile", "Test changes"}, + Priority: 5, + Type: suggestions.SuggestionCostOptimization, + }, + }, + want: []components.Suggestion{ + { + Title: "Reduce costs", + Description: "Switch to cheaper model", + Impact: "Save $5/day", + ActionItems: []string{"Update profile", "Test changes"}, + Priority: 5, + Type: "cost", + }, + }, + }, + { + name: "multiple suggestions with different types", + suggs: []suggestions.Suggestion{ + { + Title: "Cost optimization", + Description: "Use cheaper provider", + Impact: "Save $10/day", + ActionItems: []string{"Switch provider"}, + Priority: 4, + Type: suggestions.SuggestionCostOptimization, + }, + { + Title: "Profile switch", + Description: "Use fast profile for simple queries", + Impact: "Reduce latency by 50%", + ActionItems: []string{"Configure routing rules"}, + Priority: 3, + Type: suggestions.SuggestionProfileSwitch, + }, + { + Title: "Usage spike detected", + Description: "Unusual usage on weekends", + Impact: "Review billing", + ActionItems: []string{"Check logs", "Set alerts"}, + Priority: 5, + Type: suggestions.SuggestionAnomaly, + }, + }, + want: []components.Suggestion{ + { + Title: "Cost optimization", + Description: "Use cheaper provider", + Impact: "Save $10/day", + ActionItems: []string{"Switch provider"}, + Priority: 4, + Type: "cost", + }, + { + Title: "Profile switch", + Description: "Use fast profile for simple queries", + Impact: "Reduce latency by 50%", + ActionItems: []string{"Configure routing rules"}, + Priority: 3, + Type: "profile", + }, + { + Title: "Usage spike detected", + Description: "Unusual usage on weekends", + Impact: "Review billing", + ActionItems: []string{"Check logs", "Set alerts"}, + Priority: 5, + Type: "anomaly", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := svc.ConvertToView(tt.suggs) + if len(got) != len(tt.want) { + t.Fatalf("length: got %d, want %d", len(got), len(tt.want)) + } + for i := range got { + if got[i].Title != tt.want[i].Title { + t.Errorf("[%d] Title: got %q, want %q", i, got[i].Title, tt.want[i].Title) + } + if got[i].Description != tt.want[i].Description { + t.Errorf("[%d] Description: got %q, want %q", i, got[i].Description, tt.want[i].Description) + } + if got[i].Impact != tt.want[i].Impact { + t.Errorf("[%d] Impact: got %q, want %q", i, got[i].Impact, tt.want[i].Impact) + } + if got[i].Priority != tt.want[i].Priority { + t.Errorf("[%d] Priority: got %d, want %d", i, got[i].Priority, tt.want[i].Priority) + } + if got[i].Type != tt.want[i].Type { + t.Errorf("[%d] Type: got %q, want %q", i, got[i].Type, tt.want[i].Type) + } + if len(got[i].ActionItems) != len(tt.want[i].ActionItems) { + t.Errorf("[%d] ActionItems length: got %d, want %d", i, len(got[i].ActionItems), len(tt.want[i].ActionItems)) + } + for j := range got[i].ActionItems { + if got[i].ActionItems[j] != tt.want[i].ActionItems[j] { + t.Errorf("[%d] ActionItems[%d]: got %q, want %q", i, j, got[i].ActionItems[j], tt.want[i].ActionItems[j]) + } + } + } + }) + } +} + +func TestConvertToView_ActionItemsCopied(t *testing.T) { + svc := NewService("/test") + + original := []suggestions.Suggestion{ + { + Title: "Test", + Description: "Test desc", + Impact: "Test impact", + ActionItems: []string{"action1", "action2"}, + Priority: 3, + Type: suggestions.SuggestionUsagePattern, + }, + } + + result := svc.ConvertToView(original) + + // Modify original action items + original[0].ActionItems[0] = "modified" + + // Result should not be affected + if result[0].ActionItems[0] == "modified" { + t.Error("ActionItems were not properly copied, modification affected result") + } + if result[0].ActionItems[0] != "action1" { + t.Errorf("Expected ActionItems[0] to be %q, got %q", "action1", result[0].ActionItems[0]) + } +} + +func TestCommandHelp(t *testing.T) { + svc := NewService("/test") + help := svc.CommandHelp() + if help == "" { + t.Error("CommandHelp should return non-empty string") + } + expected := "Use CLI: boba action [--auto] to apply suggestions" + if help != expected { + t.Errorf("CommandHelp: got %q, want %q", help, expected) + } +} diff --git a/internal/ui/features/tools/service.go b/internal/ui/features/tools/service.go new file mode 100644 index 0000000..cec15e0 --- /dev/null +++ b/internal/ui/features/tools/service.go @@ -0,0 +1,83 @@ +// Package tools provides the service layer for tools view data and logic. +package tools + +import ( + "github.com/royisme/bobamixer/internal/domain/core" + "github.com/royisme/bobamixer/internal/ui/components" +) + +// Service encapsulates tool list rendering logic. +type Service struct { + tools *core.ToolsConfig + bindings *core.BindingsConfig +} + +// NewService wires the configs for tool rendering. +func NewService(tools *core.ToolsConfig, bindings *core.BindingsConfig) *Service { + return &Service{ + tools: tools, + bindings: bindings, + } +} + +// Rows converts filtered tool indexes into table rows. +func (s *Service) Rows(indexes []int) []components.ToolRow { + if s.tools == nil || len(indexes) == 0 { + return nil + } + + result := make([]components.ToolRow, 0, len(indexes)) + for _, idx := range indexes { + if idx < 0 || idx >= len(s.tools.Tools) { + continue + } + + tool := s.tools.Tools[idx] + bound := false + if s.bindings != nil { + if _, err := s.bindings.FindBinding(tool.ID); err == nil { + bound = true + } + } + + result = append(result, components.ToolRow{ + Name: tool.Name, + Exec: tool.Exec, + Kind: string(tool.Kind), + Bound: bound, + }) + } + + return result +} + +// Details returns the tool details for the selected filtered index. +func (s *Service) Details(indexes []int, selectedIndex int) *components.ToolDetails { + if s.tools == nil || len(indexes) == 0 || selectedIndex < 0 || selectedIndex >= len(indexes) { + return nil + } + + actualIdx := indexes[selectedIndex] + if actualIdx < 0 || actualIdx >= len(s.tools.Tools) { + return nil + } + + tool := s.tools.Tools[actualIdx] + return &components.ToolDetails{ + ID: tool.ID, + ConfigType: string(tool.ConfigType), + ConfigPath: tool.ConfigPath, + Description: tool.Description, + } +} + +// EmptyStateMessage returns descriptive text for an empty tool list. +func (s *Service) EmptyStateMessage(isEmpty bool, hasSearch bool) string { + if !isEmpty { + return "" + } + if hasSearch { + return "No tools match the current filter." + } + return "No tools configured." +} diff --git a/internal/ui/features/tools/service_test.go b/internal/ui/features/tools/service_test.go new file mode 100644 index 0000000..c65c738 --- /dev/null +++ b/internal/ui/features/tools/service_test.go @@ -0,0 +1,331 @@ +package tools + +import ( + "testing" + + "github.com/royisme/bobamixer/internal/domain/core" +) + +func TestNewService(t *testing.T) { + tools := &core.ToolsConfig{} + bindings := &core.BindingsConfig{} + + svc := NewService(tools, bindings) + + if svc == nil { + t.Fatal("expected service to be created") + } + if svc.tools != tools { + t.Error("tools not set correctly") + } + if svc.bindings != bindings { + t.Error("bindings not set correctly") + } +} + +func TestRows_Success(t *testing.T) { + tools := &core.ToolsConfig{ + Tools: []core.Tool{ + { + ID: "tool1", + Name: "Tool 1", + Exec: "tool1", + Kind: "cli", + }, + { + ID: "tool2", + Name: "Tool 2", + Exec: "tool2", + Kind: "service", + }, + }, + } + bindings := &core.BindingsConfig{ + Bindings: []core.Binding{ + {ToolID: "tool1", ProviderID: "provider1"}, + }, + } + svc := NewService(tools, bindings) + + indexes := []int{0, 1} + rows := svc.Rows(indexes) + + if len(rows) != 2 { + t.Fatalf("Expected 2 rows, got %d", len(rows)) + } + + // Check first row (bound) + if rows[0].Name != "Tool 1" { + t.Errorf("Row 0 Name: got %q, want %q", rows[0].Name, "Tool 1") + } + if rows[0].Exec != "tool1" { + t.Errorf("Row 0 Exec: got %q, want %q", rows[0].Exec, "tool1") + } + if rows[0].Kind != "cli" { + t.Errorf("Row 0 Kind: got %q, want %q", rows[0].Kind, "cli") + } + if !rows[0].Bound { + t.Error("Row 0 should be bound") + } + + // Check second row (not bound) + if rows[1].Name != "Tool 2" { + t.Errorf("Row 1 Name: got %q, want %q", rows[1].Name, "Tool 2") + } + if rows[1].Bound { + t.Error("Row 1 should not be bound") + } +} + +func TestRows_NilTools(t *testing.T) { + svc := NewService(nil, &core.BindingsConfig{}) + + indexes := []int{0} + rows := svc.Rows(indexes) + + if rows != nil { + t.Error("Rows should return nil when tools is nil") + } +} + +func TestRows_EmptyIndexes(t *testing.T) { + tools := &core.ToolsConfig{ + Tools: []core.Tool{ + {ID: "tool1", Name: "Tool 1"}, + }, + } + svc := NewService(tools, &core.BindingsConfig{}) + + rows := svc.Rows([]int{}) + + if rows != nil { + t.Error("Rows should return nil when indexes is empty") + } +} + +func TestRows_InvalidIndex(t *testing.T) { + tools := &core.ToolsConfig{ + Tools: []core.Tool{ + {ID: "tool1", Name: "Tool 1"}, + }, + } + svc := NewService(tools, &core.BindingsConfig{}) + + indexes := []int{0, 99, -1} + rows := svc.Rows(indexes) + + // Should only return valid row + if len(rows) != 1 { + t.Fatalf("Expected 1 row (skipping invalid indexes), got %d", len(rows)) + } +} + +func TestRows_NilBindings(t *testing.T) { + tools := &core.ToolsConfig{ + Tools: []core.Tool{ + {ID: "tool1", Name: "Tool 1"}, + }, + } + svc := NewService(tools, nil) + + indexes := []int{0} + rows := svc.Rows(indexes) + + if len(rows) != 1 { + t.Fatalf("Expected 1 row, got %d", len(rows)) + } + + // Should have Bound=false when bindings is nil + if rows[0].Bound { + t.Error("Row should not be bound when bindings is nil") + } +} + +func TestDetails_Success(t *testing.T) { + tools := &core.ToolsConfig{ + Tools: []core.Tool{ + { + ID: "tool1", + Name: "Tool 1", + ConfigType: "yaml", + ConfigPath: "/path/to/config.yaml", + Description: "A test tool", + }, + }, + } + svc := NewService(tools, &core.BindingsConfig{}) + + indexes := []int{0} + details := svc.Details(indexes, 0) + + if details == nil { + t.Fatal("Details should not be nil") + } + + if details.ID != "tool1" { + t.Errorf("ID: got %q, want %q", details.ID, "tool1") + } + if details.ConfigType != "yaml" { + t.Errorf("ConfigType: got %q, want %q", details.ConfigType, "yaml") + } + if details.ConfigPath != "/path/to/config.yaml" { + t.Errorf("ConfigPath: got %q, want %q", details.ConfigPath, "/path/to/config.yaml") + } + if details.Description != "A test tool" { + t.Errorf("Description: got %q, want %q", details.Description, "A test tool") + } +} + +func TestDetails_NilTools(t *testing.T) { + svc := NewService(nil, &core.BindingsConfig{}) + + indexes := []int{0} + details := svc.Details(indexes, 0) + + if details != nil { + t.Error("Details should return nil when tools is nil") + } +} + +func TestDetails_EmptyIndexes(t *testing.T) { + tools := &core.ToolsConfig{ + Tools: []core.Tool{ + {ID: "tool1"}, + }, + } + svc := NewService(tools, &core.BindingsConfig{}) + + details := svc.Details([]int{}, 0) + + if details != nil { + t.Error("Details should return nil when indexes is empty") + } +} + +func TestDetails_InvalidSelectedIndex(t *testing.T) { + tools := &core.ToolsConfig{ + Tools: []core.Tool{ + {ID: "tool1"}, + }, + } + svc := NewService(tools, &core.BindingsConfig{}) + + indexes := []int{0} + details := svc.Details(indexes, 99) + + if details != nil { + t.Error("Details should return nil for invalid selected index") + } +} + +func TestDetails_NegativeSelectedIndex(t *testing.T) { + tools := &core.ToolsConfig{ + Tools: []core.Tool{ + {ID: "tool1"}, + }, + } + svc := NewService(tools, &core.BindingsConfig{}) + + indexes := []int{0} + details := svc.Details(indexes, -1) + + if details != nil { + t.Error("Details should return nil for negative selected index") + } +} + +func TestDetails_InvalidToolIndex(t *testing.T) { + tools := &core.ToolsConfig{ + Tools: []core.Tool{ + {ID: "tool1"}, + }, + } + svc := NewService(tools, &core.BindingsConfig{}) + + indexes := []int{99} + details := svc.Details(indexes, 0) + + if details != nil { + t.Error("Details should return nil for invalid tool index") + } +} + +func TestEmptyStateMessage_Empty_NoSearch(t *testing.T) { + svc := NewService(nil, nil) + + msg := svc.EmptyStateMessage(true, false) + + if msg != "No tools configured." { + t.Errorf("Message: got %q, want %q", msg, "No tools configured.") + } +} + +func TestEmptyStateMessage_Empty_WithSearch(t *testing.T) { + svc := NewService(nil, nil) + + msg := svc.EmptyStateMessage(true, true) + + if msg != "No tools match the current filter." { + t.Errorf("Message: got %q, want %q", msg, "No tools match the current filter.") + } +} + +func TestEmptyStateMessage_NotEmpty(t *testing.T) { + svc := NewService(nil, nil) + + msg := svc.EmptyStateMessage(false, false) + + if msg != "" { + t.Errorf("Message: got %q, want empty string", msg) + } +} + +func TestRows_AllToolKinds(t *testing.T) { + tools := &core.ToolsConfig{ + Tools: []core.Tool{ + {ID: "tool1", Name: "CLI Tool", Kind: "cli"}, + {ID: "tool2", Name: "Service Tool", Kind: "service"}, + {ID: "tool3", Name: "Script Tool", Kind: "script"}, + }, + } + svc := NewService(tools, &core.BindingsConfig{}) + + indexes := []int{0, 1, 2} + rows := svc.Rows(indexes) + + if len(rows) != 3 { + t.Fatalf("Expected 3 rows, got %d", len(rows)) + } + + expectedKinds := []string{"cli", "service", "script"} + for i, expected := range expectedKinds { + if rows[i].Kind != expected { + t.Errorf("Row %d Kind: got %q, want %q", i, rows[i].Kind, expected) + } + } +} + +func TestDetails_AllConfigTypes(t *testing.T) { + tools := &core.ToolsConfig{ + Tools: []core.Tool{ + {ID: "tool1", ConfigType: "yaml"}, + {ID: "tool2", ConfigType: "json"}, + {ID: "tool3", ConfigType: "toml"}, + }, + } + svc := NewService(tools, &core.BindingsConfig{}) + + expectedTypes := []string{"yaml", "json", "toml"} + for i, expected := range expectedTypes { + indexes := []int{i} + details := svc.Details(indexes, 0) + + if details == nil { + t.Fatalf("Details should not be nil for tool %d", i) + } + + if details.ConfigType != expected { + t.Errorf("Tool %d ConfigType: got %q, want %q", i, details.ConfigType, expected) + } + } +} diff --git a/internal/ui/forms/binding_form.go b/internal/ui/forms/binding_form.go new file mode 100644 index 0000000..bbc7957 --- /dev/null +++ b/internal/ui/forms/binding_form.go @@ -0,0 +1,319 @@ +// Package forms provides interactive forms for managing providers, bindings, and secrets in the BobaMixer TUI. +package forms + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/royisme/bobamixer/internal/domain/core" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +type bindingField int + +const ( + bindingFieldToolID bindingField = iota + bindingFieldProviderID + bindingFieldModel + bindingFieldUseProxy +) + +var bindingFieldSequence = []bindingField{ + bindingFieldToolID, + bindingFieldProviderID, + bindingFieldModel, + bindingFieldUseProxy, +} + +// BindingForm manages tool-provider binding input flow. +type BindingForm struct { + active bool + add bool + index int + fieldIdx int + input textinput.Model + binding core.Binding + message string + prompt string + bindings *core.BindingsConfig + tools *core.ToolsConfig + providers *core.ProvidersConfig +} + +// NewBindingForm creates a binding form with the provided prompt. +func NewBindingForm(prompt string) BindingForm { + input := textinput.New() + input.CharLimit = 200 + input.Width = 40 + input.Prompt = prompt + + return BindingForm{ + input: input, + prompt: prompt, + } +} + +func (f BindingForm) Active() bool { + return f.active +} + +func (f BindingForm) Message() string { + return f.message +} + +func (f *BindingForm) SetMessage(msg string) { + f.message = msg +} + +func (f BindingForm) Binding() core.Binding { + return f.binding +} + +func (f BindingForm) Index() int { + return f.index +} + +func (f BindingForm) AddMode() bool { + return f.add +} + +func (f *BindingForm) Start( + add bool, + binding core.Binding, + idx int, + bindings *core.BindingsConfig, + tools *core.ToolsConfig, + providers *core.ProvidersConfig, +) { + f.add = add + f.index = idx + f.fieldIdx = 0 + f.active = true + f.message = "" + f.bindings = bindings + f.tools = tools + f.providers = providers + + if add { + f.binding = core.Binding{ + UseProxy: true, + Options: core.BindingOptions{}, + } + f.index = -1 + } else { + f.binding = binding + } + + f.skipDisabledFields() + if f.fieldIdx >= len(bindingFieldSequence) { + f.active = false + f.input.Blur() + return + } + + f.prepareField() +} + +func (f *BindingForm) Cancel(reason string) { + f.active = false + f.message = reason + f.input.Blur() + f.input.SetValue("") +} + +func (f *BindingForm) Update(msg tea.Msg) tea.Cmd { + if !f.active { + return nil + } + var cmd tea.Cmd + f.input, cmd = f.input.Update(msg) + return cmd +} + +func (f *BindingForm) Submit() (bool, error) { + if !f.active || f.fieldIdx >= len(bindingFieldSequence) { + return false, nil + } + + field := bindingFieldSequence[f.fieldIdx] + value := strings.TrimSpace(f.input.Value()) + if err := f.setFieldValue(field, value); err != nil { + f.message = err.Error() + return false, err + } + + f.message = "" + f.input.SetValue("") + f.fieldIdx++ + f.skipDisabledFields() + + if f.fieldIdx >= len(bindingFieldSequence) { + f.active = false + f.input.Blur() + return true, nil + } + + f.prepareField() + return false, nil +} + +func (f *BindingForm) fieldEnabled(field bindingField) bool { + return field != bindingFieldToolID || f.add +} + +func (f *BindingForm) skipDisabledFields() { + for f.fieldIdx < len(bindingFieldSequence) && !f.fieldEnabled(bindingFieldSequence[f.fieldIdx]) { + f.fieldIdx++ + } +} + +func (f *BindingForm) prepareField() { + if f.fieldIdx >= len(bindingFieldSequence) { + return + } + + field := bindingFieldSequence[f.fieldIdx] + f.input.Placeholder = f.promptFor(field) + f.input.SetValue(f.valueForField(field)) + f.input.CursorEnd() + f.input.Focus() +} + +func (f *BindingForm) promptFor(field bindingField) string { + switch field { + case bindingFieldToolID: + return "tool id" + case bindingFieldProviderID: + return "provider id" + case bindingFieldModel: + return "model override" + case bindingFieldUseProxy: + return "use proxy (on/off)" + default: + return "value" + } +} + +func (f *BindingForm) valueForField(field bindingField) string { + switch field { + case bindingFieldToolID: + return f.binding.ToolID + case bindingFieldProviderID: + return f.binding.ProviderID + case bindingFieldModel: + return f.binding.Options.Model + case bindingFieldUseProxy: + if f.binding.UseProxy { + return "on" + } + return "off" + default: + return "" + } +} + +func (f *BindingForm) setFieldValue(field bindingField, value string) error { + switch field { + case bindingFieldToolID: + return f.setToolID(value) + case bindingFieldProviderID: + return f.setProviderID(value) + case bindingFieldModel: + f.binding.Options.Model = value + return nil + case bindingFieldUseProxy: + return f.setUseProxy(value) + default: + return fmt.Errorf("unknown field") + } +} + +func (f *BindingForm) setToolID(value string) error { + if value == "" { + return fmt.Errorf("tool id is required") + } + if f.tools != nil { + if _, err := f.tools.FindTool(value); err != nil { + return fmt.Errorf("tool %s not found", value) + } + } + if f.bindings != nil { + for idx := range f.bindings.Bindings { + if strings.EqualFold(f.bindings.Bindings[idx].ToolID, value) { + if f.add || idx != f.index { + return fmt.Errorf("binding for %s already exists", value) + } + } + } + } + f.binding.ToolID = value + return nil +} + +func (f *BindingForm) setProviderID(value string) error { + if value == "" { + return fmt.Errorf("provider id is required") + } + if f.providers != nil { + if _, err := f.providers.FindProvider(value); err != nil { + return fmt.Errorf("provider %s not found", value) + } + } + f.binding.ProviderID = value + return nil +} + +func (f *BindingForm) setUseProxy(value string) error { + lower := strings.ToLower(value) + switch lower { + case "on", "true", "yes": + f.binding.UseProxy = true + case "off", "false", "no": + f.binding.UseProxy = false + default: + return fmt.Errorf("proxy value must be on/off") + } + return nil +} + +func (f BindingForm) View(palette theme.Theme, styles theme.Styles) string { + if !f.active { + return "" + } + + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(palette.Primary). + Padding(1, 2). + Width(70) + + title := "Edit Binding" + if f.add { + title = "Add Binding" + } + + body := strings.Builder{} + titleStyle := styles.Title + body.WriteString(titleStyle.MarginBottom(0).Render(fmt.Sprintf("%s (%s)", title, f.binding.ToolID))) + body.WriteString("\n\n") + if f.fieldIdx < len(bindingFieldSequence) { + helpStyle := styles.Help + body.WriteString(helpStyle.Italic(false).Render( + fmt.Sprintf("Field: %s", f.promptFor(bindingFieldSequence[f.fieldIdx])), + )) + body.WriteString("\n") + } + body.WriteString(f.input.View()) + body.WriteString("\n\n") + helpStyle := styles.Help + body.WriteString(helpStyle.Italic(false).Render("Enter to confirm • Esc to cancel")) + if strings.TrimSpace(f.message) != "" { + body.WriteString("\n") + body.WriteString(helpStyle.Italic(false).Render(f.message)) + } + + return boxStyle.Render(body.String()) +} diff --git a/internal/ui/forms/provider_form.go b/internal/ui/forms/provider_form.go new file mode 100644 index 0000000..1840276 --- /dev/null +++ b/internal/ui/forms/provider_form.go @@ -0,0 +1,341 @@ +package forms + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/royisme/bobamixer/internal/domain/core" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +type providerField int + +const ( + providerFieldID providerField = iota + providerFieldKind + providerFieldDisplayName + providerFieldBaseURL + providerFieldDefaultModel + providerFieldAPIKeySource + providerFieldAPIKeyEnv +) + +var providerFieldSequence = []providerField{ + providerFieldID, + providerFieldKind, + providerFieldDisplayName, + providerFieldBaseURL, + providerFieldDefaultModel, + providerFieldAPIKeySource, + providerFieldAPIKeyEnv, +} + +// ProviderForm manages the state machine for editing providers. +type ProviderForm struct { + active bool + add bool + index int + fieldIdx int + input textinput.Model + provider core.Provider + message string + prompt string + providers *core.ProvidersConfig +} + +// NewProviderForm creates a new provider form with the configured prompt prefix. +func NewProviderForm(prompt string) ProviderForm { + input := textinput.New() + input.CharLimit = 200 + input.Width = 50 + input.Prompt = prompt + + return ProviderForm{ + input: input, + prompt: prompt, + } +} + +// Active reports whether the form is currently collecting input. +func (f ProviderForm) Active() bool { + return f.active +} + +// Message returns the latest helper/error message. +func (f ProviderForm) Message() string { + return f.message +} + +// SetMessage overrides the helper message (useful for external validation feedback). +func (f *ProviderForm) SetMessage(msg string) { + f.message = msg +} + +// Provider returns the current provider being edited. +func (f ProviderForm) Provider() core.Provider { + return f.provider +} + +// Index returns the original provider index (for edit flows). +func (f ProviderForm) Index() int { + return f.index +} + +// AddMode indicates whether the form is adding a provider. +func (f ProviderForm) AddMode() bool { + return f.add +} + +// Start activates the form with either a blank or existing provider. +func (f *ProviderForm) Start(add bool, provider core.Provider, idx int, providers *core.ProvidersConfig) { + f.add = add + f.index = idx + f.message = "" + f.active = true + f.fieldIdx = 0 + f.providers = providers + + if add { + f.provider = core.Provider{ + Enabled: true, + APIKey: core.APIKeyConfig{ + Source: core.APIKeySourceEnv, + }, + } + f.index = -1 + } else { + f.provider = provider + } + + f.skipDisabledFields() + if f.fieldIdx >= len(providerFieldSequence) { + f.active = false + f.input.Blur() + return + } + + f.prepareField() +} + +// Cancel stops the form and records the provided reason. +func (f *ProviderForm) Cancel(reason string) { + f.active = false + f.message = reason + f.input.Blur() + f.input.SetValue("") +} + +// Update forwards messages to the text input when active. +func (f *ProviderForm) Update(msg tea.Msg) tea.Cmd { + if !f.active { + return nil + } + var cmd tea.Cmd + f.input, cmd = f.input.Update(msg) + return cmd +} + +// Submit stores the current field value and advances the form. +// When it returns completed=true, the form is finished collecting data. +func (f *ProviderForm) Submit() (completed bool, err error) { + if !f.active || f.fieldIdx >= len(providerFieldSequence) { + return false, nil + } + + field := providerFieldSequence[f.fieldIdx] + value := strings.TrimSpace(f.input.Value()) + if err := f.setFieldValue(field, value); err != nil { + f.message = err.Error() + return false, err + } + + f.message = "" + f.input.SetValue("") + f.fieldIdx++ + f.skipDisabledFields() + + if f.fieldIdx >= len(providerFieldSequence) { + f.active = false + f.input.Blur() + return true, nil + } + + f.prepareField() + return false, nil +} + +func (f *ProviderForm) prepareField() { + if f.fieldIdx >= len(providerFieldSequence) { + return + } + + field := providerFieldSequence[f.fieldIdx] + f.input.Placeholder = f.promptFor(field) + f.input.SetValue(f.valueForField(field)) + f.input.CursorEnd() + f.input.Focus() +} + +func (f *ProviderForm) fieldEnabled(field providerField) bool { + if !f.add && field == providerFieldID { + return false + } + if field == providerFieldAPIKeyEnv { + return strings.ToLower(string(f.provider.APIKey.Source)) == string(core.APIKeySourceEnv) + } + return true +} + +func (f *ProviderForm) skipDisabledFields() { + for f.fieldIdx < len(providerFieldSequence) && !f.fieldEnabled(providerFieldSequence[f.fieldIdx]) { + f.fieldIdx++ + } +} + +func (f *ProviderForm) promptFor(field providerField) string { + switch field { + case providerFieldID: + return "provider id" + case providerFieldKind: + return "provider kind (openai, anthropic...)" + case providerFieldDisplayName: + return "display name" + case providerFieldBaseURL: + return "base url" + case providerFieldDefaultModel: + return "default model" + case providerFieldAPIKeySource: + return "api key source (env or secrets)" + case providerFieldAPIKeyEnv: + return "env var name" + default: + return "value" + } +} + +func (f *ProviderForm) valueForField(field providerField) string { + switch field { + case providerFieldID: + return f.provider.ID + case providerFieldKind: + return string(f.provider.Kind) + case providerFieldDisplayName: + return f.provider.DisplayName + case providerFieldBaseURL: + return f.provider.BaseURL + case providerFieldDefaultModel: + return f.provider.DefaultModel + case providerFieldAPIKeySource: + if f.provider.APIKey.Source != "" { + return string(f.provider.APIKey.Source) + } + return "" + case providerFieldAPIKeyEnv: + return f.provider.APIKey.EnvVar + default: + return "" + } +} + +func (f *ProviderForm) setFieldValue(field providerField, value string) error { + switch field { + case providerFieldID: + if value == "" { + return fmt.Errorf("provider id is required") + } + if f.providers != nil { + for idx := range f.providers.Providers { + if strings.EqualFold(f.providers.Providers[idx].ID, value) { + if f.add || idx != f.index { + return fmt.Errorf("provider id already exists") + } + } + } + } + f.provider.ID = value + case providerFieldKind: + f.provider.Kind = core.ProviderKind(value) + case providerFieldDisplayName: + f.provider.DisplayName = value + case providerFieldBaseURL: + f.provider.BaseURL = value + case providerFieldDefaultModel: + f.provider.DefaultModel = value + case providerFieldAPIKeySource: + return f.setAPIKeySource(value) + case providerFieldAPIKeyEnv: + return f.setAPIKeyEnv(value) + default: + return fmt.Errorf("unknown field") + } + return nil +} + +func (f *ProviderForm) setAPIKeySource(value string) error { + switch strings.ToLower(value) { + case "env": + f.provider.APIKey.Source = core.APIKeySourceEnv + case "secrets": + f.provider.APIKey.Source = core.APIKeySourceSecrets + f.provider.APIKey.EnvVar = "" + default: + return fmt.Errorf("api key source must be 'env' or 'secrets'") + } + return nil +} + +func (f *ProviderForm) setAPIKeyEnv(value string) error { + if f.provider.APIKey.Source == core.APIKeySourceEnv && value == "" { + return fmt.Errorf("env var is required when source=env") + } + f.provider.APIKey.EnvVar = value + return nil +} + +// View renders the form UI. +func (f ProviderForm) View(palette theme.Theme, styles theme.Styles) string { + if !f.active { + return "" + } + + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(palette.Primary). + Padding(1, 2). + Width(70) + + title := "Edit Provider" + if f.add { + title = "Add Provider" + } + + var currentName string + if f.provider.DisplayName != "" { + currentName = fmt.Sprintf(" (%s)", f.provider.DisplayName) + } + + var body strings.Builder + titleStyle := styles.Title + body.WriteString(titleStyle.MarginBottom(0).Render(title + currentName)) + body.WriteString("\n\n") + if f.fieldIdx < len(providerFieldSequence) { + helpStyle := styles.Help + body.WriteString(helpStyle.Italic(false).Render( + fmt.Sprintf("Field: %s", f.promptFor(providerFieldSequence[f.fieldIdx])), + )) + } + body.WriteString("\n") + body.WriteString(f.input.View()) + body.WriteString("\n\n") + helpStyle2 := styles.Help + body.WriteString(helpStyle2.Italic(false).Render("Enter to confirm • Esc to cancel")) + if strings.TrimSpace(f.message) != "" { + body.WriteString("\n") + body.WriteString(helpStyle2.Italic(false).Render(f.message)) + } + + return boxStyle.Render(body.String()) +} diff --git a/internal/ui/forms/secret_form.go b/internal/ui/forms/secret_form.go new file mode 100644 index 0000000..4f41664 --- /dev/null +++ b/internal/ui/forms/secret_form.go @@ -0,0 +1,127 @@ +package forms + +import ( + "errors" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// SecretForm manages API key input per provider. +type SecretForm struct { + active bool + targetIndex int + providerName string + input textinput.Model + message string + prompt string +} + +// NewSecretForm creates a secret form configured with prompt prefix. +func NewSecretForm(prompt string) SecretForm { + input := textinput.New() + input.Placeholder = "Enter API key" + input.CharLimit = 200 + input.Width = 40 + input.Prompt = prompt + input.EchoMode = textinput.EchoPassword + input.EchoCharacter = '•' + + return SecretForm{ + input: input, + prompt: prompt, + } +} + +func (f SecretForm) Active() bool { + return f.active +} + +func (f SecretForm) Message() string { + return f.message +} + +func (f *SecretForm) SetMessage(msg string) { + f.message = msg +} + +func (f SecretForm) TargetIndex() int { + return f.targetIndex +} + +// Start activates the form for the given provider. +func (f *SecretForm) Start(targetIdx int, providerName string) { + f.targetIndex = targetIdx + f.providerName = providerName + f.message = "" + f.input.Placeholder = "API key for " + providerName + f.input.SetValue("") + f.input.CursorEnd() + f.input.Focus() + f.active = true +} + +// Cancel terminates the form. +func (f *SecretForm) Cancel(reason string) { + f.message = reason + f.active = false + f.input.Blur() + f.input.SetValue("") +} + +// Update forwards events to the text input. +func (f *SecretForm) Update(msg tea.Msg) tea.Cmd { + if !f.active { + return nil + } + var cmd tea.Cmd + f.input, cmd = f.input.Update(msg) + return cmd +} + +// Submit returns the API key and deactivates the form. +func (f *SecretForm) Submit() (string, error) { + value := strings.TrimSpace(f.input.Value()) + if value == "" { + f.message = "API key cannot be empty" + return "", ErrEmptySecret + } + f.active = false + f.input.Blur() + f.input.SetValue("") + return value, nil +} + +// View renders the secret form. +func (f SecretForm) View(styles theme.Styles, palette theme.Theme) string { + if !f.active { + return "" + } + + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(palette.Primary). + Padding(1, 2). + Width(60) + + body := strings.Builder{} + titleStyle := styles.Title + body.WriteString(titleStyle.MarginBottom(0).Render("Set API key for " + f.providerName)) + body.WriteString("\n\n") + body.WriteString(f.input.View()) + body.WriteString("\n\n") + helpStyle := styles.Help + body.WriteString(helpStyle.Italic(false).Render("Enter to save • Esc to cancel")) + if strings.TrimSpace(f.message) != "" { + body.WriteString("\n") + body.WriteString(helpStyle.Italic(false).Render(f.message)) + } + + return boxStyle.Render(body.String()) +} + +// ErrEmptySecret is returned when submitting an empty secret. +var ErrEmptySecret = errors.New("api key cannot be empty") diff --git a/internal/ui/i18n.go b/internal/ui/i18n/i18n.go similarity index 97% rename from internal/ui/i18n.go rename to internal/ui/i18n/i18n.go index e3c9ab4..a09b67c 100644 --- a/internal/ui/i18n.go +++ b/internal/ui/i18n/i18n.go @@ -1,5 +1,5 @@ -// Package ui provides internationalization support for BobaMixer TUI -package ui +// Package i18n provides internationalization support for BobaMixer TUI. +package i18n import ( "embed" diff --git a/internal/ui/i18n_test.go b/internal/ui/i18n/i18n_test.go similarity index 99% rename from internal/ui/i18n_test.go rename to internal/ui/i18n/i18n_test.go index 0c19522..f760ca0 100644 --- a/internal/ui/i18n_test.go +++ b/internal/ui/i18n/i18n_test.go @@ -1,4 +1,4 @@ -package ui +package i18n import ( "os" diff --git a/internal/ui/locales/en.json b/internal/ui/i18n/locales/en.json similarity index 100% rename from internal/ui/locales/en.json rename to internal/ui/i18n/locales/en.json diff --git a/internal/ui/locales/zh-CN.json b/internal/ui/i18n/locales/zh-CN.json similarity index 100% rename from internal/ui/locales/zh-CN.json rename to internal/ui/i18n/locales/zh-CN.json diff --git a/internal/ui/layouts/layouts.go b/internal/ui/layouts/layouts.go new file mode 100644 index 0000000..1ccfefe --- /dev/null +++ b/internal/ui/layouts/layouts.go @@ -0,0 +1,75 @@ +// Package layouts provides layout utilities for arranging UI components in the BobaMixer TUI. +package layouts + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// Row composes blocks horizontally without applying any additional styling. +func Row(blocks ...string) string { + items := filterBlocks(blocks) + if len(items) == 0 { + return "" + } + return lipgloss.JoinHorizontal(lipgloss.Top, items...) +} + +// Column stacks blocks vertically without altering their own styling. +func Column(blocks ...string) string { + items := filterBlocks(blocks) + if len(items) == 0 { + return "" + } + return lipgloss.JoinVertical(lipgloss.Left, items...) +} + +// Section renders a labeled block made from a title and body content. +func Section(title string, body string) string { + title = strings.TrimSpace(title) + body = strings.TrimSpace(body) + + switch { + case title == "" && body == "": + return "" + case title == "": + return body + case body == "": + return title + default: + return Column(title, body) + } +} + +// Gap inserts blank lines to create vertical spacing between layout blocks. +func Gap(n int) string { + if n <= 0 { + return "" + } + return strings.Repeat("\n", n) +} + +// Pad applies a left padding using spaces to keep spacing logic outside components. +func Pad(padding int, content string) string { + if padding <= 0 || content == "" { + return content + } + + prefix := strings.Repeat(" ", padding) + lines := strings.Split(content, "\n") + for i := range lines { + lines[i] = prefix + lines[i] + } + return strings.Join(lines, "\n") +} + +func filterBlocks(blocks []string) []string { + result := make([]string, 0, len(blocks)) + for _, block := range blocks { + if trimmed := strings.TrimSpace(block); trimmed != "" { + result = append(result, block) + } + } + return result +} diff --git a/internal/ui/onboarding.go b/internal/ui/onboarding.go index ad98191..85132e7 100644 --- a/internal/ui/onboarding.go +++ b/internal/ui/onboarding.go @@ -11,6 +11,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/royisme/bobamixer/internal/domain/core" + "github.com/royisme/bobamixer/internal/ui/i18n" ) // OnboardingStage represents the current stage in the setup wizard @@ -39,7 +40,7 @@ type OnboardingModel struct { stage OnboardingStage home string theme Theme - localizer *Localizer + localizer *i18n.Localizer // Scanning state spinner spinner.Model @@ -94,11 +95,11 @@ func (p providerItem) Description() string { func NewOnboarding(home string) (*OnboardingModel, error) { // Load theme and localizer theme := loadTheme(home) - localizer, err := NewLocalizer(GetUserLanguage()) + localizer, err := i18n.NewLocalizer(i18n.GetUserLanguage()) if err != nil { // Fallback to English - this should always succeed var fallbackErr error - localizer, fallbackErr = NewLocalizer("en") + localizer, fallbackErr = i18n.NewLocalizer("en") if fallbackErr != nil { // If even English fails, something is seriously wrong panic(fmt.Sprintf("failed to initialize localizer: %v (fallback to English also failed: %v)", err, fallbackErr)) diff --git a/internal/ui/pages/bindings_page.go b/internal/ui/pages/bindings_page.go new file mode 100644 index 0000000..1fb1a3d --- /dev/null +++ b/internal/ui/pages/bindings_page.go @@ -0,0 +1,126 @@ +// Package pages provides page-level UI components for the BobaMixer TUI. +package pages + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/components" + "github.com/royisme/bobamixer/internal/ui/layouts" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// BindingsPageProps holds data to render the bindings screen. +type BindingsPageProps struct { + Title string + SectionTitle string + DetailsTitle string + BindingForm string + ShowBindingForm bool + BindingFormMessage string + SearchBar string + EmptyStateMessage string + Bindings []components.BindingRow + SelectedIndex int + Details *components.BindingDetails + NavigationHelp string + ActionHelp string + ProxyEnabledIcon string + ProxyDisabledIcon string +} + +// BindingsPage composes the tool-provider binding view. +type BindingsPage struct { + title components.TitleBar + formMessage components.InfoMessage + list components.BindingList + details components.BindingDetailsPanel + help components.HelpBar + searchBar string + section string + detailTitle string + form string + showForm bool +} + +// NewBindingsPage builds the bindings page. +func NewBindingsPage(palette theme.Theme, props BindingsPageProps) BindingsPage { + styles := theme.NewStyles(palette) + + helpText := strings.TrimSpace(props.NavigationHelp) + if extra := strings.TrimSpace(props.ActionHelp); extra != "" { + if helpText != "" { + helpText += " " + } + helpText += extra + } + + return BindingsPage{ + title: components.NewTitleBar(props.Title, styles), + formMessage: components.NewInfoMessage(props.BindingFormMessage, styles), + list: components.NewBindingList(props.Bindings, props.SelectedIndex, props.EmptyStateMessage, props.ProxyEnabledIcon, props.ProxyDisabledIcon, styles), + details: components.NewBindingDetailsPanel(props.Details, styles), + help: components.NewHelpBar(helpText, styles), + searchBar: props.SearchBar, + section: props.SectionTitle, + detailTitle: props.DetailsTitle, + form: props.BindingForm, + showForm: props.ShowBindingForm, + } +} + +// Init satisfies the Page interface. +func (p BindingsPage) Init() tea.Cmd { + return nil +} + +// Update satisfies the Page interface (bindings page is static). +func (p BindingsPage) Update(msg tea.Msg) (Page, tea.Cmd) { + _, cmd1 := p.title.Update(msg) + _, cmd2 := p.formMessage.Update(msg) + _, cmd3 := p.list.Update(msg) + _, cmd4 := p.details.Update(msg) + _, cmd5 := p.help.Update(msg) + return p, tea.Batch(cmd1, cmd2, cmd3, cmd4, cmd5) +} + +// View assembles the bindings view using the layout DSL. +func (p BindingsPage) View() string { + body := []string{} + + if form := p.renderForm(); form != "" { + body = append(body, form) + } + if bar := strings.TrimSpace(p.searchBar); bar != "" { + body = append(body, bar) + } + if list := p.list.View(); list != "" { + body = append(body, list) + } + + detailsView := p.details.View() + + layoutBlocks := []string{ + layouts.Pad(2, p.title.View()), + layouts.Gap(1), + layouts.Section(p.section, layouts.Column(body...)), + } + + if detailsView != "" { + layoutBlocks = append(layoutBlocks, layouts.Gap(1), layouts.Section(p.detailTitle, detailsView)) + } + + layoutBlocks = append(layoutBlocks, layouts.Gap(1), layouts.Pad(2, p.help.View())) + + return layouts.Column(layoutBlocks...) +} + +func (p BindingsPage) renderForm() string { + if p.showForm { + if strings.TrimSpace(p.form) != "" { + return p.form + } + return "" + } + return p.formMessage.View() +} diff --git a/internal/ui/pages/config_page.go b/internal/ui/pages/config_page.go new file mode 100644 index 0000000..95bfc3f --- /dev/null +++ b/internal/ui/pages/config_page.go @@ -0,0 +1,96 @@ +package pages + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/components" + "github.com/royisme/bobamixer/internal/ui/layouts" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// ConfigPageProps holds the configuration editor data. +type ConfigPageProps struct { + Title string + ConfigTitle string + EditorTitle string + SafetyTitle string + ConfigFiles []components.ConfigFile + SelectedIndex int + Home string + EditorName string + NavigationHelp string + CommandHelpLine string +} + +// ConfigPage composes the configuration editor view. +type ConfigPage struct { + title components.TitleBar + configList components.ConfigFileList + editorInfo components.Paragraph + safetyList components.BulletList + help components.HelpBar + configTitle string + editorTitle string + safetyTitle string +} + +// NewConfigPage builds the config page. +func NewConfigPage(palette theme.Theme, props ConfigPageProps) ConfigPage { + styles := theme.NewStyles(palette) + editorText := "Editor: $EDITOR (" + props.EditorName + ")\nTip: Set $EDITOR to use your preferred editor" + helpText := strings.TrimSpace(props.NavigationHelp) + if extra := strings.TrimSpace(props.CommandHelpLine); extra != "" { + if helpText != "" { + helpText += "\n" + } + helpText += extra + } + + return ConfigPage{ + title: components.NewTitleBar(props.Title, styles), + configList: components.NewConfigFileList(props.ConfigFiles, props.SelectedIndex, props.Home, styles), + editorInfo: components.NewParagraph(editorText, styles), + safetyList: components.NewBulletList([]string{ + "Automatic backup before editing", + "YAML syntax validation after save", + "Rollback support if validation fails", + }, styles), + help: components.NewHelpBar(helpText, styles), + configTitle: props.ConfigTitle, + editorTitle: props.EditorTitle, + safetyTitle: props.SafetyTitle, + } +} + +// Init satisfies the Page interface. +func (p ConfigPage) Init() tea.Cmd { + return nil +} + +// Update satisfies the Page interface. +func (p ConfigPage) Update(msg tea.Msg) (Page, tea.Cmd) { + _, cmd1 := p.title.Update(msg) + _, cmd2 := p.configList.Update(msg) + _, cmd3 := p.editorInfo.Update(msg) + _, cmd4 := p.safetyList.Update(msg) + _, cmd5 := p.help.Update(msg) + return p, tea.Batch(cmd1, cmd2, cmd3, cmd4, cmd5) +} + +// View assembles the configuration editor view. +func (p ConfigPage) View() string { + blocks := []string{ + layouts.Pad(2, p.title.View()), + layouts.Gap(1), + layouts.Section(p.configTitle, p.configList.View()), + layouts.Gap(1), + layouts.Section(p.editorTitle, p.editorInfo.View()), + layouts.Gap(1), + layouts.Section(p.safetyTitle, p.safetyList.View()), + layouts.Gap(1), + layouts.Pad(2, p.help.View()), + } + + return layouts.Column(blocks...) +} diff --git a/internal/ui/pages/dashboard_page.go b/internal/ui/pages/dashboard_page.go new file mode 100644 index 0000000..d23f0a6 --- /dev/null +++ b/internal/ui/pages/dashboard_page.go @@ -0,0 +1,84 @@ +package pages + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/components" + "github.com/royisme/bobamixer/internal/ui/layouts" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// DashboardPageProps describes the data needed to render the dashboard summary. +type DashboardPageProps struct { + Title string + Table string + Message string + ProxyIcon string + ProxyStatus string + NavigationHelp string + ActionHelp string +} + +// DashboardPage composes the dashboard overview using the layout DSL. +type DashboardPage struct { + title components.TitleBar + proxy components.ProxyStatus + message components.StatusMessage + help components.HelpBar + table string +} + +// NewDashboardPage constructs a DashboardPage with the supplied palette. +func NewDashboardPage(palette theme.Theme, props DashboardPageProps) DashboardPage { + styles := theme.NewStyles(palette) + + helpText := strings.TrimSpace(props.NavigationHelp) + if extra := strings.TrimSpace(props.ActionHelp); extra != "" { + if helpText != "" { + helpText += " " + } + helpText += extra + } + + return DashboardPage{ + title: components.NewTitleBar(props.Title, styles), + proxy: components.NewProxyStatus(props.ProxyIcon, props.ProxyStatus, styles), + message: components.NewStatusMessage(props.Message, palette.Success), + help: components.NewHelpBar(helpText, styles), + table: props.Table, + } +} + +// Init satisfies the Page interface; no initialization commands are needed. +func (p DashboardPage) Init() tea.Cmd { + return nil +} + +// Update satisfies the Page interface and keeps the dashboard immutable for now. +func (p DashboardPage) Update(msg tea.Msg) (Page, tea.Cmd) { + _, cmd1 := p.title.Update(msg) + _, cmd2 := p.proxy.Update(msg) + _, cmd3 := p.message.Update(msg) + _, cmd4 := p.help.Update(msg) + return p, tea.Batch(cmd1, cmd2, cmd3, cmd4) +} + +// View composes the dashboard blocks using the layout helpers. +func (p DashboardPage) View() string { + blocks := []string{ + layouts.Pad(2, p.title.View()), + layouts.Gap(1), + layouts.Pad(2, p.proxy.View()), + layouts.Gap(1), + p.table, + } + + if msg := p.message.View(); msg != "" { + blocks = append(blocks, layouts.Gap(1), layouts.Pad(2, msg)) + } + + blocks = append(blocks, layouts.Gap(1), layouts.Pad(2, p.help.View())) + + return layouts.Column(blocks...) +} diff --git a/internal/ui/pages/help_page.go b/internal/ui/pages/help_page.go new file mode 100644 index 0000000..712a71a --- /dev/null +++ b/internal/ui/pages/help_page.go @@ -0,0 +1,95 @@ +package pages + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/components" + "github.com/royisme/bobamixer/internal/ui/layouts" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// HelpPageProps represents the required data to render the help overlay. +type HelpPageProps struct { + Title string + Subtitle string + Sections []components.HelpSection + Shortcuts []components.Shortcut + Tips []string + Links []components.HelpLink + NavigationHint string +} + +// HelpPage composes the help overlay via the new components + layout architecture. +type HelpPage struct { + header components.HelpHeader + sections components.HelpSectionList + shortcuts components.ShortcutList + tips components.HelpTips + links components.HelpLinks + footer components.HelpFooter +} + +// NewHelpPage constructs a HelpPage with the shared palette. +func NewHelpPage(palette theme.Theme, props HelpPageProps) HelpPage { + styles := theme.NewStyles(palette) + + shortcuts := props.Shortcuts + if len(shortcuts) == 0 { + shortcuts = defaultHelpShortcuts() + } + + return HelpPage{ + header: components.NewHelpHeader(props.Title, props.Subtitle, styles), + sections: components.NewHelpSectionList(props.Sections, styles), + shortcuts: components.NewShortcutList(shortcuts, styles), + tips: components.NewHelpTips(props.Tips, styles), + links: components.NewHelpLinks(props.Links, styles), + footer: components.NewHelpFooter(props.NavigationHint, styles), + } +} + +// Init satisfies the Page interface; the help page has no startup commands. +func (p HelpPage) Init() tea.Cmd { + return nil +} + +// Update keeps the page immutable because its content is static. +func (p HelpPage) Update(msg tea.Msg) (Page, tea.Cmd) { + _, cmd := p.header.Update(msg) + _, cmd2 := p.sections.Update(msg) + _, cmd3 := p.shortcuts.Update(msg) + _, cmd4 := p.tips.Update(msg) + _, cmd5 := p.links.Update(msg) + _, cmd6 := p.footer.Update(msg) + return p, tea.Batch(cmd, cmd2, cmd3, cmd4, cmd5, cmd6) +} + +// View composes the help overlay using the layout DSL. +func (p HelpPage) View() string { + return layouts.Column( + p.header.View(), + layouts.Gap(1), + layouts.Section("Section Navigation", p.sections.View()), + layouts.Gap(1), + layouts.Section("Global Shortcuts", p.shortcuts.View()), + layouts.Gap(1), + layouts.Section("Quick Tips", p.tips.View()), + layouts.Gap(1), + layouts.Section("Documentation", p.links.View()), + layouts.Gap(1), + p.footer.View(), + ) +} + +func defaultHelpShortcuts() []components.Shortcut { + return []components.Shortcut{ + {Key: "Tab / Shift+Tab", Description: "Cycle sections"}, + {Key: "[ / ]", Description: "Cycle views within a section"}, + {Key: "↑/↓ or k/j", Description: "Navigate in lists"}, + {Key: "/", Description: "Search within supported lists"}, + {Key: "Esc", Description: "Clear search / close dialogs"}, + {Key: "R", Description: "Run selected tool (Dashboard view)"}, + {Key: "X", Description: "Toggle proxy (Dashboard view)"}, + {Key: "S", Description: "Refresh proxy status (Proxy view)"}, + {Key: "Q or Ctrl+C", Description: "Quit BobaMixer"}, + } +} diff --git a/internal/ui/pages/hooks_page.go b/internal/ui/pages/hooks_page.go new file mode 100644 index 0000000..4a9893e --- /dev/null +++ b/internal/ui/pages/hooks_page.go @@ -0,0 +1,112 @@ +package pages + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/components" + "github.com/royisme/bobamixer/internal/ui/layouts" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// HooksPageProps holds the git hooks screen data. +type HooksPageProps struct { + Title string + RepoTitle string + HooksTitle string + BenefitsTitle string + ActivityTitle string + RepoPath string + HooksInstalled bool + Hooks []components.HookInfo + NavigationHelp string + CommandHelpLine string + ActiveIcon string + InactiveIcon string +} + +// HooksPage composes the git hooks management view. +type HooksPage struct { + title components.TitleBar + repoInfo components.Paragraph + hookList components.HookList + benefits components.BulletList + activity components.Paragraph + help components.HelpBar + repoTitle string + hooksTitle string + benefitsT string + activityT string +} + +// NewHooksPage builds the hooks page. +func NewHooksPage(palette theme.Theme, props HooksPageProps) HooksPage { + styles := theme.NewStyles(palette) + status := "✗ Hooks Not Installed" + if props.HooksInstalled { + status = "✓ Hooks Installed" + } + repoText := fmt.Sprintf("Path: %s\nStatus: %s", props.RepoPath, status) + + helpText := strings.TrimSpace(props.NavigationHelp) + if extra := strings.TrimSpace(props.CommandHelpLine); extra != "" { + if helpText != "" { + helpText += "\n" + } + helpText += extra + } + + return HooksPage{ + title: components.NewTitleBar(props.Title, styles), + repoInfo: components.NewParagraph(repoText, styles), + hookList: components.NewHookList(props.Hooks, props.ActiveIcon, props.InactiveIcon, styles), + benefits: components.NewBulletList([]string{ + "Automatic profile suggestions based on branch/project", + "Track repository events for better usage analytics", + "Context-aware AI model selection", + "Zero-overhead tracking (async logging)", + }, styles), + activity: components.NewParagraph("No recent activity recorded", styles), + help: components.NewHelpBar(helpText, styles), + repoTitle: props.RepoTitle, + hooksTitle: props.HooksTitle, + benefitsT: props.BenefitsTitle, + activityT: props.ActivityTitle, + } +} + +// Init satisfies the Page interface. +func (p HooksPage) Init() tea.Cmd { + return nil +} + +// Update satisfies the Page interface. +func (p HooksPage) Update(msg tea.Msg) (Page, tea.Cmd) { + _, cmd1 := p.title.Update(msg) + _, cmd2 := p.repoInfo.Update(msg) + _, cmd3 := p.hookList.Update(msg) + _, cmd4 := p.benefits.Update(msg) + _, cmd5 := p.activity.Update(msg) + _, cmd6 := p.help.Update(msg) + return p, tea.Batch(cmd1, cmd2, cmd3, cmd4, cmd5, cmd6) +} + +// View assembles the hooks management view. +func (p HooksPage) View() string { + blocks := []string{ + layouts.Pad(2, p.title.View()), + layouts.Gap(1), + layouts.Section(p.repoTitle, p.repoInfo.View()), + layouts.Gap(1), + layouts.Section(p.hooksTitle, p.hookList.View()), + layouts.Gap(1), + layouts.Section(p.benefitsT, p.benefits.View()), + layouts.Gap(1), + layouts.Section(p.activityT, p.activity.View()), + layouts.Gap(1), + layouts.Pad(2, p.help.View()), + } + + return layouts.Column(blocks...) +} diff --git a/internal/ui/pages/page.go b/internal/ui/pages/page.go new file mode 100644 index 0000000..a52dbfc --- /dev/null +++ b/internal/ui/pages/page.go @@ -0,0 +1,10 @@ +package pages + +import tea "github.com/charmbracelet/bubbletea" + +// Page represents a composable UI unit following the Bubble Tea model contract. +type Page interface { + Init() tea.Cmd + Update(msg tea.Msg) (Page, tea.Cmd) + View() string +} diff --git a/internal/ui/pages/providers_page.go b/internal/ui/pages/providers_page.go new file mode 100644 index 0000000..bdc9b64 --- /dev/null +++ b/internal/ui/pages/providers_page.go @@ -0,0 +1,124 @@ +package pages + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/components" + "github.com/royisme/bobamixer/internal/ui/layouts" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// ProvidersPageProps holds the information required to render the providers screen. +type ProvidersPageProps struct { + Title string + SectionTitle string + DetailsTitle string + ProviderForm string + ShowProviderForm bool + ProviderFormMessage string + SearchBar string + EmptyStateMessage string + Providers []components.ProviderRow + SelectedIndex int + Details *components.ProviderDetails + NavigationHelp string + ActionHelp string + Icons components.ProviderListIcons +} + +// ProvidersPage composes the providers management UI. +type ProvidersPage struct { + title components.TitleBar + formMessage components.InfoMessage + list components.ProviderList + details components.ProviderDetailsPanel + help components.HelpBar + providerForm string + showForm bool + searchBar string + sectionTitle string + detailsTitle string +} + +// NewProvidersPage creates a ProvidersPage for the supplied props. +func NewProvidersPage(palette theme.Theme, props ProvidersPageProps) ProvidersPage { + styles := theme.NewStyles(palette) + + helpText := strings.TrimSpace(props.NavigationHelp) + if extra := strings.TrimSpace(props.ActionHelp); extra != "" { + if helpText != "" { + helpText += " " + } + helpText += extra + } + + return ProvidersPage{ + title: components.NewTitleBar(props.Title, styles), + formMessage: components.NewInfoMessage(props.ProviderFormMessage, styles), + list: components.NewProviderList(props.Providers, props.SelectedIndex, props.EmptyStateMessage, props.Icons, styles), + details: components.NewProviderDetailsPanel(props.Details, styles), + help: components.NewHelpBar(helpText, styles), + providerForm: props.ProviderForm, + showForm: props.ShowProviderForm, + searchBar: props.SearchBar, + sectionTitle: props.SectionTitle, + detailsTitle: props.DetailsTitle, + } +} + +// Init satisfies the Page interface. +func (p ProvidersPage) Init() tea.Cmd { + return nil +} + +// Update satisfies the Page interface (providers page is static for now). +func (p ProvidersPage) Update(msg tea.Msg) (Page, tea.Cmd) { + _, cmd1 := p.title.Update(msg) + _, cmd2 := p.formMessage.Update(msg) + _, cmd3 := p.list.Update(msg) + _, cmd4 := p.details.Update(msg) + _, cmd5 := p.help.Update(msg) + return p, tea.Batch(cmd1, cmd2, cmd3, cmd4, cmd5) +} + +// View assembles the providers view using the layout DSL. +func (p ProvidersPage) View() string { + bodyBlocks := []string{} + + if form := p.renderForm(); form != "" { + bodyBlocks = append(bodyBlocks, form) + } + if bar := strings.TrimSpace(p.searchBar); bar != "" { + bodyBlocks = append(bodyBlocks, bar) + } + if list := p.list.View(); list != "" { + bodyBlocks = append(bodyBlocks, list) + } + + detailsView := p.details.View() + + layoutBlocks := []string{ + layouts.Pad(2, p.title.View()), + layouts.Gap(1), + layouts.Section(p.sectionTitle, layouts.Column(bodyBlocks...)), + } + + if detailsView != "" { + layoutBlocks = append(layoutBlocks, layouts.Gap(1), layouts.Section(p.detailsTitle, detailsView)) + } + + layoutBlocks = append(layoutBlocks, layouts.Gap(1), layouts.Pad(2, p.help.View())) + + return layouts.Column(layoutBlocks...) +} + +func (p ProvidersPage) renderForm() string { + if p.showForm { + if strings.TrimSpace(p.providerForm) != "" { + return p.providerForm + } + return "" + } + return p.formMessage.View() +} diff --git a/internal/ui/pages/proxy_page.go b/internal/ui/pages/proxy_page.go new file mode 100644 index 0000000..a308356 --- /dev/null +++ b/internal/ui/pages/proxy_page.go @@ -0,0 +1,104 @@ +package pages + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/components" + "github.com/royisme/bobamixer/internal/ui/layouts" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// ProxyPageProps holds the proxy screen data. +type ProxyPageProps struct { + Title string + StatusTitle string + InfoTitle string + ConfigTitle string + StatusState string + StatusText string + StatusIcon string + Address string + ShowConfig bool + NavigationHelp string + CommandHelpLine string + AdditionalNote string + InfoLines []string + ConfigLines []string +} + +// ProxyPage composes the proxy server control UI. +type ProxyPage struct { + title components.TitleBar + status components.ProxyStatusPanel + info components.BulletList + config components.BulletList + help components.HelpBar + section string + infoHdr string + cfgHdr string + showCfg bool +} + +// NewProxyPage builds a ProxyPage using the shared palette. +func NewProxyPage(palette theme.Theme, props ProxyPageProps) ProxyPage { + styles := theme.NewStyles(palette) + + helpLines := []string{} + if strings.TrimSpace(props.NavigationHelp) != "" { + helpLines = append(helpLines, strings.TrimSpace(props.NavigationHelp)) + } + if strings.TrimSpace(props.CommandHelpLine) != "" { + helpLines = append(helpLines, strings.TrimSpace(props.CommandHelpLine)) + } + if strings.TrimSpace(props.AdditionalNote) != "" { + helpLines = append(helpLines, strings.TrimSpace(props.AdditionalNote)) + } + + helpText := strings.Join(helpLines, "\n") + + return ProxyPage{ + title: components.NewTitleBar(props.Title, styles), + status: components.NewProxyStatusPanel(props.StatusState, props.StatusText, props.StatusIcon, props.Address, styles), + info: components.NewBulletList(props.InfoLines, styles), + config: components.NewBulletList(props.ConfigLines, styles), + help: components.NewHelpBar(helpText, styles), + section: props.StatusTitle, + infoHdr: props.InfoTitle, + cfgHdr: props.ConfigTitle, + showCfg: props.ShowConfig, + } +} + +// Init satisfies the Page interface. +func (p ProxyPage) Init() tea.Cmd { + return nil +} + +// Update satisfies the Page interface. +func (p ProxyPage) Update(msg tea.Msg) (Page, tea.Cmd) { + _, cmd1 := p.title.Update(msg) + _, cmd2 := p.status.Update(msg) + _, cmd3 := p.info.Update(msg) + _, cmd4 := p.config.Update(msg) + _, cmd5 := p.help.Update(msg) + return p, tea.Batch(cmd1, cmd2, cmd3, cmd4, cmd5) +} + +// View assembles the proxy view. +func (p ProxyPage) View() string { + blocks := []string{ + layouts.Pad(2, p.title.View()), + layouts.Gap(1), + layouts.Section(p.section, p.status.View()), + layouts.Gap(1), + layouts.Section(p.infoHdr, p.info.View()), + } + + if p.showCfg { + blocks = append(blocks, layouts.Gap(1), layouts.Section(p.cfgHdr, p.config.View())) + } + + blocks = append(blocks, layouts.Gap(1), layouts.Pad(2, p.help.View())) + return layouts.Column(blocks...) +} diff --git a/internal/ui/pages/reports_page.go b/internal/ui/pages/reports_page.go new file mode 100644 index 0000000..9e14561 --- /dev/null +++ b/internal/ui/pages/reports_page.go @@ -0,0 +1,98 @@ +package pages + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/components" + "github.com/royisme/bobamixer/internal/ui/layouts" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// ReportsPageProps holds the reports screen content. +type ReportsPageProps struct { + Title string + OptionsTitle string + OutputTitle string + ContentsTitle string + Options []components.ReportOption + SelectedIndex int + Home string + NavigationHelp string + CommandHelpLine string +} + +// ReportsPage composes the usage reports view. +type ReportsPage struct { + title components.TitleBar + options components.ReportOptionsList + output components.Paragraph + contents components.BulletList + help components.HelpBar + optionsT string + outputT string + contentsT string +} + +// NewReportsPage builds the reports page. +func NewReportsPage(palette theme.Theme, props ReportsPageProps) ReportsPage { + styles := theme.NewStyles(palette) + outputText := fmt.Sprintf("Default path: %s/reports/\nFilename: bobamixer-.", strings.TrimSpace(props.Home)) + helpText := strings.TrimSpace(props.NavigationHelp) + if extra := strings.TrimSpace(props.CommandHelpLine); extra != "" { + if helpText != "" { + helpText += "\n" + } + helpText += extra + } + + return ReportsPage{ + title: components.NewTitleBar(props.Title, styles), + options: components.NewReportOptionsList(props.Options, props.SelectedIndex, styles), + output: components.NewParagraph(outputText, styles), + contents: components.NewBulletList([]string{ + "Summary statistics (tokens, costs, sessions)", + "Daily trends and usage patterns", + "Profile breakdown and comparison", + "Cost analysis and optimization opportunities", + "Peak usage times and anomalies", + }, styles), + help: components.NewHelpBar(helpText, styles), + optionsT: props.OptionsTitle, + outputT: props.OutputTitle, + contentsT: props.ContentsTitle, + } +} + +// Init satisfies the Page interface. +func (p ReportsPage) Init() tea.Cmd { + return nil +} + +// Update satisfies the Page interface. +func (p ReportsPage) Update(msg tea.Msg) (Page, tea.Cmd) { + _, cmd1 := p.title.Update(msg) + _, cmd2 := p.options.Update(msg) + _, cmd3 := p.output.Update(msg) + _, cmd4 := p.contents.Update(msg) + _, cmd5 := p.help.Update(msg) + return p, tea.Batch(cmd1, cmd2, cmd3, cmd4, cmd5) +} + +// View assembles the reports view. +func (p ReportsPage) View() string { + blocks := []string{ + layouts.Pad(2, p.title.View()), + layouts.Gap(1), + layouts.Section(p.optionsT, p.options.View()), + layouts.Gap(1), + layouts.Section(p.outputT, p.output.View()), + layouts.Gap(1), + layouts.Section(p.contentsT, p.contents.View()), + layouts.Gap(1), + layouts.Pad(2, p.help.View()), + } + + return layouts.Column(blocks...) +} diff --git a/internal/ui/pages/routing_page.go b/internal/ui/pages/routing_page.go new file mode 100644 index 0000000..7dbda59 --- /dev/null +++ b/internal/ui/pages/routing_page.go @@ -0,0 +1,99 @@ +package pages + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/components" + "github.com/royisme/bobamixer/internal/ui/layouts" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// RoutingPageProps holds the routing tester content. +type RoutingPageProps struct { + Title string + TestTitle string + HowToTitle string + ExampleTitle string + ContextTitle string + TestDescription string + HowToSteps []string + ExampleLines []string + ContextLines []string + NavigationHelp string + CommandHelpLine string +} + +// RoutingPage composes the routing tester information screen. +type RoutingPage struct { + title components.TitleBar + testDesc components.Paragraph + howTo components.BulletList + example components.Paragraph + context components.BulletList + help components.HelpBar + testTitle string + howToTitle string + exTitle string + ctxTitle string +} + +// NewRoutingPage builds the routing page. +func NewRoutingPage(palette theme.Theme, props RoutingPageProps) RoutingPage { + styles := theme.NewStyles(palette) + helpText := strings.TrimSpace(props.NavigationHelp) + if extra := strings.TrimSpace(props.CommandHelpLine); extra != "" { + if helpText != "" { + helpText += "\n" + } + helpText += extra + } + + return RoutingPage{ + title: components.NewTitleBar(props.Title, styles), + testDesc: components.NewParagraph(props.TestDescription, styles), + howTo: components.NewBulletList(props.HowToSteps, styles), + example: components.NewParagraph(strings.Join(props.ExampleLines, "\n"), styles), + context: components.NewBulletList(props.ContextLines, styles), + help: components.NewHelpBar(helpText, styles), + testTitle: props.TestTitle, + howToTitle: props.HowToTitle, + exTitle: props.ExampleTitle, + ctxTitle: props.ContextTitle, + } +} + +// Init satisfies the Page interface. +func (p RoutingPage) Init() tea.Cmd { + return nil +} + +// Update satisfies the Page interface. +func (p RoutingPage) Update(msg tea.Msg) (Page, tea.Cmd) { + _, cmd1 := p.title.Update(msg) + _, cmd2 := p.testDesc.Update(msg) + _, cmd3 := p.howTo.Update(msg) + _, cmd4 := p.example.Update(msg) + _, cmd5 := p.context.Update(msg) + _, cmd6 := p.help.Update(msg) + return p, tea.Batch(cmd1, cmd2, cmd3, cmd4, cmd5, cmd6) +} + +// View assembles the routing tester view. +func (p RoutingPage) View() string { + blocks := []string{ + layouts.Pad(2, p.title.View()), + layouts.Gap(1), + layouts.Section(p.testTitle, p.testDesc.View()), + layouts.Gap(1), + layouts.Section(p.howToTitle, p.howTo.View()), + layouts.Gap(1), + layouts.Section(p.exTitle, p.example.View()), + layouts.Gap(1), + layouts.Section(p.ctxTitle, p.context.View()), + layouts.Gap(1), + layouts.Pad(2, p.help.View()), + } + + return layouts.Column(blocks...) +} diff --git a/internal/ui/pages/secrets_page.go b/internal/ui/pages/secrets_page.go new file mode 100644 index 0000000..0403af2 --- /dev/null +++ b/internal/ui/pages/secrets_page.go @@ -0,0 +1,122 @@ +package pages + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/components" + "github.com/royisme/bobamixer/internal/ui/layouts" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// SecretsPageProps holds the content required to render the secrets screen. +type SecretsPageProps struct { + Title string + StatusTitle string + SecurityTitle string + SecretForm string + ShowSecretForm bool + SearchBar string + EmptyStateMessage string + Providers []components.SecretProviderRow + SelectedIndex int + SecretMessage string + NavigationHelp string + ActionHelp string + SuccessIcon string + FailureIcon string + SecurityTips []string +} + +// SecretsPage composes the API key management UI. +type SecretsPage struct { + title components.TitleBar + list components.SecretProviderList + tips components.BulletList + message components.InfoMessage + help components.HelpBar + secretForm string + showForm bool + searchBar string + statusTitle string + security string +} + +// NewSecretsPage builds a SecretsPage for the supplied props. +func NewSecretsPage(palette theme.Theme, props SecretsPageProps) SecretsPage { + styles := theme.NewStyles(palette) + + helpText := strings.TrimSpace(props.NavigationHelp) + if extra := strings.TrimSpace(props.ActionHelp); extra != "" { + if helpText != "" { + helpText += " " + } + helpText += extra + } + + return SecretsPage{ + title: components.NewTitleBar(props.Title, styles), + list: components.NewSecretProviderList(props.Providers, props.SelectedIndex, props.EmptyStateMessage, props.SuccessIcon, props.FailureIcon, styles), + tips: components.NewBulletList(props.SecurityTips, styles), + message: components.NewInfoMessage(props.SecretMessage, styles), + help: components.NewHelpBar(helpText, styles), + secretForm: props.SecretForm, + showForm: props.ShowSecretForm, + searchBar: props.SearchBar, + statusTitle: props.StatusTitle, + security: props.SecurityTitle, + } +} + +// Init satisfies the Page interface. +func (p SecretsPage) Init() tea.Cmd { + return nil +} + +// Update satisfies the Page interface. +func (p SecretsPage) Update(msg tea.Msg) (Page, tea.Cmd) { + _, cmd1 := p.title.Update(msg) + _, cmd2 := p.list.Update(msg) + _, cmd3 := p.tips.Update(msg) + _, cmd4 := p.message.Update(msg) + _, cmd5 := p.help.Update(msg) + return p, tea.Batch(cmd1, cmd2, cmd3, cmd4, cmd5) +} + +// View assembles the secrets view. +func (p SecretsPage) View() string { + body := []string{} + if form := p.renderForm(); form != "" { + body = append(body, form) + } + if bar := strings.TrimSpace(p.searchBar); bar != "" { + body = append(body, bar) + } + if list := p.list.View(); list != "" { + body = append(body, list) + } + + if msg := strings.TrimSpace(p.message.View()); msg != "" { + body = append(body, msg) + } + + layoutBlocks := []string{ + layouts.Pad(2, p.title.View()), + layouts.Gap(1), + layouts.Section(p.statusTitle, layouts.Column(body...)), + } + + if tips := p.tips.View(); tips != "" { + layoutBlocks = append(layoutBlocks, layouts.Gap(1), layouts.Section(p.security, tips)) + } + + layoutBlocks = append(layoutBlocks, layouts.Gap(1), layouts.Pad(2, p.help.View())) + return layouts.Column(layoutBlocks...) +} + +func (p SecretsPage) renderForm() string { + if p.showForm { + return p.secretForm + } + return "" +} diff --git a/internal/ui/pages/stats_page.go b/internal/ui/pages/stats_page.go new file mode 100644 index 0000000..7b15328 --- /dev/null +++ b/internal/ui/pages/stats_page.go @@ -0,0 +1,115 @@ +package pages + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/components" + "github.com/royisme/bobamixer/internal/ui/layouts" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// StatsPageProps holds the information required to render the usage stats screen. +type StatsPageProps struct { + Title string + Loaded bool + Error string + LoadingMessage string + Today components.StatsSummary + Week components.StatsSummary + Profiles []components.StatsProfile + NavigationHelp string + LoadingHelp string + ProfileSubtitle string +} + +// StatsPage composes the usage statistics UI. +type StatsPage struct { + title components.TitleBar + today components.StatsSummaryPanel + week components.StatsSummaryPanel + profiles components.StatsProfilesList + errorMsg components.StatusMessage + loadingMsg components.InfoMessage + help components.HelpBar + profileSub string + loaded bool + todayTitle string + weekTitle string +} + +// NewStatsPage builds a StatsPage using the provided palette and props. +func NewStatsPage(palette theme.Theme, props StatsPageProps) StatsPage { + styles := theme.NewStyles(palette) + errorText := "" + if !props.Loaded { + errorText = props.Error + } + + return StatsPage{ + title: components.NewTitleBar(props.Title, styles), + today: components.NewStatsSummaryPanel(props.Today, styles), + week: components.NewStatsSummaryPanel(props.Week, styles), + profiles: components.NewStatsProfilesList(props.Profiles, styles), + errorMsg: components.NewStatusMessage(strings.TrimSpace(errorText), palette.Danger), + loadingMsg: components.NewInfoMessage(props.LoadingMessage, styles), + help: components.NewHelpBar(props.NavigationHelp, styles), + profileSub: props.ProfileSubtitle, + loaded: props.Loaded, + todayTitle: props.Today.Title, + weekTitle: props.Week.Title, + } +} + +// Init satisfies the Page interface. +func (p StatsPage) Init() tea.Cmd { + return nil +} + +// Update satisfies the Page interface. +func (p StatsPage) Update(msg tea.Msg) (Page, tea.Cmd) { + _, cmd1 := p.title.Update(msg) + _, cmd2 := p.today.Update(msg) + _, cmd3 := p.week.Update(msg) + _, cmd4 := p.profiles.Update(msg) + _, cmd5 := p.errorMsg.Update(msg) + _, cmd6 := p.loadingMsg.Update(msg) + _, cmd7 := p.help.Update(msg) + return p, tea.Batch(cmd1, cmd2, cmd3, cmd4, cmd5, cmd6, cmd7) +} + +// View assembles the stats screen with the layout DSL. +func (p StatsPage) View() string { + blocks := []string{ + layouts.Pad(2, p.title.View()), + layouts.Gap(1), + } + + if !p.loaded { + if errView := strings.TrimSpace(p.errorMsg.View()); errView != "" { + blocks = append(blocks, layouts.Pad(2, errView), layouts.Gap(1)) + } else { + blocks = append(blocks, layouts.Pad(2, p.loadingMsg.View()), layouts.Gap(1)) + } + blocks = append(blocks, layouts.Pad(2, p.help.View())) + return layouts.Column(blocks...) + } + + if today := p.today.View(); today != "" { + blocks = append(blocks, layouts.Section(strings.TrimSpace(p.todayTitle), today), layouts.Gap(1)) + } + if week := p.week.View(); week != "" { + blocks = append(blocks, layouts.Section(strings.TrimSpace(p.weekTitle), week), layouts.Gap(1)) + } + + if profiles := p.profiles.View(); profiles != "" { + title := strings.TrimSpace(p.profileSub) + if title == "" { + title = "🎯 By Profile (7d)" + } + blocks = append(blocks, layouts.Section(title, profiles), layouts.Gap(1)) + } + + blocks = append(blocks, layouts.Pad(2, p.help.View())) + return layouts.Column(blocks...) +} diff --git a/internal/ui/pages/suggestions_page.go b/internal/ui/pages/suggestions_page.go new file mode 100644 index 0000000..dd4fc20 --- /dev/null +++ b/internal/ui/pages/suggestions_page.go @@ -0,0 +1,101 @@ +package pages + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/components" + "github.com/royisme/bobamixer/internal/ui/layouts" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// SuggestionsPageProps describes the suggestions screen data. +type SuggestionsPageProps struct { + Title string + SectionTitle string + DetailsTitle string + Suggestions []components.Suggestion + SelectedIndex int + Error string + NavigationHelp string + CommandHelpLine string +} + +// SuggestionsPage composes the optimization suggestions UI. +type SuggestionsPage struct { + title components.TitleBar + list components.SuggestionList + details components.SuggestionDetails + errorMsg components.StatusMessage + help components.HelpBar + section string + detailsT string +} + +// NewSuggestionsPage builds the suggestions page. +func NewSuggestionsPage(palette theme.Theme, props SuggestionsPageProps) SuggestionsPage { + styles := theme.NewStyles(palette) + helpText := strings.TrimSpace(props.NavigationHelp) + if extra := strings.TrimSpace(props.CommandHelpLine); extra != "" { + if helpText != "" { + helpText += "\n" + } + helpText += extra + } + + var selectedSuggestion *components.Suggestion + if len(props.Suggestions) > 0 { + index := props.SelectedIndex + if index < 0 || index >= len(props.Suggestions) { + index = 0 + } + selectedSuggestion = &props.Suggestions[index] + } + + return SuggestionsPage{ + title: components.NewTitleBar(props.Title, styles), + list: components.NewSuggestionList(props.Suggestions, props.SelectedIndex, styles), + details: components.NewSuggestionDetails(selectedSuggestion, styles), + errorMsg: components.NewStatusMessage(strings.TrimSpace(props.Error), palette.Danger), + help: components.NewHelpBar(helpText, styles), + section: props.SectionTitle, + detailsT: props.DetailsTitle, + } +} + +// Init satisfies the Page interface. +func (p SuggestionsPage) Init() tea.Cmd { + return nil +} + +// Update satisfies the Page interface. +func (p SuggestionsPage) Update(msg tea.Msg) (Page, tea.Cmd) { + _, cmd1 := p.title.Update(msg) + _, cmd2 := p.list.Update(msg) + _, cmd3 := p.details.Update(msg) + _, cmd4 := p.errorMsg.Update(msg) + _, cmd5 := p.help.Update(msg) + return p, tea.Batch(cmd1, cmd2, cmd3, cmd4, cmd5) +} + +// View assembles the suggestions view. +func (p SuggestionsPage) View() string { + blocks := []string{ + layouts.Pad(2, p.title.View()), + layouts.Gap(1), + } + + if errView := strings.TrimSpace(p.errorMsg.View()); errView != "" { + blocks = append(blocks, layouts.Pad(2, errView), layouts.Gap(1), layouts.Pad(2, p.help.View())) + return layouts.Column(blocks...) + } + + blocks = append(blocks, layouts.Section(p.section, p.list.View())) + + if details := p.details.View(); details != "" { + blocks = append(blocks, layouts.Gap(1), layouts.Section(p.detailsT, details)) + } + + blocks = append(blocks, layouts.Gap(1), layouts.Pad(2, p.help.View())) + return layouts.Column(blocks...) +} diff --git a/internal/ui/pages/tools_page.go b/internal/ui/pages/tools_page.go new file mode 100644 index 0000000..5b54206 --- /dev/null +++ b/internal/ui/pages/tools_page.go @@ -0,0 +1,101 @@ +package pages + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/ui/components" + "github.com/royisme/bobamixer/internal/ui/layouts" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +// ToolsPageProps holds the data required to render the tools screen. +type ToolsPageProps struct { + Title string + SectionTitle string + DetailsTitle string + SearchBar string + EmptyStateMessage string + Tools []components.ToolRow + SelectedIndex int + Details *components.ToolDetails + NavigationHelp string + ActionHelp string + BoundIcon string + UnboundIcon string +} + +// ToolsPage composes the CLI tools view. +type ToolsPage struct { + title components.TitleBar + list components.ToolList + details components.ToolDetailsPanel + help components.HelpBar + searchBar string + section string + detailTitle string +} + +// NewToolsPage builds the tools page using the shared theme palette. +func NewToolsPage(palette theme.Theme, props ToolsPageProps) ToolsPage { + styles := theme.NewStyles(palette) + + helpText := strings.TrimSpace(props.NavigationHelp) + if extra := strings.TrimSpace(props.ActionHelp); extra != "" { + if helpText != "" { + helpText += " " + } + helpText += extra + } + + return ToolsPage{ + title: components.NewTitleBar(props.Title, styles), + list: components.NewToolList(props.Tools, props.SelectedIndex, props.EmptyStateMessage, props.BoundIcon, props.UnboundIcon, styles), + details: components.NewToolDetailsPanel(props.Details, styles), + help: components.NewHelpBar(helpText, styles), + searchBar: props.SearchBar, + section: props.SectionTitle, + detailTitle: props.DetailsTitle, + } +} + +// Init satisfies the Page interface. +func (p ToolsPage) Init() tea.Cmd { + return nil +} + +// Update satisfies the Page interface (tools page is static). +func (p ToolsPage) Update(msg tea.Msg) (Page, tea.Cmd) { + _, cmd1 := p.title.Update(msg) + _, cmd2 := p.list.Update(msg) + _, cmd3 := p.details.Update(msg) + _, cmd4 := p.help.Update(msg) + return p, tea.Batch(cmd1, cmd2, cmd3, cmd4) +} + +// View assembles the tools view with the layout DSL. +func (p ToolsPage) View() string { + body := []string{} + if bar := strings.TrimSpace(p.searchBar); bar != "" { + body = append(body, bar) + } + if list := p.list.View(); list != "" { + body = append(body, list) + } + + detailsView := p.details.View() + + layoutBlocks := []string{ + layouts.Pad(2, p.title.View()), + layouts.Gap(1), + layouts.Section(p.section, layouts.Column(body...)), + } + + if detailsView != "" { + layoutBlocks = append(layoutBlocks, layouts.Gap(1), layouts.Section(p.detailTitle, detailsView)) + } + + layoutBlocks = append(layoutBlocks, layouts.Gap(1), layouts.Pad(2, p.help.View())) + + return layouts.Column(layoutBlocks...) +} diff --git a/internal/ui/root/messages.go b/internal/ui/root/messages.go new file mode 100644 index 0000000..1580d2a --- /dev/null +++ b/internal/ui/root/messages.go @@ -0,0 +1,81 @@ +// Package root provides the root UI model and orchestration for the BobaMixer TUI. +package root + +import ( + "context" + "net/http" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/domain/stats" + "github.com/royisme/bobamixer/internal/domain/suggestions" + "github.com/royisme/bobamixer/internal/proxy" +) + +// proxyStatusMsg is sent when proxy status is checked +type proxyStatusMsg struct { + running bool +} + +// statsLoadedMsg is sent when stats are loaded +type statsLoadedMsg struct { + today stats.Summary + week stats.Summary + profileStats []stats.ProfileStats + err error +} + +// suggestionsLoadedMsg is sent when suggestions are loaded +type suggestionsLoadedMsg struct { + suggestions []suggestions.Suggestion + err error +} + +// checkProxyStatus checks if the proxy server is running +func checkProxyStatus() tea.Msg { + addr := proxy.DefaultAddr + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+addr+"/health", nil) + if err != nil { + return proxyStatusMsg{running: false} + } + + client := &http.Client{Timeout: 500 * time.Millisecond} + resp, err := client.Do(req) + if err != nil { + return proxyStatusMsg{running: false} + } + defer func() { + // Close response body; error ignored as it doesn't affect proxy status check + //nolint:errcheck,gosec // Error on close is not critical for status check + resp.Body.Close() + }() + + return proxyStatusMsg{running: resp.StatusCode == http.StatusOK} +} + +// loadStatsData loads usage statistics from the database +func (m *DashboardModel) loadStatsData() tea.Msg { + data, err := m.statsService.LoadData() + if err != nil { + return statsLoadedMsg{err: err} + } + + return statsLoadedMsg{ + today: data.Today, + week: data.Week, + profileStats: data.ProfileStats, + } +} + +// loadSuggestions loads optimization suggestions +func (m *DashboardModel) loadSuggestions() tea.Msg { + suggs, err := m.suggestionsService.LoadData(7) + if err != nil { + return suggestionsLoadedMsg{err: err} + } + + return suggestionsLoadedMsg{suggestions: suggs} +} diff --git a/internal/ui/root/model.go b/internal/ui/root/model.go new file mode 100644 index 0000000..9b26ccb --- /dev/null +++ b/internal/ui/root/model.go @@ -0,0 +1,214 @@ +// Package root provides the root UI model and orchestration for the BobaMixer TUI. +package root + +import ( + "context" + "fmt" + + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/domain/core" + "github.com/royisme/bobamixer/internal/domain/stats" + "github.com/royisme/bobamixer/internal/domain/suggestions" + "github.com/royisme/bobamixer/internal/proxy" + "github.com/royisme/bobamixer/internal/settings" + bindingsvc "github.com/royisme/bobamixer/internal/ui/features/bindings" + configsvc "github.com/royisme/bobamixer/internal/ui/features/config" + dashboardsvc "github.com/royisme/bobamixer/internal/ui/features/dashboard" + helpsvc "github.com/royisme/bobamixer/internal/ui/features/help" + hookssvc "github.com/royisme/bobamixer/internal/ui/features/hooks" + providersvc "github.com/royisme/bobamixer/internal/ui/features/providers" + proxysvc "github.com/royisme/bobamixer/internal/ui/features/proxy" + reportsvc "github.com/royisme/bobamixer/internal/ui/features/reports" + routingsvc "github.com/royisme/bobamixer/internal/ui/features/routing" + "github.com/royisme/bobamixer/internal/ui/features/secrets" + statssvc "github.com/royisme/bobamixer/internal/ui/features/stats" + suggestionssvc "github.com/royisme/bobamixer/internal/ui/features/suggestions" + toolsvc "github.com/royisme/bobamixer/internal/ui/features/tools" + "github.com/royisme/bobamixer/internal/ui/forms" + "github.com/royisme/bobamixer/internal/ui/i18n" + "github.com/royisme/bobamixer/internal/ui/theme" +) + +const ( + keyCtrlC = "ctrl+c" + keyEsc = "esc" + keyEnter = "enter" +) + +// UI constants for repeated strings +const ( + promptPrefix = "│ " +) + +// DashboardModel represents the control plane dashboard +type DashboardModel struct { + home string + theme theme.Theme + styles theme.Styles + localizer *i18n.Localizer + + // Data + providers *core.ProvidersConfig + tools *core.ToolsConfig + bindings *core.BindingsConfig + secrets *core.SecretsConfig + + // Stats data + todayStats stats.Summary + weekStats stats.Summary + profileStats []stats.ProfileStats + statsLoaded bool + statsError string + + // Suggestions data + suggestions []suggestions.Suggestion + suggestionsError string + + // UI components + table table.Model + + // State + currentView viewMode + selectedIndex int // Currently selected item in list views + width int + height int + quitting bool + proxyStatus string // proxysvc.StatusRunning, StatusStopped, StatusChecking + message string // Status message to display + sections []viewSection + currentSection int + sectionViewIndex int + showHelpOverlay bool + searchActive bool + searchInput textinput.Model + searchQuery string + searchContextView viewMode + secretMessage string + providerForm forms.ProviderForm + bindingForm forms.BindingForm + secretForm forms.SecretForm + toolsService *toolsvc.Service + reportsService *reportsvc.Service + proxyService *proxysvc.Service + bindingService *bindingsvc.Service + providerService *providersvc.Service + secretService *secrets.Service + statsService *statssvc.Service + suggestionsService *suggestionssvc.Service + dashboardService *dashboardsvc.Service + routingService *routingsvc.Service + configService *configsvc.Service + hooksService *hookssvc.Service + helpService *helpsvc.Service +} + +// NewDashboard creates a new dashboard model +func NewDashboard(home string) (*DashboardModel, error) { + // Load theme and localizer + palette := loadTheme(home) + localizer, err := i18n.NewLocalizer(i18n.GetUserLanguage()) + if err != nil { + // Fallback to English if user language is not available + localizer, err = i18n.NewLocalizer("en") + if err != nil { + // Should not happen with English, but handle it + return nil, fmt.Errorf("failed to load localizer: %w", err) + } + } + + // Load all configurations + providers, tools, bindings, secretsConfig, err := core.LoadAll(home) + if err != nil { + return nil, fmt.Errorf("failed to load configurations: %w", err) + } + + m := &DashboardModel{ + home: home, + theme: palette, + styles: theme.NewStyles(palette), + localizer: localizer, + providers: providers, + tools: tools, + bindings: bindings, + secrets: secretsConfig, + proxyStatus: proxysvc.StatusChecking, + currentView: viewDashboard, + } + + m.initSections() + searchInput := textinput.New() + searchInput.Placeholder = "Search..." + searchInput.CharLimit = 100 + searchInput.Width = 30 + m.searchInput = searchInput + m.providerForm = forms.NewProviderForm(promptPrefix) + m.bindingForm = forms.NewBindingForm(promptPrefix) + m.secretForm = forms.NewSecretForm(promptPrefix) + m.toolsService = toolsvc.NewService(m.tools, m.bindings) + m.reportsService = reportsvc.NewService() + m.proxyService = proxysvc.NewService(proxy.DefaultAddr) + m.bindingService = bindingsvc.NewService( + m.bindings, + m.tools, + m.providers, + &m.bindingForm, + dashboardsvc.MsgNoProviderSelected, + dashboardsvc.MsgInvalidProvider, + ) + m.providerService = providersvc.NewService( + m.providers, + m.secrets, + &m.providerForm, + dashboardsvc.MsgNoProviderSelected, + dashboardsvc.MsgInvalidProvider, + ) + m.secretService = secrets.NewService( + m.providers, + &m.secrets, + &m.secretForm, + &m.secretMessage, + dashboardsvc.MsgNoProviderSelected, + dashboardsvc.MsgInvalidProvider, + ) + m.statsService = statssvc.NewService(home) + m.suggestionsService = suggestionssvc.NewService(home) + m.dashboardService = dashboardsvc.NewService(m.tools, m.bindings, m.providers, m.secrets) + m.routingService = routingsvc.NewService() + m.configService = configsvc.NewService() + m.hooksService = hookssvc.NewService() + m.helpService = helpsvc.NewService() + + m.initializeTable() + + return m, nil +} + +// RunDashboard starts the dashboard TUI +func RunDashboard(home string) error { + dashboard, err := NewDashboard(home) + if err != nil { + return fmt.Errorf("failed to create dashboard: %w", err) + } + + p := tea.NewProgram(dashboard, tea.WithAltScreen()) + _, err = p.Run() + return err +} + +func loadTheme(home string) theme.Theme { + ctx := context.Background() + + userSettings, err := settings.Load(ctx, home) + if err != nil { + return theme.GetTheme(settings.DefaultSettings().Theme) + } + + themeName := userSettings.Theme + if themeName == "" { + themeName = settings.DefaultSettings().Theme + } + + return theme.GetTheme(themeName) +} diff --git a/internal/ui/root/search.go b/internal/ui/root/search.go new file mode 100644 index 0000000..7e9027a --- /dev/null +++ b/internal/ui/root/search.go @@ -0,0 +1,109 @@ +// Package root provides the root UI model and orchestration for the BobaMixer TUI. +package root + +import ( + "fmt" + "strings" +) + +// supportsSearch returns whether a view has search functionality +func (m *DashboardModel) supportsSearch(view viewMode) bool { + switch view { + case viewProviders, viewTools, viewBindings, viewSecrets: + return true + default: + return false + } +} + +// activateSearch enters search mode for the current view +func (m *DashboardModel) activateSearch() { + m.searchActive = true + m.searchInput.SetValue(m.searchQuery) + m.searchInput.CursorEnd() + m.searchContextView = m.currentView +} + +// clearSearch exits search mode and clears the search query +func (m *DashboardModel) clearSearch() { + m.searchActive = false + m.searchQuery = "" +} + +// viewHasSearch returns whether the view has an active search filter +func (m *DashboardModel) viewHasSearch(view viewMode) bool { + return strings.TrimSpace(m.searchQuery) != "" && m.searchContextView == view +} + +// filteredProviderIndexes returns indexes of providers matching the search query +func (m *DashboardModel) filteredProviderIndexes() []int { + if m.providers == nil { + return nil + } + total := len(m.providers.Providers) + indexes := make([]int, 0, total) + query := strings.ToLower(strings.TrimSpace(m.searchQuery)) + for i, provider := range m.providers.Providers { + if !m.viewHasSearch(viewProviders) || + strings.Contains(strings.ToLower(provider.DisplayName), query) || + strings.Contains(strings.ToLower(provider.ID), query) || + strings.Contains(strings.ToLower(provider.BaseURL), query) { + indexes = append(indexes, i) + } + } + return indexes +} + +// filteredToolIndexes returns indexes of tools matching the search query +func (m *DashboardModel) filteredToolIndexes() []int { + if m.tools == nil { + return nil + } + total := len(m.tools.Tools) + indexes := make([]int, 0, total) + query := strings.ToLower(strings.TrimSpace(m.searchQuery)) + for i, tool := range m.tools.Tools { + if !m.viewHasSearch(viewTools) || + strings.Contains(strings.ToLower(tool.Name), query) || + strings.Contains(strings.ToLower(tool.ID), query) || + strings.Contains(strings.ToLower(tool.Exec), query) { + indexes = append(indexes, i) + } + } + return indexes +} + +// filteredBindingIndexes returns indexes of bindings matching the search query +func (m *DashboardModel) filteredBindingIndexes() []int { + if m.bindings == nil { + return nil + } + total := len(m.bindings.Bindings) + indexes := make([]int, 0, total) + query := strings.ToLower(strings.TrimSpace(m.searchQuery)) + for i, binding := range m.bindings.Bindings { + if !m.viewHasSearch(viewBindings) || + strings.Contains(strings.ToLower(binding.ToolID), query) || + strings.Contains(strings.ToLower(binding.ProviderID), query) { + indexes = append(indexes, i) + } + } + return indexes +} + +// renderSearchBar renders the search bar UI for views that support search +func (m DashboardModel) renderSearchBar(view viewMode) string { + if !m.supportsSearch(view) { + return "" + } + style := m.styles.Normal + style = style.Padding(0, 2) + switch { + case m.searchActive && m.searchContextView == view: + return style.Render("Search: " + m.searchInput.View()) + case m.viewHasSearch(view): + return style.Render(fmt.Sprintf("Filter: %s (Esc to clear)", m.searchQuery)) + default: + return style.Render("Press '/' to search") + } +} diff --git a/internal/ui/root/sections.go b/internal/ui/root/sections.go new file mode 100644 index 0000000..7a869f8 --- /dev/null +++ b/internal/ui/root/sections.go @@ -0,0 +1,181 @@ +// Package root provides the root UI model and orchestration for the BobaMixer TUI. +package root + +import tea "github.com/charmbracelet/bubbletea" + +// viewMode represents the current view in the dashboard +type viewMode int + +const ( + viewDashboard viewMode = iota + viewProviders + viewTools + viewBindings + viewSecrets + viewStats + viewProxy + viewRouting + viewSuggestions + viewReports + viewHooks + viewConfig + viewHelp +) + +// viewSection represents a logical grouping of related views +type viewSection struct { + name string + shortcut string + views []viewMode +} + +// initSections initializes the section navigation structure +func (m *DashboardModel) initSections() { + m.sections = []viewSection{ + { + name: "Dashboard", + shortcut: "1", + views: []viewMode{viewDashboard}, + }, + { + name: "Control Plane", + shortcut: "2", + views: []viewMode{viewProviders, viewTools, viewBindings, viewSecrets, viewProxy}, + }, + { + name: "Usage", + shortcut: "3", + views: []viewMode{viewStats, viewReports}, + }, + { + name: "Optimization", + shortcut: "4", + views: []viewMode{viewSuggestions}, + }, + { + name: "DevOps", + shortcut: "5", + views: []viewMode{viewRouting, viewHooks, viewConfig}, + }, + } + m.currentSection = 0 + m.sectionViewIndex = 0 + m.updateViewFromSection() +} + +// updateViewFromSection updates the current view based on section navigation state +func (m *DashboardModel) updateViewFromSection() { + if len(m.sections) == 0 { + m.currentView = viewDashboard + return + } + + if m.currentSection < 0 { + m.currentSection = 0 + } + if m.currentSection >= len(m.sections) { + m.currentSection = 0 + } + + section := m.sections[m.currentSection] + if len(section.views) == 0 { + m.currentView = viewDashboard + return + } + + if m.sectionViewIndex < 0 { + m.sectionViewIndex = 0 + } + if m.sectionViewIndex >= len(section.views) { + m.sectionViewIndex = 0 + } + + nextView := section.views[m.sectionViewIndex] + if m.currentView != nextView { + m.currentView = nextView + m.selectedIndex = 0 + } + if m.searchContextView != m.currentView { + m.searchActive = false + m.searchQuery = "" + m.searchContextView = m.currentView + } +} + +// moveToSection jumps directly to a specific section by index +func (m *DashboardModel) moveToSection(idx int) tea.Cmd { + if idx < 0 || idx >= len(m.sections) { + return nil + } + m.currentSection = idx + m.sectionViewIndex = 0 + m.updateViewFromSection() + return m.sectionEnterCmd() +} + +// cycleSection moves forward or backward through sections +func (m *DashboardModel) cycleSection(delta int) tea.Cmd { + m.currentSection = (m.currentSection + delta + len(m.sections)) % len(m.sections) + m.sectionViewIndex = 0 + m.updateViewFromSection() + return m.sectionEnterCmd() +} + +// cycleSubview moves through views within the current section +func (m *DashboardModel) cycleSubview(delta int) tea.Cmd { + section := m.sections[m.currentSection] + if len(section.views) == 0 { + return nil + } + m.sectionViewIndex = (m.sectionViewIndex + delta + len(section.views)) % len(section.views) + m.updateViewFromSection() + return m.sectionEnterCmd() +} + +// sectionEnterCmd returns any command needed when entering a view +func (m *DashboardModel) sectionEnterCmd() tea.Cmd { + switch m.currentView { + case viewStats: + return m.loadStatsData + case viewProxy: + return checkProxyStatus + case viewSuggestions: + return m.loadSuggestions + default: + return nil + } +} + +// viewName returns the display name for a view mode +func viewName(view viewMode) string { + switch view { + case viewDashboard: + return "Dashboard" + case viewProviders: + return "Providers" + case viewTools: + return "Tools" + case viewBindings: + return "Bindings" + case viewSecrets: + return "Secrets" + case viewStats: + return "Usage Stats" + case viewProxy: + return "Proxy" + case viewRouting: + return "Routing Tester" + case viewSuggestions: + return "Suggestions" + case viewReports: + return "Reports" + case viewHooks: + return "Hooks" + case viewConfig: + return "Config Editor" + case viewHelp: + return "Help" + default: + return "View" + } +} diff --git a/internal/ui/root/table.go b/internal/ui/root/table.go new file mode 100644 index 0000000..942d56e --- /dev/null +++ b/internal/ui/root/table.go @@ -0,0 +1,75 @@ +// Package root provides the root UI model and orchestration for the BobaMixer TUI. +package root + +import ( + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/lipgloss" +) + +// initializeTable creates and configures the dashboard table +func (m *DashboardModel) initializeTable() { + columns := []table.Column{ + {Title: "Tool", Width: 15}, + {Title: "Provider", Width: 20}, + {Title: "Model", Width: 25}, + {Title: "Proxy", Width: 10}, + {Title: "Status", Width: 20}, + } + + rows := m.dashboardService.BuildTableRows() + + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(false), + table.WithHeight(7), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(m.theme.Border). + BorderBottom(true). + Bold(true). + Foreground(m.theme.Primary) + + s.Selected = s.Selected. + Foreground(m.theme.Text). + Background(m.theme.Primary). + Bold(false) + + t.SetStyles(s) + + m.table = t +} + +// updateTableSize adjusts table dimensions based on terminal size +func (m *DashboardModel) updateTableSize() { + // Calculate available height for table + headerHeight := 3 + footerHeight := 2 + availableHeight := m.height - headerHeight - footerHeight + + if availableHeight < 5 { + availableHeight = 5 + } + + // Update column widths based on width + columns := m.table.Columns() + if m.width > 100 { + columns[0].Width = 15 // Tool + columns[1].Width = 25 // Provider + columns[2].Width = 28 // Model + columns[3].Width = 10 // Proxy + columns[4].Width = 15 // Status + } else if m.width < 80 { + columns[0].Width = 10 // Tool + columns[1].Width = 18 // Provider + columns[2].Width = 20 // Model + columns[3].Width = 8 // Proxy + columns[4].Width = 12 // Status + } + + m.table.SetColumns(columns) + m.table.SetHeight(availableHeight) +} diff --git a/internal/ui/root/update.go b/internal/ui/root/update.go new file mode 100644 index 0000000..b15d963 --- /dev/null +++ b/internal/ui/root/update.go @@ -0,0 +1,420 @@ +// Package root provides the root UI model and orchestration for the BobaMixer TUI. +package root + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/royisme/bobamixer/internal/domain/core" + dashboardsvc "github.com/royisme/bobamixer/internal/ui/features/dashboard" + proxysvc "github.com/royisme/bobamixer/internal/ui/features/proxy" +) + +// Init initializes the dashboard +func (m DashboardModel) Init() tea.Cmd { + // Check proxy status on startup and load stats + return tea.Batch( + checkProxyStatus, + m.loadStatsData, + ) +} + +// Update handles messages +// +//nolint:gocyclo // UI event handlers are inherently complex +func (m DashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case proxyStatusMsg: + // Update proxy status based on check + if msg.running { + m.proxyStatus = proxysvc.StatusRunning + } else { + m.proxyStatus = proxysvc.StatusStopped + } + return m, nil + + case statsLoadedMsg: + // Update stats data + if msg.err != nil { + m.statsError = msg.err.Error() + } else { + m.todayStats = msg.today + m.weekStats = msg.week + m.profileStats = msg.profileStats + m.statsLoaded = true + m.statsError = "" + } + return m, nil + + case suggestionsLoadedMsg: + if msg.err != nil { + m.suggestionsError = msg.err.Error() + return m, nil + } + + m.suggestions = msg.suggestions + m.suggestionsError = "" + return m, nil + + case tea.KeyMsg: + key := msg.String() + if key == "ctrl+c" || key == "q" { + m.quitting = true + return m, tea.Quit + } + + if m.providerForm.Active() { + switch key { + case keyEsc: + m.providerForm.Cancel("Provider edit canceled") + return m, nil + case keyEnter: + done, err := m.providerForm.Submit() + if err != nil { + return m, nil + } + if done { + if m.providerService != nil { + if err := m.providerService.Save(m.home); err != nil { + m.providerForm.SetMessage(fmt.Sprintf("Error saving: %v", err)) + } + } + } + return m, nil + default: + return m, m.providerForm.Update(msg) + } + } + + if m.bindingForm.Active() { + switch key { + case keyEsc: + m.bindingForm.Cancel("Binding edit canceled") + return m, nil + case keyEnter: + done, err := m.bindingForm.Submit() + if err != nil { + return m, nil + } + if done { + if m.bindingService != nil { + if err := m.bindingService.Save(m.home); err == nil { + m.table.SetRows(m.dashboardService.BuildTableRows()) + } + } + } + return m, nil + default: + return m, m.bindingForm.Update(msg) + } + } + + if m.secretForm.Active() { + switch key { + case keyEsc: + m.secretForm.Cancel("Canceled secret input") + return m, nil + case keyEnter: + value, err := m.secretForm.Submit() + if err != nil { + return m, nil + } + if m.secretService != nil { + m.secretService.SaveValue(m.home, value) + } + return m, nil + default: + return m, m.secretForm.Update(msg) + } + } + + if m.searchActive { + switch key { + case keyEsc: + m.clearSearch() + return m, nil + case keyEnter: + m.searchQuery = strings.TrimSpace(m.searchInput.Value()) + m.searchActive = false + return m, nil + default: + var cmd tea.Cmd + m.searchInput, cmd = m.searchInput.Update(msg) + m.searchQuery = m.searchInput.Value() + return m, cmd + } + } + + switch key { + case "tab": + return m, m.cycleSection(1) + case "shift+tab": + return m, m.cycleSection(-1) + case "[": + return m, m.cycleSubview(-1) + case "]": + return m, m.cycleSubview(1) + case "1", "2", "3", "4", "5": + sectionIndex := int(key[0] - '1') + return m, m.moveToSection(sectionIndex) + + case "r": + if m.currentView == viewSecrets && m.secretService != nil { + m.secretService.Remove(m.home, m.filteredProviderIndexes(), m.selectedIndex) + return m, nil + } + // Run selected tool (only in dashboard view) + if m.currentView == viewDashboard { + return m.handleRun() + } + return m, nil + + case "b": + // Change binding (placeholder for now) + // In future, this would open a binding edit view + return m, nil + + case "x": + // Toggle proxy for selected tool or binding depending on view + switch m.currentView { + case viewDashboard: + return m.handleToggleProxy() + case viewBindings: + return m.handleToggleBindingProxy() + default: + return m, nil + } + + case "s": + if m.currentView == viewSecrets && m.secretService != nil { + if m.secretService.StartForm(m.filteredProviderIndexes(), m.selectedIndex) { + m.searchActive = false + } + return m, nil + } + // Check proxy status + m.proxyStatus = proxysvc.StatusChecking + return m, checkProxyStatus + + case "e": + if m.currentView == viewProviders { + indexes := m.filteredProviderIndexes() + if m.providerService != nil && m.providerService.StartForm(false, indexes, m.selectedIndex) { + m.searchActive = false + } + return m, nil + } + if m.currentView == viewBindings { + indexes := m.filteredBindingIndexes() + if m.bindingService != nil && m.bindingService.StartForm(false, indexes, m.selectedIndex) { + m.searchActive = false + } + return m, nil + } + return m, nil + + case "n": + if m.currentView == viewBindings { + indexes := m.filteredBindingIndexes() + if m.bindingService != nil && m.bindingService.StartForm(true, indexes, m.selectedIndex) { + m.searchActive = false + } + return m, nil + } + return m, nil + + case "a": + if m.currentView == viewProviders { + indexes := m.filteredProviderIndexes() + if m.providerService != nil && m.providerService.StartForm(true, indexes, m.selectedIndex) { + m.searchActive = false + } + return m, nil + } + return m, nil + + case "t": + if m.currentView == viewSecrets && m.secretService != nil { + m.secretService.Test(m.filteredProviderIndexes(), m.selectedIndex) + return m, nil + } + return m, nil + + case "/": + if m.supportsSearch(m.currentView) { + m.activateSearch() + } + return m, nil + + case "?": + m.showHelpOverlay = !m.showHelpOverlay + return m, nil + + case "esc": + if m.showHelpOverlay { + m.showHelpOverlay = false + return m, nil + } + if m.viewHasSearch(m.currentView) { + m.clearSearch() + return m, nil + } + return m, nil + + case "up", "k": + // Navigate up in list views + if m.currentView != viewDashboard && m.selectedIndex > 0 { + m.selectedIndex-- + } + return m, nil + + case "down", "j": + // Navigate down in list views + maxIndex := m.maxSelectableIndex() + if m.currentView != viewDashboard && m.selectedIndex < maxIndex { + m.selectedIndex++ + } + return m, nil + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.updateTableSize() + return m, nil + } + + // Update table (only in dashboard view) + if m.currentView == viewDashboard { + m.table, cmd = m.table.Update(msg) + } + return m, cmd +} + +// maxSelectableIndex returns the maximum selectable index for the current view +func (m DashboardModel) maxSelectableIndex() int { + switch m.currentView { + case viewProviders: + return len(m.filteredProviderIndexes()) - 1 + case viewTools: + return len(m.filteredToolIndexes()) - 1 + case viewBindings: + return len(m.filteredBindingIndexes()) - 1 + case viewSecrets: + return len(m.filteredProviderIndexes()) - 1 // Secrets are per-provider + case viewSuggestions: + return len(m.suggestions) - 1 + case viewReports: + if m.reportsService != nil { + return m.reportsService.OptionCount() - 1 + } + return -1 + case viewConfig: + return len(m.configService.GetConfigFiles()) - 1 + default: + return 0 + } +} + +// handleRun attempts to run the selected tool +func (m DashboardModel) handleRun() (tea.Model, tea.Cmd) { + // Get selected row index + selectedIdx := m.table.Cursor() + + if selectedIdx < 0 || selectedIdx >= len(m.tools.Tools) { + return m, nil + } + + tool := m.tools.Tools[selectedIdx] + + // Exit TUI and run the command + // We'll quit and let the shell run `boba run ` + m.quitting = true + + // Print command hint + fmt.Printf("\nRun: boba run %s\n", tool.ID) + + return m, tea.Quit +} + +// handleToggleProxy toggles the proxy setting for the selected tool +func (m DashboardModel) handleToggleProxy() (tea.Model, tea.Cmd) { + selectedIdx := m.table.Cursor() + + if selectedIdx < 0 || selectedIdx >= len(m.tools.Tools) { + return m, nil + } + + tool := m.tools.Tools[selectedIdx] + + // Find and toggle the binding + binding, err := m.bindings.FindBinding(tool.ID) + if err != nil { + m.message = fmt.Sprintf("Tool %s is not bound to any provider", tool.Name) + return m, nil + } + + // Toggle proxy setting + binding.UseProxy = !binding.UseProxy + + // Save the bindings + if err := core.SaveBindings(m.home, m.bindings); err != nil { + m.message = fmt.Sprintf("Failed to save binding: %v", err) + return m, nil + } + + // Update table rows to reflect the change + m.table.SetRows(m.dashboardService.BuildTableRows()) + + // Set success message + proxyState := dashboardsvc.ProxyStateOff + if binding.UseProxy { + proxyState = dashboardsvc.ProxyStateOn + } + m.message = fmt.Sprintf("Proxy %s for %s", proxyState, tool.Name) + + return m, nil +} + +// handleToggleBindingProxy toggles proxy usage for the selected binding in the bindings view +func (m DashboardModel) handleToggleBindingProxy() (tea.Model, tea.Cmd) { + indexes := m.filteredBindingIndexes() + + if len(indexes) == 0 { + m.message = "No bindings configured" + return m, nil + } + + if m.selectedIndex < 0 || m.selectedIndex >= len(indexes) { + m.message = "No binding selected" + return m, nil + } + + binding := &m.bindings.Bindings[indexes[m.selectedIndex]] + + toolName := binding.ToolID + if tool, err := m.tools.FindTool(binding.ToolID); err == nil { + toolName = tool.Name + } + + binding.UseProxy = !binding.UseProxy + + if err := core.SaveBindings(m.home, m.bindings); err != nil { + m.message = fmt.Sprintf("Failed to save binding: %v", err) + return m, nil + } + + proxyState := dashboardsvc.ProxyStateOff + if binding.UseProxy { + proxyState = dashboardsvc.ProxyStateOn + } + + // Update dashboard table rows to keep views consistent + m.table.SetRows(m.dashboardService.BuildTableRows()) + m.message = fmt.Sprintf("Proxy %s for %s", proxyState, toolName) + + return m, nil +} diff --git a/internal/ui/root/view.go b/internal/ui/root/view.go new file mode 100644 index 0000000..b2ce7e0 --- /dev/null +++ b/internal/ui/root/view.go @@ -0,0 +1,477 @@ +// Package root provides the root UI model and orchestration for the BobaMixer TUI. +package root + +import ( + "fmt" + "strings" + + "github.com/royisme/bobamixer/internal/proxy" + "github.com/royisme/bobamixer/internal/ui/components" + dashboardsvc "github.com/royisme/bobamixer/internal/ui/features/dashboard" + proxysvc "github.com/royisme/bobamixer/internal/ui/features/proxy" + "github.com/royisme/bobamixer/internal/ui/features/secrets" + statssvc "github.com/royisme/bobamixer/internal/ui/features/stats" + "github.com/royisme/bobamixer/internal/ui/pages" +) + +// View renders the dashboard +func (m DashboardModel) View() string { + if m.quitting { + return "" + } + + if m.showHelpOverlay { + return m.renderHelpView() + } + + switch m.currentView { + case viewProviders: + return m.renderProvidersView() + case viewTools: + return m.renderToolsView() + case viewBindings: + return m.renderBindingsView() + case viewSecrets: + return m.renderSecretsView() + case viewStats: + return m.renderStatsView() + case viewProxy: + return m.renderProxyView() + case viewRouting: + return m.renderRoutingView() + case viewSuggestions: + return m.renderSuggestionsView() + case viewReports: + return m.renderReportsView() + case viewHooks: + return m.renderHooksView() + case viewConfig: + return m.renderConfigView() + case viewHelp: + return m.renderHelpView() + default: + return m.renderDashboardView() + } +} + +// renderDashboardView renders the main dashboard view +func (m DashboardModel) renderDashboardView() string { + proxyIcon := dashboardsvc.IconCircleEmpty + proxyText := "Checking..." + if m.proxyService != nil { + viewData := m.proxyService.ViewData(m.proxyStatus) + proxyIcon = viewData.StatusIcon + proxyText = viewData.StatusText + } else if m.proxyStatus == proxysvc.StatusStopped { + proxyText = "Stopped" + } + + page := pages.NewDashboardPage(m.theme, pages.DashboardPageProps{ + Title: "BobaMixer - AI CLI Control Plane", + Table: m.table.View(), + Message: m.message, + ProxyIcon: proxyIcon, + ProxyStatus: proxyText, + NavigationHelp: m.dashboardService.GetNavigationHelp(), + ActionHelp: m.dashboardService.GetActionHelp(), + }) + + return page.View() +} + +// renderStatsView renders the usage statistics view +func (m DashboardModel) renderStatsView() string { + viewData := m.statsService.ConvertToView(statssvc.StatsData{ + Today: m.todayStats, + Week: m.weekStats, + ProfileStats: m.profileStats, + }) + + props := pages.StatsPageProps{ + Title: "BobaMixer - Usage Statistics", + Loaded: m.statsLoaded, + Error: m.statsError, + LoadingMessage: "Loading stats...", + Today: viewData.Today, + Week: viewData.Week, + Profiles: viewData.Profiles, + NavigationHelp: m.dashboardService.GetNavigationHelp(), + LoadingHelp: "[V] Back to Dashboard [Q] Quit", + ProfileSubtitle: "🎯 By Profile (7d)", + } + + page := pages.NewStatsPage(m.theme, props) + return page.View() +} + +// renderProvidersView renders the AI providers management view +func (m DashboardModel) renderProvidersView() string { + indexes := m.filteredProviderIndexes() + if len(indexes) > 0 && m.selectedIndex >= len(indexes) { + m.selectedIndex = len(indexes) - 1 + } + + var ( + providerRows []components.ProviderRow + providerDetails *components.ProviderDetails + emptyState string + ) + if m.providerService != nil { + providerRows = m.providerService.Rows(indexes) + providerDetails = m.providerService.Details(indexes, m.selectedIndex) + emptyState = m.providerService.EmptyStateMessage(len(indexes) == 0, m.viewHasSearch(viewProviders)) + } + + props := pages.ProvidersPageProps{ + Title: "BobaMixer - AI Providers Management", + SectionTitle: "📡 Available Providers", + DetailsTitle: "Details", + ProviderForm: m.renderProviderForm(), + ShowProviderForm: m.providerForm.Active(), + ProviderFormMessage: strings.TrimSpace(m.providerForm.Message()), + SearchBar: m.renderSearchBar(viewProviders), + EmptyStateMessage: emptyState, + Providers: providerRows, + SelectedIndex: m.selectedIndex, + Details: providerDetails, + NavigationHelp: m.dashboardService.GetNavigationHelp(), + ActionHelp: "[E] Edit provider [A] Add provider", + Icons: components.ProviderListIcons{ + Enabled: dashboardsvc.IconCheckmark, + Disabled: dashboardsvc.IconCross, + KeyPresent: "🔑", + KeyMissing: "⚠", + }, + } + + page := pages.NewProvidersPage(m.theme, props) + return page.View() +} + +func (m DashboardModel) renderProviderForm() string { + return m.providerForm.View(m.theme, m.styles) +} + +func (m DashboardModel) renderBindingForm() string { + return m.bindingForm.View(m.theme, m.styles) +} + +// renderToolsView renders the CLI tools management view +func (m DashboardModel) renderToolsView() string { + indexes := m.filteredToolIndexes() + if len(indexes) > 0 && m.selectedIndex >= len(indexes) { + m.selectedIndex = len(indexes) - 1 + } + + var ( + toolRows []components.ToolRow + toolDetails *components.ToolDetails + emptyState string + ) + if m.toolsService != nil { + toolRows = m.toolsService.Rows(indexes) + toolDetails = m.toolsService.Details(indexes, m.selectedIndex) + emptyState = m.toolsService.EmptyStateMessage(len(indexes) == 0, m.viewHasSearch(viewTools)) + } + + props := pages.ToolsPageProps{ + Title: "BobaMixer - CLI Tools Management", + SectionTitle: "🛠 Detected Tools", + DetailsTitle: "Details", + SearchBar: m.renderSearchBar(viewTools), + EmptyStateMessage: emptyState, + Tools: toolRows, + SelectedIndex: m.selectedIndex, + Details: toolDetails, + NavigationHelp: m.dashboardService.GetNavigationHelp(), + ActionHelp: "[B] Bind tool [R] Refresh tools", + BoundIcon: dashboardsvc.IconCircleFilled, + UnboundIcon: dashboardsvc.IconCircleEmpty, + } + + page := pages.NewToolsPage(m.theme, props) + return page.View() +} + +// renderBindingsView renders the tool-to-provider bindings view +func (m DashboardModel) renderBindingsView() string { + indexes := m.filteredBindingIndexes() + if len(indexes) > 0 && m.selectedIndex >= len(indexes) { + m.selectedIndex = len(indexes) - 1 + } + + var ( + bindingRows []components.BindingRow + bindingDetails *components.BindingDetails + emptyState string + ) + if m.bindingService != nil { + bindingRows = m.bindingService.Rows(indexes) + bindingDetails = m.bindingService.Details(indexes, m.selectedIndex) + emptyState = m.bindingService.EmptyStateMessage(len(indexes) == 0, m.viewHasSearch(viewBindings)) + } + + props := pages.BindingsPageProps{ + Title: "BobaMixer - Tool ↔ Provider Bindings", + SectionTitle: "🔗 Active Bindings", + DetailsTitle: "Details", + BindingForm: m.renderBindingForm(), + ShowBindingForm: m.bindingForm.Active(), + BindingFormMessage: strings.TrimSpace(m.bindingForm.Message()), + SearchBar: m.renderSearchBar(viewBindings), + EmptyStateMessage: emptyState, + Bindings: bindingRows, + SelectedIndex: m.selectedIndex, + Details: bindingDetails, + NavigationHelp: m.dashboardService.GetNavigationHelp(), + ActionHelp: "[E] Edit binding [N] New binding [X] Toggle Proxy", + ProxyEnabledIcon: dashboardsvc.IconCircleFilled, + ProxyDisabledIcon: dashboardsvc.IconCircleEmpty, + } + + page := pages.NewBindingsPage(m.theme, props) + return page.View() +} + +// renderSecretsView renders the API keys/secrets management view +func (m DashboardModel) renderSecretsView() string { + searchBar := m.renderSearchBar(viewSecrets) + indexes := m.filteredProviderIndexes() + + if len(indexes) == 0 { + m.selectedIndex = 0 + } else if m.selectedIndex >= len(indexes) { + m.selectedIndex = len(indexes) - 1 + } + + var secretRows []components.SecretProviderRow + if m.secretService != nil { + secretRows = m.secretService.Rows(indexes) + } + + props := pages.SecretsPageProps{ + Title: "BobaMixer - Secrets Management (API Keys)", + StatusTitle: "🔒 API Key Status", + SecurityTitle: "🔐 Security", + SecretForm: m.renderSecretForm(), + ShowSecretForm: m.secretForm.Active(), + SearchBar: searchBar, + EmptyStateMessage: secrets.EmptyStateMessage(len(indexes) == 0, m.viewHasSearch(viewSecrets)), + Providers: secretRows, + SelectedIndex: m.selectedIndex, + SecretMessage: strings.TrimSpace(m.secretMessage), + NavigationHelp: m.dashboardService.GetNavigationHelp(), + ActionHelp: "[S] Set [R] Remove [T] Test", + SuccessIcon: dashboardsvc.IconCheckmark, + FailureIcon: dashboardsvc.IconCross, + SecurityTips: []string{ + "API keys are stored encrypted in ~/.boba/secrets.yaml", + "Keys can also be loaded from environment variables", + "Use 'boba edit secrets' to manage keys manually", + }, + } + + page := pages.NewSecretsPage(m.theme, props) + return page.View() +} + +func (m DashboardModel) renderSecretForm() string { + return m.secretForm.View(m.styles, m.theme) +} + +// renderProxyView renders the proxy server control panel +func (m DashboardModel) renderProxyView() string { + viewData := proxysvc.ViewData{ + StatusIcon: "⋯", + StatusText: "Checking...", + InfoLines: []string{ + "The proxy server intercepts AI API requests from CLI tools", + "and routes them through BobaMixer for tracking and control.", + }, + ConfigLines: []string{ + fmt.Sprintf("Tools with proxy enabled automatically use HTTP_PROXY=%s", proxy.DefaultAddr), + fmt.Sprintf("and HTTPS_PROXY=%s", proxy.DefaultAddr), + }, + CommandHelp: "[S] Refresh Status", + Address: proxy.DefaultAddr, + } + if m.proxyService != nil { + viewData = m.proxyService.ViewData(m.proxyStatus) + } + + props := pages.ProxyPageProps{ + Title: "BobaMixer - Proxy Server Control", + StatusTitle: "🌐 Proxy Status", + InfoTitle: "ℹ️ Information", + ConfigTitle: "📝 Configuration", + StatusState: m.proxyStatus, + Address: viewData.Address, + NavigationHelp: m.dashboardService.GetNavigationHelp(), + CommandHelpLine: viewData.CommandHelp, + InfoLines: viewData.InfoLines, + ConfigLines: viewData.ConfigLines, + StatusIcon: viewData.StatusIcon, + StatusText: viewData.StatusText, + AdditionalNote: viewData.AdditionalNote, + ShowConfig: viewData.ShowConfig, + } + + page := pages.NewProxyPage(m.theme, props) + return page.View() +} + +// renderRoutingView renders the routing rules tester +func (m DashboardModel) renderRoutingView() string { + data := m.routingService.ViewData() + props := pages.RoutingPageProps{ + Title: data.Title, + TestTitle: data.TestTitle, + HowToTitle: data.HowToTitle, + ExampleTitle: data.ExampleTitle, + ContextTitle: data.ContextTitle, + TestDescription: data.TestDescription, + HowToSteps: data.HowToSteps, + ExampleLines: data.ExampleLines, + ContextLines: data.ContextLines, + NavigationHelp: m.dashboardService.GetNavigationHelp(), + CommandHelpLine: data.CommandHelpLine, + } + + page := pages.NewRoutingPage(m.theme, props) + return page.View() +} + +// renderSuggestionsView renders the optimization suggestions view +func (m DashboardModel) renderSuggestionsView() string { + selectedIndex := m.selectedIndex + if len(m.suggestions) > 0 && selectedIndex >= len(m.suggestions) { + selectedIndex = 0 + } + + props := pages.SuggestionsPageProps{ + Title: "BobaMixer - Optimization Suggestions", + SectionTitle: "💡 Recommendations (Last 7 Days)", + DetailsTitle: "Details", + Suggestions: m.suggestionsService.ConvertToView(m.suggestions), + SelectedIndex: selectedIndex, + Error: m.suggestionsError, + NavigationHelp: m.dashboardService.GetNavigationHelp(), + CommandHelpLine: m.suggestionsService.CommandHelp(), + } + + page := pages.NewSuggestionsPage(m.theme, props) + return page.View() +} + +// renderReportsView renders the report generation interface +func (m DashboardModel) renderReportsView() string { + optionCount := 0 + commandHelp := "" + var options []components.ReportOption + if m.reportsService != nil { + optionCount = m.reportsService.OptionCount() + options = m.reportsService.Options() + commandHelp = m.reportsService.CommandHelp() + } + + if optionCount > 0 && m.selectedIndex >= optionCount { + m.selectedIndex = 0 + } + + props := pages.ReportsPageProps{ + Title: "📊 Generate Usage Report", + OptionsTitle: "Report Options", + OutputTitle: "Output Configuration", + ContentsTitle: "Report Contents", + Options: options, + SelectedIndex: m.selectedIndex, + Home: m.home, + NavigationHelp: m.dashboardService.GetNavigationHelp(), + CommandHelpLine: commandHelp, + } + + page := pages.NewReportsPage(m.theme, props) + return page.View() +} + +// renderHooksView renders the Git hooks management interface +func (m DashboardModel) renderHooksView() string { + repoPath := "(Not in a git repository)" + hooksInstalled := false + + data := m.hooksService.ViewData() + hooks := m.hooksService.GetAvailableHooks(hooksInstalled) + props := pages.HooksPageProps{ + Title: data.Title, + RepoTitle: data.RepoTitle, + HooksTitle: data.HooksTitle, + BenefitsTitle: data.BenefitsTitle, + ActivityTitle: data.ActivityTitle, + RepoPath: repoPath, + HooksInstalled: hooksInstalled, + Hooks: m.hooksService.ConvertToComponents(hooks), + NavigationHelp: m.dashboardService.GetNavigationHelp(), + CommandHelpLine: data.CommandHelpLine, + ActiveIcon: dashboardsvc.IconCheckmark, + InactiveIcon: dashboardsvc.IconCross, + } + + page := pages.NewHooksPage(m.theme, props) + return page.View() +} + +func (m DashboardModel) renderConfigView() string { + data := m.configService.ViewData(m.home) + props := pages.ConfigPageProps{ + Title: data.Title, + ConfigTitle: data.ConfigTitle, + EditorTitle: data.EditorTitle, + SafetyTitle: data.SafetyTitle, + ConfigFiles: m.configService.ConvertToComponents(), + SelectedIndex: m.selectedIndex, + Home: data.Home, + EditorName: data.EditorName, + NavigationHelp: m.dashboardService.GetNavigationHelp(), + CommandHelpLine: data.CommandHelpLine, + } + + page := pages.NewConfigPage(m.theme, props) + return page.View() +} + +func (m DashboardModel) renderHelpView() string { + page := pages.NewHelpPage(m.theme, m.helpPageProps()) + return page.View() +} + +func (m DashboardModel) helpPageProps() pages.HelpPageProps { + data := m.helpService.ViewData() + tips := m.helpService.GetDefaultTips() + links := m.helpService.GetDefaultLinks() + + return pages.HelpPageProps{ + Title: data.Title, + Subtitle: data.Subtitle, + Sections: convertSectionsToComponents(m.sections), + Shortcuts: nil, + Tips: tips, + Links: m.helpService.ConvertLinksToComponents(links), + NavigationHint: data.NavigationHint + " | " + m.dashboardService.GetNavigationHelp(), + } +} + +func convertSectionsToComponents(sections []viewSection) []components.HelpSection { + result := make([]components.HelpSection, 0, len(sections)) + for _, section := range sections { + viewNames := make([]string, 0, len(section.views)) + for _, v := range section.views { + viewNames = append(viewNames, viewName(v)) + } + result = append(result, components.HelpSection{ + Name: section.name, + Shortcut: section.shortcut, + Views: viewNames, + }) + } + return result +} diff --git a/internal/ui/theme.go b/internal/ui/theme.go deleted file mode 100644 index 8f8269f..0000000 --- a/internal/ui/theme.go +++ /dev/null @@ -1,132 +0,0 @@ -// Package ui provides terminal user interface theming -package ui - -import ( - "github.com/charmbracelet/lipgloss" -) - -// Theme defines the color scheme for the TUI -type Theme struct { - Primary lipgloss.AdaptiveColor - Success lipgloss.AdaptiveColor - Warning lipgloss.AdaptiveColor - Danger lipgloss.AdaptiveColor - Muted lipgloss.AdaptiveColor - Text lipgloss.AdaptiveColor - Border lipgloss.AdaptiveColor -} - -// DefaultTheme returns an adaptive theme that works on both light and dark terminals -func DefaultTheme() Theme { - return Theme{ - Primary: lipgloss.AdaptiveColor{ - Light: "#5A56E0", // Darker purple for light backgrounds - Dark: "#7C3AED", // Brighter purple for dark backgrounds - }, - Success: lipgloss.AdaptiveColor{ - Light: "#059669", // Darker green - Dark: "#10B981", // Brighter green - }, - Warning: lipgloss.AdaptiveColor{ - Light: "#D97706", // Darker amber - Dark: "#F59E0B", // Brighter amber - }, - Danger: lipgloss.AdaptiveColor{ - Light: "#DC2626", // Darker red - Dark: "#EF4444", // Brighter red - }, - Muted: lipgloss.AdaptiveColor{ - Light: "#6B7280", // Medium gray (same on both) - Dark: "#9CA3AF", // Lighter gray for dark backgrounds - }, - Text: lipgloss.AdaptiveColor{ - Light: "#1F2937", // Dark gray text on light background - Dark: "#E5E7EB", // Light gray text on dark background - }, - Border: lipgloss.AdaptiveColor{ - Light: "#D1D5DB", // Light gray border - Dark: "#4B5563", // Dark gray border - }, - } -} - -// CatppuccinTheme returns a Catppuccin-inspired theme -// Catppuccin Latte for light mode, Catppuccin Mocha for dark mode -func CatppuccinTheme() Theme { - return Theme{ - Primary: lipgloss.AdaptiveColor{ - Light: "#8839EF", // Latte Mauve - Dark: "#CBA6F7", // Mocha Mauve - }, - Success: lipgloss.AdaptiveColor{ - Light: "#40A02B", // Latte Green - Dark: "#A6E3A1", // Mocha Green - }, - Warning: lipgloss.AdaptiveColor{ - Light: "#DF8E1D", // Latte Yellow - Dark: "#F9E2AF", // Mocha Yellow - }, - Danger: lipgloss.AdaptiveColor{ - Light: "#D20F39", // Latte Red - Dark: "#F38BA8", // Mocha Red - }, - Muted: lipgloss.AdaptiveColor{ - Light: "#6C6F85", // Latte Subtext1 - Dark: "#A6ADC8", // Mocha Subtext1 - }, - Text: lipgloss.AdaptiveColor{ - Light: "#4C4F69", // Latte Text - Dark: "#CDD6F4", // Mocha Text - }, - Border: lipgloss.AdaptiveColor{ - Light: "#DCE0E8", // Latte Surface0 - Dark: "#45475A", // Mocha Surface0 - }, - } -} - -// DraculaTheme returns a Dracula-inspired theme -func DraculaTheme() Theme { - return Theme{ - Primary: lipgloss.AdaptiveColor{ - Light: "#6272A4", // Dracula Comment (darker for light mode) - Dark: "#BD93F9", // Dracula Purple - }, - Success: lipgloss.AdaptiveColor{ - Light: "#50FA7B", // Dracula Green (works on both) - Dark: "#50FA7B", - }, - Warning: lipgloss.AdaptiveColor{ - Light: "#F1FA8C", // Dracula Yellow (works on both) - Dark: "#F1FA8C", - }, - Danger: lipgloss.AdaptiveColor{ - Light: "#FF5555", // Dracula Red (works on both) - Dark: "#FF5555", - }, - Muted: lipgloss.AdaptiveColor{ - Light: "#6272A4", // Dracula Comment - Dark: "#6272A4", - }, - Text: lipgloss.AdaptiveColor{ - Light: "#44475A", // Dracula Current Line (darker) - Dark: "#F8F8F2", // Dracula Foreground - }, - Border: lipgloss.AdaptiveColor{ - Light: "#6272A4", // Dracula Comment - Dark: "#44475A", // Dracula Current Line - }, - } -} - -// GetTheme returns the appropriate theme based on user settings -func GetTheme(themeName string) Theme { - switch themeName { - case "catppuccin": - return CatppuccinTheme() - case "dracula": - return DraculaTheme() - default: - return DefaultTheme() - } -} diff --git a/internal/ui/theme/color.go b/internal/ui/theme/color.go new file mode 100644 index 0000000..255ecfa --- /dev/null +++ b/internal/ui/theme/color.go @@ -0,0 +1,129 @@ +// Package theme provides color schemes and styling for the BobaMixer TUI. +package theme + +import "github.com/charmbracelet/lipgloss" + +// Theme defines the color scheme for the TUI. +type Theme struct { + Primary lipgloss.AdaptiveColor + Success lipgloss.AdaptiveColor + Warning lipgloss.AdaptiveColor + Danger lipgloss.AdaptiveColor + Muted lipgloss.AdaptiveColor + Text lipgloss.AdaptiveColor + Border lipgloss.AdaptiveColor +} + +// DefaultTheme returns an adaptive palette that works on both light and dark terminals. +func DefaultTheme() Theme { + return Theme{ + Primary: lipgloss.AdaptiveColor{ + Light: "#5A56E0", + Dark: "#7C3AED", + }, + Success: lipgloss.AdaptiveColor{ + Light: "#059669", + Dark: "#10B981", + }, + Warning: lipgloss.AdaptiveColor{ + Light: "#D97706", + Dark: "#F59E0B", + }, + Danger: lipgloss.AdaptiveColor{ + Light: "#DC2626", + Dark: "#EF4444", + }, + Muted: lipgloss.AdaptiveColor{ + Light: "#6B7280", + Dark: "#9CA3AF", + }, + Text: lipgloss.AdaptiveColor{ + Light: "#1F2937", + Dark: "#E5E7EB", + }, + Border: lipgloss.AdaptiveColor{ + Light: "#D1D5DB", + Dark: "#4B5563", + }, + } +} + +// CatppuccinTheme returns a Catppuccin-inspired palette. +func CatppuccinTheme() Theme { + return Theme{ + Primary: lipgloss.AdaptiveColor{ + Light: "#8839EF", + Dark: "#CBA6F7", + }, + Success: lipgloss.AdaptiveColor{ + Light: "#40A02B", + Dark: "#A6E3A1", + }, + Warning: lipgloss.AdaptiveColor{ + Light: "#DF8E1D", + Dark: "#F9E2AF", + }, + Danger: lipgloss.AdaptiveColor{ + Light: "#D20F39", + Dark: "#F38BA8", + }, + Muted: lipgloss.AdaptiveColor{ + Light: "#6C6F85", + Dark: "#A6ADC8", + }, + Text: lipgloss.AdaptiveColor{ + Light: "#4C4F69", + Dark: "#CDD6F4", + }, + Border: lipgloss.AdaptiveColor{ + Light: "#DCE0E8", + Dark: "#45475A", + }, + } +} + +// DraculaTheme returns a Dracula-inspired palette. +func DraculaTheme() Theme { + return Theme{ + Primary: lipgloss.AdaptiveColor{ + Light: "#6272A4", + Dark: "#BD93F9", + }, + Success: lipgloss.AdaptiveColor{ + Light: "#50FA7B", + Dark: "#50FA7B", + }, + Warning: lipgloss.AdaptiveColor{ + Light: "#F1FA8C", + Dark: "#F1FA8C", + }, + Danger: lipgloss.AdaptiveColor{ + Light: "#FF5555", + Dark: "#FF5555", + }, + Muted: lipgloss.AdaptiveColor{ + Light: "#6272A4", + Dark: "#6272A4", + }, + Text: lipgloss.AdaptiveColor{ + Light: "#44475A", + Dark: "#F8F8F2", + }, + Border: lipgloss.AdaptiveColor{ + Light: "#6272A4", + Dark: "#44475A", + }, + } +} + +// GetTheme returns the palette that matches the provided theme name. +func GetTheme(themeName string) Theme { + switch themeName { + case "catppuccin": + return CatppuccinTheme() + case "dracula": + return DraculaTheme() + default: + return DefaultTheme() + } +} diff --git a/internal/ui/theme/style.go b/internal/ui/theme/style.go new file mode 100644 index 0000000..953c64c --- /dev/null +++ b/internal/ui/theme/style.go @@ -0,0 +1,55 @@ +package theme + +import "github.com/charmbracelet/lipgloss" + +// Styles bundles the lipgloss styles derived from a theme palette. +type Styles struct { + Title lipgloss.Style + Header lipgloss.Style + Selected lipgloss.Style + Normal lipgloss.Style + BudgetOK lipgloss.Style + BudgetWarn lipgloss.Style + BudgetDanger lipgloss.Style + Help lipgloss.Style +} + +// NewStyles builds the default style set for the provided theme palette. +func NewStyles(palette Theme) Styles { + return Styles{ + Title: lipgloss.NewStyle(). + Bold(true). + Foreground(palette.Primary). + MarginBottom(1), + Header: lipgloss.NewStyle(). + Bold(true). + Foreground(palette.Text). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(palette.Border). + Padding(0, 1), + Selected: lipgloss.NewStyle(). + Foreground(palette.Primary). + Bold(true). + PaddingLeft(2), + Normal: lipgloss.NewStyle(). + Foreground(palette.Muted). + PaddingLeft(2), + BudgetOK: lipgloss.NewStyle(). + Foreground(palette.Success). + Bold(true), + BudgetWarn: lipgloss.NewStyle(). + Foreground(palette.Warning). + Bold(true), + BudgetDanger: lipgloss.NewStyle(). + Foreground(palette.Danger). + Bold(true), + Help: lipgloss.NewStyle(). + Foreground(palette.Muted). + Italic(true), + } +} + +// Colorize renders the text with the supplied adaptive color. +func Colorize(color lipgloss.AdaptiveColor, text string) string { + return lipgloss.NewStyle().Foreground(color).Render(text) +} diff --git a/internal/ui/theme_alias.go b/internal/ui/theme_alias.go new file mode 100644 index 0000000..e3cfc3a --- /dev/null +++ b/internal/ui/theme_alias.go @@ -0,0 +1,34 @@ +package ui + +import "github.com/royisme/bobamixer/internal/ui/theme" + +// Theme re-exports the theme palette type for existing callers while the refactor is in progress. +type Theme = theme.Theme + +// Styles re-exports the default style collection derived from a palette. +type Styles = theme.Styles + +// DefaultTheme wraps theme.DefaultTheme for backward compatibility. +func DefaultTheme() Theme { + return theme.DefaultTheme() +} + +// CatppuccinTheme wraps theme.CatppuccinTheme for backward compatibility. +func CatppuccinTheme() Theme { + return theme.CatppuccinTheme() +} + +// DraculaTheme wraps theme.DraculaTheme for backward compatibility. +func DraculaTheme() Theme { + return theme.DraculaTheme() +} + +// GetTheme wraps theme.GetTheme for backward compatibility. +func GetTheme(themeName string) Theme { + return theme.GetTheme(themeName) +} + +// NewStyles exposes the shared styles builder. +func NewStyles(palette Theme) Styles { + return theme.NewStyles(palette) +} diff --git a/internal/ui/tui.go b/internal/ui/tui.go index e83e15f..7c39f11 100644 --- a/internal/ui/tui.go +++ b/internal/ui/tui.go @@ -19,6 +19,8 @@ import ( "github.com/royisme/bobamixer/internal/settings" "github.com/royisme/bobamixer/internal/store/config" "github.com/royisme/bobamixer/internal/store/sqlite" + "github.com/royisme/bobamixer/internal/ui/i18n" + "github.com/royisme/bobamixer/internal/ui/root" ) // ViewMode represents different views in the TUI @@ -50,7 +52,8 @@ type Model struct { budgetStatus *budget.Status notifier *notifications.Notifier theme Theme - localizer *Localizer + styles Styles + localizer *i18n.Localizer home string activeProfile string flashMessage string @@ -61,48 +64,6 @@ type Model struct { err error } -// Style helper methods using adaptive theme -func (m Model) titleStyle() lipgloss.Style { - return lipgloss.NewStyle().Bold(true).Foreground(m.theme.Primary).MarginBottom(1) -} - -func (m Model) headerStyle() lipgloss.Style { - return lipgloss.NewStyle(). - Bold(true). - Foreground(m.theme.Text). - BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(m.theme.Border). - Padding(0, 1) -} - -func (m Model) selectedStyle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(m.theme.Primary).Bold(true).PaddingLeft(2) -} - -func (m Model) normalStyle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(m.theme.Muted).PaddingLeft(2) -} - -func (m Model) budgetOKStyle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(m.theme.Success).Bold(true) -} - -func (m Model) budgetWarningStyle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(m.theme.Warning).Bold(true) -} - -func (m Model) budgetDangerStyle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(m.theme.Danger).Bold(true) -} - -func (m Model) helpStyle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(m.theme.Muted).Italic(true) -} - -func (m Model) colorize(color lipgloss.AdaptiveColor, text string) string { - return lipgloss.NewStyle().Foreground(color).Render(text) -} - // Init initializes the model func (m Model) Init() tea.Cmd { cmds := []tea.Cmd{m.loadData, tea.EnterAltScreen} @@ -216,7 +177,7 @@ func (m Model) View() string { } func (m Model) renderHeader() string { - title := m.titleStyle().Render("🧋 BobaMixer") + title := m.styles.Title.Render("🧋 BobaMixer") var profileInfo string if m.activeProfile != "" { @@ -245,9 +206,9 @@ func (m Model) renderHeader() string { lipgloss.Top, title, " ", - m.normalStyle().Render(profileInfo), + m.styles.Normal.Render(profileInfo), " ", - m.helpStyle().Render(viewIndicator), + m.styles.Help.Render(viewIndicator), ) } @@ -281,7 +242,7 @@ func (m Model) renderDashboard() string { } if len(sections) == 0 { - return m.helpStyle().Render("No data available. Use 'r' to refresh.") + return m.styles.Help.Render("No data available. Use 'r' to refresh.") } return lipgloss.JoinVertical(lipgloss.Left, sections...) @@ -289,7 +250,7 @@ func (m Model) renderDashboard() string { func (m Model) renderProfiles() string { var lines []string - lines = append(lines, m.headerStyle().Render("Available Profiles")) + lines = append(lines, m.styles.Header.Render("Available Profiles")) lines = append(lines, "") for i, profileName := range m.profileList { @@ -301,35 +262,35 @@ func (m Model) renderProfiles() string { } if i == m.selectedIdx { - lines = append(lines, m.selectedStyle().Render("▶ "+line)) + lines = append(lines, m.styles.Selected.Render("▶ "+line)) } else { - lines = append(lines, m.normalStyle().Render(" "+line)) + lines = append(lines, m.styles.Normal.Render(" "+line)) } } lines = append(lines, "") - lines = append(lines, m.helpStyle().Render("↑/↓: Navigate Enter: Select Tab: Switch view")) + lines = append(lines, m.styles.Help.Render("↑/↓: Navigate Enter: Select Tab: Switch view")) return lipgloss.JoinVertical(lipgloss.Left, lines...) } func (m Model) renderBudget() string { if m.budgetStatus == nil { - return m.helpStyle().Render("No budget configured. Use 'boba budget' to set up.") + return m.styles.Help.Render("No budget configured. Use 'boba budget' to set up.") } var lines []string - lines = append(lines, m.headerStyle().Render("Budget Status")) + lines = append(lines, m.styles.Header.Render("Budget Status")) lines = append(lines, "") // Daily limit dailyPercent := m.budgetStatus.DailyProgress dailyBar := m.renderProgressBar(dailyPercent, 30) - dailyStyle := m.budgetOKStyle() + dailyStyle := m.styles.BudgetOK if m.budgetStatus.IsOverDaily { - dailyStyle = m.budgetDangerStyle() + dailyStyle = m.styles.BudgetDanger } else if dailyPercent > 80 { - dailyStyle = m.budgetWarningStyle() + dailyStyle = m.styles.BudgetWarn } lines = append(lines, fmt.Sprintf("Daily Limit: %s / %s", @@ -342,11 +303,11 @@ func (m Model) renderBudget() string { // Hard cap totalPercent := m.budgetStatus.TotalProgress totalBar := m.renderProgressBar(totalPercent, 30) - totalStyle := m.budgetOKStyle() + totalStyle := m.styles.BudgetOK if m.budgetStatus.IsOverCap { - totalStyle = m.budgetDangerStyle() + totalStyle = m.styles.BudgetDanger } else if totalPercent > 80 { - totalStyle = m.budgetWarningStyle() + totalStyle = m.styles.BudgetWarn } // Calculate total spent @@ -363,11 +324,11 @@ func (m Model) renderBudget() string { var warningMsg string switch warningLevel { case "critical": - warningMsg = m.budgetDangerStyle().Render("⚠ CRITICAL: Budget limit exceeded!") + warningMsg = m.styles.BudgetDanger.Render("⚠ CRITICAL: Budget limit exceeded!") case "warning": - warningMsg = m.budgetWarningStyle().Render("⚡ WARNING: Approaching budget limit") + warningMsg = m.styles.BudgetWarn.Render("⚡ WARNING: Approaching budget limit") default: - warningMsg = m.budgetOKStyle().Render("✓ Budget healthy") + warningMsg = m.styles.BudgetOK.Render("✓ Budget healthy") } lines = append(lines, warningMsg) @@ -376,11 +337,11 @@ func (m Model) renderBudget() string { func (m Model) renderTrends() string { if m.trend7d == nil { - return m.helpStyle().Render("No trend data available.") + return m.styles.Help.Render("No trend data available.") } var lines []string - lines = append(lines, m.headerStyle().Render("Usage Trends (7 Days)")) + lines = append(lines, m.styles.Header.Render("Usage Trends (7 Days)")) lines = append(lines, "") // Sparkline @@ -404,11 +365,11 @@ func (m Model) renderTrends() string { var trendMsg string switch trendDir { case "increasing": - trendMsg = m.colorize(m.theme.Warning, "📈 Increasing") + trendMsg = m.styles.BudgetWarn.Render("📈 Increasing") case "decreasing": - trendMsg = m.colorize(m.theme.Success, "📉 Decreasing") + trendMsg = m.styles.BudgetOK.Render("📉 Decreasing") default: - trendMsg = m.colorize(m.theme.Muted, "➡ Stable") + trendMsg = m.styles.Help.Render("➡ Stable") } lines = append(lines, fmt.Sprintf("Trend: %s", trendMsg)) @@ -417,15 +378,15 @@ func (m Model) renderTrends() string { func (m Model) renderSessions() string { if len(m.sessionList) == 0 { - return m.helpStyle().Render("No sessions recorded yet.") + return m.styles.Help.Render("No sessions recorded yet.") } - lines := []string{m.headerStyle().Render("Recent Sessions"), ""} + lines := []string{m.styles.Header.Render("Recent Sessions"), ""} for _, sess := range m.sessionList { started := time.Unix(sess.StartedAt, 0).Format("01-02 15:04") - status := m.colorize(m.theme.Success, "✓") + status := m.styles.BudgetOK.Render("✓") if !sess.Success { - status = m.colorize(m.theme.Danger, "✗") + status = m.styles.BudgetDanger.Render("✗") } dur := fmt.Sprintf("%dms", sess.LatencyMS) lines = append(lines, @@ -433,13 +394,13 @@ func (m Model) renderSessions() string { ) } lines = append(lines, "") - lines = append(lines, m.helpStyle().Render("Tab: Switch view r: Refresh")) + lines = append(lines, m.styles.Help.Render("Tab: Switch view r: Refresh")) return lipgloss.JoinVertical(lipgloss.Left, lines...) } func (m Model) renderStatsBox(title string, lines []string) string { content := []string{ - m.headerStyle().Render(title), + m.styles.Header.Render(title), "", } content = append(content, lines...) @@ -457,13 +418,13 @@ func (m Model) renderBudgetStatusBox() string { switch warningLevel { case "critical": - statusStyle = m.budgetDangerStyle() + statusStyle = m.styles.BudgetDanger statusIcon = "🔴" case "warning": - statusStyle = m.budgetWarningStyle() + statusStyle = m.styles.BudgetWarn statusIcon = "🟡" default: - statusStyle = m.budgetOKStyle() + statusStyle = m.styles.BudgetOK statusIcon = "🟢" } @@ -495,11 +456,11 @@ func (m Model) renderProgressBar(percent float64, width int) string { var style lipgloss.Style if percent > 100 { - style = m.budgetDangerStyle() + style = m.styles.BudgetDanger } else if percent > 80 { - style = m.budgetWarningStyle() + style = m.styles.BudgetWarn } else { - style = m.budgetOKStyle() + style = m.styles.BudgetOK } return style.Render(bar) + fmt.Sprintf(" %.1f%%", percent) @@ -509,20 +470,20 @@ func (m Model) renderFooter() string { var parts []string if m.err != nil { - parts = append(parts, m.colorize(m.theme.Danger, "Error: "+m.err.Error())) + parts = append(parts, m.styles.BudgetDanger.Render("Error: "+m.err.Error())) } if m.flashMessage != "" { - parts = append(parts, m.colorize(m.theme.Success, m.flashMessage)) + parts = append(parts, m.styles.BudgetOK.Render(m.flashMessage)) } if !m.lastUpdate.IsZero() { - parts = append(parts, m.helpStyle().Render( + parts = append(parts, m.styles.Help.Render( fmt.Sprintf("Last updated: %s", m.lastUpdate.Format("15:04:05")), )) } - parts = append(parts, m.helpStyle().Render(m.localizer.T("tui.quit"))) + parts = append(parts, m.styles.Help.Render(m.localizer.T("tui.quit"))) return strings.Join(parts, " | ") } @@ -604,7 +565,7 @@ func Run(home string) error { if useControlPlane { // Use new control plane dashboard - return RunDashboard(home) + return root.RunDashboard(home) } // Check if first-run (no configuration at all) @@ -621,7 +582,7 @@ func Run(home string) error { } // Onboarding completed, launch dashboard - return RunDashboard(home) + return root.RunDashboard(home) } // Legacy: Load profiles (gracefully handle missing/invalid config) @@ -638,7 +599,7 @@ func Run(home string) error { } // Launch dashboard after onboarding - return RunDashboard(home) + return root.RunDashboard(home) } // Open database @@ -668,11 +629,11 @@ func Run(home string) error { // Initialize theme and i18n theme := loadTheme(home) - localizer, err := NewLocalizer(GetUserLanguage()) + localizer, err := i18n.NewLocalizer(i18n.GetUserLanguage()) if err != nil { // Fallback to English - this should never fail with embedded locales var fallbackErr error - localizer, fallbackErr = NewLocalizer("en") + localizer, fallbackErr = i18n.NewLocalizer("en") if fallbackErr != nil { return fmt.Errorf("failed to initialize localizer: %w (fallback also failed: %w)", err, fallbackErr) } @@ -694,6 +655,7 @@ func Run(home string) error { statsAnalyzer: stats.NewAnalyzer(db), notifier: notifications.NewNotifier(tracker, suggEngine, nil), theme: theme, + styles: NewStyles(theme), localizer: localizer, } From 37fae20f27540a3a42b0e26ff3b2f24ec8ee2860 Mon Sep 17 00:00:00 2001 From: Roy Zhu Date: Wed, 19 Nov 2025 04:31:57 +0800 Subject: [PATCH 4/4] Delete GEMINI.md --- GEMINI.md | 94 ------------------------------------------------------- 1 file changed, 94 deletions(-) delete mode 100644 GEMINI.md diff --git a/GEMINI.md b/GEMINI.md deleted file mode 100644 index 0ab9c1f..0000000 --- a/GEMINI.md +++ /dev/null @@ -1,94 +0,0 @@ -# BobaMixer Project Overview for Gemini - -This document provides a comprehensive overview of the BobaMixer project, detailing its purpose, technologies, architecture, and development practices, derived from its `README.md` file. This information is intended to serve as instructional context for future interactions with Gemini. - -## Project Overview - -BobaMixer is an intelligent router and cost optimizer designed for AI workflows. It acts as a control plane for managing various AI service providers, local CLI tools, and their bindings. Its core functionalities include auto-injecting credentials, running local AI CLI tools, and providing an optional local HTTP proxy to consolidate requests. Advanced features encompass intelligent routing based on task characteristics, multi-level budget management with alerts, and precise usage analytics for cost tracking. The project is primarily written in Go (version 1.25 or newer) and leverages SQLite for local storage, and the Bubble Tea framework for its terminal user interface (TUI). It emphasizes a modular design, robust error handling, and adherence to Go best practices. - -## Building and Running - -To get started with BobaMixer, follow these steps: - -### Installation - -* **Using Go:** - ```bash - go install github.com/royisme/bobamixer/cmd/boba@latest - ``` -* **Using Homebrew:** - ```bash - brew tap royisme/tap - brew install bobamixer - ``` - -### First Time Setup (Interactive Onboarding) - -* Run `boba` in your terminal. The onboarding wizard will automatically guide you through configuration, API key input, and verification. - -### Alternative CLI Setup (for power users) - -1. **Initialize config directory:** - ```bash - boba init - ``` -2. **Configure API Key:** (e.g., for Anthropic) - ```bash - boba secrets set claude-anthropic-official - ``` -3. **Bind tool to Provider:** (e.g., bind `claude` tool to `claude-anthropic-official` provider) - ```bash - boba bind claude claude-anthropic-official - ``` -4. **Verify configuration:** - ```bash - boba doctor - ``` -5. **Run a tool:** (e.g., run the `claude` tool to check its version) - ```bash - boba run claude --version - ``` - -### Using Environment Variables - -* Set API keys as environment variables (e.g., `export ANTHROPIC_API_KEY="sk-ant..."`). BobaMixer prioritizes environment variables. - -### Build from Source (for Developers) - -1. **Clone the repository:** - ```bash - git clone https://github.com/royisme/BobaMixer.git - ``` -2. **Navigate into the directory:** - ```bash - cd BobaMixer - ``` -3. **Install dependencies:** - ```bash - go mod download - ``` -4. **Build:** - ```bash - make build - ``` -5. **Run tests:** - ```bash - make test - ``` -6. **Lint check:** - ```bash - make lint - ``` - -## Development Conventions - -The BobaMixer project adheres to strict Go language standards and best practices: - -* **Code Quality:** - * All exported types and functions must have documentation comments. - * `golangci-lint` is used for static analysis, with a target of 0 issues. - * Follow the [Effective Go](https://go.dev/doc/effective_go) guide. - * Run `make test && make lint` before committing changes. -* **Error Handling:** Complete error wrapping and graceful degradation are implemented. -* **Concurrency Safety:** `sync.RWMutex` is used to protect shared state, ensuring thread-safe operations. -* **Security:** All exceptions are marked with `#nosec` after an audit.