detached 9829e35

fix(daemon): keep bar background under attention bell and move it beside tab name

The bell glyph (and its blank pulse beat) was drawn on renderer.DefaultStyle()
regardless of the bar's actual background, so on a truecolor theme it punched
a dark hole into the statusBar/accent/MRU-faded background. pulseStyle and
writeBell now take the caller's base style and fade the glyph's foreground
from that style's own background to its own foreground, preserving the
background (and other attributes) throughout; pulseVisible is split out for
the render.go visibility check that doesn't need a style.

Also reposition the attention marker to sit right after the tab name instead
of after the whole label, in both the top bar and the session picker, so it
doesn't visually separate the name from an active pulse.
commit
9829e353e57554434ee4681a4d693303ecb0a9c5
author
brice · · 2026-07-11 11:46 UTC
parent
dfc7b6e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +160 -64
M internal/usecase/daemon/render.go +1 -1
@@ -370,7 +370,7 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
370370 d.refreshSessionFocusedTitles(sess)
371371 bars := d.barStateForClient(sess, ac, overlays.copyFeedback)
372372 bars.theme = ac.getTheme()
373- _, attentionVisible := pulseStyle(bars.attentionFrame)
373+ attentionVisible := pulseVisible(bars.attentionFrame)
374374 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
375375
376376 styles := newThemeStyles(ac.getTheme())
@@ -370,7 +370,7 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
370 d.refreshSessionFocusedTitles(sess)370 d.refreshSessionFocusedTitles(sess)
371 bars := d.barStateForClient(sess, ac, overlays.copyFeedback)371 bars := d.barStateForClient(sess, ac, overlays.copyFeedback)
372 bars.theme = ac.getTheme()372 bars.theme = ac.getTheme()
373- _, attentionVisible := pulseStyle(bars.attentionFrame)373+ attentionVisible := pulseVisible(bars.attentionFrame)
374 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)374 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
375 375
376 styles := newThemeStyles(ac.getTheme())376 styles := newThemeStyles(ac.getTheme())
M internal/usecase/daemon/status.go +30 -12
@@ -44,20 +44,35 @@ const (
4444 pulseFrameInterval = 120 * time.Millisecond
4545 )
4646
47-func pulseStyle(frame int) (renderer.Style, bool) {
48- f := frame % pulseFrameCount
49- if f == 0 {
50- return renderer.DefaultStyle(), false
47+// pulseVisible reports whether the attention bell glyph is showing (as
48+// opposed to its blank beat) at frame.
49+func pulseVisible(frame int) bool {
50+ return frame%pulseFrameCount != 0
51+}
52+
53+// pulseStyle returns the style for the attention bell glyph at frame, built
54+// on top of base so the bell always keeps the caller's background (the tab's
55+// accent/statusBar, or a faded MRU entry's blended colors) instead of
56+// punching a default-background hole in a themed bar. On the invisible beat
57+// it returns (base, false) unchanged.
58+func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) {
59+ if !pulseVisible(frame) {
60+ return base, false
5161 }
62+ f := frame % pulseFrameCount
5263 peak := pulseFrameCount / 2
5364 distance := f - peak
5465 if distance < 0 {
5566 distance = -distance
5667 }
5768 intensity := 1 - float64(distance)/float64(peak)
58- style := renderer.DefaultStyle()
69+ style := base
5970 style.Bold = true
60- style.Foreground = 244 + int(intensity*11)
71+ if base.HasForegroundRGB && base.HasBackgroundRGB {
72+ style.ForegroundRGB = themeui.Blend(base.BackgroundRGB, base.ForegroundRGB, intensity)
73+ } else {
74+ style.Foreground = 244 + int(intensity*11)
75+ }
6176 return style, true
6277 }
6378
@@ -76,11 +91,11 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t
7691 }
7792 label := labels[i]
7893 writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle)
79- writeStatusText(row, &x, label.text[label.nameLen:], titleStyle)
8094 if w.attention {
8195 writeStatusText(row, &x, " ", baseStyle)
82- writeBell(row, &x, frame)
96+ writeBell(row, &x, frame, baseStyle)
8397 }
98+ writeStatusText(row, &x, label.text[label.nameLen:], titleStyle)
8499 writeStatusText(row, &x, " ", baseStyle)
85100 }
86101 drawRightPlainText(row, topRight, x, styles.statusBar)
@@ -123,7 +138,7 @@ func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, ephemeral,
123138 writeStatusText(row, x, " "+name, style)
124139 if attention {
125140 writeStatusText(row, x, " ", style)
126- writeBell(row, x, attentionFrame)
141+ writeBell(row, x, attentionFrame, style)
127142 }
128143 writeStatusText(row, x, " ", style)
129144 }
@@ -539,10 +554,13 @@ func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style
539554 return base
540555 }
541556
542-func writeBell(row []renderer.Cell, x *int, frame int) {
543- style, visible := pulseStyle(frame)
557+// writeBell draws the attention glyph on its visible beat, or a plain cell in
558+// base's style on its blank beat, so the bell never leaves a bare
559+// default-background hole in a themed bar.
560+func writeBell(row []renderer.Cell, x *int, frame int, base renderer.Style) {
561+ style, visible := pulseStyle(frame, base)
544562 if !visible {
545- writeStatusText(row, x, " ", renderer.DefaultStyle())
563+ writeStatusText(row, x, " ", base)
546564 return
547565 }
548566 writeStatusText(row, x, string(attentionGlyph), style)
@@ -44,20 +44,35 @@ const (
44 pulseFrameInterval = 120 * time.Millisecond44 pulseFrameInterval = 120 * time.Millisecond
45 )45 )
46 46
47-func pulseStyle(frame int) (renderer.Style, bool) {47+// pulseVisible reports whether the attention bell glyph is showing (as
48- f := frame % pulseFrameCount48+// opposed to its blank beat) at frame.
49- if f == 0 {49+func pulseVisible(frame int) bool {
50- return renderer.DefaultStyle(), false50+ return frame%pulseFrameCount != 0
51+}
52+
53+// pulseStyle returns the style for the attention bell glyph at frame, built
54+// on top of base so the bell always keeps the caller's background (the tab's
55+// accent/statusBar, or a faded MRU entry's blended colors) instead of
56+// punching a default-background hole in a themed bar. On the invisible beat
57+// it returns (base, false) unchanged.
58+func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) {
59+ if !pulseVisible(frame) {
60+ return base, false
51 }61 }
62+ f := frame % pulseFrameCount
52 peak := pulseFrameCount / 263 peak := pulseFrameCount / 2
53 distance := f - peak64 distance := f - peak
54 if distance < 0 {65 if distance < 0 {
55 distance = -distance66 distance = -distance
56 }67 }
57 intensity := 1 - float64(distance)/float64(peak)68 intensity := 1 - float64(distance)/float64(peak)
58- style := renderer.DefaultStyle()69+ style := base
59 style.Bold = true70 style.Bold = true
60- style.Foreground = 244 + int(intensity*11)71+ if base.HasForegroundRGB && base.HasBackgroundRGB {
72+ style.ForegroundRGB = themeui.Blend(base.BackgroundRGB, base.ForegroundRGB, intensity)
73+ } else {
74+ style.Foreground = 244 + int(intensity*11)
75+ }
61 return style, true76 return style, true
62 }77 }
63 78
@@ -76,11 +91,11 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t
76 }91 }
77 label := labels[i]92 label := labels[i]
78 writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle)93 writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle)
79- writeStatusText(row, &x, label.text[label.nameLen:], titleStyle)
80 if w.attention {94 if w.attention {
81 writeStatusText(row, &x, " ", baseStyle)95 writeStatusText(row, &x, " ", baseStyle)
82- writeBell(row, &x, frame)96+ writeBell(row, &x, frame, baseStyle)
83 }97 }
98+ writeStatusText(row, &x, label.text[label.nameLen:], titleStyle)
84 writeStatusText(row, &x, " ", baseStyle)99 writeStatusText(row, &x, " ", baseStyle)
85 }100 }
86 drawRightPlainText(row, topRight, x, styles.statusBar)101 drawRightPlainText(row, topRight, x, styles.statusBar)
@@ -123,7 +138,7 @@ func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, ephemeral,
123 writeStatusText(row, x, " "+name, style)138 writeStatusText(row, x, " "+name, style)
124 if attention {139 if attention {
125 writeStatusText(row, x, " ", style)140 writeStatusText(row, x, " ", style)
126- writeBell(row, x, attentionFrame)141+ writeBell(row, x, attentionFrame, style)
127 }142 }
128 writeStatusText(row, x, " ", style)143 writeStatusText(row, x, " ", style)
129 }144 }
@@ -539,10 +554,13 @@ func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style
539 return base554 return base
540 }555 }
541 556
542-func writeBell(row []renderer.Cell, x *int, frame int) {557+// writeBell draws the attention glyph on its visible beat, or a plain cell in
543- style, visible := pulseStyle(frame)558+// base's style on its blank beat, so the bell never leaves a bare
559+// default-background hole in a themed bar.
560+func writeBell(row []renderer.Cell, x *int, frame int, base renderer.Style) {
561+ style, visible := pulseStyle(frame, base)
544 if !visible {562 if !visible {
545- writeStatusText(row, x, " ", renderer.DefaultStyle())563+ writeStatusText(row, x, " ", base)
546 return564 return
547 }565 }
548 writeStatusText(row, x, string(attentionGlyph), style)566 writeStatusText(row, x, string(attentionGlyph), style)
M internal/usecase/daemon/status_test.go +110 -33
@@ -240,11 +240,12 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
240240 tabs := []statusTab{
241241 {name: "api", paneTitle: "shell"},
242242 {name: "logs", active: true, attention: true},
243+ {name: "err", paneTitle: "build failed", attention: true},
243244 }
244245 status := statusSnapshot{tabs: tabs}
245- const rowLen = 40
246+ const rowLen = 60
246247
247- t.Run("truecolor theme bolds names and mutes titles", func(t *testing.T) {
248+ t.Run("truecolor theme bolds names, mutes titles, and the bell keeps the base background", func(t *testing.T) {
248249 theme := themeui.Theme{
249250 Foreground: renderer.RGB{R: 200, G: 200, B: 200},
250251 Background: renderer.RGB{R: 10, G: 20, B: 30},
@@ -257,29 +258,36 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
257258 require.False(t, styles.tabTitle.Equal(styles.statusBar), "sanity: muted title style must differ from the base style on a truecolor theme")
258259 require.False(t, styles.tabTitleActive.Equal(styles.accent), "sanity: muted active-title style must differ from the base accent style")
259260
261+ labels := fitTabLabels(tabs, rowLen, "")
262+
263+ // Frame 0 is the bell's blank beat: the attention space and the blank
264+ // bell cell must both be exactly the tab's base style (not
265+ // renderer.DefaultStyle(), which would punch a hole in a themed bar).
266+ // This also pins down segment order: name, then (if attention)
267+ // space+bell, then title, then the trailing space.
260268 row := make([]renderer.Cell, rowLen)
261269 drawTopBarSnapshot(row, status, 0, "", styles)
262-
263- labels := fitTabLabels(tabs, rowLen, "")
264270 x := 0
265271
266- // tab 0: inactive, "api" bold, " (shell)" muted, trailing space plain.
272+ // tab 0: inactive, no attention: name, title, trailing space.
267273 nameEnd := x + 1 + labels[0].nameLen
268274 for i := x; i < nameEnd; i++ {
269275 require.True(t, row[i].Style.Bold, "cell %d: name segment must be bold", i)
270276 require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: name segment style mismatch", i)
271277 }
278+ x = nameEnd
272279 titleLen := len(labels[0].text) - labels[0].nameLen
273- titleEnd := nameEnd + titleLen
274- for i := nameEnd; i < titleEnd; i++ {
280+ titleEnd := x + titleLen
281+ for i := x; i < titleEnd; i++ {
275282 require.False(t, row[i].Style.Bold, "cell %d: title segment must not be bold", i)
276283 require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: title segment style mismatch", i)
277284 }
278285 x = titleEnd
279- require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: trailing space must keep the base style", x)
286+ require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 0 trailing space must keep the base style", x)
280287 x++
281288
282- // tab 1: active, "logs" bold, no title, attention space + bell + trailing space.
289+ // tab 1: active, attention, no title: name, attention space+bell (both
290+ // base style at frame 0), trailing space.
283291 nameEnd = x + 1 + labels[1].nameLen
284292 for i := x; i < nameEnd; i++ {
285293 require.True(t, row[i].Style.Bold, "cell %d: active name segment must be bold", i)
@@ -287,9 +295,46 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
287295 }
288296 require.Equal(t, len(labels[1].text), labels[1].nameLen, "tab 1 has no pane title, so its whole label is the name segment")
289297 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)
298+ require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 attention leading space must keep the active base style", x)
299+ x++
300+ tab1Bell := x
301+ require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 blank bell must keep the active base style, not DefaultStyle", x)
302+ x++
303+ require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 trailing space must keep the active base style", x)
304+ x++
305+
306+ // tab 2: inactive, attention, with title: name, attention space+bell
307+ // (both base style at frame 0), title, trailing space.
308+ nameEnd = x + 1 + labels[2].nameLen
309+ for i := x; i < nameEnd; i++ {
310+ require.True(t, row[i].Style.Bold, "cell %d: tab 2 name segment must be bold", i)
311+ require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: tab 2 name segment style mismatch", i)
312+ }
313+ x = nameEnd
314+ require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 attention leading space must keep the base style", x)
315+ x++
316+ tab2Bell := x
317+ require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 blank bell must keep the base style, not DefaultStyle", x)
318+ x++
319+ titleLen = len(labels[2].text) - labels[2].nameLen
320+ titleEnd = x + titleLen
321+ for i := x; i < titleEnd; i++ {
322+ require.False(t, row[i].Style.Bold, "cell %d: tab 2 title segment must not be bold", i)
323+ require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: tab 2 title segment style mismatch", i)
324+ }
325+ x = titleEnd
326+ require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 trailing space must keep the base style", x)
327+
328+ // On a visible pulse frame, the bell glyph itself must keep the same
329+ // background as its tab's base style.
330+ visibleRow := make([]renderer.Cell, rowLen)
331+ drawTopBarSnapshot(visibleRow, status, 1, "", styles)
332+ require.Equal(t, rune(attentionGlyph), visibleRow[tab1Bell].Rune)
333+ require.True(t, visibleRow[tab1Bell].Style.HasBackgroundRGB)
334+ require.Equal(t, styles.accent.BackgroundRGB, visibleRow[tab1Bell].Style.BackgroundRGB, "tab 1's visible bell must keep the active base background")
335+ require.Equal(t, rune(attentionGlyph), visibleRow[tab2Bell].Rune)
336+ require.True(t, visibleRow[tab2Bell].Style.HasBackgroundRGB)
337+ require.Equal(t, styles.statusBar.BackgroundRGB, visibleRow[tab2Bell].Style.BackgroundRGB, "tab 2's visible bell must keep the plain base background")
293338 })
294339
295340 t.Run("non-usable theme matches the pre-change fallback styles", func(t *testing.T) {
@@ -302,24 +347,26 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
302347 row := make([]renderer.Cell, rowLen)
303348 drawTopBarSnapshot(row, status, 0, "", styles)
304349
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
350+ // " api (shell) " (tab 0) + " logs " (tab 1: name, attention
351+ // space+blank bell, trailing space) + " err (build failed) " (tab 2:
352+ // name, attention space+blank bell, title, trailing space), then blank
353+ // padding to rowLen: byte-for-byte what the pre-change renderer
308354 // produced, since every new style field collapses to statusBar/accent.
309355 const tab0 = " api (shell) "
310356 const tab1 = " logs "
311- require.Equal(t, tab0+tab1+strings.Repeat(" ", rowLen-len(tab0)-len(tab1)), rowText(row))
357+ const tab2 = " err (build failed) "
358+ require.Equal(t, tab0+tab1+tab2+strings.Repeat(" ", rowLen-len(tab0)-len(tab1)-len(tab2)), rowText(row))
312359
313360 for i, c := range row[:len(tab0)] {
314361 require.True(t, c.Style.Equal(styles.statusBar), "cell %d should use the plain base style", i)
315362 }
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)
363+ tab1Start := len(tab0)
364+ for i := tab1Start; i < tab1Start+len(tab1); i++ {
365+ require.True(t, row[i].Style.Equal(styles.accent), "cell %d should use the plain accent style, including the blank bell cell", i)
366+ }
367+ tab2Start := tab1Start + len(tab1)
368+ for i := tab2Start; i < tab2Start+len(tab2); i++ {
369+ require.True(t, row[i].Style.Equal(styles.statusBar), "cell %d should use the plain base style, including the blank bell cell", i)
323370 }
324371 })
325372 }
@@ -818,15 +865,44 @@ func TestAttentionConstants(t *testing.T) {
818865 require.Equal(t, 120*time.Millisecond, pulseFrameInterval)
819866 }
820867
821-func TestPulseStyleHidesAtFrameZeroAndRamps(t *testing.T) {
822- _, visible := pulseStyle(0)
823- require.False(t, visible)
868+func TestPulseStyleFadesFromBaseAndHidesAtFrameZero(t *testing.T) {
869+ rgbBase := renderer.Style{
870+ HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 200, G: 200, B: 200},
871+ HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 10, G: 20, B: 30},
872+ }
873+ indexedBase := renderer.Style{Foreground: -1, Background: -1, Inverse: true}
824874
825- low, visible := pulseStyle(1)
826- require.True(t, visible)
827- peak, visible := pulseStyle(pulseFrameCount / 2)
828- require.True(t, visible)
829- require.Greater(t, peak.Foreground, low.Foreground)
875+ t.Run("invisible frame returns base unchanged", func(t *testing.T) {
876+ for _, base := range []renderer.Style{rgbBase, indexedBase} {
877+ style, visible := pulseStyle(0, base)
878+ require.False(t, visible)
879+ require.True(t, style.Equal(base))
880+ }
881+ })
882+
883+ t.Run("rgb base blends the glyph foreground from the base background to its foreground, keeping the base background", func(t *testing.T) {
884+ low, visible := pulseStyle(1, rgbBase)
885+ require.True(t, visible)
886+ require.True(t, low.Bold)
887+ require.True(t, low.HasBackgroundRGB)
888+ require.Equal(t, rgbBase.BackgroundRGB, low.BackgroundRGB, "bell keeps the caller's background so it never punches a hole in a themed bar")
889+
890+ peak, visible := pulseStyle(pulseFrameCount/2, rgbBase)
891+ require.True(t, visible)
892+ require.Equal(t, rgbBase.ForegroundRGB, peak.ForegroundRGB, "peak intensity reaches the base foreground exactly")
893+ require.NotEqual(t, low.ForegroundRGB, peak.ForegroundRGB, "the glyph should ramp, not jump straight to full intensity")
894+ })
895+
896+ t.Run("non-RGB base falls back to the indexed grey ramp and preserves other base attributes", func(t *testing.T) {
897+ low, visible := pulseStyle(1, indexedBase)
898+ require.True(t, visible)
899+ require.True(t, low.Bold)
900+ require.True(t, low.Inverse, "non-color base attributes like inverse must survive")
901+
902+ peak, visible := pulseStyle(pulseFrameCount/2, indexedBase)
903+ require.True(t, visible)
904+ require.Greater(t, peak.Foreground, low.Foreground)
905+ })
830906 }
831907
832908 func TestTopBarRendersAttentionBell(t *testing.T) {
@@ -846,8 +922,9 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
846922 frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(true), attentionFrame: 1}, win, true)
847923
848924 // Tab 2's label is enriched with its focused pane's title ("sh", the
849- // default shell fallback) and truncated to fit alongside the bell.
850- require.Contains(t, rowText(frame.Row(0)), "2 (s")
925+ // default shell fallback) and truncated to fit alongside the bell. The
926+ // bell sits right after the name, before the (title) segment.
927+ require.Contains(t, rowText(frame.Row(0)), "2 "+string(attentionGlyph)+" (s")
851928 for _, c := range frame.Row(0) {
852929 if c.Rune == attentionGlyph {
853930 require.True(t, c.Style.Bold)
@@ -240,11 +240,12 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
240 tabs := []statusTab{240 tabs := []statusTab{
241 {name: "api", paneTitle: "shell"},241 {name: "api", paneTitle: "shell"},
242 {name: "logs", active: true, attention: true},242 {name: "logs", active: true, attention: true},
243+ {name: "err", paneTitle: "build failed", attention: true},
243 }244 }
244 status := statusSnapshot{tabs: tabs}245 status := statusSnapshot{tabs: tabs}
245- const rowLen = 40246+ const rowLen = 60
246 247
247- t.Run("truecolor theme bolds names and mutes titles", func(t *testing.T) {248+ t.Run("truecolor theme bolds names, mutes titles, and the bell keeps the base background", func(t *testing.T) {
248 theme := themeui.Theme{249 theme := themeui.Theme{
249 Foreground: renderer.RGB{R: 200, G: 200, B: 200},250 Foreground: renderer.RGB{R: 200, G: 200, B: 200},
250 Background: renderer.RGB{R: 10, G: 20, B: 30},251 Background: renderer.RGB{R: 10, G: 20, B: 30},
@@ -257,29 +258,36 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
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.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 require.False(t, styles.tabTitleActive.Equal(styles.accent), "sanity: muted active-title style must differ from the base accent style")
259 260
261+ labels := fitTabLabels(tabs, rowLen, "")
262+
263+ // Frame 0 is the bell's blank beat: the attention space and the blank
264+ // bell cell must both be exactly the tab's base style (not
265+ // renderer.DefaultStyle(), which would punch a hole in a themed bar).
266+ // This also pins down segment order: name, then (if attention)
267+ // space+bell, then title, then the trailing space.
260 row := make([]renderer.Cell, rowLen)268 row := make([]renderer.Cell, rowLen)
261 drawTopBarSnapshot(row, status, 0, "", styles)269 drawTopBarSnapshot(row, status, 0, "", styles)
262-
263- labels := fitTabLabels(tabs, rowLen, "")
264 x := 0270 x := 0
265 271
266- // tab 0: inactive, "api" bold, " (shell)" muted, trailing space plain.272+ // tab 0: inactive, no attention: name, title, trailing space.
267 nameEnd := x + 1 + labels[0].nameLen273 nameEnd := x + 1 + labels[0].nameLen
268 for i := x; i < nameEnd; i++ {274 for i := x; i < nameEnd; i++ {
269 require.True(t, row[i].Style.Bold, "cell %d: name segment must be bold", i)275 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)276 require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: name segment style mismatch", i)
271 }277 }
278+ x = nameEnd
272 titleLen := len(labels[0].text) - labels[0].nameLen279 titleLen := len(labels[0].text) - labels[0].nameLen
273- titleEnd := nameEnd + titleLen280+ titleEnd := x + titleLen
274- for i := nameEnd; i < titleEnd; i++ {281+ for i := x; i < titleEnd; i++ {
275 require.False(t, row[i].Style.Bold, "cell %d: title segment must not be bold", i)282 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)283 require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: title segment style mismatch", i)
277 }284 }
278 x = titleEnd285 x = titleEnd
279- require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: trailing space must keep the base style", x)286+ require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 0 trailing space must keep the base style", x)
280 x++287 x++
281 288
282- // tab 1: active, "logs" bold, no title, attention space + bell + trailing space.289+ // tab 1: active, attention, no title: name, attention space+bell (both
290+ // base style at frame 0), trailing space.
283 nameEnd = x + 1 + labels[1].nameLen291 nameEnd = x + 1 + labels[1].nameLen
284 for i := x; i < nameEnd; i++ {292 for i := x; i < nameEnd; i++ {
285 require.True(t, row[i].Style.Bold, "cell %d: active name segment must be bold", i)293 require.True(t, row[i].Style.Bold, "cell %d: active name segment must be bold", i)
@@ -287,9 +295,46 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
287 }295 }
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")296 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 = nameEnd297 x = nameEnd
290- require.True(t, row[x].Style.Equal(styles.accent), "cell %d: attention leading space must keep the active base style", x)298+ require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 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 owns299+ x++
292- require.True(t, row[x].Style.Equal(styles.accent), "cell %d: trailing space must keep the active base style", x)300+ tab1Bell := x
301+ require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 blank bell must keep the active base style, not DefaultStyle", x)
302+ x++
303+ require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 trailing space must keep the active base style", x)
304+ x++
305+
306+ // tab 2: inactive, attention, with title: name, attention space+bell
307+ // (both base style at frame 0), title, trailing space.
308+ nameEnd = x + 1 + labels[2].nameLen
309+ for i := x; i < nameEnd; i++ {
310+ require.True(t, row[i].Style.Bold, "cell %d: tab 2 name segment must be bold", i)
311+ require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: tab 2 name segment style mismatch", i)
312+ }
313+ x = nameEnd
314+ require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 attention leading space must keep the base style", x)
315+ x++
316+ tab2Bell := x
317+ require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 blank bell must keep the base style, not DefaultStyle", x)
318+ x++
319+ titleLen = len(labels[2].text) - labels[2].nameLen
320+ titleEnd = x + titleLen
321+ for i := x; i < titleEnd; i++ {
322+ require.False(t, row[i].Style.Bold, "cell %d: tab 2 title segment must not be bold", i)
323+ require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: tab 2 title segment style mismatch", i)
324+ }
325+ x = titleEnd
326+ require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 trailing space must keep the base style", x)
327+
328+ // On a visible pulse frame, the bell glyph itself must keep the same
329+ // background as its tab's base style.
330+ visibleRow := make([]renderer.Cell, rowLen)
331+ drawTopBarSnapshot(visibleRow, status, 1, "", styles)
332+ require.Equal(t, rune(attentionGlyph), visibleRow[tab1Bell].Rune)
333+ require.True(t, visibleRow[tab1Bell].Style.HasBackgroundRGB)
334+ require.Equal(t, styles.accent.BackgroundRGB, visibleRow[tab1Bell].Style.BackgroundRGB, "tab 1's visible bell must keep the active base background")
335+ require.Equal(t, rune(attentionGlyph), visibleRow[tab2Bell].Rune)
336+ require.True(t, visibleRow[tab2Bell].Style.HasBackgroundRGB)
337+ require.Equal(t, styles.statusBar.BackgroundRGB, visibleRow[tab2Bell].Style.BackgroundRGB, "tab 2's visible bell must keep the plain base background")
293 })338 })
294 339
295 t.Run("non-usable theme matches the pre-change fallback styles", func(t *testing.T) {340 t.Run("non-usable theme matches the pre-change fallback styles", func(t *testing.T) {
@@ -302,24 +347,26 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
302 row := make([]renderer.Cell, rowLen)347 row := make([]renderer.Cell, rowLen)
303 drawTopBarSnapshot(row, status, 0, "", styles)348 drawTopBarSnapshot(row, status, 0, "", styles)
304 349
305- // " api (shell) " (tab 0, incl. trailing space) + " logs" + attention350+ // " api (shell) " (tab 0) + " logs " (tab 1: name, attention
306- // space + blank bell (frame 0 is not visible) + trailing space, then351+ // space+blank bell, trailing space) + " err (build failed) " (tab 2:
307- // blank padding to rowLen: byte-for-byte what the pre-change renderer352+ // name, attention space+blank bell, title, trailing space), then blank
353+ // padding to rowLen: byte-for-byte what the pre-change renderer
308 // produced, since every new style field collapses to statusBar/accent.354 // produced, since every new style field collapses to statusBar/accent.
309 const tab0 = " api (shell) "355 const tab0 = " api (shell) "
310 const tab1 = " logs "356 const tab1 = " logs "
311- require.Equal(t, tab0+tab1+strings.Repeat(" ", rowLen-len(tab0)-len(tab1)), rowText(row))357+ const tab2 = " err (build failed) "
358+ require.Equal(t, tab0+tab1+tab2+strings.Repeat(" ", rowLen-len(tab0)-len(tab1)-len(tab2)), rowText(row))
312 359
313 for i, c := range row[:len(tab0)] {360 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)361 require.True(t, c.Style.Equal(styles.statusBar), "cell %d should use the plain base style", i)
315 }362 }
316- activeStart := len(tab0)363+ tab1Start := len(tab0)
317- bellCell := activeStart + len(" logs") + 1 // pulseStyle-owned, not asserted here364+ for i := tab1Start; i < tab1Start+len(tab1); i++ {
318- for i := activeStart; i < activeStart+len(tab1); i++ {365+ require.True(t, row[i].Style.Equal(styles.accent), "cell %d should use the plain accent style, including the blank bell cell", i)
319- if i == bellCell {366+ }
320- continue367+ tab2Start := tab1Start + len(tab1)
321- }368+ for i := tab2Start; i < tab2Start+len(tab2); i++ {
322- require.True(t, row[i].Style.Equal(styles.accent), "cell %d should use the plain accent style", i)369+ require.True(t, row[i].Style.Equal(styles.statusBar), "cell %d should use the plain base style, including the blank bell cell", i)
323 }370 }
324 })371 })
325 }372 }
@@ -818,15 +865,44 @@ func TestAttentionConstants(t *testing.T) {
818 require.Equal(t, 120*time.Millisecond, pulseFrameInterval)865 require.Equal(t, 120*time.Millisecond, pulseFrameInterval)
819 }866 }
820 867
821-func TestPulseStyleHidesAtFrameZeroAndRamps(t *testing.T) {868+func TestPulseStyleFadesFromBaseAndHidesAtFrameZero(t *testing.T) {
822- _, visible := pulseStyle(0)869+ rgbBase := renderer.Style{
823- require.False(t, visible)870+ HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 200, G: 200, B: 200},
871+ HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 10, G: 20, B: 30},
872+ }
873+ indexedBase := renderer.Style{Foreground: -1, Background: -1, Inverse: true}
824 874
825- low, visible := pulseStyle(1)875+ t.Run("invisible frame returns base unchanged", func(t *testing.T) {
826- require.True(t, visible)876+ for _, base := range []renderer.Style{rgbBase, indexedBase} {
827- peak, visible := pulseStyle(pulseFrameCount / 2)877+ style, visible := pulseStyle(0, base)
828- require.True(t, visible)878+ require.False(t, visible)
829- require.Greater(t, peak.Foreground, low.Foreground)879+ require.True(t, style.Equal(base))
880+ }
881+ })
882+
883+ t.Run("rgb base blends the glyph foreground from the base background to its foreground, keeping the base background", func(t *testing.T) {
884+ low, visible := pulseStyle(1, rgbBase)
885+ require.True(t, visible)
886+ require.True(t, low.Bold)
887+ require.True(t, low.HasBackgroundRGB)
888+ require.Equal(t, rgbBase.BackgroundRGB, low.BackgroundRGB, "bell keeps the caller's background so it never punches a hole in a themed bar")
889+
890+ peak, visible := pulseStyle(pulseFrameCount/2, rgbBase)
891+ require.True(t, visible)
892+ require.Equal(t, rgbBase.ForegroundRGB, peak.ForegroundRGB, "peak intensity reaches the base foreground exactly")
893+ require.NotEqual(t, low.ForegroundRGB, peak.ForegroundRGB, "the glyph should ramp, not jump straight to full intensity")
894+ })
895+
896+ t.Run("non-RGB base falls back to the indexed grey ramp and preserves other base attributes", func(t *testing.T) {
897+ low, visible := pulseStyle(1, indexedBase)
898+ require.True(t, visible)
899+ require.True(t, low.Bold)
900+ require.True(t, low.Inverse, "non-color base attributes like inverse must survive")
901+
902+ peak, visible := pulseStyle(pulseFrameCount/2, indexedBase)
903+ require.True(t, visible)
904+ require.Greater(t, peak.Foreground, low.Foreground)
905+ })
830 }906 }
831 907
832 func TestTopBarRendersAttentionBell(t *testing.T) {908 func TestTopBarRendersAttentionBell(t *testing.T) {
@@ -846,8 +922,9 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
846 frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(true), attentionFrame: 1}, win, true)922 frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(true), attentionFrame: 1}, win, true)
847 923
848 // Tab 2's label is enriched with its focused pane's title ("sh", the924 // Tab 2's label is enriched with its focused pane's title ("sh", the
849- // default shell fallback) and truncated to fit alongside the bell.925+ // default shell fallback) and truncated to fit alongside the bell. The
850- require.Contains(t, rowText(frame.Row(0)), "2 (s")926+ // bell sits right after the name, before the (title) segment.
927+ require.Contains(t, rowText(frame.Row(0)), "2 "+string(attentionGlyph)+" (s")
851 for _, c := range frame.Row(0) {928 for _, c := range frame.Row(0) {
852 if c.Rune == attentionGlyph {929 if c.Rune == attentionGlyph {
853 require.True(t, c.Style.Bold)930 require.True(t, c.Style.Bold)
M internal/usecase/picker/model.go +8 -7
@@ -217,9 +217,10 @@ func (m *Model) firstLeaf() int {
217217 }
218218
219219 // 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.
220+// (bold when styles came from a truecolor theme), a base-styled attention
221+// marker right after the name, and a muted detail segment (tab rows only) —
222+// or a base-styled "(stopped)" suffix for stopped session headers. A tight
223+// width ellipsizes the detail segment before eating into the name.
223224 func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) {
224225 if m == nil || rect.Width <= 0 || rect.Height <= 0 {
225226 return
@@ -253,12 +254,12 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
253254 continue
254255 }
255256
256- detail := ui.TruncateText(r.detail, clipX-x)
257- x = ui.DrawText(frame, x, rect.Y+y, clipX, detail, detailStyle)
258-
259257 if r.attention {
260- ui.DrawText(frame, x, rect.Y+y, clipX, " "+string(attentionGlyph), base)
258+ x = ui.DrawText(frame, x, rect.Y+y, clipX, " "+string(attentionGlyph), base)
261259 }
260+
261+ detail := ui.TruncateText(r.detail, clipX-x)
262+ ui.DrawText(frame, x, rect.Y+y, clipX, detail, detailStyle)
262263 }
263264 }
264265
@@ -217,9 +217,10 @@ func (m *Model) firstLeaf() int {
217 }217 }
218 218
219 // renderList draws each visible row as up to three segments: a name segment219 // 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 segment220+// (bold when styles came from a truecolor theme), a base-styled attention
221-// (tab rows only), and a base-styled attention marker or "(stopped)" suffix.221+// marker right after the name, and a muted detail segment (tab rows only) —
222-// A tight width ellipsizes the detail segment before eating into the name.222+// or a base-styled "(stopped)" suffix for stopped session headers. A tight
223+// width ellipsizes the detail segment before eating into the name.
223 func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) {224 func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) {
224 if m == nil || rect.Width <= 0 || rect.Height <= 0 {225 if m == nil || rect.Width <= 0 || rect.Height <= 0 {
225 return226 return
@@ -253,12 +254,12 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
253 continue254 continue
254 }255 }
255 256
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 {257 if r.attention {
260- ui.DrawText(frame, x, rect.Y+y, clipX, " "+string(attentionGlyph), base)258+ x = ui.DrawText(frame, x, rect.Y+y, clipX, " "+string(attentionGlyph), base)
261 }259 }
260+
261+ detail := ui.TruncateText(r.detail, clipX-x)
262+ ui.DrawText(frame, x, rect.Y+y, clipX, detail, detailStyle)
262 }263 }
263 }264 }
264 265
M internal/usecase/picker/model_test.go +11 -11
@@ -191,19 +191,19 @@ func TestRenderListDrawsNameAndDetailSegmentsWithDistinctStyles(t *testing.T) {
191191 require.Equal(t, '(', frame.At(8, 1).Rune)
192192 require.True(t, frame.At(8, 1).Style.Equal(selectionMutedStyle), "selected detail segment style")
193193
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).
194+ // Row 2 is the non-selected tab ("beta"): name uses Name, then the
195+ // attention marker (right after the name, before detail) uses the row's
196+ // base style (not muted), then detail uses Detail.
197197 require.Equal(t, 'b', frame.At(2, 2).Rune)
198198 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")
199+ require.Equal(t, ' ', frame.At(6, 2).Rune, "attention marker leading space")
200+ require.True(t, frame.At(6, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted")
201+ require.Equal(t, rune(attentionGlyph), frame.At(7, 2).Rune)
202+ require.True(t, frame.At(7, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted")
203+ require.Equal(t, ' ', frame.At(8, 2).Rune)
204+ require.True(t, frame.At(8, 2).Style.Equal(detailStyle), "detail segment style")
205+ require.Equal(t, '(', frame.At(9, 2).Rune)
206+ require.True(t, frame.At(9, 2).Style.Equal(detailStyle), "detail segment style")
207207 }
208208
209209 func TestRenderListTruncatesDetailBeforeName(t *testing.T) {
@@ -191,19 +191,19 @@ func TestRenderListDrawsNameAndDetailSegmentsWithDistinctStyles(t *testing.T) {
191 require.Equal(t, '(', frame.At(8, 1).Rune)191 require.Equal(t, '(', frame.At(8, 1).Rune)
192 require.True(t, frame.At(8, 1).Style.Equal(selectionMutedStyle), "selected detail segment style")192 require.True(t, frame.At(8, 1).Style.Equal(selectionMutedStyle), "selected detail segment style")
193 193
194- // Row 2 is the non-selected tab ("beta"): name uses Name, detail uses194+ // Row 2 is the non-selected tab ("beta"): name uses Name, then the
195- // Detail, and the attention marker after the detail uses the row's base195+ // attention marker (right after the name, before detail) uses the row's
196- // style (not muted).196+ // base style (not muted), then detail uses Detail.
197 require.Equal(t, 'b', frame.At(2, 2).Rune)197 require.Equal(t, 'b', frame.At(2, 2).Rune)
198 require.True(t, frame.At(2, 2).Style.Equal(nameStyle), "name segment style")198 require.True(t, frame.At(2, 2).Style.Equal(nameStyle), "name segment style")
199- require.Equal(t, ' ', frame.At(6, 2).Rune)199+ require.Equal(t, ' ', frame.At(6, 2).Rune, "attention marker leading space")
200- require.True(t, frame.At(6, 2).Style.Equal(detailStyle), "detail segment style")200+ require.True(t, frame.At(6, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted")
201- require.Equal(t, '(', frame.At(7, 2).Rune)201+ require.Equal(t, rune(attentionGlyph), frame.At(7, 2).Rune)
202- require.True(t, frame.At(7, 2).Style.Equal(detailStyle), "detail segment style")202+ require.True(t, frame.At(7, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted")
203- require.Equal(t, ' ', frame.At(13, 2).Rune, "attention marker leading space")203+ require.Equal(t, ' ', frame.At(8, 2).Rune)
204- require.True(t, frame.At(13, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted")204+ require.True(t, frame.At(8, 2).Style.Equal(detailStyle), "detail segment style")
205- require.Equal(t, rune(attentionGlyph), frame.At(14, 2).Rune)205+ require.Equal(t, '(', frame.At(9, 2).Rune)
206- require.True(t, frame.At(14, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted")206+ require.True(t, frame.At(9, 2).Style.Equal(detailStyle), "detail segment style")
207 }207 }
208 208
209 func TestRenderListTruncatesDetailBeforeName(t *testing.T) {209 func TestRenderListTruncatesDetailBeforeName(t *testing.T) {

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