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"
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
|
"github.com/steveyegge/gastown/internal/ui"
|
||||||
"github.com/steveyegge/gastown/internal/version"
|
"github.com/steveyegge/gastown/internal/version"
|
||||||
"github.com/steveyegge/gastown/internal/workspace"
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
@@ -66,6 +68,9 @@ var branchCheckExemptCommands = map[string]bool{
|
|||||||
|
|
||||||
// persistentPreRun runs before every command.
|
// persistentPreRun runs before every command.
|
||||||
func persistentPreRun(cmd *cobra.Command, args []string) error {
|
func persistentPreRun(cmd *cobra.Command, args []string) error {
|
||||||
|
// Initialize CLI theme (dark/light mode support)
|
||||||
|
initCLITheme()
|
||||||
|
|
||||||
// Get the root command name being run
|
// Get the root command name being run
|
||||||
cmdName := cmd.Name()
|
cmdName := cmd.Name()
|
||||||
|
|
||||||
@@ -88,6 +93,22 @@ func persistentPreRun(cmd *cobra.Command, args []string) error {
|
|||||||
return CheckBeadsVersion()
|
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.
|
// 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.
|
// This is a non-blocking warning to help catch accidental branch switches.
|
||||||
func warnIfTownRootOffMain() {
|
func warnIfTownRootOffMain() {
|
||||||
|
|||||||
+128
-3
@@ -6,6 +6,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/muesli/termenv"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/gastown/internal/config"
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
"github.com/steveyegge/gastown/internal/session"
|
"github.com/steveyegge/gastown/internal/session"
|
||||||
@@ -14,11 +15,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
themeListFlag bool
|
themeListFlag bool
|
||||||
themeApplyFlag bool
|
themeApplyFlag bool
|
||||||
themeApplyAllFlag bool
|
themeApplyAllFlag bool
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Valid CLI theme modes
|
||||||
|
var validCLIThemes = []string{"auto", "dark", "light"}
|
||||||
|
|
||||||
var themeCmd = &cobra.Command{
|
var themeCmd = &cobra.Command{
|
||||||
Use: "theme [name]",
|
Use: "theme [name]",
|
||||||
GroupID: GroupConfig,
|
GroupID: GroupConfig,
|
||||||
@@ -43,12 +47,37 @@ var themeApplyCmd = &cobra.Command{
|
|||||||
|
|
||||||
By default, only applies to sessions in the current rig.
|
By default, only applies to sessions in the current rig.
|
||||||
Use --all to apply to sessions across all rigs.`,
|
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() {
|
func init() {
|
||||||
rootCmd.AddCommand(themeCmd)
|
rootCmd.AddCommand(themeCmd)
|
||||||
themeCmd.AddCommand(themeApplyCmd)
|
themeCmd.AddCommand(themeApplyCmd)
|
||||||
|
themeCmd.AddCommand(themeCLICmd)
|
||||||
themeCmd.Flags().BoolVarP(&themeListFlag, "list", "l", false, "List available themes")
|
themeCmd.Flags().BoolVarP(&themeListFlag, "list", "l", false, "List available themes")
|
||||||
themeApplyCmd.Flags().BoolVarP(&themeApplyAllFlag, "all", "a", false, "Apply to all rigs, not just current")
|
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
|
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"
|
Type string `json:"type"` // "town-settings"
|
||||||
Version int `json:"version"` // schema version
|
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.
|
// DefaultAgent is the name of the agent preset to use by default.
|
||||||
// Can be a built-in preset ("claude", "gemini", "codex", "cursor", "auggie", "amp")
|
// Can be a built-in preset ("claude", "gemini", "codex", "cursor", "auggie", "amp")
|
||||||
// or a custom agent name defined in settings/agents.json.
|
// 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
|
// Ayu theme color palette
|
||||||
// Dark: https://terminalcolors.com/themes/ayu/dark/
|
// Dark: https://terminalcolors.com/themes/ayu/dark/
|
||||||
// Light: https://terminalcolors.com/themes/ayu/light/
|
// Light: https://terminalcolors.com/themes/ayu/light/
|
||||||
|
|||||||
@@ -2,10 +2,96 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/muesli/termenv"
|
||||||
"golang.org/x/term"
|
"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).
|
// IsTerminal returns true if stdout is connected to a terminal (TTY).
|
||||||
func IsTerminal() bool {
|
func IsTerminal() bool {
|
||||||
return term.IsTerminal(int(os.Stdout.Fd()))
|
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")
|
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