Files
gastown/docs/design/tmux-theming.md
Steve Yegge 4d0492fdf6 feat(tmux): add per-rig color themes and dynamic status line (gt-vc1n)
Add tmux status bar theming for Gas Town sessions:

- Per-rig color themes auto-assigned via consistent hashing
- 10 curated dark themes (ocean, forest, rust, plum, etc.)
- Special gold/dark theme for Mayor
- Dynamic status line showing current issue and mail count
- Mayor status shows polecat/rig counts

New commands:
- gt theme --list: show available themes
- gt theme apply: apply to running sessions
- gt issue set/clear: agents update their current issue
- gt status-line: internal command for tmux refresh

Status bar format:
- Left: [rig/worker] role
- Right: <issue> | <mail> | HH:MM

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 14:17:39 -08:00

13 KiB

Design: Tmux Status Bar Theming (gt-vc1n)

Problem

All Gas Town tmux sessions look identical:

  • Same green/black status bars everywhere
  • Hard to tell which rig you're in at a glance
  • Session names get truncated (only 10 chars visible)
  • No visual indication of worker role (polecat vs crew vs mayor)

Current state:

[gt-gastown] 0:zsh*  "pane_title" 14:30 19-Dec
[gt-gastown] 0:zsh*  "pane_title" 14:30 19-Dec  <- which worker?
[gt-mayor]   0:zsh*  "pane_title" 14:30 19-Dec

Solution

Per-rig color themes applied when tmux sessions are created, with optional user customization.

Goals

  1. Each rig has a distinct color theme
  2. Colors are automatically assigned from a predefined palette
  3. Users can override colors per-rig
  4. Status bar shows useful context (rig, worker, role)

Design

1. Color Palette

A curated palette of distinct, visually appealing color pairs (bg/fg):

// internal/tmux/theme.go
var DefaultPalette = []Theme{
    {Name: "ocean",    BG: "#1e3a5f", FG: "#e0e0e0"},  // Deep blue
    {Name: "forest",   BG: "#2d5a3d", FG: "#e0e0e0"},  // Forest green
    {Name: "rust",     BG: "#8b4513", FG: "#f5f5dc"},  // Rust/brown
    {Name: "plum",     BG: "#4a3050", FG: "#e0e0e0"},  // Purple
    {Name: "slate",    BG: "#4a5568", FG: "#e0e0e0"},  // Slate gray
    {Name: "ember",    BG: "#b33a00", FG: "#f5f5dc"},  // Burnt orange
    {Name: "midnight", BG: "#1a1a2e", FG: "#c0c0c0"},  // Dark blue-black
    {Name: "wine",     BG: "#722f37", FG: "#f5f5dc"},  // Burgundy
    {Name: "teal",     BG: "#0d5c63", FG: "#e0e0e0"},  // Teal
    {Name: "copper",   BG: "#6d4c41", FG: "#f5f5dc"},  // Warm brown
}

Palette criteria:

  • Distinct from each other (no two look alike)
  • Readable (sufficient contrast)
  • Professional (no neon/garish colors)
  • Dark backgrounds (easier on eyes in terminals)

2. Configuration

Per-Rig Config Extension

Extend RigConfig in internal/config/types.go:

type RigConfig struct {
    Type       string            `json:"type"`
    Version    int               `json:"version"`
    MergeQueue *MergeQueueConfig `json:"merge_queue,omitempty"`
    Theme      *ThemeConfig      `json:"theme,omitempty"`  // NEW
}

type ThemeConfig struct {
    // Name picks from palette (e.g., "ocean", "forest")
    Name string `json:"name,omitempty"`

    // Custom overrides the palette with specific colors
    Custom *CustomTheme `json:"custom,omitempty"`
}

type CustomTheme struct {
    BG string `json:"bg"`  // hex color or tmux color name
    FG string `json:"fg"`
}

Town-Level Config (optional)

Allow global palette override in mayor/town.json:

{
  "theme": {
    "palette": ["ocean", "forest", "rust", "plum"],
    "mayor_theme": "midnight"
  }
}

3. Theme Assignment

When a rig is added (or first session created), auto-assign a theme:

// internal/tmux/theme.go

// AssignTheme picks a theme for a rig based on its name.
// Uses consistent hashing so the same rig always gets the same color.
func AssignTheme(rigName string, palette []Theme) Theme {
    h := fnv.New32a()
    h.Write([]byte(rigName))
    idx := int(h.Sum32()) % len(palette)
    return palette[idx]
}

This ensures:

  • Same rig always gets same color (deterministic)
  • Different rigs get different colors (distributed)
  • No persistent state needed for assignment

4. Session Creation Changes

Modify tmux.NewSession to accept optional theming:

// SessionOptions configures session creation.
type SessionOptions struct {
    WorkDir string
    Theme   *Theme  // nil = use default
}

