detached 1fab421

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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() {
6565 }
6666 }
6767
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+
68115 func (d *Daemon) paneTitleFallback() string {
69116 fallback := filepath.Base(d.shell)
70117 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) {
2828 }
2929 }
3030
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+
3147 func TestPaneTerminalTitleGenerationChangesOnlyWhenTitleChanges(t *testing.T) {
3248 p := newPane("pane", nil, domain.Size{Cols: 20, Rows: 5})
3349 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()

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close