detached 84daa2c

feat(theme): tint complete status bars

commit
84daa2c24dc3c82147537e117b2f25083b2c17a5
author
brice · · 2026-07-18 08:50 UTC
parent
d4234db
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +159 -41
M internal/usecase/daemon/status.go +17 -20
@@ -45,17 +45,17 @@ func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) {
4545 }
4646
4747 func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeui.Styles) {
48- clearStatusRow(row)
48+ clearStatusRow(row, styles.SurfaceBar)
4949 x := 0
5050 labels := fitTabLabels(status.tabs, len(row), topRight)
5151 for i, w := range status.tabs {
52- baseStyle := styles.StatusBar
53- nameStyle := styles.TabName
54- titleStyle := styles.TabTitle
52+ baseStyle := styles.SurfaceInactive
53+ nameStyle := styles.TabInactive
54+ titleStyle := styles.TabInactiveTitle
5555 if w.active {
56- baseStyle = styles.Accent
57- nameStyle = styles.TabNameActive
58- titleStyle = styles.TabTitleActive
56+ baseStyle = styles.SurfaceActive
57+ nameStyle = styles.TabActive
58+ titleStyle = styles.TabActiveTitle
5959 }
6060 label := labels[i]
6161 writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle)
@@ -66,30 +66,30 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t
6666 writeStatusText(row, &x, label.text[label.nameLen:], titleStyle)
6767 writeStatusText(row, &x, " ", baseStyle)
6868 }
69- drawRightPlainText(row, topRight, x, styles.StatusBar)
69+ drawRightPlainText(row, topRight, x, styles.SurfaceBar)
7070 }
7171
7272 func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styles) {
73- clearStatusRow(row)
73+ clearStatusRow(row, styles.SurfaceBar)
7474 x := 0
7575 rightText := composeBottomRightText(state.bottomRight, state.copyFeedback)
76- writeStatusText(row, &x, " "+state.status.session+" ", styles.Accent)
76+ writeStatusText(row, &x, " "+state.status.session+" ", styles.SurfaceActive)
7777 if state.rankedRecent != nil {
7878 for _, sess := range fitRankedRecent(state.rankedRecent, len(row), x, rightText) {
79- style := styles.StatusBar // contextual ranks deliberately do not fade.
79+ style := styles.SurfaceInactive // contextual ranks deliberately do not fade.
8080 if sess.selected {
81- style = styles.Accent
81+ style = styles.SurfaceActive
8282 }
8383 drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame)
8484 }
8585 } else {
8686 fittedMRU := fitMRU(state.mru, len(row), x, rightText)
8787 for i, sess := range fittedMRU {
88- style := mruStyle(styles.StatusBar, state.theme, i, len(fittedMRU))
88+ style := styles.MRUStyle(i, len(fittedMRU))
8989 drawStatusSessionEntry(row, &x, sess.name, sess.attention, style, state.attentionFrame)
9090 }
9191 }
92- drawRightPlainText(row, rightText, x, styles.StatusBar)
92+ drawRightPlainText(row, rightText, x, styles.SurfaceBar)
9393 }
9494
9595 type rankedRecent struct {
@@ -147,9 +147,10 @@ func statusTextWidth(text string) int {
147147 return width
148148 }
149149
150-func clearStatusRow(row []renderer.Cell) {
150+func clearStatusRow(row []renderer.Cell, style renderer.Style) {
151+ blank := renderer.Cell{Rune: ' ', Style: style}
151152 for i := range row {
152- row[i] = renderer.BlankCell()
153+ row[i] = blank
153154 }
154155 }
155156
@@ -426,10 +427,6 @@ func mruFutureRightReserve(rowLen int) int {
426427 return reserve
427428 }
428429
429-func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style {
430- return themeui.MRUFade(base, t, i, count)
431-}
432-
433430 // writeBell draws the attention glyph on its visible beat, or a plain cell in
434431 // base's style on its blank beat, so the bell never leaves a bare
435432 // default-background hole in a themed bar.
@@ -45,17 +45,17 @@ func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) {
45 }45 }
46 46
47 func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeui.Styles) {47 func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeui.Styles) {
48- clearStatusRow(row)48+ clearStatusRow(row, styles.SurfaceBar)
49 x := 049 x := 0
50 labels := fitTabLabels(status.tabs, len(row), topRight)50 labels := fitTabLabels(status.tabs, len(row), topRight)
51 for i, w := range status.tabs {51 for i, w := range status.tabs {
52- baseStyle := styles.StatusBar52+ baseStyle := styles.SurfaceInactive
53- nameStyle := styles.TabName53+ nameStyle := styles.TabInactive
54- titleStyle := styles.TabTitle54+ titleStyle := styles.TabInactiveTitle
55 if w.active {55 if w.active {
56- baseStyle = styles.Accent56+ baseStyle = styles.SurfaceActive
57- nameStyle = styles.TabNameActive57+ nameStyle = styles.TabActive
58- titleStyle = styles.TabTitleActive58+ titleStyle = styles.TabActiveTitle
59 }59 }
60 label := labels[i]60 label := labels[i]
61 writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle)61 writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle)
@@ -66,30 +66,30 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t
66 writeStatusText(row, &x, label.text[label.nameLen:], titleStyle)66 writeStatusText(row, &x, label.text[label.nameLen:], titleStyle)
67 writeStatusText(row, &x, " ", baseStyle)67 writeStatusText(row, &x, " ", baseStyle)
68 }68 }
69- drawRightPlainText(row, topRight, x, styles.StatusBar)69+ drawRightPlainText(row, topRight, x, styles.SurfaceBar)
70 }70 }
71 71
72 func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styles) {72 func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styles) {
73- clearStatusRow(row)73+ clearStatusRow(row, styles.SurfaceBar)
74 x := 074 x := 0
75 rightText := composeBottomRightText(state.bottomRight, state.copyFeedback)75 rightText := composeBottomRightText(state.bottomRight, state.copyFeedback)
76- writeStatusText(row, &x, " "+state.status.session+" ", styles.Accent)76+ writeStatusText(row, &x, " "+state.status.session+" ", styles.SurfaceActive)
77 if state.rankedRecent != nil {77 if state.rankedRecent != nil {
78 for _, sess := range fitRankedRecent(state.rankedRecent, len(row), x, rightText) {78 for _, sess := range fitRankedRecent(state.rankedRecent, len(row), x, rightText) {
79- style := styles.StatusBar // contextual ranks deliberately do not fade.79+ style := styles.SurfaceInactive // contextual ranks deliberately do not fade.
80 if sess.selected {80 if sess.selected {
81- style = styles.Accent81+ style = styles.SurfaceActive
82 }82 }
83 drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame)83 drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame)
84 }84 }
85 } else {85 } else {
86 fittedMRU := fitMRU(state.mru, len(row), x, rightText)86 fittedMRU := fitMRU(state.mru, len(row), x, rightText)
87 for i, sess := range fittedMRU {87 for i, sess := range fittedMRU {
88- style := mruStyle(styles.StatusBar, state.theme, i, len(fittedMRU))88+ style := styles.MRUStyle(i, len(fittedMRU))
89 drawStatusSessionEntry(row, &x, sess.name, sess.attention, style, state.attentionFrame)89 drawStatusSessionEntry(row, &x, sess.name, sess.attention, style, state.attentionFrame)
90 }90 }
91 }91 }
92- drawRightPlainText(row, rightText, x, styles.StatusBar)92+ drawRightPlainText(row, rightText, x, styles.SurfaceBar)
93 }93 }
94 94
95 type rankedRecent struct {95 type rankedRecent struct {
@@ -147,9 +147,10 @@ func statusTextWidth(text string) int {
147 return width147 return width
148 }148 }
149 149
150-func clearStatusRow(row []renderer.Cell) {150+func clearStatusRow(row []renderer.Cell, style renderer.Style) {
151+ blank := renderer.Cell{Rune: ' ', Style: style}
151 for i := range row {152 for i := range row {
152- row[i] = renderer.BlankCell()153+ row[i] = blank
153 }154 }
154 }155 }
155 156
@@ -426,10 +427,6 @@ func mruFutureRightReserve(rowLen int) int {
426 return reserve427 return reserve
427 }428 }
428 429
429-func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style {
430- return themeui.MRUFade(base, t, i, count)
431-}
432-
433 // writeBell draws the attention glyph on its visible beat, or a plain cell in430 // writeBell draws the attention glyph on its visible beat, or a plain cell in
434 // base's style on its blank beat, so the bell never leaves a bare431 // base's style on its blank beat, so the bell never leaves a bare
435 // default-background hole in a themed bar.432 // default-background hole in a themed bar.
M internal/usecase/daemon/status_test.go +108 -19
@@ -1200,28 +1200,22 @@ func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testi
12001200 }
12011201
12021202 func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
1203- theme := themeui.Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 20, G: 20, B: 20}, HasFG: true, HasBG: true, TrueColor: true, Known: true}
1204- styles := themeui.NewStyles(theme)
1205- state := barState{status: statusSnapshot{session: "c"}, theme: theme, mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}}
1203+ theme := themeui.Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true}
1204+ theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
1205+ theme.Palette[10] = theme.Palette[2]
1206+ theme.PaletteKnown = 1<<2 | 1<<10
1207+ resolved := themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
1208+ state := barState{status: statusSnapshot{session: "c"}, mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}}
12061209 row := make([]renderer.Cell, 16)
12071210
1208- drawStatusBarState(row, state, styles)
1211+ drawStatusBarState(row, state, resolved.Styles)
1212+
1213+ for index, cell := range []int{4, 7, 10} {
1214+ require.True(t, row[cell].Style.Equal(themeui.MRUStyle(resolved.Ramp, index, 3)))
1215+ }
1216+ require.NotEqual(t, resolved.Styles.SurfaceBar.BackgroundRGB, row[10].Style.BackgroundRGB, "oldest MRU remains distinct from the bar")
12091217
1210- firstFG := row[4].Style.ForegroundRGB.R
1211- secondFG := row[7].Style.ForegroundRGB.R
1212- thirdFG := row[10].Style.ForegroundRGB.R
1213- require.Equal(t, styles.StatusBar.ForegroundRGB.R, firstFG)
1214- require.Greater(t, firstFG, secondFG)
1215- require.Greater(t, secondFG, thirdFG)
1216-
1217- firstBG := row[4].Style.BackgroundRGB.R
1218- secondBG := row[7].Style.BackgroundRGB.R
1219- thirdBG := row[10].Style.BackgroundRGB.R
1220- require.Equal(t, styles.StatusBar.BackgroundRGB.R, firstBG)
1221- require.Greater(t, firstBG, secondBG)
1222- require.Greater(t, secondBG, thirdBG)
1223-
1224- plain := mruStyle(renderer.DefaultStyle(), themeui.Theme{}, 1, 3)
1218+ plain := resolveStyles(nil).MRUStyle(1, 3)
12251219 require.False(t, plain.HasForegroundRGB)
12261220 require.False(t, plain.HasBackgroundRGB)
12271221 }
@@ -1403,6 +1397,101 @@ func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) {
14031397 }
14041398 }
14051399
1400+func TestStatusBarsUseCompleteSemanticSurfaces(t *testing.T) {
1401+ theme := themeui.Theme{
1402+ Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8},
1403+ Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a},
1404+ HasFG: true,
1405+ HasBG: true,
1406+ TrueColor: true,
1407+ Known: true,
1408+ UsePalette: true,
1409+ }
1410+ theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
1411+ theme.Palette[10] = theme.Palette[2]
1412+ theme.PaletteKnown = 1<<2 | 1<<10
1413+ resolved := themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
1414+ styles := resolved.Styles
1415+
1416+ t.Run("top bar maps inactive active and secondary roles while filling the row", func(t *testing.T) {
1417+ row := make([]renderer.Cell, 48)
1418+ status := statusSnapshot{tabs: []statusTab{{name: "idle", paneTitle: "shell"}, {name: "selected", active: true}}}
1419+ drawTopBarSnapshot(row, status, 0, "clock", styles)
1420+
1421+ for _, cell := range row[28:40] {
1422+ require.True(t, cell.Style.Equal(styles.SurfaceBar), "unowned top-bar filler must retain SurfaceBar")
1423+ }
1424+ require.True(t, row[1].Style.Equal(styles.TabInactive), "inactive name must use TabInactive")
1425+ require.True(t, row[5].Style.Equal(styles.TabInactiveTitle), "pane title must use contrast-derived secondary text")
1426+ selected := strings.Index(rowText(row), "selected")
1427+ require.GreaterOrEqual(t, selected, 0)
1428+ require.True(t, row[selected].Style.Equal(styles.TabActive), "active tab must outrank inactive styling")
1429+ require.True(t, row[selected].Style.Bold, "active tab stays bold")
1430+ })
1431+
1432+ t.Run("bottom bar maps current ranked and MRU roles without sacrificing right-side readability", func(t *testing.T) {
1433+ row := make([]renderer.Cell, 48)
1434+ state := barState{
1435+ status: statusSnapshot{session: "current"},
1436+ bottomRight: "script ok",
1437+ mru: []recentSession{{name: "new"}, {name: "middle"}, {name: "old"}},
1438+ }
1439+ drawStatusBarState(row, state, styles)
1440+ for i := range len(" current ") {
1441+ require.True(t, row[i].Style.Equal(styles.SurfaceActive), "current session must use SurfaceActive")
1442+ }
1443+ for index, name := range []string{"new", "middle", "old"} {
1444+ at := strings.Index(rowText(row), name)
1445+ require.GreaterOrEqual(t, at, 0)
1446+ require.True(t, row[at].Style.Equal(themeui.MRUStyle(resolved.Ramp, index, 3)), "%s has the wrong MRU position surface", name)
1447+ }
1448+ for _, cell := range row[30:38] {
1449+ require.True(t, cell.Style.Equal(styles.SurfaceBar), "bottom filler must retain SurfaceBar")
1450+ }
1451+ at := strings.Index(rowText(row), "script ok")
1452+ require.GreaterOrEqual(t, at, 0)
1453+ require.True(t, row[at].Style.Equal(styles.SurfaceBar), "right-side script text must remain readable on SurfaceBar")
1454+
1455+ ranked := make([]renderer.Cell, 48)
1456+ drawStatusBarState(ranked, barState{
1457+ status: statusSnapshot{session: "current"},
1458+ rankedRecent: []rankedRecent{{rank: 1, name: "inactive"}, {rank: 2, name: "chosen", selected: true}},
1459+ }, styles)
1460+ inactive := strings.Index(rowText(ranked), "inactive")
1461+ chosen := strings.Index(rowText(ranked), "chosen")
1462+ require.True(t, ranked[inactive].Style.Equal(styles.SurfaceInactive), "unselected ranked recent must use SurfaceInactive")
1463+ require.True(t, ranked[chosen].Style.Equal(styles.SurfaceActive), "selected ranked recent must outrank inactive styling")
1464+ })
1465+
1466+ t.Run("MRU zero and singleton cases retain semantic edge surfaces", func(t *testing.T) {
1467+ empty := make([]renderer.Cell, 20)
1468+ drawStatusBarState(empty, barState{status: statusSnapshot{session: "current"}}, styles)
1469+ for _, cell := range empty[len(" current "):] {
1470+ require.True(t, cell.Style.Equal(styles.SurfaceBar), "empty MRU leaves a complete SurfaceBar")
1471+ }
1472+
1473+ single := make([]renderer.Cell, 20)
1474+ drawStatusBarState(single, barState{status: statusSnapshot{session: "current"}, mru: []recentSession{{name: "only"}}}, styles)
1475+ only := strings.Index(rowText(single), "only")
1476+ require.GreaterOrEqual(t, only, 0)
1477+ require.True(t, single[only].Style.Equal(styles.SurfaceRecent), "a singleton MRU must retain the 22 percent recent surface")
1478+ })
1479+
1480+ t.Run("attention pulse preserves its active or inactive base surface", func(t *testing.T) {
1481+ row := make([]renderer.Cell, 32)
1482+ drawTopBarSnapshot(row, statusSnapshot{tabs: []statusTab{{name: "active", active: true, attention: true}, {name: "idle", attention: true}}}, 1, "", styles)
1483+ bells := make([]int, 0, 2)
1484+ for i, cell := range row {
1485+ if cell.Rune == ui.AttentionGlyph {
1486+ bells = append(bells, i)
1487+ }
1488+ }
1489+ require.Len(t, bells, 2)
1490+ require.Equal(t, styles.SurfaceActive.BackgroundRGB, row[bells[0]].Style.BackgroundRGB, "active must outrank attention")
1491+ require.Equal(t, styles.SurfaceInactive.BackgroundRGB, row[bells[1]].Style.BackgroundRGB, "attention must preserve inactive base surface")
1492+ })
1493+}
1494+
14061495 func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) {
14071496 p1, releasePTY1 := newBlockingPTY(t)
14081497 p2, releasePTY2 := newBlockingPTY(t)
@@ -1200,28 +1200,22 @@ func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testi
1200 }1200 }
1201 1201
1202 func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {1202 func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
1203- theme := themeui.Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 20, G: 20, B: 20}, HasFG: true, HasBG: true, TrueColor: true, Known: true}1203+ theme := themeui.Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true}
1204- styles := themeui.NewStyles(theme)1204+ theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
1205- state := barState{status: statusSnapshot{session: "c"}, theme: theme, mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}}1205+ theme.Palette[10] = theme.Palette[2]
1206+ theme.PaletteKnown = 1<<2 | 1<<10
1207+ resolved := themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
1208+ state := barState{status: statusSnapshot{session: "c"}, mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}}
1206 row := make([]renderer.Cell, 16)1209 row := make([]renderer.Cell, 16)
1207 1210
1208- drawStatusBarState(row, state, styles)1211+ drawStatusBarState(row, state, resolved.Styles)
1212+
1213+ for index, cell := range []int{4, 7, 10} {
1214+ require.True(t, row[cell].Style.Equal(themeui.MRUStyle(resolved.Ramp, index, 3)))
1215+ }
1216+ require.NotEqual(t, resolved.Styles.SurfaceBar.BackgroundRGB, row[10].Style.BackgroundRGB, "oldest MRU remains distinct from the bar")
1209 1217
1210- firstFG := row[4].Style.ForegroundRGB.R1218+ plain := resolveStyles(nil).MRUStyle(1, 3)
1211- secondFG := row[7].Style.ForegroundRGB.R
1212- thirdFG := row[10].Style.ForegroundRGB.R
1213- require.Equal(t, styles.StatusBar.ForegroundRGB.R, firstFG)
1214- require.Greater(t, firstFG, secondFG)
1215- require.Greater(t, secondFG, thirdFG)
1216-
1217- firstBG := row[4].Style.BackgroundRGB.R
1218- secondBG := row[7].Style.BackgroundRGB.R
1219- thirdBG := row[10].Style.BackgroundRGB.R
1220- require.Equal(t, styles.StatusBar.BackgroundRGB.R, firstBG)
1221- require.Greater(t, firstBG, secondBG)
1222- require.Greater(t, secondBG, thirdBG)
1223-
1224- plain := mruStyle(renderer.DefaultStyle(), themeui.Theme{}, 1, 3)
1225 require.False(t, plain.HasForegroundRGB)1219 require.False(t, plain.HasForegroundRGB)
1226 require.False(t, plain.HasBackgroundRGB)1220 require.False(t, plain.HasBackgroundRGB)
1227 }1221 }
@@ -1403,6 +1397,101 @@ func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) {
1403 }1397 }
1404 }1398 }
1405 1399
1400+func TestStatusBarsUseCompleteSemanticSurfaces(t *testing.T) {
1401+ theme := themeui.Theme{
1402+ Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8},
1403+ Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a},
1404+ HasFG: true,
1405+ HasBG: true,
1406+ TrueColor: true,
1407+ Known: true,
1408+ UsePalette: true,
1409+ }
1410+ theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
1411+ theme.Palette[10] = theme.Palette[2]
1412+ theme.PaletteKnown = 1<<2 | 1<<10
1413+ resolved := themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
1414+ styles := resolved.Styles
1415+
1416+ t.Run("top bar maps inactive active and secondary roles while filling the row", func(t *testing.T) {
1417+ row := make([]renderer.Cell, 48)
1418+ status := statusSnapshot{tabs: []statusTab{{name: "idle", paneTitle: "shell"}, {name: "selected", active: true}}}
1419+ drawTopBarSnapshot(row, status, 0, "clock", styles)
1420+
1421+ for _, cell := range row[28:40] {
1422+ require.True(t, cell.Style.Equal(styles.SurfaceBar), "unowned top-bar filler must retain SurfaceBar")
1423+ }
1424+ require.True(t, row[1].Style.Equal(styles.TabInactive), "inactive name must use TabInactive")
1425+ require.True(t, row[5].Style.Equal(styles.TabInactiveTitle), "pane title must use contrast-derived secondary text")
1426+ selected := strings.Index(rowText(row), "selected")
1427+ require.GreaterOrEqual(t, selected, 0)
1428+ require.True(t, row[selected].Style.Equal(styles.TabActive), "active tab must outrank inactive styling")
1429+ require.True(t, row[selected].Style.Bold, "active tab stays bold")
1430+ })
1431+
1432+ t.Run("bottom bar maps current ranked and MRU roles without sacrificing right-side readability", func(t *testing.T) {
1433+ row := make([]renderer.Cell, 48)
1434+ state := barState{
1435+ status: statusSnapshot{session: "current"},
1436+ bottomRight: "script ok",
1437+ mru: []recentSession{{name: "new"}, {name: "middle"}, {name: "old"}},
1438+ }
1439+ drawStatusBarState(row, state, styles)
1440+ for i := range len(" current ") {
1441+ require.True(t, row[i].Style.Equal(styles.SurfaceActive), "current session must use SurfaceActive")
1442+ }
1443+ for index, name := range []string{"new", "middle", "old"} {
1444+ at := strings.Index(rowText(row), name)
1445+ require.GreaterOrEqual(t, at, 0)
1446+ require.True(t, row[at].Style.Equal(themeui.MRUStyle(resolved.Ramp, index, 3)), "%s has the wrong MRU position surface", name)
1447+ }
1448+ for _, cell := range row[30:38] {
1449+ require.True(t, cell.Style.Equal(styles.SurfaceBar), "bottom filler must retain SurfaceBar")
1450+ }
1451+ at := strings.Index(rowText(row), "script ok")
1452+ require.GreaterOrEqual(t, at, 0)
1453+ require.True(t, row[at].Style.Equal(styles.SurfaceBar), "right-side script text must remain readable on SurfaceBar")
1454+
1455+ ranked := make([]renderer.Cell, 48)
1456+ drawStatusBarState(ranked, barState{
1457+ status: statusSnapshot{session: "current"},
1458+ rankedRecent: []rankedRecent{{rank: 1, name: "inactive"}, {rank: 2, name: "chosen", selected: true}},
1459+ }, styles)
1460+ inactive := strings.Index(rowText(ranked), "inactive")
1461+ chosen := strings.Index(rowText(ranked), "chosen")
1462+ require.True(t, ranked[inactive].Style.Equal(styles.SurfaceInactive), "unselected ranked recent must use SurfaceInactive")
1463+ require.True(t, ranked[chosen].Style.Equal(styles.SurfaceActive), "selected ranked recent must outrank inactive styling")
1464+ })
1465+
1466+ t.Run("MRU zero and singleton cases retain semantic edge surfaces", func(t *testing.T) {
1467+ empty := make([]renderer.Cell, 20)
1468+ drawStatusBarState(empty, barState{status: statusSnapshot{session: "current"}}, styles)
1469+ for _, cell := range empty[len(" current "):] {
1470+ require.True(t, cell.Style.Equal(styles.SurfaceBar), "empty MRU leaves a complete SurfaceBar")
1471+ }
1472+
1473+ single := make([]renderer.Cell, 20)
1474+ drawStatusBarState(single, barState{status: statusSnapshot{session: "current"}, mru: []recentSession{{name: "only"}}}, styles)
1475+ only := strings.Index(rowText(single), "only")
1476+ require.GreaterOrEqual(t, only, 0)
1477+ require.True(t, single[only].Style.Equal(styles.SurfaceRecent), "a singleton MRU must retain the 22 percent recent surface")
1478+ })
1479+
1480+ t.Run("attention pulse preserves its active or inactive base surface", func(t *testing.T) {
1481+ row := make([]renderer.Cell, 32)
1482+ drawTopBarSnapshot(row, statusSnapshot{tabs: []statusTab{{name: "active", active: true, attention: true}, {name: "idle", attention: true}}}, 1, "", styles)
1483+ bells := make([]int, 0, 2)
1484+ for i, cell := range row {
1485+ if cell.Rune == ui.AttentionGlyph {
1486+ bells = append(bells, i)
1487+ }
1488+ }
1489+ require.Len(t, bells, 2)
1490+ require.Equal(t, styles.SurfaceActive.BackgroundRGB, row[bells[0]].Style.BackgroundRGB, "active must outrank attention")
1491+ require.Equal(t, styles.SurfaceInactive.BackgroundRGB, row[bells[1]].Style.BackgroundRGB, "attention must preserve inactive base surface")
1492+ })
1493+}
1494+
1406 func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) {1495 func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) {
1407 p1, releasePTY1 := newBlockingPTY(t)1496 p1, releasePTY1 := newBlockingPTY(t)
1408 p2, releasePTY2 := newBlockingPTY(t)1497 p2, releasePTY2 := newBlockingPTY(t)
M internal/usecase/theme/resolved_styles.go +14 -2
@@ -61,7 +61,7 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles {
6161 CopyStatus: ramp.SurfaceBar,
6262 SearchSelection: EmphasisStyle(ramp.SurfaceActive, t),
6363 }
64- return withLegacyAliases(styles, t)
64+ return withMRUStyles(withLegacyAliases(styles, t), ramp)
6565 }
6666
6767 func secondarySurface(base renderer.Style) renderer.Style {
@@ -137,7 +137,19 @@ func neutralStyles(t Theme) Styles {
137137 CopyStatus: accent,
138138 SearchSelection: accent,
139139 }
140- return withLegacyAliases(styles, t)
140+ styles = withLegacyAliases(styles, t)
141+ // Neutral and indexed fallbacks retain their existing non-RGB hierarchy;
142+ // MRU entries deliberately share the neutral recent surface.
143+ return withMRUStyles(styles, Ramp{SurfaceBar: styles.SurfaceBar, SurfaceRecent: styles.SurfaceRecent})
144+}
145+
146+func withMRUStyles(styles Styles, ramp Ramp) Styles {
147+ for count := 1; count <= len(styles.mruStyles); count++ {
148+ for index := 0; index < count; index++ {
149+ styles.mruStyles[count-1][index] = MRUStyle(ramp, index, count)
150+ }
151+ }
152+ return styles
141153 }
142154
143155 func neutralBorderStyle(t Theme) renderer.Style {
@@ -61,7 +61,7 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles {
61 CopyStatus: ramp.SurfaceBar,61 CopyStatus: ramp.SurfaceBar,
62 SearchSelection: EmphasisStyle(ramp.SurfaceActive, t),62 SearchSelection: EmphasisStyle(ramp.SurfaceActive, t),
63 }63 }
64- return withLegacyAliases(styles, t)64+ return withMRUStyles(withLegacyAliases(styles, t), ramp)
65 }65 }
66 66
67 func secondarySurface(base renderer.Style) renderer.Style {67 func secondarySurface(base renderer.Style) renderer.Style {
@@ -137,7 +137,19 @@ func neutralStyles(t Theme) Styles {
137 CopyStatus: accent,137 CopyStatus: accent,
138 SearchSelection: accent,138 SearchSelection: accent,
139 }139 }
140- return withLegacyAliases(styles, t)140+ styles = withLegacyAliases(styles, t)
141+ // Neutral and indexed fallbacks retain their existing non-RGB hierarchy;
142+ // MRU entries deliberately share the neutral recent surface.
143+ return withMRUStyles(styles, Ramp{SurfaceBar: styles.SurfaceBar, SurfaceRecent: styles.SurfaceRecent})
144+}
145+
146+func withMRUStyles(styles Styles, ramp Ramp) Styles {
147+ for count := 1; count <= len(styles.mruStyles); count++ {
148+ for index := 0; index < count; index++ {
149+ styles.mruStyles[count-1][index] = MRUStyle(ramp, index, count)
150+ }
151+ }
152+ return styles
141 }153 }
142 154
143 func neutralBorderStyle(t Theme) renderer.Style {155 func neutralBorderStyle(t Theme) renderer.Style {
M internal/usecase/theme/styles.go +20 -0
@@ -62,6 +62,10 @@ func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style {
6262 // Styles is the complete semantic set of terminal chrome styles. The legacy
6363 // aliases remain until daemon renderers consume the semantic fields directly.
6464 type Styles struct {
65+ // mruStyles are all position/count combinations that status composition
66+ // can display. They keep OKLab interpolation out of render paths.
67+ mruStyles [9][9]renderer.Style
68+
6569 SurfaceBar renderer.Style
6670 SurfaceInactive renderer.Style
6771 SurfaceRecent renderer.Style
@@ -103,3 +107,19 @@ type Styles struct {
103107 func NewStyles(t Theme) Styles {
104108 return Resolve(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
105109 }
110+
111+// MRUStyle returns the position-dependent recent-session surface. Color
112+// interpolation stays owned by theme; renderers consume only this cached
113+// semantic style snapshot.
114+func (s Styles) MRUStyle(index, count int) renderer.Style {
115+ if count <= 0 || count > len(s.mruStyles) {
116+ return s.MRURecent
117+ }
118+ if index < 0 {
119+ index = 0
120+ }
121+ if index >= count {
122+ index = count - 1
123+ }
124+ return s.mruStyles[count-1][index]
125+}
@@ -62,6 +62,10 @@ func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style {
62 // Styles is the complete semantic set of terminal chrome styles. The legacy62 // Styles is the complete semantic set of terminal chrome styles. The legacy
63 // aliases remain until daemon renderers consume the semantic fields directly.63 // aliases remain until daemon renderers consume the semantic fields directly.
64 type Styles struct {64 type Styles struct {
65+ // mruStyles are all position/count combinations that status composition
66+ // can display. They keep OKLab interpolation out of render paths.
67+ mruStyles [9][9]renderer.Style
68+
65 SurfaceBar renderer.Style69 SurfaceBar renderer.Style
66 SurfaceInactive renderer.Style70 SurfaceInactive renderer.Style
67 SurfaceRecent renderer.Style71 SurfaceRecent renderer.Style
@@ -103,3 +107,19 @@ type Styles struct {
103 func NewStyles(t Theme) Styles {107 func NewStyles(t Theme) Styles {
104 return Resolve(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles108 return Resolve(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
105 }109 }
110+
111+// MRUStyle returns the position-dependent recent-session surface. Color
112+// interpolation stays owned by theme; renderers consume only this cached
113+// semantic style snapshot.
114+func (s Styles) MRUStyle(index, count int) renderer.Style {
115+ if count <= 0 || count > len(s.mruStyles) {
116+ return s.MRURecent
117+ }
118+ if index < 0 {
119+ index = 0
120+ }
121+ if index >= count {
122+ index = count - 1
123+ }
124+ return s.mruStyles[count-1][index]
125+}

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