Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion internal/session/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions internal/session/userconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 45 additions & 18 deletions internal/tmux/tmux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
127 changes: 127 additions & 0 deletions internal/tmux/tmux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" <session> <key> <value> [";"]
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")
}