From ed089cbd17234480400973f957a591d1d4b83dbf Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Mon, 22 Dec 2025 00:39:50 -0800 Subject: [PATCH] Add role-based theming with layered config and doctor check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Role themes: - witness: rust (red/alert) - refinery: plum (purple) - crew/polecat: inherit rig theme Resolution order: 1. Per-rig role override (rig/.gastown/config.json role_themes) 2. Global role default (mayor/town.json theme.role_defaults) 3. Built-in role defaults 4. Rig theme (config or hash-based) Config schema: - TownConfig.Theme.RoleDefaults: global role->theme map - RigConfig.Theme.RoleThemes: per-rig role overrides Doctor check: - Detects sessions with outdated theme format (brackets) - Fixable with 'gt theme apply --all' 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/doctor.go | 1 + internal/cmd/theme.go | 58 ++++++++++++++++- internal/config/types.go | 30 +++++++-- internal/doctor/theme_check.go | 116 +++++++++++++++++++++++++++++++++ 4 files changed, 198 insertions(+), 7 deletions(-) create mode 100644 internal/doctor/theme_check.go diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index 89c7d143..e13a7982 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -61,6 +61,7 @@ func runDoctor(cmd *cobra.Command, args []string) error { d.Register(doctor.NewBranchCheck()) d.Register(doctor.NewBeadsSyncOrphanCheck()) d.Register(doctor.NewIdentityCollisionCheck()) + d.Register(doctor.NewThemeCheck()) // Ephemeral beads checks d.Register(doctor.NewEphemeralExistsCheck()) diff --git a/internal/cmd/theme.go b/internal/cmd/theme.go index 31fae0c5..355878c8 100644 --- a/internal/cmd/theme.go +++ b/internal/cmd/theme.go @@ -137,7 +137,7 @@ func runThemeApply(cmd *cobra.Command, args []string) error { } else if strings.HasPrefix(session, "gt-witness-") { // Witness sessions: gt-witness- rig = strings.TrimPrefix(session, "gt-witness-") - theme = getThemeForRig(rig) + theme = getThemeForRole(rig, "witness") worker = "witness" role = "witness" } else { @@ -157,13 +157,16 @@ func runThemeApply(cmd *cobra.Command, args []string) error { if strings.HasPrefix(workerPart, "crew-") { worker = strings.TrimPrefix(workerPart, "crew-") role = "crew" + } else if workerPart == "refinery" { + worker = "refinery" + role = "refinery" } else { worker = workerPart role = "polecat" } - // Use configured theme, fall back to hash-based assignment - theme = getThemeForRig(rig) + // Use role-based theme resolution + theme = getThemeForRole(rig, role) } // Apply theme and status format @@ -249,6 +252,55 @@ func getThemeForRig(rigName string) tmux.Theme { return tmux.AssignTheme(rigName) } +// getThemeForRole returns the theme for a specific role in a rig. +// Resolution order: +// 1. Per-rig role override (rig/.gastown/config.json) +// 2. Global role default (mayor/town.json) +// 3. Built-in role defaults (witness=rust, refinery=plum) +// 4. Rig theme (config or hash-based) +func getThemeForRole(rigName, role string) tmux.Theme { + townRoot, _ := workspace.FindFromCwd() + + // 1. Check per-rig role override + if townRoot != "" { + configPath := filepath.Join(townRoot, rigName, ".gastown", "config.json") + if cfg, err := config.LoadRigConfig(configPath); err == nil { + if cfg.Theme != nil && cfg.Theme.RoleThemes != nil { + if themeName, ok := cfg.Theme.RoleThemes[role]; ok { + if theme := tmux.GetThemeByName(themeName); theme != nil { + return *theme + } + } + } + } + } + + // 2. Check global role default (town config) + if townRoot != "" { + townConfigPath := filepath.Join(townRoot, "mayor", "town.json") + if townCfg, err := config.LoadTownConfig(townConfigPath); err == nil { + if townCfg.Theme != nil && townCfg.Theme.RoleDefaults != nil { + if themeName, ok := townCfg.Theme.RoleDefaults[role]; ok { + if theme := tmux.GetThemeByName(themeName); theme != nil { + return *theme + } + } + } + } + } + + // 3. Check built-in role defaults + builtins := config.BuiltinRoleThemes() + if themeName, ok := builtins[role]; ok { + if theme := tmux.GetThemeByName(themeName); theme != nil { + return *theme + } + } + + // 4. Fall back to rig theme + return getThemeForRig(rigName) +} + // loadRigTheme loads the theme name from rig config. func loadRigTheme(rigName string) string { townRoot, err := workspace.FindFromCwd() diff --git a/internal/config/types.go b/internal/config/types.go index d5fd5978..9452c4df 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -5,10 +5,11 @@ import "time" // TownConfig represents the main town configuration (mayor/town.json). type TownConfig struct { - Type string `json:"type"` // "town" - Version int `json:"version"` // schema version - Name string `json:"name"` // town identifier - CreatedAt time.Time `json:"created_at"` + Type string `json:"type"` // "town" + Version int `json:"version"` // schema version + Name string `json:"name"` // town identifier + CreatedAt time.Time `json:"created_at"` + Theme *TownThemeConfig `json:"theme,omitempty"` // global theme settings } // RigsConfig represents the rigs registry (mayor/rigs.json). @@ -64,6 +65,10 @@ type ThemeConfig struct { // Custom overrides the palette with specific colors. Custom *CustomTheme `json:"custom,omitempty"` + + // RoleThemes overrides themes for specific roles in this rig. + // Keys: "witness", "refinery", "crew", "polecat" + RoleThemes map[string]string `json:"role_themes,omitempty"` } // CustomTheme allows specifying exact colors for the status bar. @@ -72,6 +77,23 @@ type CustomTheme struct { FG string `json:"fg"` // Foreground color (hex or tmux color name) } +// TownThemeConfig represents global theme settings (mayor/config.json). +type TownThemeConfig struct { + // RoleDefaults sets default themes for roles across all rigs. + // Keys: "witness", "refinery", "crew", "polecat" + RoleDefaults map[string]string `json:"role_defaults,omitempty"` +} + +// BuiltinRoleThemes returns the default themes for each role. +// These are used when no explicit configuration is provided. +func BuiltinRoleThemes() map[string]string { + return map[string]string{ + "witness": "rust", // Red/rust - watchful, alert + "refinery": "plum", // Purple - processing, refining + // crew and polecat use rig theme by default (no override) + } +} + // MergeQueueConfig represents merge queue settings for a rig. type MergeQueueConfig struct { // Enabled controls whether the merge queue is active. diff --git a/internal/doctor/theme_check.go b/internal/doctor/theme_check.go new file mode 100644 index 00000000..8d836056 --- /dev/null +++ b/internal/doctor/theme_check.go @@ -0,0 +1,116 @@ +package doctor + +import ( + "fmt" + "os/exec" + "strings" + + "github.com/steveyegge/gastown/internal/tmux" +) + +// ThemeCheck verifies tmux sessions have correct themes applied. +type ThemeCheck struct { + FixableCheck +} + +// NewThemeCheck creates a new theme check. +func NewThemeCheck() *ThemeCheck { + return &ThemeCheck{ + FixableCheck: FixableCheck{ + BaseCheck: BaseCheck{ + CheckName: "themes", + CheckDescription: "Check tmux session theme configuration", + }, + }, + } +} + +// Run checks if tmux sessions have themes applied correctly. +func (c *ThemeCheck) Run(ctx *CheckContext) *CheckResult { + t := tmux.NewTmux() + + // List all sessions + sessions, err := t.ListSessions() + if err != nil { + // No tmux server or error - not a problem, just skip + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "No tmux sessions running", + } + } + + // Check for Gas Town sessions + var gtSessions []string + for _, s := range sessions { + if strings.HasPrefix(s, "gt-") { + gtSessions = append(gtSessions, s) + } + } + + if len(gtSessions) == 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "No Gas Town sessions running", + } + } + + // Check if sessions have proper status-left format (no brackets = new format) + var needsUpdate []string + for _, session := range gtSessions { + statusLeft, err := getSessionStatusLeft(session) + if err != nil { + continue + } + // Old format had brackets like [Mayor] or [gastown/crew] + if strings.Contains(statusLeft, "[") && strings.Contains(statusLeft, "]") { + needsUpdate = append(needsUpdate, session) + } + } + + if len(needsUpdate) > 0 { + details := make([]string, len(needsUpdate)) + for i, s := range needsUpdate { + details[i] = fmt.Sprintf("Needs update: %s", s) + } + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: fmt.Sprintf("%d session(s) have outdated theme format", len(needsUpdate)), + Details: details, + FixHint: "Run 'gt theme apply --all' or 'gt doctor --fix'", + } + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: fmt.Sprintf("%d session(s) have correct themes", len(gtSessions)), + } +} + +// Fix applies themes to all sessions. +func (c *ThemeCheck) Fix(ctx *CheckContext) error { + cmd := exec.Command("gt", "theme", "apply", "--all") + cmd.Dir = ctx.TownRoot + return cmd.Run() +} + +// getSessionStatusLeft retrieves the status-left setting for a tmux session. +func getSessionStatusLeft(session string) (string, error) { + cmd := exec.Command("tmux", "show-options", "-t", session, "status-left") + output, err := cmd.Output() + if err != nil { + return "", err + } + // Parse: status-left "value" + line := strings.TrimSpace(string(output)) + if idx := strings.Index(line, "\""); idx != -1 { + end := strings.LastIndex(line, "\"") + if end > idx { + return line[idx+1 : end], nil + } + } + return line, nil +}