diff --git a/cmd/claws/main.go b/cmd/claws/main.go index 187ee8b..a0d46f1 100644 --- a/cmd/claws/main.go +++ b/cmd/claws/main.go @@ -53,6 +53,14 @@ func main() { } cfg.SetReadOnly(opts.readOnly) + var compactHeader bool + if opts.compactHeader != nil { + compactHeader = *opts.compactHeader + } else { + compactHeader = fileCfg.GetCompactHeader() + } + cfg.SetCompactHeader(compactHeader) + for _, p := range opts.profiles { if !config.IsValidProfileName(p) { fmt.Fprintf(os.Stderr, "Error: invalid profile name: %s\n", p) @@ -116,16 +124,17 @@ func main() { } type cliOptions struct { - profiles []string - regions []string - readOnly bool - envCreds bool - autosave *bool - logFile string - configFile string - service string - resourceID string - theme string + profiles []string + regions []string + readOnly bool + envCreds bool + autosave *bool + logFile string + configFile string + service string + resourceID string + theme string + compactHeader *bool } // parseFlags parses command line flags and returns options @@ -194,6 +203,12 @@ func parseFlagsFromArgs(args []string) cliOptions { i++ opts.theme = args[i] } + case "--compact": + t := true + opts.compactHeader = &t + case "--no-compact": + f := false + opts.compactHeader = &f case "-h", "--help": showHelp = true case "-v", "--version": @@ -245,6 +260,10 @@ func printUsage() { fmt.Println(" Enable debug logging to specified file") fmt.Println(" -t, --theme ") fmt.Println(" Color theme: dark, light, nord, dracula, gruvbox, catppuccin") + fmt.Println(" --compact") + fmt.Println(" Start with compact header mode (toggle with Ctrl+E)") + fmt.Println(" --no-compact") + fmt.Println(" Disable compact header (overrides config file)") fmt.Println(" -v, --version") fmt.Println(" Show version") fmt.Println(" -h, --help") diff --git a/docs/configuration.md b/docs/configuration.md index 00dfa6c..449537f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -49,7 +49,9 @@ cloudwatch: window: 15m # Metrics data window period (default: 15m) autosave: - enabled: true # Save region/profile/theme on change (default: false) + enabled: true # Save region/profile/theme/compact_header on change (default: false) + +compact_header: false # Use single-line compact header (default: false) startup: # Applied on launch if present view: services # Startup view: "dashboard", "services", or "service/resource" (e.g., "ec2", "rds/snapshots") @@ -84,7 +86,7 @@ theme: nord # Preset: dark, light, nord, dracula, gruvbox, catppuc The config file is **not created automatically**. Create it manually if needed. -CLI flags (`-p`, `-r`, `-t`, `--autosave`, `--no-autosave`) override config file settings. +CLI flags (`-p`, `-r`, `-t`, `--compact`, `--no-compact`, `--autosave`, `--no-autosave`) override config file settings. Multiple values supported: `-p dev,prod` or `-p dev -p prod`. ### Special Profile IDs diff --git a/docs/keybindings.md b/docs/keybindings.md index 6ee3872..fa20c65 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -23,6 +23,7 @@ Complete reference for all keyboard shortcuts in claws. | `:services` | Go to service browser | | `/` | Filter mode (fuzzy search) | | `A` | AI Chat (Bedrock) | +| `Ctrl+E` | Toggle compact header | | `?` | Show help | ## Resource Browser @@ -63,6 +64,7 @@ Complete reference for all keyboard shortcuts in claws. | `:diff ` | Compare two named resources | | `:theme ` | Change color theme | | `:autosave on/off` | Enable/disable config autosave | +| `:settings` | Show current settings | | `:clear-history` | Clear navigation history (stack) | ## Mouse Support diff --git a/internal/app/app.go b/internal/app/app.go index c836c08..8cacbac 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -227,6 +227,15 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, nil + case view.CompactHeaderChangedMsg: + if a.currentView != nil { + a.currentView.Update(msg) + } + for _, v := range a.viewStack { + v.Update(msg) + } + return a, nil + case view.ThemeChangeMsg: theme := ui.GetPreset(msg.Name) if theme == nil { @@ -340,6 +349,16 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { chatOverlay.Init(), a.modal.SetSize(a.width, a.height), ) + + case key.Matches(msg, a.keys.CompactHeader): + compact := !config.Global().CompactHeader() + config.Global().SetCompactHeader(compact) + if config.File().PersistenceEnabled() { + if err := config.File().SaveCompactHeader(compact); err != nil { + log.Warn("failed to persist compact header", "error", err) + } + } + return a, func() tea.Msg { return view.CompactHeaderChangedMsg{} } } case view.ShowModalMsg: @@ -504,7 +523,7 @@ func (a *App) View() tea.View { var statusContent string if a.commandMode { - statusContent = a.commandInput.View() + " • Esc:cancel Enter:run Tab:complete" + statusContent = a.commandInput.View() + ui.DimStyle().Render(" • Esc:cancel Enter:run Tab:complete") } else { if a.err != nil { statusContent = ui.DangerStyle().Render("Error: " + a.err.Error()) @@ -774,17 +793,18 @@ func (a *App) refreshCurrentView() (tea.Model, tea.Cmd) { } type keyMap struct { - Up key.Binding - Down key.Binding - Enter key.Binding - Back key.Binding - Filter key.Binding - Command key.Binding - Region key.Binding - Profile key.Binding - AI key.Binding - Help key.Binding - Quit key.Binding + Up key.Binding + Down key.Binding + Enter key.Binding + Back key.Binding + Filter key.Binding + Command key.Binding + Region key.Binding + Profile key.Binding + AI key.Binding + CompactHeader key.Binding + Help key.Binding + Quit key.Binding } func defaultKeyMap() keyMap { @@ -825,6 +845,10 @@ func defaultKeyMap() keyMap { key.WithKeys("A"), key.WithHelp("A", "ai chat"), ), + CompactHeader: key.NewBinding( + key.WithKeys("ctrl+e"), + key.WithHelp("ctrl+e", "compact header"), + ), Help: key.NewBinding( key.WithKeys("?"), key.WithHelp("?", "help"), diff --git a/internal/config/config.go b/internal/config/config.go index 8612174..e5446a1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -176,12 +176,13 @@ func (s ProfileSelection) ID() string { } type Config struct { - mu sync.RWMutex - regions []string - selections []ProfileSelection - accountIDs map[string]string - warnings []string - readOnly bool + mu sync.RWMutex + regions []string + selections []ProfileSelection + accountIDs map[string]string + warnings []string + readOnly bool + compactHeader bool } var ( @@ -346,6 +347,14 @@ func (c *Config) SetReadOnly(readOnly bool) { doWithLock(&c.mu, func() { c.readOnly = readOnly }) } +func (c *Config) CompactHeader() bool { + return withRLock(&c.mu, func() bool { return c.compactHeader }) +} + +func (c *Config) SetCompactHeader(compact bool) { + doWithLock(&c.mu, func() { c.compactHeader = compact }) +} + func (c *Config) AddWarning(msg string) { doWithLock(&c.mu, func() { c.warnings = append(c.warnings, msg) }) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0ee616c..6f5373e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -89,6 +89,27 @@ func TestConfig_ReadOnlyGetSet(t *testing.T) { } } +func TestConfig_CompactHeaderGetSet(t *testing.T) { + cfg := &Config{} + + // Initial value should be false + if cfg.CompactHeader() { + t.Error("CompactHeader() = true, want false") + } + + // Set to true + cfg.SetCompactHeader(true) + if !cfg.CompactHeader() { + t.Error("CompactHeader() = false, want true") + } + + // Set back to false + cfg.SetCompactHeader(false) + if cfg.CompactHeader() { + t.Error("CompactHeader() = true, want false") + } +} + func TestConfig_Warnings(t *testing.T) { cfg := &Config{} diff --git a/internal/config/file.go b/internal/config/file.go index 763c8c2..22c3c3c 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -209,6 +209,7 @@ type FileConfig struct { Theme ThemeConfig `yaml:"theme,omitempty"` Navigation NavigationConfig `yaml:"navigation,omitempty"` AI AIConfig `yaml:"ai,omitempty"` + CompactHeader bool `yaml:"compact_header,omitempty"` } // Duration wraps time.Duration for YAML marshal/unmarshal as string (e.g., "5s", "30s") @@ -593,6 +594,23 @@ func (c *FileConfig) SavePersistence(enabled bool) error { }) } +func (c *FileConfig) GetCompactHeader() bool { + return withRLock(&c.mu, func() bool { + return c.CompactHeader + }) +} + +func (c *FileConfig) SaveCompactHeader(compact bool) error { + c.mu.Lock() + defer c.mu.Unlock() + + c.CompactHeader = compact + + return c.patchConfigLocked(func(mapping *yaml.Node) { + setBoolValue(mapping, "compact_header", compact) + }) +} + func (c *FileConfig) patchConfigLocked(patchFn func(mapping *yaml.Node)) error { path, err := ConfigPath() if err != nil { diff --git a/internal/ui/theme.go b/internal/ui/theme.go index 6002270..2d9e6b1 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -10,6 +10,7 @@ import ( "sync" "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/textinput" "charm.land/lipgloss/v2" "github.com/clawscli/claws/internal/config" @@ -472,3 +473,17 @@ func NewSpinner() spinner.Model { s.Style = lipgloss.NewStyle().Foreground(Current().Accent) return s } + +func TextInputStyles() textinput.Styles { + t := Current() + state := textinput.StyleState{ + Text: lipgloss.NewStyle().Foreground(t.Text), + Placeholder: lipgloss.NewStyle().Foreground(t.TextDim), + Suggestion: lipgloss.NewStyle().Foreground(t.TextDim), + Prompt: lipgloss.NewStyle().Foreground(t.Text), + } + return textinput.Styles{ + Focused: state, + Blurred: state, + } +} diff --git a/internal/view/command_input.go b/internal/view/command_input.go index 6e02f5c..2029476 100644 --- a/internal/view/command_input.go +++ b/internal/view/command_input.go @@ -38,7 +38,7 @@ func newCommandInputStyles() commandInputStyles { input: ui.InputFieldStyle(), suggestion: ui.DimStyle(), highlight: ui.HighlightStyle(), - alias: ui.NoStyle(), // Normal text, not dimmed + alias: ui.TextStyle(), } } @@ -80,6 +80,7 @@ func NewCommandInput(ctx context.Context, reg *registry.Registry) *CommandInput ti.Prompt = ":" ti.CharLimit = 150 ti.SetWidth(commandInputWidth1) + ti.SetStyles(ui.TextInputStyles()) return &CommandInput{ ctx: ctx, @@ -101,6 +102,7 @@ func (c *CommandInput) Activate() tea.Cmd { func (c *CommandInput) ReloadStyles() { c.styles = newCommandInputStyles() + c.textInput.SetStyles(ui.TextInputStyles()) } // Deactivate deactivates command mode @@ -314,7 +316,7 @@ func (c *CommandInput) View() string { if len(c.suggestions) > maxShow+1 { suggText += " ..." } - suggView = s.alias.Render(suggText) // alias = NoStyle (white) + suggView = s.alias.Render(suggText) } } diff --git a/internal/view/dashboard_view.go b/internal/view/dashboard_view.go index 202879a..be2d07a 100644 --- a/internal/view/dashboard_view.go +++ b/internal/view/dashboard_view.go @@ -192,6 +192,9 @@ func (d *DashboardView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { d.styles = newDashboardStyles() d.headerPanel.ReloadStyles() return d, nil + case CompactHeaderChangedMsg: + d.lastHeaderHeight = 0 // force hitAreas rebuild + return d, nil case tea.MouseClickMsg: return d.handleMouseClick(msg) diff --git a/internal/view/detail_view.go b/internal/view/detail_view.go index 2b1d1a6..8c12655 100644 --- a/internal/view/detail_view.go +++ b/internal/view/detail_view.go @@ -17,6 +17,8 @@ import ( "github.com/clawscli/claws/internal/ui" ) +const minViewportHeight = 5 + // DetailView displays detailed information about a single resource // detailViewStyles holds cached lipgloss styles for performance type detailViewStyles struct { @@ -47,6 +49,8 @@ type DetailView struct { refreshErr error spinner spinner.Model styles detailViewStyles + width int + height int } // NewDetailView creates a new DetailView @@ -128,6 +132,9 @@ func (d *DetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { d.vp.Model.SetContent(d.renderContent()) } return d, nil + case CompactHeaderChangedMsg: + d.recalcViewport() + return d, nil case tea.KeyPressMsg: // Let app handle back navigation (esc/backspace/q handled by app.go) @@ -205,8 +212,16 @@ func (d *DetailView) View() tea.View { // SetSize implements View func (d *DetailView) SetSize(width, height int) tea.Cmd { + d.width = width + d.height = height d.headerPanel.SetWidth(width) + d.recalcViewport() + + return nil +} + +func (d *DetailView) recalcViewport() { // Calculate header height dynamically var summaryFields []render.SummaryField if d.renderer != nil { @@ -215,14 +230,16 @@ func (d *DetailView) SetSize(width, height int) tea.Cmd { headerStr := d.headerPanel.Render(d.service, d.resType, summaryFields) headerHeight := d.headerPanel.Height(headerStr) - viewportHeight := max(height-headerHeight+1, 5) + // +1 compensates for border overlap + viewportHeight := max(d.height-headerHeight+1, minViewportHeight) - d.vp.SetSize(width, viewportHeight) + d.vp.SetSize(d.width, viewportHeight) + if !d.vp.Ready { + return + } content := d.renderContent() d.vp.Model.SetContent(content) - - return nil } func (d *DetailView) StatusLine() string { diff --git a/internal/view/header_panel.go b/internal/view/header_panel.go index a33ec3d..2069b09 100644 --- a/internal/view/header_panel.go +++ b/internal/view/header_panel.go @@ -14,11 +14,15 @@ import ( ) const ( - // headerFixedLines is the fixed number of content lines in the header panel - // 1: context line, 1: separator, 3: summary field rows - headerFixedLines = 5 - // maxFieldValueWidth is the maximum width for a single field value before truncation - maxFieldValueWidth = 30 + // headerFixedLines: 2 profile/region + 1 separator + 2 summary rows + headerFixedLines = 5 + maxFieldValueWidth = 30 + headerPanelPadding = 6 + minAvailableWidth = 40 + profileTruncateWidth = 20 + // profileWidthRatio: profile gets 2/3 of remaining width, region gets 1/3 (compact mode) + profileWidthRatio = 2 + regionWidthRatio = 3 ) // HeaderPanel renders the fixed header panel at the top of resource views @@ -55,71 +59,203 @@ func NewHeaderPanel() *HeaderPanel { } } -func (h *HeaderPanel) renderContextLine(service, resourceType string) string { +func (h *HeaderPanel) renderProfileAccountLine() string { cfg := config.Global() s := h.styles - var profileDisplay, accountDisplay string + labelStr := s.label.Render("Profile: ") + labelWidth := lipgloss.Width(labelStr) + availableWidth := h.width - headerPanelPadding - labelWidth + + var profileWithAccount string if cfg.IsMultiProfile() { selections := cfg.Selections() - profileDisplay = formatMultiProfiles(selections) - accountDisplay = formatMultiAccounts(selections, cfg.AccountIDs()) + profileWithAccount = formatProfilesWithAccounts(selections, cfg.AccountIDs(), s.value, ui.DangerStyle(), availableWidth) } else { - profileDisplay = cfg.Selection().DisplayName() - accountDisplay = cmp.Or(cfg.AccountID(), "-") + name := cfg.Selection().DisplayName() + accID := cmp.Or(cfg.AccountID(), "-") + profileWithAccount = formatSingleProfile(name, accID, s.value, 0) } - regions := cfg.Regions() - regionDisplay := cmp.Or(strings.Join(regions, ", "), "-") + return labelStr + profileWithAccount +} + +// renderRegionServiceLine renders line 2: Region on left, Service›Type right-aligned +func (h *HeaderPanel) renderRegionServiceLine(service, resourceType string) string { + cfg := config.Global() + s := h.styles + + labelStr := s.label.Render("Region: ") + labelWidth := lipgloss.Width(labelStr) - line := s.label.Render("Profile: ") + s.value.Render(profileDisplay) + - s.dim.Render(" │ ") + - s.label.Render("Account: ") + s.value.Render(accountDisplay) + - s.dim.Render(" │ ") + - s.label.Render("Region: ") + s.value.Render(regionDisplay) + availableWidth := max(h.width-headerPanelPadding, minAvailableWidth) + var rightPart string + rightWidth := 0 if service != "" { displayName := registry.Global.GetDisplayName(service) - line += s.dim.Render(" │ ") + - s.accent.Render(displayName) + + rightPart = s.accent.Render(displayName) + s.dim.Render(" › ") + s.accent.Render(resourceType) + rightWidth = lipgloss.Width(rightPart) } - return line + minPadding := 2 + regionMaxWidth := availableWidth - labelWidth - rightWidth - minPadding + regionPart := formatRegions(cfg.Regions(), s.value, regionMaxWidth) + leftPart := labelStr + regionPart + + if service == "" { + return leftPart + } + + leftWidth := lipgloss.Width(leftPart) + padding := max(minPadding, availableWidth-leftWidth-rightWidth) + + return leftPart + strings.Repeat(" ", padding) + rightPart } -func formatMultiProfiles(selections []config.ProfileSelection) string { - const maxShow = 2 - if len(selections) <= maxShow { - names := make([]string, len(selections)) - for i, sel := range selections { - names[i] = sel.DisplayName() +// formatProfilesWithAccounts formats profiles with account IDs, truncating with (+N) suffix when they don't all fit. +// Note: The first profile is always shown regardless of maxWidth to ensure at least one item is visible. +func formatProfilesWithAccounts(selections []config.ProfileSelection, accountIDs map[string]string, valueStyle, dangerStyle lipgloss.Style, maxWidth int) string { + if len(selections) == 0 { + return valueStyle.Render("-") + } + + separator := valueStyle.Render(", ") + sepWidth := lipgloss.Width(separator) + + if maxWidth <= 0 && len(selections) > 1 { + first := selections[0] + name := first.DisplayName() + accID := accountIDs[first.ID()] + + var firstPart string + if accID == "" || accID == "-" { + firstPart = valueStyle.Render(name+" ") + dangerStyle.Render("(-)") + } else { + firstPart = valueStyle.Render(name + " (" + accID + ")") } - return strings.Join(names, ", ") + + suffix := valueStyle.Render("(+" + strconv.Itoa(len(selections)-1) + ")") + return firstPart + separator + suffix } - names := make([]string, maxShow) - for i := range maxShow { - names[i] = selections[i].DisplayName() + + parts := make([]string, 0, len(selections)) + currentWidth := 0 + + for i, sel := range selections { + name := sel.DisplayName() + accID := accountIDs[sel.ID()] + + var part string + if accID == "" || accID == "-" { + part = valueStyle.Render(name+" ") + dangerStyle.Render("(-)") + } else { + part = valueStyle.Render(name + " (" + accID + ")") + } + + partWidth := lipgloss.Width(part) + + if maxWidth > 0 && len(parts) > 0 { + // remainingAfter = items AFTER current (not including current) + remainingAfter := len(selections) - i - 1 + suffixWidth := 0 + if remainingAfter > 0 { + // +1 because suffix shows total skipped count (current + remaining) + suffixWidth = lipgloss.Width("(+" + strconv.Itoa(remainingAfter+1) + ")") + } + + neededWidth := currentWidth + sepWidth + partWidth + if remainingAfter > 0 { + neededWidth += sepWidth + suffixWidth + } + + if neededWidth > maxWidth { + skipped := len(selections) - i + parts = append(parts, valueStyle.Render("(+"+strconv.Itoa(skipped)+")")) + break + } + } + + if len(parts) > 0 { + currentWidth += sepWidth + } + parts = append(parts, part) + currentWidth += partWidth } - return strings.Join(names, ", ") + " (+" + strconv.Itoa(len(selections)-maxShow) + ")" + + return strings.Join(parts, separator) } -func formatMultiAccounts(selections []config.ProfileSelection, accountIDs map[string]string) string { - const maxShow = 2 - accounts := make([]string, 0, len(selections)) - for _, sel := range selections { - if acc := accountIDs[sel.ID()]; acc != "" { - accounts = append(accounts, acc) +// formatRegions formats regions with (+N) suffix when they don't all fit. +// Note: The first region is always shown regardless of maxWidth to ensure at least one item is visible. +func formatRegions(regions []string, valueStyle lipgloss.Style, maxWidth int) string { + if len(regions) == 0 { + return valueStyle.Render("-") + } + + if len(regions) == 1 { + return valueStyle.Render(regions[0]) + } + + if maxWidth <= 0 { + separator := valueStyle.Render(", ") + return valueStyle.Render(regions[0]) + separator + valueStyle.Render("(+"+strconv.Itoa(len(regions)-1)+")") + } + + separator := valueStyle.Render(", ") + sepWidth := lipgloss.Width(separator) + parts := make([]string, 0, len(regions)) + currentWidth := 0 + + for i, region := range regions { + part := valueStyle.Render(region) + partWidth := lipgloss.Width(part) + + if len(parts) > 0 { + // remainingAfter = items AFTER current (not including current) + remainingAfter := len(regions) - i - 1 + suffixWidth := 0 + if remainingAfter > 0 { + // +1 because suffix shows total skipped count (current + remaining) + suffixWidth = lipgloss.Width("(+" + strconv.Itoa(remainingAfter+1) + ")") + } + + neededWidth := currentWidth + sepWidth + partWidth + if remainingAfter > 0 { + neededWidth += sepWidth + suffixWidth + } + + if neededWidth > maxWidth { + skipped := len(regions) - i + parts = append(parts, valueStyle.Render("(+"+strconv.Itoa(skipped)+")")) + break + } + } + + parts = append(parts, part) + if len(parts) == 1 { + currentWidth = partWidth + } else { + currentWidth += sepWidth + partWidth } } - if len(accounts) == 0 { - return "-" + + return strings.Join(parts, separator) +} + +// formatSingleProfile formats a single profile with account ID +// truncateWidth: 0 = no truncation, >0 = truncate name to this width +func formatSingleProfile(name, accID string, valueStyle lipgloss.Style, truncateWidth int) string { + if truncateWidth > 0 { + name = TruncateString(name, truncateWidth) } - if len(accounts) <= maxShow { - return strings.Join(accounts, ", ") + + if accID == "-" || accID == "" { + return valueStyle.Render(name+" ") + ui.DangerStyle().Render("(-)") } - return strings.Join(accounts[:maxShow], ", ") + " (+" + strconv.Itoa(len(accounts)-maxShow) + ")" + return valueStyle.Render(name + " (" + accID + ")") } // SetWidth sets the panel width @@ -138,14 +274,80 @@ func (h *HeaderPanel) Height(rendered string) int { // RenderHome renders a simple header box for the home page (no service/resource info) func (h *HeaderPanel) RenderHome() string { - contextLine := h.renderContextLine("", "") + if config.Global().CompactHeader() { + return h.RenderCompact("", "") + } + + lines := []string{ + h.renderProfileAccountLine(), + h.renderRegionServiceLine("", ""), + } + + content := strings.Join(lines, "\n") panelStyle := h.styles.panel if h.width > 4 { panelStyle = panelStyle.Width(h.width - 2) } - return panelStyle.Render(contextLine) + return panelStyle.Render(content) +} + +func (h *HeaderPanel) RenderCompact(service, resourceType string) string { + cfg := config.Global() + s := h.styles + + separator := s.dim.Render(" │ ") + sepWidth := lipgloss.Width(separator) + + availableWidth := max(h.width-headerPanelPadding, minAvailableWidth) + + var servicePart string + serviceWidth := 0 + if service != "" { + displayName := registry.Global.GetDisplayName(service) + servicePart = s.accent.Render(displayName) + + s.dim.Render(" › ") + + s.accent.Render(resourceType) + serviceWidth = lipgloss.Width(servicePart) + } + + numSeparators := 2 + if servicePart != "" { + numSeparators = 3 + } + remainingWidth := availableWidth - serviceWidth - (numSeparators-1)*sepWidth + profileMaxWidth := remainingWidth * profileWidthRatio / regionWidthRatio + regionMaxWidth := remainingWidth - profileMaxWidth + + var profilePart string + if cfg.IsMultiProfile() { + selections := cfg.Selections() + profilePart = formatProfilesWithAccounts(selections, cfg.AccountIDs(), s.value, ui.DangerStyle(), profileMaxWidth) + } else { + name := cfg.Selection().DisplayName() + accID := cmp.Or(cfg.AccountID(), "-") + profilePart = formatSingleProfile(name, accID, s.value, profileTruncateWidth) + } + + regionPart := formatRegions(cfg.Regions(), s.value, regionMaxWidth) + + var parts []string + parts = append(parts, profilePart) + parts = append(parts, regionPart) + if servicePart != "" { + parts = append(parts, servicePart) + } + + content := strings.Join(parts, separator) + content = TruncateString(content, availableWidth) + + panelStyle := s.panel + if h.width > 4 { + panelStyle = panelStyle.Width(h.width - 2) + } + + return panelStyle.Render(content) } // Render renders the header panel with fixed height @@ -153,39 +355,41 @@ func (h *HeaderPanel) RenderHome() string { // resourceType: current resource type (e.g., "instances") // summaryFields: fields from renderer.RenderSummary() func (h *HeaderPanel) Render(service, resourceType string, summaryFields []render.SummaryField) string { + if config.Global().CompactHeader() { + return h.RenderCompact(service, resourceType) + } + s := h.styles - // Build content lines (fixed to headerFixedLines) lines := make([]string, headerFixedLines) - lines[0] = h.renderContextLine(service, resourceType) - // Line 2: Separator - sepWidth := h.width - 6 - if sepWidth < 20 { - sepWidth = 60 - } - lines[1] = s.separator.Render(strings.Repeat("─", sepWidth)) + lines[0] = h.renderProfileAccountLine() + lines[1] = h.renderRegionServiceLine(service, resourceType) + + sepWidth := max(h.width-headerPanelPadding, minAvailableWidth) + lines[2] = s.separator.Render(strings.Repeat("─", sepWidth)) if len(summaryFields) == 0 { - // No resource selected - show placeholder on line 3, empty line 4 - lines[2] = s.dim.Render("No resource selected") - lines[3] = "" + lines[3] = s.dim.Render("No resource selected") + lines[4] = "" } else { - // Render fields in rows (3 fields per row), max 3 rows - fieldsPerRow := 3 - maxRows := 3 - var currentRow []string + availableWidth := max(h.width-headerPanelPadding, minAvailableWidth) + + separator := s.dim.Render(" │ ") + sepWidth := lipgloss.Width(separator) + + maxRows := 2 rowIndex := 0 + currentLineWidth := 0 + var currentRow []string - for i, field := range summaryFields { + for _, field := range summaryFields { if rowIndex >= maxRows { - break // Only show first 3 rows of fields + break } - // Truncate long values to prevent line wrapping truncatedValue := TruncateString(field.Value, maxFieldValueWidth) - // Format field with appropriate styling var styledValue string if field.Style.GetForeground() != (lipgloss.NoColor{}) { styledValue = field.Style.Render(truncatedValue) @@ -193,26 +397,39 @@ func (h *HeaderPanel) Render(service, resourceType string, summaryFields []rende styledValue = s.value.Render(truncatedValue) } part := s.label.Render(field.Label+": ") + styledValue - currentRow = append(currentRow, part) - - // Check if we should start a new row - if len(currentRow) >= fieldsPerRow || i == len(summaryFields)-1 { - lines[2+rowIndex] = strings.Join(currentRow, s.dim.Render(" │ ")) - currentRow = nil - rowIndex++ + partWidth := lipgloss.Width(part) + + if len(currentRow) > 0 { + if currentLineWidth+sepWidth+partWidth > availableWidth { + lines[3+rowIndex] = strings.Join(currentRow, separator) + currentRow = []string{part} + currentLineWidth = partWidth + rowIndex++ + if rowIndex >= maxRows { + break + } + } else { + currentRow = append(currentRow, part) + currentLineWidth += sepWidth + partWidth + } + } else { + currentRow = []string{part} + currentLineWidth = partWidth } } - // Fill remaining lines with empty strings - for i := 2 + rowIndex; i < headerFixedLines; i++ { + if len(currentRow) > 0 && rowIndex < maxRows { + lines[3+rowIndex] = strings.Join(currentRow, separator) + rowIndex++ + } + + for i := 3 + rowIndex; i < headerFixedLines; i++ { lines[i] = "" } } - // Combine lines content := strings.Join(lines, "\n") - // Apply panel style with width panelStyle := s.panel if h.width > 4 { panelStyle = panelStyle.Width(h.width - 2) diff --git a/internal/view/header_panel_test.go b/internal/view/header_panel_test.go new file mode 100644 index 0000000..8561f03 --- /dev/null +++ b/internal/view/header_panel_test.go @@ -0,0 +1,330 @@ +package view + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" + + "github.com/clawscli/claws/internal/config" + "github.com/clawscli/claws/internal/render" +) + +func TestHeaderPanel_New(t *testing.T) { + hp := NewHeaderPanel() + + if hp == nil { + t.Fatal("NewHeaderPanel() returned nil") + } +} + +func TestHeaderPanel_RenderNormalMode(t *testing.T) { + cfg := config.Global() + t.Cleanup(func() { cfg.SetCompactHeader(false) }) + cfg.SetCompactHeader(false) + + hp := NewHeaderPanel() + hp.SetWidth(80) + + output := hp.Render("ec2", "instances", nil) + + lines := strings.Count(output, "\n") + if lines < 3 { + t.Errorf("Normal mode should have multiple lines (at least 4), got %d lines", lines+1) + } + + if !strings.Contains(output, "Profile:") { + t.Error("Normal mode output should contain 'Profile:' label") + } + if !strings.Contains(output, "Region:") { + t.Error("Normal mode output should contain 'Region:' label") + } +} + +func TestHeaderPanel_RenderCompactMode(t *testing.T) { + cfg := config.Global() + t.Cleanup(func() { cfg.SetCompactHeader(false) }) + cfg.SetCompactHeader(true) + + hp := NewHeaderPanel() + hp.SetWidth(80) + + output := hp.Render("ec2", "instances", nil) + + lines := strings.Count(output, "\n") + if lines > 3 { + t.Errorf("Compact mode should have minimal lines (1-2), got %d lines", lines+1) + } + + if !strings.Contains(output, "│") { + t.Error("Compact mode output should contain '│' separator") + } +} + +func TestHeaderPanel_RenderModeSwitching(t *testing.T) { + cfg := config.Global() + t.Cleanup(func() { cfg.SetCompactHeader(false) }) + hp := NewHeaderPanel() + hp.SetWidth(80) + + cfg.SetCompactHeader(false) + normalOutput := hp.Render("ec2", "instances", nil) + normalLines := strings.Count(normalOutput, "\n") + + cfg.SetCompactHeader(true) + compactOutput := hp.Render("ec2", "instances", nil) + compactLines := strings.Count(compactOutput, "\n") + + if normalLines <= compactLines { + t.Errorf("Normal mode should have more lines than Compact mode. Normal: %d, Compact: %d", normalLines+1, compactLines+1) + } +} + +func TestHeaderPanel_RenderHome(t *testing.T) { + cfg := config.Global() + t.Cleanup(func() { cfg.SetCompactHeader(false) }) + hp := NewHeaderPanel() + hp.SetWidth(80) + + cfg.SetCompactHeader(false) + output := hp.RenderHome() + + if !strings.Contains(output, "Profile:") { + t.Error("RenderHome() should contain 'Profile:' label") + } + if !strings.Contains(output, "Region:") { + t.Error("RenderHome() should contain 'Region:' label") + } +} + +func TestHeaderPanel_RenderHomeCompact(t *testing.T) { + cfg := config.Global() + t.Cleanup(func() { cfg.SetCompactHeader(false) }) + hp := NewHeaderPanel() + hp.SetWidth(80) + + cfg.SetCompactHeader(true) + output := hp.RenderHome() + + if !strings.Contains(output, "│") { + t.Error("RenderHome() in compact mode should contain '│' separator") + } +} + +func TestHeaderPanel_RenderWithSummaryFields(t *testing.T) { + cfg := config.Global() + t.Cleanup(func() { cfg.SetCompactHeader(false) }) + cfg.SetCompactHeader(false) + + hp := NewHeaderPanel() + hp.SetWidth(80) + + summaryFields := []render.SummaryField{ + {Label: "ID", Value: "i-1234567890abcdef0"}, + {Label: "State", Value: "running"}, + {Label: "Type", Value: "t3.medium"}, + } + + output := hp.Render("ec2", "instances", summaryFields) + + if !strings.Contains(output, "ID:") { + t.Error("Output should contain 'ID:' label from summary fields") + } + if !strings.Contains(output, "State:") { + t.Error("Output should contain 'State:' label from summary fields") + } +} + +func TestHeaderPanel_Height(t *testing.T) { + cfg := config.Global() + t.Cleanup(func() { cfg.SetCompactHeader(false) }) + + hp := NewHeaderPanel() + hp.SetWidth(80) + cfg.SetCompactHeader(false) + + output := hp.Render("ec2", "instances", nil) + height := hp.Height(output) + + if height < 1 { + t.Errorf("Height() should return positive value, got %d", height) + } + + expectedHeight := strings.Count(output, "\n") + 1 + if height != expectedHeight { + t.Errorf("Height() = %d, want %d based on newline count", height, expectedHeight) + } +} + +func TestFormatRegions(t *testing.T) { + valueStyle := lipgloss.NewStyle() + + tests := []struct { + name string + regions []string + maxWidth int + wantSuffix string + notWant string + }{ + { + name: "empty regions", + regions: nil, + maxWidth: 100, + wantSuffix: "-", + }, + { + name: "single region", + regions: []string{"us-east-1"}, + maxWidth: 100, + wantSuffix: "us-east-1", + notWant: "(+", + }, + { + name: "two regions fit", + regions: []string{"us-east-1", "us-west-2"}, + maxWidth: 100, + wantSuffix: "us-west-2", + notWant: "(+", + }, + { + name: "narrow width truncates", + regions: []string{"us-east-1", "us-west-2", "eu-west-1"}, + maxWidth: 25, + wantSuffix: "(+2)", + }, + { + name: "non-positive width truncates", + regions: []string{"us-east-1", "us-west-2", "eu-west-1"}, + maxWidth: 0, + wantSuffix: "(+2)", + notWant: "us-west-2", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatRegions(tt.regions, valueStyle, tt.maxWidth) + + if !strings.Contains(result, tt.wantSuffix) { + t.Errorf("formatRegions() = %q, want to contain %q", result, tt.wantSuffix) + } + + if tt.notWant != "" && strings.Contains(result, tt.notWant) { + t.Errorf("formatRegions() = %q, should not contain %q", result, tt.notWant) + } + }) + } +} + +func TestFormatProfilesWithAccounts(t *testing.T) { + valueStyle := lipgloss.NewStyle() + dangerStyle := lipgloss.NewStyle() + + tests := []struct { + name string + selections []config.ProfileSelection + accountIDs map[string]string + maxWidth int + wantSuffix string + notWant string + }{ + { + name: "empty selections", + selections: nil, + accountIDs: nil, + maxWidth: 100, + wantSuffix: "-", + }, + { + name: "single profile fits", + selections: []config.ProfileSelection{ + config.NamedProfile("dev"), + }, + accountIDs: map[string]string{"dev": "111111111111"}, + maxWidth: 100, + wantSuffix: "dev", + notWant: "(+", + }, + { + name: "two profiles fit without suffix", + selections: []config.ProfileSelection{ + config.NamedProfile("dev"), + config.NamedProfile("prod"), + }, + accountIDs: map[string]string{ + "dev": "111111111111", + "prod": "222222222222", + }, + maxWidth: 200, + wantSuffix: "prod", + notWant: "(+", + }, + { + name: "narrow width truncates second profile", + selections: []config.ProfileSelection{ + config.NamedProfile("dev"), + config.NamedProfile("prod"), + }, + accountIDs: map[string]string{ + "dev": "111111111111", + "prod": "222222222222", + }, + maxWidth: 25, + wantSuffix: "(+1)", + }, + { + name: "very narrow width truncates all but first", + selections: []config.ProfileSelection{ + config.NamedProfile("dev"), + config.NamedProfile("staging"), + config.NamedProfile("prod"), + }, + accountIDs: map[string]string{ + "dev": "111111111111", + "staging": "222222222222", + "prod": "333333333333", + }, + maxWidth: 30, + wantSuffix: "(+2)", + }, + { + name: "non-positive width truncates", + selections: []config.ProfileSelection{ + config.NamedProfile("dev"), + config.NamedProfile("staging"), + config.NamedProfile("prod"), + }, + accountIDs: map[string]string{ + "dev": "111111111111", + "staging": "222222222222", + "prod": "333333333333", + }, + maxWidth: 0, + wantSuffix: "(+2)", + notWant: "staging", + }, + { + name: "missing account shows danger style", + selections: []config.ProfileSelection{ + config.NamedProfile("dev"), + }, + accountIDs: map[string]string{}, + maxWidth: 100, + wantSuffix: "(-)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatProfilesWithAccounts(tt.selections, tt.accountIDs, valueStyle, dangerStyle, tt.maxWidth) + + if !strings.Contains(result, tt.wantSuffix) { + t.Errorf("formatProfilesWithAccounts() = %q, want to contain %q", result, tt.wantSuffix) + } + + if tt.notWant != "" && strings.Contains(result, tt.notWant) { + t.Errorf("formatProfilesWithAccounts() = %q, should not contain %q", result, tt.notWant) + } + }) + } +} diff --git a/internal/view/help_view.go b/internal/view/help_view.go index bbc4959..7127fc0 100644 --- a/internal/view/help_view.go +++ b/internal/view/help_view.go @@ -149,6 +149,7 @@ func (h *HelpView) renderContent() string { out += "\n" + s.section.Render("Global") + "\n" out += s.key.Render("R") + s.desc.Render("Switch AWS region") + "\n" out += s.key.Render("P") + s.desc.Render("Switch AWS profile") + "\n" + out += s.key.Render("Ctrl+E") + s.desc.Render("Toggle compact header") + "\n" out += s.key.Render("?") + s.desc.Render("Show this help") + "\n" // Command examples diff --git a/internal/view/modal.go b/internal/view/modal.go index 60063c7..e1bab14 100644 --- a/internal/view/modal.go +++ b/internal/view/modal.go @@ -23,7 +23,7 @@ const ( ModalWidthProfile = 55 ModalWidthProfileDetail = 65 ModalWidthActionMenu = 60 - ModalWidthSettings = 70 + ModalWidthSettings = 75 ModalWidthChat = 80 ) diff --git a/internal/view/resource_browser.go b/internal/view/resource_browser.go index 6106ba1..58a7ffc 100644 --- a/internal/view/resource_browser.go +++ b/internal/view/resource_browser.go @@ -224,6 +224,9 @@ func (r *ResourceBrowser) Update(msg tea.Msg) (tea.Model, tea.Cmd) { r.headerPanel.ReloadStyles() r.buildTable() return r, nil + case CompactHeaderChangedMsg: + r.buildTable() + return r, nil case SortMsg: return r.handleSortMsg(msg) case TagFilterMsg: diff --git a/internal/view/service_browser.go b/internal/view/service_browser.go index 22ca865..4b8181b 100644 --- a/internal/view/service_browser.go +++ b/internal/view/service_browser.go @@ -48,7 +48,9 @@ type ServiceBrowser struct { headerPanel *HeaderPanel // Viewport for scrolling - vp ViewportState + vp ViewportState + width int + height int // Filter filterInput textinput.Model @@ -175,6 +177,10 @@ func (s *ServiceBrowser) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case ThemeChangedMsg: s.styles = newServiceBrowserStyles() s.headerPanel.ReloadStyles() + s.updateViewport() + return s, nil + case CompactHeaderChangedMsg: + s.recalcViewport() return s, nil case tea.KeyPressMsg: @@ -572,22 +578,29 @@ func (s *ServiceBrowser) renderCell(item serviceItem, selected bool) string { // SetSize implements View func (s *ServiceBrowser) SetSize(width, height int) tea.Cmd { + s.width = width + s.height = height + // Set header panel width s.headerPanel.SetWidth(width) // Calculate columns based on width s.cols = max(minColumns, min((width-cellPaddingX)/cellWidth, maxColumns)) + s.recalcViewport() + + return nil +} + +func (s *ServiceBrowser) recalcViewport() { // Calculate header height dynamically headerStr := s.headerPanel.RenderHome() headerHeight := s.headerPanel.Height(headerStr) - vpHeight := max(height-headerHeight+1, 5) + vpHeight := max(s.height-headerHeight+1, 5) - s.vp.SetSize(width, vpHeight) + s.vp.SetSize(s.width, vpHeight) s.vp.Model.SetContent(s.renderContent()) - - return nil } // StatusLine implements View diff --git a/internal/view/settings_view.go b/internal/view/settings_view.go index 87636ec..0ba177c 100644 --- a/internal/view/settings_view.go +++ b/internal/view/settings_view.go @@ -3,7 +3,6 @@ package view import ( "context" "fmt" - "slices" "strings" tea "charm.land/bubbletea/v2" @@ -16,21 +15,55 @@ import ( const ( noneValue = "(none)" settingsSeparatorInset = 10 + settingsLabelWidth = 18 ) // settingsViewStyles holds cached lipgloss styles for performance. type settingsViewStyles struct { title lipgloss.Style separator lipgloss.Style + text lipgloss.Style } func newSettingsViewStyles() settingsViewStyles { return settingsViewStyles{ title: ui.TitleStyle(), separator: ui.DimStyle(), + text: ui.TextStyle(), } } +func wrapSettingsValue(value string, maxWidth int) string { + if lipgloss.Width(value) <= maxWidth || maxWidth <= 0 { + return value + } + + var lines []string + indent := strings.Repeat(" ", settingsLabelWidth) + + words := strings.Split(value, ", ") + var currentLine string + + for i, word := range words { + sep := "" + if i > 0 { + sep = ", " + } + candidate := currentLine + sep + word + if lipgloss.Width(candidate) > maxWidth && currentLine != "" { + lines = append(lines, currentLine) + currentLine = word + } else { + currentLine = candidate + } + } + if currentLine != "" { + lines = append(lines, currentLine) + } + + return strings.Join(lines, "\n"+indent) +} + type SettingsView struct { ctx context.Context vp ViewportState @@ -100,12 +133,13 @@ func (v *SettingsView) buildContent() string { separatorWidth := max(0, ModalWidthSettings-settingsSeparatorInset) separator := v.styles.separator.Render(" " + strings.Repeat("─", separatorWidth)) - // Section 1: Config File + valueWidth := ModalWidthSettings - settingsLabelWidth - 2 + sb.WriteString(v.styles.title.Render("Config File")) sb.WriteString("\n\n") configPath := config.GetConfigPath() if configPath != "" { - sb.WriteString(fmt.Sprintf(" Path %s\n", configPath)) + sb.WriteString(fmt.Sprintf(" Path %s\n", wrapSettingsValue(configPath, valueWidth))) sb.WriteString(" Type custom\n") } else { sb.WriteString(" Path ~/.config/claws/config.yaml (default)\n") @@ -119,28 +153,14 @@ func (v *SettingsView) buildContent() string { sb.WriteString(v.styles.title.Render("Runtime")) sb.WriteString("\n\n") - // Regions - runtimeRegions := globalCfg.Regions() - startupRegions := cfg.Startup.Regions - regionsMatch := slices.Equal(runtimeRegions, startupRegions) - regionStr := strings.Join(runtimeRegions, ", ") + regionStr := strings.Join(globalCfg.Regions(), ", ") if regionStr == "" { regionStr = noneValue } - if !regionsMatch && len(startupRegions) > 0 { - regionStr += " (CLI)" - } - sb.WriteString(fmt.Sprintf(" Regions %s\n", regionStr)) + sb.WriteString(fmt.Sprintf(" Regions %s\n", wrapSettingsValue(regionStr, valueWidth))) - // Profiles - runtimeProfiles := v.getProfileIDs(globalCfg.Selections()) - startupProfiles := cfg.Startup.GetProfiles() - profilesMatch := slices.Equal(runtimeProfiles, startupProfiles) profileStr := v.formatProfiles(globalCfg.Selections()) - if !profilesMatch && len(startupProfiles) > 0 { - profileStr += " (CLI)" - } - sb.WriteString(fmt.Sprintf(" Profiles %s\n", profileStr)) + sb.WriteString(fmt.Sprintf(" Profiles %s\n", wrapSettingsValue(profileStr, valueWidth))) // Read-only readOnly := "no" @@ -149,37 +169,17 @@ func (v *SettingsView) buildContent() string { } sb.WriteString(fmt.Sprintf(" Read-only %s\n", readOnly)) - sb.WriteString("\n") - sb.WriteString(separator) - sb.WriteString("\n\n") - - // Section 3: Startup - sb.WriteString(v.styles.title.Render("Startup")) - sb.WriteString("\n\n") - - view := cfg.GetStartupView() - if view == "" { - view = noneValue - } - sb.WriteString(fmt.Sprintf(" View %s\n", view)) - - startupRegionStr := strings.Join(cfg.Startup.Regions, ", ") - if startupRegionStr == "" { - startupRegionStr = noneValue - } - sb.WriteString(fmt.Sprintf(" Regions %s\n", startupRegionStr)) - - startupProfileStr := strings.Join(cfg.Startup.GetProfiles(), ", ") - if startupProfileStr == "" { - startupProfileStr = noneValue + compactHeader := "no" + if globalCfg.CompactHeader() { + compactHeader = "yes" } - sb.WriteString(fmt.Sprintf(" Profiles %s\n", startupProfileStr)) + sb.WriteString(fmt.Sprintf(" Compact %s\n", compactHeader)) sb.WriteString("\n") sb.WriteString(separator) sb.WriteString("\n\n") - // Section 4: Theme + // Section 3: Theme sb.WriteString(v.styles.title.Render("Theme")) sb.WriteString("\n\n") @@ -268,7 +268,7 @@ func (v *SettingsView) buildContent() string { } sb.WriteString(fmt.Sprintf(" Region %s\n", aiRegion)) - sb.WriteString(fmt.Sprintf(" Model %s\n", cfg.GetAIModel())) + sb.WriteString(fmt.Sprintf(" Model %s\n", wrapSettingsValue(cfg.GetAIModel(), valueWidth))) sb.WriteString(fmt.Sprintf(" Max sessions %d\n", cfg.GetAIMaxSessions())) sb.WriteString(fmt.Sprintf(" Max tokens %d\n", cfg.GetAIMaxTokens())) sb.WriteString(fmt.Sprintf(" Thinking budget %d\n", cfg.GetAIThinkingBudget())) @@ -281,7 +281,7 @@ func (v *SettingsView) buildContent() string { } sb.WriteString(fmt.Sprintf(" Save sessions %s\n", saveSessions)) - return sb.String() + return v.styles.text.Render(sb.String()) } func (v *SettingsView) getThemeOverrides(theme config.ThemeConfig) []string { @@ -371,11 +371,3 @@ func (v *SettingsView) formatProfiles(selections []config.ProfileSelection) stri } return strings.Join(names, ", ") } - -func (v *SettingsView) getProfileIDs(selections []config.ProfileSelection) []string { - ids := make([]string, len(selections)) - for i, sel := range selections { - ids[i] = sel.ID() - } - return ids -} diff --git a/internal/view/settings_view_test.go b/internal/view/settings_view_test.go index 4aef148..4bb2adb 100644 --- a/internal/view/settings_view_test.go +++ b/internal/view/settings_view_test.go @@ -96,7 +96,6 @@ func TestSettingsView_BuildContent(t *testing.T) { expectedSections := []string{ "Config File", "Runtime", - "Startup", "Theme", "Timeouts", "Concurrency", @@ -111,6 +110,19 @@ func TestSettingsView_BuildContent(t *testing.T) { t.Errorf("buildContent() should contain section %q", section) } } + + expectedFields := []string{ + "Compact", + "Read-only", + "Regions", + "Profiles", + } + + for _, field := range expectedFields { + if !strings.Contains(content, field) { + t.Errorf("buildContent() should contain field %q", field) + } + } } func TestSettingsView_GetThemeOverrides_Empty(t *testing.T) { @@ -155,17 +167,3 @@ func TestSettingsView_FormatProfiles_Empty(t *testing.T) { t.Errorf("formatProfiles([]) should return %q, got %q", noneValue, result) } } - -func TestSettingsView_GetProfileIDs(t *testing.T) { - sv := NewSettingsView(context.Background()) - - ids := sv.getProfileIDs(nil) - if len(ids) != 0 { - t.Errorf("getProfileIDs(nil) should return empty slice, got %d items", len(ids)) - } - - ids = sv.getProfileIDs([]config.ProfileSelection{}) - if len(ids) != 0 { - t.Errorf("getProfileIDs([]) should return empty slice, got %d items", len(ids)) - } -} diff --git a/internal/view/view.go b/internal/view/view.go index 31b2902..6f2c586 100644 --- a/internal/view/view.go +++ b/internal/view/view.go @@ -67,6 +67,9 @@ type RefreshMsg struct{} // ThemeChangedMsg tells views to reload their cached styles type ThemeChangedMsg struct{} +// CompactHeaderChangedMsg tells views to update header rendering +type CompactHeaderChangedMsg struct{} + type ThemeChangeMsg struct { Name string }