// NewSessionWithOptions creates a session with theming.
func (t *Tmux) NewSessionWithOptions(name string, opts SessionOptions) error {
    args := []string{"new-session", "-d", "-s", name}
    if opts.WorkDir != "" {
        args = append(args, "-c", opts.WorkDir)
    }

    if _, err := t.run(args...); err != nil {
        return err
    }

    // Apply theme
    if opts.Theme != nil {
        t.ApplyTheme(name, *opts.Theme)
    }

    return nil
}

// ApplyTheme sets the status bar style for a session.
func (t *Tmux) ApplyTheme(session string, theme Theme) error {
    style := fmt.Sprintf("bg=%s,fg=%s", theme.BG, theme.FG)
    _, err := t.run("set-option", "-t", session, "status-style", style)
    return err
}

5. Status Line Format

Static Identity (Left)

// SetStatusFormat configures the status line for Gas Town sessions.
func (t *Tmux) SetStatusFormat(session, rig, worker, role string) error {
    // Format: [gastown/Rictus] polecat
    left := fmt.Sprintf("[%s/%s] %s ", rig, worker, role)

    if _, err := t.run("set-option", "-t", session, "status-left-length", "40"); err != nil {
        return err
    }
    return t.run("set-option", "-t", session, "status-left", left)
}

Dynamic Context (Right)

The right side shows dynamic info that agents can update:

gt-70b3 | 📬 2 | 14:30

Components:

  • Current issue - what the agent is working on
  • Mail indicator - unread mail count (hidden if 0)
  • Time - simple clock

Implementation via tmux environment variables + shell expansion:

// SetDynamicStatus configures the right side with dynamic content.
func (t *Tmux) SetDynamicStatus(session string) error {
    // Use a shell command that reads from env vars we set
    // Agents update GT_ISSUE, we poll mail count
    //
    // Format: #{GT_ISSUE} | 📬 #{mail_count} | %H:%M
    //
    // tmux can run shell commands in status-right with #()
    right := `#(gt status-line --session=` + session + `) %H:%M`

    if _, err := t.run("set-option", "-t", session, "status-right-length", "50"); err != nil {
        return err
    }
    return t.run("set-option", "-t", session, "status-right", right)
}

gt status-line Command

A fast command for tmux to call every few seconds:

// cmd/statusline.go
func runStatusLine(cmd *cobra.Command, args []string) error {
    session := cmd.Flag("session").Value.String()

    // Get current issue from tmux env
    issue, _ := tmux.GetEnvironment(session, "GT_ISSUE")

    // Get mail count (fast - just counts files or queries beads)
    mailCount := mail.UnreadCount(identity)

    // Build output
    var parts []string
    if issue != "" {
        parts = append(parts, issue)
    }
    if mailCount > 0 {
        parts = append(parts, fmt.Sprintf("📬 %d", mailCount))
    }

    fmt.Print(strings.Join(parts, " | "))
    return nil
}

Agent Updates Issue

Agents call this when starting/finishing work:

# When starting work on an issue
gt issue set gt-70b3

# When done
gt issue clear

Implementation:

// cmd/issue.go
func runIssueSet(cmd *cobra.Command, args []string) error {
    issueID := args[0]
    session := os.Getenv("TMUX_PANE") // or detect from GT_* vars

    return tmux.SetEnvironment(session, "GT_ISSUE", issueID)
}

Mayor-Specific Status

Mayor gets a different right-side format:

5 polecats | 2 rigs | 📬 1 | 14:30
func runMayorStatusLine() {
    polecats := countActivePolecats()
    rigs := countActiveRigs()
    mail := mail.UnreadCount("mayor/")

    var parts []string
    parts = append(parts, fmt.Sprintf("%d polecats", polecats))
    parts = append(parts, fmt.Sprintf("%d rigs", rigs))
    if mail > 0 {
        parts = append(parts, fmt.Sprintf("📬 %d", mail))
    }
    fmt.Print(strings.Join(parts, " | "))
}

Example Status Bars

Polecat working on issue:

[gastown/Rictus] polecat                    gt-70b3 | 📬 1 | 14:30

Crew worker, no mail:

[gastown/max] crew                                gt-vc1n | 14:30

Mayor overview:

[Mayor] coordinator              5 polecats | 2 rigs | 📬 2 | 14:30

Idle polecat:

[gastown/Wez] polecat                                      | 14:30

6. Integration Points

Session Manager (session/manager.go)

func (m *Manager) Start(polecat string, opts StartOptions) error {
    // ... existing code ...

    // Get theme from rig config
    theme := m.getTheme()

    // Create session with theme
    if err := m.tmux.NewSessionWithOptions(sessionID, tmux.SessionOptions{
        WorkDir: workDir,
        Theme:   theme,
    }); err != nil {
        return fmt.Errorf("creating session: %w", err)
    }

    // Set status format
    m.tmux.SetStatusFormat(sessionID, m.rig.Name, polecat, "polecat")

    // ... rest of existing code ...
}

