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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 222 | 222 | awaitText(t, p, sz, "Commands") | |
| 223 | 223 | ||
| 224 | 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 ") | |
| 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) ") | |
| 227 | 230 | } | |
| 228 | 231 | ||
| 229 | 232 | 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) { | |||
| 156 | 156 | ||
| 157 | 157 | require.Equal(t, 80, frame.Width) | |
| 158 | 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 | 160 | require.Contains(t, rowText(frame.Row(1)), "live") | |
| 161 | 161 | require.Contains(t, rowText(frame.Row(24)), "[SCROLL]") | |
| 162 | 162 | 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) { | |||
| 344 | 344 | if overlays.previewTab != tb { | |
| 345 | 345 | preview = snapshotPickerPreview(overlays.previewTab) | |
| 346 | 346 | } | |
| 347 | + | d.refreshSessionFocusedTitles(sess) | |
| 347 | 348 | bars := d.barStateForClient(sess, ac, overlays.copyFeedback) | |
| 348 | 349 | bars.theme = ac.getTheme() | |
| 349 | 350 | _, 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 ( | |||
| 33 | 33 | ||
| 34 | 34 | "github.com/bnema/vev/internal/domain" | |
| 35 | 35 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 36 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 36 | 37 | "github.com/bnema/vev/pkg/renderer" | |
| 37 | 38 | ) | |
| 38 | 39 | ||
| @@ -63,12 +64,13 @@ func pulseStyle(frame int) (renderer.Style, bool) { | |||
| 63 | 64 | func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeStyles) { | |
| 64 | 65 | clearStatusRow(row) | |
| 65 | 66 | x := 0 | |
| 66 | - | for _, w := range status.tabs { | |
| 67 | + | labels := fitTabLabels(status.tabs, len(row), topRight) | |
| 68 | + | for i, w := range status.tabs { | |
| 67 | 69 | style := styles.statusBar | |
| 68 | 70 | if w.active { | |
| 69 | 71 | style = styles.accent | |
| 70 | 72 | } | |
| 71 | - | writeStatusText(row, &x, " "+w.name, style) | |
| 73 | + | writeStatusText(row, &x, " "+labels[i], style) | |
| 72 | 74 | if w.attention { | |
| 73 | 75 | writeStatusText(row, &x, " ", style) | |
| 74 | 76 | writeBell(row, &x, frame) | |
| @@ -190,7 +192,8 @@ type statusSnapshot struct { | |||
| 190 | 192 | } | |
| 191 | 193 | ||
| 192 | 194 | type statusTab struct { | |
| 193 | - | name string | |
| 195 | + | name string // tab display name (degradation target) | |
| 196 | + | paneTitle string // formatPaneTitle output of the focused pane | |
| 194 | 197 | active bool | |
| 195 | 198 | attention bool | |
| 196 | 199 | } | |
| @@ -207,7 +210,7 @@ func (s *session) statusSegments() statusSnapshot { | |||
| 207 | 210 | name := tabDisplayName(tb, i) | |
| 208 | 211 | active := i == s.active | |
| 209 | 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 | 215 | return snap | |
| 213 | 216 | } | |
| @@ -376,6 +379,75 @@ func fitHistoryNav(entries []historyNavSession, rowLen, leftUsed int, feedback s | |||
| 376 | 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 | 451 | func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSession { | |
| 380 | 452 | // With no feedback, keep one blank trailing cell; with feedback, reserve | |
| 381 | 453 | // 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 := 0 | 66 | 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.statusBar | 69 | style := styles.statusBar | ||
| 68 | if w.active { | 70 | if w.active { | ||
| 69 | style = styles.accent | 71 | 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 string | 195 | + | name string // tab display name (degradation target) |
| 196 | + | paneTitle string // formatPaneTitle output of the focused pane | |||
| 194 | active bool | 197 | active bool | ||
| 195 | attention bool | 198 | 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.active | 211 | 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 snap | 215 | 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, reserve | 452 | // 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) { | |||
| 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 | 204 | func TestTabDisplayName(t *testing.T) { | |
| 101 | 205 | tests := []struct { | |
| 102 | 206 | name string | |
| @@ -512,7 +616,7 @@ func TestStatusMarksEphemeralSession(t *testing.T) { | |||
| 512 | 616 | ||
| 513 | 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 | 620 | require.Equal(t, " 0* ", rowText(frame.Row(3))) | |
| 517 | 621 | } | |
| 518 | 622 | ||
| @@ -533,11 +637,15 @@ func TestTopBarRightAnchor(t *testing.T) { | |||
| 533 | 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 | 645 | width: 10, | |
| 538 | 646 | status: statusSnapshot{tabs: []statusTab{{name: "1"}, {name: "2"}}}, | |
| 539 | 647 | topRight: "12345", | |
| 540 | - | want: " 1 2 ", | |
| 648 | + | want: " 12345", | |
| 541 | 649 | }, | |
| 542 | 650 | { | |
| 543 | 651 | name: "uses display width", | |
| @@ -614,7 +722,9 @@ func TestTopBarRendersAttentionBell(t *testing.T) { | |||
| 614 | 722 | ||
| 615 | 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 | 728 | for _, c := range frame.Row(0) { | |
| 619 | 729 | if c.Rune == attentionGlyph { | |
| 620 | 730 | 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 string | 206 | 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) | ||