From fac42c39f49b18ac6c0d0c2a6987b321982ead03 Mon Sep 17 00:00:00 2001 From: yimsk Date: Tue, 13 Jan 2026 19:00:55 +0900 Subject: [PATCH 1/2] feat(view): add :settings command to display config (#145) (#149) * feat(view): add :settings command to display config (#145) - Add ModalWidthSettings = 70 constant - Create settings_view.go with 10 sections: Config File, Runtime, Startup, Theme, Timeouts, Concurrency, CloudWatch, Navigation, Autosave, AI - Add :settings command handler and autocomplete - Show CLI overrides with (CLI) suffix - Scrollable with j/k and mouse - Add Ctrl+C to close modals globally * chore: ignore .sisyphus directory * fix(view): add style caching and nil-safe Update to SettingsView * docs(view): add :settings to help documentation * refactor(view): use slices.Equal instead of custom method * test(view): add SettingsView unit tests --- .gitignore | 1 + internal/app/app.go | 2 +- internal/view/command_input.go | 16 ++ internal/view/help_view.go | 1 + internal/view/modal.go | 1 + internal/view/settings_view.go | 381 ++++++++++++++++++++++++++++ internal/view/settings_view_test.go | 80 ++++++ 7 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 internal/view/settings_view.go create mode 100644 internal/view/settings_view_test.go diff --git a/.gitignore b/.gitignore index 6c92ad6..278435e 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ Thumbs.db CLAUDE.md AGENTS.md .serena/ +.sisyphus/ # Build artifacts dist/ diff --git a/internal/app/app.go b/internal/app/app.go index 2ec80d9..c836c08 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -599,7 +599,7 @@ func (a *App) handleModalUpdate(msg tea.Msg) (tea.Model, tea.Cmd) { return a.handleProfilesChanged(msg) case tea.KeyPressMsg: - if view.IsEscKey(msg) || msg.Code == tea.KeyBackspace || msg.String() == "q" { + if view.IsEscKey(msg) || msg.Code == tea.KeyBackspace || msg.String() == "q" || msg.String() == "ctrl+c" { if ic, ok := a.modal.Content.(view.InputCapture); ok && ic.HasActiveInput() { break } diff --git a/internal/view/command_input.go b/internal/view/command_input.go index 87af7a0..6e02f5c 100644 --- a/internal/view/command_input.go +++ b/internal/view/command_input.go @@ -474,6 +474,18 @@ func (c *CommandInput) executeCommand() (tea.Cmd, *NavigateMsg) { return nil, &NavigateMsg{View: browser, ClearStack: false} } + // Handle settings command - show settings modal + if input == "settings" { + return func() tea.Msg { + return ShowModalMsg{ + Modal: &Modal{ + Content: NewSettingsView(c.ctx), + Width: ModalWidthSettings, + }, + } + }, nil + } + // Handle sort command: :sort (clear) or :sort (sort by column) if input == "sort" { return func() tea.Msg { @@ -704,6 +716,10 @@ func (c *CommandInput) GetSuggestions() []string { suggestions = append(suggestions, "autosave") } + if strings.HasPrefix("settings", input) { + suggestions = append(suggestions, "settings") + } + for _, svc := range c.registry.ListServices() { // Skip if input exactly matches service (already fully typed) if svc != input && strings.HasPrefix(svc, input) { diff --git a/internal/view/help_view.go b/internal/view/help_view.go index e728ff0..bbc4959 100644 --- a/internal/view/help_view.go +++ b/internal/view/help_view.go @@ -110,6 +110,7 @@ func (h *HelpView) renderContent() string { out += s.key.Render(":login ") + s.desc.Render("AWS Console login with profile") + "\n" out += s.key.Render(":theme ") + s.desc.Render("Change theme (dark/light/nord/dracula/...)") + "\n" out += s.key.Render(":autosave") + s.desc.Render("Toggle config persistence (on/off)") + "\n" + out += s.key.Render(":settings") + s.desc.Render("Show current settings") + "\n" // Tag Commands out += "\n" + s.section.Render("Tag Commands") + "\n" diff --git a/internal/view/modal.go b/internal/view/modal.go index 3700b80..60063c7 100644 --- a/internal/view/modal.go +++ b/internal/view/modal.go @@ -23,6 +23,7 @@ const ( ModalWidthProfile = 55 ModalWidthProfileDetail = 65 ModalWidthActionMenu = 60 + ModalWidthSettings = 70 ModalWidthChat = 80 ) diff --git a/internal/view/settings_view.go b/internal/view/settings_view.go new file mode 100644 index 0000000..3d79405 --- /dev/null +++ b/internal/view/settings_view.go @@ -0,0 +1,381 @@ +package view + +import ( + "context" + "fmt" + "slices" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/clawscli/claws/internal/config" + "github.com/clawscli/claws/internal/ui" +) + +const ( + noneValue = "(none)" + settingsSeparatorInset = 10 +) + +// settingsViewStyles holds cached lipgloss styles for performance. +type settingsViewStyles struct { + title lipgloss.Style + separator lipgloss.Style +} + +func newSettingsViewStyles() settingsViewStyles { + return settingsViewStyles{ + title: ui.TitleStyle(), + separator: ui.DimStyle(), + } +} + +type SettingsView struct { + ctx context.Context + vp ViewportState + screenWidth int + screenHeight int + styles settingsViewStyles +} + +func NewSettingsView(ctx context.Context) *SettingsView { + return &SettingsView{ + ctx: ctx, + styles: newSettingsViewStyles(), + } +} + +func (v *SettingsView) Init() tea.Cmd { + return nil +} + +func (v *SettingsView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg.(type) { + case ThemeChangedMsg: + v.styles = newSettingsViewStyles() + if v.vp.Ready { + v.vp.Model.SetContent(v.buildContent()) + } + return v, nil + } + + if !v.vp.Ready { + return v, nil + } + + var cmd tea.Cmd + v.vp.Model, cmd = v.vp.Model.Update(msg) + return v, cmd +} + +func (v *SettingsView) View() tea.View { + return tea.NewView(v.ViewString()) +} + +func (v *SettingsView) ViewString() string { + if !v.vp.Ready { + return LoadingMessage + } + return v.vp.Model.View() +} + +func (v *SettingsView) StatusLine() string { + return "" +} + +func (v *SettingsView) SetSize(w, h int) tea.Cmd { + v.screenWidth, v.screenHeight = w, h + content := v.buildContent() + v.vp.SetSize(w, h) + v.vp.Model.SetContent(content) + return nil +} + +func (v *SettingsView) buildContent() string { + var sb strings.Builder + cfg := config.File() + globalCfg := config.Global() + + separatorWidth := ModalWidthSettings - settingsSeparatorInset + separator := v.styles.separator.Render(" " + strings.Repeat("─", separatorWidth)) + + // Section 1: Config File + 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(" Type custom\n") + } else { + sb.WriteString(" Path ~/.config/claws/config.yaml (default)\n") + sb.WriteString(" Type default\n") + } + sb.WriteString("\n") + sb.WriteString(separator) + sb.WriteString("\n\n") + + // Section 2: Runtime + 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, ", ") + if regionStr == "" { + regionStr = noneValue + } + if !regionsMatch && len(startupRegions) > 0 { + regionStr += " (CLI)" + } + sb.WriteString(fmt.Sprintf(" Regions %s\n", regionStr)) + + // 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)) + + // Read-only + readOnly := "no" + if globalCfg.ReadOnly() { + readOnly = "yes" + } + 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 + } + sb.WriteString(fmt.Sprintf(" Profiles %s\n", startupProfileStr)) + + sb.WriteString("\n") + sb.WriteString(separator) + sb.WriteString("\n\n") + + // Section 4: Theme + sb.WriteString(v.styles.title.Render("Theme")) + sb.WriteString("\n\n") + + theme := cfg.GetTheme() + preset := theme.Preset + if preset == "" { + preset = noneValue + } + sb.WriteString(fmt.Sprintf(" Preset %s\n", preset)) + + // Show overridden colors + overrides := v.getThemeOverrides(theme) + if len(overrides) > 0 { + sb.WriteString("\n") + for _, override := range overrides { + sb.WriteString(fmt.Sprintf(" + %s\n", override)) + } + } + + sb.WriteString("\n") + sb.WriteString(separator) + sb.WriteString("\n\n") + + // Section 5: Timeouts + sb.WriteString(v.styles.title.Render("Timeouts")) + sb.WriteString("\n\n") + sb.WriteString(fmt.Sprintf(" AWS Init %s\n", cfg.Timeouts.AWSInit.Duration().String())) + sb.WriteString(fmt.Sprintf(" Multi-region %s\n", cfg.Timeouts.MultiRegionFetch.Duration().String())) + sb.WriteString(fmt.Sprintf(" Tag search %s\n", cfg.Timeouts.TagSearch.Duration().String())) + sb.WriteString(fmt.Sprintf(" Metrics load %s\n", cfg.Timeouts.MetricsLoad.Duration().String())) + sb.WriteString(fmt.Sprintf(" Log fetch %s\n", cfg.Timeouts.LogFetch.Duration().String())) + sb.WriteString(fmt.Sprintf(" Docs search %s\n", cfg.Timeouts.DocsSearch.Duration().String())) + sb.WriteString("\n") + sb.WriteString(separator) + sb.WriteString("\n\n") + + // Section 6: Concurrency + sb.WriteString(v.styles.title.Render("Concurrency")) + sb.WriteString("\n\n") + sb.WriteString(fmt.Sprintf(" Max fetches %d\n", cfg.Concurrency.MaxFetches)) + sb.WriteString("\n") + sb.WriteString(separator) + sb.WriteString("\n\n") + + // Section 7: CloudWatch + sb.WriteString(v.styles.title.Render("CloudWatch")) + sb.WriteString("\n\n") + sb.WriteString(fmt.Sprintf(" Metrics window %s\n", cfg.CloudWatch.Window.Duration().String())) + sb.WriteString("\n") + sb.WriteString(separator) + sb.WriteString("\n\n") + + // Section 8: Navigation + sb.WriteString(v.styles.title.Render("Navigation")) + sb.WriteString("\n\n") + sb.WriteString(fmt.Sprintf(" Max stack size %d\n", cfg.Navigation.MaxStackSize)) + sb.WriteString("\n") + sb.WriteString(separator) + sb.WriteString("\n\n") + + // Section 9: Autosave + sb.WriteString(v.styles.title.Render("Autosave")) + sb.WriteString("\n\n") + enabled := "no" + if cfg.Autosave.Enabled { + enabled = "yes" + } + sb.WriteString(fmt.Sprintf(" Enabled %s\n", enabled)) + sb.WriteString("\n") + sb.WriteString(separator) + sb.WriteString("\n\n") + + // Section 10: AI + sb.WriteString(v.styles.title.Render("AI")) + sb.WriteString("\n\n") + + aiProfile := cfg.AI.Profile + if aiProfile == "" { + aiProfile = "(default)" + } + sb.WriteString(fmt.Sprintf(" Profile %s\n", aiProfile)) + + aiRegion := cfg.AI.Region + if aiRegion == "" { + aiRegion = "(default)" + } + sb.WriteString(fmt.Sprintf(" Region %s\n", aiRegion)) + + sb.WriteString(fmt.Sprintf(" Model %s\n", cfg.GetAIModel())) + 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())) + sb.WriteString(fmt.Sprintf(" Max tool rounds %d\n", cfg.GetAIMaxToolRounds())) + sb.WriteString(fmt.Sprintf(" Max tool calls %d\n", cfg.GetAIMaxToolCallsPerQuery())) + + saveSessions := "no" + if cfg.GetAISaveSessions() { + saveSessions = "yes" + } + sb.WriteString(fmt.Sprintf(" Save sessions %s\n", saveSessions)) + + return sb.String() +} + +func (v *SettingsView) getThemeOverrides(theme config.ThemeConfig) []string { + var overrides []string + + if theme.Primary != "" { + overrides = append(overrides, fmt.Sprintf("Primary: %s", theme.Primary)) + } + if theme.Secondary != "" { + overrides = append(overrides, fmt.Sprintf("Secondary: %s", theme.Secondary)) + } + if theme.Accent != "" { + overrides = append(overrides, fmt.Sprintf("Accent: %s", theme.Accent)) + } + if theme.Text != "" { + overrides = append(overrides, fmt.Sprintf("Text: %s", theme.Text)) + } + if theme.TextBright != "" { + overrides = append(overrides, fmt.Sprintf("TextBright: %s", theme.TextBright)) + } + if theme.TextDim != "" { + overrides = append(overrides, fmt.Sprintf("TextDim: %s", theme.TextDim)) + } + if theme.TextMuted != "" { + overrides = append(overrides, fmt.Sprintf("TextMuted: %s", theme.TextMuted)) + } + if theme.Success != "" { + overrides = append(overrides, fmt.Sprintf("Success: %s", theme.Success)) + } + if theme.Warning != "" { + overrides = append(overrides, fmt.Sprintf("Warning: %s", theme.Warning)) + } + if theme.Danger != "" { + overrides = append(overrides, fmt.Sprintf("Danger: %s", theme.Danger)) + } + if theme.Info != "" { + overrides = append(overrides, fmt.Sprintf("Info: %s", theme.Info)) + } + if theme.Pending != "" { + overrides = append(overrides, fmt.Sprintf("Pending: %s", theme.Pending)) + } + if theme.Border != "" { + overrides = append(overrides, fmt.Sprintf("Border: %s", theme.Border)) + } + if theme.BorderHighlight != "" { + overrides = append(overrides, fmt.Sprintf("BorderHighlight: %s", theme.BorderHighlight)) + } + if theme.Background != "" { + overrides = append(overrides, fmt.Sprintf("Background: %s", theme.Background)) + } + if theme.BackgroundAlt != "" { + overrides = append(overrides, fmt.Sprintf("BackgroundAlt: %s", theme.BackgroundAlt)) + } + if theme.Selection != "" { + overrides = append(overrides, fmt.Sprintf("Selection: %s", theme.Selection)) + } + if theme.SelectionText != "" { + overrides = append(overrides, fmt.Sprintf("SelectionText: %s", theme.SelectionText)) + } + if theme.TableHeader != "" { + overrides = append(overrides, fmt.Sprintf("TableHeader: %s", theme.TableHeader)) + } + if theme.TableHeaderText != "" { + overrides = append(overrides, fmt.Sprintf("TableHeaderText: %s", theme.TableHeaderText)) + } + if theme.TableBorder != "" { + overrides = append(overrides, fmt.Sprintf("TableBorder: %s", theme.TableBorder)) + } + if theme.BadgeForeground != "" { + overrides = append(overrides, fmt.Sprintf("BadgeForeground: %s", theme.BadgeForeground)) + } + if theme.BadgeBackground != "" { + overrides = append(overrides, fmt.Sprintf("BadgeBackground: %s", theme.BadgeBackground)) + } + + return overrides +} + +func (v *SettingsView) formatProfiles(selections []config.ProfileSelection) string { + if len(selections) == 0 { + return noneValue + } + + names := make([]string, len(selections)) + for i, sel := range selections { + names[i] = sel.DisplayName() + } + 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 new file mode 100644 index 0000000..f22530c --- /dev/null +++ b/internal/view/settings_view_test.go @@ -0,0 +1,80 @@ +package view + +import ( + "context" + "testing" +) + +func TestSettingsView_New(t *testing.T) { + sv := NewSettingsView(context.Background()) + + if sv == nil { + t.Fatal("NewSettingsView() returned nil") + } +} + +func TestSettingsView_Init(t *testing.T) { + sv := NewSettingsView(context.Background()) + + cmd := sv.Init() + if cmd != nil { + t.Error("Init() should return nil") + } +} + +func TestSettingsView_StatusLine(t *testing.T) { + sv := NewSettingsView(context.Background()) + + status := sv.StatusLine() + if status != "" { + t.Error("StatusLine() should return empty string for modal") + } +} + +func TestSettingsView_ViewString_BeforeSetSize(t *testing.T) { + sv := NewSettingsView(context.Background()) + + view := sv.ViewString() + if view != LoadingMessage { + t.Errorf("ViewString() before SetSize should return LoadingMessage, got: %s", view) + } +} + +func TestSettingsView_SetSize(t *testing.T) { + sv := NewSettingsView(context.Background()) + + cmd := sv.SetSize(80, 24) + if cmd != nil { + t.Error("SetSize() should return nil") + } + + if !sv.vp.Ready { + t.Error("viewport should be ready after SetSize") + } + + view := sv.ViewString() + if view == LoadingMessage { + t.Error("ViewString() after SetSize should not return LoadingMessage") + } +} + +func TestSettingsView_ThemeChanged(t *testing.T) { + sv := NewSettingsView(context.Background()) + sv.SetSize(80, 24) + + originalStyles := sv.styles + + model, cmd := sv.Update(ThemeChangedMsg{}) + if cmd != nil { + t.Error("Update(ThemeChangedMsg) should return nil cmd") + } + + updated, ok := model.(*SettingsView) + if !ok { + t.Fatal("Update should return *SettingsView") + } + + if &updated.styles == &originalStyles { + t.Error("styles should be regenerated on ThemeChangedMsg") + } +} From a30ac8cde30f8afce54af588158bfcb7b2f40bfe Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Tue, 13 Jan 2026 10:10:28 +0000 Subject: [PATCH 2/2] fix(view): improve SettingsView tests and add bounds check - Fix ThemeChanged test to compare content instead of addresses - Add bounds check for separator width (prevents panic) - Add tests for buildContent, getThemeOverrides, formatProfiles, getProfileIDs --- internal/view/settings_view.go | 2 +- internal/view/settings_view_test.go | 97 ++++++++++++++++++++++++++++- 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/internal/view/settings_view.go b/internal/view/settings_view.go index 3d79405..87636ec 100644 --- a/internal/view/settings_view.go +++ b/internal/view/settings_view.go @@ -97,7 +97,7 @@ func (v *SettingsView) buildContent() string { cfg := config.File() globalCfg := config.Global() - separatorWidth := ModalWidthSettings - settingsSeparatorInset + separatorWidth := max(0, ModalWidthSettings-settingsSeparatorInset) separator := v.styles.separator.Render(" " + strings.Repeat("─", separatorWidth)) // Section 1: Config File diff --git a/internal/view/settings_view_test.go b/internal/view/settings_view_test.go index f22530c..4aef148 100644 --- a/internal/view/settings_view_test.go +++ b/internal/view/settings_view_test.go @@ -2,7 +2,10 @@ package view import ( "context" + "strings" "testing" + + "github.com/clawscli/claws/internal/config" ) func TestSettingsView_New(t *testing.T) { @@ -62,7 +65,7 @@ func TestSettingsView_ThemeChanged(t *testing.T) { sv := NewSettingsView(context.Background()) sv.SetSize(80, 24) - originalStyles := sv.styles + contentBefore := sv.vp.Model.View() model, cmd := sv.Update(ThemeChangedMsg{}) if cmd != nil { @@ -74,7 +77,95 @@ func TestSettingsView_ThemeChanged(t *testing.T) { t.Fatal("Update should return *SettingsView") } - if &updated.styles == &originalStyles { - t.Error("styles should be regenerated on ThemeChangedMsg") + contentAfter := updated.vp.Model.View() + if contentAfter == "" { + t.Error("content should be regenerated on ThemeChangedMsg") + } + + if contentBefore != contentAfter { + t.Log("content was regenerated (styles may have changed)") + } +} + +func TestSettingsView_BuildContent(t *testing.T) { + sv := NewSettingsView(context.Background()) + sv.SetSize(80, 24) + + content := sv.buildContent() + + expectedSections := []string{ + "Config File", + "Runtime", + "Startup", + "Theme", + "Timeouts", + "Concurrency", + "CloudWatch", + "Navigation", + "Autosave", + "AI", + } + + for _, section := range expectedSections { + if !strings.Contains(content, section) { + t.Errorf("buildContent() should contain section %q", section) + } + } +} + +func TestSettingsView_GetThemeOverrides_Empty(t *testing.T) { + sv := NewSettingsView(context.Background()) + + overrides := sv.getThemeOverrides(config.ThemeConfig{}) + + if len(overrides) != 0 { + t.Errorf("getThemeOverrides() with empty config should return empty slice, got %d items", len(overrides)) + } +} + +func TestSettingsView_GetThemeOverrides_WithOverrides(t *testing.T) { + sv := NewSettingsView(context.Background()) + + theme := config.ThemeConfig{ + Primary: "#ff0000", + Secondary: "#00ff00", + } + overrides := sv.getThemeOverrides(theme) + + if len(overrides) != 2 { + t.Errorf("getThemeOverrides() should return 2 overrides, got %d", len(overrides)) + } + + if !strings.Contains(overrides[0], "Primary") { + t.Errorf("first override should contain 'Primary', got %q", overrides[0]) + } +} + +func TestSettingsView_FormatProfiles_Empty(t *testing.T) { + sv := NewSettingsView(context.Background()) + + result := sv.formatProfiles(nil) + + if result != noneValue { + t.Errorf("formatProfiles(nil) should return %q, got %q", noneValue, result) + } + + result = sv.formatProfiles([]config.ProfileSelection{}) + if result != noneValue { + 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)) } }