Mayor (cmd/mayor.go)

func runMayorStart(cmd *cobra.Command, args []string) error {
    // ... existing code ...

    // Mayor uses a special theme
    theme := tmux.MayorTheme() // Gold/dark - distinguished

    if err := t.NewSessionWithOptions(MayorSessionName, tmux.SessionOptions{
        WorkDir: townRoot,
        Theme:   &theme,
    }); err != nil {
        return fmt.Errorf("creating session: %w", err)
    }

    t.SetStatusFormat(MayorSessionName, "town", "mayor", "coordinator")

    // ... rest ...
}

Crew (cmd/crew.go)

Similar pattern - get rig theme and apply.

7. Commands

gt theme - View/Set Themes

# View current rig theme
gt theme
# Theme: ocean (bg=#1e3a5f, fg=#e0e0e0)

# View available themes
gt theme --list
# ocean, forest, rust, plum, slate, ember, midnight, wine, teal, copper

# Set theme for current rig
gt theme set forest

# Set custom colors
gt theme set --bg="#2d5a3d" --fg="#e0e0e0"

gt theme apply - Apply to Running Sessions

# Re-apply theme to all running sessions in this rig
gt theme apply

8. Backward Compatibility

  • Existing sessions without themes continue to work (they'll just have default green)
  • New sessions get themed automatically
  • Users can run gt theme apply to update running sessions

Implementation Plan

Phase 1: Core Infrastructure

  1. Add Theme types to internal/tmux/theme.go
  2. Add ThemeConfig to internal/config/types.go
  3. Implement AssignTheme() function
  4. Add ApplyTheme() to Tmux wrapper

Phase 2: Session Integration

  1. Modify NewSession to accept SessionOptions
  2. Update session.Manager.Start() to apply themes
  3. Update cmd/mayor.go to theme Mayor session
  4. Update cmd/crew.go to theme crew sessions

Phase 3: Static Status Line

  1. Implement SetStatusFormat() for left side
  2. Apply to all session creation points
  3. Update witness.go, spawn.go, refinery, daemon

Phase 4: Dynamic Status Line

  1. Add gt status-line command (fast, tmux-callable)
  2. Implement mail count lookup (fast path)
  3. Implement gt issue set/clear for agents to update current issue
  4. Configure status-right to call gt status-line
  5. Add Mayor-specific status line variant

Phase 5: Commands & Polish

  1. Add gt theme command (view/set/apply)
  2. Add config file support for custom themes
  3. Documentation
  4. Update CLAUDE.md with gt issue set guidance for agents

File Changes

File Changes
internal/tmux/theme.go NEW - Theme types, palette, assignment
internal/tmux/tmux.go Add ApplyTheme, SetStatusFormat, SetDynamicStatus
internal/config/types.go Add ThemeConfig
internal/session/manager.go Use themed session creation
internal/cmd/mayor.go Apply Mayor theme + Mayor status format
internal/cmd/crew.go Apply rig theme to crew sessions
internal/cmd/witness.go Apply rig theme
internal/cmd/spawn.go Apply rig theme
internal/cmd/theme.go NEW - gt theme command
internal/cmd/statusline.go NEW - gt status-line (tmux-callable)
internal/cmd/issue.go NEW - gt issue set/clear
internal/daemon/lifecycle.go Apply rig theme
internal/refinery/manager.go Apply rig theme
CLAUDE.md (various) Document gt issue set for agents

Open Questions

  1. Should refinery/witness have distinct colors? RESOLVED

    • Answer: Same as rig polecats, role shown in status-left
  2. Color storage location?

    • Option A: In rig config.json (requires file write)
    • Option B: In beads (config-as-data approach from gt-vc1n)
    • Recommendation: Start with config.json for simplicity
  3. Hex colors vs tmux color names?

    • Hex: More precise, but some terminals don't support
    • Names: Limited palette, but universal support
    • Recommendation: Support both, default to hex with true-color fallback
  4. Status-line refresh frequency?

    • tmux calls #() commands every status-interval seconds (default 15)
    • Trade-off: Faster = more responsive, but more CPU
    • Recommendation: 5 seconds (set -g status-interval 5)

Success Criteria

  • Each rig has distinct status bar color
  • Users can identify rig at a glance
  • Status bar shows rig/worker/role clearly (left side)
  • Current issue displayed when agent sets it
  • Mail indicator shows unread count
  • Mayor shows aggregate stats (polecats, rigs)
  • Custom colors configurable per-rig
  • Works with existing sessions after gt theme apply
  • Agents can update issue via gt issue set