detached 7a1586d

Merge remote-tracking branch 'origin/main' into feat/recent-session-jump

# Conflicts:
#	internal/usecase/daemon/status.go
commit
7a1586d665867bd6ba01926889455eb4906694db
author
brice · · 2026-07-11 13:55 UTC
merge of
c082b4b + 8b8c982 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +633 -121
M README.md +1 -1
@@ -223,7 +223,7 @@ Each tab also owns one independent floating terminal. It prewarms asynchronously
223223
224224 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.
225225
226-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*`.
226+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*`.
227227
228228 ## Scrollback mode and copy
229229
@@ -223,7 +223,7 @@ Each tab also owns one independent floating terminal. It prewarms asynchronously
223 223
224 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 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.
225 225
226-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*`.226+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*`.
227 227
228 ## Scrollback mode and copy228 ## Scrollback mode and copy
229 229
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) {
@@ -496,8 +499,16 @@ func (d *Daemon) refreshPicker(ac *attachedClient) {
496499
497500 func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
498501 styleSet := resolveThemeStyles(styles)
499- return composeModalClientFrame(base, pickerModal, styleSet, styleSet.selection, func(size domain.Size, styles ...renderer.Style) renderer.Frame {
500- return model.Render(size, preview, styles...)
502+ renderStyles := picker.RenderStyles{
503+ Selection: styleSet.selection,
504+ SelectionName: styleSet.pickerSelectionName,
505+ SelectionMuted: styleSet.pickerSelectionMuted,
506+ Name: styleSet.pickerName,
507+ Detail: styleSet.paletteDesc,
508+ Base: renderer.DefaultStyle(),
509+ }
510+ return composeModalClientFrame(base, pickerModal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {
511+ return model.Render(size, preview, renderStyles)
501512 })
502513 }
503514
@@ -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) {
@@ -496,8 +499,16 @@ func (d *Daemon) refreshPicker(ac *attachedClient) {
496 499
497 func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {500 func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
498 styleSet := resolveThemeStyles(styles)501 styleSet := resolveThemeStyles(styles)
499- return composeModalClientFrame(base, pickerModal, styleSet, styleSet.selection, func(size domain.Size, styles ...renderer.Style) renderer.Frame {502+ renderStyles := picker.RenderStyles{
500- return model.Render(size, preview, styles...)503+ Selection: styleSet.selection,
504+ SelectionName: styleSet.pickerSelectionName,
505+ SelectionMuted: styleSet.pickerSelectionMuted,
506+ Name: styleSet.pickerName,
507+ Detail: styleSet.paletteDesc,
508+ Base: renderer.DefaultStyle(),
509+ }
510+ return composeModalClientFrame(base, pickerModal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {
511+ return model.Render(size, preview, renderStyles)
501 })512 })
502 }513 }
503 514
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
@@ -372,7 +372,7 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
372372 // without reading the live MRU, whose order may have changed mid-interaction.
373373 bars := d.barStateForPaletteHints(sess, overlays.copyFeedback, overlays.paletteHints, overlays.paletteRecent)
374374 bars.theme = ac.getTheme()
375- _, attentionVisible := pulseStyle(bars.attentionFrame)
375+ attentionVisible := pulseVisible(bars.attentionFrame)
376376 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
377377
378378 styles := newThemeStyles(ac.getTheme())
@@ -582,16 +582,42 @@ type themeStyles struct {
582582 selection renderer.Style
583583 copyStatus renderer.Style
584584 paletteDesc renderer.Style
585+
586+ // Top bar tab label segments: name (bold) and parenthesized pane-title
587+ // (muted), one pair per base style. No-ops to statusBar/accent on
588+ // non-truecolor themes.
589+ tabName renderer.Style
590+ tabNameActive renderer.Style
591+ tabTitle renderer.Style
592+ tabTitleActive renderer.Style
593+
594+ // Session picker row segments. Detail reuses paletteDesc (both are
595+ // themeui.MutedTextStyle(t)); pickerName/pickerSelection* are picker-only.
596+ pickerName renderer.Style
597+ pickerSelectionName renderer.Style
598+ pickerSelectionMuted renderer.Style
585599 }
586600
587601 func newThemeStyles(t themeui.Theme) themeStyles {
602+ statusBar := themeui.StatusBarStyle(t)
603+ accent := themeui.AccentStyle(t)
604+ selection := themeui.SelectionStyle(t)
588605 return themeStyles{
589- statusBar: themeui.StatusBarStyle(t),
590- accent: themeui.AccentStyle(t),
606+ statusBar: statusBar,
607+ accent: accent,
591608 border: themeui.BorderStyle(t),
592- selection: themeui.SelectionStyle(t),
593- copyStatus: themeui.SelectionStyle(t),
609+ selection: selection,
610+ copyStatus: selection,
594611 paletteDesc: themeui.MutedTextStyle(t),
612+
613+ tabName: themeui.EmphasisStyle(statusBar, t),
614+ tabNameActive: themeui.EmphasisStyle(accent, t),
615+ tabTitle: themeui.MutedVariantStyle(statusBar, t),
616+ tabTitleActive: themeui.MutedVariantStyle(accent, t),
617+
618+ pickerName: themeui.EmphasisStyle(renderer.DefaultStyle(), t),
619+ pickerSelectionName: themeui.EmphasisStyle(selection, t),
620+ pickerSelectionMuted: themeui.MutedVariantStyle(selection, t),
595621 }
596622 }
597623
@@ -372,7 +372,7 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
372 // without reading the live MRU, whose order may have changed mid-interaction.372 // without reading the live MRU, whose order may have changed mid-interaction.
373 bars := d.barStateForPaletteHints(sess, overlays.copyFeedback, overlays.paletteHints, overlays.paletteRecent)373 bars := d.barStateForPaletteHints(sess, overlays.copyFeedback, overlays.paletteHints, overlays.paletteRecent)
374 bars.theme = ac.getTheme()374 bars.theme = ac.getTheme()
375- _, attentionVisible := pulseStyle(bars.attentionFrame)375+ attentionVisible := pulseVisible(bars.attentionFrame)
376 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)376 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
377 377
378 styles := newThemeStyles(ac.getTheme())378 styles := newThemeStyles(ac.getTheme())
@@ -582,16 +582,42 @@ type themeStyles struct {
582 selection renderer.Style582 selection renderer.Style
583 copyStatus renderer.Style583 copyStatus renderer.Style
584 paletteDesc renderer.Style584 paletteDesc renderer.Style
585+
586+ // Top bar tab label segments: name (bold) and parenthesized pane-title
587+ // (muted), one pair per base style. No-ops to statusBar/accent on
588+ // non-truecolor themes.
589+ tabName renderer.Style
590+ tabNameActive renderer.Style
591+ tabTitle renderer.Style
592+ tabTitleActive renderer.Style
593+
594+ // Session picker row segments. Detail reuses paletteDesc (both are
595+ // themeui.MutedTextStyle(t)); pickerName/pickerSelection* are picker-only.
596+ pickerName renderer.Style
597+ pickerSelectionName renderer.Style
598+ pickerSelectionMuted renderer.Style
585 }599 }
586 600
587 func newThemeStyles(t themeui.Theme) themeStyles {601 func newThemeStyles(t themeui.Theme) themeStyles {
602+ statusBar := themeui.StatusBarStyle(t)
603+ accent := themeui.AccentStyle(t)
604+ selection := themeui.SelectionStyle(t)
588 return themeStyles{605 return themeStyles{
589- statusBar: themeui.StatusBarStyle(t),606+ statusBar: statusBar,
590- accent: themeui.AccentStyle(t),607+ accent: accent,
591 border: themeui.BorderStyle(t),608 border: themeui.BorderStyle(t),
592- selection: themeui.SelectionStyle(t),609+ selection: selection,
593- copyStatus: themeui.SelectionStyle(t),610+ copyStatus: selection,
594 paletteDesc: themeui.MutedTextStyle(t),611 paletteDesc: themeui.MutedTextStyle(t),
612+
613+ tabName: themeui.EmphasisStyle(statusBar, t),
614+ tabNameActive: themeui.EmphasisStyle(accent, t),
615+ tabTitle: themeui.MutedVariantStyle(statusBar, t),
616+ tabTitleActive: themeui.MutedVariantStyle(accent, t),
617+
618+ pickerName: themeui.EmphasisStyle(renderer.DefaultStyle(), t),
619+ pickerSelectionName: themeui.EmphasisStyle(selection, t),
620+ pickerSelectionMuted: themeui.MutedVariantStyle(selection, t),
595 }621 }
596 }622 }
597 623
M internal/usecase/daemon/status.go +66 -31
@@ -20,8 +20,8 @@
2020 // Locking: a pane's screen/scrollback and per-client renderer shadow are
2121 // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
2222 // session.mu; the registry by Daemon.mu. When more than one is held the order
23-// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. The PTY reader only ever takes pane.mu, so it
24-// never blocks on a slow client.
23+// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
2525 package daemon
2626
2727 import (
@@ -37,26 +37,40 @@ import (
3737 )
3838
3939 const (
40- attentionGlyph = ''
4140 maxMRUSessions = 9
4241 pulseFrameCount = 30
4342 pulseFrameInterval = 120 * time.Millisecond
4443 )
4544
46-func pulseStyle(frame int) (renderer.Style, bool) {
47- f := frame % pulseFrameCount
48- if f == 0 {
49- return renderer.DefaultStyle(), false
45+// pulseVisible reports whether the attention bell glyph is showing (as
46+// opposed to its blank beat) at frame.
47+func pulseVisible(frame int) bool {
48+ return frame%pulseFrameCount != 0
49+}
50+
51+// pulseStyle returns the style for the attention bell glyph at frame, built
52+// on top of base so the bell always keeps the caller's background (the tab's
53+// accent/statusBar, or a faded MRU entry's blended colors) instead of
54+// punching a default-background hole in a themed bar. On the invisible beat
55+// it returns (base, false) unchanged.
56+func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) {
57+ if !pulseVisible(frame) {
58+ return base, false
5059 }
60+ f := frame % pulseFrameCount
5161 peak := pulseFrameCount / 2
5262 distance := f - peak
5363 if distance < 0 {
5464 distance = -distance
5565 }
5666 intensity := 1 - float64(distance)/float64(peak)
57- style := renderer.DefaultStyle()
67+ style := base
5868 style.Bold = true
59- style.Foreground = 244 + int(intensity*11)
69+ if base.HasForegroundRGB && base.HasBackgroundRGB {
70+ style.ForegroundRGB = themeui.Blend(base.BackgroundRGB, base.ForegroundRGB, intensity)
71+ } else {
72+ style.Foreground = 244 + int(intensity*11)
73+ }
6074 return style, true
6175 }
6276
@@ -65,16 +79,22 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t
6579 x := 0
6680 labels := fitTabLabels(status.tabs, len(row), topRight)
6781 for i, w := range status.tabs {
68- style := styles.statusBar
82+ baseStyle := styles.statusBar
83+ nameStyle := styles.tabName
84+ titleStyle := styles.tabTitle
6985 if w.active {
70- style = styles.accent
86+ baseStyle = styles.accent
87+ nameStyle = styles.tabNameActive
88+ titleStyle = styles.tabTitleActive
7189 }
72- writeStatusText(row, &x, " "+labels[i], style)
90+ label := labels[i]
91+ writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle)
7392 if w.attention {
74- writeStatusText(row, &x, " ", style)
75- writeBell(row, &x, frame)
93+ writeStatusText(row, &x, " ", baseStyle)
94+ writeBell(row, &x, frame, baseStyle)
7695 }
77- writeStatusText(row, &x, " ", style)
96+ writeStatusText(row, &x, label.text[label.nameLen:], titleStyle)
97+ writeStatusText(row, &x, " ", baseStyle)
7898 }
7999 drawRightPlainText(row, topRight, x, styles.statusBar)
80100 }
@@ -121,7 +141,7 @@ func drawRankedStatusSessionEntry(row []renderer.Cell, x *int, sess rankedRecent
121141 writeStatusText(row, x, name, style)
122142 if sess.attention {
123143 writeStatusText(row, x, " ", style)
124- writeBell(row, x, attentionFrame)
144+ writeBell(row, x, attentionFrame, style)
125145 }
126146 writeStatusText(row, x, " ", style)
127147 }
@@ -133,7 +153,7 @@ func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, ephemeral,
133153 writeStatusText(row, x, " "+name, style)
134154 if attention {
135155 writeStatusText(row, x, " ", style)
136- writeBell(row, x, attentionFrame)
156+ writeBell(row, x, attentionFrame, style)
137157 }
138158 writeStatusText(row, x, " ", style)
139159 }
@@ -285,9 +305,18 @@ func (d *Daemon) barStateBase(cur *session, copyFeedback string) barState {
285305 return state
286306 }
287307
308+// fittedTabLabel is one tab's drawable label; text[:nameLen] is the tab-name
309+// segment, text[nameLen:] the pane-title segment (possibly empty). nameLen is
310+// always a byte offset landing on a rune boundary, since it is derived from
311+// the byte length of the (possibly truncated) name prefix.
312+type fittedTabLabel struct {
313+ text string
314+ nameLen int
315+}
316+
288317 // fitTabLabels returns, per tab, the text drawn between its surrounding
289318 // spaces (attention glyph handled by the caller), guaranteeing all tabs fit.
290-func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string {
319+func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []fittedTabLabel {
291320 reserve := 1
292321 if rightText != "" {
293322 reserve = statusTextWidth(rightText) + 2
@@ -302,15 +331,17 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string {
302331 full[i] = composeTabTitle(t.name, t.paneTitle)
303332 o := 2
304333 if t.attention {
305- o += 1 + renderer.RuneWidth(attentionGlyph)
334+ o += 1 + renderer.RuneWidth(ui.AttentionGlyph)
306335 }
307336 overhead[i] = o
308337 widths[i] = statusTextWidth(full[i])
309338 total += o + widths[i]
310339 }
311- labels := make([]string, len(tabs))
340+ labels := make([]fittedTabLabel, len(tabs))
312341 if total <= budget {
313- copy(labels, full)
342+ for i, t := range tabs {
343+ labels[i] = fittedTabLabel{text: full[i], nameLen: len(t.name)}
344+ }
314345 return labels
315346 }
316347
@@ -332,13 +363,14 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string {
332363 textBudget := share - overhead[i]
333364 switch {
334365 case textBudget <= 0:
335- labels[i] = ""
366+ labels[i] = fittedTabLabel{}
336367 case widths[i] <= textBudget:
337- labels[i] = full[i]
368+ labels[i] = fittedTabLabel{text: full[i], nameLen: len(tabs[i].name)}
338369 case textBudget >= statusTextWidth(tabs[i].name)+4:
339- labels[i] = ui.TruncateText(full[i], textBudget)
370+ labels[i] = fittedTabLabel{text: ui.TruncateText(full[i], textBudget), nameLen: len(tabs[i].name)}
340371 default:
341- labels[i] = ui.TruncateText(tabs[i].name, textBudget)
372+ text := ui.TruncateText(tabs[i].name, textBudget)
373+ labels[i] = fittedTabLabel{text: text, nameLen: len(text)}
342374 }
343375 consumed := overhead[i] + min(widths[i], max(textBudget, 0))
344376 remaining -= consumed
@@ -363,7 +395,7 @@ func fitRankedRecent(entries []rankedRecent, rowLen, leftUsed int, feedback stri
363395 }
364396 width := 2 + statusTextWidth(strconv.Itoa(e.rank)+":") + statusTextWidth(name)
365397 if e.attention {
366- width += 1 + renderer.RuneWidth(attentionGlyph)
398+ width += 1 + renderer.RuneWidth(ui.AttentionGlyph)
367399 }
368400 return width
369401 }
@@ -395,7 +427,7 @@ func fitMRU(entries []recentSession, rowLen, leftUsed int, feedback string) []re
395427 }
396428 n := 2 + statusTextWidth(name)
397429 if e.attention {
398- n += 1 + renderer.RuneWidth(attentionGlyph)
430+ n += 1 + renderer.RuneWidth(ui.AttentionGlyph)
399431 }
400432 return n
401433 }
@@ -453,13 +485,16 @@ func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style
453485 return base
454486 }
455487
456-func writeBell(row []renderer.Cell, x *int, frame int) {
457- style, visible := pulseStyle(frame)
488+// writeBell draws the attention glyph on its visible beat, or a plain cell in
489+// base's style on its blank beat, so the bell never leaves a bare
490+// default-background hole in a themed bar.
491+func writeBell(row []renderer.Cell, x *int, frame int, base renderer.Style) {
492+ style, visible := pulseStyle(frame, base)
458493 if !visible {
459- writeStatusText(row, x, " ", renderer.DefaultStyle())
494+ writeStatusText(row, x, " ", base)
460495 return
461496 }
462- writeStatusText(row, x, string(attentionGlyph), style)
497+ writeStatusText(row, x, string(ui.AttentionGlyph), style)
463498 }
464499
465500 func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {
@@ -20,8 +20,8 @@
20 // Locking: a pane's screen/scrollback and per-client renderer shadow are20 // Locking: a pane's screen/scrollback and per-client renderer shadow are
21 // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by21 // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22 // session.mu; the registry by Daemon.mu. When more than one is held the order22 // session.mu; the registry by Daemon.mu. When more than one is held the order
23-// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. The PTY reader only ever takes pane.mu, so it23+// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24-// never blocks on a slow client.24+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
25 package daemon25 package daemon
26 26
27 import (27 import (
@@ -37,26 +37,40 @@ import (
37 )37 )
38 38
39 const (39 const (
40- attentionGlyph = ''
41 maxMRUSessions = 940 maxMRUSessions = 9
42 pulseFrameCount = 3041 pulseFrameCount = 30
43 pulseFrameInterval = 120 * time.Millisecond42 pulseFrameInterval = 120 * time.Millisecond
44 )43 )
45 44
46-func pulseStyle(frame int) (renderer.Style, bool) {45+// pulseVisible reports whether the attention bell glyph is showing (as
47- f := frame % pulseFrameCount46+// opposed to its blank beat) at frame.
48- if f == 0 {47+func pulseVisible(frame int) bool {
49- return renderer.DefaultStyle(), false48+ return frame%pulseFrameCount != 0
49+}
50+
51+// pulseStyle returns the style for the attention bell glyph at frame, built
52+// on top of base so the bell always keeps the caller's background (the tab's
53+// accent/statusBar, or a faded MRU entry's blended colors) instead of
54+// punching a default-background hole in a themed bar. On the invisible beat
55+// it returns (base, false) unchanged.
56+func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) {
57+ if !pulseVisible(frame) {
58+ return base, false
50 }59 }
60+ f := frame % pulseFrameCount
51 peak := pulseFrameCount / 261 peak := pulseFrameCount / 2
52 distance := f - peak62 distance := f - peak
53 if distance < 0 {63 if distance < 0 {
54 distance = -distance64 distance = -distance
55 }65 }
56 intensity := 1 - float64(distance)/float64(peak)66 intensity := 1 - float64(distance)/float64(peak)
57- style := renderer.DefaultStyle()67+ style := base
58 style.Bold = true68 style.Bold = true
59- style.Foreground = 244 + int(intensity*11)69+ if base.HasForegroundRGB && base.HasBackgroundRGB {
70+ style.ForegroundRGB = themeui.Blend(base.BackgroundRGB, base.ForegroundRGB, intensity)
71+ } else {
72+ style.Foreground = 244 + int(intensity*11)
73+ }
60 return style, true74 return style, true
61 }75 }
62 76
@@ -65,16 +79,22 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t
65 x := 079 x := 0
66 labels := fitTabLabels(status.tabs, len(row), topRight)80 labels := fitTabLabels(status.tabs, len(row), topRight)
67 for i, w := range status.tabs {81 for i, w := range status.tabs {
68- style := styles.statusBar82+ baseStyle := styles.statusBar
83+ nameStyle := styles.tabName
84+ titleStyle := styles.tabTitle
69 if w.active {85 if w.active {
70- style = styles.accent86+ baseStyle = styles.accent
87+ nameStyle = styles.tabNameActive
88+ titleStyle = styles.tabTitleActive
71 }89 }
72- writeStatusText(row, &x, " "+labels[i], style)90+ label := labels[i]
91+ writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle)
73 if w.attention {92 if w.attention {
74- writeStatusText(row, &x, " ", style)93+ writeStatusText(row, &x, " ", baseStyle)
75- writeBell(row, &x, frame)94+ writeBell(row, &x, frame, baseStyle)
76 }95 }
77- writeStatusText(row, &x, " ", style)96+ writeStatusText(row, &x, label.text[label.nameLen:], titleStyle)
97+ writeStatusText(row, &x, " ", baseStyle)
78 }98 }
79 drawRightPlainText(row, topRight, x, styles.statusBar)99 drawRightPlainText(row, topRight, x, styles.statusBar)
80 }100 }
@@ -121,7 +141,7 @@ func drawRankedStatusSessionEntry(row []renderer.Cell, x *int, sess rankedRecent
121 writeStatusText(row, x, name, style)141 writeStatusText(row, x, name, style)
122 if sess.attention {142 if sess.attention {
123 writeStatusText(row, x, " ", style)143 writeStatusText(row, x, " ", style)
124- writeBell(row, x, attentionFrame)144+ writeBell(row, x, attentionFrame, style)
125 }145 }
126 writeStatusText(row, x, " ", style)146 writeStatusText(row, x, " ", style)
127 }147 }
@@ -133,7 +153,7 @@ func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, ephemeral,
133 writeStatusText(row, x, " "+name, style)153 writeStatusText(row, x, " "+name, style)
134 if attention {154 if attention {
135 writeStatusText(row, x, " ", style)155 writeStatusText(row, x, " ", style)
136- writeBell(row, x, attentionFrame)156+ writeBell(row, x, attentionFrame, style)
137 }157 }
138 writeStatusText(row, x, " ", style)158 writeStatusText(row, x, " ", style)
139 }159 }
@@ -285,9 +305,18 @@ func (d *Daemon) barStateBase(cur *session, copyFeedback string) barState {
285 return state305 return state
286 }306 }
287 307
308+// fittedTabLabel is one tab's drawable label; text[:nameLen] is the tab-name
309+// segment, text[nameLen:] the pane-title segment (possibly empty). nameLen is
310+// always a byte offset landing on a rune boundary, since it is derived from
311+// the byte length of the (possibly truncated) name prefix.
312+type fittedTabLabel struct {
313+ text string
314+ nameLen int
315+}
316+
288 // fitTabLabels returns, per tab, the text drawn between its surrounding317 // fitTabLabels returns, per tab, the text drawn between its surrounding
289 // spaces (attention glyph handled by the caller), guaranteeing all tabs fit.318 // spaces (attention glyph handled by the caller), guaranteeing all tabs fit.
290-func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string {319+func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []fittedTabLabel {
291 reserve := 1320 reserve := 1
292 if rightText != "" {321 if rightText != "" {
293 reserve = statusTextWidth(rightText) + 2322 reserve = statusTextWidth(rightText) + 2
@@ -302,15 +331,17 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string {
302 full[i] = composeTabTitle(t.name, t.paneTitle)331 full[i] = composeTabTitle(t.name, t.paneTitle)
303 o := 2332 o := 2
304 if t.attention {333 if t.attention {
305- o += 1 + renderer.RuneWidth(attentionGlyph)334+ o += 1 + renderer.RuneWidth(ui.AttentionGlyph)
306 }335 }
307 overhead[i] = o336 overhead[i] = o
308 widths[i] = statusTextWidth(full[i])337 widths[i] = statusTextWidth(full[i])
309 total += o + widths[i]338 total += o + widths[i]
310 }339 }
311- labels := make([]string, len(tabs))340+ labels := make([]fittedTabLabel, len(tabs))
312 if total <= budget {341 if total <= budget {
313- copy(labels, full)342+ for i, t := range tabs {
343+ labels[i] = fittedTabLabel{text: full[i], nameLen: len(t.name)}
344+ }
314 return labels345 return labels
315 }346 }
316 347
@@ -332,13 +363,14 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string {
332 textBudget := share - overhead[i]363 textBudget := share - overhead[i]
333 switch {364 switch {
334 case textBudget <= 0:365 case textBudget <= 0:
335- labels[i] = ""366+ labels[i] = fittedTabLabel{}
336 case widths[i] <= textBudget:367 case widths[i] <= textBudget:
337- labels[i] = full[i]368+ labels[i] = fittedTabLabel{text: full[i], nameLen: len(tabs[i].name)}
338 case textBudget >= statusTextWidth(tabs[i].name)+4:369 case textBudget >= statusTextWidth(tabs[i].name)+4:
339- labels[i] = ui.TruncateText(full[i], textBudget)370+ labels[i] = fittedTabLabel{text: ui.TruncateText(full[i], textBudget), nameLen: len(tabs[i].name)}
340 default:371 default:
341- labels[i] = ui.TruncateText(tabs[i].name, textBudget)372+ text := ui.TruncateText(tabs[i].name, textBudget)
373+ labels[i] = fittedTabLabel{text: text, nameLen: len(text)}
342 }374 }
343 consumed := overhead[i] + min(widths[i], max(textBudget, 0))375 consumed := overhead[i] + min(widths[i], max(textBudget, 0))
344 remaining -= consumed376 remaining -= consumed
@@ -363,7 +395,7 @@ func fitRankedRecent(entries []rankedRecent, rowLen, leftUsed int, feedback stri
363 }395 }
364 width := 2 + statusTextWidth(strconv.Itoa(e.rank)+":") + statusTextWidth(name)396 width := 2 + statusTextWidth(strconv.Itoa(e.rank)+":") + statusTextWidth(name)
365 if e.attention {397 if e.attention {
366- width += 1 + renderer.RuneWidth(attentionGlyph)398+ width += 1 + renderer.RuneWidth(ui.AttentionGlyph)
367 }399 }
368 return width400 return width
369 }401 }
@@ -395,7 +427,7 @@ func fitMRU(entries []recentSession, rowLen, leftUsed int, feedback string) []re
395 }427 }
396 n := 2 + statusTextWidth(name)428 n := 2 + statusTextWidth(name)
397 if e.attention {429 if e.attention {
398- n += 1 + renderer.RuneWidth(attentionGlyph)430+ n += 1 + renderer.RuneWidth(ui.AttentionGlyph)
399 }431 }
400 return n432 return n
401 }433 }
@@ -453,13 +485,16 @@ func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style
453 return base485 return base
454 }486 }
455 487
456-func writeBell(row []renderer.Cell, x *int, frame int) {488+// writeBell draws the attention glyph on its visible beat, or a plain cell in
457- style, visible := pulseStyle(frame)489+// base's style on its blank beat, so the bell never leaves a bare
490+// default-background hole in a themed bar.
491+func writeBell(row []renderer.Cell, x *int, frame int, base renderer.Style) {
492+ style, visible := pulseStyle(frame, base)
458 if !visible {493 if !visible {
459- writeStatusText(row, x, " ", renderer.DefaultStyle())494+ writeStatusText(row, x, " ", base)
460 return495 return
461 }496 }
462- writeStatusText(row, x, string(attentionGlyph), style)497+ writeStatusText(row, x, string(ui.AttentionGlyph), style)
463 }498 }
464 499
465 func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {500 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
@@ -16,6 +17,7 @@ import (
1617 "github.com/bnema/vev/internal/usecase/layout"
1718 "github.com/bnema/vev/internal/usecase/palette"
1819 themeui "github.com/bnema/vev/internal/usecase/theme"
20+ "github.com/bnema/vev/internal/usecase/ui"
1921 "github.com/bnema/vev/pkg/renderer"
2022 )
2123
@@ -164,19 +166,19 @@ func TestFitTabLabels(t *testing.T) {
164166 tabs []statusTab
165167 rowLen int
166168 rightText string
167- want []string
169+ want []fittedTabLabel
168170 }{
169171 {
170172 name: "no tabs",
171173 tabs: nil,
172174 rowLen: 10,
173- want: []string{},
175+ want: []fittedTabLabel{},
174176 },
175177 {
176178 name: "everything fits stays untruncated",
177179 tabs: []statusTab{{name: "one"}, {name: "two", paneTitle: "shell"}},
178180 rowLen: 40,
179- want: []string{"one", "two (shell)"},
181+ want: []fittedTabLabel{{text: "one", nameLen: len("one")}, {text: "two (shell)", nameLen: len("two")}},
180182 },
181183 {
182184 name: "overflow redistributes budget: short tab stays full, long tab gets ellipsis",
@@ -185,26 +187,26 @@ func TestFitTabLabels(t *testing.T) {
185187 {name: "ed", paneTitle: "verylongtitle"},
186188 },
187189 rowLen: 20,
188- want: []string{"a", "ed (verylongt…"},
190+ want: []fittedTabLabel{{text: "a", nameLen: len("a")}, {text: "ed (verylongt…", nameLen: len("ed")}},
189191 },
190192 {
191193 name: "tight budget degrades to a truncated tab name only",
192194 tabs: []statusTab{{name: "editor", paneTitle: "some pane title text"}},
193195 rowLen: 7,
194- want: []string{"edi…"},
196+ want: []fittedTabLabel{{text: "edi…", nameLen: len("edi…")}},
195197 },
196198 {
197199 name: "non-empty rightText shrinks the budget",
198200 tabs: []statusTab{{name: "aaaaaaaaaa"}},
199201 rowLen: 20,
200202 rightText: "0123456789",
201- want: []string{"aaaaa…"},
203+ want: []fittedTabLabel{{text: "aaaaa…", nameLen: len("aaaaa…")}},
202204 },
203205 {
204206 name: "single tab wider than the whole row collapses to empty",
205207 tabs: []statusTab{{name: "reallyverylongtabname"}},
206208 rowLen: 3,
207- want: []string{""},
209+ want: []fittedTabLabel{{}},
208210 },
209211 }
210212 for _, tt := range tests {
@@ -222,9 +224,13 @@ func TestFitTabLabels(t *testing.T) {
222224 for i, tb := range tt.tabs {
223225 overhead := 2
224226 if tb.attention {
225- overhead += 1 + renderer.RuneWidth(attentionGlyph)
227+ overhead += 1 + renderer.RuneWidth(ui.AttentionGlyph)
228+ }
229+ drawn += overhead + statusTextWidth(got[i].text)
230+ require.LessOrEqual(t, got[i].nameLen, len(got[i].text), "nameLen must not exceed the label's byte length")
231+ if got[i].nameLen < len(got[i].text) {
232+ require.True(t, utf8.RuneStart(got[i].text[got[i].nameLen]), "nameLen must land on a rune boundary")
226233 }
227- drawn += overhead + statusTextWidth(got[i])
228234 }
229235 if budget > 0 {
230236 require.LessOrEqual(t, drawn, budget, "drawn width must not exceed the derived budget")
@@ -233,6 +239,141 @@ func TestFitTabLabels(t *testing.T) {
233239 }
234240 }
235241
242+func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
243+ tabs := []statusTab{
244+ {name: "api", paneTitle: "shell"},
245+ {name: "logs", active: true, attention: true},
246+ {name: "err", paneTitle: "build failed", attention: true},
247+ }
248+ status := statusSnapshot{tabs: tabs}
249+ const rowLen = 60
250+
251+ t.Run("truecolor theme bolds names, mutes titles, and the bell keeps the base background", func(t *testing.T) {
252+ theme := themeui.Theme{
253+ Foreground: renderer.RGB{R: 200, G: 200, B: 200},
254+ Background: renderer.RGB{R: 10, G: 20, B: 30},
255+ HasFG: true,
256+ HasBG: true,
257+ Known: true,
258+ TrueColor: true,
259+ }
260+ styles := newThemeStyles(theme)
261+ require.False(t, styles.tabTitle.Equal(styles.statusBar), "sanity: muted title style must differ from the base style on a truecolor theme")
262+ require.False(t, styles.tabTitleActive.Equal(styles.accent), "sanity: muted active-title style must differ from the base accent style")
263+
264+ labels := fitTabLabels(tabs, rowLen, "")
265+
266+ // Frame 0 is the bell's blank beat: the attention space and the blank
267+ // bell cell must both be exactly the tab's base style (not
268+ // renderer.DefaultStyle(), which would punch a hole in a themed bar).
269+ // This also pins down segment order: name, then (if attention)
270+ // space+bell, then title, then the trailing space.
271+ row := make([]renderer.Cell, rowLen)
272+ drawTopBarSnapshot(row, status, 0, "", styles)
273+ x := 0
274+
275+ // tab 0: inactive, no attention: name, title, trailing space.
276+ nameEnd := x + 1 + labels[0].nameLen
277+ for i := x; i < nameEnd; i++ {
278+ require.True(t, row[i].Style.Bold, "cell %d: name segment must be bold", i)
279+ require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: name segment style mismatch", i)
280+ }
281+ x = nameEnd
282+ titleLen := len(labels[0].text) - labels[0].nameLen
283+ titleEnd := x + titleLen
284+ for i := x; i < titleEnd; i++ {
285+ require.False(t, row[i].Style.Bold, "cell %d: title segment must not be bold", i)
286+ require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: title segment style mismatch", i)
287+ }
288+ x = titleEnd
289+ require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 0 trailing space must keep the base style", x)
290+ x++
291+
292+ // tab 1: active, attention, no title: name, attention space+bell (both
293+ // base style at frame 0), trailing space.
294+ nameEnd = x + 1 + labels[1].nameLen
295+ for i := x; i < nameEnd; i++ {
296+ require.True(t, row[i].Style.Bold, "cell %d: active name segment must be bold", i)
297+ require.True(t, row[i].Style.Equal(styles.tabNameActive), "cell %d: active name segment style mismatch", i)
298+ }
299+ require.Equal(t, len(labels[1].text), labels[1].nameLen, "tab 1 has no pane title, so its whole label is the name segment")
300+ x = nameEnd
301+ require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 attention leading space must keep the active base style", x)
302+ x++
303+ tab1Bell := x
304+ require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 blank bell must keep the active base style, not DefaultStyle", x)
305+ x++
306+ require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 trailing space must keep the active base style", x)
307+ x++
308+
309+ // tab 2: inactive, attention, with title: name, attention space+bell
310+ // (both base style at frame 0), title, trailing space.
311+ nameEnd = x + 1 + labels[2].nameLen
312+ for i := x; i < nameEnd; i++ {
313+ require.True(t, row[i].Style.Bold, "cell %d: tab 2 name segment must be bold", i)
314+ require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: tab 2 name segment style mismatch", i)
315+ }
316+ x = nameEnd
317+ require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 attention leading space must keep the base style", x)
318+ x++
319+ tab2Bell := x
320+ require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 blank bell must keep the base style, not DefaultStyle", x)
321+ x++
322+ titleLen = len(labels[2].text) - labels[2].nameLen
323+ titleEnd = x + titleLen
324+ for i := x; i < titleEnd; i++ {
325+ require.False(t, row[i].Style.Bold, "cell %d: tab 2 title segment must not be bold", i)
326+ require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: tab 2 title segment style mismatch", i)
327+ }
328+ x = titleEnd
329+ require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 trailing space must keep the base style", x)
330+
331+ // On a visible pulse frame, the bell glyph itself must keep the same
332+ // background as its tab's base style.
333+ visibleRow := make([]renderer.Cell, rowLen)
334+ drawTopBarSnapshot(visibleRow, status, 1, "", styles)
335+ require.Equal(t, rune(ui.AttentionGlyph), visibleRow[tab1Bell].Rune)
336+ require.True(t, visibleRow[tab1Bell].Style.HasBackgroundRGB)
337+ require.Equal(t, styles.accent.BackgroundRGB, visibleRow[tab1Bell].Style.BackgroundRGB, "tab 1's visible bell must keep the active base background")
338+ require.Equal(t, rune(ui.AttentionGlyph), visibleRow[tab2Bell].Rune)
339+ require.True(t, visibleRow[tab2Bell].Style.HasBackgroundRGB)
340+ require.Equal(t, styles.statusBar.BackgroundRGB, visibleRow[tab2Bell].Style.BackgroundRGB, "tab 2's visible bell must keep the plain base background")
341+ })
342+
343+ t.Run("non-usable theme matches the pre-change fallback styles", func(t *testing.T) {
344+ styles := resolveThemeStyles(nil)
345+ require.True(t, styles.tabName.Equal(styles.statusBar))
346+ require.True(t, styles.tabTitle.Equal(styles.statusBar))
347+ require.True(t, styles.tabNameActive.Equal(styles.accent))
348+ require.True(t, styles.tabTitleActive.Equal(styles.accent))
349+
350+ row := make([]renderer.Cell, rowLen)
351+ drawTopBarSnapshot(row, status, 0, "", styles)
352+
353+ // " api (shell) " (tab 0) + " logs " (tab 1: name, attention
354+ // space+blank bell, trailing space) + " err (build failed) " (tab 2:
355+ // name, attention space+blank bell, title, trailing space), then blank
356+ // padding to rowLen: byte-for-byte what the pre-change renderer
357+ // produced, since every new style field collapses to statusBar/accent.
358+ const tab0 = " api (shell) "
359+ const tab1 = " logs "
360+ const tab2 = " err (build failed) "
361+ require.Equal(t, tab0+tab1+tab2+strings.Repeat(" ", rowLen-len(tab0)-len(tab1)-len(tab2)), rowText(row))
362+
363+ for i, c := range row[:len(tab0)] {
364+ require.True(t, c.Style.Equal(styles.statusBar), "cell %d should use the plain base style", i)
365+ }
366+ tab1Start := len(tab0)
367+ for i := tab1Start; i < tab1Start+len(tab1); i++ {
368+ require.True(t, row[i].Style.Equal(styles.accent), "cell %d should use the plain accent style, including the blank bell cell", i)
369+ }
370+ tab2Start := tab1Start + len(tab1)
371+ for i := tab2Start; i < tab2Start+len(tab2); i++ {
372+ require.True(t, row[i].Style.Equal(styles.statusBar), "cell %d should use the plain base style, including the blank bell cell", i)
373+ }
374+ })
375+}
376+
236377 func TestTabDisplayName(t *testing.T) {
237378 tests := []struct {
238379 name string
@@ -722,20 +863,49 @@ func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
722863 }
723864
724865 func TestAttentionConstants(t *testing.T) {
725- require.Equal(t, '', attentionGlyph)
866+ require.Equal(t, '', ui.AttentionGlyph)
726867 require.Equal(t, 30, pulseFrameCount)
727868 require.Equal(t, 120*time.Millisecond, pulseFrameInterval)
728869 }
729870
730-func TestPulseStyleHidesAtFrameZeroAndRamps(t *testing.T) {
731- _, visible := pulseStyle(0)
732- require.False(t, visible)
871+func TestPulseStyleFadesFromBaseAndHidesAtFrameZero(t *testing.T) {
872+ rgbBase := renderer.Style{
873+ HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 200, G: 200, B: 200},
874+ HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 10, G: 20, B: 30},
875+ }
876+ indexedBase := renderer.Style{Foreground: -1, Background: -1, Inverse: true}
877+
878+ t.Run("invisible frame returns base unchanged", func(t *testing.T) {
879+ for _, base := range []renderer.Style{rgbBase, indexedBase} {
880+ style, visible := pulseStyle(0, base)
881+ require.False(t, visible)
882+ require.True(t, style.Equal(base))
883+ }
884+ })
885+
886+ t.Run("rgb base blends the glyph foreground from the base background to its foreground, keeping the base background", func(t *testing.T) {
887+ low, visible := pulseStyle(1, rgbBase)
888+ require.True(t, visible)
889+ require.True(t, low.Bold)
890+ require.True(t, low.HasBackgroundRGB)
891+ require.Equal(t, rgbBase.BackgroundRGB, low.BackgroundRGB, "bell keeps the caller's background so it never punches a hole in a themed bar")
892+
893+ peak, visible := pulseStyle(pulseFrameCount/2, rgbBase)
894+ require.True(t, visible)
895+ require.Equal(t, rgbBase.ForegroundRGB, peak.ForegroundRGB, "peak intensity reaches the base foreground exactly")
896+ require.NotEqual(t, low.ForegroundRGB, peak.ForegroundRGB, "the glyph should ramp, not jump straight to full intensity")
897+ })
898+
899+ t.Run("non-RGB base falls back to the indexed grey ramp and preserves other base attributes", func(t *testing.T) {
900+ low, visible := pulseStyle(1, indexedBase)
901+ require.True(t, visible)
902+ require.True(t, low.Bold)
903+ require.True(t, low.Inverse, "non-color base attributes like inverse must survive")
733904
734- low, visible := pulseStyle(1)
735- require.True(t, visible)
736- peak, visible := pulseStyle(pulseFrameCount / 2)
737- require.True(t, visible)
738- require.Greater(t, peak.Foreground, low.Foreground)
905+ peak, visible := pulseStyle(pulseFrameCount/2, indexedBase)
906+ require.True(t, visible)
907+ require.Greater(t, peak.Foreground, low.Foreground)
908+ })
739909 }
740910
741911 func TestTopBarRendersAttentionBell(t *testing.T) {
@@ -755,10 +925,11 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
755925 frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(true), attentionFrame: 1}, win, true)
756926
757927 // Tab 2's label is enriched with its focused pane's title ("sh", the
758- // default shell fallback) and truncated to fit alongside the bell.
759- require.Contains(t, rowText(frame.Row(0)), "2 (s")
928+ // default shell fallback) and truncated to fit alongside the bell. The
929+ // bell sits right after the name, before the (title) segment.
930+ require.Contains(t, rowText(frame.Row(0)), "2 "+string(ui.AttentionGlyph)+" (s")
760931 for _, c := range frame.Row(0) {
761- if c.Rune == attentionGlyph {
932+ if c.Rune == ui.AttentionGlyph {
762933 require.True(t, c.Style.Bold)
763934 return
764935 }
@@ -879,7 +1050,7 @@ func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) {
8791050
8801051 require.Equal(t, " cur fresh tmp*  ", rowText(row))
8811052 for _, c := range row {
882- if c.Rune == attentionGlyph {
1053+ if c.Rune == ui.AttentionGlyph {
8831054 require.True(t, c.Style.Bold)
8841055 return
8851056 }
@@ -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
@@ -16,6 +17,7 @@ import (
16 "github.com/bnema/vev/internal/usecase/layout"17 "github.com/bnema/vev/internal/usecase/layout"
17 "github.com/bnema/vev/internal/usecase/palette"18 "github.com/bnema/vev/internal/usecase/palette"
18 themeui "github.com/bnema/vev/internal/usecase/theme"19 themeui "github.com/bnema/vev/internal/usecase/theme"
20+ "github.com/bnema/vev/internal/usecase/ui"
19 "github.com/bnema/vev/pkg/renderer"21 "github.com/bnema/vev/pkg/renderer"
20 )22 )
21 23
@@ -164,19 +166,19 @@ func TestFitTabLabels(t *testing.T) {
164 tabs []statusTab166 tabs []statusTab
165 rowLen int167 rowLen int
166 rightText string168 rightText string
167- want []string169+ want []fittedTabLabel
168 }{170 }{
169 {171 {
170 name: "no tabs",172 name: "no tabs",
171 tabs: nil,173 tabs: nil,
172 rowLen: 10,174 rowLen: 10,
173- want: []string{},175+ want: []fittedTabLabel{},
174 },176 },
175 {177 {
176 name: "everything fits stays untruncated",178 name: "everything fits stays untruncated",
177 tabs: []statusTab{{name: "one"}, {name: "two", paneTitle: "shell"}},179 tabs: []statusTab{{name: "one"}, {name: "two", paneTitle: "shell"}},
178 rowLen: 40,180 rowLen: 40,
179- want: []string{"one", "two (shell)"},181+ want: []fittedTabLabel{{text: "one", nameLen: len("one")}, {text: "two (shell)", nameLen: len("two")}},
180 },182 },
181 {183 {
182 name: "overflow redistributes budget: short tab stays full, long tab gets ellipsis",184 name: "overflow redistributes budget: short tab stays full, long tab gets ellipsis",
@@ -185,26 +187,26 @@ func TestFitTabLabels(t *testing.T) {
185 {name: "ed", paneTitle: "verylongtitle"},187 {name: "ed", paneTitle: "verylongtitle"},
186 },188 },
187 rowLen: 20,189 rowLen: 20,
188- want: []string{"a", "ed (verylongt…"},190+ want: []fittedTabLabel{{text: "a", nameLen: len("a")}, {text: "ed (verylongt…", nameLen: len("ed")}},
189 },191 },
190 {192 {
191 name: "tight budget degrades to a truncated tab name only",193 name: "tight budget degrades to a truncated tab name only",
192 tabs: []statusTab{{name: "editor", paneTitle: "some pane title text"}},194 tabs: []statusTab{{name: "editor", paneTitle: "some pane title text"}},
193 rowLen: 7,195 rowLen: 7,
194- want: []string{"edi…"},196+ want: []fittedTabLabel{{text: "edi…", nameLen: len("edi…")}},
195 },197 },
196 {198 {
197 name: "non-empty rightText shrinks the budget",199 name: "non-empty rightText shrinks the budget",
198 tabs: []statusTab{{name: "aaaaaaaaaa"}},200 tabs: []statusTab{{name: "aaaaaaaaaa"}},
199 rowLen: 20,201 rowLen: 20,
200 rightText: "0123456789",202 rightText: "0123456789",
201- want: []string{"aaaaa…"},203+ want: []fittedTabLabel{{text: "aaaaa…", nameLen: len("aaaaa…")}},
202 },204 },
203 {205 {
204 name: "single tab wider than the whole row collapses to empty",206 name: "single tab wider than the whole row collapses to empty",
205 tabs: []statusTab{{name: "reallyverylongtabname"}},207 tabs: []statusTab{{name: "reallyverylongtabname"}},
206 rowLen: 3,208 rowLen: 3,
207- want: []string{""},209+ want: []fittedTabLabel{{}},
208 },210 },
209 }211 }
210 for _, tt := range tests {212 for _, tt := range tests {
@@ -222,9 +224,13 @@ func TestFitTabLabels(t *testing.T) {
222 for i, tb := range tt.tabs {224 for i, tb := range tt.tabs {
223 overhead := 2225 overhead := 2
224 if tb.attention {226 if tb.attention {
225- overhead += 1 + renderer.RuneWidth(attentionGlyph)227+ overhead += 1 + renderer.RuneWidth(ui.AttentionGlyph)
228+ }
229+ drawn += overhead + statusTextWidth(got[i].text)
230+ require.LessOrEqual(t, got[i].nameLen, len(got[i].text), "nameLen must not exceed the label's byte length")
231+ if got[i].nameLen < len(got[i].text) {
232+ require.True(t, utf8.RuneStart(got[i].text[got[i].nameLen]), "nameLen must land on a rune boundary")
226 }233 }
227- drawn += overhead + statusTextWidth(got[i])
228 }234 }
229 if budget > 0 {235 if budget > 0 {
230 require.LessOrEqual(t, drawn, budget, "drawn width must not exceed the derived budget")236 require.LessOrEqual(t, drawn, budget, "drawn width must not exceed the derived budget")
@@ -233,6 +239,141 @@ func TestFitTabLabels(t *testing.T) {
233 }239 }
234 }240 }
235 241
242+func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
243+ tabs := []statusTab{
244+ {name: "api", paneTitle: "shell"},
245+ {name: "logs", active: true, attention: true},
246+ {name: "err", paneTitle: "build failed", attention: true},
247+ }
248+ status := statusSnapshot{tabs: tabs}
249+ const rowLen = 60
250+
251+ t.Run("truecolor theme bolds names, mutes titles, and the bell keeps the base background", func(t *testing.T) {
252+ theme := themeui.Theme{
253+ Foreground: renderer.RGB{R: 200, G: 200, B: 200},
254+ Background: renderer.RGB{R: 10, G: 20, B: 30},
255+ HasFG: true,
256+ HasBG: true,
257+ Known: true,
258+ TrueColor: true,
259+ }
260+ styles := newThemeStyles(theme)
261+ require.False(t, styles.tabTitle.Equal(styles.statusBar), "sanity: muted title style must differ from the base style on a truecolor theme")
262+ require.False(t, styles.tabTitleActive.Equal(styles.accent), "sanity: muted active-title style must differ from the base accent style")
263+
264+ labels := fitTabLabels(tabs, rowLen, "")
265+
266+ // Frame 0 is the bell's blank beat: the attention space and the blank
267+ // bell cell must both be exactly the tab's base style (not
268+ // renderer.DefaultStyle(), which would punch a hole in a themed bar).
269+ // This also pins down segment order: name, then (if attention)
270+ // space+bell, then title, then the trailing space.
271+ row := make([]renderer.Cell, rowLen)
272+ drawTopBarSnapshot(row, status, 0, "", styles)
273+ x := 0
274+
275+ // tab 0: inactive, no attention: name, title, trailing space.
276+ nameEnd := x + 1 + labels[0].nameLen
277+ for i := x; i < nameEnd; i++ {
278+ require.True(t, row[i].Style.Bold, "cell %d: name segment must be bold", i)
279+ require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: name segment style mismatch", i)
280+ }
281+ x = nameEnd
282+ titleLen := len(labels[0].text) - labels[0].nameLen
283+ titleEnd := x + titleLen
284+ for i := x; i < titleEnd; i++ {
285+ require.False(t, row[i].Style.Bold, "cell %d: title segment must not be bold", i)
286+ require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: title segment style mismatch", i)
287+ }
288+ x = titleEnd
289+ require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 0 trailing space must keep the base style", x)
290+ x++
291+
292+ // tab 1: active, attention, no title: name, attention space+bell (both
293+ // base style at frame 0), trailing space.
294+ nameEnd = x + 1 + labels[1].nameLen
295+ for i := x; i < nameEnd; i++ {
296+ require.True(t, row[i].Style.Bold, "cell %d: active name segment must be bold", i)
297+ require.True(t, row[i].Style.Equal(styles.tabNameActive), "cell %d: active name segment style mismatch", i)
298+ }
299+ require.Equal(t, len(labels[1].text), labels[1].nameLen, "tab 1 has no pane title, so its whole label is the name segment")
300+ x = nameEnd
301+ require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 attention leading space must keep the active base style", x)
302+ x++
303+ tab1Bell := x
304+ require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 blank bell must keep the active base style, not DefaultStyle", x)
305+ x++
306+ require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 trailing space must keep the active base style", x)
307+ x++
308+
309+ // tab 2: inactive, attention, with title: name, attention space+bell
310+ // (both base style at frame 0), title, trailing space.
311+ nameEnd = x + 1 + labels[2].nameLen
312+ for i := x; i < nameEnd; i++ {
313+ require.True(t, row[i].Style.Bold, "cell %d: tab 2 name segment must be bold", i)
314+ require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: tab 2 name segment style mismatch", i)
315+ }
316+ x = nameEnd
317+ require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 attention leading space must keep the base style", x)
318+ x++
319+ tab2Bell := x
320+ require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 blank bell must keep the base style, not DefaultStyle", x)
321+ x++
322+ titleLen = len(labels[2].text) - labels[2].nameLen
323+ titleEnd = x + titleLen
324+ for i := x; i < titleEnd; i++ {
325+ require.False(t, row[i].Style.Bold, "cell %d: tab 2 title segment must not be bold", i)
326+ require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: tab 2 title segment style mismatch", i)
327+ }
328+ x = titleEnd
329+ require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 trailing space must keep the base style", x)
330+
331+ // On a visible pulse frame, the bell glyph itself must keep the same
332+ // background as its tab's base style.
333+ visibleRow := make([]renderer.Cell, rowLen)
334+ drawTopBarSnapshot(visibleRow, status, 1, "", styles)
335+ require.Equal(t, rune(ui.AttentionGlyph), visibleRow[tab1Bell].Rune)
336+ require.True(t, visibleRow[tab1Bell].Style.HasBackgroundRGB)
337+ require.Equal(t, styles.accent.BackgroundRGB, visibleRow[tab1Bell].Style.BackgroundRGB, "tab 1's visible bell must keep the active base background")
338+ require.Equal(t, rune(ui.AttentionGlyph), visibleRow[tab2Bell].Rune)
339+ require.True(t, visibleRow[tab2Bell].Style.HasBackgroundRGB)
340+ require.Equal(t, styles.statusBar.BackgroundRGB, visibleRow[tab2Bell].Style.BackgroundRGB, "tab 2's visible bell must keep the plain base background")
341+ })
342+
343+ t.Run("non-usable theme matches the pre-change fallback styles", func(t *testing.T) {
344+ styles := resolveThemeStyles(nil)
345+ require.True(t, styles.tabName.Equal(styles.statusBar))
346+ require.True(t, styles.tabTitle.Equal(styles.statusBar))
347+ require.True(t, styles.tabNameActive.Equal(styles.accent))
348+ require.True(t, styles.tabTitleActive.Equal(styles.accent))
349+
350+ row := make([]renderer.Cell, rowLen)
351+ drawTopBarSnapshot(row, status, 0, "", styles)
352+
353+ // " api (shell) " (tab 0) + " logs " (tab 1: name, attention
354+ // space+blank bell, trailing space) + " err (build failed) " (tab 2:
355+ // name, attention space+blank bell, title, trailing space), then blank
356+ // padding to rowLen: byte-for-byte what the pre-change renderer
357+ // produced, since every new style field collapses to statusBar/accent.
358+ const tab0 = " api (shell) "
359+ const tab1 = " logs "
360+ const tab2 = " err (build failed) "
361+ require.Equal(t, tab0+tab1+tab2+strings.Repeat(" ", rowLen-len(tab0)-len(tab1)-len(tab2)), rowText(row))
362+
363+ for i, c := range row[:len(tab0)] {
364+ require.True(t, c.Style.Equal(styles.statusBar), "cell %d should use the plain base style", i)
365+ }
366+ tab1Start := len(tab0)
367+ for i := tab1Start; i < tab1Start+len(tab1); i++ {
368+ require.True(t, row[i].Style.Equal(styles.accent), "cell %d should use the plain accent style, including the blank bell cell", i)
369+ }
370+ tab2Start := tab1Start + len(tab1)
371+ for i := tab2Start; i < tab2Start+len(tab2); i++ {
372+ require.True(t, row[i].Style.Equal(styles.statusBar), "cell %d should use the plain base style, including the blank bell cell", i)
373+ }
374+ })
375+}
376+
236 func TestTabDisplayName(t *testing.T) {377 func TestTabDisplayName(t *testing.T) {
237 tests := []struct {378 tests := []struct {
238 name string379 name string
@@ -722,20 +863,49 @@ func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
722 }863 }
723 864
724 func TestAttentionConstants(t *testing.T) {865 func TestAttentionConstants(t *testing.T) {
725- require.Equal(t, '', attentionGlyph)866+ require.Equal(t, '', ui.AttentionGlyph)
726 require.Equal(t, 30, pulseFrameCount)867 require.Equal(t, 30, pulseFrameCount)
727 require.Equal(t, 120*time.Millisecond, pulseFrameInterval)868 require.Equal(t, 120*time.Millisecond, pulseFrameInterval)
728 }869 }
729 870
730-func TestPulseStyleHidesAtFrameZeroAndRamps(t *testing.T) {871+func TestPulseStyleFadesFromBaseAndHidesAtFrameZero(t *testing.T) {
731- _, visible := pulseStyle(0)872+ rgbBase := renderer.Style{
732- require.False(t, visible)873+ HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 200, G: 200, B: 200},
874+ HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 10, G: 20, B: 30},
875+ }
876+ indexedBase := renderer.Style{Foreground: -1, Background: -1, Inverse: true}
877+
878+ t.Run("invisible frame returns base unchanged", func(t *testing.T) {
879+ for _, base := range []renderer.Style{rgbBase, indexedBase} {
880+ style, visible := pulseStyle(0, base)
881+ require.False(t, visible)
882+ require.True(t, style.Equal(base))
883+ }
884+ })
885+
886+ t.Run("rgb base blends the glyph foreground from the base background to its foreground, keeping the base background", func(t *testing.T) {
887+ low, visible := pulseStyle(1, rgbBase)
888+ require.True(t, visible)
889+ require.True(t, low.Bold)
890+ require.True(t, low.HasBackgroundRGB)
891+ require.Equal(t, rgbBase.BackgroundRGB, low.BackgroundRGB, "bell keeps the caller's background so it never punches a hole in a themed bar")
892+
893+ peak, visible := pulseStyle(pulseFrameCount/2, rgbBase)
894+ require.True(t, visible)
895+ require.Equal(t, rgbBase.ForegroundRGB, peak.ForegroundRGB, "peak intensity reaches the base foreground exactly")
896+ require.NotEqual(t, low.ForegroundRGB, peak.ForegroundRGB, "the glyph should ramp, not jump straight to full intensity")
897+ })
898+
899+ t.Run("non-RGB base falls back to the indexed grey ramp and preserves other base attributes", func(t *testing.T) {
900+ low, visible := pulseStyle(1, indexedBase)
901+ require.True(t, visible)
902+ require.True(t, low.Bold)
903+ require.True(t, low.Inverse, "non-color base attributes like inverse must survive")
733 904
734- low, visible := pulseStyle(1)905+ peak, visible := pulseStyle(pulseFrameCount/2, indexedBase)
735- require.True(t, visible)906+ require.True(t, visible)
736- peak, visible := pulseStyle(pulseFrameCount / 2)907+ require.Greater(t, peak.Foreground, low.Foreground)
737- require.True(t, visible)908+ })
738- require.Greater(t, peak.Foreground, low.Foreground)
739 }909 }
740 910
741 func TestTopBarRendersAttentionBell(t *testing.T) {911 func TestTopBarRendersAttentionBell(t *testing.T) {
@@ -755,10 +925,11 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
755 frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(true), attentionFrame: 1}, win, true)925 frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(true), attentionFrame: 1}, win, true)
756 926
757 // Tab 2's label is enriched with its focused pane's title ("sh", the927 // Tab 2's label is enriched with its focused pane's title ("sh", the
758- // default shell fallback) and truncated to fit alongside the bell.928+ // default shell fallback) and truncated to fit alongside the bell. The
759- require.Contains(t, rowText(frame.Row(0)), "2 (s")929+ // bell sits right after the name, before the (title) segment.
930+ require.Contains(t, rowText(frame.Row(0)), "2 "+string(ui.AttentionGlyph)+" (s")
760 for _, c := range frame.Row(0) {931 for _, c := range frame.Row(0) {
761- if c.Rune == attentionGlyph {932+ if c.Rune == ui.AttentionGlyph {
762 require.True(t, c.Style.Bold)933 require.True(t, c.Style.Bold)
763 return934 return
764 }935 }
@@ -879,7 +1050,7 @@ func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) {
879 1050
880 require.Equal(t, " cur fresh tmp*  ", rowText(row))1051 require.Equal(t, " cur fresh tmp*  ", rowText(row))
881 for _, c := range row {1052 for _, c := range row {
882- if c.Rune == attentionGlyph {1053+ if c.Rune == ui.AttentionGlyph {
883 require.True(t, c.Style.Bold)1054 require.True(t, c.Style.Bold)
884 return1055 return
885 }1056 }
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 12

Tags 11

↑↓ moveenter openesc close