diff --git a/internal/session/storage.go b/internal/session/storage.go index d793355f..4e4d0e00 100644 --- a/internal/session/storage.go +++ b/internal/session/storage.go @@ -728,7 +728,7 @@ func (s *Storage) convertToInstances(data *StorageData) ([]*Instance, []*GroupDa // Pass instance ID for activity hooks (enables real-time status updates) tmuxSess.InstanceID = instData.ID tmuxSess.SetInjectStatusLine(GetTmuxSettings().GetInjectStatusLine()) - // Note: EnableMouseMode is now deferred to EnsureConfigured() + // Note: EnableMouseMode and ConfigureStatusBar are deferred to EnsureConfigured() // Called automatically when user attaches to session } @@ -792,6 +792,12 @@ func (s *Storage) convertToInstances(data *StorageData) ([]*Instance, []*GroupDa }) } + // Set tmux option overrides so EnsureConfigured/ConfigureStatusBar + // respects user-defined keys (e.g. status = "2" for multi-line bar). + if tmuxSess != nil { + tmuxSess.OptionOverrides = inst.buildTmuxOptionOverrides() + } + // PERFORMANCE: Skip UpdateStatus at load time - use cached status from SQLite // The background worker will update status on first tick. // This saves one subprocess call per session at startup. diff --git a/internal/session/userconfig.go b/internal/session/userconfig.go index 1c15cd6b..18cdd1e2 100644 --- a/internal/session/userconfig.go +++ b/internal/session/userconfig.go @@ -1730,7 +1730,12 @@ auto_cleanup = true # Default: true (agent-deck injects its own status bar with session info) # inject_status_line = false # Override tmux options applied to every session (applied after defaults) +# Options matching agent-deck's managed keys (status, status-style, +# status-left-length, status-right, status-right-length) will cause agent-deck +# to skip its default for that key, letting your value take full effect. # options = { "allow-passthrough" = "all", "history-limit" = "50000" } +# Example: keep agent-deck notifications but use a 2-line status bar +# options = { "status" = "2" } # ============================================================================ # MCP Server Definitions diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index 45c7f718..23c9dbce 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -1347,29 +1347,56 @@ func (s *Session) IsPaneDead() bool { return strings.TrimSpace(string(out)) == "1" } -// ConfigureStatusBar sets up the tmux status bar with session info -// Shows: notification bar on left (managed by NotificationManager), session info on right -// NOTE: status-left is reserved for the notification bar showing waiting sessions -// This function only configures status-right to avoid overwriting notification bar -func (s *Session) ConfigureStatusBar() { - // Skip status bar injection if disabled by user config +// buildStatusBarArgs returns the tmux command args for configuring the status bar. +// Returns nil if status bar injection is disabled. +// Skips any option key that exists in s.OptionOverrides — user-defined options take precedence. +func (s *Session) buildStatusBarArgs() []string { if !s.injectStatusLine { - return + return nil } themeStyle := currentTmuxThemeStyle() rightStatus := s.themedStatusRight(themeStyle) - // PERFORMANCE: Batch all 5 status bar options into single subprocess call - // Uses tmux command chaining with \; separator (73% reduction in subprocess calls) - // Before: 5 separate exec.Command calls = 5 subprocess spawns - // After: 1 exec.Command call = 1 subprocess spawn - cmd := exec.Command("tmux", - "set-option", "-t", s.Name, "status", "on", ";", - "set-option", "-t", s.Name, "status-style", themeStyle.statusStyle, ";", - "set-option", "-t", s.Name, "status-left-length", "120", ";", - "set-option", "-t", s.Name, "status-right", rightStatus, ";", - "set-option", "-t", s.Name, "status-right-length", "80") - _ = cmd.Run() + // Managed defaults — each can be skipped if user defined it in [tmux] options + type option struct { + key string + value string + } + defaults := []option{ + {"status", "on"}, + {"status-style", themeStyle.statusStyle}, + {"status-left-length", "120"}, + {"status-right", rightStatus}, + {"status-right-length", "80"}, + } + + var args []string + for _, opt := range defaults { + if _, overridden := s.OptionOverrides[opt.key]; overridden { + continue + } + if len(args) > 0 { + args = append(args, ";") + } + args = append(args, "set-option", "-t", s.Name, opt.key, opt.value) + } + + if len(args) == 0 { + return nil + } + return args +} + +// ConfigureStatusBar sets up the tmux status bar with session info. +// Shows: notification bar on left (managed by NotificationManager), session info on right. +// NOTE: status-left is reserved for the notification bar showing waiting sessions. +// Options defined in [tmux] options are respected — agent-deck skips those keys. +func (s *Session) ConfigureStatusBar() { + args := s.buildStatusBarArgs() + if args == nil { + return + } + _ = exec.Command("tmux", args...).Run() } // EnableMouseMode enables mouse scrolling, clipboard integration, and optimal settings diff --git a/internal/tmux/tmux_test.go b/internal/tmux/tmux_test.go index 1ef8f4d8..15267bad 100644 --- a/internal/tmux/tmux_test.go +++ b/internal/tmux/tmux_test.go @@ -2552,3 +2552,130 @@ func TestParseWindowCacheEmptyInput(t *testing.T) { assert.Empty(t, sessionCache) assert.Empty(t, windowCache) } + +func TestBuildStatusBarArgs(t *testing.T) { + tests := []struct { + name string + sessionName string + displayName string + workDir string + optionOverrides map[string]string + wantKeys []string // keys that SHOULD appear in args + skipKeys []string // keys that should NOT appear in args + }{ + { + name: "no overrides - all defaults applied", + sessionName: "test-sess", + displayName: "my-project", + workDir: "/home/user/my-project", + optionOverrides: nil, + wantKeys: []string{"status", "status-style", "status-left-length", "status-right", "status-right-length"}, + skipKeys: nil, + }, + { + name: "empty overrides - all defaults applied", + sessionName: "test-sess", + displayName: "my-project", + workDir: "/home/user/my-project", + optionOverrides: map[string]string{}, + wantKeys: []string{"status", "status-style", "status-left-length", "status-right", "status-right-length"}, + skipKeys: nil, + }, + { + name: "status overridden - status skipped", + sessionName: "test-sess", + displayName: "my-project", + workDir: "/home/user/my-project", + optionOverrides: map[string]string{"status": "2"}, + wantKeys: []string{"status-style", "status-left-length", "status-right", "status-right-length"}, + skipKeys: []string{"status"}, + }, + { + name: "status-style overridden - status-style skipped", + sessionName: "test-sess", + displayName: "my-project", + workDir: "/home/user/my-project", + optionOverrides: map[string]string{"status-style": "bg=#000000"}, + wantKeys: []string{"status", "status-left-length", "status-right", "status-right-length"}, + skipKeys: []string{"status-style"}, + }, + { + name: "multiple overrides - multiple skipped", + sessionName: "test-sess", + displayName: "my-project", + workDir: "/home/user/my-project", + optionOverrides: map[string]string{"status": "2", "status-style": "bg=#000", "status-right-length": "100"}, + wantKeys: []string{"status-left-length", "status-right"}, + skipKeys: []string{"status", "status-style", "status-right-length"}, + }, + { + name: "unrelated override - all defaults applied", + sessionName: "test-sess", + displayName: "my-project", + workDir: "/home/user/my-project", + optionOverrides: map[string]string{"history-limit": "50000"}, + wantKeys: []string{"status", "status-style", "status-left-length", "status-right", "status-right-length"}, + skipKeys: nil, + }, + { + name: "all managed keys overridden - returns nil", + sessionName: "test-sess", + displayName: "my-project", + workDir: "/home/user/my-project", + optionOverrides: map[string]string{ + "status": "2", "status-style": "bg=#000", + "status-left-length": "50", "status-right": "custom", + "status-right-length": "100", + }, + wantKeys: nil, + skipKeys: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Session{ + Name: tt.sessionName, + DisplayName: tt.displayName, + WorkDir: tt.workDir, + OptionOverrides: tt.optionOverrides, + injectStatusLine: true, + } + args := s.buildStatusBarArgs() + + if tt.wantKeys == nil && tt.skipKeys == nil { + assert.Nil(t, args, "args should be nil when all managed keys are overridden") + return + } + + require.NotNil(t, args, "args should not be nil when injectStatusLine is true") + + // Extract the set of option keys from the args. + // Args follow the pattern: "set-option" "-t" [";"] + keys := make(map[string]bool) + for i, a := range args { + if a == "set-option" && i+3 < len(args) { + keys[args[i+3]] = true + } + } + + for _, key := range tt.wantKeys { + assert.True(t, keys[key], "expected key %q in args", key) + } + for _, key := range tt.skipKeys { + assert.False(t, keys[key], "key %q should be skipped", key) + } + }) + } +} + +func TestBuildStatusBarArgs_InjectDisabled(t *testing.T) { + s := &Session{ + Name: "test-sess", + DisplayName: "proj", + WorkDir: "/tmp", + injectStatusLine: false, + } + args := s.buildStatusBarArgs() + assert.Nil(t, args, "args should be nil when injectStatusLine is false") +}