feat(daemon): add tab title composition helpers
Add composeTabTitle, focusedPaneTitleLocked, and refreshSessionFocusedTitles so a later task can enrich top-bar and picker tab labels with the focused pane's live title. Not yet wired in; refreshSessionFocusedTitles is unused until that task lands.
- commit
- 1fab4211b79e81f2e353c0d6b93f4c97fb08551a
- author
- brice · · 2026-07-11 07:55 UTC
- parent
- b40aa09
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +63 -0
M internal/usecase/daemon/pane_titles.go +47 -0
| @@ -65,6 +65,53 @@ func (p *pane) refreshTerminalTitleLocked() { | |||
| 65 | 65 | } | |
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | + | // composeTabTitle renders "name (paneTitle)". An empty paneTitle, or one equal | |
| 69 | + | // to the tab name, collapses to just the name. | |
| 70 | + | func composeTabTitle(tabName, paneTitle string) string { | |
| 71 | + | if paneTitle == "" || paneTitle == tabName { | |
| 72 | + | return tabName | |
| 73 | + | } | |
| 74 | + | return tabName + " (" + paneTitle + ")" | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // focusedPaneTitleLocked returns the focused pane's display title. Caller must | |
| 78 | + | // hold the owning session.mu (so tb.mu then pane.mu obey lock order). | |
| 79 | + | func (tb *tab) focusedPaneTitleLocked() string { | |
| 80 | + | tb.mu.Lock() | |
| 81 | + | p := tb.focusedPane() | |
| 82 | + | tb.mu.Unlock() | |
| 83 | + | if p == nil { | |
| 84 | + | return "" | |
| 85 | + | } | |
| 86 | + | p.mu.Lock() | |
| 87 | + | defer p.mu.Unlock() | |
| 88 | + | return p.displayTitleLocked() | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // refreshSessionFocusedTitles opportunistically refreshes the process-name half | |
| 92 | + | // of every tab's focused pane. Snapshot panes under locks, then refresh with no | |
| 93 | + | // lock held (refreshPaneDisplayTitle re-enters p.mu itself). TTL-throttled. | |
| 94 | + | func (d *Daemon) refreshSessionFocusedTitles(sess *session) { | |
| 95 | + | if sess == nil { | |
| 96 | + | return | |
| 97 | + | } | |
| 98 | + | fallback := d.paneTitleFallback() | |
| 99 | + | sess.mu.Lock() | |
| 100 | + | panes := make([]*pane, 0, len(sess.tabs)) | |
| 101 | + | for _, tb := range sess.tabs { | |
| 102 | + | tb.mu.Lock() | |
| 103 | + | if p := tb.focusedPane(); p != nil { | |
| 104 | + | panes = append(panes, p) | |
| 105 | + | } | |
| 106 | + | tb.mu.Unlock() | |
| 107 | + | } | |
| 108 | + | sess.mu.Unlock() | |
| 109 | + | for _, p := range panes { | |
| 110 | + | p.setDisplayFallback(fallback) | |
| 111 | + | d.refreshPaneDisplayTitle(sess, p, false) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 68 | 115 | func (d *Daemon) paneTitleFallback() string { | |
| 69 | 116 | fallback := filepath.Base(d.shell) | |
| 70 | 117 | if fallback == "." || fallback == string(filepath.Separator) || fallback == "" { | |
| @@ -65,6 +65,53 @@ func (p *pane) refreshTerminalTitleLocked() { | |||||
| 65 | } | 65 | } | ||
| 66 | } | 66 | } | ||
| 67 | 67 | ||||
| 68 | + | // composeTabTitle renders "name (paneTitle)". An empty paneTitle, or one equal | |||
| 69 | + | // to the tab name, collapses to just the name. | |||
| 70 | + | func composeTabTitle(tabName, paneTitle string) string { | |||
| 71 | + | if paneTitle == "" || paneTitle == tabName { | |||
| 72 | + | return tabName | |||
| 73 | + | } | |||
| 74 | + | return tabName + " (" + paneTitle + ")" | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // focusedPaneTitleLocked returns the focused pane's display title. Caller must | |||
| 78 | + | // hold the owning session.mu (so tb.mu then pane.mu obey lock order). | |||
| 79 | + | func (tb *tab) focusedPaneTitleLocked() string { | |||
| 80 | + | tb.mu.Lock() | |||
| 81 | + | p := tb.focusedPane() | |||
| 82 | + | tb.mu.Unlock() | |||
| 83 | + | if p == nil { | |||
| 84 | + | return "" | |||
| 85 | + | } | |||
| 86 | + | p.mu.Lock() | |||
| 87 | + | defer p.mu.Unlock() | |||
| 88 | + | return p.displayTitleLocked() | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // refreshSessionFocusedTitles opportunistically refreshes the process-name half | |||
| 92 | + | // of every tab's focused pane. Snapshot panes under locks, then refresh with no | |||
| 93 | + | // lock held (refreshPaneDisplayTitle re-enters p.mu itself). TTL-throttled. | |||
| 94 | + | func (d *Daemon) refreshSessionFocusedTitles(sess *session) { | |||
| 95 | + | if sess == nil { | |||
| 96 | + | return | |||
| 97 | + | } | |||
| 98 | + | fallback := d.paneTitleFallback() | |||
| 99 | + | sess.mu.Lock() | |||
| 100 | + | panes := make([]*pane, 0, len(sess.tabs)) | |||
| 101 | + | for _, tb := range sess.tabs { | |||
| 102 | + | tb.mu.Lock() | |||
| 103 | + | if p := tb.focusedPane(); p != nil { | |||
| 104 | + | panes = append(panes, p) | |||
| 105 | + | } | |||
| 106 | + | tb.mu.Unlock() | |||
| 107 | + | } | |||
| 108 | + | sess.mu.Unlock() | |||
| 109 | + | for _, p := range panes { | |||
| 110 | + | p.setDisplayFallback(fallback) | |||
| 111 | + | d.refreshPaneDisplayTitle(sess, p, false) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 68 | func (d *Daemon) paneTitleFallback() string { | 115 | func (d *Daemon) paneTitleFallback() string { | ||
| 69 | fallback := filepath.Base(d.shell) | 116 | fallback := filepath.Base(d.shell) | ||
| 70 | if fallback == "." || fallback == string(filepath.Separator) || fallback == "" { | 117 | if fallback == "." || fallback == string(filepath.Separator) || fallback == "" { | ||
M internal/usecase/daemon/pane_titles_test.go +16 -0
| @@ -28,6 +28,22 @@ func TestFormatPaneTitle(t *testing.T) { | |||
| 28 | 28 | } | |
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | + | func TestComposeTabTitle(t *testing.T) { | |
| 32 | + | tests := []struct { | |
| 33 | + | name, tabName, paneTitle, want string | |
| 34 | + | }{ | |
| 35 | + | {name: "name and title", tabName: "api", paneTitle: "nvim: server.go — vev", want: "api (nvim: server.go — vev)"}, | |
| 36 | + | {name: "empty pane title", tabName: "api", paneTitle: "", want: "api"}, | |
| 37 | + | {name: "pane title equals name", tabName: "api", paneTitle: "api", want: "api"}, | |
| 38 | + | {name: "unnamed tab style", tabName: "1", paneTitle: "zsh", want: "1 (zsh)"}, | |
| 39 | + | } | |
| 40 | + | for _, tt := range tests { | |
| 41 | + | t.Run(tt.name, func(t *testing.T) { | |
| 42 | + | require.Equal(t, tt.want, composeTabTitle(tt.tabName, tt.paneTitle)) | |
| 43 | + | }) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 31 | 47 | func TestPaneTerminalTitleGenerationChangesOnlyWhenTitleChanges(t *testing.T) { | |
| 32 | 48 | p := newPane("pane", nil, domain.Size{Cols: 20, Rows: 5}) | |
| 33 | 49 | p.mu.Lock() | |
| @@ -28,6 +28,22 @@ func TestFormatPaneTitle(t *testing.T) { | |||||
| 28 | } | 28 | } | ||
| 29 | } | 29 | } | ||
| 30 | 30 | ||||
| 31 | + | func TestComposeTabTitle(t *testing.T) { | |||
| 32 | + | tests := []struct { | |||
| 33 | + | name, tabName, paneTitle, want string | |||
| 34 | + | }{ | |||
| 35 | + | {name: "name and title", tabName: "api", paneTitle: "nvim: server.go — vev", want: "api (nvim: server.go — vev)"}, | |||
| 36 | + | {name: "empty pane title", tabName: "api", paneTitle: "", want: "api"}, | |||
| 37 | + | {name: "pane title equals name", tabName: "api", paneTitle: "api", want: "api"}, | |||
| 38 | + | {name: "unnamed tab style", tabName: "1", paneTitle: "zsh", want: "1 (zsh)"}, | |||
| 39 | + | } | |||
| 40 | + | for _, tt := range tests { | |||
| 41 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 42 | + | require.Equal(t, tt.want, composeTabTitle(tt.tabName, tt.paneTitle)) | |||
| 43 | + | }) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 31 | func TestPaneTerminalTitleGenerationChangesOnlyWhenTitleChanges(t *testing.T) { | 47 | func TestPaneTerminalTitleGenerationChangesOnlyWhenTitleChanges(t *testing.T) { | ||
| 32 | p := newPane("pane", nil, domain.Size{Cols: 20, Rows: 5}) | 48 | p := newPane("pane", nil, domain.Size{Cols: 20, Rows: 5}) | ||
| 33 | p.mu.Lock() | 49 | p.mu.Lock() | ||