detached 75e21f8

feat(daemon): gate terminal palette through effective theme

commit
75e21f896377904ec3d1685e37431a3002dc024f
author
brice · · 2026-07-17 08:26 UTC
parent
0b10c36
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +112 -10
M internal/usecase/daemon/client.go +10 -8
@@ -698,14 +698,16 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
698698
699699 func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) {
700700 clientTheme := themeui.Theme{
701- Foreground: msg.Foreground,
702- Background: msg.Background,
703- HasFG: msg.HasForeground,
704- HasBG: msg.HasBackground,
705- TrueColor: msg.TrueColor,
706- Known: msg.HasForeground && msg.HasBackground,
707- SchemeKnown: msg.SchemeKnown,
708- Light: msg.Light,
701+ Foreground: msg.Foreground,
702+ Background: msg.Background,
703+ Palette: msg.Palette,
704+ PaletteKnown: msg.PaletteKnown,
705+ HasFG: msg.HasForeground,
706+ HasBG: msg.HasBackground,
707+ TrueColor: msg.TrueColor,
708+ Known: msg.HasForeground && msg.HasBackground,
709+ SchemeKnown: msg.SchemeKnown,
710+ Light: msg.Light,
709711 }
710712 ac.setClientTheme(clientTheme)
711713 t := d.effectiveTheme(clientTheme)
@@ -698,14 +698,16 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
698 698
699 func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) {699 func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) {
700 clientTheme := themeui.Theme{700 clientTheme := themeui.Theme{
701- Foreground: msg.Foreground,701+ Foreground: msg.Foreground,
702- Background: msg.Background,702+ Background: msg.Background,
703- HasFG: msg.HasForeground,703+ Palette: msg.Palette,
704- HasBG: msg.HasBackground,704+ PaletteKnown: msg.PaletteKnown,
705- TrueColor: msg.TrueColor,705+ HasFG: msg.HasForeground,
706- Known: msg.HasForeground && msg.HasBackground,706+ HasBG: msg.HasBackground,
707- SchemeKnown: msg.SchemeKnown,707+ TrueColor: msg.TrueColor,
708- Light: msg.Light,708+ Known: msg.HasForeground && msg.HasBackground,
709+ SchemeKnown: msg.SchemeKnown,
710+ Light: msg.Light,
709 }711 }
710 ac.setClientTheme(clientTheme)712 ac.setClientTheme(clientTheme)
711 t := d.effectiveTheme(clientTheme)713 t := d.effectiveTheme(clientTheme)
M internal/usecase/daemon/config.go +2 -0
@@ -36,6 +36,7 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
3636 tabs := cfg.Tabs
3737 d.tabsConfig.Store(&tabs)
3838 d.themeMode.Store(uint32(cfg.Theme))
39+ d.themePaletteOff.Store(!cfg.ThemePalette)
3940 if d.barScripts != nil {
4041 d.barScripts.mu.Lock()
4142 d.barScripts.initLocked()
@@ -246,6 +247,7 @@ func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme {
246247 case domain.ThemeLight:
247248 return theme.BuiltinLight
248249 default:
250+ clientTheme.UsePalette = !d.themePaletteOff.Load()
249251 return clientTheme
250252 }
251253 }
@@ -36,6 +36,7 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
36 tabs := cfg.Tabs36 tabs := cfg.Tabs
37 d.tabsConfig.Store(&tabs)37 d.tabsConfig.Store(&tabs)
38 d.themeMode.Store(uint32(cfg.Theme))38 d.themeMode.Store(uint32(cfg.Theme))
39+ d.themePaletteOff.Store(!cfg.ThemePalette)
39 if d.barScripts != nil {40 if d.barScripts != nil {
40 d.barScripts.mu.Lock()41 d.barScripts.mu.Lock()
41 d.barScripts.initLocked()42 d.barScripts.initLocked()
@@ -246,6 +247,7 @@ func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme {
246 case domain.ThemeLight:247 case domain.ThemeLight:
247 return theme.BuiltinLight248 return theme.BuiltinLight
248 default:249 default:
250+ clientTheme.UsePalette = !d.themePaletteOff.Load()
249 return clientTheme251 return clientTheme
250 }252 }
251 }253 }
M internal/usecase/daemon/config_test.go +95 -0
@@ -8,6 +8,8 @@ import (
88 "github.com/bnema/vev/internal/domain"
99 "github.com/bnema/vev/internal/ports"
1010 "github.com/bnema/vev/internal/usecase/keys"
11+ themeui "github.com/bnema/vev/internal/usecase/theme"
12+ "github.com/bnema/vev/pkg/renderer"
1113 )
1214
1315 type captureKeyHandler struct {
@@ -50,6 +52,99 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
5052 require.Equal(t, "NT", cmd.Code)
5153 }
5254
55+func TestApplyThemeCopiesTerminalPaletteAndHotReloadGatesIt(t *testing.T) {
56+ p, release := newBlockingPTY(t)
57+ defer release()
58+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
59+
60+ clientPalette := [16]renderer.RGB{}
61+ clientPalette[1] = renderer.RGB{R: 1, G: 2, B: 3}
62+ clientPalette[12] = renderer.RGB{R: 4, G: 5, B: 6}
63+ d.applyTheme(sess, ac, ports.Theme{
64+ HasForeground: true,
65+ Foreground: renderer.RGB{R: 7, G: 8, B: 9},
66+ HasBackground: true,
67+ Background: renderer.RGB{R: 10, G: 11, B: 12},
68+ Palette: clientPalette,
69+ PaletteKnown: 1<<1 | 1<<12,
70+ })
71+
72+ require.Equal(t, clientPalette, ac.getClientTheme().Palette)
73+ require.Equal(t, uint16(1<<1|1<<12), ac.getClientTheme().PaletteKnown)
74+ require.True(t, ac.getTheme().UsePalette)
75+
76+ cfg := domain.Defaults()
77+ cfg.ThemePalette = false
78+ d.ApplyConfig(cfg)
79+
80+ // ApplyConfig reuses the live client report and repaints without requiring
81+ // a reconnect; only palette inheritance changes.
82+ reloaded := ac.getTheme()
83+ require.Equal(t, clientPalette, reloaded.Palette)
84+ require.Equal(t, uint16(1<<1|1<<12), reloaded.PaletteKnown)
85+ require.False(t, reloaded.UsePalette)
86+}
87+
88+func TestEffectiveThemePaletteGate(t *testing.T) {
89+ clientPalette := [16]renderer.RGB{}
90+ clientPalette[3] = renderer.RGB{R: 1, G: 2, B: 3}
91+ clientTheme := themeui.Theme{
92+ Palette: clientPalette,
93+ PaletteKnown: 1 << 3,
94+ }
95+
96+ tests := []struct {
97+ name string
98+ config domain.Config
99+ want themeui.Theme
100+ usePalette bool
101+ }{
102+ {
103+ name: "auto inherits terminal palette by default",
104+ config: domain.Defaults(),
105+ want: clientTheme,
106+ usePalette: true,
107+ },
108+ {
109+ name: "auto disables terminal palette",
110+ config: func() domain.Config {
111+ cfg := domain.Defaults()
112+ cfg.ThemePalette = false
113+ return cfg
114+ }(),
115+ want: clientTheme,
116+ },
117+ {
118+ name: "forced dark uses palette free builtin",
119+ config: domain.Config{
120+ Theme: domain.ThemeDark,
121+ ThemePalette: true,
122+ },
123+ want: themeui.BuiltinDark,
124+ },
125+ {
126+ name: "forced light uses palette free builtin",
127+ config: domain.Config{
128+ Theme: domain.ThemeLight,
129+ ThemePalette: true,
130+ },
131+ want: themeui.BuiltinLight,
132+ },
133+ }
134+
135+ for _, tt := range tests {
136+ t.Run(tt.name, func(t *testing.T) {
137+ d := newTestDaemon(t, nil, stubClock{})
138+ d.ApplyConfig(tt.config)
139+
140+ got := d.effectiveTheme(clientTheme)
141+ want := tt.want
142+ want.UsePalette = tt.usePalette
143+ require.Equal(t, want, got)
144+ })
145+ }
146+}
147+
53148 func TestApplyConfigPublishesCopyConfig(t *testing.T) {
54149 tests := []struct {
55150 name string
@@ -8,6 +8,8 @@ import (
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/usecase/keys"10 "github.com/bnema/vev/internal/usecase/keys"
11+ themeui "github.com/bnema/vev/internal/usecase/theme"
12+ "github.com/bnema/vev/pkg/renderer"
11 )13 )
12 14
13 type captureKeyHandler struct {15 type captureKeyHandler struct {
@@ -50,6 +52,99 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
50 require.Equal(t, "NT", cmd.Code)52 require.Equal(t, "NT", cmd.Code)
51 }53 }
52 54
55+func TestApplyThemeCopiesTerminalPaletteAndHotReloadGatesIt(t *testing.T) {
56+ p, release := newBlockingPTY(t)
57+ defer release()
58+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
59+
60+ clientPalette := [16]renderer.RGB{}
61+ clientPalette[1] = renderer.RGB{R: 1, G: 2, B: 3}
62+ clientPalette[12] = renderer.RGB{R: 4, G: 5, B: 6}
63+ d.applyTheme(sess, ac, ports.Theme{
64+ HasForeground: true,
65+ Foreground: renderer.RGB{R: 7, G: 8, B: 9},
66+ HasBackground: true,
67+ Background: renderer.RGB{R: 10, G: 11, B: 12},
68+ Palette: clientPalette,
69+ PaletteKnown: 1<<1 | 1<<12,
70+ })
71+
72+ require.Equal(t, clientPalette, ac.getClientTheme().Palette)
73+ require.Equal(t, uint16(1<<1|1<<12), ac.getClientTheme().PaletteKnown)
74+ require.True(t, ac.getTheme().UsePalette)
75+
76+ cfg := domain.Defaults()
77+ cfg.ThemePalette = false
78+ d.ApplyConfig(cfg)
79+
80+ // ApplyConfig reuses the live client report and repaints without requiring
81+ // a reconnect; only palette inheritance changes.
82+ reloaded := ac.getTheme()
83+ require.Equal(t, clientPalette, reloaded.Palette)
84+ require.Equal(t, uint16(1<<1|1<<12), reloaded.PaletteKnown)
85+ require.False(t, reloaded.UsePalette)
86+}
87+
88+func TestEffectiveThemePaletteGate(t *testing.T) {
89+ clientPalette := [16]renderer.RGB{}
90+ clientPalette[3] = renderer.RGB{R: 1, G: 2, B: 3}
91+ clientTheme := themeui.Theme{
92+ Palette: clientPalette,
93+ PaletteKnown: 1 << 3,
94+ }
95+
96+ tests := []struct {
97+ name string
98+ config domain.Config
99+ want themeui.Theme
100+ usePalette bool
101+ }{
102+ {
103+ name: "auto inherits terminal palette by default",
104+ config: domain.Defaults(),
105+ want: clientTheme,
106+ usePalette: true,
107+ },
108+ {
109+ name: "auto disables terminal palette",
110+ config: func() domain.Config {
111+ cfg := domain.Defaults()
112+ cfg.ThemePalette = false
113+ return cfg
114+ }(),
115+ want: clientTheme,
116+ },
117+ {
118+ name: "forced dark uses palette free builtin",
119+ config: domain.Config{
120+ Theme: domain.ThemeDark,
121+ ThemePalette: true,
122+ },
123+ want: themeui.BuiltinDark,
124+ },
125+ {
126+ name: "forced light uses palette free builtin",
127+ config: domain.Config{
128+ Theme: domain.ThemeLight,
129+ ThemePalette: true,
130+ },
131+ want: themeui.BuiltinLight,
132+ },
133+ }
134+
135+ for _, tt := range tests {
136+ t.Run(tt.name, func(t *testing.T) {
137+ d := newTestDaemon(t, nil, stubClock{})
138+ d.ApplyConfig(tt.config)
139+
140+ got := d.effectiveTheme(clientTheme)
141+ want := tt.want
142+ want.UsePalette = tt.usePalette
143+ require.Equal(t, want, got)
144+ })
145+ }
146+}
147+
53 func TestApplyConfigPublishesCopyConfig(t *testing.T) {148 func TestApplyConfigPublishesCopyConfig(t *testing.T) {
54 tests := []struct {149 tests := []struct {
55 name string150 name string
M internal/usecase/daemon/daemon.go +5 -2
@@ -145,8 +145,11 @@ type Daemon struct {
145145 paletteConfig atomic.Pointer[domain.PaletteConfig]
146146 tabsConfig atomic.Pointer[domain.TabsConfig]
147147 themeMode atomic.Uint32
148- barScripts *barScriptState
149- resumeParkGrace time.Duration
148+ // themePaletteOff is deliberately inverted so its zero value preserves the
149+ // default-on terminal palette inheritance before configuration is loaded.
150+ themePaletteOff atomic.Bool
151+ barScripts *barScriptState
152+ resumeParkGrace time.Duration
150153 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
151154 // (see clipboard.go); empty means use os.TempDir().
152155 tempDir string
@@ -145,8 +145,11 @@ type Daemon struct {
145 paletteConfig atomic.Pointer[domain.PaletteConfig]145 paletteConfig atomic.Pointer[domain.PaletteConfig]
146 tabsConfig atomic.Pointer[domain.TabsConfig]146 tabsConfig atomic.Pointer[domain.TabsConfig]
147 themeMode atomic.Uint32147 themeMode atomic.Uint32
148- barScripts *barScriptState148+ // themePaletteOff is deliberately inverted so its zero value preserves the
149- resumeParkGrace time.Duration149+ // default-on terminal palette inheritance before configuration is loaded.
150+ themePaletteOff atomic.Bool
151+ barScripts *barScriptState
152+ resumeParkGrace time.Duration
150 // tempDir overrides os.TempDir() for clipboard-image-transfer writes153 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
151 // (see clipboard.go); empty means use os.TempDir().154 // (see clipboard.go); empty means use os.TempDir().
152 tempDir string155 tempDir string

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