detached 3e48405

feat(daemon): show focused pane titles in top bar tabs

Wire the composeTabTitle/focusedPaneTitleLocked helpers from Task 1 into
the paint path and top bar: refresh focused-pane titles once per paint
before status segments are read, carry each tab's pane title through
statusSnapshot, and fit enriched labels ("name (pane title)") to the
live row width via a new fitTabLabels budget/water-filling algorithm
that always reserves room for the right-anchored text and degrades
gracefully (full label -> truncated pane title -> name-only -> empty)
under a tight budget.

Updates pre-existing tests whose expectations depended on plain,
unenriched tab names now that every tab always carries at least the
shell-basename fallback as its pane title.
commit
3e48405441874468ef7e3fcf8e4e3f7e503dcd2d
author
brice · · 2026-07-11 08:07 UTC
parent
1fab421
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +197 -11
M internal/app/daemon_integration_test.go +5 -2
@@ -222,8 +222,11 @@ func TestIntegration_CommandPaletteCreatesTab(t *testing.T) {
222222 awaitText(t, p, sz, "Commands")
223223
224224 require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("CNT\r")})}))
225- text := awaitScreenText(t, p, sz, " 1 2 ")
226- require.Contains(t, text, " 1 2 ")
225+ // Tab labels are enriched with the focused pane's title; with no process
226+ // inspector wired in this test, that title falls back to the shell's
227+ // basename ("sh").
228+ text := awaitScreenText(t, p, sz, " 1 (sh) 2 (sh) ")
229+ require.Contains(t, text, " 1 (sh) 2 (sh) ")
227230 }
228231
229232 func TestIntegration_CommandPaletteRenamesEphemeralSession(t *testing.T) {
@@ -222,8 +222,11 @@ func TestIntegration_CommandPaletteCreatesTab(t *testing.T) {
222 awaitText(t, p, sz, "Commands")222 awaitText(t, p, sz, "Commands")
223 223
224 require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("CNT\r")})}))224 require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("CNT\r")})}))
225- text := awaitScreenText(t, p, sz, " 1 2 ")225+ // Tab labels are enriched with the focused pane's title; with no process
226- require.Contains(t, text, " 1 2 ")226+ // inspector wired in this test, that title falls back to the shell's
227+ // basename ("sh").
228+ text := awaitScreenText(t, p, sz, " 1 (sh) 2 (sh) ")
229+ require.Contains(t, text, " 1 (sh) 2 (sh) ")
227 }230 }
228 231
229 func TestIntegration_CommandPaletteRenamesEphemeralSession(t *testing.T) {232 func TestIntegration_CommandPaletteRenamesEphemeralSession(t *testing.T) {
M internal/usecase/daemon/copymode_test.go +1 -1
@@ -156,7 +156,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
156156
157157 require.Equal(t, 80, frame.Width)
158158 require.Equal(t, 25, frame.Height)
159- require.Equal(t, " 1", strings.TrimRight(rowText(frame.Row(0)), " "))
159+ require.Equal(t, " 1 (sh)", strings.TrimRight(rowText(frame.Row(0)), " "))
160160 require.Contains(t, rowText(frame.Row(1)), "live")
161161 require.Contains(t, rowText(frame.Row(24)), "[SCROLL]")
162162 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
@@ -156,7 +156,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
156 156
157 require.Equal(t, 80, frame.Width)157 require.Equal(t, 80, frame.Width)
158 require.Equal(t, 25, frame.Height)158 require.Equal(t, 25, frame.Height)
159- require.Equal(t, " 1", strings.TrimRight(rowText(frame.Row(0)), " "))159+ require.Equal(t, " 1 (sh)", strings.TrimRight(rowText(frame.Row(0)), " "))
160 require.Contains(t, rowText(frame.Row(1)), "live")160 require.Contains(t, rowText(frame.Row(1)), "live")
161 require.Contains(t, rowText(frame.Row(24)), "[SCROLL]")161 require.Contains(t, rowText(frame.Row(24)), "[SCROLL]")
162 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)162 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
M internal/usecase/daemon/render.go +1 -0
@@ -344,6 +344,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
344344 if overlays.previewTab != tb {
345345 preview = snapshotPickerPreview(overlays.previewTab)
346346 }
347+ d.refreshSessionFocusedTitles(sess)
347348 bars := d.barStateForClient(sess, ac, overlays.copyFeedback)
348349 bars.theme = ac.getTheme()
349350 _, attentionVisible := pulseStyle(bars.attentionFrame)
@@ -344,6 +344,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
344 if overlays.previewTab != tb {344 if overlays.previewTab != tb {
345 preview = snapshotPickerPreview(overlays.previewTab)345 preview = snapshotPickerPreview(overlays.previewTab)
346 }346 }
347+ d.refreshSessionFocusedTitles(sess)
347 bars := d.barStateForClient(sess, ac, overlays.copyFeedback)348 bars := d.barStateForClient(sess, ac, overlays.copyFeedback)
348 bars.theme = ac.getTheme()349 bars.theme = ac.getTheme()
349 _, attentionVisible := pulseStyle(bars.attentionFrame)350 _, attentionVisible := pulseStyle(bars.attentionFrame)
M internal/usecase/daemon/status.go +76 -4
@@ -33,6 +33,7 @@ import (
3333
3434 "github.com/bnema/vev/internal/domain"
3535 themeui "github.com/bnema/vev/internal/usecase/theme"
36+ "github.com/bnema/vev/internal/usecase/ui"
3637 "github.com/bnema/vev/pkg/renderer"
3738 )
3839
@@ -63,12 +64,13 @@ func pulseStyle(frame int) (renderer.Style, bool) {
6364 func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeStyles) {
6465 clearStatusRow(row)
6566 x := 0
66- for _, w := range status.tabs {
67+ labels := fitTabLabels(status.tabs, len(row), topRight)
68+ for i, w := range status.tabs {
6769 style := styles.statusBar
6870 if w.active {
6971 style = styles.accent
7072 }
71- writeStatusText(row, &x, " "+w.name, style)
73+ writeStatusText(row, &x, " "+labels[i], style)
7274 if w.attention {
7375 writeStatusText(row, &x, " ", style)
7476 writeBell(row, &x, frame)
@@ -190,7 +192,8 @@ type statusSnapshot struct {
190192 }
191193
192194 type statusTab struct {
193- name string
195+ name string // tab display name (degradation target)
196+ paneTitle string // formatPaneTitle output of the focused pane
194197 active bool
195198 attention bool
196199 }
@@ -207,7 +210,7 @@ func (s *session) statusSegments() statusSnapshot {
207210 name := tabDisplayName(tb, i)
208211 active := i == s.active
209212 attention := tb.attention && (!active || tb.attentionVisiblePaint)
210- snap.tabs[i] = statusTab{name: name, active: active, attention: attention}
213+ snap.tabs[i] = statusTab{name: name, paneTitle: tb.focusedPaneTitleLocked(), active: active, attention: attention}
211214 }
212215 return snap
213216 }
@@ -376,6 +379,75 @@ func fitHistoryNav(entries []historyNavSession, rowLen, leftUsed int, feedback s
376379 return entries[start:end]
377380 }
378381
382+// fitTabLabels returns, per tab, the text drawn between its surrounding
383+// spaces (attention glyph handled by the caller), guaranteeing all tabs fit.
384+func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string {
385+ reserve := 1
386+ if rightText != "" {
387+ reserve = statusTextWidth(rightText) + 2
388+ }
389+ budget := rowLen - reserve
390+
391+ full := make([]string, len(tabs))
392+ overhead := make([]int, len(tabs))
393+ widths := make([]int, len(tabs))
394+ total := 0
395+ for i, t := range tabs {
396+ full[i] = composeTabTitle(t.name, t.paneTitle)
397+ o := 2
398+ if t.attention {
399+ o += 1 + renderer.RuneWidth(attentionGlyph)
400+ }
401+ overhead[i] = o
402+ widths[i] = statusTextWidth(full[i])
403+ total += o + widths[i]
404+ }
405+ labels := make([]string, len(tabs))
406+ if total <= budget {
407+ copy(labels, full)
408+ return labels
409+ }
410+
411+ // Overflow: greedy water-filling in ascending width order (shortest tabs
412+ // first) so unused budget from short tabs flows to longer ones, while
413+ // output order (labels indexed by original tab index) stays stable.
414+ order := make([]int, len(tabs))
415+ for i := range order {
416+ order[i] = i
417+ }
418+ sort.SliceStable(order, func(a, b int) bool {
419+ return widths[order[a]] < widths[order[b]]
420+ })
421+
422+ remaining := budget
423+ remainingTabs := len(tabs)
424+ for _, i := range order {
425+ share := remaining / remainingTabs
426+ textBudget := share - overhead[i]
427+ switch {
428+ case textBudget <= 0:
429+ labels[i] = ""
430+ case widths[i] <= textBudget:
431+ labels[i] = full[i]
432+ case textBudget >= statusTextWidth(tabs[i].name)+4:
433+ labels[i] = ui.TruncateText(full[i], textBudget)
434+ default:
435+ labels[i] = ui.TruncateText(tabs[i].name, textBudget)
436+ }
437+ consumed := overhead[i]
438+ if tb := textBudget; tb > 0 {
439+ if widths[i] < tb {
440+ consumed += widths[i]
441+ } else {
442+ consumed += tb
443+ }
444+ }
445+ remaining -= consumed
446+ remainingTabs--
447+ }
448+ return labels
449+}
450+
379451 func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSession {
380452 // With no feedback, keep one blank trailing cell; with feedback, reserve
381453 // its " text" width plus a one-cell gap so drawRightPlainText always fits.
@@ -33,6 +33,7 @@ import (
33 33
34 "github.com/bnema/vev/internal/domain"34 "github.com/bnema/vev/internal/domain"
35 themeui "github.com/bnema/vev/internal/usecase/theme"35 themeui "github.com/bnema/vev/internal/usecase/theme"
36+ "github.com/bnema/vev/internal/usecase/ui"
36 "github.com/bnema/vev/pkg/renderer"37 "github.com/bnema/vev/pkg/renderer"
37 )38 )
38 39
@@ -63,12 +64,13 @@ func pulseStyle(frame int) (renderer.Style, bool) {
63 func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeStyles) {64 func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeStyles) {
64 clearStatusRow(row)65 clearStatusRow(row)
65 x := 066 x := 0
66- for _, w := range status.tabs {67+ labels := fitTabLabels(status.tabs, len(row), topRight)
68+ for i, w := range status.tabs {
67 style := styles.statusBar69 style := styles.statusBar
68 if w.active {70 if w.active {
69 style = styles.accent71 style = styles.accent
70 }72 }
71- writeStatusText(row, &x, " "+w.name, style)73+ writeStatusText(row, &x, " "+labels[i], style)
72 if w.attention {74 if w.attention {
73 writeStatusText(row, &x, " ", style)75 writeStatusText(row, &x, " ", style)
74 writeBell(row, &x, frame)76 writeBell(row, &x, frame)
@@ -190,7 +192,8 @@ type statusSnapshot struct {
190 }192 }
191 193
192 type statusTab struct {194 type statusTab struct {
193- name string195+ name string // tab display name (degradation target)
196+ paneTitle string // formatPaneTitle output of the focused pane
194 active bool197 active bool
195 attention bool198 attention bool
196 }199 }
@@ -207,7 +210,7 @@ func (s *session) statusSegments() statusSnapshot {
207 name := tabDisplayName(tb, i)210 name := tabDisplayName(tb, i)
208 active := i == s.active211 active := i == s.active
209 attention := tb.attention && (!active || tb.attentionVisiblePaint)212 attention := tb.attention && (!active || tb.attentionVisiblePaint)
210- snap.tabs[i] = statusTab{name: name, active: active, attention: attention}213+ snap.tabs[i] = statusTab{name: name, paneTitle: tb.focusedPaneTitleLocked(), active: active, attention: attention}
211 }214 }
212 return snap215 return snap
213 }216 }
@@ -376,6 +379,75 @@ func fitHistoryNav(entries []historyNavSession, rowLen, leftUsed int, feedback s
376 return entries[start:end]379 return entries[start:end]
377 }380 }
378 381
382+// fitTabLabels returns, per tab, the text drawn between its surrounding
383+// spaces (attention glyph handled by the caller), guaranteeing all tabs fit.
384+func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string {
385+ reserve := 1
386+ if rightText != "" {
387+ reserve = statusTextWidth(rightText) + 2
388+ }
389+ budget := rowLen - reserve
390+
391+ full := make([]string, len(tabs))
392+ overhead := make([]int, len(tabs))
393+ widths := make([]int, len(tabs))
394+ total := 0
395+ for i, t := range tabs {
396+ full[i] = composeTabTitle(t.name, t.paneTitle)
397+ o := 2
398+ if t.attention {
399+ o += 1 + renderer.RuneWidth(attentionGlyph)
400+ }
401+ overhead[i] = o
402+ widths[i] = statusTextWidth(full[i])
403+ total += o + widths[i]
404+ }
405+ labels := make([]string, len(tabs))
406+ if total <= budget {
407+ copy(labels, full)
408+ return labels
409+ }
410+
411+ // Overflow: greedy water-filling in ascending width order (shortest tabs
412+ // first) so unused budget from short tabs flows to longer ones, while
413+ // output order (labels indexed by original tab index) stays stable.
414+ order := make([]int, len(tabs))
415+ for i := range order {
416+ order[i] = i
417+ }
418+ sort.SliceStable(order, func(a, b int) bool {
419+ return widths[order[a]] < widths[order[b]]
420+ })
421+
422+ remaining := budget
423+ remainingTabs := len(tabs)
424+ for _, i := range order {
425+ share := remaining / remainingTabs
426+ textBudget := share - overhead[i]
427+ switch {
428+ case textBudget <= 0:
429+ labels[i] = ""
430+ case widths[i] <= textBudget:
431+ labels[i] = full[i]
432+ case textBudget >= statusTextWidth(tabs[i].name)+4:
433+ labels[i] = ui.TruncateText(full[i], textBudget)
434+ default:
435+ labels[i] = ui.TruncateText(tabs[i].name, textBudget)
436+ }
437+ consumed := overhead[i]
438+ if tb := textBudget; tb > 0 {
439+ if widths[i] < tb {
440+ consumed += widths[i]
441+ } else {
442+ consumed += tb
443+ }
444+ }
445+ remaining -= consumed
446+ remainingTabs--
447+ }
448+ return labels
449+}
450+
379 func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSession {451 func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSession {
380 // With no feedback, keep one blank trailing cell; with feedback, reserve452 // With no feedback, keep one blank trailing cell; with feedback, reserve
381 // its " text" width plus a one-cell gap so drawRightPlainText always fits.453 // its " text" width plus a one-cell gap so drawRightPlainText always fits.
M internal/usecase/daemon/status_test.go +114 -4
@@ -97,6 +97,110 @@ func TestStatusCompositionGolden(t *testing.T) {
9797 }
9898 }
9999
100+func TestStatusSegmentsIncludesFocusedPaneTitle(t *testing.T) {
101+ p1, releasePTY1 := newBlockingPTY(t)
102+ p2, releasePTY2 := newBlockingPTY(t)
103+ _, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
104+ defer releasePTY1()
105+ defer releasePTY2()
106+
107+ sess.tabs[1].name = "logs"
108+
109+ pane0 := sess.tabs[0].focusedPane()
110+ pane0.mu.Lock()
111+ pane0.title.processName = "vim"
112+ pane0.title.processNameValid = true
113+ pane0.mu.Unlock()
114+
115+ pane1 := sess.tabs[1].focusedPane()
116+ pane1.mu.Lock()
117+ pane1.title.terminalTitle = "build output"
118+ pane1.mu.Unlock()
119+
120+ snap := sess.statusSegments()
121+
122+ require.Len(t, snap.tabs, 2)
123+ require.Equal(t, "1", snap.tabs[0].name)
124+ require.Equal(t, "vim", snap.tabs[0].paneTitle)
125+ require.Equal(t, "logs", snap.tabs[1].name)
126+ require.Equal(t, "build output", snap.tabs[1].paneTitle)
127+}
128+
129+func TestFitTabLabels(t *testing.T) {
130+ tests := []struct {
131+ name string
132+ tabs []statusTab
133+ rowLen int
134+ rightText string
135+ want []string
136+ }{
137+ {
138+ name: "no tabs",
139+ tabs: nil,
140+ rowLen: 10,
141+ want: []string{},
142+ },
143+ {
144+ name: "everything fits stays untruncated",
145+ tabs: []statusTab{{name: "one"}, {name: "two", paneTitle: "shell"}},
146+ rowLen: 40,
147+ want: []string{"one", "two (shell)"},
148+ },
149+ {
150+ name: "overflow redistributes budget: short tab stays full, long tab gets ellipsis",
151+ tabs: []statusTab{
152+ {name: "a"},
153+ {name: "ed", paneTitle: "verylongtitle"},
154+ },
155+ rowLen: 20,
156+ want: []string{"a", "ed (verylongt…"},
157+ },
158+ {
159+ name: "tight budget degrades to a truncated tab name only",
160+ tabs: []statusTab{{name: "editor", paneTitle: "some pane title text"}},
161+ rowLen: 7,
162+ want: []string{"edi…"},
163+ },
164+ {
165+ name: "non-empty rightText shrinks the budget",
166+ tabs: []statusTab{{name: "aaaaaaaaaa"}},
167+ rowLen: 20,
168+ rightText: "0123456789",
169+ want: []string{"aaaaa…"},
170+ },
171+ {
172+ name: "single tab wider than the whole row collapses to empty",
173+ tabs: []statusTab{{name: "reallyverylongtabname"}},
174+ rowLen: 3,
175+ want: []string{""},
176+ },
177+ }
178+ for _, tt := range tests {
179+ t.Run(tt.name, func(t *testing.T) {
180+ got := fitTabLabels(tt.tabs, tt.rowLen, tt.rightText)
181+
182+ require.Equal(t, tt.want, got)
183+
184+ reserve := 1
185+ if tt.rightText != "" {
186+ reserve = statusTextWidth(tt.rightText) + 2
187+ }
188+ budget := tt.rowLen - reserve
189+ drawn := 0
190+ for i, tb := range tt.tabs {
191+ overhead := 2
192+ if tb.attention {
193+ overhead += 1 + renderer.RuneWidth(attentionGlyph)
194+ }
195+ drawn += overhead + statusTextWidth(got[i])
196+ }
197+ if budget > 0 {
198+ require.LessOrEqual(t, drawn, budget, "drawn width must not exceed the derived budget")
199+ }
200+ })
201+ }
202+}
203+
100204 func TestTabDisplayName(t *testing.T) {
101205 tests := []struct {
102206 name string
@@ -512,7 +616,7 @@ func TestStatusMarksEphemeralSession(t *testing.T) {
512616
513617 frame, _ := composeClientFrame(sess, win, true, "")
514618
515- require.Equal(t, " 1 ", rowText(frame.Row(0)))
619+ require.Equal(t, " 1 (sh) ", rowText(frame.Row(0)))
516620 require.Equal(t, " 0* ", rowText(frame.Row(3)))
517621 }
518622
@@ -533,11 +637,15 @@ func TestTopBarRightAnchor(t *testing.T) {
533637 want: " 1 14:32",
534638 },
535639 {
536- name: "hides on overlap",
640+ // The tab budget reserves room for topRight up front (fitTabLabels),
641+ // so tab labels shrink to nothing before the right-anchored text is
642+ // dropped; unlike the bottom bar's MRU, top-bar tabs never hide
643+ // entirely, so topRight still renders once tabs are squeezed out.
644+ name: "squeezes tabs empty to keep topRight visible",
537645 width: 10,
538646 status: statusSnapshot{tabs: []statusTab{{name: "1"}, {name: "2"}}},
539647 topRight: "12345",
540- want: " 1 2 ",
648+ want: " 12345",
541649 },
542650 {
543651 name: "uses display width",
@@ -614,7 +722,9 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
614722
615723 frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(), attentionFrame: 1}, win, true)
616724
617- require.Contains(t, rowText(frame.Row(0)), "2 ")
725+ // Tab 2's label is enriched with its focused pane's title ("sh", the
726+ // default shell fallback) and truncated to fit alongside the bell.
727+ require.Contains(t, rowText(frame.Row(0)), "2 (s")
618728 for _, c := range frame.Row(0) {
619729 if c.Rune == attentionGlyph {
620730 require.True(t, c.Style.Bold)
@@ -97,6 +97,110 @@ func TestStatusCompositionGolden(t *testing.T) {
97 }97 }
98 }98 }
99 99
100+func TestStatusSegmentsIncludesFocusedPaneTitle(t *testing.T) {
101+ p1, releasePTY1 := newBlockingPTY(t)
102+ p2, releasePTY2 := newBlockingPTY(t)
103+ _, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
104+ defer releasePTY1()
105+ defer releasePTY2()
106+
107+ sess.tabs[1].name = "logs"
108+
109+ pane0 := sess.tabs[0].focusedPane()
110+ pane0.mu.Lock()
111+ pane0.title.processName = "vim"
112+ pane0.title.processNameValid = true
113+ pane0.mu.Unlock()
114+
115+ pane1 := sess.tabs[1].focusedPane()
116+ pane1.mu.Lock()
117+ pane1.title.terminalTitle = "build output"
118+ pane1.mu.Unlock()
119+
120+ snap := sess.statusSegments()
121+
122+ require.Len(t, snap.tabs, 2)
123+ require.Equal(t, "1", snap.tabs[0].name)
124+ require.Equal(t, "vim", snap.tabs[0].paneTitle)
125+ require.Equal(t, "logs", snap.tabs[1].name)
126+ require.Equal(t, "build output", snap.tabs[1].paneTitle)
127+}
128+
129+func TestFitTabLabels(t *testing.T) {
130+ tests := []struct {
131+ name string
132+ tabs []statusTab
133+ rowLen int
134+ rightText string
135+ want []string
136+ }{
137+ {
138+ name: "no tabs",
139+ tabs: nil,
140+ rowLen: 10,
141+ want: []string{},
142+ },
143+ {
144+ name: "everything fits stays untruncated",
145+ tabs: []statusTab{{name: "one"}, {name: "two", paneTitle: "shell"}},
146+ rowLen: 40,
147+ want: []string{"one", "two (shell)"},
148+ },
149+ {
150+ name: "overflow redistributes budget: short tab stays full, long tab gets ellipsis",
151+ tabs: []statusTab{
152+ {name: "a"},
153+ {name: "ed", paneTitle: "verylongtitle"},
154+ },
155+ rowLen: 20,
156+ want: []string{"a", "ed (verylongt…"},
157+ },
158+ {
159+ name: "tight budget degrades to a truncated tab name only",
160+ tabs: []statusTab{{name: "editor", paneTitle: "some pane title text"}},
161+ rowLen: 7,
162+ want: []string{"edi…"},
163+ },
164+ {
165+ name: "non-empty rightText shrinks the budget",
166+ tabs: []statusTab{{name: "aaaaaaaaaa"}},
167+ rowLen: 20,
168+ rightText: "0123456789",
169+ want: []string{"aaaaa…"},
170+ },
171+ {
172+ name: "single tab wider than the whole row collapses to empty",
173+ tabs: []statusTab{{name: "reallyverylongtabname"}},
174+ rowLen: 3,
175+ want: []string{""},
176+ },
177+ }
178+ for _, tt := range tests {
179+ t.Run(tt.name, func(t *testing.T) {
180+ got := fitTabLabels(tt.tabs, tt.rowLen, tt.rightText)
181+
182+ require.Equal(t, tt.want, got)
183+
184+ reserve := 1
185+ if tt.rightText != "" {
186+ reserve = statusTextWidth(tt.rightText) + 2
187+ }
188+ budget := tt.rowLen - reserve
189+ drawn := 0
190+ for i, tb := range tt.tabs {
191+ overhead := 2
192+ if tb.attention {
193+ overhead += 1 + renderer.RuneWidth(attentionGlyph)
194+ }
195+ drawn += overhead + statusTextWidth(got[i])
196+ }
197+ if budget > 0 {
198+ require.LessOrEqual(t, drawn, budget, "drawn width must not exceed the derived budget")
199+ }
200+ })
201+ }
202+}
203+
100 func TestTabDisplayName(t *testing.T) {204 func TestTabDisplayName(t *testing.T) {
101 tests := []struct {205 tests := []struct {
102 name string206 name string
@@ -512,7 +616,7 @@ func TestStatusMarksEphemeralSession(t *testing.T) {
512 616
513 frame, _ := composeClientFrame(sess, win, true, "")617 frame, _ := composeClientFrame(sess, win, true, "")
514 618
515- require.Equal(t, " 1 ", rowText(frame.Row(0)))619+ require.Equal(t, " 1 (sh) ", rowText(frame.Row(0)))
516 require.Equal(t, " 0* ", rowText(frame.Row(3)))620 require.Equal(t, " 0* ", rowText(frame.Row(3)))
517 }621 }
518 622
@@ -533,11 +637,15 @@ func TestTopBarRightAnchor(t *testing.T) {
533 want: " 1 14:32",637 want: " 1 14:32",
534 },638 },
535 {639 {
536- name: "hides on overlap",640+ // The tab budget reserves room for topRight up front (fitTabLabels),
641+ // so tab labels shrink to nothing before the right-anchored text is
642+ // dropped; unlike the bottom bar's MRU, top-bar tabs never hide
643+ // entirely, so topRight still renders once tabs are squeezed out.
644+ name: "squeezes tabs empty to keep topRight visible",
537 width: 10,645 width: 10,
538 status: statusSnapshot{tabs: []statusTab{{name: "1"}, {name: "2"}}},646 status: statusSnapshot{tabs: []statusTab{{name: "1"}, {name: "2"}}},
539 topRight: "12345",647 topRight: "12345",
540- want: " 1 2 ",648+ want: " 12345",
541 },649 },
542 {650 {
543 name: "uses display width",651 name: "uses display width",
@@ -614,7 +722,9 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
614 722
615 frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(), attentionFrame: 1}, win, true)723 frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(), attentionFrame: 1}, win, true)
616 724
617- require.Contains(t, rowText(frame.Row(0)), "2 ")725+ // Tab 2's label is enriched with its focused pane's title ("sh", the
726+ // default shell fallback) and truncated to fit alongside the bell.
727+ require.Contains(t, rowText(frame.Row(0)), "2 (s")
618 for _, c := range frame.Row(0) {728 for _, c := range frame.Row(0) {
619 if c.Rune == attentionGlyph {729 if c.Rune == attentionGlyph {
620 require.True(t, c.Style.Bold)730 require.True(t, c.Style.Bold)

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