detached 163735e

feat(daemon): bold tab names and mute pane titles on truecolor clients

Split tab labels ("name (process: title)") into a bold name segment and a
muted parenthesized pane-title segment in the top bar and session picker,
but only when the attached client's theme is truecolor-capable; on other
clients rendering is byte-for-byte unchanged. Adds EmphasisStyle and
MutedVariantStyle to internal/usecase/theme as the reusable no-op-unless-
usable building blocks, threads the split through fitTabLabels' new
fittedTabLabel return type in the top bar, and widens the picker's
SessionView.Tabs to []TabEntry{Name, Detail, Attention} plus a
RenderStyles struct (mirroring palette.RenderStyles) so the picker package
still doesn't need to import internal/usecase/theme.
commit
163735ec7d43d840907075105665994c0dae0160
author
brice · · 2026-07-11 11:27 UTC
parent
e94bd47
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +493 -79
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 +18 -7
@@ -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 }
@@ -490,8 +493,16 @@ func (d *Daemon) refreshPicker(ac *attachedClient) {
490493
491494 func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
492495 styleSet := resolveThemeStyles(styles)
493- return composeModalClientFrame(base, pickerModal, styleSet, styleSet.selection, func(size domain.Size, styles ...renderer.Style) renderer.Frame {
494- return model.Render(size, preview, styles...)
496+ renderStyles := picker.RenderStyles{
497+ Selection: styleSet.selection,
498+ SelectionName: styleSet.pickerSelectionName,
499+ SelectionMuted: styleSet.pickerSelectionMuted,
500+ Name: styleSet.pickerName,
501+ Detail: styleSet.paletteDesc,
502+ Base: renderer.DefaultStyle(),
503+ }
504+ return composeModalClientFrame(base, pickerModal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {
505+ return model.Render(size, preview, renderStyles)
495506 })
496507 }
497508
@@ -72,15 +72,18 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
72 curTab := 072 curTab := 0
73 for _, s := range sessions {73 for _, s := range sessions {
74 s.mu.Lock()74 s.mu.Lock()
75- view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}75+ view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))}
76 sessionAttention := false76 sessionAttention := false
77 for i, tb := range s.tabs {77 for i, tb := range s.tabs {
78- label := composeTabTitle(tabDisplayName(tb, i), tb.focusedPaneTitle(includeTerminalTitle))78+ name := tabDisplayName(tb, i)
79+ view.Tabs[i] = picker.TabEntry{
80+ Name: name,
81+ Detail: tabTitleDetail(name, tb.focusedPaneTitle(includeTerminalTitle)),
82+ Attention: tb.attention,
83+ }
79 if tb.attention {84 if tb.attention {
80- label = attentionSuffix(label)
81 sessionAttention = true85 sessionAttention = true
82 }86 }
83- view.Tabs[i] = label
84 }87 }
85 if sessionAttention {88 if sessionAttention {
86 view.Name = attentionSuffix(view.Name)89 view.Name = attentionSuffix(view.Name)
@@ -95,7 +98,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
95 views = append(views, picker.SessionView{98 views = append(views, picker.SessionView{
96 ID: domain.SessionID("stopped:" + s.name),99 ID: domain.SessionID("stopped:" + s.name),
97 Name: s.name,100 Name: s.name,
98- Tabs: []string{""},101+ Tabs: []picker.TabEntry{{}},
99 Stopped: true,102 Stopped: true,
100 })103 })
101 }104 }
@@ -490,8 +493,16 @@ func (d *Daemon) refreshPicker(ac *attachedClient) {
490 493
491 func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {494 func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
492 styleSet := resolveThemeStyles(styles)495 styleSet := resolveThemeStyles(styles)
493- return composeModalClientFrame(base, pickerModal, styleSet, styleSet.selection, func(size domain.Size, styles ...renderer.Style) renderer.Frame {496+ renderStyles := picker.RenderStyles{
494- return model.Render(size, preview, styles...)497+ Selection: styleSet.selection,
498+ SelectionName: styleSet.pickerSelectionName,
499+ SelectionMuted: styleSet.pickerSelectionMuted,
500+ Name: styleSet.pickerName,
501+ Detail: styleSet.paletteDesc,
502+ Base: renderer.DefaultStyle(),
503+ }
504+ return composeModalClientFrame(base, pickerModal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {
505+ return model.Render(size, preview, renderStyles)
495 })506 })
496 }507 }
497 508
M internal/usecase/daemon/picker_test.go +4 -4
@@ -61,9 +61,9 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
6161 require.Equal(t, 0, curTab)
6262 require.Len(t, views, 2)
6363 require.Equal(t, "alpha", views[0].Name)
64- require.Equal(t, []string{"1", "2"}, views[0].Tabs)
64+ require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs)
6565 require.Equal(t, "beta ", views[1].Name)
66- require.Equal(t, []string{"shell", "logs "}, views[1].Tabs)
66+ require.Equal(t, []picker.TabEntry{{Name: "shell"}, {Name: "logs", Attention: true}}, views[1].Tabs)
6767 }
6868
6969 func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {
@@ -88,7 +88,7 @@ func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {
8888 views, _ := d.pickerViews(sess)
8989
9090 require.Len(t, views, 1)
91- require.Equal(t, []string{"1 (vim)", attentionSuffix("logs (sh)")}, views[0].Tabs)
91+ require.Equal(t, []picker.TabEntry{{Name: "1", Detail: " (vim)"}, {Name: "logs", Detail: " (sh)", Attention: true}}, views[0].Tabs)
9292 }
9393
9494 func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) {
@@ -112,7 +112,7 @@ func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) {
112112 views, _ := d.pickerViews(sess)
113113
114114 require.Len(t, views, 1)
115- require.Equal(t, []string{"1 (vim)", "logs (sh)"}, views[0].Tabs)
115+ require.Equal(t, []picker.TabEntry{{Name: "1", Detail: " (vim)"}, {Name: "logs", Detail: " (sh)"}}, views[0].Tabs)
116116 }
117117
118118 func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
@@ -61,9 +61,9 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
61 require.Equal(t, 0, curTab)61 require.Equal(t, 0, curTab)
62 require.Len(t, views, 2)62 require.Len(t, views, 2)
63 require.Equal(t, "alpha", views[0].Name)63 require.Equal(t, "alpha", views[0].Name)
64- require.Equal(t, []string{"1", "2"}, views[0].Tabs)64+ require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs)
65 require.Equal(t, "beta ", views[1].Name)65 require.Equal(t, "beta ", views[1].Name)
66- require.Equal(t, []string{"shell", "logs "}, views[1].Tabs)66+ require.Equal(t, []picker.TabEntry{{Name: "shell"}, {Name: "logs", Attention: true}}, views[1].Tabs)
67 }67 }
68 68
69 func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {69 func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {
@@ -88,7 +88,7 @@ func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {
88 views, _ := d.pickerViews(sess)88 views, _ := d.pickerViews(sess)
89 89
90 require.Len(t, views, 1)90 require.Len(t, views, 1)
91- require.Equal(t, []string{"1 (vim)", attentionSuffix("logs (sh)")}, views[0].Tabs)91+ require.Equal(t, []picker.TabEntry{{Name: "1", Detail: " (vim)"}, {Name: "logs", Detail: " (sh)", Attention: true}}, views[0].Tabs)
92 }92 }
93 93
94 func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) {94 func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) {
@@ -112,7 +112,7 @@ func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) {
112 views, _ := d.pickerViews(sess)112 views, _ := d.pickerViews(sess)
113 113
114 require.Len(t, views, 1)114 require.Len(t, views, 1)
115- require.Equal(t, []string{"1 (vim)", "logs (sh)"}, views[0].Tabs)115+ require.Equal(t, []picker.TabEntry{{Name: "1", Detail: " (vim)"}, {Name: "logs", Detail: " (sh)"}}, views[0].Tabs)
116 }116 }
117 117
118 func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {118 func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
M internal/usecase/daemon/render.go +30 -4
@@ -576,16 +576,42 @@ type themeStyles struct {
576576 selection renderer.Style
577577 copyStatus renderer.Style
578578 paletteDesc renderer.Style
579+
580+ // Top bar tab label segments: name (bold) and parenthesized pane-title
581+ // (muted), one pair per base style. No-ops to statusBar/accent on
582+ // non-truecolor themes.
583+ tabName renderer.Style
584+ tabNameActive renderer.Style
585+ tabTitle renderer.Style
586+ tabTitleActive renderer.Style
587+
588+ // Session picker row segments. Detail reuses paletteDesc (both are
589+ // themeui.MutedTextStyle(t)); pickerName/pickerSelection* are picker-only.
590+ pickerName renderer.Style
591+ pickerSelectionName renderer.Style
592+ pickerSelectionMuted renderer.Style
579593 }
580594
581595 func newThemeStyles(t themeui.Theme) themeStyles {
596+ statusBar := themeui.StatusBarStyle(t)
597+ accent := themeui.AccentStyle(t)
598+ selection := themeui.SelectionStyle(t)
582599 return themeStyles{
583- statusBar: themeui.StatusBarStyle(t),
584- accent: themeui.AccentStyle(t),
600+ statusBar: statusBar,
601+ accent: accent,
585602 border: themeui.BorderStyle(t),
586- selection: themeui.SelectionStyle(t),
587- copyStatus: themeui.SelectionStyle(t),
603+ selection: selection,
604+ copyStatus: selection,
588605 paletteDesc: themeui.MutedTextStyle(t),
606+
607+ tabName: themeui.EmphasisStyle(statusBar, t),
608+ tabNameActive: themeui.EmphasisStyle(accent, t),
609+ tabTitle: themeui.MutedVariantStyle(statusBar, t),
610+ tabTitleActive: themeui.MutedVariantStyle(accent, t),
611+
612+ pickerName: themeui.EmphasisStyle(renderer.DefaultStyle(), t),
613+ pickerSelectionName: themeui.EmphasisStyle(selection, t),
614+ pickerSelectionMuted: themeui.MutedVariantStyle(selection, t),
589615 }
590616 }
591617
@@ -576,16 +576,42 @@ type themeStyles struct {
576 selection renderer.Style576 selection renderer.Style
577 copyStatus renderer.Style577 copyStatus renderer.Style
578 paletteDesc renderer.Style578 paletteDesc renderer.Style
579+
580+ // Top bar tab label segments: name (bold) and parenthesized pane-title
581+ // (muted), one pair per base style. No-ops to statusBar/accent on
582+ // non-truecolor themes.
583+ tabName renderer.Style
584+ tabNameActive renderer.Style
585+ tabTitle renderer.Style
586+ tabTitleActive renderer.Style
587+
588+ // Session picker row segments. Detail reuses paletteDesc (both are
589+ // themeui.MutedTextStyle(t)); pickerName/pickerSelection* are picker-only.
590+ pickerName renderer.Style
591+ pickerSelectionName renderer.Style
592+ pickerSelectionMuted renderer.Style
579 }593 }
580 594
581 func newThemeStyles(t themeui.Theme) themeStyles {595 func newThemeStyles(t themeui.Theme) themeStyles {
596+ statusBar := themeui.StatusBarStyle(t)
597+ accent := themeui.AccentStyle(t)
598+ selection := themeui.SelectionStyle(t)
582 return themeStyles{599 return themeStyles{
583- statusBar: themeui.StatusBarStyle(t),600+ statusBar: statusBar,
584- accent: themeui.AccentStyle(t),601+ accent: accent,
585 border: themeui.BorderStyle(t),602 border: themeui.BorderStyle(t),
586- selection: themeui.SelectionStyle(t),603+ selection: selection,
587- copyStatus: themeui.SelectionStyle(t),604+ copyStatus: selection,
588 paletteDesc: themeui.MutedTextStyle(t),605 paletteDesc: themeui.MutedTextStyle(t),
606+
607+ tabName: themeui.EmphasisStyle(statusBar, t),
608+ tabNameActive: themeui.EmphasisStyle(accent, t),
609+ tabTitle: themeui.MutedVariantStyle(statusBar, t),
610+ tabTitleActive: themeui.MutedVariantStyle(accent, t),
611+
612+ pickerName: themeui.EmphasisStyle(renderer.DefaultStyle(), t),
613+ pickerSelectionName: themeui.EmphasisStyle(selection, t),
614+ pickerSelectionMuted: themeui.MutedVariantStyle(selection, t),
589 }615 }
590 }616 }
591 617
M internal/usecase/daemon/status.go +30 -12
@@ -66,16 +66,22 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t
6666 x := 0
6767 labels := fitTabLabels(status.tabs, len(row), topRight)
6868 for i, w := range status.tabs {
69- style := styles.statusBar
69+ baseStyle := styles.statusBar
70+ nameStyle := styles.tabName
71+ titleStyle := styles.tabTitle
7072 if w.active {
71- style = styles.accent
73+ baseStyle = styles.accent
74+ nameStyle = styles.tabNameActive
75+ titleStyle = styles.tabTitleActive
7276 }
73- writeStatusText(row, &x, " "+labels[i], style)
77+ label := labels[i]
78+ writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle)
79+ writeStatusText(row, &x, label.text[label.nameLen:], titleStyle)
7480 if w.attention {
75- writeStatusText(row, &x, " ", style)
81+ writeStatusText(row, &x, " ", baseStyle)
7682 writeBell(row, &x, frame)
7783 }
78- writeStatusText(row, &x, " ", style)
84+ writeStatusText(row, &x, " ", baseStyle)
7985 }
8086 drawRightPlainText(row, topRight, x, styles.statusBar)
8187 }
@@ -383,9 +389,18 @@ func fitHistoryNav(entries []historyNavSession, rowLen, leftUsed int, feedback s
383389 return entries[start:end]
384390 }
385391
392+// fittedTabLabel is one tab's drawable label; text[:nameLen] is the tab-name
393+// segment, text[nameLen:] the pane-title segment (possibly empty). nameLen is
394+// always a byte offset landing on a rune boundary, since it is derived from
395+// the byte length of the (possibly truncated) name prefix.
396+type fittedTabLabel struct {
397+ text string
398+ nameLen int
399+}
400+
386401 // fitTabLabels returns, per tab, the text drawn between its surrounding
387402 // spaces (attention glyph handled by the caller), guaranteeing all tabs fit.
388-func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string {
403+func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []fittedTabLabel {
389404 reserve := 1
390405 if rightText != "" {
391406 reserve = statusTextWidth(rightText) + 2
@@ -406,9 +421,11 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string {
406421 widths[i] = statusTextWidth(full[i])
407422 total += o + widths[i]
408423 }
409- labels := make([]string, len(tabs))
424+ labels := make([]fittedTabLabel, len(tabs))
410425 if total <= budget {
411- copy(labels, full)
426+ for i, t := range tabs {
427+ labels[i] = fittedTabLabel{text: full[i], nameLen: len(t.name)}
428+ }
412429 return labels
413430 }
414431
@@ -430,13 +447,14 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string {
430447 textBudget := share - overhead[i]
431448 switch {
432449 case textBudget <= 0:
433- labels[i] = ""
450+ labels[i] = fittedTabLabel{}
434451 case widths[i] <= textBudget:
435- labels[i] = full[i]
452+ labels[i] = fittedTabLabel{text: full[i], nameLen: len(tabs[i].name)}
436453 case textBudget >= statusTextWidth(tabs[i].name)+4:
437- labels[i] = ui.TruncateText(full[i], textBudget)
454+ labels[i] = fittedTabLabel{text: ui.TruncateText(full[i], textBudget), nameLen: len(tabs[i].name)}
438455 default:
439- labels[i] = ui.TruncateText(tabs[i].name, textBudget)
456+ text := ui.TruncateText(tabs[i].name, textBudget)
457+ labels[i] = fittedTabLabel{text: text, nameLen: len(text)}
440458 }
441459 consumed := overhead[i] + min(widths[i], max(textBudget, 0))
442460 remaining -= consumed
@@ -66,16 +66,22 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t
66 x := 066 x := 0
67 labels := fitTabLabels(status.tabs, len(row), topRight)67 labels := fitTabLabels(status.tabs, len(row), topRight)
68 for i, w := range status.tabs {68 for i, w := range status.tabs {
69- style := styles.statusBar69+ baseStyle := styles.statusBar
70+ nameStyle := styles.tabName
71+ titleStyle := styles.tabTitle
70 if w.active {72 if w.active {
71- style = styles.accent73+ baseStyle = styles.accent
74+ nameStyle = styles.tabNameActive
75+ titleStyle = styles.tabTitleActive
72 }76 }
73- writeStatusText(row, &x, " "+labels[i], style)77+ label := labels[i]
78+ writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle)
79+ writeStatusText(row, &x, label.text[label.nameLen:], titleStyle)
74 if w.attention {80 if w.attention {
75- writeStatusText(row, &x, " ", style)81+ writeStatusText(row, &x, " ", baseStyle)
76 writeBell(row, &x, frame)82 writeBell(row, &x, frame)
77 }83 }
78- writeStatusText(row, &x, " ", style)84+ writeStatusText(row, &x, " ", baseStyle)
79 }85 }
80 drawRightPlainText(row, topRight, x, styles.statusBar)86 drawRightPlainText(row, topRight, x, styles.statusBar)
81 }87 }
@@ -383,9 +389,18 @@ func fitHistoryNav(entries []historyNavSession, rowLen, leftUsed int, feedback s
383 return entries[start:end]389 return entries[start:end]
384 }390 }
385 391
392+// fittedTabLabel is one tab's drawable label; text[:nameLen] is the tab-name
393+// segment, text[nameLen:] the pane-title segment (possibly empty). nameLen is
394+// always a byte offset landing on a rune boundary, since it is derived from
395+// the byte length of the (possibly truncated) name prefix.
396+type fittedTabLabel struct {
397+ text string
398+ nameLen int
399+}
400+
386 // fitTabLabels returns, per tab, the text drawn between its surrounding401 // fitTabLabels returns, per tab, the text drawn between its surrounding
387 // spaces (attention glyph handled by the caller), guaranteeing all tabs fit.402 // spaces (attention glyph handled by the caller), guaranteeing all tabs fit.
388-func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string {403+func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []fittedTabLabel {
389 reserve := 1404 reserve := 1
390 if rightText != "" {405 if rightText != "" {
391 reserve = statusTextWidth(rightText) + 2406 reserve = statusTextWidth(rightText) + 2
@@ -406,9 +421,11 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string {
406 widths[i] = statusTextWidth(full[i])421 widths[i] = statusTextWidth(full[i])
407 total += o + widths[i]422 total += o + widths[i]
408 }423 }
409- labels := make([]string, len(tabs))424+ labels := make([]fittedTabLabel, len(tabs))
410 if total <= budget {425 if total <= budget {
411- copy(labels, full)426+ for i, t := range tabs {
427+ labels[i] = fittedTabLabel{text: full[i], nameLen: len(t.name)}
428+ }
412 return labels429 return labels
413 }430 }
414 431
@@ -430,13 +447,14 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string {
430 textBudget := share - overhead[i]447 textBudget := share - overhead[i]
431 switch {448 switch {
432 case textBudget <= 0:449 case textBudget <= 0:
433- labels[i] = ""450+ labels[i] = fittedTabLabel{}
434 case widths[i] <= textBudget:451 case widths[i] <= textBudget:
435- labels[i] = full[i]452+ labels[i] = fittedTabLabel{text: full[i], nameLen: len(tabs[i].name)}
436 case textBudget >= statusTextWidth(tabs[i].name)+4:453 case textBudget >= statusTextWidth(tabs[i].name)+4:
437- labels[i] = ui.TruncateText(full[i], textBudget)454+ labels[i] = fittedTabLabel{text: ui.TruncateText(full[i], textBudget), nameLen: len(tabs[i].name)}
438 default:455 default:
439- labels[i] = ui.TruncateText(tabs[i].name, textBudget)456+ text := ui.TruncateText(tabs[i].name, textBudget)
457+ labels[i] = fittedTabLabel{text: text, nameLen: len(text)}
440 }458 }
441 consumed := overhead[i] + min(widths[i], max(textBudget, 0))459 consumed := overhead[i] + min(widths[i], max(textBudget, 0))
442 remaining -= consumed460 remaining -= consumed
M internal/usecase/daemon/status_test.go +101 -8
@@ -7,6 +7,7 @@ import (
77 "sync"
88 "testing"
99 "time"
10+ "unicode/utf8"
1011
1112 "github.com/stretchr/testify/require"
1213
@@ -162,19 +163,19 @@ func TestFitTabLabels(t *testing.T) {
162163 tabs []statusTab
163164 rowLen int
164165 rightText string
165- want []string
166+ want []fittedTabLabel
166167 }{
167168 {
168169 name: "no tabs",
169170 tabs: nil,
170171 rowLen: 10,
171- want: []string{},
172+ want: []fittedTabLabel{},
172173 },
173174 {
174175 name: "everything fits stays untruncated",
175176 tabs: []statusTab{{name: "one"}, {name: "two", paneTitle: "shell"}},
176177 rowLen: 40,
177- want: []string{"one", "two (shell)"},
178+ want: []fittedTabLabel{{text: "one", nameLen: len("one")}, {text: "two (shell)", nameLen: len("two")}},
178179 },
179180 {
180181 name: "overflow redistributes budget: short tab stays full, long tab gets ellipsis",
@@ -183,26 +184,26 @@ func TestFitTabLabels(t *testing.T) {
183184 {name: "ed", paneTitle: "verylongtitle"},
184185 },
185186 rowLen: 20,
186- want: []string{"a", "ed (verylongt…"},
187+ want: []fittedTabLabel{{text: "a", nameLen: len("a")}, {text: "ed (verylongt…", nameLen: len("ed")}},
187188 },
188189 {
189190 name: "tight budget degrades to a truncated tab name only",
190191 tabs: []statusTab{{name: "editor", paneTitle: "some pane title text"}},
191192 rowLen: 7,
192- want: []string{"edi…"},
193+ want: []fittedTabLabel{{text: "edi…", nameLen: len("edi…")}},
193194 },
194195 {
195196 name: "non-empty rightText shrinks the budget",
196197 tabs: []statusTab{{name: "aaaaaaaaaa"}},
197198 rowLen: 20,
198199 rightText: "0123456789",
199- want: []string{"aaaaa…"},
200+ want: []fittedTabLabel{{text: "aaaaa…", nameLen: len("aaaaa…")}},
200201 },
201202 {
202203 name: "single tab wider than the whole row collapses to empty",
203204 tabs: []statusTab{{name: "reallyverylongtabname"}},
204205 rowLen: 3,
205- want: []string{""},
206+ want: []fittedTabLabel{{}},
206207 },
207208 }
208209 for _, tt := range tests {
@@ -222,7 +223,11 @@ func TestFitTabLabels(t *testing.T) {
222223 if tb.attention {
223224 overhead += 1 + renderer.RuneWidth(attentionGlyph)
224225 }
225- drawn += overhead + statusTextWidth(got[i])
226+ drawn += overhead + statusTextWidth(got[i].text)
227+ require.LessOrEqual(t, got[i].nameLen, len(got[i].text), "nameLen must not exceed the label's byte length")
228+ if got[i].nameLen < len(got[i].text) {
229+ require.True(t, utf8.RuneStart(got[i].text[got[i].nameLen]), "nameLen must land on a rune boundary")
230+ }
226231 }
227232 if budget > 0 {
228233 require.LessOrEqual(t, drawn, budget, "drawn width must not exceed the derived budget")
@@ -231,6 +236,94 @@ func TestFitTabLabels(t *testing.T) {
231236 }
232237 }
233238
239+func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
240+ tabs := []statusTab{
241+ {name: "api", paneTitle: "shell"},
242+ {name: "logs", active: true, attention: true},
243+ }
244+ status := statusSnapshot{tabs: tabs}
245+ const rowLen = 40
246+
247+ t.Run("truecolor theme bolds names and mutes titles", func(t *testing.T) {
248+ theme := themeui.Theme{
249+ Foreground: renderer.RGB{R: 200, G: 200, B: 200},
250+ Background: renderer.RGB{R: 10, G: 20, B: 30},
251+ HasFG: true,
252+ HasBG: true,
253+ Known: true,
254+ TrueColor: true,
255+ }
256+ styles := newThemeStyles(theme)
257+ require.False(t, styles.tabTitle.Equal(styles.statusBar), "sanity: muted title style must differ from the base style on a truecolor theme")
258+ require.False(t, styles.tabTitleActive.Equal(styles.accent), "sanity: muted active-title style must differ from the base accent style")
259+
260+ row := make([]renderer.Cell, rowLen)
261+ drawTopBarSnapshot(row, status, 0, "", styles)
262+
263+ labels := fitTabLabels(tabs, rowLen, "")
264+ x := 0
265+
266+ // tab 0: inactive, "api" bold, " (shell)" muted, trailing space plain.
267+ nameEnd := x + 1 + labels[0].nameLen
268+ for i := x; i < nameEnd; i++ {
269+ require.True(t, row[i].Style.Bold, "cell %d: name segment must be bold", i)
270+ require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: name segment style mismatch", i)
271+ }
272+ titleLen := len(labels[0].text) - labels[0].nameLen
273+ titleEnd := nameEnd + titleLen
274+ for i := nameEnd; i < titleEnd; i++ {
275+ require.False(t, row[i].Style.Bold, "cell %d: title segment must not be bold", i)
276+ require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: title segment style mismatch", i)
277+ }
278+ x = titleEnd
279+ require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: trailing space must keep the base style", x)
280+ x++
281+
282+ // tab 1: active, "logs" bold, no title, attention space + bell + trailing space.
283+ nameEnd = x + 1 + labels[1].nameLen
284+ for i := x; i < nameEnd; i++ {
285+ require.True(t, row[i].Style.Bold, "cell %d: active name segment must be bold", i)
286+ require.True(t, row[i].Style.Equal(styles.tabNameActive), "cell %d: active name segment style mismatch", i)
287+ }
288+ require.Equal(t, len(labels[1].text), labels[1].nameLen, "tab 1 has no pane title, so its whole label is the name segment")
289+ x = nameEnd
290+ require.True(t, row[x].Style.Equal(styles.accent), "cell %d: attention leading space must keep the active base style", x)
291+ x += 2 // skip the leading space (checked above) and the bell glyph itself, which pulseStyle owns
292+ require.True(t, row[x].Style.Equal(styles.accent), "cell %d: trailing space must keep the active base style", x)
293+ })
294+
295+ t.Run("non-usable theme matches the pre-change fallback styles", func(t *testing.T) {
296+ styles := resolveThemeStyles(nil)
297+ require.True(t, styles.tabName.Equal(styles.statusBar))
298+ require.True(t, styles.tabTitle.Equal(styles.statusBar))
299+ require.True(t, styles.tabNameActive.Equal(styles.accent))
300+ require.True(t, styles.tabTitleActive.Equal(styles.accent))
301+
302+ row := make([]renderer.Cell, rowLen)
303+ drawTopBarSnapshot(row, status, 0, "", styles)
304+
305+ // " api (shell) " (tab 0, incl. trailing space) + " logs" + attention
306+ // space + blank bell (frame 0 is not visible) + trailing space, then
307+ // blank padding to rowLen: byte-for-byte what the pre-change renderer
308+ // produced, since every new style field collapses to statusBar/accent.
309+ const tab0 = " api (shell) "
310+ const tab1 = " logs "
311+ require.Equal(t, tab0+tab1+strings.Repeat(" ", rowLen-len(tab0)-len(tab1)), rowText(row))
312+
313+ for i, c := range row[:len(tab0)] {
314+ require.True(t, c.Style.Equal(styles.statusBar), "cell %d should use the plain base style", i)
315+ }
316+ activeStart := len(tab0)
317+ bellCell := activeStart + len(" logs") + 1 // pulseStyle-owned, not asserted here
318+ for i := activeStart; i < activeStart+len(tab1); i++ {
319+ if i == bellCell {
320+ continue
321+ }
322+ require.True(t, row[i].Style.Equal(styles.accent), "cell %d should use the plain accent style", i)
323+ }
324+ })
325+}
326+
234327 func TestTabDisplayName(t *testing.T) {
235328 tests := []struct {
236329 name string
@@ -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
@@ -162,19 +163,19 @@ func TestFitTabLabels(t *testing.T) {
162 tabs []statusTab163 tabs []statusTab
163 rowLen int164 rowLen int
164 rightText string165 rightText string
165- want []string166+ want []fittedTabLabel
166 }{167 }{
167 {168 {
168 name: "no tabs",169 name: "no tabs",
169 tabs: nil,170 tabs: nil,
170 rowLen: 10,171 rowLen: 10,
171- want: []string{},172+ want: []fittedTabLabel{},
172 },173 },
173 {174 {
174 name: "everything fits stays untruncated",175 name: "everything fits stays untruncated",
175 tabs: []statusTab{{name: "one"}, {name: "two", paneTitle: "shell"}},176 tabs: []statusTab{{name: "one"}, {name: "two", paneTitle: "shell"}},
176 rowLen: 40,177 rowLen: 40,
177- want: []string{"one", "two (shell)"},178+ want: []fittedTabLabel{{text: "one", nameLen: len("one")}, {text: "two (shell)", nameLen: len("two")}},
178 },179 },
179 {180 {
180 name: "overflow redistributes budget: short tab stays full, long tab gets ellipsis",181 name: "overflow redistributes budget: short tab stays full, long tab gets ellipsis",
@@ -183,26 +184,26 @@ func TestFitTabLabels(t *testing.T) {
183 {name: "ed", paneTitle: "verylongtitle"},184 {name: "ed", paneTitle: "verylongtitle"},
184 },185 },
185 rowLen: 20,186 rowLen: 20,
186- want: []string{"a", "ed (verylongt…"},187+ want: []fittedTabLabel{{text: "a", nameLen: len("a")}, {text: "ed (verylongt…", nameLen: len("ed")}},
187 },188 },
188 {189 {
189 name: "tight budget degrades to a truncated tab name only",190 name: "tight budget degrades to a truncated tab name only",
190 tabs: []statusTab{{name: "editor", paneTitle: "some pane title text"}},191 tabs: []statusTab{{name: "editor", paneTitle: "some pane title text"}},
191 rowLen: 7,192 rowLen: 7,
192- want: []string{"edi…"},193+ want: []fittedTabLabel{{text: "edi…", nameLen: len("edi…")}},
193 },194 },
194 {195 {
195 name: "non-empty rightText shrinks the budget",196 name: "non-empty rightText shrinks the budget",
196 tabs: []statusTab{{name: "aaaaaaaaaa"}},197 tabs: []statusTab{{name: "aaaaaaaaaa"}},
197 rowLen: 20,198 rowLen: 20,
198 rightText: "0123456789",199 rightText: "0123456789",
199- want: []string{"aaaaa…"},200+ want: []fittedTabLabel{{text: "aaaaa…", nameLen: len("aaaaa…")}},
200 },201 },
201 {202 {
202 name: "single tab wider than the whole row collapses to empty",203 name: "single tab wider than the whole row collapses to empty",
203 tabs: []statusTab{{name: "reallyverylongtabname"}},204 tabs: []statusTab{{name: "reallyverylongtabname"}},
204 rowLen: 3,205 rowLen: 3,
205- want: []string{""},206+ want: []fittedTabLabel{{}},
206 },207 },
207 }208 }
208 for _, tt := range tests {209 for _, tt := range tests {
@@ -222,7 +223,11 @@ func TestFitTabLabels(t *testing.T) {
222 if tb.attention {223 if tb.attention {
223 overhead += 1 + renderer.RuneWidth(attentionGlyph)224 overhead += 1 + renderer.RuneWidth(attentionGlyph)
224 }225 }
225- drawn += overhead + statusTextWidth(got[i])226+ drawn += overhead + statusTextWidth(got[i].text)
227+ require.LessOrEqual(t, got[i].nameLen, len(got[i].text), "nameLen must not exceed the label's byte length")
228+ if got[i].nameLen < len(got[i].text) {
229+ require.True(t, utf8.RuneStart(got[i].text[got[i].nameLen]), "nameLen must land on a rune boundary")
230+ }
226 }231 }
227 if budget > 0 {232 if budget > 0 {
228 require.LessOrEqual(t, drawn, budget, "drawn width must not exceed the derived budget")233 require.LessOrEqual(t, drawn, budget, "drawn width must not exceed the derived budget")
@@ -231,6 +236,94 @@ func TestFitTabLabels(t *testing.T) {
231 }236 }
232 }237 }
233 238
239+func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
240+ tabs := []statusTab{
241+ {name: "api", paneTitle: "shell"},
242+ {name: "logs", active: true, attention: true},
243+ }
244+ status := statusSnapshot{tabs: tabs}
245+ const rowLen = 40
246+
247+ t.Run("truecolor theme bolds names and mutes titles", func(t *testing.T) {
248+ theme := themeui.Theme{
249+ Foreground: renderer.RGB{R: 200, G: 200, B: 200},
250+ Background: renderer.RGB{R: 10, G: 20, B: 30},
251+ HasFG: true,
252+ HasBG: true,
253+ Known: true,
254+ TrueColor: true,
255+ }
256+ styles := newThemeStyles(theme)
257+ require.False(t, styles.tabTitle.Equal(styles.statusBar), "sanity: muted title style must differ from the base style on a truecolor theme")
258+ require.False(t, styles.tabTitleActive.Equal(styles.accent), "sanity: muted active-title style must differ from the base accent style")
259+
260+ row := make([]renderer.Cell, rowLen)
261+ drawTopBarSnapshot(row, status, 0, "", styles)
262+
263+ labels := fitTabLabels(tabs, rowLen, "")
264+ x := 0
265+
266+ // tab 0: inactive, "api" bold, " (shell)" muted, trailing space plain.
267+ nameEnd := x + 1 + labels[0].nameLen
268+ for i := x; i < nameEnd; i++ {
269+ require.True(t, row[i].Style.Bold, "cell %d: name segment must be bold", i)
270+ require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: name segment style mismatch", i)
271+ }
272+ titleLen := len(labels[0].text) - labels[0].nameLen
273+ titleEnd := nameEnd + titleLen
274+ for i := nameEnd; i < titleEnd; i++ {
275+ require.False(t, row[i].Style.Bold, "cell %d: title segment must not be bold", i)
276+ require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: title segment style mismatch", i)
277+ }
278+ x = titleEnd
279+ require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: trailing space must keep the base style", x)
280+ x++
281+
282+ // tab 1: active, "logs" bold, no title, attention space + bell + trailing space.
283+ nameEnd = x + 1 + labels[1].nameLen
284+ for i := x; i < nameEnd; i++ {
285+ require.True(t, row[i].Style.Bold, "cell %d: active name segment must be bold", i)
286+ require.True(t, row[i].Style.Equal(styles.tabNameActive), "cell %d: active name segment style mismatch", i)
287+ }
288+ require.Equal(t, len(labels[1].text), labels[1].nameLen, "tab 1 has no pane title, so its whole label is the name segment")
289+ x = nameEnd
290+ require.True(t, row[x].Style.Equal(styles.accent), "cell %d: attention leading space must keep the active base style", x)
291+ x += 2 // skip the leading space (checked above) and the bell glyph itself, which pulseStyle owns
292+ require.True(t, row[x].Style.Equal(styles.accent), "cell %d: trailing space must keep the active base style", x)
293+ })
294+
295+ t.Run("non-usable theme matches the pre-change fallback styles", func(t *testing.T) {
296+ styles := resolveThemeStyles(nil)
297+ require.True(t, styles.tabName.Equal(styles.statusBar))
298+ require.True(t, styles.tabTitle.Equal(styles.statusBar))
299+ require.True(t, styles.tabNameActive.Equal(styles.accent))
300+ require.True(t, styles.tabTitleActive.Equal(styles.accent))
301+
302+ row := make([]renderer.Cell, rowLen)
303+ drawTopBarSnapshot(row, status, 0, "", styles)
304+
305+ // " api (shell) " (tab 0, incl. trailing space) + " logs" + attention
306+ // space + blank bell (frame 0 is not visible) + trailing space, then
307+ // blank padding to rowLen: byte-for-byte what the pre-change renderer
308+ // produced, since every new style field collapses to statusBar/accent.
309+ const tab0 = " api (shell) "
310+ const tab1 = " logs "
311+ require.Equal(t, tab0+tab1+strings.Repeat(" ", rowLen-len(tab0)-len(tab1)), rowText(row))
312+
313+ for i, c := range row[:len(tab0)] {
314+ require.True(t, c.Style.Equal(styles.statusBar), "cell %d should use the plain base style", i)
315+ }
316+ activeStart := len(tab0)
317+ bellCell := activeStart + len(" logs") + 1 // pulseStyle-owned, not asserted here
318+ for i := activeStart; i < activeStart+len(tab1); i++ {
319+ if i == bellCell {
320+ continue
321+ }
322+ require.True(t, row[i].Style.Equal(styles.accent), "cell %d should use the plain accent style", i)
323+ }
324+ })
325+}
326+
234 func TestTabDisplayName(t *testing.T) {327 func TestTabDisplayName(t *testing.T) {
235 tests := []struct {328 tests := []struct {
236 name string329 name string
M internal/usecase/picker/model.go +81 -29
@@ -17,11 +17,43 @@ 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 after 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+
52+// attentionGlyph mirrors the bell icon internal/usecase/daemon draws beside
53+// tab/session labels with a pending attention event. Duplicated here since
54+// picker cannot import daemon.
55+const attentionGlyph = ''
56+
2557 type Target struct {
2658 Session domain.SessionID
2759 Name string
@@ -46,30 +78,34 @@ type Model struct {
4678 }
4779
4880 type row struct {
49- header bool
50- label string
51- session domain.SessionID
52- name string
53- tabIndex int
54- stopped bool
81+ header bool
82+ dispName string // display name segment; bold on truecolor
83+ detail string // " (paneTitle)" segment, muted; "" for headers
84+ attention bool // draw the attention marker after detail; tab rows only
85+ session domain.SessionID
86+ // targetName is the stopped-session lookup name threaded into Target;
87+ // distinct from dispName, which is what gets drawn.
88+ targetName string
89+ tabIndex int
90+ stopped bool
5591 }
5692
5793 func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model {
5894 m := &Model{selected: -1}
5995 activeSelection := -1
6096 for _, session := range sessions {
61- name := ""
97+ targetName := ""
6298 if session.Stopped {
63- name = session.Name
99+ targetName = session.Name
64100 }
65- m.rows = append(m.rows, row{header: true, label: session.Name, session: session.ID, name: name, tabIndex: -1, stopped: session.Stopped})
101+ m.rows = append(m.rows, row{header: true, dispName: session.Name, session: session.ID, targetName: targetName, tabIndex: -1, stopped: session.Stopped})
66102 active := session.Active
67103 if active < 0 || active >= len(session.Tabs) {
68104 active = 0
69105 }
70106 for i, tab := range session.Tabs {
71107 idx := len(m.rows)
72- m.rows = append(m.rows, row{label: tab, session: session.ID, name: name, tabIndex: i, stopped: session.Stopped})
108+ 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})
73109 if session.ID == cur && i == curTab {
74110 m.selected = idx
75111 }
@@ -131,7 +167,7 @@ func (m *Model) Selected() (Target, bool) {
131167 if r.header {
132168 return Target{}, false
133169 }
134- return Target{Session: r.session, Name: r.name, TabIndex: r.tabIndex, Stopped: r.stopped}, true
170+ return Target{Session: r.session, Name: r.targetName, TabIndex: r.tabIndex, Stopped: r.stopped}, true
135171 }
136172
137173 func (m *Model) Clone() *Model {
@@ -143,15 +179,14 @@ func (m *Model) Clone() *Model {
143179 return &clone
144180 }
145181
146-func (m *Model) Render(inner domain.Size, preview Preview, selectedStyle ...renderer.Style) renderer.Frame {
182+func (m *Model) Render(inner domain.Size, preview Preview, styles ...RenderStyles) renderer.Frame {
147183 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
148184 layout := ChooseLayout(inner)
149- selection := renderer.DefaultStyle()
150- selection.Inverse = true
151- if len(selectedStyle) > 0 {
152- selection = selectedStyle[0]
185+ styleSet := defaultRenderStyles()
186+ if len(styles) > 0 {
187+ styleSet = styles[0]
153188 }
154- m.renderList(frame, layout.List, selection)
189+ m.renderList(frame, layout.List, styleSet)
155190 blitPreview(frame, layout.Preview, preview)
156191 return frame
157192 }
@@ -181,32 +216,49 @@ func (m *Model) firstLeaf() int {
181216 return -1
182217 }
183218
184-func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, selection renderer.Style) {
219+// renderList draws each visible row as up to three segments: a name segment
220+// (bold when styles came from a truecolor theme), a muted detail segment
221+// (tab rows only), and a base-styled attention marker or "(stopped)" suffix.
222+// A tight width ellipsizes the detail segment before eating into the name.
223+func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) {
185224 if m == nil || rect.Width <= 0 || rect.Height <= 0 {
186225 return
187226 }
188227 visible := min(rect.Height, frame.Height-rect.Y)
189228 offset := m.scrollOffset(visible)
229+ clipX := rect.X + rect.Width
190230 for y := range visible {
191231 idx := offset + y
192232 if idx >= len(m.rows) {
193233 break
194234 }
195235 r := m.rows[idx]
196- style := renderer.DefaultStyle()
236+ base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail
197237 if idx == m.selected {
198- style = selection
199- }
200- label := r.label
201- if r.header && r.stopped {
202- label += " (stopped)"
238+ base, nameStyle, detailStyle = styles.Selection, styles.SelectionName, styles.SelectionMuted
203239 }
240+ ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})
241+
242+ name := r.dispName
204243 if !r.header {
205- label = " " + label
244+ name = " " + name
245+ }
246+ name = ui.TruncateText(name, rect.Width)
247+ x := ui.DrawText(frame, rect.X, rect.Y+y, clipX, name, nameStyle)
248+
249+ if r.header {
250+ if r.stopped {
251+ ui.DrawText(frame, x, rect.Y+y, clipX, " (stopped)", base)
252+ }
253+ continue
254+ }
255+
256+ detail := ui.TruncateText(r.detail, clipX-x)
257+ x = ui.DrawText(frame, x, rect.Y+y, clipX, detail, detailStyle)
258+
259+ if r.attention {
260+ ui.DrawText(frame, x, rect.Y+y, clipX, " "+string(attentionGlyph), base)
206261 }
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)
210262 }
211263 }
212264
@@ -17,11 +17,43 @@ 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 after 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+
52+// attentionGlyph mirrors the bell icon internal/usecase/daemon draws beside
53+// tab/session labels with a pending attention event. Duplicated here since
54+// picker cannot import daemon.
55+const attentionGlyph = ''
56+
25 type Target struct {57 type Target struct {
26 Session domain.SessionID58 Session domain.SessionID
27 Name string59 Name string
@@ -46,30 +78,34 @@ type Model struct {
46 }78 }
47 79
48 type row struct {80 type row struct {
49- header bool81+ header bool
50- label string82+ dispName string // display name segment; bold on truecolor
51- session domain.SessionID83+ detail string // " (paneTitle)" segment, muted; "" for headers
52- name string84+ attention bool // draw the attention marker after detail; tab rows only
53- tabIndex int85+ session domain.SessionID
54- stopped bool86+ // targetName is the stopped-session lookup name threaded into Target;
87+ // distinct from dispName, which is what gets drawn.
88+ targetName string
89+ tabIndex int
90+ stopped bool
55 }91 }
56 92
57 func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model {93 func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model {
58 m := &Model{selected: -1}94 m := &Model{selected: -1}
59 activeSelection := -195 activeSelection := -1
60 for _, session := range sessions {96 for _, session := range sessions {
61- name := ""97+ targetName := ""
62 if session.Stopped {98 if session.Stopped {
63- name = session.Name99+ targetName = session.Name
64 }100 }
65- m.rows = append(m.rows, row{header: true, label: session.Name, session: session.ID, name: name, tabIndex: -1, stopped: session.Stopped})101+ m.rows = append(m.rows, row{header: true, dispName: session.Name, session: session.ID, targetName: targetName, tabIndex: -1, stopped: session.Stopped})
66 active := session.Active102 active := session.Active
67 if active < 0 || active >= len(session.Tabs) {103 if active < 0 || active >= len(session.Tabs) {
68 active = 0104 active = 0
69 }105 }
70 for i, tab := range session.Tabs {106 for i, tab := range session.Tabs {
71 idx := len(m.rows)107 idx := len(m.rows)
72- m.rows = append(m.rows, row{label: tab, session: session.ID, name: name, tabIndex: i, stopped: session.Stopped})108+ 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 {109 if session.ID == cur && i == curTab {
74 m.selected = idx110 m.selected = idx
75 }111 }
@@ -131,7 +167,7 @@ func (m *Model) Selected() (Target, bool) {
131 if r.header {167 if r.header {
132 return Target{}, false168 return Target{}, false
133 }169 }
134- return Target{Session: r.session, Name: r.name, TabIndex: r.tabIndex, Stopped: r.stopped}, true170+ return Target{Session: r.session, Name: r.targetName, TabIndex: r.tabIndex, Stopped: r.stopped}, true
135 }171 }
136 172
137 func (m *Model) Clone() *Model {173 func (m *Model) Clone() *Model {
@@ -143,15 +179,14 @@ func (m *Model) Clone() *Model {
143 return &clone179 return &clone
144 }180 }
145 181
146-func (m *Model) Render(inner domain.Size, preview Preview, selectedStyle ...renderer.Style) renderer.Frame {182+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))183 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
148 layout := ChooseLayout(inner)184 layout := ChooseLayout(inner)
149- selection := renderer.DefaultStyle()185+ styleSet := defaultRenderStyles()
150- selection.Inverse = true186+ if len(styles) > 0 {
151- if len(selectedStyle) > 0 {187+ styleSet = styles[0]
152- selection = selectedStyle[0]
153 }188 }
154- m.renderList(frame, layout.List, selection)189+ m.renderList(frame, layout.List, styleSet)
155 blitPreview(frame, layout.Preview, preview)190 blitPreview(frame, layout.Preview, preview)
156 return frame191 return frame
157 }192 }
@@ -181,32 +216,49 @@ func (m *Model) firstLeaf() int {
181 return -1216 return -1
182 }217 }
183 218
184-func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, selection renderer.Style) {219+// renderList draws each visible row as up to three segments: a name segment
220+// (bold when styles came from a truecolor theme), a muted detail segment
221+// (tab rows only), and a base-styled attention marker or "(stopped)" suffix.
222+// A tight width ellipsizes the detail segment before eating into the name.
223+func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) {
185 if m == nil || rect.Width <= 0 || rect.Height <= 0 {224 if m == nil || rect.Width <= 0 || rect.Height <= 0 {
186 return225 return
187 }226 }
188 visible := min(rect.Height, frame.Height-rect.Y)227 visible := min(rect.Height, frame.Height-rect.Y)
189 offset := m.scrollOffset(visible)228 offset := m.scrollOffset(visible)
229+ clipX := rect.X + rect.Width
190 for y := range visible {230 for y := range visible {
191 idx := offset + y231 idx := offset + y
192 if idx >= len(m.rows) {232 if idx >= len(m.rows) {
193 break233 break
194 }234 }
195 r := m.rows[idx]235 r := m.rows[idx]
196- style := renderer.DefaultStyle()236+ base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail
197 if idx == m.selected {237 if idx == m.selected {
198- style = selection238+ base, nameStyle, detailStyle = styles.Selection, styles.SelectionName, styles.SelectionMuted
199- }
200- label := r.label
201- if r.header && r.stopped {
202- label += " (stopped)"
203 }239 }
240+ ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})
241+
242+ name := r.dispName
204 if !r.header {243 if !r.header {
205- label = " " + label244+ name = " " + name
245+ }
246+ name = ui.TruncateText(name, rect.Width)
247+ x := ui.DrawText(frame, rect.X, rect.Y+y, clipX, name, nameStyle)
248+
249+ if r.header {
250+ if r.stopped {
251+ ui.DrawText(frame, x, rect.Y+y, clipX, " (stopped)", base)
252+ }
253+ continue
254+ }
255+
256+ detail := ui.TruncateText(r.detail, clipX-x)
257+ x = ui.DrawText(frame, x, rect.Y+y, clipX, detail, detailStyle)
258+
259+ if r.attention {
260+ ui.DrawText(frame, x, rect.Y+y, clipX, " "+string(attentionGlyph), base)
206 }261 }
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)
210 }262 }
211 }263 }
212 264
M internal/usecase/picker/model_test.go +85 -13
@@ -10,8 +10,8 @@ import (
1010
1111 func TestNewFlattensAndSelectsCurrentTab(t *testing.T) {
1212 m := New([]SessionView{
13- {ID: "s1", Name: "one", Tabs: []string{"shell", "logs"}, Active: 0},
14- {ID: "s2", Name: "two", Tabs: []string{"api"}, Active: 0},
13+ {ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 0},
14+ {ID: "s2", Name: "two", Tabs: []TabEntry{{Name: "api"}}, Active: 0},
1515 }, "s1", 1)
1616
1717 got, ok := m.Selected()
@@ -25,12 +25,12 @@ func TestNewFlattensAndSelectsCurrentTab(t *testing.T) {
2525 }
2626
2727 func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) {
28- m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"shell", "logs"}, Active: 1}}, "missing", 0)
28+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 1}}, "missing", 0)
2929 got, ok := m.Selected()
3030 require.True(t, ok)
3131 require.Equal(t, Target{Session: "s1", TabIndex: 1}, got)
3232
33- m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"shell"}, Active: 4}}, "missing", 0)
33+ m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}}, Active: 4}}, "missing", 0)
3434 got, ok = m.Selected()
3535 require.True(t, ok)
3636 require.Equal(t, Target{Session: "s1", TabIndex: 0}, got)
@@ -38,8 +38,8 @@ func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) {
3838
3939 func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) {
4040 m := New([]SessionView{
41- {ID: "s1", Name: "one", Tabs: []string{"a", "b"}, Active: 0},
42- {ID: "s2", Name: "two", Tabs: []string{"c"}, Active: 0},
41+ {ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a"}, {Name: "b"}}, Active: 0},
42+ {ID: "s2", Name: "two", Tabs: []TabEntry{{Name: "c"}}, Active: 0},
4343 }, "s1", 0)
4444
4545 m.Up()
@@ -92,8 +92,8 @@ func TestChooseLayoutBoundaries(t *testing.T) {
9292
9393 func TestSelectedMapping(t *testing.T) {
9494 m := New([]SessionView{
95- {ID: "alpha", Name: "alpha", Tabs: []string{"one"}, Active: 0},
96- {ID: "beta", Name: "beta", Tabs: []string{"two", "three"}, Active: 0},
95+ {ID: "alpha", Name: "alpha", Tabs: []TabEntry{{Name: "one"}}, Active: 0},
96+ {ID: "beta", Name: "beta", Tabs: []TabEntry{{Name: "two"}, {Name: "three"}}, Active: 0},
9797 }, "beta", 1)
9898
9999 got, ok := m.Selected()
@@ -102,7 +102,7 @@ func TestSelectedMapping(t *testing.T) {
102102 }
103103
104104 func TestStoppedSessionSelectableAndRendered(t *testing.T) {
105- m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []string{""}, Stopped: true}}, "", 0)
105+ m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, "", 0)
106106 got, ok := m.Selected()
107107 require.True(t, ok)
108108 require.Equal(t, Target{Session: "stopped:work", Name: "work", TabIndex: 0, Stopped: true}, got)
@@ -112,7 +112,7 @@ func TestStoppedSessionSelectableAndRendered(t *testing.T) {
112112 }
113113
114114 func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
115- m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0)
115+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0)
116116 preview := Preview{
117117 Width: 24,
118118 Height: 1,
@@ -132,7 +132,7 @@ func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
132132 }
133133
134134 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)
135+ 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)
136136 for range 5 {
137137 m.Down()
138138 }
@@ -144,7 +144,7 @@ func TestRenderListScrollsSelectionIntoView(t *testing.T) {
144144 }
145145
146146 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)
147+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a-really-long-focused-pane-tab-label"}}, Active: 0}}, "s1", 0)
148148
149149 frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{})
150150
@@ -155,7 +155,7 @@ func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) {
155155 }
156156
157157 func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) {
158- m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0)
158+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0)
159159 preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}
160160
161161 frame := m.Render(domain.Size{Cols: 23, Rows: 11}, preview)
@@ -164,6 +164,78 @@ func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) {
164164 require.NotEqual(t, 'x', frame.At(0, 0).Rune)
165165 }
166166
167+func TestRenderListDrawsNameAndDetailSegmentsWithDistinctStyles(t *testing.T) {
168+ nameStyle := renderer.Style{Bold: true}
169+ detailStyle := renderer.Style{Italic: true}
170+ baseStyle := renderer.DefaultStyle()
171+ selectionStyle := renderer.Style{Inverse: true}
172+ selectionNameStyle := renderer.Style{Inverse: true, Bold: true}
173+ selectionMutedStyle := renderer.Style{Inverse: true, Italic: true}
174+ styles := RenderStyles{
175+ Selection: selectionStyle, SelectionName: selectionNameStyle, SelectionMuted: selectionMutedStyle,
176+ Name: nameStyle, Detail: detailStyle, Base: baseStyle,
177+ }
178+
179+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{
180+ {Name: "alpha", Detail: " (running)"},
181+ {Name: "beta", Detail: " (idle)", Attention: true},
182+ }, Active: 0}}, "s1", 0)
183+
184+ frame := m.Render(domain.Size{Cols: 30, Rows: 5}, Preview{}, styles)
185+
186+ // Row 1 is the selected tab ("alpha"): name uses SelectionName, detail uses SelectionMuted.
187+ require.Equal(t, 'a', frame.At(2, 1).Rune)
188+ require.True(t, frame.At(2, 1).Style.Equal(selectionNameStyle), "selected name segment style")
189+ require.Equal(t, ' ', frame.At(7, 1).Rune)
190+ require.True(t, frame.At(7, 1).Style.Equal(selectionMutedStyle), "selected detail segment style")
191+ require.Equal(t, '(', frame.At(8, 1).Rune)
192+ require.True(t, frame.At(8, 1).Style.Equal(selectionMutedStyle), "selected detail segment style")
193+
194+ // Row 2 is the non-selected tab ("beta"): name uses Name, detail uses
195+ // Detail, and the attention marker after the detail uses the row's base
196+ // style (not muted).
197+ require.Equal(t, 'b', frame.At(2, 2).Rune)
198+ require.True(t, frame.At(2, 2).Style.Equal(nameStyle), "name segment style")
199+ require.Equal(t, ' ', frame.At(6, 2).Rune)
200+ require.True(t, frame.At(6, 2).Style.Equal(detailStyle), "detail segment style")
201+ require.Equal(t, '(', frame.At(7, 2).Rune)
202+ require.True(t, frame.At(7, 2).Style.Equal(detailStyle), "detail segment style")
203+ require.Equal(t, ' ', frame.At(13, 2).Rune, "attention marker leading space")
204+ require.True(t, frame.At(13, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted")
205+ require.Equal(t, rune(attentionGlyph), frame.At(14, 2).Rune)
206+ require.True(t, frame.At(14, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted")
207+}
208+
209+func TestRenderListTruncatesDetailBeforeName(t *testing.T) {
210+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{
211+ {Name: "short-name", Detail: " (a very long detail text)"},
212+ }, Active: 0}}, "s1", 0)
213+
214+ frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{})
215+ layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5})
216+ require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column")
217+
218+ want := " short-name (a…"
219+ for i, r := range want {
220+ require.Equal(t, r, frame.At(i, 1).Rune, "cell %d", i)
221+ }
222+ require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "detail is ellipsized to fit, the intact name is not touched")
223+ require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing drawn past the list width")
224+}
225+
226+func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) {
227+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{
228+ {Name: "a-really-long-focused-pane-tab-label", Detail: " (detail)"},
229+ }, Active: 0}}, "s1", 0)
230+
231+ frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{})
232+ layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5})
233+ require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column")
234+
235+ require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "the name segment itself is ellipsized once it alone exceeds the width")
236+ require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing drawn past the list width, detail gets no room at all")
237+}
238+
167239 func cell(r rune) renderer.Cell {
168240 return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
169241 }
@@ -10,8 +10,8 @@ import (
10 10
11 func TestNewFlattensAndSelectsCurrentTab(t *testing.T) {11 func TestNewFlattensAndSelectsCurrentTab(t *testing.T) {
12 m := New([]SessionView{12 m := New([]SessionView{
13- {ID: "s1", Name: "one", Tabs: []string{"shell", "logs"}, Active: 0},13+ {ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 0},
14- {ID: "s2", Name: "two", Tabs: []string{"api"}, Active: 0},14+ {ID: "s2", Name: "two", Tabs: []TabEntry{{Name: "api"}}, Active: 0},
15 }, "s1", 1)15 }, "s1", 1)
16 16
17 got, ok := m.Selected()17 got, ok := m.Selected()
@@ -25,12 +25,12 @@ func TestNewFlattensAndSelectsCurrentTab(t *testing.T) {
25 }25 }
26 26
27 func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) {27 func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) {
28- m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"shell", "logs"}, Active: 1}}, "missing", 0)28+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 1}}, "missing", 0)
29 got, ok := m.Selected()29 got, ok := m.Selected()
30 require.True(t, ok)30 require.True(t, ok)
31 require.Equal(t, Target{Session: "s1", TabIndex: 1}, got)31 require.Equal(t, Target{Session: "s1", TabIndex: 1}, got)
32 32
33- m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"shell"}, Active: 4}}, "missing", 0)33+ m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}}, Active: 4}}, "missing", 0)
34 got, ok = m.Selected()34 got, ok = m.Selected()
35 require.True(t, ok)35 require.True(t, ok)
36 require.Equal(t, Target{Session: "s1", TabIndex: 0}, got)36 require.Equal(t, Target{Session: "s1", TabIndex: 0}, got)
@@ -38,8 +38,8 @@ func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) {
38 38
39 func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) {39 func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) {
40 m := New([]SessionView{40 m := New([]SessionView{
41- {ID: "s1", Name: "one", Tabs: []string{"a", "b"}, Active: 0},41+ {ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a"}, {Name: "b"}}, Active: 0},
42- {ID: "s2", Name: "two", Tabs: []string{"c"}, Active: 0},42+ {ID: "s2", Name: "two", Tabs: []TabEntry{{Name: "c"}}, Active: 0},
43 }, "s1", 0)43 }, "s1", 0)
44 44
45 m.Up()45 m.Up()
@@ -92,8 +92,8 @@ func TestChooseLayoutBoundaries(t *testing.T) {
92 92
93 func TestSelectedMapping(t *testing.T) {93 func TestSelectedMapping(t *testing.T) {
94 m := New([]SessionView{94 m := New([]SessionView{
95- {ID: "alpha", Name: "alpha", Tabs: []string{"one"}, Active: 0},95+ {ID: "alpha", Name: "alpha", Tabs: []TabEntry{{Name: "one"}}, Active: 0},
96- {ID: "beta", Name: "beta", Tabs: []string{"two", "three"}, Active: 0},96+ {ID: "beta", Name: "beta", Tabs: []TabEntry{{Name: "two"}, {Name: "three"}}, Active: 0},
97 }, "beta", 1)97 }, "beta", 1)
98 98
99 got, ok := m.Selected()99 got, ok := m.Selected()
@@ -102,7 +102,7 @@ func TestSelectedMapping(t *testing.T) {
102 }102 }
103 103
104 func TestStoppedSessionSelectableAndRendered(t *testing.T) {104 func TestStoppedSessionSelectableAndRendered(t *testing.T) {
105- m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []string{""}, Stopped: true}}, "", 0)105+ m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, "", 0)
106 got, ok := m.Selected()106 got, ok := m.Selected()
107 require.True(t, ok)107 require.True(t, ok)
108 require.Equal(t, Target{Session: "stopped:work", Name: "work", TabIndex: 0, Stopped: true}, got)108 require.Equal(t, Target{Session: "stopped:work", Name: "work", TabIndex: 0, Stopped: true}, got)
@@ -112,7 +112,7 @@ func TestStoppedSessionSelectableAndRendered(t *testing.T) {
112 }112 }
113 113
114 func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {114 func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
115- m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0)115+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0)
116 preview := Preview{116 preview := Preview{
117 Width: 24,117 Width: 24,
118 Height: 1,118 Height: 1,
@@ -132,7 +132,7 @@ func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
132 }132 }
133 133
134 func TestRenderListScrollsSelectionIntoView(t *testing.T) {134 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)135+ 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 {136 for range 5 {
137 m.Down()137 m.Down()
138 }138 }
@@ -144,7 +144,7 @@ func TestRenderListScrollsSelectionIntoView(t *testing.T) {
144 }144 }
145 145
146 func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) {146 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)147+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a-really-long-focused-pane-tab-label"}}, Active: 0}}, "s1", 0)
148 148
149 frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{})149 frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{})
150 150
@@ -155,7 +155,7 @@ func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) {
155 }155 }
156 156
157 func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) {157 func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) {
158- m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0)158+ 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')}}}159 preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}
160 160
161 frame := m.Render(domain.Size{Cols: 23, Rows: 11}, preview)161 frame := m.Render(domain.Size{Cols: 23, Rows: 11}, preview)
@@ -164,6 +164,78 @@ func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) {
164 require.NotEqual(t, 'x', frame.At(0, 0).Rune)164 require.NotEqual(t, 'x', frame.At(0, 0).Rune)
165 }165 }
166 166
167+func TestRenderListDrawsNameAndDetailSegmentsWithDistinctStyles(t *testing.T) {
168+ nameStyle := renderer.Style{Bold: true}
169+ detailStyle := renderer.Style{Italic: true}
170+ baseStyle := renderer.DefaultStyle()
171+ selectionStyle := renderer.Style{Inverse: true}
172+ selectionNameStyle := renderer.Style{Inverse: true, Bold: true}
173+ selectionMutedStyle := renderer.Style{Inverse: true, Italic: true}
174+ styles := RenderStyles{
175+ Selection: selectionStyle, SelectionName: selectionNameStyle, SelectionMuted: selectionMutedStyle,
176+ Name: nameStyle, Detail: detailStyle, Base: baseStyle,
177+ }
178+
179+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{
180+ {Name: "alpha", Detail: " (running)"},
181+ {Name: "beta", Detail: " (idle)", Attention: true},
182+ }, Active: 0}}, "s1", 0)
183+
184+ frame := m.Render(domain.Size{Cols: 30, Rows: 5}, Preview{}, styles)
185+
186+ // Row 1 is the selected tab ("alpha"): name uses SelectionName, detail uses SelectionMuted.
187+ require.Equal(t, 'a', frame.At(2, 1).Rune)
188+ require.True(t, frame.At(2, 1).Style.Equal(selectionNameStyle), "selected name segment style")
189+ require.Equal(t, ' ', frame.At(7, 1).Rune)
190+ require.True(t, frame.At(7, 1).Style.Equal(selectionMutedStyle), "selected detail segment style")
191+ require.Equal(t, '(', frame.At(8, 1).Rune)
192+ require.True(t, frame.At(8, 1).Style.Equal(selectionMutedStyle), "selected detail segment style")
193+
194+ // Row 2 is the non-selected tab ("beta"): name uses Name, detail uses
195+ // Detail, and the attention marker after the detail uses the row's base
196+ // style (not muted).
197+ require.Equal(t, 'b', frame.At(2, 2).Rune)
198+ require.True(t, frame.At(2, 2).Style.Equal(nameStyle), "name segment style")
199+ require.Equal(t, ' ', frame.At(6, 2).Rune)
200+ require.True(t, frame.At(6, 2).Style.Equal(detailStyle), "detail segment style")
201+ require.Equal(t, '(', frame.At(7, 2).Rune)
202+ require.True(t, frame.At(7, 2).Style.Equal(detailStyle), "detail segment style")
203+ require.Equal(t, ' ', frame.At(13, 2).Rune, "attention marker leading space")
204+ require.True(t, frame.At(13, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted")
205+ require.Equal(t, rune(attentionGlyph), frame.At(14, 2).Rune)
206+ require.True(t, frame.At(14, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted")
207+}
208+
209+func TestRenderListTruncatesDetailBeforeName(t *testing.T) {
210+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{
211+ {Name: "short-name", Detail: " (a very long detail text)"},
212+ }, Active: 0}}, "s1", 0)
213+
214+ frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{})
215+ layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5})
216+ require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column")
217+
218+ want := " short-name (a…"
219+ for i, r := range want {
220+ require.Equal(t, r, frame.At(i, 1).Rune, "cell %d", i)
221+ }
222+ require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "detail is ellipsized to fit, the intact name is not touched")
223+ require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing drawn past the list width")
224+}
225+
226+func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) {
227+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{
228+ {Name: "a-really-long-focused-pane-tab-label", Detail: " (detail)"},
229+ }, Active: 0}}, "s1", 0)
230+
231+ frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{})
232+ layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5})
233+ require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column")
234+
235+ require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "the name segment itself is ellipsized once it alone exceeds the width")
236+ require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing drawn past the list width, detail gets no room at all")
237+}
238+
167 func cell(r rune) renderer.Cell {239 func cell(r rune) renderer.Cell {
168 return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}240 return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
169 }241 }
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+}

Keyboard shortcuts

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

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

Branches 2

Tags 13

↑↓ moveenter openesc close