Skip to content
Open
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
3 changes: 2 additions & 1 deletion internal/session/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -4554,11 +4554,12 @@ func (i *Instance) SetAcknowledgedFromShared(ack bool) {
i.tmuxSession.Acknowledge()
}

// SyncTmuxDisplayName updates the tmux status bar to reflect the current title.
// SyncTmuxDisplayName updates tmux-rendered UI that reflects the current title.
func (i *Instance) SyncTmuxDisplayName() {
if tmuxSess := i.GetTmuxSession(); tmuxSess != nil && tmuxSess.Exists() {
tmuxSess.DisplayName = i.Title
tmuxSess.ConfigureStatusBar()
tmuxSess.ConfigureTerminalTitle()
}
}

Expand Down
51 changes: 49 additions & 2 deletions internal/tmux/tmux.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,15 @@ func currentTmuxThemeStyle() tmuxThemeStyle {
}

func (s *Session) themedStatusRight(themeStyle tmuxThemeStyle) string {
return fmt.Sprintf("#[fg=%s]ctrl+q detach#[default] │ 📁 %s | %s ", themeStyle.hintColor, s.DisplayName, s.projectDisplayName())
}

func (s *Session) projectDisplayName() string {
folderName := filepath.Base(s.WorkDir)
if folderName == "" || folderName == "." {
folderName = "~"
}
return fmt.Sprintf("#[fg=%s]ctrl+q detach#[default] │ 📁 %s | %s ", themeStyle.hintColor, s.DisplayName, folderName)
return folderName
}

// ErrCaptureTimeout is returned when CapturePane exceeds its timeout.
Expand Down Expand Up @@ -790,7 +794,7 @@ func sanitizeSystemdUnitComponent(raw string) string {
return out
}

