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:
@@ -504,6 +504,10 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
||||
_ = t.SetEnvironment(sessionID, "GT_RIG", r.Name)
|
||||
_ = t.SetEnvironment(sessionID, "GT_CREW", name)
|
||||
|
||||
// Apply theme
|
||||
theme := tmux.AssignTheme(r.Name)
|
||||
_ = t.ConfigureGasTownSession(sessionID, theme, r.Name, name, "crew")
|
||||
|
||||
// Start claude with skip permissions (crew workers are trusted like Mayor)
|
||||
// Use SendKeysDelayed to allow shell initialization after NewSession
|
||||
if err := t.SendKeysDelayed(sessionID, "claude --dangerously-skip-permissions", 200); err != nil {
|
||||
@@ -726,6 +730,10 @@ func runCrewRefresh(cmd *cobra.Command, args []string) error {
|
||||
_ = t.SetEnvironment(sessionID, "GT_RIG", r.Name)
|
||||
_ = t.SetEnvironment(sessionID, "GT_CREW", name)
|
||||
|
||||
// Apply theme
|
||||
theme := tmux.AssignTheme(r.Name)
|
||||
_ = t.ConfigureGasTownSession(sessionID, theme, r.Name, name, "crew")
|
||||
|
||||
// Start claude
|
||||
// Use SendKeysDelayed to allow shell initialization after NewSession
|
||||
if err := t.SendKeysDelayed(sessionID, "claude", 200); err != nil {
|
||||
@@ -776,6 +784,10 @@ func runCrewRestart(cmd *cobra.Command, args []string) error {
|
||||
t.SetEnvironment(sessionID, "GT_RIG", r.Name)
|
||||
t.SetEnvironment(sessionID, "GT_CREW", name)
|
||||
|
||||
// Apply theme
|
||||
theme := tmux.AssignTheme(r.Name)
|
||||
_ = t.ConfigureGasTownSession(sessionID, theme, r.Name, name, "crew")
|
||||
|
||||
// Start claude with skip permissions (crew workers are trusted)
|
||||
// Use SendKeysDelayed to allow shell initialization after NewSession
|
||||
if err := t.SendKeysDelayed(sessionID, "claude --dangerously-skip-permissions", 200); err != nil {
|
||||
|
||||
128
internal/cmd/issue.go
Normal file
128
internal/cmd/issue.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
)
|
||||
|
||||
var issueCmd = &cobra.Command{
|
||||
Use: "issue",
|
||||
Short: "Manage current issue for status line display",
|
||||
}
|
||||
|
||||
var issueSetCmd = &cobra.Command{
|
||||
Use: "set <issue-id>",
|
||||
Short: "Set the current issue (shown in tmux status line)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runIssueSet,
|
||||
}
|
||||
|
||||
var issueClearCmd = &cobra.Command{
|
||||
Use: "clear",
|
||||
Short: "Clear the current issue from status line",
|
||||
RunE: runIssueClear,
|
||||
}
|
||||
|
||||
var issueShowCmd = &cobra.Command{
|
||||
Use: "show",
|
||||
Short: "Show the current issue",
|
||||
RunE: runIssueShow,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(issueCmd)
|
||||
issueCmd.AddCommand(issueSetCmd)
|
||||
issueCmd.AddCommand(issueClearCmd)
|
||||
issueCmd.AddCommand(issueShowCmd)
|
||||
}
|
||||
|
||||
func runIssueSet(cmd *cobra.Command, args []string) error {
|
||||
issueID := args[0]
|
||||
|
||||
// Get current tmux session
|
||||
session := os.Getenv("TMUX_PANE")
|
||||
if session == "" {
|
||||
// Try to detect from GT env vars
|
||||
session = detectCurrentSession()
|
||||
if session == "" {
|
||||
return fmt.Errorf("not in a tmux session")
|
||||
}
|
||||
}
|
||||
|
||||
t := tmux.NewTmux()
|
||||
if err := t.SetEnvironment(session, "GT_ISSUE", issueID); err != nil {
|
||||
return fmt.Errorf("setting issue: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Issue set to: %s\n", issueID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runIssueClear(cmd *cobra.Command, args []string) error {
|
||||
session := os.Getenv("TMUX_PANE")
|
||||
if session == "" {
|
||||
session = detectCurrentSession()
|
||||
if session == "" {
|
||||
return fmt.Errorf("not in a tmux session")
|
||||
}
|
||||
}
|
||||
|
||||
t := tmux.NewTmux()
|
||||
// Set to empty string to clear
|
||||
if err := t.SetEnvironment(session, "GT_ISSUE", ""); err != nil {
|
||||
return fmt.Errorf("clearing issue: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Issue cleared")
|
||||
return nil
|
||||
}
|
||||
|
||||
func runIssueShow(cmd *cobra.Command, args []string) error {
|
||||
session := os.Getenv("TMUX_PANE")
|
||||
if session == "" {
|
||||
session = detectCurrentSession()
|
||||
if session == "" {
|
||||
return fmt.Errorf("not in a tmux session")
|
||||
}
|
||||
}
|
||||
|
||||
t := tmux.NewTmux()
|
||||
issue, err := t.GetEnvironment(session, "GT_ISSUE")
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting issue: %w", err)
|
||||
}
|
||||
|
||||
if issue == "" {
|
||||
fmt.Println("No issue set")
|
||||
} else {
|
||||
fmt.Printf("Current issue: %s\n", issue)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// detectCurrentSession tries to find the tmux session name from env.
|
||||
func detectCurrentSession() string {
|
||||
// Try to build session name from GT env vars
|
||||
rig := os.Getenv("GT_RIG")
|
||||
polecat := os.Getenv("GT_POLECAT")
|
||||
crew := os.Getenv("GT_CREW")
|
||||
|
||||
if rig != "" {
|
||||
if polecat != "" {
|
||||
return fmt.Sprintf("gt-%s-%s", rig, polecat)
|
||||
}
|
||||
if crew != "" {
|
||||
return fmt.Sprintf("gt-%s-crew-%s", rig, crew)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we're mayor
|
||||
if os.Getenv("GT_ROLE") == "mayor" {
|
||||
return "gt-mayor"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -122,6 +122,10 @@ func startMayorSession(t *tmux.Tmux) error {
|
||||
// Set environment
|
||||
_ = t.SetEnvironment(MayorSessionName, "GT_ROLE", "mayor")
|
||||
|
||||
// Apply Mayor theme
|
||||
theme := tmux.MayorTheme()
|
||||
_ = t.ConfigureGasTownSession(MayorSessionName, theme, "", "Mayor", "coordinator")
|
||||
|
||||
// Launch Claude in a respawn loop - session survives restarts
|
||||
// The startup hook handles 'gt prime' automatically
|
||||
// Use SendKeysDelayed to allow shell initialization after NewSession
|
||||
|
||||
145
internal/cmd/statusline.go
Normal file
145
internal/cmd/statusline.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/mail"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
)
|
||||
|
||||
var (
|
||||
statusLineSession string
|
||||
)
|
||||
|
||||
var statusLineCmd = &cobra.Command{
|
||||
Use: "status-line",
|
||||
Short: "Output status line content for tmux (internal use)",
|
||||
Hidden: true, // Internal command called by tmux
|
||||
RunE: runStatusLine,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(statusLineCmd)
|
||||
statusLineCmd.Flags().StringVar(&statusLineSession, "session", "", "Tmux session name")
|
||||
}
|
||||
|
||||
func runStatusLine(cmd *cobra.Command, args []string) error {
|
||||
t := tmux.NewTmux()
|
||||
|
||||
// Get session environment
|
||||
var rigName, polecat, crew, issue, role string
|
||||
|
||||
if statusLineSession != "" {
|
||||
rigName, _ = t.GetEnvironment(statusLineSession, "GT_RIG")
|
||||
polecat, _ = t.GetEnvironment(statusLineSession, "GT_POLECAT")
|
||||
crew, _ = t.GetEnvironment(statusLineSession, "GT_CREW")
|
||||
issue, _ = t.GetEnvironment(statusLineSession, "GT_ISSUE")
|
||||
role, _ = t.GetEnvironment(statusLineSession, "GT_ROLE")
|
||||
} else {
|
||||
// Fallback to process environment
|
||||
rigName = os.Getenv("GT_RIG")
|
||||
polecat = os.Getenv("GT_POLECAT")
|
||||
crew = os.Getenv("GT_CREW")
|
||||
issue = os.Getenv("GT_ISSUE")
|
||||
role = os.Getenv("GT_ROLE")
|
||||
}
|
||||
|
||||
// Determine identity and output based on role
|
||||
if role == "mayor" || statusLineSession == "gt-mayor" {
|
||||
return runMayorStatusLine(t)
|
||||
}
|
||||
|
||||
// Build mail identity
|
||||
var identity string
|
||||
if rigName != "" {
|
||||
if polecat != "" {
|
||||
identity = fmt.Sprintf("%s/%s", rigName, polecat)
|
||||
} else if crew != "" {
|
||||
identity = fmt.Sprintf("%s/%s", rigName, crew)
|
||||
}
|
||||
}
|
||||
|
||||
// Build status parts
|
||||
var parts []string
|
||||
|
||||
// Current issue
|
||||
if issue != "" {
|
||||
parts = append(parts, issue)
|
||||
}
|
||||
|
||||
// Mail count
|
||||
if identity != "" {
|
||||
unread := getUnreadMailCount(identity)
|
||||
if unread > 0 {
|
||||
parts = append(parts, fmt.Sprintf("\U0001F4EC %d", unread)) // mail emoji
|
||||
}
|
||||
}
|
||||
|
||||
// Output
|
||||
if len(parts) > 0 {
|
||||
fmt.Print(strings.Join(parts, " | ") + " |")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMayorStatusLine(t *tmux.Tmux) error {
|
||||
// Count active sessions by listing tmux sessions
|
||||
sessions, err := t.ListSessions()
|
||||
if err != nil {
|
||||
return nil // Silent fail
|
||||
}
|
||||
|
||||
// Count gt-* sessions (polecats) and rigs
|
||||
polecatCount := 0
|
||||
rigs := make(map[string]bool)
|
||||
for _, s := range sessions {
|
||||
if strings.HasPrefix(s, "gt-") && s != "gt-mayor" {
|
||||
polecatCount++
|
||||
// Extract rig name: gt-<rig>-<worker>
|
||||
parts := strings.SplitN(s, "-", 3)
|
||||
if len(parts) >= 2 {
|
||||
rigs[parts[1]] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
rigCount := len(rigs)
|
||||
|
||||
// Get mayor mail
|
||||
unread := getUnreadMailCount("mayor/")
|
||||
|
||||
// Build status
|
||||
var parts []string
|
||||
parts = append(parts, fmt.Sprintf("%d polecats", polecatCount))
|
||||
parts = append(parts, fmt.Sprintf("%d rigs", rigCount))
|
||||
if unread > 0 {
|
||||
parts = append(parts, fmt.Sprintf("\U0001F4EC %d", unread))
|
||||
}
|
||||
|
||||
fmt.Print(strings.Join(parts, " | ") + " |")
|
||||
return nil
|
||||
}
|
||||
|
||||
// getUnreadMailCount returns unread mail count for an identity.
|
||||
// Fast path - returns 0 on any error.
|
||||
func getUnreadMailCount(identity string) int {
|
||||
// Find workspace
|
||||
workDir, err := findBeadsWorkDir()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Create mailbox using beads
|
||||
mailbox := mail.NewMailboxBeads(identity, workDir)
|
||||
|
||||
// Get count
|
||||
_, unread, err := mailbox.Count()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return unread
|
||||
}
|
||||
193
internal/cmd/theme.go
Normal file
193
internal/cmd/theme.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
)
|
||||
|
||||
var (
|
||||
themeListFlag bool
|
||||
themeApplyFlag bool
|
||||
)
|
||||
|
||||
var themeCmd = &cobra.Command{
|
||||
Use: "theme [name]",
|
||||
Short: "View or set tmux theme for the current rig",
|
||||
Long: `Manage tmux status bar themes for Gas Town sessions.
|
||||
|
||||
Without arguments, shows the current theme assignment.
|
||||
With a name argument, sets the theme for this rig.
|
||||
|
||||
Examples:
|
||||
gt theme # Show current theme
|
||||
gt theme --list # List available themes
|
||||
gt theme forest # Set theme to 'forest'
|
||||
gt theme apply # Apply theme to all running sessions in this rig`,
|
||||
RunE: runTheme,
|
||||
}
|
||||
|
||||
var themeApplyCmd = &cobra.Command{
|
||||
Use: "apply",
|
||||
Short: "Apply theme to all running sessions in this rig",
|
||||
RunE: runThemeApply,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(themeCmd)
|
||||
themeCmd.AddCommand(themeApplyCmd)
|
||||
themeCmd.Flags().BoolVarP(&themeListFlag, "list", "l", false, "List available themes")
|
||||
}
|
||||
|
||||
func runTheme(cmd *cobra.Command, args []string) error {
|
||||
// List mode
|
||||
if themeListFlag {
|
||||
fmt.Println("Available themes:")
|
||||
for _, name := range tmux.ListThemeNames() {
|
||||
theme := tmux.GetThemeByName(name)
|
||||
fmt.Printf(" %-10s %s\n", name, theme.Style())
|
||||
}
|
||||
// Also show Mayor theme
|
||||
mayor := tmux.MayorTheme()
|
||||
fmt.Printf(" %-10s %s (Mayor only)\n", mayor.Name, mayor.Style())
|
||||
return nil
|
||||
}
|
||||
|
||||
// Determine current rig
|
||||
rigName := detectCurrentRig()
|
||||
if rigName == "" {
|
||||
rigName = "unknown"
|
||||
}
|
||||
|
||||
// Show current theme assignment
|
||||
if len(args) == 0 {
|
||||
theme := tmux.AssignTheme(rigName)
|
||||
fmt.Printf("Rig: %s\n", rigName)
|
||||
fmt.Printf("Theme: %s (%s)\n", theme.Name, theme.Style())
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set theme
|
||||
themeName := args[0]
|
||||
theme := tmux.GetThemeByName(themeName)
|
||||
if theme == nil {
|
||||
return fmt.Errorf("unknown theme: %s (use --list to see available themes)", themeName)
|
||||
}
|
||||
|
||||
// TODO: Save to rig config.json
|
||||
fmt.Printf("Theme '%s' selected for rig '%s'\n", themeName, rigName)
|
||||
fmt.Println("Note: Run 'gt theme apply' to apply to running sessions")
|
||||
fmt.Println("(Persistent config not yet implemented)")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runThemeApply(cmd *cobra.Command, args []string) error {
|
||||
t := tmux.NewTmux()
|
||||
|
||||
// Get all sessions
|
||||
sessions, err := t.ListSessions()
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing sessions: %w", err)
|
||||
}
|
||||
|
||||
// Determine current rig
|
||||
rigName := detectCurrentRig()
|
||||
|
||||
// Apply to matching sessions
|
||||
applied := 0
|
||||
for _, session := range sessions {
|
||||
if !strings.HasPrefix(session, "gt-") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine theme and identity for this session
|
||||
var theme tmux.Theme
|
||||
var rig, worker, role string
|
||||
|
||||
if session == "gt-mayor" {
|
||||
theme = tmux.MayorTheme()
|
||||
worker = "Mayor"
|
||||
role = "coordinator"
|
||||
} else {
|
||||
// Parse session name: gt-<rig>-<worker> or gt-<rig>-crew-<name>
|
||||
parts := strings.SplitN(session, "-", 3)
|
||||
if len(parts) < 3 {
|
||||
continue
|
||||
}
|
||||
rig = parts[1]
|
||||
|
||||
// Skip if not matching current rig (if we know it)
|
||||
if rigName != "" && rig != rigName {
|
||||
continue
|
||||
}
|
||||
|
||||
workerPart := parts[2]
|
||||
if strings.HasPrefix(workerPart, "crew-") {
|
||||
worker = strings.TrimPrefix(workerPart, "crew-")
|
||||
role = "crew"
|
||||
} else {
|
||||
worker = workerPart
|
||||
role = "polecat"
|
||||
}
|
||||
|
||||
theme = tmux.AssignTheme(rig)
|
||||
}
|
||||
|
||||
// Apply theme and status format
|
||||
if err := t.ApplyTheme(session, theme); err != nil {
|
||||
fmt.Printf(" %s: failed (%v)\n", session, err)
|
||||
continue
|
||||
}
|
||||
if err := t.SetStatusFormat(session, rig, worker, role); err != nil {
|
||||
fmt.Printf(" %s: failed to set format (%v)\n", session, err)
|
||||
continue
|
||||
}
|
||||
if err := t.SetDynamicStatus(session); err != nil {
|
||||
fmt.Printf(" %s: failed to set dynamic status (%v)\n", session, err)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf(" %s: applied %s theme\n", session, theme.Name)
|
||||
applied++
|
||||
}
|
||||
|
||||
if applied == 0 {
|
||||
fmt.Println("No matching sessions found")
|
||||
} else {
|
||||
fmt.Printf("\nApplied theme to %d session(s)\n", applied)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// detectCurrentRig determines the rig from environment or cwd.
|
||||
func detectCurrentRig() string {
|
||||
// Try environment first
|
||||
if rig := detectCurrentSession(); rig != "" {
|
||||
// Extract rig from session name
|
||||
parts := strings.SplitN(rig, "-", 3)
|
||||
if len(parts) >= 2 && parts[0] == "gt" {
|
||||
return parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
// Try to detect from cwd
|
||||
cwd, err := findBeadsWorkDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Extract rig name from path
|
||||
// Typical paths: /Users/stevey/gt/<rig>/...
|
||||
parts := strings.Split(cwd, "/")
|
||||
for i, p := range parts {
|
||||
if p == "gt" && i+1 < len(parts) {
|
||||
return parts[i+1]
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -266,6 +266,10 @@ func runWitnessAttach(cmd *cobra.Command, args []string) error {
|
||||
t.SetEnvironment(sessionName, "GT_ROLE", "witness")
|
||||
t.SetEnvironment(sessionName, "GT_RIG", rigName)
|
||||
|
||||
// Apply theme (same as rig polecats)
|
||||
theme := tmux.AssignTheme(rigName)
|
||||
_ = t.ConfigureGasTownSession(sessionName, theme, rigName, "witness", "witness")
|
||||
|
||||
// Launch Claude in a respawn loop
|
||||
loopCmd := `while true; do echo "👁️ Starting Witness for ` + rigName + `..."; claude --dangerously-skip-permissions; echo ""; echo "Witness exited. Restarting in 2s... (Ctrl-C to stop)"; sleep 2; done`
|
||||
if err := t.SendKeysDelayed(sessionName, loopCmd, 200); err != nil {
|
||||
|
||||
@@ -52,6 +52,23 @@ type RigConfig struct {
|
||||
Type string `json:"type"` // "rig"
|
||||
Version int `json:"version"` // schema version
|
||||
MergeQueue *MergeQueueConfig `json:"merge_queue,omitempty"` // merge queue settings
|
||||
Theme *ThemeConfig `json:"theme,omitempty"` // tmux theme settings
|
||||
}
|
||||
|
||||
// ThemeConfig represents tmux theme settings for a rig.
|
||||
type ThemeConfig struct {
|
||||
// Name picks from the default palette (e.g., "ocean", "forest").
|
||||
// If empty, a theme is auto-assigned based on rig name.
|
||||
Name string `json:"name,omitempty"`
|
||||
|
||||
// Custom overrides the palette with specific colors.
|
||||
Custom *CustomTheme `json:"custom,omitempty"`
|
||||
}
|
||||
|
||||
// CustomTheme allows specifying exact colors for the status bar.
|
||||
type CustomTheme struct {
|
||||
BG string `json:"bg"` // Background color (hex or tmux color name)
|
||||
FG string `json:"fg"` // Foreground color (hex or tmux color name)
|
||||
}
|
||||
|
||||
// MergeQueueConfig represents merge queue settings for a rig.
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
)
|
||||
|
||||
// BeadsMessage represents a message from beads mail.
|
||||
@@ -177,13 +179,14 @@ func (d *Daemon) identityToSession(identity string) string {
|
||||
func (d *Daemon) restartSession(sessionName, identity string) error {
|
||||
// Determine working directory and startup command based on agent type
|
||||
var workDir, startCmd string
|
||||
var rigName string
|
||||
|
||||
if identity == "mayor" {
|
||||
workDir = d.config.TownRoot
|
||||
startCmd = "exec claude --dangerously-skip-permissions"
|
||||
} else if strings.HasSuffix(identity, "-witness") {
|
||||
// Extract rig name: <rig>-witness → <rig>
|
||||
rigName := strings.TrimSuffix(identity, "-witness")
|
||||
rigName = strings.TrimSuffix(identity, "-witness")
|
||||
workDir = d.config.TownRoot + "/" + rigName
|
||||
startCmd = "exec claude --dangerously-skip-permissions"
|
||||
} else {
|
||||
@@ -198,6 +201,15 @@ func (d *Daemon) restartSession(sessionName, identity string) error {
|
||||
// Set environment
|
||||
_ = d.tmux.SetEnvironment(sessionName, "GT_ROLE", identity)
|
||||
|
||||
// Apply theme
|
||||
if identity == "mayor" {
|
||||
theme := tmux.MayorTheme()
|
||||
_ = d.tmux.ConfigureGasTownSession(sessionName, theme, "", "Mayor", "coordinator")
|
||||
} else if rigName != "" {
|
||||
theme := tmux.AssignTheme(rigName)
|
||||
_ = d.tmux.ConfigureGasTownSession(sessionName, theme, rigName, "witness", "witness")
|
||||
}
|
||||
|
||||
// Send startup command
|
||||
if err := d.tmux.SendKeys(sessionName, startCmd); err != nil {
|
||||
return fmt.Errorf("sending startup command: %w", err)
|
||||
|
||||
@@ -171,6 +171,10 @@ func (m *Manager) Start(foreground bool) error {
|
||||
_ = t.SetEnvironment(sessionID, "GT_RIG", m.rig.Name)
|
||||
_ = t.SetEnvironment(sessionID, "GT_REFINERY", "1")
|
||||
|
||||
// Apply theme (same as rig polecats)
|
||||
theme := tmux.AssignTheme(m.rig.Name)
|
||||
_ = t.ConfigureGasTownSession(sessionID, theme, m.rig.Name, "refinery", "refinery")
|
||||
|
||||
// Send the command to start refinery in foreground mode
|
||||
// The foreground mode handles state updates and the processing loop
|
||||
command := fmt.Sprintf("gt refinery start %s --foreground", m.rig.Name)
|
||||
|
||||
@@ -132,6 +132,10 @@ func (m *Manager) Start(polecat string, opts StartOptions) error {
|
||||
_ = m.tmux.SetEnvironment(sessionID, "BEADS_NO_DAEMON", "1")
|
||||
_ = m.tmux.SetEnvironment(sessionID, "BEADS_AGENT_NAME", fmt.Sprintf("%s/%s", m.rig.Name, polecat))
|
||||
|
||||
// Apply theme
|
||||
theme := tmux.AssignTheme(m.rig.Name)
|
||||
_ = m.tmux.ConfigureGasTownSession(sessionID, theme, m.rig.Name, polecat, "polecat")
|
||||
|
||||
// Send initial command
|
||||
command := opts.Command
|
||||
if command == "" {
|
||||
|
||||
77
internal/tmux/theme.go
Normal file
77
internal/tmux/theme.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Package tmux provides theme support for Gas Town tmux sessions.
|
||||
package tmux
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
)
|
||||
|
||||
// Theme represents a tmux status bar color scheme.
|
||||
type Theme struct {
|
||||
Name string // Human-readable name
|
||||
BG string // Background color (hex or tmux color name)
|
||||
FG string // Foreground color (hex or tmux color name)
|
||||
}
|
||||
|
||||
// DefaultPalette is the curated set of distinct, professional color themes.
|
||||
// Each theme has good contrast and is visually distinct from others.
|
||||
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
|
||||
}
|
||||
|
||||
// MayorTheme returns the special theme for the Mayor session.
|
||||
// Gold/dark to distinguish it from rig themes.
|
||||
func MayorTheme() Theme {
|
||||
return Theme{Name: "mayor", BG: "#3d3200", FG: "#ffd700"}
|
||||
}
|
||||
|
||||
// GetThemeByName finds a theme by name from the default palette.
|
||||
// Returns nil if not found.
|
||||
func GetThemeByName(name string) *Theme {
|
||||
for _, t := range DefaultPalette {
|
||||
if t.Name == name {
|
||||
return &t
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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) Theme {
|
||||
return AssignThemeFromPalette(rigName, DefaultPalette)
|
||||
}
|
||||
|
||||
// AssignThemeFromPalette picks a theme using a custom palette.
|
||||
func AssignThemeFromPalette(rigName string, palette []Theme) Theme {
|
||||
if len(palette) == 0 {
|
||||
return DefaultPalette[0]
|
||||
}
|
||||
h := fnv.New32a()
|
||||
h.Write([]byte(rigName))
|
||||
idx := int(h.Sum32()) % len(palette)
|
||||
return palette[idx]
|
||||
}
|
||||
|
||||
// Style returns the tmux status-style string for this theme.
|
||||
func (t Theme) Style() string {
|
||||
return fmt.Sprintf("bg=%s,fg=%s", t.BG, t.FG)
|
||||
}
|
||||
|
||||
// ListThemeNames returns the names of all themes in the default palette.
|
||||
func ListThemeNames() []string {
|
||||
names := make([]string, len(DefaultPalette))
|
||||
for i, t := range DefaultPalette {
|
||||
names[i] = t.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
127
internal/tmux/theme_test.go
Normal file
127
internal/tmux/theme_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package tmux
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAssignTheme_Deterministic(t *testing.T) {
|
||||
// Same rig name should always get same theme
|
||||
theme1 := AssignTheme("gastown")
|
||||
theme2 := AssignTheme("gastown")
|
||||
|
||||
if theme1.Name != theme2.Name {
|
||||
t.Errorf("AssignTheme not deterministic: got %s and %s for same input", theme1.Name, theme2.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignTheme_Distribution(t *testing.T) {
|
||||
// Different rig names should (mostly) get different themes
|
||||
// With 10 themes and good hashing, collisions should be rare
|
||||
rigs := []string{"gastown", "beads", "myproject", "frontend", "backend", "api", "web", "mobile"}
|
||||
themes := make(map[string]int)
|
||||
|
||||
for _, rig := range rigs {
|
||||
theme := AssignTheme(rig)
|
||||
themes[theme.Name]++
|
||||
}
|
||||
|
||||
// We should have at least 4 different themes for 8 rigs
|
||||
if len(themes) < 4 {
|
||||
t.Errorf("Poor distribution: only %d different themes for %d rigs", len(themes), len(rigs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetThemeByName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
want bool
|
||||
}{
|
||||
{"ocean", true},
|
||||
{"forest", true},
|
||||
{"nonexistent", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
theme := GetThemeByName(tt.name)
|
||||
got := theme != nil
|
||||
if got != tt.want {
|
||||
t.Errorf("GetThemeByName(%q) = %v, want %v", tt.name, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestThemeStyle(t *testing.T) {
|
||||
theme := Theme{Name: "test", BG: "#1e3a5f", FG: "#e0e0e0"}
|
||||
want := "bg=#1e3a5f,fg=#e0e0e0"
|
||||
got := theme.Style()
|
||||
|
||||
if got != want {
|
||||
t.Errorf("Theme.Style() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMayorTheme(t *testing.T) {
|
||||
theme := MayorTheme()
|
||||
|
||||
if theme.Name != "mayor" {
|
||||
t.Errorf("MayorTheme().Name = %q, want %q", theme.Name, "mayor")
|
||||
}
|
||||
|
||||
// Mayor should have distinct gold/dark colors
|
||||
if theme.BG == "" || theme.FG == "" {
|
||||
t.Error("MayorTheme() has empty colors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListThemeNames(t *testing.T) {
|
||||
names := ListThemeNames()
|
||||
|
||||
if len(names) != len(DefaultPalette) {
|
||||
t.Errorf("ListThemeNames() returned %d names, want %d", len(names), len(DefaultPalette))
|
||||
}
|
||||
|
||||
// Check that known themes are in the list
|
||||
found := make(map[string]bool)
|
||||
for _, name := range names {
|
||||
found[name] = true
|
||||
}
|
||||
|
||||
for _, want := range []string{"ocean", "forest", "rust"} {
|
||||
if !found[want] {
|
||||
t.Errorf("ListThemeNames() missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPaletteHasDistinctColors(t *testing.T) {
|
||||
// Ensure no duplicate colors in the palette
|
||||
bgColors := make(map[string]string)
|
||||
for _, theme := range DefaultPalette {
|
||||
if existing, ok := bgColors[theme.BG]; ok {
|
||||
t.Errorf("Duplicate BG color %s used by %s and %s", theme.BG, existing, theme.Name)
|
||||
}
|
||||
bgColors[theme.BG] = theme.Name
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignThemeFromPalette_EmptyPalette(t *testing.T) {
|
||||
// Empty palette should return first default theme
|
||||
theme := AssignThemeFromPalette("test", []Theme{})
|
||||
if theme.Name != DefaultPalette[0].Name {
|
||||
t.Errorf("AssignThemeFromPalette with empty palette = %q, want %q", theme.Name, DefaultPalette[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignThemeFromPalette_CustomPalette(t *testing.T) {
|
||||
custom := []Theme{
|
||||
{Name: "custom1", BG: "#111", FG: "#fff"},
|
||||
{Name: "custom2", BG: "#222", FG: "#fff"},
|
||||
}
|
||||
|
||||
// Should only return themes from custom palette
|
||||
theme := AssignThemeFromPalette("test", custom)
|
||||
if theme.Name != "custom1" && theme.Name != "custom2" {
|
||||
t.Errorf("AssignThemeFromPalette returned %q, want one of custom themes", theme.Name)
|
||||
}
|
||||
}
|
||||
@@ -269,3 +269,62 @@ func (t *Tmux) GetSessionInfo(name string) (*SessionInfo, error) {
|
||||
Attached: parts[3] == "1",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ApplyTheme sets the status bar style for a session.
|
||||
func (t *Tmux) ApplyTheme(session string, theme Theme) error {
|
||||
_, err := t.run("set-option", "-t", session, "status-style", theme.Style())
|
||||
return err
|
||||
}
|
||||
|
||||
// SetStatusFormat configures the left side of the status bar.
|
||||
// Shows: [rig/worker] role
|
||||
func (t *Tmux) SetStatusFormat(session, rig, worker, role string) error {
|
||||
// Format: [gastown/Rictus] polecat
|
||||
var left string
|
||||
if rig == "" {
|
||||
// Mayor or other top-level agent
|
||||
left = fmt.Sprintf("[%s] %s ", worker, role)
|
||||
} else {
|
||||
left = fmt.Sprintf("[%s/%s] %s ", rig, worker, role)
|
||||
}
|
||||
|
||||
// Allow enough room for the identity
|
||||
if _, err := t.run("set-option", "-t", session, "status-left-length", "40"); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := t.run("set-option", "-t", session, "status-left", left)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetDynamicStatus configures the right side with dynamic content.
|
||||
// Uses a shell command that tmux calls periodically to get current status.
|
||||
func (t *Tmux) SetDynamicStatus(session string) error {
|
||||
// tmux calls this command every status-interval seconds
|
||||
// gt status-line reads env vars and mail to build the status
|
||||
right := fmt.Sprintf(`#(gt status-line --session=%s 2>/dev/null) %%H:%%M`, session)
|
||||
|
||||
if _, err := t.run("set-option", "-t", session, "status-right-length", "50"); err != nil {
|
||||
return err
|
||||
}
|
||||
// Set faster refresh for more responsive status
|
||||
if _, err := t.run("set-option", "-t", session, "status-interval", "5"); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := t.run("set-option", "-t", session, "status-right", right)
|
||||
return err
|
||||
}
|
||||
|
||||
// ConfigureGasTownSession applies full Gas Town theming to a session.
|
||||
// This is a convenience method that applies theme, status format, and dynamic status.
|
||||
func (t *Tmux) ConfigureGasTownSession(session string, theme Theme, rig, worker, role string) error {
|
||||
if err := t.ApplyTheme(session, theme); err != nil {
|
||||
return fmt.Errorf("applying theme: %w", err)
|
||||
}
|
||||
if err := t.SetStatusFormat(session, rig, worker, role); err != nil {
|
||||
return fmt.Errorf("setting status format: %w", err)
|
||||
}
|
||||
if err := t.SetDynamicStatus(session); err != nil {
|
||||
return fmt.Errorf("setting dynamic status: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user