detached ec95daf

feat(config): add terminal accent policy

commit
ec95daf46353d63ec5fa466cfbe3edf8395de0cc
author
brice · · 2026-07-18 06:05 UTC
parent
48dc9c8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +213 -3
M docs/configuration.md +9 -3
@@ -5,8 +5,10 @@ vev reads `~/.config/vev/config` (`$XDG_CONFIG_HOME` respected). No file means d
55 ```text
66 # Theme: auto follows the client; dark/light use neutral built-in defaults.
77 theme = auto
8-# In auto mode, inherit the terminal's ANSI blue for chrome accents.
8+# In auto mode with palette inheritance enabled, infer a terminal accent.
99 theme.palette = on
10+# Accent policy: auto or one exact ANSI slot number from 0 through 15.
11+theme.accent = auto
1012
1113 # Palette placement: auto, center, top-left, top, top-right, left, right,
1214 # bottom-left, bottom, or bottom-right.
@@ -66,11 +68,15 @@ code.rename-tab = RNT
6668 code.detach = DET
6769 ```
6870
69-Invalid values log a warning and keep the last valid value.
71+Invalid values log a warning and resolve that setting to its default on both initial load and reload.
7072
7173 ## Theme
7274
73-With `theme = auto`, vev follows the terminal's foreground, background, light/dark scheme, and ANSI palette as the terminal reports changes. `theme.palette = on` is the default and uses the terminal's blue for chrome accents when available; vev falls back to neutral foreground/background blends when the color is unavailable or lacks contrast. Set it to `off` for blend-only styling. Forced `theme = dark` or `theme = light` modes are always blend-only.
75+With `theme = auto`, vev follows the terminal's foreground, background, light/dark scheme, and ANSI palette as the terminal reports changes. `theme.palette = on` is the default. `theme.accent = auto` selects automatic terminal accent inference; `theme.accent = 0` through `theme.accent = 15` selects exactly that ANSI slot. Arbitrary RGB values and `off` are not accepted for `theme.accent`.
76+
77+Slots `0`, `7`, `8`, and `15` are valid explicit selections, but log a warning because conventional neutral slots may provide little or no accent separation. An invalid accent value logs a warning and falls back to `auto`, including on hot reload.
78+
79+`theme.palette = off` is authoritative: it keeps exact neutral foreground/background rendering and ignores `theme.accent`. Forced `theme = dark` or `theme = light` is also neutral and ignores both palette and accent policy.
7480
7581 ## Bindings
7682
@@ -5,8 +5,10 @@ vev reads `~/.config/vev/config` (`$XDG_CONFIG_HOME` respected). No file means d
5 ```text5 ```text
6 # Theme: auto follows the client; dark/light use neutral built-in defaults.6 # Theme: auto follows the client; dark/light use neutral built-in defaults.
7 theme = auto7 theme = auto
8-# In auto mode, inherit the terminal's ANSI blue for chrome accents.8+# In auto mode with palette inheritance enabled, infer a terminal accent.
9 theme.palette = on9 theme.palette = on
10+# Accent policy: auto or one exact ANSI slot number from 0 through 15.
11+theme.accent = auto
10 12
11 # Palette placement: auto, center, top-left, top, top-right, left, right,13 # Palette placement: auto, center, top-left, top, top-right, left, right,
12 # bottom-left, bottom, or bottom-right.14 # bottom-left, bottom, or bottom-right.
@@ -66,11 +68,15 @@ code.rename-tab = RNT
66 code.detach = DET68 code.detach = DET
67 ```69 ```
68 70
69-Invalid values log a warning and keep the last valid value.71+Invalid values log a warning and resolve that setting to its default on both initial load and reload.
70 72
71 ## Theme73 ## Theme
72 74
73-With `theme = auto`, vev follows the terminal's foreground, background, light/dark scheme, and ANSI palette as the terminal reports changes. `theme.palette = on` is the default and uses the terminal's blue for chrome accents when available; vev falls back to neutral foreground/background blends when the color is unavailable or lacks contrast. Set it to `off` for blend-only styling. Forced `theme = dark` or `theme = light` modes are always blend-only.75+With `theme = auto`, vev follows the terminal's foreground, background, light/dark scheme, and ANSI palette as the terminal reports changes. `theme.palette = on` is the default. `theme.accent = auto` selects automatic terminal accent inference; `theme.accent = 0` through `theme.accent = 15` selects exactly that ANSI slot. Arbitrary RGB values and `off` are not accepted for `theme.accent`.
76+
77+Slots `0`, `7`, `8`, and `15` are valid explicit selections, but log a warning because conventional neutral slots may provide little or no accent separation. An invalid accent value logs a warning and falls back to `auto`, including on hot reload.
78+
79+`theme.palette = off` is authoritative: it keeps exact neutral foreground/background rendering and ignores `theme.accent`. Forced `theme = dark` or `theme = light` is also neutral and ignores both palette and accent policy.
74 80
75 ## Bindings81 ## Bindings
76 82
M internal/adapters/config/config.go +6 -0
@@ -75,6 +75,12 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
7575 continue
7676 }
7777 cfg.ThemePalette = on
78+ case key == "theme.accent":
79+ accent, warning := parseThemeAccent(value)
80+ cfg.ThemeAccent = accent
81+ if warning != "" {
82+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: warning})
83+ }
7884 case key == "bar.top-right":
7985 command, ok := parseBarCommand(value)
8086 if !ok {
@@ -75,6 +75,12 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
75 continue75 continue
76 }76 }
77 cfg.ThemePalette = on77 cfg.ThemePalette = on
78+ case key == "theme.accent":
79+ accent, warning := parseThemeAccent(value)
80+ cfg.ThemeAccent = accent
81+ if warning != "" {
82+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: warning})
83+ }
78 case key == "bar.top-right":84 case key == "bar.top-right":
79 command, ok := parseBarCommand(value)85 command, ok := parseBarCommand(value)
80 if !ok {86 if !ok {
A internal/adapters/config/theme_accent.go +27 -0
@@ -0,0 +1,27 @@
1+package config
2+
3+import (
4+ "fmt"
5+ "strconv"
6+
7+ "github.com/bnema/vev/internal/domain"
8+)
9+
10+func parseThemeAccent(value string) (domain.ThemeAccent, string) {
11+ if value == "auto" {
12+ return domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, ""
13+ }
14+
15+ slot, err := strconv.Atoi(value)
16+ if err != nil || slot < 0 || slot > 15 || strconv.Itoa(slot) != value {
17+ return domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, fmt.Sprintf("invalid theme.accent %q", value)
18+ }
19+
20+ accent := domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: uint8(slot)}
21+ switch slot {
22+ case 0, 7, 8, 15:
23+ return accent, fmt.Sprintf("theme.accent slot %d is conventionally neutral", slot)
24+ default:
25+ return accent, ""
26+ }
27+}
@@ -0,0 +1,27 @@
1+package config
2+
3+import (
4+ "fmt"
5+ "strconv"
6+
7+ "github.com/bnema/vev/internal/domain"
8+)
9+
10+func parseThemeAccent(value string) (domain.ThemeAccent, string) {
11+ if value == "auto" {
12+ return domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, ""
13+ }
14+
15+ slot, err := strconv.Atoi(value)
16+ if err != nil || slot < 0 || slot > 15 || strconv.Itoa(slot) != value {
17+ return domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, fmt.Sprintf("invalid theme.accent %q", value)
18+ }
19+
20+ accent := domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: uint8(slot)}
21+ switch slot {
22+ case 0, 7, 8, 15:
23+ return accent, fmt.Sprintf("theme.accent slot %d is conventionally neutral", slot)
24+ default:
25+ return accent, ""
26+ }
27+}
A internal/adapters/config/theme_accent_test.go +150 -0
@@ -0,0 +1,150 @@
1+package config
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestParseThemeAccent(t *testing.T) {
17+ t.Parallel()
18+
19+ tests := []struct {
20+ name string
21+ input string
22+ want domain.ThemeAccent
23+ warnings []domain.Warning
24+ }{
25+ {
26+ name: "auto",
27+ input: "theme.accent = auto\n",
28+ want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
29+ },
30+ {
31+ name: "slot zero is valid but warns",
32+ input: "theme.accent = 0\n",
33+ want: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 0},
34+ warnings: []domain.Warning{
35+ {Line: 1, Msg: "theme.accent slot 0 is conventionally neutral"},
36+ },
37+ },
38+ {
39+ name: "slot fifteen is valid but warns",
40+ input: "theme.accent = 15\n",
41+ want: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 15},
42+ warnings: []domain.Warning{
43+ {Line: 1, Msg: "theme.accent slot 15 is conventionally neutral"},
44+ },
45+ },
46+ {
47+ name: "chromatic slot",
48+ input: "theme.accent = 2\n",
49+ want: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2},
50+ },
51+ {
52+ name: "negative slot falls back to auto",
53+ input: "theme.accent = -1\n",
54+ want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
55+ warnings: []domain.Warning{
56+ {Line: 1, Msg: "invalid theme.accent \"-1\""},
57+ },
58+ },
59+ {
60+ name: "out of range slot falls back to auto",
61+ input: "theme.accent = 16\n",
62+ want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
63+ warnings: []domain.Warning{
64+ {Line: 1, Msg: "invalid theme.accent \"16\""},
65+ },
66+ },
67+ {
68+ name: "non numeric value falls back to auto",
69+ input: "theme.accent = teal\n",
70+ want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
71+ warnings: []domain.Warning{
72+ {Line: 1, Msg: "invalid theme.accent \"teal\""},
73+ },
74+ },
75+ {
76+ name: "non canonical slot falls back to auto",
77+ input: "theme.accent = 02\n",
78+ want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
79+ warnings: []domain.Warning{
80+ {Line: 1, Msg: "invalid theme.accent \"02\""},
81+ },
82+ },
83+ {
84+ name: "case sensitive auto falls back to auto",
85+ input: "theme.accent = AUTO\n",
86+ want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
87+ warnings: []domain.Warning{
88+ {Line: 1, Msg: "invalid theme.accent \"AUTO\""},
89+ },
90+ },
91+ }
92+
93+ for _, tt := range tests {
94+ t.Run(tt.name, func(t *testing.T) {
95+ t.Parallel()
96+
97+ cfg, warnings, err := Parse(strings.NewReader(tt.input))
98+ require.NoError(t, err)
99+ require.Equal(t, tt.want, cfg.ThemeAccent)
100+ require.Equal(t, tt.warnings, warnings)
101+ })
102+ }
103+}
104+
105+func TestDefaultsThemeAccent(t *testing.T) {
106+ t.Parallel()
107+
108+ require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, domain.Defaults().ThemeAccent)
109+}
110+
111+func TestWatchThemeAccentInvalidReloadFallsBackToAuto(t *testing.T) {
112+ t.Parallel()
113+
114+ path := filepath.Join(t.TempDir(), "config")
115+ require.NoError(t, os.WriteFile(path, []byte("theme.accent = 2\n"), 0o600))
116+ initial, warnings, err := Load(path)
117+ require.NoError(t, err)
118+ require.Empty(t, warnings)
119+ require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}, initial.ThemeAccent)
120+
121+ clk := newFakeClock(time.Unix(0, 0))
122+ ctx, cancel := context.WithCancel(context.Background())
123+ defer cancel()
124+ type change struct {
125+ cfg domain.Config
126+ warnings []domain.Warning
127+ }
128+ changes := make(chan change, 1)
129+ done := make(chan error, 1)
130+ go func() {
131+ done <- Watch(ctx, clk, path, func(cfg domain.Config, warnings []domain.Warning) {
132+ changes <- change{cfg: cfg, warnings: warnings}
133+ })
134+ }()
135+
136+ clk.waitForTimers(t, 1)
137+ require.NoError(t, os.WriteFile(path, []byte("theme.accent = invalid\n"), 0o600))
138+ clk.advance(2 * time.Second)
139+
140+ select {
141+ case got := <-changes:
142+ require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, got.cfg.ThemeAccent)
143+ require.Equal(t, []domain.Warning{{Line: 1, Msg: "invalid theme.accent \"invalid\""}}, got.warnings)
144+ case <-time.After(2 * time.Second):
145+ t.Fatal("timed out waiting for config reload")
146+ }
147+
148+ cancel()
149+ require.True(t, errors.Is(<-done, context.Canceled))
150+}
@@ -0,0 +1,150 @@
1+package config
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestParseThemeAccent(t *testing.T) {
17+ t.Parallel()
18+
19+ tests := []struct {
20+ name string
21+ input string
22+ want domain.ThemeAccent
23+ warnings []domain.Warning
24+ }{
25+ {
26+ name: "auto",
27+ input: "theme.accent = auto\n",
28+ want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
29+ },
30+ {
31+ name: "slot zero is valid but warns",
32+ input: "theme.accent = 0\n",
33+ want: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 0},
34+ warnings: []domain.Warning{
35+ {Line: 1, Msg: "theme.accent slot 0 is conventionally neutral"},
36+ },
37+ },
38+ {
39+ name: "slot fifteen is valid but warns",
40+ input: "theme.accent = 15\n",
41+ want: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 15},
42+ warnings: []domain.Warning{
43+ {Line: 1, Msg: "theme.accent slot 15 is conventionally neutral"},
44+ },
45+ },
46+ {
47+ name: "chromatic slot",
48+ input: "theme.accent = 2\n",
49+ want: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2},
50+ },
51+ {
52+ name: "negative slot falls back to auto",
53+ input: "theme.accent = -1\n",
54+ want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
55+ warnings: []domain.Warning{
56+ {Line: 1, Msg: "invalid theme.accent \"-1\""},
57+ },
58+ },
59+ {
60+ name: "out of range slot falls back to auto",
61+ input: "theme.accent = 16\n",
62+ want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
63+ warnings: []domain.Warning{
64+ {Line: 1, Msg: "invalid theme.accent \"16\""},
65+ },
66+ },
67+ {
68+ name: "non numeric value falls back to auto",
69+ input: "theme.accent = teal\n",
70+ want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
71+ warnings: []domain.Warning{
72+ {Line: 1, Msg: "invalid theme.accent \"teal\""},
73+ },
74+ },
75+ {
76+ name: "non canonical slot falls back to auto",
77+ input: "theme.accent = 02\n",
78+ want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
79+ warnings: []domain.Warning{
80+ {Line: 1, Msg: "invalid theme.accent \"02\""},
81+ },
82+ },
83+ {
84+ name: "case sensitive auto falls back to auto",
85+ input: "theme.accent = AUTO\n",
86+ want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
87+ warnings: []domain.Warning{
88+ {Line: 1, Msg: "invalid theme.accent \"AUTO\""},
89+ },
90+ },
91+ }
92+
93+ for _, tt := range tests {
94+ t.Run(tt.name, func(t *testing.T) {
95+ t.Parallel()
96+
97+ cfg, warnings, err := Parse(strings.NewReader(tt.input))
98+ require.NoError(t, err)
99+ require.Equal(t, tt.want, cfg.ThemeAccent)
100+ require.Equal(t, tt.warnings, warnings)
101+ })
102+ }
103+}
104+
105+func TestDefaultsThemeAccent(t *testing.T) {
106+ t.Parallel()
107+
108+ require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, domain.Defaults().ThemeAccent)
109+}
110+
111+func TestWatchThemeAccentInvalidReloadFallsBackToAuto(t *testing.T) {
112+ t.Parallel()
113+
114+ path := filepath.Join(t.TempDir(), "config")
115+ require.NoError(t, os.WriteFile(path, []byte("theme.accent = 2\n"), 0o600))
116+ initial, warnings, err := Load(path)
117+ require.NoError(t, err)
118+ require.Empty(t, warnings)
119+ require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}, initial.ThemeAccent)
120+
121+ clk := newFakeClock(time.Unix(0, 0))
122+ ctx, cancel := context.WithCancel(context.Background())
123+ defer cancel()
124+ type change struct {
125+ cfg domain.Config
126+ warnings []domain.Warning
127+ }
128+ changes := make(chan change, 1)
129+ done := make(chan error, 1)
130+ go func() {
131+ done <- Watch(ctx, clk, path, func(cfg domain.Config, warnings []domain.Warning) {
132+ changes <- change{cfg: cfg, warnings: warnings}
133+ })
134+ }()
135+
136+ clk.waitForTimers(t, 1)
137+ require.NoError(t, os.WriteFile(path, []byte("theme.accent = invalid\n"), 0o600))
138+ clk.advance(2 * time.Second)
139+
140+ select {
141+ case got := <-changes:
142+ require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, got.cfg.ThemeAccent)
143+ require.Equal(t, []domain.Warning{{Line: 1, Msg: "invalid theme.accent \"invalid\""}}, got.warnings)
144+ case <-time.After(2 * time.Second):
145+ t.Fatal("timed out waiting for config reload")
146+ }
147+
148+ cancel()
149+ require.True(t, errors.Is(<-done, context.Canceled))
150+}
M internal/domain/config.go +2 -0
@@ -82,6 +82,7 @@ type TabsConfig struct {
8282 type Config struct {
8383 Theme ThemeMode
8484 ThemePalette bool
85+ ThemeAccent ThemeAccent
8586 Bar BarConfig
8687 BindingEntries []ConfigEntry
8788 Codes map[string]string
@@ -104,6 +105,7 @@ func Defaults() Config {
104105 return Config{
105106 Theme: ThemeAuto,
106107 ThemePalette: true,
108+ ThemeAccent: ThemeAccent{Mode: ThemeAccentAuto},
107109 Bar: BarConfig{
108110 TopRight: "vev-bar-top-right",
109111 BottomRight: "vev-bar-bottom-right",
@@ -82,6 +82,7 @@ type TabsConfig struct {
82 type Config struct {82 type Config struct {
83 Theme ThemeMode83 Theme ThemeMode
84 ThemePalette bool84 ThemePalette bool
85+ ThemeAccent ThemeAccent
85 Bar BarConfig86 Bar BarConfig
86 BindingEntries []ConfigEntry87 BindingEntries []ConfigEntry
87 Codes map[string]string88 Codes map[string]string
@@ -104,6 +105,7 @@ func Defaults() Config {
104 return Config{105 return Config{
105 Theme: ThemeAuto,106 Theme: ThemeAuto,
106 ThemePalette: true,107 ThemePalette: true,
108+ ThemeAccent: ThemeAccent{Mode: ThemeAccentAuto},
107 Bar: BarConfig{109 Bar: BarConfig{
108 TopRight: "vev-bar-top-right",110 TopRight: "vev-bar-top-right",
109 BottomRight: "vev-bar-bottom-right",111 BottomRight: "vev-bar-bottom-right",
A internal/domain/theme_accent.go +19 -0
@@ -0,0 +1,19 @@
1+package domain
2+
3+// ThemeAccentMode selects whether vev infers a terminal accent or uses one
4+// explicit ANSI slot.
5+type ThemeAccentMode uint8
6+
7+const (
8+ // ThemeAccentAuto infers an accent from the terminal palette.
9+ ThemeAccentAuto ThemeAccentMode = iota
10+ // ThemeAccentSlot selects one explicit ANSI palette slot.
11+ ThemeAccentSlot
12+)
13+
14+// ThemeAccent is the parsed terminal accent policy. Slot is meaningful only
15+// when Mode is ThemeAccentSlot.
16+type ThemeAccent struct {
17+ Mode ThemeAccentMode
18+ Slot uint8
19+}
@@ -0,0 +1,19 @@
1+package domain
2+
3+// ThemeAccentMode selects whether vev infers a terminal accent or uses one
4+// explicit ANSI slot.
5+type ThemeAccentMode uint8
6+
7+const (
8+ // ThemeAccentAuto infers an accent from the terminal palette.
9+ ThemeAccentAuto ThemeAccentMode = iota
10+ // ThemeAccentSlot selects one explicit ANSI palette slot.
11+ ThemeAccentSlot
12+)
13+
14+// ThemeAccent is the parsed terminal accent policy. Slot is meaningful only
15+// when Mode is ThemeAccentSlot.
16+type ThemeAccent struct {
17+ Mode ThemeAccentMode
18+ Slot uint8
19+}

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