diff --git a/.gitignore b/.gitignore index a7926e6..a5d246f 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ go.work.sum .works/ AGENTS.md CLAUDE.md +GEMINI.md .serena/ .claude/ # VitePress @@ -50,3 +51,4 @@ docs/.vitepress/cache # Node node_modules/ +.gocache/ 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 1afa534..0000000 --- a/internal/ui/dashboard.go +++ /dev/null @@ -1,1972 +0,0 @@ -package ui - -import ( - "context" - "fmt" - "net/http" - "path/filepath" - "strings" - "time" - - "github.com/charmbracelet/bubbles/table" - 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" -) - -// 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-9,0,H,C,?] Switch View [↑/↓] Navigate [Tab] Next View [Q] Quit" -) - -const totalViews viewMode = viewHelp + 1 - -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 -} - -// 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.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: - switch msg.String() { - case "ctrl+c", "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 - - case "0": - m.currentView = viewReports - m.selectedIndex = 0 - return m, nil - - case "h", "H": - m.currentView = viewHooks - m.selectedIndex = 0 - return m, nil - - case "c", "C": - m.currentView = viewConfig - m.selectedIndex = 0 - return m, nil - - 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 - - case "r": - // 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": - // Check proxy status - m.proxyStatus = proxyStatusChecking - return m, checkProxyStatus - - case "p": - // View providers (placeholder for now) - // In future, this would open provider management view - return m, nil - - case "?": - m.currentView = viewHelp - m.selectedIndex = 0 - 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.providers.Providers) - 1 - case viewTools: - return len(m.tools.Tools) - 1 - case viewBindings: - return len(m.bindings.Bindings) - 1 - case viewSecrets: - return len(m.providers.Providers) - 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 - } -} - -// 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) { - if len(m.bindings.Bindings) == 0 { - m.message = "No bindings configured" - return m, nil - } - - if m.selectedIndex < 0 || m.selectedIndex >= len(m.bindings.Bindings) { - m.message = "No binding selected" - return m, nil - } - - binding := &m.bindings.Bindings[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 "" - } - - 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 { - 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..." - switch m.proxyStatus { - case proxyStatusRunning: - proxyStatusIcon = iconCircleFilled - proxyStatusText = "Running" - case proxyStatusStopped: - proxyStatusIcon = iconCircleEmpty - proxyStatusText = "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") - } - - // 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() -} - -// 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() - } - - // 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") - - // 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") - } - - // Footer/Help - helpText := "[V] Back to Dashboard [S] Refresh [Q] Quit" - content.WriteString(helpStyle.Render(helpText)) - - return content.String() -} - -// 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) - - 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 - AI Providers Management" - content.WriteString(titleStyle.Render(title)) - content.WriteString("\n\n") - - // Section header - content.WriteString(headerStyle.Render("📡 Available Providers")) - content.WriteString("\n\n") - - // Provider 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 { - // 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") - } - } - - content.WriteString("\n") - - // 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") - } - content.WriteString("\n") - } - - // Footer/Help - content.WriteString(helpStyle.Render(helpTextNavigation)) - - return content.String() -} - -// renderToolsView renders the CLI tools management view -func (m DashboardModel) renderToolsView() 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 - CLI Tools Management" - content.WriteString(titleStyle.Render(title)) - content.WriteString("\n\n") - - // Section header - content.WriteString(headerStyle.Render("🛠 Detected Tools")) - content.WriteString("\n\n") - - // 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 - } - - 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") - } - } - - content.WriteString("\n") - - // 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") - } - - // Footer/Help - content.WriteString(helpStyle.Render(helpTextNavigation)) - - return content.String() -} - -// 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") - - // 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, - ) - - if i == m.selectedIndex { - content.WriteString(selectedStyle.Render("▶ " + line)) - } else { - content.WriteString(normalStyle.Render(" " + line)) - } - content.WriteString("\n") - } - } - - 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") - } - - // Footer/Help - helpText := "[1-6] Switch View [↑/↓] Navigate [X] Toggle Proxy [Tab] Next View [Q] Quit" - 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) - - 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) - - dangerStyle := lipgloss.NewStyle(). - Foreground(m.theme.Danger). - Padding(0, 1) - - successStyle := lipgloss.NewStyle(). - Foreground(m.theme.Success). - Padding(0, 1) - - helpStyle := lipgloss.NewStyle(). - Foreground(m.theme.Muted). - Padding(1, 2) - - var content strings.Builder - - // Header - title := "BobaMixer - Secrets Management (API Keys)" - content.WriteString(titleStyle.Render(title)) - content.WriteString("\n\n") - - // Section header - content.WriteString(headerStyle.Render("🔒 API Key Status")) - content.WriteString("\n\n") - - // 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) - } - - var statusIcon, statusText string - var keyStatusStyle lipgloss.Style - if hasKey { - statusIcon = iconCheckmark - statusText = "Configured" - keyStatusStyle = successStyle - } else { - statusIcon = iconCross - statusText = "Missing" - keyStatusStyle = dangerStyle - } - - 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") - } - } - - content.WriteString("\n") - - // 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") - - // Footer/Help - content.WriteString(helpStyle.Render(helpTextNavigation)) - - return content.String() -} - -// 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) - - var content strings.Builder - - // Header - title := "BobaMixer - Proxy Server Control" - content.WriteString(titleStyle.Render(title)) - content.WriteString("\n\n") - - // Proxy status section - content.WriteString(headerStyle.Render("🌐 Proxy Status")) - content.WriteString("\n\n") - - var statusStyle lipgloss.Style - var statusIcon, statusText string - - switch m.proxyStatus { - case proxyStatusRunning: - statusIcon = iconCircleFilled - statusText = "Running" - statusStyle = successStyle - case proxyStatusStopped: - statusIcon = iconCircleEmpty - statusText = "Stopped" - statusStyle = dangerStyle - 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") - } - - // 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() -} - -// 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)) - - return content.String() -} - -// 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() - } - - // 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") - } - } - } - } - - 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() -} - -// 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 { - 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") - } - } - - 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") - - 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") - - // 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)) - - return content.String() -} - -// 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) - - 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") - - // Try to detect current git repo - repoPath := "(Not in a git repository)" - hooksInstalled := false - - // Simple check - in real implementation this would call git commands - content.WriteString(normalStyle.Render(fmt.Sprintf(" Path: %s", repoPath))) - content.WriteString("\n") - - if hooksInstalled { - content.WriteString(successStyle.Render(" Status: ✓ Hooks Installed")) - } else { - content.WriteString(dangerStyle.Render(" Status: ✗ Hooks Not Installed")) - } - content.WriteString("\n\n") - - // Hook types - content.WriteString(headerStyle.Render("Available Hooks")) - content.WriteString("\n") - - 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 - } - - 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") - } - - 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") - - // 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)) - - return content.String() -} - -// 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) - - var content strings.Builder - - // Header - content.WriteString(titleStyle.Render("⚙️ Configuration Editor")) - content.WriteString("\n\n") - - content.WriteString(headerStyle.Render("Configuration Files")) - content.WriteString("\n") - - if m.selectedIndex >= len(configFiles) { - m.selectedIndex = 0 - } - - for i, cfg := range configFiles { - line := fmt.Sprintf(" %s", cfg.name) - filePath := lipgloss.NewStyle().Foreground(m.theme.Muted).Render(fmt.Sprintf(" (%s)", cfg.file)) - - if i == m.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 == 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") - } - } - - 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 := "[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)) - - return content.String() -} - -// 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) - - var content strings.Builder - - // Header - content.WriteString(titleStyle.Render("❓ BobaMixer Help & Shortcuts")) - content.WriteString("\n\n") - - // 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") - } - - content.WriteString("\n") - content.WriteString(headerStyle.Render("Global Shortcuts")) - content.WriteString("\n") - - 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"}, - } - - 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") - } - - 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") - - 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 := "Use navigation keys (1-9, 0, H, C, ?) to switch views | [Tab] Next View | [Q] Quit" - content.WriteString(helpStyle.Render(helpText)) - - return content.String() -} - -// 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/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/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/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 e2cb5e0..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)) @@ -138,7 +139,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/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 de5677a..7c39f11 100644 --- a/internal/ui/tui.go +++ b/internal/ui/tui.go @@ -19,10 +19,8 @@ import ( "github.com/royisme/bobamixer/internal/settings" "github.com/royisme/bobamixer/internal/store/config" "github.com/royisme/bobamixer/internal/store/sqlite" -) - -const ( - keyCtrlC = "ctrl+c" + "github.com/royisme/bobamixer/internal/ui/i18n" + "github.com/royisme/bobamixer/internal/ui/root" ) // ViewMode represents different views in the TUI @@ -54,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 @@ -65,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} @@ -141,7 +98,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 @@ -220,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 != "" { @@ -249,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), ) } @@ -285,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...) @@ -293,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 { @@ -305,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", @@ -346,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 @@ -367,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) @@ -380,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 @@ -408,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)) @@ -421,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, @@ -437,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...) @@ -461,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 = "🟢" } @@ -499,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) @@ -513,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, " | ") } @@ -608,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) @@ -625,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) @@ -642,7 +599,7 @@ func Run(home string) error { } // Launch dashboard after onboarding - return RunDashboard(home) + return root.RunDashboard(home) } // Open database @@ -672,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) } @@ -698,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, } 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 等) + +任何违反以上规则的代码都必须拒绝生成。 + +---