diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 2ac6f1a3..3b5f6b92 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -8,7 +8,9 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/ui" "github.com/steveyegge/gastown/internal/version" "github.com/steveyegge/gastown/internal/workspace" ) @@ -66,6 +68,9 @@ var branchCheckExemptCommands = map[string]bool{ // persistentPreRun runs before every command. func persistentPreRun(cmd *cobra.Command, args []string) error { + // Initialize CLI theme (dark/light mode support) + initCLITheme() + // Get the root command name being run cmdName := cmd.Name() @@ -88,6 +93,22 @@ func persistentPreRun(cmd *cobra.Command, args []string) error { return CheckBeadsVersion() } +// initCLITheme initializes the CLI color theme based on settings and environment. +func initCLITheme() { + // Try to load town settings for CLITheme config + var configTheme string + if townRoot, err := workspace.FindFromCwd(); err == nil && townRoot != "" { + settingsPath := config.TownSettingsPath(townRoot) + if settings, err := config.LoadOrCreateTownSettings(settingsPath); err == nil { + configTheme = settings.CLITheme + } + } + + // Initialize theme with config value (env var takes precedence inside InitTheme) + ui.InitTheme(configTheme) + ui.ApplyThemeMode() +} + // warnIfTownRootOffMain prints a warning if the town root is not on main branch. // This is a non-blocking warning to help catch accidental branch switches. func warnIfTownRootOffMain() { diff --git a/internal/cmd/theme.go b/internal/cmd/theme.go index 9cab0c82..ea32d5ef 100644 --- a/internal/cmd/theme.go +++ b/internal/cmd/theme.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" + "github.com/muesli/termenv" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/session" @@ -14,11 +15,14 @@ import ( ) var ( - themeListFlag bool - themeApplyFlag bool + themeListFlag bool + themeApplyFlag bool themeApplyAllFlag bool ) +// Valid CLI theme modes +var validCLIThemes = []string{"auto", "dark", "light"} + var themeCmd = &cobra.Command{ Use: "theme [name]", GroupID: GroupConfig, @@ -43,12 +47,37 @@ var themeApplyCmd = &cobra.Command{ By default, only applies to sessions in the current rig. Use --all to apply to sessions across all rigs.`, - RunE: runThemeApply, + RunE: runThemeApply, +} + +var themeCLICmd = &cobra.Command{ + Use: "cli [mode]", + Short: "View or set CLI color scheme (dark/light/auto)", + Long: `Manage CLI output color scheme for Gas Town commands. + +Without arguments, shows the current CLI theme mode and detection. +With a mode argument, sets the CLI theme preference. + +Modes: + auto - Automatically detect terminal background (default) + dark - Force dark mode colors (light text for dark backgrounds) + light - Force light mode colors (dark text for light backgrounds) + +The setting is stored in town settings (settings/config.json) and can +be overridden per-session via the GT_THEME environment variable. + +Examples: + gt theme cli # Show current CLI theme + gt theme cli dark # Set CLI theme to dark mode + gt theme cli auto # Reset to auto-detection + GT_THEME=light gt status # Override for a single command`, + RunE: runThemeCLI, } func init() { rootCmd.AddCommand(themeCmd) themeCmd.AddCommand(themeApplyCmd) + themeCmd.AddCommand(themeCLICmd) themeCmd.Flags().BoolVarP(&themeListFlag, "list", "l", false, "List available themes") themeApplyCmd.Flags().BoolVarP(&themeApplyAllFlag, "all", "a", false, "Apply to all rigs, not just current") } @@ -362,3 +391,99 @@ func saveRigTheme(rigName, themeName string) error { return nil } + +func runThemeCLI(cmd *cobra.Command, args []string) error { + townRoot, err := workspace.FindFromCwd() + if err != nil { + return fmt.Errorf("finding workspace: %w", err) + } + if townRoot == "" { + return fmt.Errorf("not in a Gas Town workspace") + } + + settingsPath := config.TownSettingsPath(townRoot) + + // Show current theme + if len(args) == 0 { + settings, err := config.LoadOrCreateTownSettings(settingsPath) + if err != nil { + return fmt.Errorf("loading settings: %w", err) + } + + // Determine effective mode + configValue := settings.CLITheme + if configValue == "" { + configValue = "auto" + } + + // Check for env override + envValue := os.Getenv("GT_THEME") + effectiveMode := configValue + if envValue != "" { + effectiveMode = strings.ToLower(envValue) + } + + fmt.Printf("CLI Theme:\n") + fmt.Printf(" Configured: %s\n", configValue) + if envValue != "" { + fmt.Printf(" Override: %s (via GT_THEME)\n", envValue) + } + fmt.Printf(" Effective: %s\n", effectiveMode) + + // Show detection result for auto mode + if effectiveMode == "auto" { + detected := "light" + if detectTerminalBackground() { + detected = "dark" + } + fmt.Printf(" Detected: %s background\n", detected) + } + + return nil + } + + // Set CLI theme + mode := strings.ToLower(args[0]) + if !isValidCLITheme(mode) { + return fmt.Errorf("invalid CLI theme '%s' (valid: auto, dark, light)", mode) + } + + // Load existing settings + settings, err := config.LoadOrCreateTownSettings(settingsPath) + if err != nil { + return fmt.Errorf("loading settings: %w", err) + } + + // Update CLITheme + settings.CLITheme = mode + + // Save + if err := config.SaveTownSettings(settingsPath, settings); err != nil { + return fmt.Errorf("saving settings: %w", err) + } + + fmt.Printf("CLI theme set to '%s'\n", mode) + if mode == "auto" { + fmt.Println("Colors will adapt to your terminal's background.") + } else { + fmt.Printf("Colors optimized for %s backgrounds.\n", mode) + } + + return nil +} + +// isValidCLITheme checks if a CLI theme mode is valid. +func isValidCLITheme(mode string) bool { + for _, valid := range validCLIThemes { + if mode == valid { + return true + } + } + return false +} + +// detectTerminalBackground returns true if terminal has dark background. +func detectTerminalBackground() bool { + // Use termenv for detection + return termenv.HasDarkBackground() +} diff --git a/internal/config/types.go b/internal/config/types.go index 177f6a5f..d3b14888 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -39,6 +39,12 @@ type TownSettings struct { Type string `json:"type"` // "town-settings" Version int `json:"version"` // schema version + // CLITheme controls CLI output color scheme. + // Values: "dark", "light", "auto" (default). + // "auto" lets the terminal emulator's background color guide the choice. + // Can be overridden by GT_THEME environment variable. + CLITheme string `json:"cli_theme,omitempty"` + // DefaultAgent is the name of the agent preset to use by default. // Can be a built-in preset ("claude", "gemini", "codex", "cursor", "auggie", "amp") // or a custom agent name defined in settings/agents.json. diff --git a/internal/ui/styles.go b/internal/ui/styles.go index fbe00536..dbfb953e 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -22,6 +22,16 @@ func init() { } } +// ApplyThemeMode applies the theme mode settings to lipgloss. +// This should be called after InitTheme() has been called. +func ApplyThemeMode() { + if !ShouldUseColor() { + return + } + // Set lipgloss dark background flag based on theme mode + lipgloss.SetHasDarkBackground(HasDarkBackground()) +} + // Ayu theme color palette // Dark: https://terminalcolors.com/themes/ayu/dark/ // Light: https://terminalcolors.com/themes/ayu/light/ diff --git a/internal/ui/terminal.go b/internal/ui/terminal.go index afdf6e95..7e39f91d 100644 --- a/internal/ui/terminal.go +++ b/internal/ui/terminal.go @@ -2,10 +2,96 @@ package ui import ( "os" + "strings" + "github.com/muesli/termenv" "golang.org/x/term" ) +// ThemeMode represents the CLI color scheme mode. +type ThemeMode string + +const ( + // ThemeModeAuto lets the terminal background guide color selection. + ThemeModeAuto ThemeMode = "auto" + // ThemeModeDark forces dark mode colors (light text on dark background). + ThemeModeDark ThemeMode = "dark" + // ThemeModeLight forces light mode colors (dark text on light background). + ThemeModeLight ThemeMode = "light" +) + +// themeMode is the cached theme mode, set during init. +var themeMode ThemeMode + +// hasDarkBackground caches whether we're in dark mode. +var hasDarkBackground bool + +// InitTheme initializes the theme mode. Call this early in main. +// configTheme is the value from TownSettings.CLITheme (may be empty). +func InitTheme(configTheme string) { + themeMode = resolveThemeMode(configTheme) + hasDarkBackground = detectDarkBackground(themeMode) +} + +// GetThemeMode returns the current CLI color scheme mode. +// Priority order: +// 1. GT_THEME environment variable ("dark", "light", "auto") +// 2. Configured value from settings (passed to InitTheme) +// 3. Default: "auto" +func GetThemeMode() ThemeMode { + return themeMode +} + +// HasDarkBackground returns true if we're displaying on a dark background. +// This is used by lipgloss AdaptiveColor to select appropriate colors. +func HasDarkBackground() bool { + return hasDarkBackground +} + +// resolveThemeMode determines the theme mode from env and config. +func resolveThemeMode(configTheme string) ThemeMode { + // Priority 1: GT_THEME environment variable + if envTheme := os.Getenv("GT_THEME"); envTheme != "" { + switch strings.ToLower(envTheme) { + case "dark": + return ThemeModeDark + case "light": + return ThemeModeLight + case "auto": + return ThemeModeAuto + } + // Invalid value - fall through to config + } + + // Priority 2: Config value + if configTheme != "" { + switch strings.ToLower(configTheme) { + case "dark": + return ThemeModeDark + case "light": + return ThemeModeLight + case "auto": + return ThemeModeAuto + } + } + + // Default: auto + return ThemeModeAuto +} + +// detectDarkBackground determines if we're on a dark background. +func detectDarkBackground(mode ThemeMode) bool { + switch mode { + case ThemeModeDark: + return true + case ThemeModeLight: + return false + default: + // Auto mode - use termenv detection + return termenv.HasDarkBackground() + } +} + // IsTerminal returns true if stdout is connected to a terminal (TTY). func IsTerminal() bool { return term.IsTerminal(int(os.Stdout.Fd())) diff --git a/internal/ui/terminal_test.go b/internal/ui/terminal_test.go index a309b9c6..86044c9e 100644 --- a/internal/ui/terminal_test.go +++ b/internal/ui/terminal_test.go @@ -245,3 +245,90 @@ func TestIsAgentMode_CLAUDE_CODE_AnyValue(t *testing.T) { t.Error("IsAgentMode() should return true when CLAUDE_CODE is set to any value") } } + +func TestInitTheme_EnvOverridesConfig(t *testing.T) { + oldGTTheme := os.Getenv("GT_THEME") + defer func() { + if oldGTTheme != "" { + os.Setenv("GT_THEME", oldGTTheme) + } else { + os.Unsetenv("GT_THEME") + } + }() + + // Test: env var overrides config + os.Setenv("GT_THEME", "dark") + InitTheme("light") // config says light + if GetThemeMode() != ThemeModeDark { + t.Errorf("Expected dark mode from env var, got %s", GetThemeMode()) + } + + os.Setenv("GT_THEME", "light") + InitTheme("dark") // config says dark + if GetThemeMode() != ThemeModeLight { + t.Errorf("Expected light mode from env var, got %s", GetThemeMode()) + } +} + +func TestInitTheme_ConfigUsedWhenNoEnv(t *testing.T) { + oldGTTheme := os.Getenv("GT_THEME") + defer func() { + if oldGTTheme != "" { + os.Setenv("GT_THEME", oldGTTheme) + } else { + os.Unsetenv("GT_THEME") + } + }() + + os.Unsetenv("GT_THEME") + + InitTheme("dark") + if GetThemeMode() != ThemeModeDark { + t.Errorf("Expected dark mode from config, got %s", GetThemeMode()) + } + + InitTheme("light") + if GetThemeMode() != ThemeModeLight { + t.Errorf("Expected light mode from config, got %s", GetThemeMode()) + } +} + +func TestInitTheme_DefaultsToAuto(t *testing.T) { + oldGTTheme := os.Getenv("GT_THEME") + defer func() { + if oldGTTheme != "" { + os.Setenv("GT_THEME", oldGTTheme) + } else { + os.Unsetenv("GT_THEME") + } + }() + + os.Unsetenv("GT_THEME") + InitTheme("") // no config + if GetThemeMode() != ThemeModeAuto { + t.Errorf("Expected auto mode as default, got %s", GetThemeMode()) + } +} + +func TestHasDarkBackground_ForcedModes(t *testing.T) { + oldGTTheme := os.Getenv("GT_THEME") + defer func() { + if oldGTTheme != "" { + os.Setenv("GT_THEME", oldGTTheme) + } else { + os.Unsetenv("GT_THEME") + } + }() + + os.Setenv("GT_THEME", "dark") + InitTheme("") + if !HasDarkBackground() { + t.Error("Expected HasDarkBackground() to return true when mode is dark") + } + + os.Setenv("GT_THEME", "light") + InitTheme("") + if HasDarkBackground() { + t.Error("Expected HasDarkBackground() to return false when mode is light") + } +}