feat(theme): add dark mode CLI theme support (#911)
Add the ability to force dark or light mode colors for CLI output, overriding the automatic terminal background detection. Changes: - Add CLITheme field to TownSettings for persisting preference - Add GetThemeMode() and HasDarkBackground() to ui package for theme detection with GT_THEME env var override - Add ApplyThemeMode() to explicitly set lipgloss dark background - Add 'gt theme cli' subcommand to view/set CLI theme preference - Initialize theme in CLI startup (persistentPreRun) - Add comprehensive tests for theme mode functionality Usage: - gt theme cli # show current theme - gt theme cli dark # force dark mode - gt theme cli light # force light mode - gt theme cli auto # auto-detect (default) - GT_THEME=dark gt status # per-command override Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user