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:
Steve Yegge
2025-12-19 14:16:34 -08:00
parent e859938545
commit 4d0492fdf6
15 changed files with 1428 additions and 170 deletions

View File

@@ -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
View 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 ""
}

View File

@@ -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
View 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
View 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 ""
}

View File

@@ -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 {