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>
This commit is contained in:
472
docs/design/tmux-theming.md
Normal file
472
docs/design/tmux-theming.md
Normal file
@@ -0,0 +1,472 @@
|
||||
# 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):
|
||||
|
||||
```go
|
||||
// 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`:
|
||||
|
||||
```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`:
|
||||
|
||||
```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:
|
||||
|
||||
```go
|
||||
// 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:
|
||||
|
||||
```go
|
||||
// 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)
|
||||
|
||||
```go
|
||||
// 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:
|
||||
|
||||
```go
|
||||
// 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:
|
||||
|
||||
```go
|
||||
// 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:
|
||||
|
||||
```bash
|
||||
# When starting work on an issue
|
||||
gt issue set gt-70b3
|
||||
|
||||
# When done
|
||||
gt issue clear
|
||||
```
|
||||
|
||||
Implementation:
|
||||
|
||||
```go
|
||||
// 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
|
||||
```
|
||||
|
||||
```go
|
||||
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)
|
||||
|
||||
```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)
|
||||
|
||||
```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
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```bash
|
||||
# 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
|
||||
5. Modify `NewSession` to accept SessionOptions
|
||||
6. Update session.Manager.Start() to apply themes
|
||||
7. Update cmd/mayor.go to theme Mayor session
|
||||
8. Update cmd/crew.go to theme crew sessions
|
||||
|
||||
### Phase 3: Static Status Line
|
||||
9. Implement SetStatusFormat() for left side
|
||||
10. Apply to all session creation points
|
||||
11. Update witness.go, spawn.go, refinery, daemon
|
||||
|
||||
### Phase 4: Dynamic Status Line
|
||||
12. Add `gt status-line` command (fast, tmux-callable)
|
||||
13. Implement mail count lookup (fast path)
|
||||
14. Implement `gt issue set/clear` for agents to update current issue
|
||||
15. Configure status-right to call `gt status-line`
|
||||
16. Add Mayor-specific status line variant
|
||||
|
||||
### Phase 5: Commands & Polish
|
||||
17. Add `gt theme` command (view/set/apply)
|
||||
18. Add config file support for custom themes
|
||||
19. Documentation
|
||||
20. 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`
|
||||
Reference in New Issue
Block a user