// bashCWrap returns the given command wrapped in `bash -c ''` with
// bashCWrap returns the given command wrapped in `bash -c '...'` with
// single quotes safely escaped using the POSIX shell quote-break pattern. The result
// is a single shell word that can be passed to any `sh -c` invocation
// (e.g. tmux's default shell-command delivery) and will always be
Expand Down Expand Up @@ -998,6 +1002,7 @@ func ReconnectSession(tmuxName, displayName, workDir, command string) *Session {
// Configure existing sessions
if sess.Exists() {
sess.ConfigureStatusBar()
sess.ConfigureTerminalTitle()
sess.configured = true
}

Expand Down Expand Up @@ -1103,6 +1108,7 @@ func (s *Session) EnsureConfigured() {

// Run deferred configuration
s.ConfigureStatusBar()
s.ConfigureTerminalTitle()
_ = s.EnableMouseMode()

s.configured = true
Expand Down Expand Up @@ -1436,6 +1442,7 @@ func (s *Session) Start(command string) error {
// Configure status bar with session info for easy identification
// Shows: session title on left, project folder on right
s.ConfigureStatusBar()
s.ConfigureTerminalTitle()

// Wait for the pane shell to be ready before sending the command via send-keys.
// On WSL/Linux non-interactive contexts, pane initialisation can take 100-500ms and
Expand Down Expand Up @@ -1562,6 +1569,46 @@ func (s *Session) buildStatusBarArgs() []string {
return args
}

// buildTerminalTitleArgs returns the tmux command args for configuring the outer
// terminal title shown by clients such as iTerm2. Session metadata user options
// are always refreshed so custom title formats can reuse them.
func (s *Session) buildTerminalTitleArgs() []string {
type option struct {
key string
value string
}

defaults := []option{
{"@agentdeck_project_name", s.projectDisplayName()},
{"@agentdeck_display_name", s.DisplayName},
}
if _, overridden := s.OptionOverrides["set-titles"]; !overridden {
defaults = append(defaults, option{key: "set-titles", value: "on"})
}
if _, overridden := s.OptionOverrides["set-titles-string"]; !overridden {
defaults = append(defaults, option{key: "set-titles-string", value: "[#{@agentdeck_project_name}] #{@agentdeck_display_name}"})
}

args := make([]string, 0, len(defaults)*6)
for i, opt := range defaults {
if i > 0 {
args = append(args, ";")
}
args = append(args, "set-option", "-t", s.Name, opt.key, opt.value)
}
return args
}

// ConfigureTerminalTitle sets tmux options that drive the outer terminal tab or
// window title for this session.
func (s *Session) ConfigureTerminalTitle() {
args := s.buildTerminalTitleArgs()
if len(args) == 0 {
return
}
_ = exec.Command("tmux", args...).Run()
}

// 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.
Expand Down
111 changes: 111 additions & 0 deletions internal/tmux/tmux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2686,6 +2686,117 @@ func TestBuildStatusBarArgs_InjectDisabled(t *testing.T) {
assert.Nil(t, args, "args should be nil when injectStatusLine is false")
}

func TestBuildTerminalTitleArgs(t *testing.T) {
tests := []struct {
name string
displayName string
workDir string
optionOverrides map[string]string
wantKeys []string
skipKeys []string
}{
{
name: "defaults include metadata and title settings",
displayName: "tmux session title in terminal tab",
workDir: "/tmp/agent-deck",
wantKeys: []string{"@agentdeck_project_name", "@agentdeck_display_name", "set-titles", "set-titles-string"},
},
{
name: "set-titles override skips only managed title toggle",
displayName: "feature work",
workDir: "/tmp/agent-deck",
optionOverrides: map[string]string{"set-titles": "off"},
wantKeys: []string{"@agentdeck_project_name", "@agentdeck_display_name", "set-titles-string"},
skipKeys: []string{"set-titles"},
},
{
name: "set-titles-string override skips managed format only",
displayName: "feature work",
workDir: "/tmp/agent-deck",
optionOverrides: map[string]string{"set-titles-string": "custom"},
wantKeys: []string{"@agentdeck_project_name", "@agentdeck_display_name", "set-titles"},
skipKeys: []string{"set-titles-string"},
},
{
name: "all managed title keys overridden still refreshes metadata",
displayName: "feature work",
workDir: "/tmp/agent-deck",
optionOverrides: map[string]string{"set-titles": "off", "set-titles-string": "custom"},
wantKeys: []string{"@agentdeck_project_name", "@agentdeck_display_name"},
skipKeys: []string{"set-titles", "set-titles-string"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &Session{
Name: "test-sess",
DisplayName: tt.displayName,
WorkDir: tt.workDir,
OptionOverrides: tt.optionOverrides,
}

args := s.buildTerminalTitleArgs()
require.NotEmpty(t, args)

valuesByKey := make(map[string]string)
for i, a := range args {
if a == "set-option" && i+4 < len(args) {
valuesByKey[args[i+3]] = args[i+4]
}
}

for _, key := range tt.wantKeys {
assert.Contains(t, valuesByKey, key, "expected key %q in args", key)
}
for _, key := range tt.skipKeys {
assert.NotContains(t, valuesByKey, key, "key %q should be skipped", key)
}

assert.Equal(t, filepath.Base(tt.workDir), valuesByKey["@agentdeck_project_name"])
assert.Equal(t, tt.displayName, valuesByKey["@agentdeck_display_name"])
if _, ok := valuesByKey["set-titles-string"]; ok {
assert.Equal(t, "[#{@agentdeck_project_name}] #{@agentdeck_display_name}", valuesByKey["set-titles-string"])
}
})
}
}

func TestConfigureTerminalTitle(t *testing.T) {
if _, err := exec.LookPath("tmux"); err != nil {
t.Skip("tmux not available")
}

root := t.TempDir()
projectDir := filepath.Join(root, "agent-deck")
require.NoError(t, os.Mkdir(projectDir, 0o755))

sessionName := "agentdeck_test_title_" + fmt.Sprintf("%d", time.Now().UnixNano())
cmd := exec.Command("tmux", "new-session", "-d", "-s", sessionName, "-c", projectDir)
require.NoError(t, cmd.Run())
defer func() {
_ = exec.Command("tmux", "kill-session", "-t", sessionName).Run()
}()

sess := &Session{
Name: sessionName,
DisplayName: "tmux session title in terminal tab",
WorkDir: projectDir,
}
sess.ConfigureTerminalTitle()

showOption := func(key string) string {
out, err := exec.Command("tmux", "show-option", "-t", sessionName, "-v", key).Output()
require.NoError(t, err)
return strings.TrimSpace(string(out))
}

assert.Equal(t, "agent-deck", showOption("@agentdeck_project_name"))
assert.Equal(t, "tmux session title in terminal tab", showOption("@agentdeck_display_name"))
assert.Equal(t, "on", showOption("set-titles"))
assert.Equal(t, "[#{@agentdeck_project_name}] #{@agentdeck_display_name}", showOption("set-titles-string"))
}

func TestStartCommandSpec_Default(t *testing.T) {
s := &Session{
Name: "agentdeck_test-session_1234abcd",
Expand Down
Loading