detached 8b8c982

Merge pull request #72 from bnema/feature/tab-label-styling

feat(daemon): bold tab names and mute pane titles on truecolor clients
commit
8b8c9826601e546f44e29189e8fdd6eb3981b2c9
author
bnema · · 2026-07-11 13:43 UTC
committer
GitHub · · 2026-07-11 13:43 UTC
merge of
e94bd47 + 9a37fb8 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
13 files · +630 -118
M README.md +1 -1
@@ -222,7 +222,7 @@ Each tab also owns one independent floating terminal. It prewarms asynchronously
222222
223223 While visible, the floating terminal receives ordinary input, bracketed paste, mouse input, clipboard-path injection, and scrollback/copy mode. Global vev bindings remain active, and a bare Esc is passed to the floating application. `Alt+f` and `FLT` toggle visibility; there is no separate floating-pane close command.
224224
225-The top bar shows each tab as `name (title)`, where the name is the tab's rename or number and the title is the focused pane's `ProcessName: ProcessTerminalTitle`. Set `tabs.terminal-title = off` to drop the `ProcessTerminalTitle` half from tab labels (top bar and session picker) and show only the process name; floating and stacked pane title bars are unaffected. Labels share the row width: when everything fits they render in full, otherwise unused space flows from short tabs to long ones and long labels are ellipsized, degrading to the bare tab name when space runs out. The bottom bar shows the active session followed by recent sessions, fading toward older entries. Ephemeral sessions are marked with `*`, for example `0*`.
225+The top bar shows each tab as `name (title)`, where the name is the tab's rename or number and the title is the focused pane's `ProcessName: ProcessTerminalTitle`. On truecolor clients the name renders bold and the parenthesized title slightly muted (in both the top bar and the session picker), derived from the client's reported theme; other terminals render plain labels. Set `tabs.terminal-title = off` to drop the `ProcessTerminalTitle` half from tab labels (top bar and session picker) and show only the process name; floating and stacked pane title bars are unaffected. Labels share the row width: when everything fits they render in full, otherwise unused space flows from short tabs to long ones and long labels are ellipsized, degrading to the bare tab name when space runs out. The bottom bar shows the active session followed by recent sessions, fading toward older entries. Ephemeral sessions are marked with `*`, for example `0*`.
226226
227227 ## Scrollback mode and copy
228228
@@ -222,7 +222,7 @@ Each tab also owns one independent floating terminal. It prewarms asynchronously
222 222
223 While visible, the floating terminal receives ordinary input, bracketed paste, mouse input, clipboard-path injection, and scrollback/copy mode. Global vev bindings remain active, and a bare Esc is passed to the floating application. `Alt+f` and `FLT` toggle visibility; there is no separate floating-pane close command.223 While visible, the floating terminal receives ordinary input, bracketed paste, mouse input, clipboard-path injection, and scrollback/copy mode. Global vev bindings remain active, and a bare Esc is passed to the floating application. `Alt+f` and `FLT` toggle visibility; there is no separate floating-pane close command.
224 224
225-The top bar shows each tab as `name (title)`, where the name is the tab's rename or number and the title is the focused pane's `ProcessName: ProcessTerminalTitle`. Set `tabs.terminal-title = off` to drop the `ProcessTerminalTitle` half from tab labels (top bar and session picker) and show only the process name; floating and stacked pane title bars are unaffected. Labels share the row width: when everything fits they render in full, otherwise unused space flows from short tabs to long ones and long labels are ellipsized, degrading to the bare tab name when space runs out. The bottom bar shows the active session followed by recent sessions, fading toward older entries. Ephemeral sessions are marked with `*`, for example `0*`.225+The top bar shows each tab as `name (title)`, where the name is the tab's rename or number and the title is the focused pane's `ProcessName: ProcessTerminalTitle`. On truecolor clients the name renders bold and the parenthesized title slightly muted (in both the top bar and the session picker), derived from the client's reported theme; other terminals render plain labels. Set `tabs.terminal-title = off` to drop the `ProcessTerminalTitle` half from tab labels (top bar and session picker) and show only the process name; floating and stacked pane title bars are unaffected. Labels share the row width: when everything fits they render in full, otherwise unused space flows from short tabs to long ones and long labels are ellipsized, degrading to the bare tab name when space runs out. The bottom bar shows the active session followed by recent sessions, fading toward older entries. Ephemeral sessions are marked with `*`, for example `0*`.
226 226
227 ## Scrollback mode and copy227 ## Scrollback mode and copy
228 228
M internal/usecase/daemon/attention_test.go +7 -6
@@ -14,6 +14,7 @@ import (
1414 "github.com/bnema/vev/internal/ports"
1515 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1616 "github.com/bnema/vev/internal/usecase/keys"
17+ "github.com/bnema/vev/internal/usecase/ui"
1718 "github.com/bnema/vev/pkg/renderer"
1819 )
1920
@@ -54,7 +55,7 @@ func TestPaintDoesNotAckActiveAttentionOnBlankPulseFrame(t *testing.T) {
5455
5556 d.paint(sess, ac, true)
5657 data := mustOutputData(t, sends)
57- require.NotContains(t, string(data), string(attentionGlyph))
58+ require.NotContains(t, string(data), string(ui.AttentionGlyph))
5859
5960 sess.mu.Lock()
6061 require.True(t, sess.tabs[0].attention)
@@ -63,7 +64,7 @@ func TestPaintDoesNotAckActiveAttentionOnBlankPulseFrame(t *testing.T) {
6364 d.setAttentionFrame(1)
6465 d.paint(sess, ac, true)
6566 data = mustOutputData(t, sends)
66- require.Contains(t, string(data), string(attentionGlyph))
67+ require.Contains(t, string(data), string(ui.AttentionGlyph))
6768
6869 sess.mu.Lock()
6970 require.False(t, sess.tabs[0].attention)
@@ -98,7 +99,7 @@ func TestPTYReaderActiveVisibleAgentNotificationRendersBellBeforeAck(t *testing.
9899
99100 d.paint(sess, ac, true)
100101 data := mustOutputData(t, sends)
101- require.Contains(t, string(data), string(attentionGlyph))
102+ require.Contains(t, string(data), string(ui.AttentionGlyph))
102103
103104 sess.mu.Lock()
104105 require.False(t, sess.tabs[0].attention)
@@ -106,7 +107,7 @@ func TestPTYReaderActiveVisibleAgentNotificationRendersBellBeforeAck(t *testing.
106107 sess.mu.Unlock()
107108
108109 cleared := mustOutputData(t, sends)
109- require.NotContains(t, string(cleared), string(attentionGlyph))
110+ require.NotContains(t, string(cleared), string(ui.AttentionGlyph))
110111
111112 _ = pty.Close()
112113 d.sessWg.Wait()
@@ -429,13 +430,13 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
429430
430431 d.paint(sessB, acB, true)
431432 before := mustOutputData(t, sendsB)
432- require.Contains(t, string(before), string(attentionGlyph))
433+ require.Contains(t, string(before), string(ui.AttentionGlyph))
433434
434435 d.closeTab(sessA, ringing, true)
435436 _ = mustOutputData(t, sendsA)
436437
437438 after := mustOutputData(t, sendsB)
438- require.NotContains(t, string(after), string(attentionGlyph))
439+ require.NotContains(t, string(after), string(ui.AttentionGlyph))
439440 }
440441
441442 func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) {
@@ -14,6 +14,7 @@ import (
14 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
15 portsmocks "github.com/bnema/vev/internal/ports/mocks"15 portsmocks "github.com/bnema/vev/internal/ports/mocks"
16 "github.com/bnema/vev/internal/usecase/keys"16 "github.com/bnema/vev/internal/usecase/keys"
17+ "github.com/bnema/vev/internal/usecase/ui"
17 "github.com/bnema/vev/pkg/renderer"18 "github.com/bnema/vev/pkg/renderer"
18 )19 )
19 20
@@ -54,7 +55,7 @@ func TestPaintDoesNotAckActiveAttentionOnBlankPulseFrame(t *testing.T) {
54 55
55 d.paint(sess, ac, true)56 d.paint(sess, ac, true)
56 data := mustOutputData(t, sends)57 data := mustOutputData(t, sends)
57- require.NotContains(t, string(data), string(attentionGlyph))58+ require.NotContains(t, string(data), string(ui.AttentionGlyph))
58 59
59 sess.mu.Lock()60 sess.mu.Lock()
60 require.True(t, sess.tabs[0].attention)61 require.True(t, sess.tabs[0].attention)
@@ -63,7 +64,7 @@ func TestPaintDoesNotAckActiveAttentionOnBlankPulseFrame(t *testing.T) {
63 d.setAttentionFrame(1)64 d.setAttentionFrame(1)
64 d.paint(sess, ac, true)65 d.paint(sess, ac, true)
65 data = mustOutputData(t, sends)66 data = mustOutputData(t, sends)
66- require.Contains(t, string(data), string(attentionGlyph))67+ require.Contains(t, string(data), string(ui.AttentionGlyph))
67 68
68 sess.mu.Lock()69 sess.mu.Lock()
69 require.False(t, sess.tabs[0].attention)70 require.False(t, sess.tabs[0].attention)
@@ -98,7 +99,7 @@ func TestPTYReaderActiveVisibleAgentNotificationRendersBellBeforeAck(t *testing.
98 99
99 d.paint(sess, ac, true)100 d.paint(sess, ac, true)
100 data := mustOutputData(t, sends)101 data := mustOutputData(t, sends)
101- require.Contains(t, string(data), string(attentionGlyph))102+ require.Contains(t, string(data), string(ui.AttentionGlyph))
102 103
103 sess.mu.Lock()104 sess.mu.Lock()
104 require.False(t, sess.tabs[0].attention)105 require.False(t, sess.tabs[0].attention)
@@ -106,7 +107,7 @@ func TestPTYReaderActiveVisibleAgentNotificationRendersBellBeforeAck(t *testing.
106 sess.mu.Unlock()107 sess.mu.Unlock()
107 108
108 cleared := mustOutputData(t, sends)109 cleared := mustOutputData(t, sends)
109- require.NotContains(t, string(cleared), string(attentionGlyph))110+ require.NotContains(t, string(cleared), string(ui.AttentionGlyph))
110 111
111 _ = pty.Close()112 _ = pty.Close()
112 d.sessWg.Wait()113 d.sessWg.Wait()
@@ -429,13 +430,13 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
429 430
430 d.paint(sessB, acB, true)431 d.paint(sessB, acB, true)
431 before := mustOutputData(t, sendsB)432 before := mustOutputData(t, sendsB)
432- require.Contains(t, string(before), string(attentionGlyph))433+ require.Contains(t, string(before), string(ui.AttentionGlyph))
433 434
434 d.closeTab(sessA, ringing, true)435 d.closeTab(sessA, ringing, true)
435 _ = mustOutputData(t, sendsA)436 _ = mustOutputData(t, sendsA)
436 437
437 after := mustOutputData(t, sendsB)438 after := mustOutputData(t, sendsB)
438- require.NotContains(t, string(after), string(attentionGlyph))439+ require.NotContains(t, string(after), string(ui.AttentionGlyph))
439 }440 }
440 441
441 func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) {442 func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) {
M internal/usecase/daemon/pane_titles.go +10 -2
@@ -72,10 +72,18 @@ func (p *pane) refreshTerminalTitleLocked() {
7272 // composeTabTitle renders "name (paneTitle)". An empty paneTitle, or one equal
7373 // to the tab name, collapses to just the name.
7474 func composeTabTitle(tabName, paneTitle string) string {
75+ return tabName + tabTitleDetail(tabName, paneTitle)
76+}
77+
78+// tabTitleDetail returns the parenthesized " (paneTitle)" suffix for
79+// tabName/paneTitle, or "" when paneTitle is empty or equals tabName. This is
80+// the single place the name/detail composition rule lives; composeTabTitle
81+// and the picker's TabEntry.Detail both build on it.
82+func tabTitleDetail(tabName, paneTitle string) string {
7583 if paneTitle == "" || paneTitle == tabName {
76- return tabName
84+ return ""
7785 }
78- return tabName + " (" + paneTitle + ")"
86+ return " (" + paneTitle + ")"
7987 }
8088
8189 // focusedPaneTitle returns the focused pane's display title for tab labels.
@@ -72,10 +72,18 @@ func (p *pane) refreshTerminalTitleLocked() {
72 // composeTabTitle renders "name (paneTitle)". An empty paneTitle, or one equal72 // composeTabTitle renders "name (paneTitle)". An empty paneTitle, or one equal
73 // to the tab name, collapses to just the name.73 // to the tab name, collapses to just the name.
74 func composeTabTitle(tabName, paneTitle string) string {74 func composeTabTitle(tabName, paneTitle string) string {
75+ return tabName + tabTitleDetail(tabName, paneTitle)
76+}
77+
78+// tabTitleDetail returns the parenthesized " (paneTitle)" suffix for
79+// tabName/paneTitle, or "" when paneTitle is empty or equals tabName. This is
80+// the single place the name/detail composition rule lives; composeTabTitle
81+// and the picker's TabEntry.Detail both build on it.
82+func tabTitleDetail(tabName, paneTitle string) string {
75 if paneTitle == "" || paneTitle == tabName {83 if paneTitle == "" || paneTitle == tabName {
76- return tabName84+ return ""
77 }85 }
78- return tabName + " (" + paneTitle + ")"86+ return " (" + paneTitle + ")"
79 }87 }
80 88
81 // focusedPaneTitle returns the focused pane's display title for tab labels.89 // focusedPaneTitle returns the focused pane's display title for tab labels.
M internal/usecase/daemon/picker.go +19 -8
@@ -72,15 +72,18 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
7272 curTab := 0
7373 for _, s := range sessions {
7474 s.mu.Lock()
75- view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}
75+ view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))}
7676 sessionAttention := false
7777 for i, tb := range s.tabs {
78- label := composeTabTitle(tabDisplayName(tb, i), tb.focusedPaneTitle(includeTerminalTitle))
78+ name := tabDisplayName(tb, i)
79+ view.Tabs[i] = picker.TabEntry{
80+ Name: name,
81+ Detail: tabTitleDetail(name, tb.focusedPaneTitle(includeTerminalTitle)),
82+ Attention: tb.attention,
83+ }
7984 if tb.attention {
80- label = attentionSuffix(label)
8185 sessionAttention = true
8286 }
83- view.Tabs[i] = label
8487 }
8588 if sessionAttention {
8689 view.Name = attentionSuffix(view.Name)
@@ -95,7 +98,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
9598 views = append(views, picker.SessionView{
9699 ID: domain.SessionID("stopped:" + s.name),
97100 Name: s.name,
98- Tabs: []string{""},
101+ Tabs: []picker.TabEntry{{}},
99102 Stopped: true,
100103 })
101104 }
@@ -103,7 +106,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
103106 }
104107
105108 func attentionSuffix(label string) string {
106- return label + " " + string(attentionGlyph)
109+ return label + " " + string(ui.AttentionGlyph)
107110 }
108111
109112 func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
@@ -490,8 +493,16 @@ func (d *Daemon) refreshPicker(ac *attachedClient) {
490493
491494 func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
492495 styleSet := resolveThemeStyles(styles)
493- return composeModalClientFrame(base, pickerModal, styleSet, styleSet.selection, func(size domain.Size, styles ...renderer.Style) renderer.Frame {
494- return model.Render(size, preview, styles...)
496+ renderStyles := picker.RenderStyles{
497+ Selection: styleSet.selection,
498+ SelectionName: styleSet.pickerSelectionName,
499+ SelectionMuted: styleSet.pickerSelectionMuted,
500+ Name: styleSet.pickerName,
501+ Detail: styleSet.paletteDesc,
502+ Base: renderer.DefaultStyle(),
503+ }
504+ return composeModalClientFrame(base, pickerModal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {
505+ return model.Render(size, preview, renderStyles)
495506 })
496507 }
497508
@@ -72,15 +72,18 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
72 curTab := 072 curTab := 0
73 for _, s := range sessions {73 for _, s := range sessions {
74 s.mu.Lock()74 s.mu.Lock()
75- view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}75+ view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))}
76 sessionAttention := false76 sessionAttention := false
77 for i, tb := range s.tabs {77 for i, tb := range s.tabs {
78- label := composeTabTitle(tabDisplayName(tb, i), tb.focusedPaneTitle(includeTerminalTitle))78+ name := tabDisplayName(tb, i)
79+ view.Tabs[i] = picker.TabEntry{
80+ Name: name,
81+ Detail: tabTitleDetail(name, tb.focusedPaneTitle(includeTerminalTitle)),
82+ Attention: tb.attention,
83+ }
79 if tb.attention {84 if tb.attention {
80- label = attentionSuffix(label)
81 sessionAttention = true85 sessionAttention = true
82 }86 }
83- view.Tabs[i] = label
84 }87 }
85 if sessionAttention {88 if sessionAttention {
86 view.Name = attentionSuffix(view.Name)89 view.Name = attentionSuffix(view.Name)
@@ -95,7 +98,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
95 views = append(views, picker.SessionView{98 views = append(views, picker.SessionView{
96 ID: domain.SessionID("stopped:" + s.name),99 ID: domain.SessionID("stopped:" + s.name),
97 Name: s.name,100 Name: s.name,
98- Tabs: []string{""},101+ Tabs: []picker.TabEntry{{}},
99 Stopped: true,102 Stopped: true,
100 })103 })
101 }104 }
@@ -103,7 +106,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
103 }106 }
104 107
105 func attentionSuffix(label string) string {108 func attentionSuffix(label string) string {
106- return label + " " + string(attentionGlyph)109+ return label + " " + string(ui.AttentionGlyph)
107 }110 }
108 111
109 func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {112 func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
@@ -490,8 +493,16 @@ func (d *Daemon) refreshPicker(ac *attachedClient) {
490 493
491 func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {494 func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
492 styleSet := resolveThemeStyles(styles)495 styleSet := resolveThemeStyles(styles)
493- return composeModalClientFrame(base, pickerModal, styleSet, styleSet.selection, func(size domain.Size, styles ...renderer.Style) renderer.Frame {496+ renderStyles := picker.RenderStyles{
494- return model.Render(size, preview, styles...)497+ Selection: styleSet.selection,
498+ SelectionName: styleSet.pickerSelectionName,
499+ SelectionMuted: styleSet.pickerSelectionMuted,
500+ Name: styleSet.pickerName,
501+ Detail: styleSet.paletteDesc,
502+ Base: renderer.DefaultStyle(),
503+ }
504+ return composeModalClientFrame(base, pickerModal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {
505+ return model.Render(size, preview, renderStyles)
495 })506 })
496 }507 }
497 508
M internal/usecase/daemon/picker_test.go +4 -4
@@ -61,9 +61,9 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
6161 require.Equal(t, 0, curTab)
6262 require.Len(t, views, 2)
6363 require.Equal(t, "alpha", views[0].Name)
64- require.Equal(t, []string{"1", "2"}, views[0].Tabs)
64+ require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs)
6565 require.Equal(t, "beta ", views[1].Name)
66- require.Equal(t, []string{"shell", "logs "}, views[1].Tabs)
66+ require.Equal(t, []picker.TabEntry{{Name: "shell"}, {Name: "logs", Attention: true}}, views[1].Tabs)
6767 }
6868
6969 func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {
@@ -88,7 +88,7 @@ func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {
8888 views, _ := d.pickerViews(sess)
8989
9090 require.Len(t, views, 1)
91- require.Equal(t, []string{"1 (vim)", attentionSuffix("logs (sh)")}, views[0].Tabs)
91+ require.Equal(t, []picker.TabEntry{{Name: "1", Detail: " (vim)"}, {Name: "logs", Detail: " (sh)", Attention: true}}, views[0].Tabs)
9292 }
9393
9494 func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) {
@@ -112,7 +112,7 @@ func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) {
112112 views, _ := d.pickerViews(sess)
113113
114114 require.Len(t, views, 1)
115- require.Equal(t, []string{"1 (vim)", "logs (sh)"}, views[0].Tabs)
115+ require.Equal(t, []picker.TabEntry{{Name: "1", Detail: " (vim)"}, {Name: "logs", Detail: " (sh)"}}, views[0].Tabs)
116116 }
117117
118118 func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
@@ -61,9 +61,9 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
61 require.Equal(t, 0, curTab)61 require.Equal(t, 0, curTab)
62 require.Len(t, views, 2)62 require.Len(t, views, 2)
63 require.Equal(t, "alpha", views[0].Name)63 require.Equal(t, "alpha", views[0].Name)
64- require.Equal(t, []string{"1", "2"}, views[0].Tabs)64+ require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs)
65 require.Equal(t, "beta ", views[1].Name)65 require.Equal(t, "beta ", views[1].Name)
66- require.Equal(t, []string{"shell", "logs "}, views[1].Tabs)66+ require.Equal(t, []picker.TabEntry{{Name: "shell"}, {Name: "logs", Attention: true}}, views[1].Tabs)
67 }67 }
68 68
69 func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {69 func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {
@@ -88,7 +88,7 @@ func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {
88 views, _ := d.pickerViews(sess)88 views, _ := d.pickerViews(sess)
89 89
90 require.Len(t, views, 1)90 require.Len(t, views, 1)
91- require.Equal(t, []string{"1 (vim)", attentionSuffix("logs (sh)")}, views[0].Tabs)91+ require.Equal(t, []picker.TabEntry{{Name: "1", Detail: " (vim)"}, {Name: "logs", Detail: " (sh)", Attention: true}}, views[0].Tabs)
92 }92 }
93 93
94 func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) {94 func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) {
@@ -112,7 +112,7 @@ func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) {
112 views, _ := d.pickerViews(sess)112 views, _ := d.pickerViews(sess)
113 113
114 require.Len(t, views, 1)114 require.Len(t, views, 1)
115- require.Equal(t, []string{"1 (vim)", "logs (sh)"}, views[0].Tabs)115+ require.Equal(t, []picker.TabEntry{{Name: "1", Detail: " (vim)"}, {Name: "logs", Detail: " (sh)"}}, views[0].Tabs)
116 }116 }
117 117
118 func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {118 func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
M internal/usecase/daemon/render.go +31 -5
@@ -370,7 +370,7 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
370370 d.refreshSessionFocusedTitles(sess)
371371 bars := d.barStateForClient(sess, ac, overlays.copyFeedback)
372372 bars.theme = ac.getTheme()
373- _, attentionVisible := pulseStyle(bars.attentionFrame)
373+ attentionVisible := pulseVisible(bars.attentionFrame)
374374 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
375375
376376 styles := newThemeStyles(ac.getTheme())
@@ -576,16 +576,42 @@ type themeStyles struct {
576576 selection renderer.Style
577577 copyStatus renderer.Style
578578 paletteDesc renderer.Style
579+
580+ // Top bar tab label segments: name (bold) and parenthesized pane-title
581+ // (muted), one pair per base style. No-ops to statusBar/accent on
582+ // non-truecolor themes.
583+ tabName renderer.Style
584+ tabNameActive renderer.Style
585+ tabTitle renderer.Style
586+ tabTitleActive renderer.Style
587+
588+ // Session picker row segments. Detail reuses paletteDesc (both are
589+ // themeui.MutedTextStyle(t)); pickerName/pickerSelection* are picker-only.
590+ pickerName renderer.Style
591+ pickerSelectionName renderer.Style
592+ pickerSelectionMuted renderer.Style
579593 }
580594
581595 func newThemeStyles(t themeui.Theme) themeStyles {
596+ statusBar := themeui.StatusBarStyle(t)
597+ accent := themeui.AccentStyle(t)
598+ selection := themeui.SelectionStyle(t)
582599 return themeStyles{
583- statusBar: themeui.StatusBarStyle(t),
584- accent: themeui.AccentStyle(t),
600+ statusBar: statusBar,
601+ accent: accent,
585602 border: themeui.BorderStyle(t),
586- selection: themeui.SelectionStyle(t),
587- copyStatus: themeui.SelectionStyle(t),
603+ selection: selection,
604+ copyStatus: selection,
588605 paletteDesc: themeui.MutedTextStyle(t),
606+
607+ tabName: themeui.EmphasisStyle(statusBar, t),
608+ tabNameActive: themeui.EmphasisStyle(accent, t),
609+ tabTitle: themeui.MutedVariantStyle(statusBar, t),
610+ tabTitleActive: themeui.MutedVariantStyle(accent, t),
611+
612+ pickerName: themeui.EmphasisStyle(renderer.DefaultStyle(), t),
613+ pickerSelectionName: themeui.EmphasisStyle(selection, t),
614+ pickerSelectionMuted: themeui.MutedVariantStyle(selection, t),
589615 }
590616 }
591617
@@ -370,7 +370,7 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
370 d.refreshSessionFocusedTitles(sess)370 d.refreshSessionFocusedTitles(sess)
371 bars := d.barStateForClient(sess, ac, overlays.copyFeedback)371 bars := d.barStateForClient(sess, ac, overlays.copyFeedback)
372 bars.theme = ac.getTheme()372 bars.theme = ac.getTheme()
373- _, attentionVisible := pulseStyle(bars.attentionFrame)373+ attentionVisible := pulseVisible(bars.attentionFrame)
374 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)374 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
375 375
376 styles := newThemeStyles(ac.getTheme())376 styles := newThemeStyles(ac.getTheme())
@@ -576,16 +576,42 @@ type themeStyles struct {
576 selection renderer.Style576 selection renderer.Style
577 copyStatus renderer.Style577 copyStatus renderer.Style
578 paletteDesc renderer.Style578 paletteDesc renderer.Style
579+
580+ // Top bar tab label segments: name (bold) and parenthesized pane-title
581+ // (muted), one pair per base style. No-ops to statusBar/accent on
582+ // non-truecolor themes.
583+ tabName renderer.Style
584+ tabNameActive renderer.Style
585+ tabTitle renderer.Style
586+ tabTitleActive renderer.Style
587+
588+ // Session picker row segments. Detail reuses paletteDesc (both are
589+ // themeui.MutedTextStyle(t)); pickerName/pickerSelection* are picker-only.
590+ pickerName renderer.Style
591+ pickerSelectionName renderer.Style
592+ pickerSelectionMuted renderer.Style
579 }593 }
580 594
581 func newThemeStyles(t themeui.Theme) themeStyles {595 func newThemeStyles(t themeui.Theme) themeStyles {
596+ statusBar := themeui.StatusBarStyle(t)
597+ accent := themeui.AccentStyle(t)
598+ selection := themeui.SelectionStyle(t)
582 return themeStyles{599 return themeStyles{
583- statusBar: themeui.StatusBarStyle(t),600+ statusBar: statusBar,
584- accent: themeui.AccentStyle(t),601+ accent: accent,
585 border: themeui.BorderStyle(t),602 border: themeui.BorderStyle(t),
586- selection: themeui.SelectionStyle(t),603+ selection: selection,
587- copyStatus: themeui.SelectionStyle(t),604+ copyStatus: selection,
588 paletteDesc: themeui.MutedTextStyle(t),605 paletteDesc: themeui.MutedTextStyle(t),
606+
607+ tabName: themeui.EmphasisStyle(statusBar, t),
608+ tabNameActive: themeui.EmphasisStyle(accent, t),
609+ tabTitle: themeui.MutedVariantStyle(statusBar, t),
610+ tabTitleActive: themeui.MutedVariantStyle(accent, t),
611+
612+ pickerName: themeui.EmphasisStyle(renderer.DefaultStyle(), t),
613+ pickerSelectionName: themeui.EmphasisStyle(selection, t),
614+ pickerSelectionMuted: themeui.MutedVariantStyle(selection, t),
589 }615 }
590 }616 }
591 617
M internal/usecase/daemon/status.go +63 -28
@@ -38,26 +38,40 @@ import (
3838 )
3939
4040 const (
41- attentionGlyph = ''
4241 maxMRUSessions = 9
4342 pulseFrameCount = 30
4443 pulseFrameInterval = 120 * time.Millisecond
4544 )
4645
47-func pulseStyle(frame int) (renderer.Style, bool) {
48- f := frame % pulseFrameCount
49- if f == 0 {
50- return renderer.DefaultStyle(), false
46+// pulseVisible reports whether the attention bell glyph is showing (as
47+// opposed to its blank beat) at frame.
48+func pulseVisible(frame int) bool {
49+ return frame%pulseFrameCount != 0
50+}
51+
52+// pulseStyle returns the style for the attention bell glyph at frame, built
53+// on top of base so the bell always keeps the caller's background (the tab's
54+// accent/statusBar, or a faded MRU entry's blended colors) instead of
55+// punching a default-background hole in a themed bar. On the invisible beat
56+// it returns (base, false) unchanged.
57+func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) {
58+ if !pulseVisible(frame) {
59+ return base, false
5160 }
61+ f := frame % pulseFrameCount
5262 peak := pulseFrameCount / 2
5363 distance := f - peak
5464 if distance < 0 {
5565 distance = -distance
5666 }
5767 intensity := 1 - float64(distance)/float64(peak)
58- style := renderer.DefaultStyle()
68+ style := base
5969 style.Bold = true
60- style.Foreground = 244 + int(intensity*11)
70+ if base.HasForegroundRGB && base.HasBackgroundRGB {
71+ style.ForegroundRGB = themeui.Blend(base.BackgroundRGB, base.ForegroundRGB, intensity)
72+ } else {
73+ style.Foreground = 244 + int(intensity*11)
74+ }
6175 return style, true
6276 }
6377
@@ -66,16 +80,22 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t
6680 x := 0
6781 labels := fitTabLabels(status.tabs, len(row), topRight)
6882 for i, w := range status.tabs {
69- style := styles.statusBar
83+ baseStyle := styles.statusBar
84+ nameStyle := styles.tabName
85+ titleStyle := styles.tabTitle
7086 if w.active {
71- style = styles.accent
87+ baseStyle = styles.accent
88+ nameStyle = styles.tabNameActive
89+ titleStyle = styles.tabTitleActive
7290 }
73- writeStatusText(row, &x, " "+labels[i], style)
91+ label := labels[i]
92+ writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle)
7493 if w.attention {
75- writeStatusText(row, &x, " ", style)
76- writeBell(row, &x, frame)
94+ writeStatusText(row, &x, " ", baseStyle)
95+ writeBell(row, &x, frame, baseStyle)
7796 }
78- writeStatusText(row, &x, " ", style)
97+ writeStatusText(row, &x, label.text[label.nameLen:], titleStyle)
98+ writeStatusText(row, &x, " ", baseStyle)
7999 }
80100 drawRightPlainText(row, topRight, x, styles.statusBar)
81101 }
@@ -117,7 +137,7 @@ func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, ephemeral,
117137 writeStatusText(row, x, " "+name, style)
118138 if attention {
119139 writeStatusText(row, x, " ", style)
120- writeBell(row, x, attentionFrame)
140+ writeBell(row, x, attentionFrame, style)
121141 }
122142 writeStatusText(row, x, " ", style)
123143 }
@@ -335,7 +355,7 @@ func fitHistoryNav(entries []historyNavSession, rowLen, leftUsed int, feedback s
335355 }
336356 n := 2 + statusTextWidth(name)
337357 if e.attention {
338- n += 1 + renderer.RuneWidth(attentionGlyph)
358+ n += 1 + renderer.RuneWidth(ui.AttentionGlyph)
339359 }
340360 return n
341361 }
@@ -383,9 +403,18 @@ func fitHistoryNav(entries []historyNavSession, rowLen, leftUsed int, feedback s
383403 return entries[start:end]
384404 }
385405
406+// fittedTabLabel is one tab's drawable label; text[:nameLen] is the tab-name
407+// segment, text[nameLen:] the pane-title segment (possibly empty). nameLen is
408+// always a byte offset landing on a rune boundary, since it is derived from
409+// the byte length of the (possibly truncated) name prefix.
410+type fittedTabLabel struct {
411+ text string
412+ nameLen int
413+}
414+
386415 // fitTabLabels returns, per tab, the text drawn between its surrounding
387416 // spaces (attention glyph handled by the caller), guaranteeing all tabs fit.
388-func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string {
417+func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []fittedTabLabel {
389418 reserve := 1
390419 if rightText != "" {
391420 reserve = statusTextWidth(rightText) + 2
@@ -400,15 +429,17 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string {
400429 full[i] = composeTabTitle(t.name, t.paneTitle)
401430 o := 2
402431 if t.attention {
403- o += 1 + renderer.RuneWidth(attentionGlyph)
432+ o += 1 + renderer.RuneWidth(ui.AttentionGlyph)
404433 }
405434 overhead[i] = o
406435 widths[i] = statusTextWidth(full[i])
407436 total += o + widths[i]
408437 }
409- labels := make([]string, len(tabs))
438+ labels := make([]fittedTabLabel, len(tabs))
410439 if total <= budget {
411- copy(labels, full)
440+ for i, t := range tabs {
441+ labels[i] = fittedTabLabel{text: full[i], nameLen: len(t.name)}
442+ }
412443 return labels
413444 }
414445
@@ -430,13 +461,14 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string {
430461 textBudget := share - overhead[i]
431462 switch {
432463 case textBudget <= 0:
433- labels[i] = ""
464+ labels[i] = fittedTabLabel{}
434465 case widths[i] <= textBudget:
435- labels[i] = full[i]
466+ labels[i] = fittedTabLabel{text: full[i], nameLen: len(tabs[i].name)}
436467 case textBudget >= statusTextWidth(tabs[i].name)+4:
437- labels[i] = ui.TruncateText(full[i], textBudget)
468+ labels[i] = fittedTabLabel{text: ui.TruncateText(full[i], textBudget), nameLen: len(tabs[i].name)}
438469 default:
439- labels[i] = ui.TruncateText(tabs[i].name, textBudget)
470+ text := ui.TruncateText(tabs[i].name, textBudget)
471+ labels[i] = fittedTabLabel{text: text, nameLen: len(text)}
440472 }
441473 consumed := overhead[i] + min(widths[i], max(textBudget, 0))
442474 remaining -= consumed
@@ -463,7 +495,7 @@ func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSe
463495 }
464496 n := 2 + statusTextWidth(name)
465497 if e.attention {
466- n += 1 + renderer.RuneWidth(attentionGlyph)
498+ n += 1 + renderer.RuneWidth(ui.AttentionGlyph)
467499 }
468500 return n
469501 }
@@ -521,13 +553,16 @@ func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style
521553 return base
522554 }
523555
524-func writeBell(row []renderer.Cell, x *int, frame int) {
525- style, visible := pulseStyle(frame)
556+// writeBell draws the attention glyph on its visible beat, or a plain cell in
557+// base's style on its blank beat, so the bell never leaves a bare
558+// default-background hole in a themed bar.
559+func writeBell(row []renderer.Cell, x *int, frame int, base renderer.Style) {
560+ style, visible := pulseStyle(frame, base)
526561 if !visible {
527- writeStatusText(row, x, " ", renderer.DefaultStyle())
562+ writeStatusText(row, x, " ", base)
528563 return
529564 }
530- writeStatusText(row, x, string(attentionGlyph), style)
565+ writeStatusText(row, x, string(ui.AttentionGlyph), style)
531566 }
532567
533568 func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {
@@ -38,26 +38,40 @@ import (
38 )38 )
39 39
40 const (40 const (
41- attentionGlyph = ''
42 maxMRUSessions = 941 maxMRUSessions = 9
43 pulseFrameCount = 3042 pulseFrameCount = 30
44 pulseFrameInterval = 120 * time.Millisecond43 pulseFrameInterval = 120 * time.Millisecond
45 )44 )
46 45
47-func pulseStyle(frame int) (renderer.Style, bool) {46+// pulseVisible reports whether the attention bell glyph is showing (as
48- f := frame % pulseFrameCount47+// opposed to its blank beat) at frame.
49- if f == 0 {48+func pulseVisible(frame int) bool {
50- return renderer.DefaultStyle(), false49+ return frame%pulseFrameCount != 0
50+}
51+
52+// pulseStyle returns the style for the attention bell glyph at frame, built
53+// on top of base so the bell always keeps the caller's background (the tab's
54+// accent/statusBar, or a faded MRU entry's blended colors) instead of
55+// punching a default-background hole in a themed bar. On the invisible beat
56+// it returns (base, false) unchanged.
57+func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) {
58+ if !pulseVisible(frame) {
59+ return base, false
51 }60 }
61+ f := frame % pulseFrameCount
52 peak := pulseFrameCount / 262 peak := pulseFrameCount / 2
53 distance := f - peak63 distance := f - peak
54 if distance < 0 {64 if distance < 0 {
55 distance = -distance65 distance = -distance
56 }66 }
57 intensity := 1 - float64(distance)/float64(peak)67 intensity := 1 - float64(distance)/float64(peak)
58- style := renderer.DefaultStyle()68+ style := base
59 style.Bold = true69 style.Bold = true
60- style.Foreground = 244 + int(intensity*11)70+ if base.HasForegroundRGB && base.HasBackgroundRGB {
71+ style.ForegroundRGB = themeui.Blend(base.BackgroundRGB, base.ForegroundRGB, intensity)
72+ } else {
73+ style.Foreground = 244 + int(intensity*11)
74+ }
61 return style, true75 return style, true
62 }76 }
63 77
@@ -66,16 +80,22 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t
66 x := 080 x := 0
67 labels := fitTabLabels(status.tabs, len(row), topRight)81 labels := fitTabLabels(status.tabs, len(row), topRight)
68 for i, w := range status.tabs {82 for i, w := range status.tabs {
69- style := styles.statusBar83+ baseStyle := styles.statusBar
84+ nameStyle := styles.tabName
85+ titleStyle := styles.tabTitle
70 if w.active {86 if w.active {
71- style = styles.accent87+ baseStyle = styles.accent
88+ nameStyle = styles.tabNameActive
89+ titleStyle = styles.tabTitleActive
72 }90 }
73- writeStatusText(row, &x, " "+labels[i], style)91+ label := labels[i]
92+ writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle)
74 if w.attention {93 if w.attention {
75- writeStatusText(row, &x, " ", style)94+ writeStatusText(row, &x, " ", baseStyle)
76- writeBell(row, &x, frame)95+ writeBell(row, &x, frame, baseStyle)
77 }96 }
78- writeStatusText(row, &x, " ", style)97+ writeStatusText(row, &x, label.text[label.nameLen:], titleStyle)
98+ writeStatusText(row, &x, " ", baseStyle)
79 }99 }
80 drawRightPlainText(row, topRight, x, styles.statusBar)100 drawRightPlainText(row, topRight, x, styles.statusBar)
81 }101 }
@@ -117,7 +137,7 @@ func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, ephemeral,
117 writeStatusText(row, x, " "+name, style)137 writeStatusText(row, x, " "+name, style)
118 if attention {138 if attention {
119 writeStatusText(row, x, " ", style)139 writeStatusText(row, x, " ", style)
120- writeBell(row, x, attentionFrame)140+ writeBell(row, x, attentionFrame, style)
121 }141 }
122 writeStatusText(row, x, " ", style)142 writeStatusText(row, x, " ", style)
123 }143 }
@@ -335,7 +355,7 @@ func fitHistoryNav(entries []historyNavSession, rowLen, leftUsed int, feedback s
335 }355 }
336 n := 2 + statusTextWidth(name)356 n := 2 + statusTextWidth(name)
337 if e.attention {357 if e.attention {
338- n += 1 + renderer.RuneWidth(attentionGlyph)358+ n += 1 + renderer.RuneWidth(ui.AttentionGlyph)
339 }359 }
340 return n360 return n
341 }361 }
@@ -383,9 +403,18 @@ func fitHistoryNav(entries []historyNavSession, rowLen, leftUsed int, feedback s
383 return entries[start:end]403 return entries[start:end]
384 }404 }
385 405
406+// fittedTabLabel is one tab's drawable label; text[:nameLen] is the tab-name
407+// segment, text[nameLen:] the pane-title segment (possibly empty). nameLen is
408+// always a byte offset landing on a rune boundary, since it is derived from
409+// the byte length of the (possibly truncated) name prefix.
410+type fittedTabLabel struct {
411+ text string
412+ nameLen int
413+}
414+
386 // fitTabLabels returns, per tab, the text drawn between its surrounding415 // fitTabLabels returns, per tab, the text drawn between its surrounding
387 // spaces (attention glyph handled by the caller), guaranteeing all tabs fit.416 // spaces (attention glyph handled by the caller), guaranteeing all tabs fit.
388-func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string {417+func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []fittedTabLabel {
389 reserve := 1418 reserve := 1
390 if rightText != "" {419 if rightText != "" {
391 reserve = statusTextWidth(rightText) + 2420 reserve = statusTextWidth(rightText) + 2
@@ -400,15 +429,17 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string {
400 full[i] = composeTabTitle(t.name, t.paneTitle)429 full[i] = composeTabTitle(t.name, t.paneTitle)
401 o := 2430 o := 2
402 if t.attention {431 if t.attention {
403- o += 1 + renderer.RuneWidth(attentionGlyph)432+ o += 1 + renderer.RuneWidth(ui.AttentionGlyph)
404 }433 }
405 overhead[i] = o434 overhead[i] = o
406 widths[i] = statusTextWidth(full[i])435 widths[i] = statusTextWidth(full[i])
407 total += o + widths[i]436 total += o + widths[i]
408 }437 }
409- labels := make([]string, len(tabs))438+ labels := make([]fittedTabLabel, len(tabs))
410 if total <= budget {439 if total <= budget {
411- copy(labels, full)440+ for i, t := range tabs {
441+ labels[i] = fittedTabLabel{text: full[i], nameLen: len(t.name)}
442+ }
412 return labels443 return labels
413 }444 }
414 445
@@ -430,13 +461,14 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string {
430 textBudget := share - overhead[i]461 textBudget := share - overhead[i]
431 switch {462 switch {
432 case textBudget <= 0:463 case textBudget <= 0:
433- labels[i] = ""464+ labels[i] = fittedTabLabel{}
434 case widths[i] <= textBudget:465 case widths[i] <= textBudget:
435- labels[i] = full[i]466+ labels[i] = fittedTabLabel{text: full[i], nameLen: len(tabs[i].name)}
436 case textBudget >= statusTextWidth(tabs[i].name)+4:467 case textBudget >= statusTextWidth(tabs[i].name)+4:
437- labels[i] = ui.TruncateText(full[i], textBudget)468+ labels[i] = fittedTabLabel{text: ui.TruncateText(full[i], textBudget), nameLen: len(tabs[i].name)}
438 default:469 default:
439- labels[i] = ui.TruncateText(tabs[i].name, textBudget)470+ text := ui.TruncateText(tabs[i].name, textBudget)
471+ labels[i] = fittedTabLabel{text: text, nameLen: len(text)}
440 }472 }
441 consumed := overhead[i] + min(widths[i], max(textBudget, 0))473 consumed := overhead[i] + min(widths[i], max(textBudget, 0))
442 remaining -= consumed474 remaining -= consumed
@@ -463,7 +495,7 @@ func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSe
463 }495 }
464 n := 2 + statusTextWidth(name)496 n := 2 + statusTextWidth(name)
465 if e.attention {497 if e.attention {
466- n += 1 + renderer.RuneWidth(attentionGlyph)498+ n += 1 + renderer.RuneWidth(ui.AttentionGlyph)
467 }499 }
468 return n500 return n
469 }501 }
@@ -521,13 +553,16 @@ func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style
521 return base553 return base
522 }554 }
523 555
524-func writeBell(row []renderer.Cell, x *int, frame int) {556+// writeBell draws the attention glyph on its visible beat, or a plain cell in
525- style, visible := pulseStyle(frame)557+// base's style on its blank beat, so the bell never leaves a bare
558+// default-background hole in a themed bar.
559+func writeBell(row []renderer.Cell, x *int, frame int, base renderer.Style) {
560+ style, visible := pulseStyle(frame, base)
526 if !visible {561 if !visible {
527- writeStatusText(row, x, " ", renderer.DefaultStyle())562+ writeStatusText(row, x, " ", base)
528 return563 return
529 }564 }
530- writeStatusText(row, x, string(attentionGlyph), style)565+ writeStatusText(row, x, string(ui.AttentionGlyph), style)
531 }566 }
532 567
533 func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {568 func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {
M internal/usecase/daemon/status_test.go +193 -22
@@ -7,6 +7,7 @@ import (
77 "sync"
88 "testing"
99 "time"
10+ "unicode/utf8"
1011
1112 "github.com/stretchr/testify/require"
1213
@@ -14,6 +15,7 @@ import (
1415 "github.com/bnema/vev/internal/ports"
1516 "github.com/bnema/vev/internal/usecase/layout"
1617 themeui "github.com/bnema/vev/internal/usecase/theme"
18+ "github.com/bnema/vev/internal/usecase/ui"
1719 "github.com/bnema/vev/pkg/renderer"
1820 )
1921
@@ -162,19 +164,19 @@ func TestFitTabLabels(t *testing.T) {
162164 tabs []statusTab
163165 rowLen int
164166 rightText string
165- want []string
167+ want []fittedTabLabel
166168 }{
167169 {
168170 name: "no tabs",
169171 tabs: nil,
170172 rowLen: 10,
171- want: []string{},
173+ want: []fittedTabLabel{},
172174 },
173175 {
174176 name: "everything fits stays untruncated",
175177 tabs: []statusTab{{name: "one"}, {name: "two", paneTitle: "shell"}},
176178 rowLen: 40,
177- want: []string{"one", "two (shell)"},
179+ want: []fittedTabLabel{{text: "one", nameLen: len("one")}, {text: "two (shell)", nameLen: len("two")}},
178180 },
179181 {
180182 name: "overflow redistributes budget: short tab stays full, long tab gets ellipsis",
@@ -183,26 +185,26 @@ func TestFitTabLabels(t *testing.T) {
183185 {name: "ed", paneTitle: "verylongtitle"},
184186 },
185187 rowLen: 20,
186- want: []string{"a", "ed (verylongt…"},
188+ want: []fittedTabLabel{{text: "a", nameLen: len("a")}, {text: "ed (verylongt…", nameLen: len("ed")}},
187189 },
188190 {
189191 name: "tight budget degrades to a truncated tab name only",
190192 tabs: []statusTab{{name: "editor", paneTitle: "some pane title text"}},
191193 rowLen: 7,
192- want: []string{"edi…"},
194+ want: []fittedTabLabel{{text: "edi…", nameLen: len("edi…")}},
193195 },
194196 {
195197 name: "non-empty rightText shrinks the budget",
196198 tabs: []statusTab{{name: "aaaaaaaaaa"}},
197199 rowLen: 20,
198200 rightText: "0123456789",
199- want: []string{"aaaaa…"},
201+ want: []fittedTabLabel{{text: "aaaaa…", nameLen: len("aaaaa…")}},
200202 },
201203 {
202204 name: "single tab wider than the whole row collapses to empty",
203205 tabs: []statusTab{{name: "reallyverylongtabname"}},
204206 rowLen: 3,
205- want: []string{""},
207+ want: []fittedTabLabel{{}},
206208 },
207209 }
208210 for _, tt := range tests {
@@ -220,9 +222,13 @@ func TestFitTabLabels(t *testing.T) {
220222 for i, tb := range tt.tabs {
221223 overhead := 2
222224 if tb.attention {
223- overhead += 1 + renderer.RuneWidth(attentionGlyph)
225+ overhead += 1 + renderer.RuneWidth(ui.AttentionGlyph)
226+ }
227+ drawn += overhead + statusTextWidth(got[i].text)
228+ require.LessOrEqual(t, got[i].nameLen, len(got[i].text), "nameLen must not exceed the label's byte length")
229+ if got[i].nameLen < len(got[i].text) {
230+ require.True(t, utf8.RuneStart(got[i].text[got[i].nameLen]), "nameLen must land on a rune boundary")
224231 }
225- drawn += overhead + statusTextWidth(got[i])
226232 }
227233 if budget > 0 {
228234 require.LessOrEqual(t, drawn, budget, "drawn width must not exceed the derived budget")
@@ -231,6 +237,141 @@ func TestFitTabLabels(t *testing.T) {
231237 }
232238 }
233239
240+func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
241+ tabs := []statusTab{
242+ {name: "api", paneTitle: "shell"},
243+ {name: "logs", active: true, attention: true},
244+ {name: "err", paneTitle: "build failed", attention: true},
245+ }
246+ status := statusSnapshot{tabs: tabs}
247+ const rowLen = 60
248+
249+ t.Run("truecolor theme bolds names, mutes titles, and the bell keeps the base background", func(t *testing.T) {
250+ theme := themeui.Theme{
251+ Foreground: renderer.RGB{R: 200, G: 200, B: 200},
252+ Background: renderer.RGB{R: 10, G: 20, B: 30},
253+ HasFG: true,
254+ HasBG: true,
255+ Known: true,
256+ TrueColor: true,
257+ }
258+ styles := newThemeStyles(theme)
259+ require.False(t, styles.tabTitle.Equal(styles.statusBar), "sanity: muted title style must differ from the base style on a truecolor theme")
260+ require.False(t, styles.tabTitleActive.Equal(styles.accent), "sanity: muted active-title style must differ from the base accent style")
261+
262+ labels := fitTabLabels(tabs, rowLen, "")
263+
264+ // Frame 0 is the bell's blank beat: the attention space and the blank
265+ // bell cell must both be exactly the tab's base style (not
266+ // renderer.DefaultStyle(), which would punch a hole in a themed bar).
267+ // This also pins down segment order: name, then (if attention)
268+ // space+bell, then title, then the trailing space.
269+ row := make([]renderer.Cell, rowLen)
270+ drawTopBarSnapshot(row, status, 0, "", styles)
271+ x := 0
272+
273+ // tab 0: inactive, no attention: name, title, trailing space.
274+ nameEnd := x + 1 + labels[0].nameLen
275+ for i := x; i < nameEnd; i++ {
276+ require.True(t, row[i].Style.Bold, "cell %d: name segment must be bold", i)
277+ require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: name segment style mismatch", i)
278+ }
279+ x = nameEnd
280+ titleLen := len(labels[0].text) - labels[0].nameLen
281+ titleEnd := x + titleLen
282+ for i := x; i < titleEnd; i++ {
283+ require.False(t, row[i].Style.Bold, "cell %d: title segment must not be bold", i)
284+ require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: title segment style mismatch", i)
285+ }
286+ x = titleEnd
287+ require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 0 trailing space must keep the base style", x)
288+ x++
289+
290+ // tab 1: active, attention, no title: name, attention space+bell (both
291+ // base style at frame 0), trailing space.
292+ nameEnd = x + 1 + labels[1].nameLen
293+ for i := x; i < nameEnd; i++ {
294+ require.True(t, row[i].Style.Bold, "cell %d: active name segment must be bold", i)
295+ require.True(t, row[i].Style.Equal(styles.tabNameActive), "cell %d: active name segment style mismatch", i)
296+ }
297+ require.Equal(t, len(labels[1].text), labels[1].nameLen, "tab 1 has no pane title, so its whole label is the name segment")
298+ x = nameEnd
299+ require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 attention leading space must keep the active base style", x)
300+ x++
301+ tab1Bell := x
302+ require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 blank bell must keep the active base style, not DefaultStyle", x)
303+ x++
304+ require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 trailing space must keep the active base style", x)
305+ x++
306+
307+ // tab 2: inactive, attention, with title: name, attention space+bell
308+ // (both base style at frame 0), title, trailing space.
309+ nameEnd = x + 1 + labels[2].nameLen
310+ for i := x; i < nameEnd; i++ {
311+ require.True(t, row[i].Style.Bold, "cell %d: tab 2 name segment must be bold", i)
312+ require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: tab 2 name segment style mismatch", i)
313+ }
314+ x = nameEnd
315+ require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 attention leading space must keep the base style", x)
316+ x++
317+ tab2Bell := x
318+ require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 blank bell must keep the base style, not DefaultStyle", x)
319+ x++
320+ titleLen = len(labels[2].text) - labels[2].nameLen
321+ titleEnd = x + titleLen
322+ for i := x; i < titleEnd; i++ {
323+ require.False(t, row[i].Style.Bold, "cell %d: tab 2 title segment must not be bold", i)
324+ require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: tab 2 title segment style mismatch", i)
325+ }
326+ x = titleEnd
327+ require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 trailing space must keep the base style", x)
328+
329+ // On a visible pulse frame, the bell glyph itself must keep the same
330+ // background as its tab's base style.
331+ visibleRow := make([]renderer.Cell, rowLen)
332+ drawTopBarSnapshot(visibleRow, status, 1, "", styles)
333+ require.Equal(t, rune(ui.AttentionGlyph), visibleRow[tab1Bell].Rune)
334+ require.True(t, visibleRow[tab1Bell].Style.HasBackgroundRGB)
335+ require.Equal(t, styles.accent.BackgroundRGB, visibleRow[tab1Bell].Style.BackgroundRGB, "tab 1's visible bell must keep the active base background")
336+ require.Equal(t, rune(ui.AttentionGlyph), visibleRow[tab2Bell].Rune)
337+ require.True(t, visibleRow[tab2Bell].Style.HasBackgroundRGB)
338+ require.Equal(t, styles.statusBar.BackgroundRGB, visibleRow[tab2Bell].Style.BackgroundRGB, "tab 2's visible bell must keep the plain base background")
339+ })
340+
341+ t.Run("non-usable theme matches the pre-change fallback styles", func(t *testing.T) {
342+ styles := resolveThemeStyles(nil)
343+ require.True(t, styles.tabName.Equal(styles.statusBar))
344+ require.True(t, styles.tabTitle.Equal(styles.statusBar))
345+ require.True(t, styles.tabNameActive.Equal(styles.accent))
346+ require.True(t, styles.tabTitleActive.Equal(styles.accent))
347+
348+ row := make([]renderer.Cell, rowLen)
349+ drawTopBarSnapshot(row, status, 0, "", styles)
350+
351+ // " api (shell) " (tab 0) + " logs " (tab 1: name, attention
352+ // space+blank bell, trailing space) + " err (build failed) " (tab 2:
353+ // name, attention space+blank bell, title, trailing space), then blank
354+ // padding to rowLen: byte-for-byte what the pre-change renderer
355+ // produced, since every new style field collapses to statusBar/accent.
356+ const tab0 = " api (shell) "
357+ const tab1 = " logs "
358+ const tab2 = " err (build failed) "
359+ require.Equal(t, tab0+tab1+tab2+strings.Repeat(" ", rowLen-len(tab0)-len(tab1)-len(tab2)), rowText(row))
360+
361+ for i, c := range row[:len(tab0)] {
362+ require.True(t, c.Style.Equal(styles.statusBar), "cell %d should use the plain base style", i)
363+ }
364+ tab1Start := len(tab0)
365+ for i := tab1Start; i < tab1Start+len(tab1); i++ {
366+ require.True(t, row[i].Style.Equal(styles.accent), "cell %d should use the plain accent style, including the blank bell cell", i)
367+ }
368+ tab2Start := tab1Start + len(tab1)
369+ for i := tab2Start; i < tab2Start+len(tab2); i++ {
370+ require.True(t, row[i].Style.Equal(styles.statusBar), "cell %d should use the plain base style, including the blank bell cell", i)
371+ }
372+ })
373+}
374+
234375 func TestTabDisplayName(t *testing.T) {
235376 tests := []struct {
236377 name string
@@ -720,20 +861,49 @@ func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
720861 }
721862
722863 func TestAttentionConstants(t *testing.T) {
723- require.Equal(t, '', attentionGlyph)
864+ require.Equal(t, '', ui.AttentionGlyph)
724865 require.Equal(t, 30, pulseFrameCount)
725866 require.Equal(t, 120*time.Millisecond, pulseFrameInterval)
726867 }
727868
728-func TestPulseStyleHidesAtFrameZeroAndRamps(t *testing.T) {
729- _, visible := pulseStyle(0)
730- require.False(t, visible)
869+func TestPulseStyleFadesFromBaseAndHidesAtFrameZero(t *testing.T) {
870+ rgbBase := renderer.Style{
871+ HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 200, G: 200, B: 200},
872+ HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 10, G: 20, B: 30},
873+ }
874+ indexedBase := renderer.Style{Foreground: -1, Background: -1, Inverse: true}
875+
876+ t.Run("invisible frame returns base unchanged", func(t *testing.T) {
877+ for _, base := range []renderer.Style{rgbBase, indexedBase} {
878+ style, visible := pulseStyle(0, base)
879+ require.False(t, visible)
880+ require.True(t, style.Equal(base))
881+ }
882+ })
883+
884+ t.Run("rgb base blends the glyph foreground from the base background to its foreground, keeping the base background", func(t *testing.T) {
885+ low, visible := pulseStyle(1, rgbBase)
886+ require.True(t, visible)
887+ require.True(t, low.Bold)
888+ require.True(t, low.HasBackgroundRGB)
889+ require.Equal(t, rgbBase.BackgroundRGB, low.BackgroundRGB, "bell keeps the caller's background so it never punches a hole in a themed bar")
890+
891+ peak, visible := pulseStyle(pulseFrameCount/2, rgbBase)
892+ require.True(t, visible)
893+ require.Equal(t, rgbBase.ForegroundRGB, peak.ForegroundRGB, "peak intensity reaches the base foreground exactly")
894+ require.NotEqual(t, low.ForegroundRGB, peak.ForegroundRGB, "the glyph should ramp, not jump straight to full intensity")
895+ })
896+
897+ t.Run("non-RGB base falls back to the indexed grey ramp and preserves other base attributes", func(t *testing.T) {
898+ low, visible := pulseStyle(1, indexedBase)
899+ require.True(t, visible)
900+ require.True(t, low.Bold)
901+ require.True(t, low.Inverse, "non-color base attributes like inverse must survive")
731902
732- low, visible := pulseStyle(1)
733- require.True(t, visible)
734- peak, visible := pulseStyle(pulseFrameCount / 2)
735- require.True(t, visible)
736- require.Greater(t, peak.Foreground, low.Foreground)
903+ peak, visible := pulseStyle(pulseFrameCount/2, indexedBase)
904+ require.True(t, visible)
905+ require.Greater(t, peak.Foreground, low.Foreground)
906+ })
737907 }
738908
739909 func TestTopBarRendersAttentionBell(t *testing.T) {
@@ -753,10 +923,11 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
753923 frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(true), attentionFrame: 1}, win, true)
754924
755925 // Tab 2's label is enriched with its focused pane's title ("sh", the
756- // default shell fallback) and truncated to fit alongside the bell.
757- require.Contains(t, rowText(frame.Row(0)), "2 (s")
926+ // default shell fallback) and truncated to fit alongside the bell. The
927+ // bell sits right after the name, before the (title) segment.
928+ require.Contains(t, rowText(frame.Row(0)), "2 "+string(ui.AttentionGlyph)+" (s")
758929 for _, c := range frame.Row(0) {
759- if c.Rune == attentionGlyph {
930+ if c.Rune == ui.AttentionGlyph {
760931 require.True(t, c.Style.Bold)
761932 return
762933 }
@@ -839,7 +1010,7 @@ func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) {
8391010
8401011 require.Equal(t, " cur fresh tmp*  ", rowText(row))
8411012 for _, c := range row {
842- if c.Rune == attentionGlyph {
1013+ if c.Rune == ui.AttentionGlyph {
8431014 require.True(t, c.Style.Bold)
8441015 return
8451016 }
@@ -7,6 +7,7 @@ import (
7 "sync"7 "sync"
8 "testing"8 "testing"
9 "time"9 "time"
10+ "unicode/utf8"
10 11
11 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
12 13
@@ -14,6 +15,7 @@ import (
14 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
15 "github.com/bnema/vev/internal/usecase/layout"16 "github.com/bnema/vev/internal/usecase/layout"
16 themeui "github.com/bnema/vev/internal/usecase/theme"17 themeui "github.com/bnema/vev/internal/usecase/theme"
18+ "github.com/bnema/vev/internal/usecase/ui"
17 "github.com/bnema/vev/pkg/renderer"19 "github.com/bnema/vev/pkg/renderer"
18 )20 )
19 21
@@ -162,19 +164,19 @@ func TestFitTabLabels(t *testing.T) {
162 tabs []statusTab164 tabs []statusTab
163 rowLen int165 rowLen int
164 rightText string166 rightText string
165- want []string167+ want []fittedTabLabel
166 }{168 }{
167 {169 {
168 name: "no tabs",170 name: "no tabs",
169 tabs: nil,171 tabs: nil,
170 rowLen: 10,172 rowLen: 10,
171- want: []string{},173+ want: []fittedTabLabel{},
172 },174 },
173 {175 {
174 name: "everything fits stays untruncated",176 name: "everything fits stays untruncated",
175 tabs: []statusTab{{name: "one"}, {name: "two", paneTitle: "shell"}},177 tabs: []statusTab{{name: "one"}, {name: "two", paneTitle: "shell"}},
176 rowLen: 40,178 rowLen: 40,
177- want: []string{"one", "two (shell)"},179+ want: []fittedTabLabel{{text: "one", nameLen: len("one")}, {text: "two (shell)", nameLen: len("two")}},
178 },180 },
179 {181 {
180 name: "overflow redistributes budget: short tab stays full, long tab gets ellipsis",182 name: "overflow redistributes budget: short tab stays full, long tab gets ellipsis",
@@ -183,26 +185,26 @@ func TestFitTabLabels(t *testing.T) {
183 {name: "ed", paneTitle: "verylongtitle"},185 {name: "ed", paneTitle: "verylongtitle"},
184 },186 },
185 rowLen: 20,187 rowLen: 20,
186- want: []string{"a", "ed (verylongt…"},188+ want: []fittedTabLabel{{text: "a", nameLen: len("a")}, {text: "ed (verylongt…", nameLen: len("ed")}},
187 },189 },
188 {190 {
189 name: "tight budget degrades to a truncated tab name only",191 name: "tight budget degrades to a truncated tab name only",
190 tabs: []statusTab{{name: "editor", paneTitle: "some pane title text"}},192 tabs: []statusTab{{name: "editor", paneTitle: "some pane title text"}},
191 rowLen: 7,193 rowLen: 7,
192- want: []string{"edi…"},194+ want: []fittedTabLabel{{text: "edi…", nameLen: len("edi…")}},
193 },195 },
194 {196 {
195 name: "non-empty rightText shrinks the budget",197 name: "non-empty rightText shrinks the budget",
196 tabs: []statusTab{{name: "aaaaaaaaaa"}},198 tabs: []statusTab{{name: "aaaaaaaaaa"}},
197 rowLen: 20,199 rowLen: 20,
198 rightText: "0123456789",200 rightText: "0123456789",
199- want: []string{"aaaaa…"},201+ want: []fittedTabLabel{{text: "aaaaa…", nameLen: len("aaaaa…")}},
200 },202 },
201 {203 {
202 name: "single tab wider than the whole row collapses to empty",204 name: "single tab wider than the whole row collapses to empty",
203 tabs: []statusTab{{name: "reallyverylongtabname"}},205 tabs: []statusTab{{name: "reallyverylongtabname"}},
204 rowLen: 3,206 rowLen: 3,
205- want: []string{""},207+ want: []fittedTabLabel{{}},
206 },208 },
207 }209 }
208 for _, tt := range tests {210 for _, tt := range tests {
@@ -220,9 +222,13 @@ func TestFitTabLabels(t *testing.T) {
220 for i, tb := range tt.tabs {222 for i, tb := range tt.tabs {
221 overhead := 2223 overhead := 2
222 if tb.attention {224 if tb.attention {
223- overhead += 1 + renderer.RuneWidth(attentionGlyph)225+ overhead += 1 + renderer.RuneWidth(ui.AttentionGlyph)
226+ }
227+ drawn += overhead + statusTextWidth(got[i].text)
228+ require.LessOrEqual(t, got[i].nameLen, len(got[i].text), "nameLen must not exceed the label's byte length")
229+ if got[i].nameLen < len(got[i].text) {
230+ require.True(t, utf8.RuneStart(got[i].text[got[i].nameLen]), "nameLen must land on a rune boundary")
224 }231 }
225- drawn += overhead + statusTextWidth(got[i])
226 }232 }
227 if budget > 0 {233 if budget > 0 {
228 require.LessOrEqual(t, drawn, budget, "drawn width must not exceed the derived budget")234 require.LessOrEqual(t, drawn, budget, "drawn width must not exceed the derived budget")
@@ -231,6 +237,141 @@ func TestFitTabLabels(t *testing.T) {
231 }237 }
232 }238 }
233 239
240+func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
241+ tabs := []statusTab{
242+ {name: "api", paneTitle: "shell"},
243+ {name: "logs", active: true, attention: true},
244+ {name: "err", paneTitle: "build failed", attention: true},
245+ }
246+ status := statusSnapshot{tabs: tabs}
247+ const rowLen = 60
248+
249+ t.Run("truecolor theme bolds names, mutes titles, and the bell keeps the base background", func(t *testing.T) {
250+ theme := themeui.Theme{
251+ Foreground: renderer.RGB{R: 200, G: 200, B: 200},
252+ Background: renderer.RGB{R: 10, G: 20, B: 30},
253+ HasFG: true,
254+ HasBG: true,
255+ Known: true,
256+ TrueColor: true,
257+ }
258+ styles := newThemeStyles(theme)
259+ require.False(t, styles.tabTitle.Equal(styles.statusBar), "sanity: muted title style must differ from the base style on a truecolor theme")
260+ require.False(t, styles.tabTitleActive.Equal(styles.accent), "sanity: muted active-title style must differ from the base accent style")
261+
262+ labels := fitTabLabels(tabs, rowLen, "")
263+
264+ // Frame 0 is the bell's blank beat: the attention space and the blank
265+ // bell cell must both be exactly the tab's base style (not
266+ // renderer.DefaultStyle(), which would punch a hole in a themed bar).
267+ // This also pins down segment order: name, then (if attention)
268+ // space+bell, then title, then the trailing space.
269+ row := make([]renderer.Cell, rowLen)
270+ drawTopBarSnapshot(row, status, 0, "", styles)
271+ x := 0
272+
273+ // tab 0: inactive, no attention: name, title, trailing space.
274+ nameEnd := x + 1 + labels[0].nameLen
275+ for i := x; i < nameEnd; i++ {
276+ require.True(t, row[i].Style.Bold, "cell %d: name segment must be bold", i)
277+ require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: name segment style mismatch", i)
278+ }
279+ x = nameEnd
280+ titleLen := len(labels[0].text) - labels[0].nameLen
281+ titleEnd := x + titleLen
282+ for i := x; i < titleEnd; i++ {
283+ require.False(t, row[i].Style.Bold, "cell %d: title segment must not be bold", i)
284+ require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: title segment style mismatch", i)
285+ }
286+ x = titleEnd
287+ require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 0 trailing space must keep the base style", x)
288+ x++
289+
290+ // tab 1: active, attention, no title: name, attention space+bell (both
291+ // base style at frame 0), trailing space.
292+ nameEnd = x + 1 + labels[1].nameLen
293+ for i := x; i < nameEnd; i++ {
294+ require.True(t, row[i].Style.Bold, "cell %d: active name segment must be bold", i)
295+ require.True(t, row[i].Style.Equal(styles.tabNameActive), "cell %d: active name segment style mismatch", i)
296+ }
297+ require.Equal(t, len(labels[1].text), labels[1].nameLen, "tab 1 has no pane title, so its whole label is the name segment")
298+ x = nameEnd
299+ require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 attention leading space must keep the active base style", x)
300+ x++
301+ tab1Bell := x
302+ require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 blank bell must keep the active base style, not DefaultStyle", x)
303+ x++
304+ require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 trailing space must keep the active base style", x)
305+ x++
306+
307+ // tab 2: inactive, attention, with title: name, attention space+bell
308+ // (both base style at frame 0), title, trailing space.
309+ nameEnd = x + 1 + labels[2].nameLen
310+ for i := x; i < nameEnd; i++ {
311+ require.True(t, row[i].Style.Bold, "cell %d: tab 2 name segment must be bold", i)
312+ require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: tab 2 name segment style mismatch", i)
313+ }
314+ x = nameEnd
315+ require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 attention leading space must keep the base style", x)
316+ x++
317+ tab2Bell := x
318+ require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 blank bell must keep the base style, not DefaultStyle", x)
319+ x++
320+ titleLen = len(labels[2].text) - labels[2].nameLen
321+ titleEnd = x + titleLen
322+ for i := x; i < titleEnd; i++ {
323+ require.False(t, row[i].Style.Bold, "cell %d: tab 2 title segment must not be bold", i)
324+ require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: tab 2 title segment style mismatch", i)
325+ }
326+ x = titleEnd
327+ require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 trailing space must keep the base style", x)
328+
329+ // On a visible pulse frame, the bell glyph itself must keep the same
330+ // background as its tab's base style.
331+ visibleRow := make([]renderer.Cell, rowLen)
332+ drawTopBarSnapshot(visibleRow, status, 1, "", styles)
333+ require.Equal(t, rune(ui.AttentionGlyph), visibleRow[tab1Bell].Rune)
334+ require.True(t, visibleRow[tab1Bell].Style.HasBackgroundRGB)
335+ require.Equal(t, styles.accent.BackgroundRGB, visibleRow[tab1Bell].Style.BackgroundRGB, "tab 1's visible bell must keep the active base background")
336+ require.Equal(t, rune(ui.AttentionGlyph), visibleRow[tab2Bell].Rune)
337+ require.True(t, visibleRow[tab2Bell].Style.HasBackgroundRGB)
338+ require.Equal(t, styles.statusBar.BackgroundRGB, visibleRow[tab2Bell].Style.BackgroundRGB, "tab 2's visible bell must keep the plain base background")
339+ })
340+
341+ t.Run("non-usable theme matches the pre-change fallback styles", func(t *testing.T) {
342+ styles := resolveThemeStyles(nil)
343+ require.True(t, styles.tabName.Equal(styles.statusBar))
344+ require.True(t, styles.tabTitle.Equal(styles.statusBar))
345+ require.True(t, styles.tabNameActive.Equal(styles.accent))
346+ require.True(t, styles.tabTitleActive.Equal(styles.accent))
347+
348+ row := make([]renderer.Cell, rowLen)
349+ drawTopBarSnapshot(row, status, 0, "", styles)
350+
351+ // " api (shell) " (tab 0) + " logs " (tab 1: name, attention
352+ // space+blank bell, trailing space) + " err (build failed) " (tab 2:
353+ // name, attention space+blank bell, title, trailing space), then blank
354+ // padding to rowLen: byte-for-byte what the pre-change renderer
355+ // produced, since every new style field collapses to statusBar/accent.
356+ const tab0 = " api (shell) "
357+ const tab1 = " logs "
358+ const tab2 = " err (build failed) "
359+ require.Equal(t, tab0+tab1+tab2+strings.Repeat(" ", rowLen-len(tab0)-len(tab1)-len(tab2)), rowText(row))
360+
361+ for i, c := range row[:len(tab0)] {
362+ require.True(t, c.Style.Equal(styles.statusBar), "cell %d should use the plain base style", i)
363+ }
364+ tab1Start := len(tab0)
365+ for i := tab1Start; i < tab1Start+len(tab1); i++ {
366+ require.True(t, row[i].Style.Equal(styles.accent), "cell %d should use the plain accent style, including the blank bell cell", i)
367+ }
368+ tab2Start := tab1Start + len(tab1)
369+ for i := tab2Start; i < tab2Start+len(tab2); i++ {
370+ require.True(t, row[i].Style.Equal(styles.statusBar), "cell %d should use the plain base style, including the blank bell cell", i)
371+ }
372+ })
373+}
374+
234 func TestTabDisplayName(t *testing.T) {375 func TestTabDisplayName(t *testing.T) {
235 tests := []struct {376 tests := []struct {
236 name string377 name string
@@ -720,20 +861,49 @@ func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
720 }861 }
721 862
722 func TestAttentionConstants(t *testing.T) {863 func TestAttentionConstants(t *testing.T) {
723- require.Equal(t, '', attentionGlyph)864+ require.Equal(t, '', ui.AttentionGlyph)
724 require.Equal(t, 30, pulseFrameCount)865 require.Equal(t, 30, pulseFrameCount)
725 require.Equal(t, 120*time.Millisecond, pulseFrameInterval)866 require.Equal(t, 120*time.Millisecond, pulseFrameInterval)
726 }867 }
727 868
728-func TestPulseStyleHidesAtFrameZeroAndRamps(t *testing.T) {869+func TestPulseStyleFadesFromBaseAndHidesAtFrameZero(t *testing.T) {
729- _, visible := pulseStyle(0)870+ rgbBase := renderer.Style{
730- require.False(t, visible)871+ HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 200, G: 200, B: 200},
872+ HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 10, G: 20, B: 30},
873+ }
874+ indexedBase := renderer.Style{Foreground: -1, Background: -1, Inverse: true}
875+
876+ t.Run("invisible frame returns base unchanged", func(t *testing.T) {
877+ for _, base := range []renderer.Style{rgbBase, indexedBase} {
878+ style, visible := pulseStyle(0, base)
879+ require.False(t, visible)
880+ require.True(t, style.Equal(base))
881+ }
882+ })
883+
884+ t.Run("rgb base blends the glyph foreground from the base background to its foreground, keeping the base background", func(t *testing.T) {
885+ low, visible := pulseStyle(1, rgbBase)
886+ require.True(t, visible)
887+ require.True(t, low.Bold)
888+ require.True(t, low.HasBackgroundRGB)
889+ require.Equal(t, rgbBase.BackgroundRGB, low.BackgroundRGB, "bell keeps the caller's background so it never punches a hole in a themed bar")
890+
891+ peak, visible := pulseStyle(pulseFrameCount/2, rgbBase)
892+ require.True(t, visible)
893+ require.Equal(t, rgbBase.ForegroundRGB, peak.ForegroundRGB, "peak intensity reaches the base foreground exactly")
894+ require.NotEqual(t, low.ForegroundRGB, peak.ForegroundRGB, "the glyph should ramp, not jump straight to full intensity")
895+ })
896+
897+ t.Run("non-RGB base falls back to the indexed grey ramp and preserves other base attributes", func(t *testing.T) {
898+ low, visible := pulseStyle(1, indexedBase)
899+ require.True(t, visible)
900+ require.True(t, low.Bold)
901+ require.True(t, low.Inverse, "non-color base attributes like inverse must survive")
731 902
732- low, visible := pulseStyle(1)903+ peak, visible := pulseStyle(pulseFrameCount/2, indexedBase)
733- require.True(t, visible)904+ require.True(t, visible)
734- peak, visible := pulseStyle(pulseFrameCount / 2)905+ require.Greater(t, peak.Foreground, low.Foreground)
735- require.True(t, visible)906+ })
736- require.Greater(t, peak.Foreground, low.Foreground)
737 }907 }
738 908
739 func TestTopBarRendersAttentionBell(t *testing.T) {909 func TestTopBarRendersAttentionBell(t *testing.T) {
@@ -753,10 +923,11 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
753 frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(true), attentionFrame: 1}, win, true)923 frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(true), attentionFrame: 1}, win, true)
754 924
755 // Tab 2's label is enriched with its focused pane's title ("sh", the925 // Tab 2's label is enriched with its focused pane's title ("sh", the
756- // default shell fallback) and truncated to fit alongside the bell.926+ // default shell fallback) and truncated to fit alongside the bell. The
757- require.Contains(t, rowText(frame.Row(0)), "2 (s")927+ // bell sits right after the name, before the (title) segment.
928+ require.Contains(t, rowText(frame.Row(0)), "2 "+string(ui.AttentionGlyph)+" (s")
758 for _, c := range frame.Row(0) {929 for _, c := range frame.Row(0) {
759- if c.Rune == attentionGlyph {930+ if c.Rune == ui.AttentionGlyph {
760 require.True(t, c.Style.Bold)931 require.True(t, c.Style.Bold)
761 return932 return
762 }933 }
@@ -839,7 +1010,7 @@ func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) {
839 1010
840 require.Equal(t, " cur fresh tmp*  ", rowText(row))1011 require.Equal(t, " cur fresh tmp*  ", rowText(row))
841 for _, c := range row {1012 for _, c := range row {
842- if c.Rune == attentionGlyph {1013+ if c.Rune == ui.AttentionGlyph {
843 require.True(t, c.Style.Bold)1014 require.True(t, c.Style.Bold)
844 return1015 return
845 }1016 }
M internal/usecase/picker/model.go +77 -29
@@ -17,11 +17,38 @@ const (
1717 type SessionView struct {
1818 ID domain.SessionID
1919 Name string
20- Tabs []string
20+ Tabs []TabEntry
2121 Active int
2222 Stopped bool
2323 }
2424
25+// TabEntry is one tab row; Name is drawn emphasized, Detail muted.
26+type TabEntry struct {
27+ Name string // tab display name
28+ Detail string // " (paneTitle)" or "", drawn muted
29+ Attention bool // draw the attention marker right after Name, before Detail
30+}
31+
32+// RenderStyles are the styles Render uses to draw list rows. The zero value
33+// (all renderer.Style{}) is never used directly; Render falls back to
34+// defaultRenderStyles when no RenderStyles is supplied, mirroring the styling
35+// of a non-truecolor client.
36+type RenderStyles struct {
37+ Selection renderer.Style // selected row fill + suffixes
38+ SelectionName renderer.Style // selected row name segment
39+ SelectionMuted renderer.Style // selected row detail segment
40+ Name renderer.Style // non-selected name segment
41+ Detail renderer.Style // non-selected detail segment
42+ Base renderer.Style // non-selected fill + suffixes
43+}
44+
45+func defaultRenderStyles() RenderStyles {
46+ selection := renderer.DefaultStyle()
47+ selection.Inverse = true
48+ base := renderer.DefaultStyle()
49+ return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Base: base}
50+}
51+
2552 type Target struct {
2653 Session domain.SessionID
2754 Name string
@@ -46,30 +73,34 @@ type Model struct {
4673 }
4774
4875 type row struct {
49- header bool
50- label string
51- session domain.SessionID
52- name string
53- tabIndex int
54- stopped bool
76+ header bool
77+ dispName string // display name segment; bold on truecolor
78+ detail string // " (paneTitle)" segment, muted; "" for headers
79+ attention bool // draw the attention marker right after the name, before detail; tab rows only
80+ session domain.SessionID
81+ // targetName is the stopped-session lookup name threaded into Target;
82+ // distinct from dispName, which is what gets drawn.
83+ targetName string
84+ tabIndex int
85+ stopped bool
5586 }
5687
5788 func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model {
5889 m := &Model{selected: -1}
5990 activeSelection := -1
6091 for _, session := range sessions {
61- name := ""
92+ targetName := ""
6293 if session.Stopped {
63- name = session.Name
94+ targetName = session.Name
6495 }
65- m.rows = append(m.rows, row{header: true, label: session.Name, session: session.ID, name: name, tabIndex: -1, stopped: session.Stopped})
96+ m.rows = append(m.rows, row{header: true, dispName: session.Name, session: session.ID, targetName: targetName, tabIndex: -1, stopped: session.Stopped})
6697 active := session.Active
6798 if active < 0 || active >= len(session.Tabs) {
6899 active = 0
69100 }
70101 for i, tab := range session.Tabs {
71102 idx := len(m.rows)
72- m.rows = append(m.rows, row{label: tab, session: session.ID, name: name, tabIndex: i, stopped: session.Stopped})
103+ m.rows = append(m.rows, row{dispName: tab.Name, detail: tab.Detail, attention: tab.Attention, session: session.ID, targetName: targetName, tabIndex: i, stopped: session.Stopped})
73104 if session.ID == cur && i == curTab {
74105 m.selected = idx
75106 }
@@ -131,7 +162,7 @@ func (m *Model) Selected() (Target, bool) {
131162 if r.header {
132163 return Target{}, false
133164 }
134- return Target{Session: r.session, Name: r.name, TabIndex: r.tabIndex, Stopped: r.stopped}, true
165+ return Target{Session: r.session, Name: r.targetName, TabIndex: r.tabIndex, Stopped: r.stopped}, true
135166 }
136167
137168 func (m *Model) Clone() *Model {
@@ -143,15 +174,14 @@ func (m *Model) Clone() *Model {
143174 return &clone
144175 }
145176
146-func (m *Model) Render(inner domain.Size, preview Preview, selectedStyle ...renderer.Style) renderer.Frame {
177+func (m *Model) Render(inner domain.Size, preview Preview, styles ...RenderStyles) renderer.Frame {
147178 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
148179 layout := ChooseLayout(inner)
149- selection := renderer.DefaultStyle()
150- selection.Inverse = true
151- if len(selectedStyle) > 0 {
152- selection = selectedStyle[0]
180+ styleSet := defaultRenderStyles()
181+ if len(styles) > 0 {
182+ styleSet = styles[0]
153183 }
154- m.renderList(frame, layout.List, selection)
184+ m.renderList(frame, layout.List, styleSet)
155185 blitPreview(frame, layout.Preview, preview)
156186 return frame
157187 }
@@ -181,32 +211,50 @@ func (m *Model) firstLeaf() int {
181211 return -1
182212 }
183213
184-func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, selection renderer.Style) {
214+// renderList draws each visible row as up to three segments: a name segment
215+// (bold when styles came from a truecolor theme), a base-styled attention
216+// marker right after the name, and a muted detail segment (tab rows only) —
217+// or a base-styled "(stopped)" suffix for stopped session headers. A tight
218+// width ellipsizes the detail segment before eating into the name.
219+func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) {
185220 if m == nil || rect.Width <= 0 || rect.Height <= 0 {
186221 return
187222 }
188223 visible := min(rect.Height, frame.Height-rect.Y)
189224 offset := m.scrollOffset(visible)
225+ clipX := rect.X + rect.Width
190226 for y := range visible {
191227 idx := offset + y
192228 if idx >= len(m.rows) {
193229 break
194230 }
195231 r := m.rows[idx]
196- style := renderer.DefaultStyle()
232+ base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail
197233 if idx == m.selected {
198- style = selection
199- }
200- label := r.label
201- if r.header && r.stopped {
202- label += " (stopped)"
234+ base, nameStyle, detailStyle = styles.Selection, styles.SelectionName, styles.SelectionMuted
203235 }
236+ ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})
237+
238+ name := r.dispName
204239 if !r.header {
205- label = " " + label
240+ name = " " + name
206241 }
207- label = ui.TruncateText(label, rect.Width)
208- ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
209- ui.DrawText(frame, rect.X, rect.Y+y, rect.X+rect.Width, label, style)
242+ name = ui.TruncateText(name, rect.Width)
243+ x := ui.DrawText(frame, rect.X, rect.Y+y, clipX, name, nameStyle)
244+
245+ if r.header {
246+ if r.stopped {
247+ ui.DrawText(frame, x, rect.Y+y, clipX, " (stopped)", base)
248+ }
249+ continue
250+ }
251+
252+ if r.attention {
253+ x = ui.DrawText(frame, x, rect.Y+y, clipX, " "+string(ui.AttentionGlyph), base)
254+ }
255+
256+ detail := ui.TruncateText(r.detail, clipX-x)
257+ ui.DrawText(frame, x, rect.Y+y, clipX, detail, detailStyle)
210258 }
211259 }
212260
@@ -17,11 +17,38 @@ const (
17 type SessionView struct {17 type SessionView struct {
18 ID domain.SessionID18 ID domain.SessionID
19 Name string19 Name string
20- Tabs []string20+ Tabs []TabEntry
21 Active int21 Active int
22 Stopped bool22 Stopped bool
23 }23 }
24 24
25+// TabEntry is one tab row; Name is drawn emphasized, Detail muted.
26+type TabEntry struct {
27+ Name string // tab display name
28+ Detail string // " (paneTitle)" or "", drawn muted
29+ Attention bool // draw the attention marker right after Name, before Detail
30+}
31+
32+// RenderStyles are the styles Render uses to draw list rows. The zero value
33+// (all renderer.Style{}) is never used directly; Render falls back to
34+// defaultRenderStyles when no RenderStyles is supplied, mirroring the styling
35+// of a non-truecolor client.
36+type RenderStyles struct {
37+ Selection renderer.Style // selected row fill + suffixes
38+ SelectionName renderer.Style // selected row name segment
39+ SelectionMuted renderer.Style // selected row detail segment
40+ Name renderer.Style // non-selected name segment
41+ Detail renderer.Style // non-selected detail segment
42+ Base renderer.Style // non-selected fill + suffixes
43+}
44+
45+func defaultRenderStyles() RenderStyles {
46+ selection := renderer.DefaultStyle()
47+ selection.Inverse = true
48+ base := renderer.DefaultStyle()
49+ return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Base: base}
50+}
51+
25 type Target struct {52 type Target struct {
26 Session domain.SessionID53 Session domain.SessionID
27 Name string54 Name string
@@ -46,30 +73,34 @@ type Model struct {
46 }73 }
47 74
48 type row struct {75 type row struct {
49- header bool76+ header bool
50- label string77+ dispName string // display name segment; bold on truecolor
51- session domain.SessionID78+ detail string // " (paneTitle)" segment, muted; "" for headers
52- name string79+ attention bool // draw the attention marker right after the name, before detail; tab rows only
53- tabIndex int80+ session domain.SessionID
54- stopped bool81+ // targetName is the stopped-session lookup name threaded into Target;
82+ // distinct from dispName, which is what gets drawn.
83+ targetName string
84+ tabIndex int
85+ stopped bool
55 }86 }
56 87
57 func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model {88 func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model {
58 m := &Model{selected: -1}89 m := &Model{selected: -1}
59 activeSelection := -190 activeSelection := -1
60 for _, session := range sessions {91 for _, session := range sessions {
61- name := ""92+ targetName := ""
62 if session.Stopped {93 if session.Stopped {
63- name = session.Name94+ targetName = session.Name
64 }95 }
65- m.rows = append(m.rows, row{header: true, label: session.Name, session: session.ID, name: name, tabIndex: -1, stopped: session.Stopped})96+ m.rows = append(m.rows, row{header: true, dispName: session.Name, session: session.ID, targetName: targetName, tabIndex: -1, stopped: session.Stopped})
66 active := session.Active97 active := session.Active
67 if active < 0 || active >= len(session.Tabs) {98 if active < 0 || active >= len(session.Tabs) {
68 active = 099 active = 0
69 }100 }
70 for i, tab := range session.Tabs {101 for i, tab := range session.Tabs {
71 idx := len(m.rows)102 idx := len(m.rows)
72- m.rows = append(m.rows, row{label: tab, session: session.ID, name: name, tabIndex: i, stopped: session.Stopped})103+ m.rows = append(m.rows, row{dispName: tab.Name, detail: tab.Detail, attention: tab.Attention, session: session.ID, targetName: targetName, tabIndex: i, stopped: session.Stopped})
73 if session.ID == cur && i == curTab {104 if session.ID == cur && i == curTab {
74 m.selected = idx105 m.selected = idx
75 }106 }
@@ -131,7 +162,7 @@ func (m *Model) Selected() (Target, bool) {
131 if r.header {162 if r.header {
132 return Target{}, false163 return Target{}, false
133 }164 }
134- return Target{Session: r.session, Name: r.name, TabIndex: r.tabIndex, Stopped: r.stopped}, true165+ return Target{Session: r.session, Name: r.targetName, TabIndex: r.tabIndex, Stopped: r.stopped}, true
135 }166 }
136 167
137 func (m *Model) Clone() *Model {168 func (m *Model) Clone() *Model {
@@ -143,15 +174,14 @@ func (m *Model) Clone() *Model {
143 return &clone174 return &clone
144 }175 }
145 176
146-func (m *Model) Render(inner domain.Size, preview Preview, selectedStyle ...renderer.Style) renderer.Frame {177+func (m *Model) Render(inner domain.Size, preview Preview, styles ...RenderStyles) renderer.Frame {
147 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))178 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
148 layout := ChooseLayout(inner)179 layout := ChooseLayout(inner)
149- selection := renderer.DefaultStyle()180+ styleSet := defaultRenderStyles()
150- selection.Inverse = true181+ if len(styles) > 0 {
151- if len(selectedStyle) > 0 {182+ styleSet = styles[0]
152- selection = selectedStyle[0]
153 }183 }
154- m.renderList(frame, layout.List, selection)184+ m.renderList(frame, layout.List, styleSet)
155 blitPreview(frame, layout.Preview, preview)185 blitPreview(frame, layout.Preview, preview)
156 return frame186 return frame
157 }187 }
@@ -181,32 +211,50 @@ func (m *Model) firstLeaf() int {
181 return -1211 return -1
182 }212 }
183 213
184-func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, selection renderer.Style) {214+// renderList draws each visible row as up to three segments: a name segment
215+// (bold when styles came from a truecolor theme), a base-styled attention
216+// marker right after the name, and a muted detail segment (tab rows only) —
217+// or a base-styled "(stopped)" suffix for stopped session headers. A tight
218+// width ellipsizes the detail segment before eating into the name.
219+func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) {
185 if m == nil || rect.Width <= 0 || rect.Height <= 0 {220 if m == nil || rect.Width <= 0 || rect.Height <= 0 {
186 return221 return
187 }222 }
188 visible := min(rect.Height, frame.Height-rect.Y)223 visible := min(rect.Height, frame.Height-rect.Y)
189 offset := m.scrollOffset(visible)224 offset := m.scrollOffset(visible)
225+ clipX := rect.X + rect.Width
190 for y := range visible {226 for y := range visible {
191 idx := offset + y227 idx := offset + y
192 if idx >= len(m.rows) {228 if idx >= len(m.rows) {
193 break229 break
194 }230 }
195 r := m.rows[idx]231 r := m.rows[idx]
196- style := renderer.DefaultStyle()232+ base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail
197 if idx == m.selected {233 if idx == m.selected {
198- style = selection234+ base, nameStyle, detailStyle = styles.Selection, styles.SelectionName, styles.SelectionMuted
199- }
200- label := r.label
201- if r.header && r.stopped {
202- label += " (stopped)"
203 }235 }
236+ ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})
237+
238+ name := r.dispName
204 if !r.header {239 if !r.header {
205- label = " " + label240+ name = " " + name
206 }241 }
207- label = ui.TruncateText(label, rect.Width)242+ name = ui.TruncateText(name, rect.Width)
208- ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})243+ x := ui.DrawText(frame, rect.X, rect.Y+y, clipX, name, nameStyle)
209- ui.DrawText(frame, rect.X, rect.Y+y, rect.X+rect.Width, label, style)244+
245+ if r.header {
246+ if r.stopped {
247+ ui.DrawText(frame, x, rect.Y+y, clipX, " (stopped)", base)
248+ }
249+ continue
250+ }
251+
252+ if r.attention {
253+ x = ui.DrawText(frame, x, rect.Y+y, clipX, " "+string(ui.AttentionGlyph), base)
254+ }
255+
256+ detail := ui.TruncateText(r.detail, clipX-x)
257+ ui.DrawText(frame, x, rect.Y+y, clipX, detail, detailStyle)
210 }258 }
211 }259 }
212 260
M internal/usecase/picker/model_test.go +86 -13
@@ -4,14 +4,15 @@ import (
44 "testing"
55
66 "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/usecase/ui"
78 "github.com/bnema/vev/pkg/renderer"
89 "github.com/stretchr/testify/require"
910 )
1011
1112 func TestNewFlattensAndSelectsCurrentTab(t *testing.T) {
1213 m := New([]SessionView{
13- {ID: "s1", Name: "one", Tabs: []string{"shell", "logs"}, Active: 0},
14- {ID: "s2", Name: "two", Tabs: []string{"api"}, Active: 0},
14+ {ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 0},
15+ {ID: "s2", Name: "two", Tabs: []TabEntry{{Name: "api"}}, Active: 0},
1516 }, "s1", 1)
1617
1718 got, ok := m.Selected()
@@ -25,12 +26,12 @@ func TestNewFlattensAndSelectsCurrentTab(t *testing.T) {
2526 }
2627
2728 func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) {
28- m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"shell", "logs"}, Active: 1}}, "missing", 0)
29+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 1}}, "missing", 0)
2930 got, ok := m.Selected()
3031 require.True(t, ok)
3132 require.Equal(t, Target{Session: "s1", TabIndex: 1}, got)
3233
33- m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"shell"}, Active: 4}}, "missing", 0)
34+ m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}}, Active: 4}}, "missing", 0)
3435 got, ok = m.Selected()
3536 require.True(t, ok)
3637 require.Equal(t, Target{Session: "s1", TabIndex: 0}, got)
@@ -38,8 +39,8 @@ func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) {
3839
3940 func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) {
4041 m := New([]SessionView{
41- {ID: "s1", Name: "one", Tabs: []string{"a", "b"}, Active: 0},
42- {ID: "s2", Name: "two", Tabs: []string{"c"}, Active: 0},
42+ {ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a"}, {Name: "b"}}, Active: 0},
43+ {ID: "s2", Name: "two", Tabs: []TabEntry{{Name: "c"}}, Active: 0},
4344 }, "s1", 0)
4445
4546 m.Up()
@@ -92,8 +93,8 @@ func TestChooseLayoutBoundaries(t *testing.T) {
9293
9394 func TestSelectedMapping(t *testing.T) {
9495 m := New([]SessionView{
95- {ID: "alpha", Name: "alpha", Tabs: []string{"one"}, Active: 0},
96- {ID: "beta", Name: "beta", Tabs: []string{"two", "three"}, Active: 0},
96+ {ID: "alpha", Name: "alpha", Tabs: []TabEntry{{Name: "one"}}, Active: 0},
97+ {ID: "beta", Name: "beta", Tabs: []TabEntry{{Name: "two"}, {Name: "three"}}, Active: 0},
9798 }, "beta", 1)
9899
99100 got, ok := m.Selected()
@@ -102,7 +103,7 @@ func TestSelectedMapping(t *testing.T) {
102103 }
103104
104105 func TestStoppedSessionSelectableAndRendered(t *testing.T) {
105- m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []string{""}, Stopped: true}}, "", 0)
106+ m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, "", 0)
106107 got, ok := m.Selected()
107108 require.True(t, ok)
108109 require.Equal(t, Target{Session: "stopped:work", Name: "work", TabIndex: 0, Stopped: true}, got)
@@ -112,7 +113,7 @@ func TestStoppedSessionSelectableAndRendered(t *testing.T) {
112113 }
113114
114115 func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
115- m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0)
116+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0)
116117 preview := Preview{
117118 Width: 24,
118119 Height: 1,
@@ -132,7 +133,7 @@ func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
132133 }
133134
134135 func TestRenderListScrollsSelectionIntoView(t *testing.T) {
135- m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"a", "b", "c", "d", "e", "f"}, Active: 0}}, "s1", 0)
136+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a"}, {Name: "b"}, {Name: "c"}, {Name: "d"}, {Name: "e"}, {Name: "f"}}, Active: 0}}, "s1", 0)
136137 for range 5 {
137138 m.Down()
138139 }
@@ -144,7 +145,7 @@ func TestRenderListScrollsSelectionIntoView(t *testing.T) {
144145 }
145146
146147 func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) {
147- m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"a-really-long-focused-pane-tab-label"}, Active: 0}}, "s1", 0)
148+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a-really-long-focused-pane-tab-label"}}, Active: 0}}, "s1", 0)
148149
149150 frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{})
150151
@@ -155,7 +156,7 @@ func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) {
155156 }
156157
157158 func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) {
158- m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0)
159+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0)
159160 preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}
160161
161162 frame := m.Render(domain.Size{Cols: 23, Rows: 11}, preview)
@@ -164,6 +165,78 @@ func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) {
164165 require.NotEqual(t, 'x', frame.At(0, 0).Rune)
165166 }
166167
168+func TestRenderListDrawsNameAndDetailSegmentsWithDistinctStyles(t *testing.T) {
169+ nameStyle := renderer.Style{Bold: true}
170+ detailStyle := renderer.Style{Italic: true}
171+ baseStyle := renderer.DefaultStyle()
172+ selectionStyle := renderer.Style{Inverse: true}
173+ selectionNameStyle := renderer.Style{Inverse: true, Bold: true}
174+ selectionMutedStyle := renderer.Style{Inverse: true, Italic: true}
175+ styles := RenderStyles{
176+ Selection: selectionStyle, SelectionName: selectionNameStyle, SelectionMuted: selectionMutedStyle,
177+ Name: nameStyle, Detail: detailStyle, Base: baseStyle,
178+ }
179+
180+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{
181+ {Name: "alpha", Detail: " (running)"},
182+ {Name: "beta", Detail: " (idle)", Attention: true},
183+ }, Active: 0}}, "s1", 0)
184+
185+ frame := m.Render(domain.Size{Cols: 30, Rows: 5}, Preview{}, styles)
186+
187+ // Row 1 is the selected tab ("alpha"): name uses SelectionName, detail uses SelectionMuted.
188+ require.Equal(t, 'a', frame.At(2, 1).Rune)
189+ require.True(t, frame.At(2, 1).Style.Equal(selectionNameStyle), "selected name segment style")
190+ require.Equal(t, ' ', frame.At(7, 1).Rune)
191+ require.True(t, frame.At(7, 1).Style.Equal(selectionMutedStyle), "selected detail segment style")
192+ require.Equal(t, '(', frame.At(8, 1).Rune)
193+ require.True(t, frame.At(8, 1).Style.Equal(selectionMutedStyle), "selected detail segment style")
194+
195+ // Row 2 is the non-selected tab ("beta"): name uses Name, then the
196+ // attention marker (right after the name, before detail) uses the row's
197+ // base style (not muted), then detail uses Detail.
198+ require.Equal(t, 'b', frame.At(2, 2).Rune)
199+ require.True(t, frame.At(2, 2).Style.Equal(nameStyle), "name segment style")
200+ require.Equal(t, ' ', frame.At(6, 2).Rune, "attention marker leading space")
201+ require.True(t, frame.At(6, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted")
202+ require.Equal(t, rune(ui.AttentionGlyph), frame.At(7, 2).Rune)
203+ require.True(t, frame.At(7, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted")
204+ require.Equal(t, ' ', frame.At(8, 2).Rune)
205+ require.True(t, frame.At(8, 2).Style.Equal(detailStyle), "detail segment style")
206+ require.Equal(t, '(', frame.At(9, 2).Rune)
207+ require.True(t, frame.At(9, 2).Style.Equal(detailStyle), "detail segment style")
208+}
209+
210+func TestRenderListTruncatesDetailBeforeName(t *testing.T) {
211+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{
212+ {Name: "short-name", Detail: " (a very long detail text)"},
213+ }, Active: 0}}, "s1", 0)
214+
215+ frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{})
216+ layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5})
217+ require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column")
218+
219+ want := " short-name (a…"
220+ for i, r := range want {
221+ require.Equal(t, r, frame.At(i, 1).Rune, "cell %d", i)
222+ }
223+ require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "detail is ellipsized to fit, the intact name is not touched")
224+ require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing drawn past the list width")
225+}
226+
227+func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) {
228+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{
229+ {Name: "a-really-long-focused-pane-tab-label", Detail: " (detail)"},
230+ }, Active: 0}}, "s1", 0)
231+
232+ frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{})
233+ layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5})
234+ require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column")
235+
236+ require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "the name segment itself is ellipsized once it alone exceeds the width")
237+ require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing drawn past the list width, detail gets no room at all")
238+}
239+
167240 func cell(r rune) renderer.Cell {
168241 return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
169242 }
@@ -4,14 +4,15 @@ import (
4 "testing"4 "testing"
5 5
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/usecase/ui"
7 "github.com/bnema/vev/pkg/renderer"8 "github.com/bnema/vev/pkg/renderer"
8 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
9 )10 )
10 11
11 func TestNewFlattensAndSelectsCurrentTab(t *testing.T) {12 func TestNewFlattensAndSelectsCurrentTab(t *testing.T) {
12 m := New([]SessionView{13 m := New([]SessionView{
13- {ID: "s1", Name: "one", Tabs: []string{"shell", "logs"}, Active: 0},14+ {ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 0},
14- {ID: "s2", Name: "two", Tabs: []string{"api"}, Active: 0},15+ {ID: "s2", Name: "two", Tabs: []TabEntry{{Name: "api"}}, Active: 0},
15 }, "s1", 1)16 }, "s1", 1)
16 17
17 got, ok := m.Selected()18 got, ok := m.Selected()
@@ -25,12 +26,12 @@ func TestNewFlattensAndSelectsCurrentTab(t *testing.T) {
25 }26 }
26 27
27 func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) {28 func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) {
28- m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"shell", "logs"}, Active: 1}}, "missing", 0)29+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 1}}, "missing", 0)
29 got, ok := m.Selected()30 got, ok := m.Selected()
30 require.True(t, ok)31 require.True(t, ok)
31 require.Equal(t, Target{Session: "s1", TabIndex: 1}, got)32 require.Equal(t, Target{Session: "s1", TabIndex: 1}, got)
32 33
33- m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"shell"}, Active: 4}}, "missing", 0)34+ m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}}, Active: 4}}, "missing", 0)
34 got, ok = m.Selected()35 got, ok = m.Selected()
35 require.True(t, ok)36 require.True(t, ok)
36 require.Equal(t, Target{Session: "s1", TabIndex: 0}, got)37 require.Equal(t, Target{Session: "s1", TabIndex: 0}, got)
@@ -38,8 +39,8 @@ func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) {
38 39
39 func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) {40 func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) {
40 m := New([]SessionView{41 m := New([]SessionView{
41- {ID: "s1", Name: "one", Tabs: []string{"a", "b"}, Active: 0},42+ {ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a"}, {Name: "b"}}, Active: 0},
42- {ID: "s2", Name: "two", Tabs: []string{"c"}, Active: 0},43+ {ID: "s2", Name: "two", Tabs: []TabEntry{{Name: "c"}}, Active: 0},
43 }, "s1", 0)44 }, "s1", 0)
44 45
45 m.Up()46 m.Up()
@@ -92,8 +93,8 @@ func TestChooseLayoutBoundaries(t *testing.T) {
92 93
93 func TestSelectedMapping(t *testing.T) {94 func TestSelectedMapping(t *testing.T) {
94 m := New([]SessionView{95 m := New([]SessionView{
95- {ID: "alpha", Name: "alpha", Tabs: []string{"one"}, Active: 0},96+ {ID: "alpha", Name: "alpha", Tabs: []TabEntry{{Name: "one"}}, Active: 0},
96- {ID: "beta", Name: "beta", Tabs: []string{"two", "three"}, Active: 0},97+ {ID: "beta", Name: "beta", Tabs: []TabEntry{{Name: "two"}, {Name: "three"}}, Active: 0},
97 }, "beta", 1)98 }, "beta", 1)
98 99
99 got, ok := m.Selected()100 got, ok := m.Selected()
@@ -102,7 +103,7 @@ func TestSelectedMapping(t *testing.T) {
102 }103 }
103 104
104 func TestStoppedSessionSelectableAndRendered(t *testing.T) {105 func TestStoppedSessionSelectableAndRendered(t *testing.T) {
105- m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []string{""}, Stopped: true}}, "", 0)106+ m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, "", 0)
106 got, ok := m.Selected()107 got, ok := m.Selected()
107 require.True(t, ok)108 require.True(t, ok)
108 require.Equal(t, Target{Session: "stopped:work", Name: "work", TabIndex: 0, Stopped: true}, got)109 require.Equal(t, Target{Session: "stopped:work", Name: "work", TabIndex: 0, Stopped: true}, got)
@@ -112,7 +113,7 @@ func TestStoppedSessionSelectableAndRendered(t *testing.T) {
112 }113 }
113 114
114 func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {115 func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
115- m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0)116+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0)
116 preview := Preview{117 preview := Preview{
117 Width: 24,118 Width: 24,
118 Height: 1,119 Height: 1,
@@ -132,7 +133,7 @@ func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
132 }133 }
133 134
134 func TestRenderListScrollsSelectionIntoView(t *testing.T) {135 func TestRenderListScrollsSelectionIntoView(t *testing.T) {
135- m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"a", "b", "c", "d", "e", "f"}, Active: 0}}, "s1", 0)136+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a"}, {Name: "b"}, {Name: "c"}, {Name: "d"}, {Name: "e"}, {Name: "f"}}, Active: 0}}, "s1", 0)
136 for range 5 {137 for range 5 {
137 m.Down()138 m.Down()
138 }139 }
@@ -144,7 +145,7 @@ func TestRenderListScrollsSelectionIntoView(t *testing.T) {
144 }145 }
145 146
146 func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) {147 func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) {
147- m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"a-really-long-focused-pane-tab-label"}, Active: 0}}, "s1", 0)148+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a-really-long-focused-pane-tab-label"}}, Active: 0}}, "s1", 0)
148 149
149 frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{})150 frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{})
150 151
@@ -155,7 +156,7 @@ func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) {
155 }156 }
156 157
157 func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) {158 func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) {
158- m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0)159+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0)
159 preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}160 preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}
160 161
161 frame := m.Render(domain.Size{Cols: 23, Rows: 11}, preview)162 frame := m.Render(domain.Size{Cols: 23, Rows: 11}, preview)
@@ -164,6 +165,78 @@ func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) {
164 require.NotEqual(t, 'x', frame.At(0, 0).Rune)165 require.NotEqual(t, 'x', frame.At(0, 0).Rune)
165 }166 }
166 167
168+func TestRenderListDrawsNameAndDetailSegmentsWithDistinctStyles(t *testing.T) {
169+ nameStyle := renderer.Style{Bold: true}
170+ detailStyle := renderer.Style{Italic: true}
171+ baseStyle := renderer.DefaultStyle()
172+ selectionStyle := renderer.Style{Inverse: true}
173+ selectionNameStyle := renderer.Style{Inverse: true, Bold: true}
174+ selectionMutedStyle := renderer.Style{Inverse: true, Italic: true}
175+ styles := RenderStyles{
176+ Selection: selectionStyle, SelectionName: selectionNameStyle, SelectionMuted: selectionMutedStyle,
177+ Name: nameStyle, Detail: detailStyle, Base: baseStyle,
178+ }
179+
180+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{
181+ {Name: "alpha", Detail: " (running)"},
182+ {Name: "beta", Detail: " (idle)", Attention: true},
183+ }, Active: 0}}, "s1", 0)
184+
185+ frame := m.Render(domain.Size{Cols: 30, Rows: 5}, Preview{}, styles)
186+
187+ // Row 1 is the selected tab ("alpha"): name uses SelectionName, detail uses SelectionMuted.
188+ require.Equal(t, 'a', frame.At(2, 1).Rune)
189+ require.True(t, frame.At(2, 1).Style.Equal(selectionNameStyle), "selected name segment style")
190+ require.Equal(t, ' ', frame.At(7, 1).Rune)
191+ require.True(t, frame.At(7, 1).Style.Equal(selectionMutedStyle), "selected detail segment style")
192+ require.Equal(t, '(', frame.At(8, 1).Rune)
193+ require.True(t, frame.At(8, 1).Style.Equal(selectionMutedStyle), "selected detail segment style")
194+
195+ // Row 2 is the non-selected tab ("beta"): name uses Name, then the
196+ // attention marker (right after the name, before detail) uses the row's
197+ // base style (not muted), then detail uses Detail.
198+ require.Equal(t, 'b', frame.At(2, 2).Rune)
199+ require.True(t, frame.At(2, 2).Style.Equal(nameStyle), "name segment style")
200+ require.Equal(t, ' ', frame.At(6, 2).Rune, "attention marker leading space")
201+ require.True(t, frame.At(6, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted")
202+ require.Equal(t, rune(ui.AttentionGlyph), frame.At(7, 2).Rune)
203+ require.True(t, frame.At(7, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted")
204+ require.Equal(t, ' ', frame.At(8, 2).Rune)
205+ require.True(t, frame.At(8, 2).Style.Equal(detailStyle), "detail segment style")
206+ require.Equal(t, '(', frame.At(9, 2).Rune)
207+ require.True(t, frame.At(9, 2).Style.Equal(detailStyle), "detail segment style")
208+}
209+
210+func TestRenderListTruncatesDetailBeforeName(t *testing.T) {
211+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{
212+ {Name: "short-name", Detail: " (a very long detail text)"},
213+ }, Active: 0}}, "s1", 0)
214+
215+ frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{})
216+ layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5})
217+ require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column")
218+
219+ want := " short-name (a…"
220+ for i, r := range want {
221+ require.Equal(t, r, frame.At(i, 1).Rune, "cell %d", i)
222+ }
223+ require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "detail is ellipsized to fit, the intact name is not touched")
224+ require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing drawn past the list width")
225+}
226+
227+func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) {
228+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{
229+ {Name: "a-really-long-focused-pane-tab-label", Detail: " (detail)"},
230+ }, Active: 0}}, "s1", 0)
231+
232+ frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{})
233+ layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5})
234+ require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column")
235+
236+ require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "the name segment itself is ellipsized once it alone exceeds the width")
237+ require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing drawn past the list width, detail gets no room at all")
238+}
239+
167 func cell(r rune) renderer.Cell {240 func cell(r rune) renderer.Cell {
168 return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}241 return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
169 }242 }
M internal/usecase/theme/theme.go +36 -0
@@ -337,6 +337,42 @@ func MutedTextStyle(t Theme) renderer.Style {
337337 return style
338338 }
339339
340+// mutedVariantBlend is the fraction MutedVariantStyle blends a style's own
341+// foreground toward its own background, subtler than MutedTextStyle's 0.45
342+// (which blends the theme's global fg/bg instead of a style's own colors).
343+const mutedVariantBlend = 0.4
344+
345+// EmphasisStyle returns base with bold applied when the client theme supports
346+// styled output; otherwise base is returned unchanged.
347+func EmphasisStyle(base renderer.Style, t Theme) renderer.Style {
348+ if !usable(t) {
349+ return base
350+ }
351+ out := base
352+ out.Bold = true
353+ return out
354+}
355+
356+// MutedVariantStyle fades base's foreground toward its background when both
357+// are RGB and the client theme supports styled output; otherwise base is
358+// returned unchanged. When base has an RGB foreground but no RGB background,
359+// it fades toward t.Background instead.
360+func MutedVariantStyle(base renderer.Style, t Theme) renderer.Style {
361+ if !usable(t) || !base.HasForegroundRGB {
362+ return base
363+ }
364+ bg := base.BackgroundRGB
365+ if !base.HasBackgroundRGB {
366+ if !t.HasBG {
367+ return base
368+ }
369+ bg = t.Background
370+ }
371+ out := base
372+ out.ForegroundRGB = Blend(base.ForegroundRGB, bg, mutedVariantBlend)
373+ return out
374+}
375+
340376 func DimStyle(style renderer.Style, t Theme) renderer.Style {
341377 if !usable(t) {
342378 return style
@@ -337,6 +337,42 @@ func MutedTextStyle(t Theme) renderer.Style {
337 return style337 return style
338 }338 }
339 339
340+// mutedVariantBlend is the fraction MutedVariantStyle blends a style's own
341+// foreground toward its own background, subtler than MutedTextStyle's 0.45
342+// (which blends the theme's global fg/bg instead of a style's own colors).
343+const mutedVariantBlend = 0.4
344+
345+// EmphasisStyle returns base with bold applied when the client theme supports
346+// styled output; otherwise base is returned unchanged.
347+func EmphasisStyle(base renderer.Style, t Theme) renderer.Style {
348+ if !usable(t) {
349+ return base
350+ }
351+ out := base
352+ out.Bold = true
353+ return out
354+}
355+
356+// MutedVariantStyle fades base's foreground toward its background when both
357+// are RGB and the client theme supports styled output; otherwise base is
358+// returned unchanged. When base has an RGB foreground but no RGB background,
359+// it fades toward t.Background instead.
360+func MutedVariantStyle(base renderer.Style, t Theme) renderer.Style {
361+ if !usable(t) || !base.HasForegroundRGB {
362+ return base
363+ }
364+ bg := base.BackgroundRGB
365+ if !base.HasBackgroundRGB {
366+ if !t.HasBG {
367+ return base
368+ }
369+ bg = t.Background
370+ }
371+ out := base
372+ out.ForegroundRGB = Blend(base.ForegroundRGB, bg, mutedVariantBlend)
373+ return out
374+}
375+
340 func DimStyle(style renderer.Style, t Theme) renderer.Style {376 func DimStyle(style renderer.Style, t Theme) renderer.Style {
341 if !usable(t) {377 if !usable(t) {
342 return style378 return style
M internal/usecase/theme/theme_test.go +98 -0
@@ -388,3 +388,101 @@ func TestStyleHelpersFallbackAndThemed(t *testing.T) {
388388 t.Fatalf("muted themed=%+v", muted)
389389 }
390390 }
391+
392+func TestEmphasisStyle(t *testing.T) {
393+ usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true}
394+ base := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}}
395+
396+ tests := []struct {
397+ name string
398+ theme Theme
399+ base renderer.Style
400+ want renderer.Style
401+ }{
402+ {
403+ name: "usable theme applies bold",
404+ theme: usableTheme,
405+ base: base,
406+ want: renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}, Bold: true},
407+ },
408+ {
409+ name: "unknown theme returns base unchanged",
410+ theme: Theme{},
411+ base: base,
412+ want: base,
413+ },
414+ {
415+ name: "theme missing truecolor returns base unchanged",
416+ theme: Theme{Foreground: usableTheme.Foreground, Background: usableTheme.Background, HasFG: true, HasBG: true, Known: true},
417+ base: base,
418+ want: base,
419+ },
420+ }
421+ for _, tt := range tests {
422+ t.Run(tt.name, func(t *testing.T) {
423+ if got := EmphasisStyle(tt.base, tt.theme); !got.Equal(tt.want) {
424+ t.Fatalf("EmphasisStyle()=%+v want %+v", got, tt.want)
425+ }
426+ })
427+ }
428+}
429+
430+func TestMutedVariantStyle(t *testing.T) {
431+ usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true}
432+ rgbBase := renderer.Style{
433+ HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 100, G: 100, B: 100},
434+ HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 0, G: 0, B: 0},
435+ }
436+ fgOnlyBase := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 100, G: 100, B: 100}}
437+ noRGBBase := renderer.DefaultStyle()
438+
439+ tests := []struct {
440+ name string
441+ base renderer.Style
442+ theme Theme
443+ want renderer.Style
444+ }{
445+ {
446+ name: "usable theme blends fg toward own bg",
447+ base: rgbBase,
448+ theme: usableTheme,
449+ want: renderer.Style{
450+ HasForegroundRGB: true, ForegroundRGB: Blend(renderer.RGB{R: 100, G: 100, B: 100}, renderer.RGB{R: 0, G: 0, B: 0}, mutedVariantBlend),
451+ HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 0, G: 0, B: 0},
452+ },
453+ },
454+ {
455+ name: "non-usable theme returns base unchanged",
456+ base: rgbBase,
457+ theme: Theme{},
458+ want: rgbBase,
459+ },
460+ {
461+ name: "base without RGB background falls back to theme background",
462+ base: fgOnlyBase,
463+ theme: usableTheme,
464+ want: renderer.Style{
465+ HasForegroundRGB: true, ForegroundRGB: Blend(renderer.RGB{R: 100, G: 100, B: 100}, usableTheme.Background, mutedVariantBlend),
466+ },
467+ },
468+ {
469+ name: "base without RGB background and theme without background returns base unchanged",
470+ base: fgOnlyBase,
471+ theme: Theme{Known: true, TrueColor: true, HasFG: true},
472+ want: fgOnlyBase,
473+ },
474+ {
475+ name: "base without RGB foreground returns base unchanged",
476+ base: noRGBBase,
477+ theme: usableTheme,
478+ want: noRGBBase,
479+ },
480+ }
481+ for _, tt := range tests {
482+ t.Run(tt.name, func(t *testing.T) {
483+ if got := MutedVariantStyle(tt.base, tt.theme); !got.Equal(tt.want) {
484+ t.Fatalf("MutedVariantStyle()=%+v want %+v", got, tt.want)
485+ }
486+ })
487+ }
488+}
@@ -388,3 +388,101 @@ func TestStyleHelpersFallbackAndThemed(t *testing.T) {
388 t.Fatalf("muted themed=%+v", muted)388 t.Fatalf("muted themed=%+v", muted)
389 }389 }
390 }390 }
391+
392+func TestEmphasisStyle(t *testing.T) {
393+ usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true}
394+ base := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}}
395+
396+ tests := []struct {
397+ name string
398+ theme Theme
399+ base renderer.Style
400+ want renderer.Style
401+ }{
402+ {
403+ name: "usable theme applies bold",
404+ theme: usableTheme,
405+ base: base,
406+ want: renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}, Bold: true},
407+ },
408+ {
409+ name: "unknown theme returns base unchanged",
410+ theme: Theme{},
411+ base: base,
412+ want: base,
413+ },
414+ {
415+ name: "theme missing truecolor returns base unchanged",
416+ theme: Theme{Foreground: usableTheme.Foreground, Background: usableTheme.Background, HasFG: true, HasBG: true, Known: true},
417+ base: base,
418+ want: base,
419+ },
420+ }
421+ for _, tt := range tests {
422+ t.Run(tt.name, func(t *testing.T) {
423+ if got := EmphasisStyle(tt.base, tt.theme); !got.Equal(tt.want) {
424+ t.Fatalf("EmphasisStyle()=%+v want %+v", got, tt.want)
425+ }
426+ })
427+ }
428+}
429+
430+func TestMutedVariantStyle(t *testing.T) {
431+ usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true}
432+ rgbBase := renderer.Style{
433+ HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 100, G: 100, B: 100},
434+ HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 0, G: 0, B: 0},
435+ }
436+ fgOnlyBase := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 100, G: 100, B: 100}}
437+ noRGBBase := renderer.DefaultStyle()
438+
439+ tests := []struct {
440+ name string
441+ base renderer.Style
442+ theme Theme
443+ want renderer.Style
444+ }{
445+ {
446+ name: "usable theme blends fg toward own bg",
447+ base: rgbBase,
448+ theme: usableTheme,
449+ want: renderer.Style{
450+ HasForegroundRGB: true, ForegroundRGB: Blend(renderer.RGB{R: 100, G: 100, B: 100}, renderer.RGB{R: 0, G: 0, B: 0}, mutedVariantBlend),
451+ HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 0, G: 0, B: 0},
452+ },
453+ },
454+ {
455+ name: "non-usable theme returns base unchanged",
456+ base: rgbBase,
457+ theme: Theme{},
458+ want: rgbBase,
459+ },
460+ {
461+ name: "base without RGB background falls back to theme background",
462+ base: fgOnlyBase,
463+ theme: usableTheme,
464+ want: renderer.Style{
465+ HasForegroundRGB: true, ForegroundRGB: Blend(renderer.RGB{R: 100, G: 100, B: 100}, usableTheme.Background, mutedVariantBlend),
466+ },
467+ },
468+ {
469+ name: "base without RGB background and theme without background returns base unchanged",
470+ base: fgOnlyBase,
471+ theme: Theme{Known: true, TrueColor: true, HasFG: true},
472+ want: fgOnlyBase,
473+ },
474+ {
475+ name: "base without RGB foreground returns base unchanged",
476+ base: noRGBBase,
477+ theme: usableTheme,
478+ want: noRGBBase,
479+ },
480+ }
481+ for _, tt := range tests {
482+ t.Run(tt.name, func(t *testing.T) {
483+ if got := MutedVariantStyle(tt.base, tt.theme); !got.Equal(tt.want) {
484+ t.Fatalf("MutedVariantStyle()=%+v want %+v", got, tt.want)
485+ }
486+ })
487+ }
488+}
A internal/usecase/ui/glyphs.go +5 -0
@@ -0,0 +1,5 @@
1+package ui
2+
3+// AttentionGlyph is the bell icon drawn beside tab/session labels with a
4+// pending attention event.
5+const AttentionGlyph = ''
@@ -0,0 +1,5 @@
1+package ui
2+
3+// AttentionGlyph is the bell icon drawn beside tab/session labels with a
4+// pending attention event.
5+const AttentionGlyph = ''

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 2

Tags 13

↑↓ moveenter openesc close