detached dc993a0

feat(config): add live reload and theme scheme propagation

commit
dc993a0ac4d22b3726f73662376d394d061d524b
author
brice · · 2026-07-05 16:26 UTC
committer
brice · · 2026-07-05 16:59 UTC
parent
5eef83d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
37 files · +2156 -169
A internal/adapters/config/config.go +189 -0
@@ -0,0 +1,189 @@
1+package config
2+
3+import (
4+ "bufio"
5+ "context"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "os"
10+ "strings"
11+ "time"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+)
16+
17+const pollInterval = 2 * time.Second
18+
19+// Parse reads vev's flat action = value config format. Duplicate action keys
20+// are accepted with a warning; the last value wins while first-seen action order
21+// is preserved for binding conflict resolution.
22+func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
23+ cfg := domain.Defaults()
24+ var warnings []domain.Warning
25+
26+ scanner := bufio.NewScanner(r)
27+ lineNo := 0
28+ for scanner.Scan() {
29+ lineNo++
30+ line := strings.TrimSpace(strings.TrimSuffix(scanner.Text(), "\r"))
31+ if line == "" || strings.HasPrefix(line, "#") {
32+ continue
33+ }
34+ line = stripInlineComment(line)
35+ if line == "" {
36+ continue
37+ }
38+
39+ key, value, ok := strings.Cut(line, "=")
40+ if !ok {
41+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"})
42+ continue
43+ }
44+ key = strings.TrimSpace(key)
45+ value = strings.TrimSpace(value)
46+ if key == "" {
47+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing key"})
48+ continue
49+ }
50+
51+ switch {
52+ case key == "theme":
53+ mode, ok := parseTheme(value)
54+ if !ok {
55+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid theme %q", value)})
56+ continue
57+ }
58+ cfg.Theme = mode
59+ case strings.HasPrefix(key, "code."):
60+ codeKey := strings.TrimPrefix(key, "code.")
61+ if codeKey == "" {
62+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing code slug"})
63+ continue
64+ }
65+ if _, exists := cfg.Codes[codeKey]; exists {
66+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
67+ }
68+ cfg.Codes[codeKey] = value
69+ default:
70+ if _, exists := cfg.Bindings[key]; exists {
71+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
72+ updateBindingEntry(cfg.BindingEntries, key, value)
73+ } else {
74+ cfg.BindingEntries = append(cfg.BindingEntries, domain.ConfigEntry{Key: key, Value: value})
75+ }
76+ cfg.Bindings[key] = value
77+ }
78+ }
79+ if err := scanner.Err(); err != nil {
80+ return cfg, warnings, err
81+ }
82+ return cfg, warnings, nil
83+}
84+
85+// Load reads path and parses it. Missing config files are treated as defaults.
86+func Load(path string) (domain.Config, []domain.Warning, error) {
87+ f, err := os.Open(path)
88+ if errors.Is(err, os.ErrNotExist) {
89+ return domain.Defaults(), nil, nil
90+ }
91+ if err != nil {
92+ return domain.Defaults(), nil, err
93+ }
94+ cfg, warnings, parseErr := Parse(f)
95+ if closeErr := f.Close(); parseErr == nil && closeErr != nil {
96+ return cfg, warnings, closeErr
97+ }
98+ return cfg, warnings, parseErr
99+}
100+
101+// Watch polls path for mtime/size changes every two seconds and calls onChange
102+// with each successfully loaded config. Missing files are allowed and reload to
103+// defaults when the watched state changes.
104+func Watch(ctx context.Context, clock ports.Clock, path string, onChange func(domain.Config, []domain.Warning)) error {
105+ last, err := fileStamp(path)
106+ lastErrMsg := ""
107+ if err != nil {
108+ lastErrMsg = err.Error()
109+ onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}})
110+ }
111+ timer := clock.NewTimer(pollInterval)
112+ defer timer.Stop()
113+
114+ for {
115+ select {
116+ case <-ctx.Done():
117+ return ctx.Err()
118+ case <-timer.C():
119+ current, statErr := fileStamp(path)
120+ if statErr != nil {
121+ if statErr.Error() != lastErrMsg {
122+ lastErrMsg = statErr.Error()
123+ onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}})
124+ }
125+ } else if current != last || lastErrMsg != "" {
126+ lastErrMsg = ""
127+ last = current
128+ cfg, warnings, err := Load(path)
129+ if err != nil {
130+ warnings = append(warnings, domain.Warning{Msg: err.Error()})
131+ }
132+ onChange(cfg, warnings)
133+ }
134+ timer.Reset(pollInterval)
135+ }
136+ }
137+}
138+
139+type stamp struct {
140+ modTime time.Time
141+ size int64
142+ exists bool
143+}
144+
145+func fileStamp(path string) (stamp, error) {
146+ st, err := os.Stat(path)
147+ if errors.Is(err, os.ErrNotExist) {
148+ return stamp{}, nil
149+ }
150+ if err != nil {
151+ return stamp{}, err
152+ }
153+ return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil
154+}
155+
156+func updateBindingEntry(entries []domain.ConfigEntry, key, value string) {
157+ for i := range entries {
158+ if entries[i].Key == key {
159+ entries[i].Value = value
160+ return
161+ }
162+ }
163+}
164+
165+func parseTheme(value string) (domain.ThemeMode, bool) {
166+ switch strings.ToLower(strings.TrimSpace(value)) {
167+ case "auto", "":
168+ return domain.ThemeAuto, true
169+ case "dark":
170+ return domain.ThemeDark, true
171+ case "light":
172+ return domain.ThemeLight, true
173+ default:
174+ return domain.ThemeAuto, false
175+ }
176+}
177+
178+func stripInlineComment(line string) string {
179+ for i, r := range line {
180+ if r == '#' && (i == 0 || isSpace(rune(line[i-1]))) {
181+ return strings.TrimSpace(line[:i])
182+ }
183+ }
184+ return line
185+}
186+
187+func isSpace(r rune) bool {
188+ return r == ' ' || r == '\t' || r == '\r' || r == '\n'
189+}
@@ -0,0 +1,189 @@
1+package config
2+
3+import (
4+ "bufio"
5+ "context"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "os"
10+ "strings"
11+ "time"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+)
16+
17+const pollInterval = 2 * time.Second
18+
19+// Parse reads vev's flat action = value config format. Duplicate action keys
20+// are accepted with a warning; the last value wins while first-seen action order
21+// is preserved for binding conflict resolution.
22+func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
23+ cfg := domain.Defaults()
24+ var warnings []domain.Warning
25+
26+ scanner := bufio.NewScanner(r)
27+ lineNo := 0
28+ for scanner.Scan() {
29+ lineNo++
30+ line := strings.TrimSpace(strings.TrimSuffix(scanner.Text(), "\r"))
31+ if line == "" || strings.HasPrefix(line, "#") {
32+ continue
33+ }
34+ line = stripInlineComment(line)
35+ if line == "" {
36+ continue
37+ }
38+
39+ key, value, ok := strings.Cut(line, "=")
40+ if !ok {
41+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"})
42+ continue
43+ }
44+ key = strings.TrimSpace(key)
45+ value = strings.TrimSpace(value)
46+ if key == "" {
47+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing key"})
48+ continue
49+ }
50+
51+ switch {
52+ case key == "theme":
53+ mode, ok := parseTheme(value)
54+ if !ok {
55+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid theme %q", value)})
56+ continue
57+ }
58+ cfg.Theme = mode
59+ case strings.HasPrefix(key, "code."):
60+ codeKey := strings.TrimPrefix(key, "code.")
61+ if codeKey == "" {
62+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing code slug"})
63+ continue
64+ }
65+ if _, exists := cfg.Codes[codeKey]; exists {
66+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
67+ }
68+ cfg.Codes[codeKey] = value
69+ default:
70+ if _, exists := cfg.Bindings[key]; exists {
71+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
72+ updateBindingEntry(cfg.BindingEntries, key, value)
73+ } else {
74+ cfg.BindingEntries = append(cfg.BindingEntries, domain.ConfigEntry{Key: key, Value: value})
75+ }
76+ cfg.Bindings[key] = value
77+ }
78+ }
79+ if err := scanner.Err(); err != nil {
80+ return cfg, warnings, err
81+ }
82+ return cfg, warnings, nil
83+}
84+
85+// Load reads path and parses it. Missing config files are treated as defaults.
86+func Load(path string) (domain.Config, []domain.Warning, error) {
87+ f, err := os.Open(path)
88+ if errors.Is(err, os.ErrNotExist) {
89+ return domain.Defaults(), nil, nil
90+ }
91+ if err != nil {
92+ return domain.Defaults(), nil, err
93+ }
94+ cfg, warnings, parseErr := Parse(f)
95+ if closeErr := f.Close(); parseErr == nil && closeErr != nil {
96+ return cfg, warnings, closeErr
97+ }
98+ return cfg, warnings, parseErr
99+}
100+
101+// Watch polls path for mtime/size changes every two seconds and calls onChange
102+// with each successfully loaded config. Missing files are allowed and reload to
103+// defaults when the watched state changes.
104+func Watch(ctx context.Context, clock ports.Clock, path string, onChange func(domain.Config, []domain.Warning)) error {
105+ last, err := fileStamp(path)
106+ lastErrMsg := ""
107+ if err != nil {
108+ lastErrMsg = err.Error()
109+ onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}})
110+ }
111+ timer := clock.NewTimer(pollInterval)
112+ defer timer.Stop()
113+
114+ for {
115+ select {
116+ case <-ctx.Done():
117+ return ctx.Err()
118+ case <-timer.C():
119+ current, statErr := fileStamp(path)
120+ if statErr != nil {
121+ if statErr.Error() != lastErrMsg {
122+ lastErrMsg = statErr.Error()
123+ onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}})
124+ }
125+ } else if current != last || lastErrMsg != "" {
126+ lastErrMsg = ""
127+ last = current
128+ cfg, warnings, err := Load(path)
129+ if err != nil {
130+ warnings = append(warnings, domain.Warning{Msg: err.Error()})
131+ }
132+ onChange(cfg, warnings)
133+ }
134+ timer.Reset(pollInterval)
135+ }
136+ }
137+}
138+
139+type stamp struct {
140+ modTime time.Time
141+ size int64
142+ exists bool
143+}
144+
145+func fileStamp(path string) (stamp, error) {
146+ st, err := os.Stat(path)
147+ if errors.Is(err, os.ErrNotExist) {
148+ return stamp{}, nil
149+ }
150+ if err != nil {
151+ return stamp{}, err
152+ }
153+ return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil
154+}
155+
156+func updateBindingEntry(entries []domain.ConfigEntry, key, value string) {
157+ for i := range entries {
158+ if entries[i].Key == key {
159+ entries[i].Value = value
160+ return
161+ }
162+ }
163+}
164+
165+func parseTheme(value string) (domain.ThemeMode, bool) {
166+ switch strings.ToLower(strings.TrimSpace(value)) {
167+ case "auto", "":
168+ return domain.ThemeAuto, true
169+ case "dark":
170+ return domain.ThemeDark, true
171+ case "light":
172+ return domain.ThemeLight, true
173+ default:
174+ return domain.ThemeAuto, false
175+ }
176+}
177+
178+func stripInlineComment(line string) string {
179+ for i, r := range line {
180+ if r == '#' && (i == 0 || isSpace(rune(line[i-1]))) {
181+ return strings.TrimSpace(line[:i])
182+ }
183+ }
184+ return line
185+}
186+
187+func isSpace(r rune) bool {
188+ return r == ' ' || r == '\t' || r == '\r' || r == '\n'
189+}
A internal/adapters/config/config_test.go +332 -0
@@ -0,0 +1,332 @@
1+package config
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "reflect"
9+ "strings"
10+ "sync"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+func TestParse(t *testing.T) {
20+ t.Parallel()
21+
22+ tests := []struct {
23+ name string
24+ input string
25+ want domain.Config
26+ wantWarnings []domain.Warning
27+ }{
28+ {
29+ name: "comments and inline comments",
30+ input: "# full line\nnew-tab = alt+t # open tab\ndetach = alt+#\n",
31+ want: domain.Config{
32+ Theme: domain.ThemeAuto,
33+ Bindings: map[string]string{
34+ "new-tab": "alt+t",
35+ "detach": "alt+#",
36+ },
37+ BindingEntries: []domain.ConfigEntry{
38+ {Key: "new-tab", Value: "alt+t"},
39+ {Key: "detach", Value: "alt+#"},
40+ },
41+ Codes: map[string]string{},
42+ },
43+ },
44+ {
45+ name: "crlf and code prefix",
46+ input: "theme = light\r\ncode.detach = dt\r\n",
47+ want: domain.Config{
48+ Theme: domain.ThemeLight,
49+ Bindings: map[string]string{},
50+ BindingEntries: []domain.ConfigEntry{},
51+ Codes: map[string]string{"detach": "dt"},
52+ },
53+ },
54+ {
55+ name: "invalid theme warns and keeps default",
56+ input: "theme = blue\n",
57+ want: domain.Config{
58+ Theme: domain.ThemeAuto,
59+ Bindings: map[string]string{},
60+ BindingEntries: []domain.ConfigEntry{},
61+ Codes: map[string]string{},
62+ },
63+ wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid theme \"blue\""}},
64+ },
65+ {
66+ name: "unknown binding key stored",
67+ input: "unknown.action = alt+x\n",
68+ want: domain.Config{
69+ Theme: domain.ThemeAuto,
70+ Bindings: map[string]string{"unknown.action": "alt+x"},
71+ BindingEntries: []domain.ConfigEntry{{Key: "unknown.action", Value: "alt+x"}},
72+ Codes: map[string]string{},
73+ },
74+ },
75+ {
76+ name: "duplicates warn and last wins",
77+ input: "new-tab = alt+t\nnew-tab = alt+n\ncode.detach = DT\ncode.detach = DX\n",
78+ want: domain.Config{
79+ Theme: domain.ThemeAuto,
80+ Bindings: map[string]string{"new-tab": "alt+n"},
81+ BindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+n"}},
82+ Codes: map[string]string{"detach": "DX"},
83+ },
84+ wantWarnings: []domain.Warning{
85+ {Line: 2, Msg: "duplicate key \"new-tab\""},
86+ {Line: 4, Msg: "duplicate key \"code.detach\""},
87+ },
88+ },
89+ {
90+ name: "invalid lines report line numbers",
91+ input: "\nno equals\n = value\ntheme = dark\n",
92+ want: domain.Config{
93+ Theme: domain.ThemeDark,
94+ Bindings: map[string]string{},
95+ BindingEntries: []domain.ConfigEntry{},
96+ Codes: map[string]string{},
97+ },
98+ wantWarnings: []domain.Warning{
99+ {Line: 2, Msg: "missing '='"},
100+ {Line: 3, Msg: "missing key"},
101+ },
102+ },
103+ }
104+
105+ for _, tt := range tests {
106+ t.Run(tt.name, func(t *testing.T) {
107+ t.Parallel()
108+
109+ got, warnings, err := Parse(strings.NewReader(tt.input))
110+ if err != nil {
111+ t.Fatalf("Parse() error = %v", err)
112+ }
113+ if !reflect.DeepEqual(got, tt.want) {
114+ t.Fatalf("Parse() config = %#v, want %#v", got, tt.want)
115+ }
116+ if !reflect.DeepEqual(warnings, tt.wantWarnings) {
117+ t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
118+ }
119+ })
120+ }
121+}
122+
123+func TestParsePreservesBindingFileOrder(t *testing.T) {
124+ t.Parallel()
125+
126+ cfg, warnings, err := Parse(strings.NewReader("open-palette = alt+p\nfocus-pane-left = alt+p\n"))
127+ if err != nil {
128+ t.Fatalf("Parse() error = %v", err)
129+ }
130+ if len(warnings) != 0 {
131+ t.Fatalf("Parse() warnings = %#v, want none", warnings)
132+ }
133+ want := []domain.ConfigEntry{
134+ {Key: "open-palette", Value: "alt+p"},
135+ {Key: "focus-pane-left", Value: "alt+p"},
136+ }
137+ if !reflect.DeepEqual(cfg.BindingEntries, want) {
138+ t.Fatalf("Parse() BindingEntries = %#v, want %#v", cfg.BindingEntries, want)
139+ }
140+}
141+
142+func TestLoadMissingFileReturnsDefaults(t *testing.T) {
143+ t.Parallel()
144+
145+ cfg, warnings, err := Load(filepath.Join(t.TempDir(), "missing"))
146+ if err != nil {
147+ t.Fatalf("Load() error = %v", err)
148+ }
149+ if len(warnings) != 0 {
150+ t.Fatalf("Load() warnings = %#v, want none", warnings)
151+ }
152+ if !reflect.DeepEqual(cfg, domain.Defaults()) {
153+ t.Fatalf("Load() config = %#v, want defaults", cfg)
154+ }
155+}
156+
157+func TestWatchReloadsChangedFileWithFakeClock(t *testing.T) {
158+ t.Parallel()
159+
160+ path := filepath.Join(t.TempDir(), "config")
161+ if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil {
162+ t.Fatal(err)
163+ }
164+
165+ clk := newFakeClock(time.Unix(0, 0))
166+ ctx, cancel := context.WithCancel(context.Background())
167+ defer cancel()
168+
169+ type change struct {
170+ cfg domain.Config
171+ warnings []domain.Warning
172+ }
173+ changes := make(chan change, 2)
174+ done := make(chan error, 1)
175+ go func() {
176+ done <- Watch(ctx, clk, path, func(cfg domain.Config, warnings []domain.Warning) {
177+ changes <- change{cfg: cfg, warnings: warnings}
178+ })
179+ }()
180+
181+ clk.waitForTimers(t, 1)
182+ if err := os.WriteFile(path, []byte("theme = light\nbad line\n"), 0o600); err != nil {
183+ t.Fatal(err)
184+ }
185+ clk.advance(2 * time.Second)
186+
187+ var got change
188+ select {
189+ case got = <-changes:
190+ case <-time.After(2 * time.Second):
191+ t.Fatal("timed out waiting for config reload")
192+ }
193+ if got.cfg.Theme != domain.ThemeLight {
194+ t.Fatalf("Watch() theme = %v, want %v", got.cfg.Theme, domain.ThemeLight)
195+ }
196+ if !reflect.DeepEqual(got.warnings, []domain.Warning{{Line: 2, Msg: "missing '='"}}) {
197+ t.Fatalf("Watch() warnings = %#v", got.warnings)
198+ }
199+
200+ cancel()
201+ if err := <-done; !errors.Is(err, context.Canceled) {
202+ t.Fatalf("Watch() error = %v, want context.Canceled", err)
203+ }
204+}
205+
206+func TestWatchDoesNotRepeatPersistentStatError(t *testing.T) {
207+ t.Parallel()
208+
209+ path := filepath.Join(t.TempDir(), strings.Repeat("x", 5000))
210+ clk := newFakeClock(time.Unix(0, 0))
211+ ctx, cancel := context.WithCancel(context.Background())
212+ defer cancel()
213+
214+ changes := make(chan []domain.Warning, 4)
215+ done := make(chan error, 1)
216+ go func() {
217+ done <- Watch(ctx, clk, path, func(_ domain.Config, warnings []domain.Warning) {
218+ changes <- warnings
219+ })
220+ }()
221+
222+ first := <-changes
223+ require.Len(t, first, 1)
224+ clk.waitForTimers(t, 1)
225+ clk.advance(2 * time.Second)
226+ clk.advance(2 * time.Second)
227+
228+ select {
229+ case extra := <-changes:
230+ t.Fatalf("Watch() repeated persistent stat error: %#v", extra)
231+ case <-time.After(100 * time.Millisecond):
232+ }
233+
234+ cancel()
235+ if err := <-done; !errors.Is(err, context.Canceled) {
236+ t.Fatalf("Watch() error = %v, want context.Canceled", err)
237+ }
238+}
239+
240+var _ ports.Clock = (*fakeClock)(nil)
241+var _ ports.Timer = (*fakeTimer)(nil)
242+
243+type fakeClock struct {
244+ mu sync.Mutex
245+ now time.Time
246+ timers []*fakeTimer
247+ notify chan struct{}
248+}
249+
250+func newFakeClock(now time.Time) *fakeClock {
251+ return &fakeClock{now: now, notify: make(chan struct{}, 16)}
252+}
253+
254+func (c *fakeClock) Now() time.Time {
255+ c.mu.Lock()
256+ defer c.mu.Unlock()
257+ return c.now
258+}
259+
260+func (c *fakeClock) NewTimer(d time.Duration) ports.Timer {
261+ c.mu.Lock()
262+ defer c.mu.Unlock()
263+
264+ t := &fakeTimer{clock: c, ch: make(chan time.Time, 1), next: c.now.Add(d), active: true}
265+ c.timers = append(c.timers, t)
266+ c.notify <- struct{}{}
267+ return t
268+}
269+
270+func (c *fakeClock) waitForTimers(t *testing.T, n int) {
271+ t.Helper()
272+ deadline := time.After(2 * time.Second)
273+ for c.timerCount() < n {
274+ select {
275+ case <-c.notify:
276+ case <-deadline:
277+ t.Fatalf("timed out waiting for %d timers, got %d", n, c.timerCount())
278+ }
279+ }
280+}
281+
282+func (c *fakeClock) timerCount() int {
283+ c.mu.Lock()
284+ defer c.mu.Unlock()
285+ return len(c.timers)
286+}
287+
288+func (c *fakeClock) advance(d time.Duration) {
289+ c.mu.Lock()
290+ defer c.mu.Unlock()
291+
292+ c.now = c.now.Add(d)
293+ for _, timer := range c.timers {
294+ timer.mu.Lock()
295+ if timer.active && !timer.next.After(c.now) {
296+ timer.ch <- c.now
297+ timer.active = false
298+ }
299+ timer.mu.Unlock()
300+ }
301+}
302+
303+type fakeTimer struct {
304+ clock *fakeClock
305+ mu sync.Mutex
306+ ch chan time.Time
307+ next time.Time
308+ active bool
309+}
310+
311+func (t *fakeTimer) C() <-chan time.Time { return t.ch }
312+
313+func (t *fakeTimer) Reset(d time.Duration) bool {
314+ t.clock.mu.Lock()
315+ defer t.clock.mu.Unlock()
316+ t.mu.Lock()
317+ defer t.mu.Unlock()
318+
319+ wasActive := t.active
320+ t.active = true
321+ t.next = t.clock.now.Add(d)
322+ return wasActive
323+}
324+
325+func (t *fakeTimer) Stop() bool {
326+ t.mu.Lock()
327+ defer t.mu.Unlock()
328+
329+ wasActive := t.active
330+ t.active = false
331+ return wasActive
332+}
@@ -0,0 +1,332 @@
1+package config
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "reflect"
9+ "strings"
10+ "sync"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+func TestParse(t *testing.T) {
20+ t.Parallel()
21+
22+ tests := []struct {
23+ name string
24+ input string
25+ want domain.Config
26+ wantWarnings []domain.Warning
27+ }{
28+ {
29+ name: "comments and inline comments",
30+ input: "# full line\nnew-tab = alt+t # open tab\ndetach = alt+#\n",
31+ want: domain.Config{
32+ Theme: domain.ThemeAuto,
33+ Bindings: map[string]string{
34+ "new-tab": "alt+t",
35+ "detach": "alt+#",
36+ },
37+ BindingEntries: []domain.ConfigEntry{
38+ {Key: "new-tab", Value: "alt+t"},
39+ {Key: "detach", Value: "alt+#"},
40+ },
41+ Codes: map[string]string{},
42+ },
43+ },
44+ {
45+ name: "crlf and code prefix",
46+ input: "theme = light\r\ncode.detach = dt\r\n",
47+ want: domain.Config{
48+ Theme: domain.ThemeLight,
49+ Bindings: map[string]string{},
50+ BindingEntries: []domain.ConfigEntry{},
51+ Codes: map[string]string{"detach": "dt"},
52+ },
53+ },
54+ {
55+ name: "invalid theme warns and keeps default",
56+ input: "theme = blue\n",
57+ want: domain.Config{
58+ Theme: domain.ThemeAuto,
59+ Bindings: map[string]string{},
60+ BindingEntries: []domain.ConfigEntry{},
61+ Codes: map[string]string{},
62+ },
63+ wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid theme \"blue\""}},
64+ },
65+ {
66+ name: "unknown binding key stored",
67+ input: "unknown.action = alt+x\n",
68+ want: domain.Config{
69+ Theme: domain.ThemeAuto,
70+ Bindings: map[string]string{"unknown.action": "alt+x"},
71+ BindingEntries: []domain.ConfigEntry{{Key: "unknown.action", Value: "alt+x"}},
72+ Codes: map[string]string{},
73+ },
74+ },
75+ {
76+ name: "duplicates warn and last wins",
77+ input: "new-tab = alt+t\nnew-tab = alt+n\ncode.detach = DT\ncode.detach = DX\n",
78+ want: domain.Config{
79+ Theme: domain.ThemeAuto,
80+ Bindings: map[string]string{"new-tab": "alt+n"},
81+ BindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+n"}},
82+ Codes: map[string]string{"detach": "DX"},
83+ },
84+ wantWarnings: []domain.Warning{
85+ {Line: 2, Msg: "duplicate key \"new-tab\""},
86+ {Line: 4, Msg: "duplicate key \"code.detach\""},
87+ },
88+ },
89+ {
90+ name: "invalid lines report line numbers",
91+ input: "\nno equals\n = value\ntheme = dark\n",
92+ want: domain.Config{
93+ Theme: domain.ThemeDark,
94+ Bindings: map[string]string{},
95+ BindingEntries: []domain.ConfigEntry{},
96+ Codes: map[string]string{},
97+ },
98+ wantWarnings: []domain.Warning{
99+ {Line: 2, Msg: "missing '='"},
100+ {Line: 3, Msg: "missing key"},
101+ },
102+ },
103+ }
104+
105+ for _, tt := range tests {
106+ t.Run(tt.name, func(t *testing.T) {
107+ t.Parallel()
108+
109+ got, warnings, err := Parse(strings.NewReader(tt.input))
110+ if err != nil {
111+ t.Fatalf("Parse() error = %v", err)
112+ }
113+ if !reflect.DeepEqual(got, tt.want) {
114+ t.Fatalf("Parse() config = %#v, want %#v", got, tt.want)
115+ }
116+ if !reflect.DeepEqual(warnings, tt.wantWarnings) {
117+ t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
118+ }
119+ })
120+ }
121+}
122+
123+func TestParsePreservesBindingFileOrder(t *testing.T) {
124+ t.Parallel()
125+
126+ cfg, warnings, err := Parse(strings.NewReader("open-palette = alt+p\nfocus-pane-left = alt+p\n"))
127+ if err != nil {
128+ t.Fatalf("Parse() error = %v", err)
129+ }
130+ if len(warnings) != 0 {
131+ t.Fatalf("Parse() warnings = %#v, want none", warnings)
132+ }
133+ want := []domain.ConfigEntry{
134+ {Key: "open-palette", Value: "alt+p"},
135+ {Key: "focus-pane-left", Value: "alt+p"},
136+ }
137+ if !reflect.DeepEqual(cfg.BindingEntries, want) {
138+ t.Fatalf("Parse() BindingEntries = %#v, want %#v", cfg.BindingEntries, want)
139+ }
140+}
141+
142+func TestLoadMissingFileReturnsDefaults(t *testing.T) {
143+ t.Parallel()
144+
145+ cfg, warnings, err := Load(filepath.Join(t.TempDir(), "missing"))
146+ if err != nil {
147+ t.Fatalf("Load() error = %v", err)
148+ }
149+ if len(warnings) != 0 {
150+ t.Fatalf("Load() warnings = %#v, want none", warnings)
151+ }
152+ if !reflect.DeepEqual(cfg, domain.Defaults()) {
153+ t.Fatalf("Load() config = %#v, want defaults", cfg)
154+ }
155+}
156+
157+func TestWatchReloadsChangedFileWithFakeClock(t *testing.T) {
158+ t.Parallel()
159+
160+ path := filepath.Join(t.TempDir(), "config")
161+ if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil {
162+ t.Fatal(err)
163+ }
164+
165+ clk := newFakeClock(time.Unix(0, 0))
166+ ctx, cancel := context.WithCancel(context.Background())
167+ defer cancel()
168+
169+ type change struct {
170+ cfg domain.Config
171+ warnings []domain.Warning
172+ }
173+ changes := make(chan change, 2)
174+ done := make(chan error, 1)
175+ go func() {
176+ done <- Watch(ctx, clk, path, func(cfg domain.Config, warnings []domain.Warning) {
177+ changes <- change{cfg: cfg, warnings: warnings}
178+ })
179+ }()
180+
181+ clk.waitForTimers(t, 1)
182+ if err := os.WriteFile(path, []byte("theme = light\nbad line\n"), 0o600); err != nil {
183+ t.Fatal(err)
184+ }
185+ clk.advance(2 * time.Second)
186+
187+ var got change
188+ select {
189+ case got = <-changes:
190+ case <-time.After(2 * time.Second):
191+ t.Fatal("timed out waiting for config reload")
192+ }
193+ if got.cfg.Theme != domain.ThemeLight {
194+ t.Fatalf("Watch() theme = %v, want %v", got.cfg.Theme, domain.ThemeLight)
195+ }
196+ if !reflect.DeepEqual(got.warnings, []domain.Warning{{Line: 2, Msg: "missing '='"}}) {
197+ t.Fatalf("Watch() warnings = %#v", got.warnings)
198+ }
199+
200+ cancel()
201+ if err := <-done; !errors.Is(err, context.Canceled) {
202+ t.Fatalf("Watch() error = %v, want context.Canceled", err)
203+ }
204+}
205+
206+func TestWatchDoesNotRepeatPersistentStatError(t *testing.T) {
207+ t.Parallel()
208+
209+ path := filepath.Join(t.TempDir(), strings.Repeat("x", 5000))
210+ clk := newFakeClock(time.Unix(0, 0))
211+ ctx, cancel := context.WithCancel(context.Background())
212+ defer cancel()
213+
214+ changes := make(chan []domain.Warning, 4)
215+ done := make(chan error, 1)
216+ go func() {
217+ done <- Watch(ctx, clk, path, func(_ domain.Config, warnings []domain.Warning) {
218+ changes <- warnings
219+ })
220+ }()
221+
222+ first := <-changes
223+ require.Len(t, first, 1)
224+ clk.waitForTimers(t, 1)
225+ clk.advance(2 * time.Second)
226+ clk.advance(2 * time.Second)
227+
228+ select {
229+ case extra := <-changes:
230+ t.Fatalf("Watch() repeated persistent stat error: %#v", extra)
231+ case <-time.After(100 * time.Millisecond):
232+ }
233+
234+ cancel()
235+ if err := <-done; !errors.Is(err, context.Canceled) {
236+ t.Fatalf("Watch() error = %v, want context.Canceled", err)
237+ }
238+}
239+
240+var _ ports.Clock = (*fakeClock)(nil)
241+var _ ports.Timer = (*fakeTimer)(nil)
242+
243+type fakeClock struct {
244+ mu sync.Mutex
245+ now time.Time
246+ timers []*fakeTimer
247+ notify chan struct{}
248+}
249+
250+func newFakeClock(now time.Time) *fakeClock {
251+ return &fakeClock{now: now, notify: make(chan struct{}, 16)}
252+}
253+
254+func (c *fakeClock) Now() time.Time {
255+ c.mu.Lock()
256+ defer c.mu.Unlock()
257+ return c.now
258+}
259+
260+func (c *fakeClock) NewTimer(d time.Duration) ports.Timer {
261+ c.mu.Lock()
262+ defer c.mu.Unlock()
263+
264+ t := &fakeTimer{clock: c, ch: make(chan time.Time, 1), next: c.now.Add(d), active: true}
265+ c.timers = append(c.timers, t)
266+ c.notify <- struct{}{}
267+ return t
268+}
269+
270+func (c *fakeClock) waitForTimers(t *testing.T, n int) {
271+ t.Helper()
272+ deadline := time.After(2 * time.Second)
273+ for c.timerCount() < n {
274+ select {
275+ case <-c.notify:
276+ case <-deadline:
277+ t.Fatalf("timed out waiting for %d timers, got %d", n, c.timerCount())
278+ }
279+ }
280+}
281+
282+func (c *fakeClock) timerCount() int {
283+ c.mu.Lock()
284+ defer c.mu.Unlock()
285+ return len(c.timers)
286+}
287+
288+func (c *fakeClock) advance(d time.Duration) {
289+ c.mu.Lock()
290+ defer c.mu.Unlock()
291+
292+ c.now = c.now.Add(d)
293+ for _, timer := range c.timers {
294+ timer.mu.Lock()
295+ if timer.active && !timer.next.After(c.now) {
296+ timer.ch <- c.now
297+ timer.active = false
298+ }
299+ timer.mu.Unlock()
300+ }
301+}
302+
303+type fakeTimer struct {
304+ clock *fakeClock
305+ mu sync.Mutex
306+ ch chan time.Time
307+ next time.Time
308+ active bool
309+}
310+
311+func (t *fakeTimer) C() <-chan time.Time { return t.ch }
312+
313+func (t *fakeTimer) Reset(d time.Duration) bool {
314+ t.clock.mu.Lock()
315+ defer t.clock.mu.Unlock()
316+ t.mu.Lock()
317+ defer t.mu.Unlock()
318+
319+ wasActive := t.active
320+ t.active = true
321+ t.next = t.clock.now.Add(d)
322+ return wasActive
323+}
324+
325+func (t *fakeTimer) Stop() bool {
326+ t.mu.Lock()
327+ defer t.mu.Unlock()
328+
329+ wasActive := t.active
330+ t.active = false
331+ return wasActive
332+}
M internal/adapters/term/pty_integration_test.go +1 -1
@@ -102,7 +102,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) {
102102
103103 select {
104104 case got := <-captured:
105- want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit
105+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
106106 if string(got) != want {
107107 t.Fatalf("captured escapes = %q, want %q", got, want)
108108 }
@@ -102,7 +102,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) {
102 102
103 select {103 select {
104 case got := <-captured:104 case got := <-captured:
105- want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit105+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
106 if string(got) != want {106 if string(got) != want {
107 t.Fatalf("captured escapes = %q, want %q", got, want)107 t.Fatalf("captured escapes = %q, want %q", got, want)
108 }108 }
M internal/adapters/term/terminal.go +17 -2
@@ -30,6 +30,8 @@ const (
3030 mouseDisable = "\x1b[?1002l\x1b[?1006l"
3131 bracketedPasteEnable = "\x1b[?2004h"
3232 bracketedPasteDisable = "\x1b[?2004l"
33+ colorSchemeEnable = "\x1b[?2031h"
34+ colorSchemeDisable = "\x1b[?2031l"
3335 oscColorQuery = "\x1b]10;?\x07\x1b]11;?\x07"
3436 )
3537
@@ -107,7 +109,7 @@ func (t *Terminal) EnterRaw() (func() error, error) {
107109 }
108110 t.orig = old
109111
110- if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery); err != nil {
112+ if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery); err != nil {
111113 _ = t.restoreRawLocked()
112114 return nil, fmt.Errorf("term: enter alt screen: %w", err)
113115 }
@@ -141,7 +143,7 @@ func (t *Terminal) restore() error {
141143 t.mu.Lock()
142144 defer t.mu.Unlock()
143145
144- _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit)
146+ _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit)
145147 ferr := t.bw.Flush()
146148
147149 rerr := t.restoreRawLocked()
@@ -234,6 +236,19 @@ func (t *Terminal) stopResizeLocked() {
234236 t.resizeWG.Wait()
235237 }
236238
239+// QueryColors re-emits OSC 10/11 queries for default foreground/background.
240+func (t *Terminal) QueryColors() error {
241+ t.mu.Lock()
242+ defer t.mu.Unlock()
243+ if _, err := t.bw.WriteString(oscColorQuery); err != nil {
244+ return fmt.Errorf("term: query colors: %w", err)
245+ }
246+ if err := t.bw.Flush(); err != nil {
247+ return fmt.Errorf("term: query colors: %w", err)
248+ }
249+ return nil
250+}
251+
237252 // In returns the reader for the controlling terminal's input.
238253 func (t *Terminal) In() io.Reader {
239254 return t.in
@@ -30,6 +30,8 @@ const (
30 mouseDisable = "\x1b[?1002l\x1b[?1006l"30 mouseDisable = "\x1b[?1002l\x1b[?1006l"
31 bracketedPasteEnable = "\x1b[?2004h"31 bracketedPasteEnable = "\x1b[?2004h"
32 bracketedPasteDisable = "\x1b[?2004l"32 bracketedPasteDisable = "\x1b[?2004l"
33+ colorSchemeEnable = "\x1b[?2031h"
34+ colorSchemeDisable = "\x1b[?2031l"
33 oscColorQuery = "\x1b]10;?\x07\x1b]11;?\x07"35 oscColorQuery = "\x1b]10;?\x07\x1b]11;?\x07"
34 )36 )
35 37
@@ -107,7 +109,7 @@ func (t *Terminal) EnterRaw() (func() error, error) {
107 }109 }
108 t.orig = old110 t.orig = old
109 111
110- if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery); err != nil {112+ if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery); err != nil {
111 _ = t.restoreRawLocked()113 _ = t.restoreRawLocked()
112 return nil, fmt.Errorf("term: enter alt screen: %w", err)114 return nil, fmt.Errorf("term: enter alt screen: %w", err)
113 }115 }
@@ -141,7 +143,7 @@ func (t *Terminal) restore() error {
141 t.mu.Lock()143 t.mu.Lock()
142 defer t.mu.Unlock()144 defer t.mu.Unlock()
143 145
144- _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit)146+ _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit)
145 ferr := t.bw.Flush()147 ferr := t.bw.Flush()
146 148
147 rerr := t.restoreRawLocked()149 rerr := t.restoreRawLocked()
@@ -234,6 +236,19 @@ func (t *Terminal) stopResizeLocked() {
234 t.resizeWG.Wait()236 t.resizeWG.Wait()
235 }237 }
236 238
239+// QueryColors re-emits OSC 10/11 queries for default foreground/background.
240+func (t *Terminal) QueryColors() error {
241+ t.mu.Lock()
242+ defer t.mu.Unlock()
243+ if _, err := t.bw.WriteString(oscColorQuery); err != nil {
244+ return fmt.Errorf("term: query colors: %w", err)
245+ }
246+ if err := t.bw.Flush(); err != nil {
247+ return fmt.Errorf("term: query colors: %w", err)
248+ }
249+ return nil
250+}
251+
237 // In returns the reader for the controlling terminal's input.252 // In returns the reader for the controlling terminal's input.
238 func (t *Terminal) In() io.Reader {253 func (t *Terminal) In() io.Reader {
239 return t.in254 return t.in
M internal/adapters/term/terminal_test.go +24 -2
@@ -60,7 +60,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) {
6060 _ = outW.Close()
6161 <-done
6262
63- want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit
63+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
6464 if got := captured.String(); got != want {
6565 t.Fatalf("captured escapes = %q, want %q", got, want)
6666 }
@@ -101,12 +101,34 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
101101 // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and
102102 // cursor/alt-screen exits exactly once, regardless of how many times
103103 // EnterRaw/restore were called.
104- want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit
104+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
105105 if got := captured.String(); got != want {
106106 t.Fatalf("captured escapes = %q, want %q", got, want)
107107 }
108108 }
109109
110+func TestTerminal_QueryColors(t *testing.T) {
111+ inR, inW, err := os.Pipe()
112+ if err != nil {
113+ t.Fatalf("os.Pipe(in): %v", err)
114+ }
115+ defer func() { _ = inW.Close() }()
116+ defer func() { _ = inR.Close() }()
117+
118+ outR, outW, captured, done := pipeCapture(t)
119+ defer func() { _ = outR.Close() }()
120+
121+ tm := NewWithFiles(inR, outW)
122+ if err := tm.QueryColors(); err != nil {
123+ t.Fatalf("QueryColors: %v", err)
124+ }
125+ _ = outW.Close()
126+ <-done
127+ if got := captured.String(); got != oscColorQuery {
128+ t.Fatalf("captured = %q, want %q", got, oscColorQuery)
129+ }
130+}
131+
110132 func TestTerminal_InOut(t *testing.T) {
111133 inR, inW, err := os.Pipe()
112134 if err != nil {
@@ -60,7 +60,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) {
60 _ = outW.Close()60 _ = outW.Close()
61 <-done61 <-done
62 62
63- want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit63+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
64 if got := captured.String(); got != want {64 if got := captured.String(); got != want {
65 t.Fatalf("captured escapes = %q, want %q", got, want)65 t.Fatalf("captured escapes = %q, want %q", got, want)
66 }66 }
@@ -101,12 +101,34 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
101 // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and101 // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and
102 // cursor/alt-screen exits exactly once, regardless of how many times102 // cursor/alt-screen exits exactly once, regardless of how many times
103 // EnterRaw/restore were called.103 // EnterRaw/restore were called.
104- want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit104+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
105 if got := captured.String(); got != want {105 if got := captured.String(); got != want {
106 t.Fatalf("captured escapes = %q, want %q", got, want)106 t.Fatalf("captured escapes = %q, want %q", got, want)
107 }107 }
108 }108 }
109 109
110+func TestTerminal_QueryColors(t *testing.T) {
111+ inR, inW, err := os.Pipe()
112+ if err != nil {
113+ t.Fatalf("os.Pipe(in): %v", err)
114+ }
115+ defer func() { _ = inW.Close() }()
116+ defer func() { _ = inR.Close() }()
117+
118+ outR, outW, captured, done := pipeCapture(t)
119+ defer func() { _ = outR.Close() }()
120+
121+ tm := NewWithFiles(inR, outW)
122+ if err := tm.QueryColors(); err != nil {
123+ t.Fatalf("QueryColors: %v", err)
124+ }
125+ _ = outW.Close()
126+ <-done
127+ if got := captured.String(); got != oscColorQuery {
128+ t.Fatalf("captured = %q, want %q", got, oscColorQuery)
129+ }
130+}
131+
110 func TestTerminal_InOut(t *testing.T) {132 func TestTerminal_InOut(t *testing.T) {
111 inR, inW, err := os.Pipe()133 inR, inW, err := os.Pipe()
112 if err != nil {134 if err != nil {
M internal/app/run.go +31 -1
@@ -23,6 +23,7 @@ import (
2323
2424 "github.com/bnema/vev/internal/adapters/clipboard"
2525 "github.com/bnema/vev/internal/adapters/clock"
26+ "github.com/bnema/vev/internal/adapters/config"
2627 "github.com/bnema/vev/internal/adapters/dgram"
2728 "github.com/bnema/vev/internal/adapters/ipc"
2829 "github.com/bnema/vev/internal/adapters/pty"
@@ -224,6 +225,16 @@ func configureLogging(component logging.Component, rotateAtRuntime bool) (*slog.
224225 })
225226 }
226227
228+func logConfigWarnings(log *slog.Logger, warnings []domain.Warning) {
229+ for _, warning := range warnings {
230+ if warning.Line > 0 {
231+ log.Warn("config warning", "line", warning.Line, "msg", warning.Msg)
232+ continue
233+ }
234+ log.Warn("config warning", "msg", warning.Msg)
235+ }
236+}
237+
227238 // runDaemon runs the daemon in the foreground (the hidden --daemon path,
228239 // entered by an auto-spawned child): it sets up logging, binds the socket,
229240 // constructs the daemon, and serves until the last session exits or a
@@ -256,6 +267,14 @@ func runDaemon() error {
256267
257268 log.Info("daemon starting", "socket", ln.Addr())
258269 daemonOpts := []daemon.Option(nil)
270+ configPath := platform.ConfigPath()
271+ cfg, warnings, err := config.Load(configPath)
272+ if err != nil {
273+ log.Warn("loading config failed; using defaults", "path", configPath, "err", err)
274+ cfg = domain.Defaults()
275+ }
276+ logConfigWarnings(log, warnings)
277+ daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))
259278 storePath := persist.StorePath(platform.StateDir())
260279 if store, err := kv.Open(storePath); err != nil {
261280 log.Warn("opening session store failed; persistence disabled", "path", storePath, "err", err)
@@ -263,7 +282,18 @@ func runDaemon() error {
263282 log.Info("session persistence enabled", "path", storePath)
264283 daemonOpts = append(daemonOpts, daemon.WithStore(store))
265284 }
266- d := daemon.New(pty.NewFactory(), clock.New(), log, daemonOpts...)
285+ clk := clock.New()
286+ d := daemon.New(pty.NewFactory(), clk, log, daemonOpts...)
287+ watchCtx, stopWatch := context.WithCancel(ctx)
288+ defer stopWatch()
289+ go func() {
290+ if err := config.Watch(watchCtx, clk, configPath, func(cfg domain.Config, warnings []domain.Warning) {
291+ logConfigWarnings(log, warnings)
292+ d.ApplyConfig(cfg)
293+ }); err != nil && !errors.Is(err, context.Canceled) {
294+ log.Warn("config watcher stopped", "path", configPath, "err", err)
295+ }
296+ }()
267297 if err := d.Serve(ctx, ln); err != nil {
268298 log.Error("daemon exited", "err", err)
269299 return err
@@ -23,6 +23,7 @@ import (
23 23
24 "github.com/bnema/vev/internal/adapters/clipboard"24 "github.com/bnema/vev/internal/adapters/clipboard"
25 "github.com/bnema/vev/internal/adapters/clock"25 "github.com/bnema/vev/internal/adapters/clock"
26+ "github.com/bnema/vev/internal/adapters/config"
26 "github.com/bnema/vev/internal/adapters/dgram"27 "github.com/bnema/vev/internal/adapters/dgram"
27 "github.com/bnema/vev/internal/adapters/ipc"28 "github.com/bnema/vev/internal/adapters/ipc"
28 "github.com/bnema/vev/internal/adapters/pty"29 "github.com/bnema/vev/internal/adapters/pty"
@@ -224,6 +225,16 @@ func configureLogging(component logging.Component, rotateAtRuntime bool) (*slog.
224 })225 })
225 }226 }
226 227
228+func logConfigWarnings(log *slog.Logger, warnings []domain.Warning) {
229+ for _, warning := range warnings {
230+ if warning.Line > 0 {
231+ log.Warn("config warning", "line", warning.Line, "msg", warning.Msg)
232+ continue
233+ }
234+ log.Warn("config warning", "msg", warning.Msg)
235+ }
236+}
237+
227 // runDaemon runs the daemon in the foreground (the hidden --daemon path,238 // runDaemon runs the daemon in the foreground (the hidden --daemon path,
228 // entered by an auto-spawned child): it sets up logging, binds the socket,239 // entered by an auto-spawned child): it sets up logging, binds the socket,
229 // constructs the daemon, and serves until the last session exits or a240 // constructs the daemon, and serves until the last session exits or a
@@ -256,6 +267,14 @@ func runDaemon() error {
256 267
257 log.Info("daemon starting", "socket", ln.Addr())268 log.Info("daemon starting", "socket", ln.Addr())
258 daemonOpts := []daemon.Option(nil)269 daemonOpts := []daemon.Option(nil)
270+ configPath := platform.ConfigPath()
271+ cfg, warnings, err := config.Load(configPath)
272+ if err != nil {
273+ log.Warn("loading config failed; using defaults", "path", configPath, "err", err)
274+ cfg = domain.Defaults()
275+ }
276+ logConfigWarnings(log, warnings)
277+ daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))
259 storePath := persist.StorePath(platform.StateDir())278 storePath := persist.StorePath(platform.StateDir())
260 if store, err := kv.Open(storePath); err != nil {279 if store, err := kv.Open(storePath); err != nil {
261 log.Warn("opening session store failed; persistence disabled", "path", storePath, "err", err)280 log.Warn("opening session store failed; persistence disabled", "path", storePath, "err", err)
@@ -263,7 +282,18 @@ func runDaemon() error {
263 log.Info("session persistence enabled", "path", storePath)282 log.Info("session persistence enabled", "path", storePath)
264 daemonOpts = append(daemonOpts, daemon.WithStore(store))283 daemonOpts = append(daemonOpts, daemon.WithStore(store))
265 }284 }
266- d := daemon.New(pty.NewFactory(), clock.New(), log, daemonOpts...)285+ clk := clock.New()
286+ d := daemon.New(pty.NewFactory(), clk, log, daemonOpts...)
287+ watchCtx, stopWatch := context.WithCancel(ctx)
288+ defer stopWatch()
289+ go func() {
290+ if err := config.Watch(watchCtx, clk, configPath, func(cfg domain.Config, warnings []domain.Warning) {
291+ logConfigWarnings(log, warnings)
292+ d.ApplyConfig(cfg)
293+ }); err != nil && !errors.Is(err, context.Canceled) {
294+ log.Warn("config watcher stopped", "path", configPath, "err", err)
295+ }
296+ }()
267 if err := d.Serve(ctx, ln); err != nil {297 if err := d.Serve(ctx, ln); err != nil {
268 log.Error("daemon exited", "err", err)298 log.Error("daemon exited", "err", err)
269 return err299 return err
A internal/domain/config.go +46 -0
@@ -0,0 +1,46 @@
1+package domain
2+
3+// ThemeMode selects how vev derives its color scheme.
4+type ThemeMode int
5+
6+const (
7+ // ThemeAuto follows the attached client's reported color scheme.
8+ ThemeAuto ThemeMode = iota
9+ // ThemeDark forces vev's built-in dark palette.
10+ ThemeDark
11+ // ThemeLight forces vev's built-in light palette.
12+ ThemeLight
13+)
14+
15+// ConfigEntry preserves one parsed config key/value in file order.
16+type ConfigEntry struct {
17+ Key string
18+ Value string
19+}
20+
21+// Config is the user-editable vev configuration after parsing. Unknown binding
22+// keys are preserved here so the usecase layer can decide which actions it
23+// understands.
24+type Config struct {
25+ Theme ThemeMode
26+ Bindings map[string]string
27+ BindingEntries []ConfigEntry
28+ Codes map[string]string
29+}
30+
31+// Warning describes a non-fatal config problem. Parsers and reloaders should
32+// warn and continue with defaults rather than aborting startup.
33+type Warning struct {
34+ Line int
35+ Msg string
36+}
37+
38+// Defaults returns vev's default configuration.
39+func Defaults() Config {
40+ return Config{
41+ Theme: ThemeAuto,
42+ Bindings: map[string]string{},
43+ BindingEntries: []ConfigEntry{},
44+ Codes: map[string]string{},
45+ }
46+}
@@ -0,0 +1,46 @@
1+package domain
2+
3+// ThemeMode selects how vev derives its color scheme.
4+type ThemeMode int
5+
6+const (
7+ // ThemeAuto follows the attached client's reported color scheme.
8+ ThemeAuto ThemeMode = iota
9+ // ThemeDark forces vev's built-in dark palette.
10+ ThemeDark
11+ // ThemeLight forces vev's built-in light palette.
12+ ThemeLight
13+)
14+
15+// ConfigEntry preserves one parsed config key/value in file order.
16+type ConfigEntry struct {
17+ Key string
18+ Value string
19+}
20+
21+// Config is the user-editable vev configuration after parsing. Unknown binding
22+// keys are preserved here so the usecase layer can decide which actions it
23+// understands.
24+type Config struct {
25+ Theme ThemeMode
26+ Bindings map[string]string
27+ BindingEntries []ConfigEntry
28+ Codes map[string]string
29+}
30+
31+// Warning describes a non-fatal config problem. Parsers and reloaders should
32+// warn and continue with defaults rather than aborting startup.
33+type Warning struct {
34+ Line int
35+ Msg string
36+}
37+
38+// Defaults returns vev's default configuration.
39+func Defaults() Config {
40+ return Config{
41+ Theme: ThemeAuto,
42+ Bindings: map[string]string{},
43+ BindingEntries: []ConfigEntry{},
44+ Codes: map[string]string{},
45+ }
46+}
M internal/platform/dir.go +14 -0
@@ -20,6 +20,20 @@ func StateDir() string {
2020 return filepath.Join(home, ".local", "state", "vev")
2121 }
2222
23+// ConfigPath returns the user config file path: $XDG_CONFIG_HOME/vev/config if
24+// set, else ~/.config/vev/config.
25+func ConfigPath() string {
26+ if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
27+ return filepath.Join(xdg, "vev", "config")
28+ }
29+ home, err := os.UserHomeDir()
30+ if err != nil || home == "" {
31+ // Last resort: a per-uid temp path so config lookup never aborts startup.
32+ return filepath.Join(os.TempDir(), fmt.Sprintf("vev-config-%d", os.Getuid()), "config")
33+ }
34+ return filepath.Join(home, ".config", "vev", "config")
35+}
36+
2337 // DirOrHome returns dir when it names an existing directory. Otherwise it
2438 // falls back to the current user's home directory, then to / as a last resort.
2539 func DirOrHome(dir string) string {
@@ -20,6 +20,20 @@ func StateDir() string {
20 return filepath.Join(home, ".local", "state", "vev")20 return filepath.Join(home, ".local", "state", "vev")
21 }21 }
22 22
23+// ConfigPath returns the user config file path: $XDG_CONFIG_HOME/vev/config if
24+// set, else ~/.config/vev/config.
25+func ConfigPath() string {
26+ if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
27+ return filepath.Join(xdg, "vev", "config")
28+ }
29+ home, err := os.UserHomeDir()
30+ if err != nil || home == "" {
31+ // Last resort: a per-uid temp path so config lookup never aborts startup.
32+ return filepath.Join(os.TempDir(), fmt.Sprintf("vev-config-%d", os.Getuid()), "config")
33+ }
34+ return filepath.Join(home, ".config", "vev", "config")
35+}
36+
23 // DirOrHome returns dir when it names an existing directory. Otherwise it37 // DirOrHome returns dir when it names an existing directory. Otherwise it
24 // falls back to the current user's home directory, then to / as a last resort.38 // falls back to the current user's home directory, then to / as a last resort.
25 func DirOrHome(dir string) string {39 func DirOrHome(dir string) string {
A internal/platform/dir_test.go +39 -0
@@ -0,0 +1,39 @@
1+package platform
2+
3+import (
4+ "path/filepath"
5+ "testing"
6+)
7+
8+func TestConfigPath(t *testing.T) {
9+ tests := []struct {
10+ name string
11+ xdgConfig string
12+ home string
13+ }{
14+ {
15+ name: "xdg config home",
16+ xdgConfig: filepath.Join(t.TempDir(), "xdg-config"),
17+ home: filepath.Join(t.TempDir(), "home"),
18+ },
19+ {
20+ name: "home fallback",
21+ home: filepath.Join(t.TempDir(), "home"),
22+ },
23+ }
24+
25+ for _, tt := range tests {
26+ t.Run(tt.name, func(t *testing.T) {
27+ t.Setenv("XDG_CONFIG_HOME", tt.xdgConfig)
28+ t.Setenv("HOME", tt.home)
29+
30+ want := filepath.Join(tt.xdgConfig, "vev", "config")
31+ if tt.xdgConfig == "" {
32+ want = filepath.Join(tt.home, ".config", "vev", "config")
33+ }
34+ if got := ConfigPath(); got != want {
35+ t.Fatalf("ConfigPath() = %q, want %q", got, want)
36+ }
37+ })
38+ }
39+}
@@ -0,0 +1,39 @@
1+package platform
2+
3+import (
4+ "path/filepath"
5+ "testing"
6+)
7+
8+func TestConfigPath(t *testing.T) {
9+ tests := []struct {
10+ name string
11+ xdgConfig string
12+ home string
13+ }{
14+ {
15+ name: "xdg config home",
16+ xdgConfig: filepath.Join(t.TempDir(), "xdg-config"),
17+ home: filepath.Join(t.TempDir(), "home"),
18+ },
19+ {
20+ name: "home fallback",
21+ home: filepath.Join(t.TempDir(), "home"),
22+ },
23+ }
24+
25+ for _, tt := range tests {
26+ t.Run(tt.name, func(t *testing.T) {
27+ t.Setenv("XDG_CONFIG_HOME", tt.xdgConfig)
28+ t.Setenv("HOME", tt.home)
29+
30+ want := filepath.Join(tt.xdgConfig, "vev", "config")
31+ if tt.xdgConfig == "" {
32+ want = filepath.Join(tt.home, ".config", "vev", "config")
33+ }
34+ if got := ConfigPath(); got != want {
35+ t.Fatalf("ConfigPath() = %q, want %q", got, want)
36+ }
37+ })
38+ }
39+}
M internal/ports/frame.go +1 -1
@@ -1,7 +1,7 @@
11 package ports
22
33 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 7
4+const ProtocolVersion uint16 = 8
55
66 // MsgType identifies the kind of payload carried by a Frame.
77 type MsgType uint8
@@ -1,7 +1,7 @@
1 package ports1 package ports
2 2
3 // ProtocolVersion is the current vev IPC wire protocol version.3 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 74+const ProtocolVersion uint16 = 8
5 5
6 // MsgType identifies the kind of payload carried by a Frame.6 // MsgType identifies the kind of payload carried by a Frame.
7 type MsgType uint87 type MsgType uint8
M internal/ports/frame_test.go +2 -2
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 7 {
7- t.Fatalf("ProtocolVersion = %d, want 7", ProtocolVersion)
6+ if ProtocolVersion != 8 {
7+ t.Fatalf("ProtocolVersion = %d, want 8", ProtocolVersion)
88 }
99 }
1010
@@ -3,8 +3,8 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 7 {6+ if ProtocolVersion != 8 {
7- t.Fatalf("ProtocolVersion = %d, want 7", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 8", ProtocolVersion)
8 }8 }
9 }9 }
10 10
M internal/ports/mocks/mocks.go +44 -0
@@ -684,6 +684,50 @@ func (_c *MockTerminal_Out_Call) RunAndReturn(run func() io.Writer) *MockTermina
684684 return _c
685685 }
686686
687+// QueryColors provides a mock function for the type MockTerminal
688+func (_mock *MockTerminal) QueryColors() error {
689+ ret := _mock.Called()
690+
691+ if len(ret) == 0 {
692+ panic("no return value specified for QueryColors")
693+ }
694+
695+ var r0 error
696+ if returnFunc, ok := ret.Get(0).(func() error); ok {
697+ r0 = returnFunc()
698+ } else {
699+ r0 = ret.Error(0)
700+ }
701+ return r0
702+}
703+
704+// MockTerminal_QueryColors_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'QueryColors'
705+type MockTerminal_QueryColors_Call struct {
706+ *mock.Call
707+}
708+
709+// QueryColors is a helper method to define mock.On call
710+func (_e *MockTerminal_Expecter) QueryColors() *MockTerminal_QueryColors_Call {
711+ return &MockTerminal_QueryColors_Call{Call: _e.mock.On("QueryColors")}
712+}
713+
714+func (_c *MockTerminal_QueryColors_Call) Run(run func()) *MockTerminal_QueryColors_Call {
715+ _c.Call.Run(func(args mock.Arguments) {
716+ run()
717+ })
718+ return _c
719+}
720+
721+func (_c *MockTerminal_QueryColors_Call) Return(err error) *MockTerminal_QueryColors_Call {
722+ _c.Call.Return(err)
723+ return _c
724+}
725+
726+func (_c *MockTerminal_QueryColors_Call) RunAndReturn(run func() error) *MockTerminal_QueryColors_Call {
727+ _c.Call.Return(run)
728+ return _c
729+}
730+
687731 // ResizeEvents provides a mock function for the type MockTerminal
688732 func (_mock *MockTerminal) ResizeEvents() <-chan domain.Size {
689733 ret := _mock.Called()
@@ -684,6 +684,50 @@ func (_c *MockTerminal_Out_Call) RunAndReturn(run func() io.Writer) *MockTermina
684 return _c684 return _c
685 }685 }
686 686
687+// QueryColors provides a mock function for the type MockTerminal
688+func (_mock *MockTerminal) QueryColors() error {
689+ ret := _mock.Called()
690+
691+ if len(ret) == 0 {
692+ panic("no return value specified for QueryColors")
693+ }
694+
695+ var r0 error
696+ if returnFunc, ok := ret.Get(0).(func() error); ok {
697+ r0 = returnFunc()
698+ } else {
699+ r0 = ret.Error(0)
700+ }
701+ return r0
702+}
703+
704+// MockTerminal_QueryColors_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'QueryColors'
705+type MockTerminal_QueryColors_Call struct {
706+ *mock.Call
707+}
708+
709+// QueryColors is a helper method to define mock.On call
710+func (_e *MockTerminal_Expecter) QueryColors() *MockTerminal_QueryColors_Call {
711+ return &MockTerminal_QueryColors_Call{Call: _e.mock.On("QueryColors")}
712+}
713+
714+func (_c *MockTerminal_QueryColors_Call) Run(run func()) *MockTerminal_QueryColors_Call {
715+ _c.Call.Run(func(args mock.Arguments) {
716+ run()
717+ })
718+ return _c
719+}
720+
721+func (_c *MockTerminal_QueryColors_Call) Return(err error) *MockTerminal_QueryColors_Call {
722+ _c.Call.Return(err)
723+ return _c
724+}
725+
726+func (_c *MockTerminal_QueryColors_Call) RunAndReturn(run func() error) *MockTerminal_QueryColors_Call {
727+ _c.Call.Return(run)
728+ return _c
729+}
730+
687 // ResizeEvents provides a mock function for the type MockTerminal731 // ResizeEvents provides a mock function for the type MockTerminal
688 func (_mock *MockTerminal) ResizeEvents() <-chan domain.Size {732 func (_mock *MockTerminal) ResizeEvents() <-chan domain.Size {
689 ret := _mock.Called()733 ret := _mock.Called()
M internal/ports/ports.go +1 -0
@@ -32,6 +32,7 @@ type Terminal interface {
3232 EnterRaw() (restore func() error, err error)
3333 Size() (domain.Size, error)
3434 ResizeEvents() <-chan domain.Size
35+ QueryColors() error
3536 In() io.Reader
3637 Out() io.Writer
3738 Flush() error
@@ -32,6 +32,7 @@ type Terminal interface {
32 EnterRaw() (restore func() error, err error)32 EnterRaw() (restore func() error, err error)
33 Size() (domain.Size, error)33 Size() (domain.Size, error)
34 ResizeEvents() <-chan domain.Size34 ResizeEvents() <-chan domain.Size
35+ QueryColors() error
35 In() io.Reader36 In() io.Reader
36 Out() io.Writer37 Out() io.Writer
37 Flush() error38 Flush() error
M internal/ports/wire.go +10 -0
@@ -85,6 +85,8 @@ type Theme struct {
8585 HasBackground bool
8686 Background renderer.RGB
8787 TrueColor bool
88+ SchemeKnown bool
89+ Light bool
8890 }
8991
9092 // ImagePush carries a clipboard image from a remote client, to be written to
@@ -411,6 +413,12 @@ func MarshalTheme(m Theme) []byte {
411413 if m.TrueColor {
412414 flags |= 0x04
413415 }
416+ if m.SchemeKnown {
417+ flags |= 0x08
418+ }
419+ if m.Light {
420+ flags |= 0x10
421+ }
414422 return []byte{
415423 flags,
416424 m.Foreground.R, m.Foreground.G, m.Foreground.B,
@@ -458,6 +466,8 @@ func UnmarshalTheme(b []byte) (Theme, error) {
458466 HasBackground: flags&0x02 != 0,
459467 Background: renderer.RGB{R: bgR, G: bgG, B: bgB},
460468 TrueColor: flags&0x04 != 0,
469+ SchemeKnown: flags&0x08 != 0,
470+ Light: flags&0x10 != 0,
461471 }, nil
462472 }
463473
@@ -85,6 +85,8 @@ type Theme struct {
85 HasBackground bool85 HasBackground bool
86 Background renderer.RGB86 Background renderer.RGB
87 TrueColor bool87 TrueColor bool
88+ SchemeKnown bool
89+ Light bool
88 }90 }
89 91
90 // ImagePush carries a clipboard image from a remote client, to be written to92 // ImagePush carries a clipboard image from a remote client, to be written to
@@ -411,6 +413,12 @@ func MarshalTheme(m Theme) []byte {
411 if m.TrueColor {413 if m.TrueColor {
412 flags |= 0x04414 flags |= 0x04
413 }415 }
416+ if m.SchemeKnown {
417+ flags |= 0x08
418+ }
419+ if m.Light {
420+ flags |= 0x10
421+ }
414 return []byte{422 return []byte{
415 flags,423 flags,
416 m.Foreground.R, m.Foreground.G, m.Foreground.B,424 m.Foreground.R, m.Foreground.G, m.Foreground.B,
@@ -458,6 +466,8 @@ func UnmarshalTheme(b []byte) (Theme, error) {
458 HasBackground: flags&0x02 != 0,466 HasBackground: flags&0x02 != 0,
459 Background: renderer.RGB{R: bgR, G: bgG, B: bgB},467 Background: renderer.RGB{R: bgR, G: bgG, B: bgB},
460 TrueColor: flags&0x04 != 0,468 TrueColor: flags&0x04 != 0,
469+ SchemeKnown: flags&0x08 != 0,
470+ Light: flags&0x10 != 0,
461 }, nil471 }, nil
462 }472 }
463 473
M internal/ports/wire_test.go +10 -2
@@ -181,15 +181,23 @@ func TestThemeGoldenAndRoundTrip(t *testing.T) {
181181 want []byte
182182 }{
183183 {
184- name: "foreground background truecolor",
184+ name: "foreground background truecolor dark scheme",
185185 msg: Theme{
186186 HasForeground: true,
187187 Foreground: renderer.RGB{R: 1, G: 2, B: 3},
188188 HasBackground: true,
189189 Background: renderer.RGB{R: 4, G: 5, B: 6},
190190 TrueColor: true,
191+ SchemeKnown: true,
191192 },
192- want: []byte{0x07, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06},
193+ want: []byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06},
194+ },
195+ {
196+ name: "light scheme bit without known is preserved",
197+ msg: Theme{
198+ Light: true,
199+ },
200+ want: []byte{0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
193201 },
194202 {
195203 name: "foreground only no truecolor",
@@ -181,15 +181,23 @@ func TestThemeGoldenAndRoundTrip(t *testing.T) {
181 want []byte181 want []byte
182 }{182 }{
183 {183 {
184- name: "foreground background truecolor",184+ name: "foreground background truecolor dark scheme",
185 msg: Theme{185 msg: Theme{
186 HasForeground: true,186 HasForeground: true,
187 Foreground: renderer.RGB{R: 1, G: 2, B: 3},187 Foreground: renderer.RGB{R: 1, G: 2, B: 3},
188 HasBackground: true,188 HasBackground: true,
189 Background: renderer.RGB{R: 4, G: 5, B: 6},189 Background: renderer.RGB{R: 4, G: 5, B: 6},
190 TrueColor: true,190 TrueColor: true,
191+ SchemeKnown: true,
191 },192 },
192- want: []byte{0x07, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06},193+ want: []byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06},
194+ },
195+ {
196+ name: "light scheme bit without known is preserved",
197+ msg: Theme{
198+ Light: true,
199+ },
200+ want: []byte{0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
193 },201 },
194 {202 {
195 name: "foreground only no truecolor",203 name: "foreground only no truecolor",
M internal/usecase/client/client.go +17 -3
@@ -348,7 +348,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
348348 clip = clipboard
349349 }
350350 go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log)
351- go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, clip, log)
351+ go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, term.QueryColors, clip, log)
352352 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)
353353
354354 // 5. Output/main loop: the only goroutine that touches the terminal.
@@ -472,7 +472,7 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
472472 // exits. That is harmless here — Attach has already returned and restored
473473 // the terminal — and matches the standard pattern for stdin pumps; a
474474 // closable stdin duplicate could lift it later if ever needed.
475-func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, clipboard ports.ClipboardReader, log *slog.Logger) {
475+func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, queryColors func() error, clipboard ports.ClipboardReader, log *slog.Logger) {
476476 defer log.Debug("stdin pump exited")
477477 buf := make([]byte, stdinBufSize)
478478 var scanner theme.Scanner
@@ -522,6 +522,9 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
522522 for {
523523 n, rerr := in.Read(buf)
524524 if n > 0 {
525+ sendTheme := func() {
526+ send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})
527+ }
525528 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {
526529 switch kind {
527530 case 10:
@@ -533,7 +536,18 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
533536 default:
534537 return
535538 }
536- send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})
539+ sendTheme()
540+ }, func(light bool) {
541+ current.SchemeKnown = true
542+ current.Light = light
543+ if queryColors != nil {
544+ go func() {
545+ if err := queryColors(); err != nil {
546+ log.Warn("querying terminal colors", "err", err)
547+ }
548+ }()
549+ }
550+ sendTheme()
537551 }, sink)
538552 if !sendOK.Load() {
539553 return
@@ -348,7 +348,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
348 clip = clipboard348 clip = clipboard
349 }349 }
350 go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log)350 go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log)
351- go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, clip, log)351+ go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, term.QueryColors, clip, log)
352 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)352 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)
353 353
354 // 5. Output/main loop: the only goroutine that touches the terminal.354 // 5. Output/main loop: the only goroutine that touches the terminal.
@@ -472,7 +472,7 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
472 // exits. That is harmless here — Attach has already returned and restored472 // exits. That is harmless here — Attach has already returned and restored
473 // the terminal — and matches the standard pattern for stdin pumps; a473 // the terminal — and matches the standard pattern for stdin pumps; a
474 // closable stdin duplicate could lift it later if ever needed.474 // closable stdin duplicate could lift it later if ever needed.
475-func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, clipboard ports.ClipboardReader, log *slog.Logger) {475+func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, queryColors func() error, clipboard ports.ClipboardReader, log *slog.Logger) {
476 defer log.Debug("stdin pump exited")476 defer log.Debug("stdin pump exited")
477 buf := make([]byte, stdinBufSize)477 buf := make([]byte, stdinBufSize)
478 var scanner theme.Scanner478 var scanner theme.Scanner
@@ -522,6 +522,9 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
522 for {522 for {
523 n, rerr := in.Read(buf)523 n, rerr := in.Read(buf)
524 if n > 0 {524 if n > 0 {
525+ sendTheme := func() {
526+ send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})
527+ }
525 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {528 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {
526 switch kind {529 switch kind {
527 case 10:530 case 10:
@@ -533,7 +536,18 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
533 default:536 default:
534 return537 return
535 }538 }
536- send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})539+ sendTheme()
540+ }, func(light bool) {
541+ current.SchemeKnown = true
542+ current.Light = light
543+ if queryColors != nil {
544+ go func() {
545+ if err := queryColors(); err != nil {
546+ log.Warn("querying terminal colors", "err", err)
547+ }
548+ }()
549+ }
550+ sendTheme()
537 }, sink)551 }, sink)
538 if !sendOK.Load() {552 if !sendOK.Load() {
539 return553 return
M internal/usecase/client/client_test.go +1 -0
@@ -746,6 +746,7 @@ func (t *runTerminal) EnterRaw() (func() error, error) {
746746 }
747747 func (t *runTerminal) Size() (domain.Size, error) { return domain.Size{Cols: 80, Rows: 24}, nil }
748748 func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh }
749+func (t *runTerminal) QueryColors() error { return nil }
749750 func (t *runTerminal) In() io.Reader { return t.in }
750751 func (t *runTerminal) Out() io.Writer { return &t.out }
751752 func (t *runTerminal) Flush() error { return nil }
@@ -746,6 +746,7 @@ func (t *runTerminal) EnterRaw() (func() error, error) {
746 }746 }
747 func (t *runTerminal) Size() (domain.Size, error) { return domain.Size{Cols: 80, Rows: 24}, nil }747 func (t *runTerminal) Size() (domain.Size, error) { return domain.Size{Cols: 80, Rows: 24}, nil }
748 func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh }748 func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh }
749+func (t *runTerminal) QueryColors() error { return nil }
749 func (t *runTerminal) In() io.Reader { return t.in }750 func (t *runTerminal) In() io.Reader { return t.in }
750 func (t *runTerminal) Out() io.Writer { return &t.out }751 func (t *runTerminal) Out() io.Writer { return &t.out }
751 func (t *runTerminal) Flush() error { return nil }752 func (t *runTerminal) Flush() error { return nil }
M internal/usecase/command/command.go +2 -2
@@ -26,6 +26,6 @@ type Context interface {
2626
2727 // Command describes an executable command.
2828 type Command struct {
29- Code, Name, Desc string
30- Run func(Context) error
29+ Slug, Code, Name, Desc string
30+ Run func(Context) error
3131 }
@@ -26,6 +26,6 @@ type Context interface {
26 26
27 // Command describes an executable command.27 // Command describes an executable command.
28 type Command struct {28 type Command struct {
29- Code, Name, Desc string29+ Slug, Code, Name, Desc string
30- Run func(Context) error30+ Run func(Context) error
31 }31 }
M internal/usecase/command/registry.go +20 -20
@@ -5,26 +5,26 @@ import "strings"
55 // Registry returns all commands in display order.
66 func Registry() []Command {
77 commands := []Command{
8- {Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }},
9- {Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context) error { return ctx.CreateSession() }},
10- {Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }},
11- {Code: "SPR", Name: "Split right", Desc: "Split the focused pane to the right", Run: func(ctx Context) error { return ctx.SplitRight() }},
12- {Code: "SPL", Name: "Split left", Desc: "Split the focused pane to the left", Run: func(ctx Context) error { return ctx.SplitLeft() }},
13- {Code: "SPU", Name: "Split up", Desc: "Split the focused pane upward", Run: func(ctx Context) error { return ctx.SplitUp() }},
14- {Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context) error { return ctx.SplitDown() }},
15- {Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context) error { return ctx.StackPane() }},
16- {Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context) error { return ctx.ToggleStack() }},
17- {Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context) error { return ctx.ClosePane() }},
18- {Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context) error { return ctx.FocusPaneLeft() }},
19- {Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context) error { return ctx.FocusPaneRight() }},
20- {Code: "FPU", Name: "Focus pane up", Desc: "Focus the pane above", Run: func(ctx Context) error { return ctx.FocusPaneUp() }},
21- {Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context) error { return ctx.FocusPaneDown() }},
22- {Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }},
23- {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},
24- {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
25- {Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},
26- {Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},
27- {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},
8+ {Slug: "new-tab", Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }},
9+ {Slug: "new-session", Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context) error { return ctx.CreateSession() }},
10+ {Slug: "close-tab", Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }},
11+ {Slug: "split-right", Code: "SPR", Name: "Split right", Desc: "Split the focused pane to the right", Run: func(ctx Context) error { return ctx.SplitRight() }},
12+ {Slug: "split-left", Code: "SPL", Name: "Split left", Desc: "Split the focused pane to the left", Run: func(ctx Context) error { return ctx.SplitLeft() }},
13+ {Slug: "split-up", Code: "SPU", Name: "Split up", Desc: "Split the focused pane upward", Run: func(ctx Context) error { return ctx.SplitUp() }},
14+ {Slug: "split-down", Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context) error { return ctx.SplitDown() }},
15+ {Slug: "stack-pane", Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context) error { return ctx.StackPane() }},
16+ {Slug: "toggle-stack", Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context) error { return ctx.ToggleStack() }},
17+ {Slug: "close-pane", Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context) error { return ctx.ClosePane() }},
18+ {Slug: "focus-pane-left", Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context) error { return ctx.FocusPaneLeft() }},
19+ {Slug: "focus-pane-right", Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context) error { return ctx.FocusPaneRight() }},
20+ {Slug: "focus-pane-up", Code: "FPU", Name: "Focus pane up", Desc: "Focus the pane above", Run: func(ctx Context) error { return ctx.FocusPaneUp() }},
21+ {Slug: "focus-pane-down", Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context) error { return ctx.FocusPaneDown() }},
22+ {Slug: "next-tab", Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }},
23+ {Slug: "previous-tab", Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},
24+ {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
25+ {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},
26+ {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},
27+ {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},
2828 }
2929
3030 return commands
@@ -5,26 +5,26 @@ import "strings"
5 // Registry returns all commands in display order.5 // Registry returns all commands in display order.
6 func Registry() []Command {6 func Registry() []Command {
7 commands := []Command{7 commands := []Command{
8- {Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }},8+ {Slug: "new-tab", Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }},
9- {Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context) error { return ctx.CreateSession() }},9+ {Slug: "new-session", Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context) error { return ctx.CreateSession() }},
10- {Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }},10+ {Slug: "close-tab", Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }},
11- {Code: "SPR", Name: "Split right", Desc: "Split the focused pane to the right", Run: func(ctx Context) error { return ctx.SplitRight() }},11+ {Slug: "split-right", Code: "SPR", Name: "Split right", Desc: "Split the focused pane to the right", Run: func(ctx Context) error { return ctx.SplitRight() }},
12- {Code: "SPL", Name: "Split left", Desc: "Split the focused pane to the left", Run: func(ctx Context) error { return ctx.SplitLeft() }},12+ {Slug: "split-left", Code: "SPL", Name: "Split left", Desc: "Split the focused pane to the left", Run: func(ctx Context) error { return ctx.SplitLeft() }},
13- {Code: "SPU", Name: "Split up", Desc: "Split the focused pane upward", Run: func(ctx Context) error { return ctx.SplitUp() }},13+ {Slug: "split-up", Code: "SPU", Name: "Split up", Desc: "Split the focused pane upward", Run: func(ctx Context) error { return ctx.SplitUp() }},
14- {Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context) error { return ctx.SplitDown() }},14+ {Slug: "split-down", Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context) error { return ctx.SplitDown() }},
15- {Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context) error { return ctx.StackPane() }},15+ {Slug: "stack-pane", Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context) error { return ctx.StackPane() }},
16- {Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context) error { return ctx.ToggleStack() }},16+ {Slug: "toggle-stack", Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context) error { return ctx.ToggleStack() }},
17- {Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context) error { return ctx.ClosePane() }},17+ {Slug: "close-pane", Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context) error { return ctx.ClosePane() }},
18- {Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context) error { return ctx.FocusPaneLeft() }},18+ {Slug: "focus-pane-left", Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context) error { return ctx.FocusPaneLeft() }},
19- {Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context) error { return ctx.FocusPaneRight() }},19+ {Slug: "focus-pane-right", Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context) error { return ctx.FocusPaneRight() }},
20- {Code: "FPU", Name: "Focus pane up", Desc: "Focus the pane above", Run: func(ctx Context) error { return ctx.FocusPaneUp() }},20+ {Slug: "focus-pane-up", Code: "FPU", Name: "Focus pane up", Desc: "Focus the pane above", Run: func(ctx Context) error { return ctx.FocusPaneUp() }},
21- {Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context) error { return ctx.FocusPaneDown() }},21+ {Slug: "focus-pane-down", Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context) error { return ctx.FocusPaneDown() }},
22- {Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }},22+ {Slug: "next-tab", Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }},
23- {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},23+ {Slug: "previous-tab", Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},
24- {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},24+ {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
25- {Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},25+ {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},
26- {Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},26+ {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},
27- {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},27+ {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},
28 }28 }
29 29
30 return commands30 return commands
M internal/usecase/command/registry_test.go +12 -4
@@ -5,7 +5,7 @@ import (
55 "testing"
66 )
77
8-func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) {
8+func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
99 commands := Registry()
1010 wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"}
1111
@@ -14,7 +14,8 @@ func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) {
1414 }
1515
1616 codePattern := regexp.MustCompile(`^[A-Z]{3}$`)
17- seen := make(map[string]bool, len(commands))
17+ seenCodes := make(map[string]bool, len(commands))
18+ seenSlugs := make(map[string]bool, len(commands))
1819 for i, cmd := range commands {
1920 if cmd.Code != wantCodes[i] {
2021 t.Fatalf("Registry()[%d].Code = %q, want %q", i, cmd.Code, wantCodes[i])
@@ -22,10 +23,17 @@ func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) {
2223 if !codePattern.MatchString(cmd.Code) {
2324 t.Errorf("Registry()[%d].Code = %q, want three uppercase letters", i, cmd.Code)
2425 }
25- if seen[cmd.Code] {
26+ if seenCodes[cmd.Code] {
2627 t.Errorf("Registry()[%d].Code = %q, duplicate code", i, cmd.Code)
2728 }
28- seen[cmd.Code] = true
29+ seenCodes[cmd.Code] = true
30+ if cmd.Slug == "" {
31+ t.Errorf("Registry()[%d].Slug is empty", i)
32+ }
33+ if seenSlugs[cmd.Slug] {
34+ t.Errorf("Registry()[%d].Slug = %q, duplicate slug", i, cmd.Slug)
35+ }
36+ seenSlugs[cmd.Slug] = true
2937 }
3038 }
3139
@@ -5,7 +5,7 @@ import (
5 "testing"5 "testing"
6 )6 )
7 7
8-func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) {8+func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
9 commands := Registry()9 commands := Registry()
10 wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"}10 wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"}
11 11
@@ -14,7 +14,8 @@ func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) {
14 }14 }
15 15
16 codePattern := regexp.MustCompile(`^[A-Z]{3}$`)16 codePattern := regexp.MustCompile(`^[A-Z]{3}$`)
17- seen := make(map[string]bool, len(commands))17+ seenCodes := make(map[string]bool, len(commands))
18+ seenSlugs := make(map[string]bool, len(commands))
18 for i, cmd := range commands {19 for i, cmd := range commands {
19 if cmd.Code != wantCodes[i] {20 if cmd.Code != wantCodes[i] {
20 t.Fatalf("Registry()[%d].Code = %q, want %q", i, cmd.Code, wantCodes[i])21 t.Fatalf("Registry()[%d].Code = %q, want %q", i, cmd.Code, wantCodes[i])
@@ -22,10 +23,17 @@ func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) {
22 if !codePattern.MatchString(cmd.Code) {23 if !codePattern.MatchString(cmd.Code) {
23 t.Errorf("Registry()[%d].Code = %q, want three uppercase letters", i, cmd.Code)24 t.Errorf("Registry()[%d].Code = %q, want three uppercase letters", i, cmd.Code)
24 }25 }
25- if seen[cmd.Code] {26+ if seenCodes[cmd.Code] {
26 t.Errorf("Registry()[%d].Code = %q, duplicate code", i, cmd.Code)27 t.Errorf("Registry()[%d].Code = %q, duplicate code", i, cmd.Code)
27 }28 }
28- seen[cmd.Code] = true29+ seenCodes[cmd.Code] = true
30+ if cmd.Slug == "" {
31+ t.Errorf("Registry()[%d].Slug is empty", i)
32+ }
33+ if seenSlugs[cmd.Slug] {
34+ t.Errorf("Registry()[%d].Slug = %q, duplicate slug", i, cmd.Slug)
35+ }
36+ seenSlugs[cmd.Slug] = true
29 }37 }
30 }38 }
31 39
M internal/usecase/daemon/client.go +30 -56
@@ -55,6 +55,7 @@ type attachedClient struct {
5555 mouseScan mouse.Scanner
5656 themeMu sync.Mutex
5757 theme themeui.Theme
58+ clientTheme themeui.Theme
5859 lastCursor cursorOut
5960 linkMu sync.Mutex
6061 sendMu sync.Mutex
@@ -114,12 +115,24 @@ func (ac *attachedClient) getTheme() themeui.Theme {
114115 return ac.theme
115116 }
116117
118+func (ac *attachedClient) getClientTheme() themeui.Theme {
119+ ac.themeMu.Lock()
120+ defer ac.themeMu.Unlock()
121+ return ac.clientTheme
122+}
123+
117124 func (ac *attachedClient) setTheme(t themeui.Theme) {
118125 ac.themeMu.Lock()
119126 ac.theme = t
120127 ac.themeMu.Unlock()
121128 }
122129
130+func (ac *attachedClient) setClientTheme(t themeui.Theme) {
131+ ac.themeMu.Lock()
132+ ac.clientTheme = t
133+ ac.themeMu.Unlock()
134+}
135+
123136 func (ac *attachedClient) transport() ports.Transport {
124137 ac.linkMu.Lock()
125138 defer ac.linkMu.Unlock()
@@ -320,25 +333,15 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
320333 }
321334 ac.initOverlays()
322335 ac.setSession(sess)
323- ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})
336+ ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
324337 sess.mu.Lock()
325338 old := sess.client
326339 sess.client = ac
327340 name := sess.name
328- tabs := append([]*tab(nil), sess.tabs...)
329341 sess.mu.Unlock()
330342 d.touchMRU(sess)
331343 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
332- for _, tb := range tabs {
333- tb.mu.Lock()
334- panes := tb.panesSnapshot()
335- tb.mu.Unlock()
336- for _, p := range panes {
337- p.mu.Lock()
338- p.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false)
339- p.mu.Unlock()
340- }
341- }
344+ d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}))
342345 return ac, old
343346 }
344347
@@ -354,23 +357,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
354357 // (and run its own attach-time reset), so this call must leave the tabs
355358 // alone rather than clobbering that client's freshly applied colors.
356359 func (d *Daemon) resetScreenDefaultColors(sess *session) {
357- sess.mu.Lock()
358- if sess.client != nil {
359- sess.mu.Unlock()
360- return
361- }
362- tabs := append([]*tab(nil), sess.tabs...)
363- sess.mu.Unlock()
364- for _, tb := range tabs {
365- tb.mu.Lock()
366- panes := tb.panesSnapshot()
367- tb.mu.Unlock()
368- for _, p := range panes {
369- p.mu.Lock()
370- p.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false)
371- p.mu.Unlock()
372- }
373- }
360+ d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{}))
374361 }
375362
376363 func (d *Daemon) detachReplacedClient(old *attachedClient) {
@@ -508,35 +495,22 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
508495 // detachIfCurrent clears the client iff ac is the current one, reporting
509496
510497 func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) {
511- t := themeui.Theme{
512- Foreground: msg.Foreground,
513- Background: msg.Background,
514- HasFG: msg.HasForeground,
515- HasBG: msg.HasBackground,
516- TrueColor: msg.TrueColor,
517- Known: msg.HasForeground && msg.HasBackground,
518- }
519-
520- knownDefaultColors := t.HasFG && t.HasBG
521- sess.mu.Lock()
522- if sess.client != ac {
523- sess.mu.Unlock()
498+ clientTheme := themeui.Theme{
499+ Foreground: msg.Foreground,
500+ Background: msg.Background,
501+ HasFG: msg.HasForeground,
502+ HasBG: msg.HasBackground,
503+ TrueColor: msg.TrueColor,
504+ Known: msg.HasForeground && msg.HasBackground,
505+ SchemeKnown: msg.SchemeKnown,
506+ Light: msg.Light,
507+ }
508+ ac.setClientTheme(clientTheme)
509+ t := d.effectiveTheme(clientTheme)
510+
511+ if !d.applyHostTheme(sess, ac, t) {
524512 return
525513 }
526- tabs := append([]*tab(nil), sess.tabs...)
527- sess.mu.Unlock()
528-
529- ac.setTheme(t)
530- for _, tb := range tabs {
531- tb.mu.Lock()
532- panes := tb.panesSnapshot()
533- tb.mu.Unlock()
534- for _, p := range panes {
535- p.mu.Lock()
536- p.screen.SetDefaultColors(t.Foreground, t.Background, knownDefaultColors)
537- p.mu.Unlock()
538- }
539- }
540514 d.paint(sess, ac, true)
541515 }
542516
@@ -55,6 +55,7 @@ type attachedClient struct {
55 mouseScan mouse.Scanner55 mouseScan mouse.Scanner
56 themeMu sync.Mutex56 themeMu sync.Mutex
57 theme themeui.Theme57 theme themeui.Theme
58+ clientTheme themeui.Theme
58 lastCursor cursorOut59 lastCursor cursorOut
59 linkMu sync.Mutex60 linkMu sync.Mutex
60 sendMu sync.Mutex61 sendMu sync.Mutex
@@ -114,12 +115,24 @@ func (ac *attachedClient) getTheme() themeui.Theme {
114 return ac.theme115 return ac.theme
115 }116 }
116 117
118+func (ac *attachedClient) getClientTheme() themeui.Theme {
119+ ac.themeMu.Lock()
120+ defer ac.themeMu.Unlock()
121+ return ac.clientTheme
122+}
123+
117 func (ac *attachedClient) setTheme(t themeui.Theme) {124 func (ac *attachedClient) setTheme(t themeui.Theme) {
118 ac.themeMu.Lock()125 ac.themeMu.Lock()
119 ac.theme = t126 ac.theme = t
120 ac.themeMu.Unlock()127 ac.themeMu.Unlock()
121 }128 }
122 129
130+func (ac *attachedClient) setClientTheme(t themeui.Theme) {
131+ ac.themeMu.Lock()
132+ ac.clientTheme = t
133+ ac.themeMu.Unlock()
134+}
135+
123 func (ac *attachedClient) transport() ports.Transport {136 func (ac *attachedClient) transport() ports.Transport {
124 ac.linkMu.Lock()137 ac.linkMu.Lock()
125 defer ac.linkMu.Unlock()138 defer ac.linkMu.Unlock()
@@ -320,25 +333,15 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
320 }333 }
321 ac.initOverlays()334 ac.initOverlays()
322 ac.setSession(sess)335 ac.setSession(sess)
323- ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})336+ ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
324 sess.mu.Lock()337 sess.mu.Lock()
325 old := sess.client338 old := sess.client
326 sess.client = ac339 sess.client = ac
327 name := sess.name340 name := sess.name
328- tabs := append([]*tab(nil), sess.tabs...)
329 sess.mu.Unlock()341 sess.mu.Unlock()
330 d.touchMRU(sess)342 d.touchMRU(sess)
331 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)343 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
332- for _, tb := range tabs {344+ d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}))
333- tb.mu.Lock()
334- panes := tb.panesSnapshot()
335- tb.mu.Unlock()
336- for _, p := range panes {
337- p.mu.Lock()
338- p.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false)
339- p.mu.Unlock()
340- }
341- }
342 return ac, old345 return ac, old
343 }346 }
344 347
@@ -354,23 +357,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
354 // (and run its own attach-time reset), so this call must leave the tabs357 // (and run its own attach-time reset), so this call must leave the tabs
355 // alone rather than clobbering that client's freshly applied colors.358 // alone rather than clobbering that client's freshly applied colors.
356 func (d *Daemon) resetScreenDefaultColors(sess *session) {359 func (d *Daemon) resetScreenDefaultColors(sess *session) {
357- sess.mu.Lock()360+ d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{}))
358- if sess.client != nil {
359- sess.mu.Unlock()
360- return
361- }
362- tabs := append([]*tab(nil), sess.tabs...)
363- sess.mu.Unlock()
364- for _, tb := range tabs {
365- tb.mu.Lock()
366- panes := tb.panesSnapshot()
367- tb.mu.Unlock()
368- for _, p := range panes {
369- p.mu.Lock()
370- p.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false)
371- p.mu.Unlock()
372- }
373- }
374 }361 }
375 362
376 func (d *Daemon) detachReplacedClient(old *attachedClient) {363 func (d *Daemon) detachReplacedClient(old *attachedClient) {
@@ -508,35 +495,22 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
508 // detachIfCurrent clears the client iff ac is the current one, reporting495 // detachIfCurrent clears the client iff ac is the current one, reporting
509 496
510 func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) {497 func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) {
511- t := themeui.Theme{498+ clientTheme := themeui.Theme{
512- Foreground: msg.Foreground,499+ Foreground: msg.Foreground,
513- Background: msg.Background,500+ Background: msg.Background,
514- HasFG: msg.HasForeground,501+ HasFG: msg.HasForeground,
515- HasBG: msg.HasBackground,502+ HasBG: msg.HasBackground,
516- TrueColor: msg.TrueColor,503+ TrueColor: msg.TrueColor,
517- Known: msg.HasForeground && msg.HasBackground,504+ Known: msg.HasForeground && msg.HasBackground,
518- }505+ SchemeKnown: msg.SchemeKnown,
519-506+ Light: msg.Light,
520- knownDefaultColors := t.HasFG && t.HasBG507+ }
521- sess.mu.Lock()508+ ac.setClientTheme(clientTheme)
522- if sess.client != ac {509+ t := d.effectiveTheme(clientTheme)
523- sess.mu.Unlock()510+
511+ if !d.applyHostTheme(sess, ac, t) {
524 return512 return
525 }513 }
526- tabs := append([]*tab(nil), sess.tabs...)
527- sess.mu.Unlock()
528-
529- ac.setTheme(t)
530- for _, tb := range tabs {
531- tb.mu.Lock()
532- panes := tb.panesSnapshot()
533- tb.mu.Unlock()
534- for _, p := range panes {
535- p.mu.Lock()
536- p.screen.SetDefaultColors(t.Foreground, t.Background, knownDefaultColors)
537- p.mu.Unlock()
538- }
539- }
540 d.paint(sess, ac, true)514 d.paint(sess, ac, true)
541 }515 }
542 516
A internal/usecase/daemon/config.go +152 -0
@@ -0,0 +1,152 @@
1+package daemon
2+
3+import (
4+ "fmt"
5+ "regexp"
6+ "sort"
7+ "strings"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/usecase/command"
11+ "github.com/bnema/vev/internal/usecase/keys"
12+ "github.com/bnema/vev/internal/usecase/theme"
13+)
14+
15+var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`)
16+
17+// ApplyConfig validates and atomically swaps daemon runtime configuration.
18+func (d *Daemon) ApplyConfig(cfg domain.Config) {
19+ bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries)
20+ if len(cfg.BindingEntries) == 0 && len(cfg.Bindings) > 0 {
21+ bindings, warnings = keys.BuildBindings(cfg.Bindings)
22+ }
23+ for _, warning := range warnings {
24+ d.logConfigWarning(warning)
25+ }
26+ overrides := d.buildCodeOverrides(cfg.Codes)
27+ d.bindings.Store(bindings)
28+ d.codeOverrides.Store(&overrides)
29+ d.themeMode.Store(uint32(cfg.Theme))
30+ d.reapplyThemeAllSessions()
31+ d.repaintAllAttachedClients()
32+}
33+
34+func (d *Daemon) buildCodeOverrides(configured map[string]string) map[string]string {
35+ commands := command.Registry()
36+ bySlug := make(map[string]command.Command, len(commands))
37+ for _, cmd := range commands {
38+ bySlug[cmd.Slug] = cmd
39+ }
40+
41+ desired := make(map[string]string)
42+ for _, slug := range sortedKeys(configured) {
43+ if _, ok := bySlug[slug]; !ok {
44+ d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("unknown command code slug %q", slug)})
45+ continue
46+ }
47+ code := strings.ToUpper(strings.TrimSpace(configured[slug]))
48+ if !commandCodePattern.MatchString(code) {
49+ d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("invalid command code for %q", slug)})
50+ continue
51+ }
52+ desired[slug] = code
53+ }
54+
55+ used := make(map[string]string, len(commands))
56+ for _, cmd := range commands {
57+ used[cmd.Code] = cmd.Slug
58+ }
59+ overrides := make(map[string]string)
60+ for _, slug := range sortedKeys(desired) {
61+ code := desired[slug]
62+ fallback := bySlug[slug].Code
63+ delete(used, fallback)
64+ if existing, ok := used[code]; ok && existing != slug {
65+ d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("command code %q for %q conflicts with %q", code, slug, existing)})
66+ if fallbackOwner, fallbackUsed := used[fallback]; fallbackUsed && fallbackOwner != slug {
67+ d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("default command code %q for %q conflicts with %q", fallback, slug, fallbackOwner)})
68+ continue
69+ }
70+ used[fallback] = slug
71+ continue
72+ }
73+ used[code] = slug
74+ overrides[slug] = code
75+ }
76+ return overrides
77+}
78+
79+func sortedKeys(values map[string]string) []string {
80+ keys := make([]string, 0, len(values))
81+ for key := range values {
82+ keys = append(keys, key)
83+ }
84+ sort.Strings(keys)
85+ return keys
86+}
87+
88+func (d *Daemon) logConfigWarning(w domain.Warning) {
89+ if w.Line > 0 {
90+ d.log.Warn("config warning", "line", w.Line, "msg", w.Msg)
91+ return
92+ }
93+ d.log.Warn("config warning", "msg", w.Msg)
94+}
95+
96+func (d *Daemon) codeOverrideSnapshot() map[string]string {
97+ overrides := d.codeOverrides.Load()
98+ if overrides == nil {
99+ return nil
100+ }
101+ return *overrides
102+}
103+
104+func commandWithOverrides(cmd command.Command, overrides map[string]string) command.Command {
105+ if code, ok := overrides[cmd.Slug]; ok {
106+ cmd.Code = code
107+ }
108+ return cmd
109+}
110+
111+func (d *Daemon) commandByEffectiveCode(code string) (command.Command, bool) {
112+ code = strings.ToUpper(strings.TrimSpace(code))
113+ overrides := d.codeOverrideSnapshot()
114+ for _, cmd := range command.Registry() {
115+ cmd = commandWithOverrides(cmd, overrides)
116+ if cmd.Code == code {
117+ return cmd, true
118+ }
119+ }
120+ return command.Command{}, false
121+}
122+
123+func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme {
124+ switch domain.ThemeMode(d.themeMode.Load()) {
125+ case domain.ThemeDark:
126+ return theme.BuiltinDark
127+ case domain.ThemeLight:
128+ return theme.BuiltinLight
129+ default:
130+ return clientTheme
131+ }
132+}
133+
134+func (d *Daemon) reapplyThemeAllSessions() {
135+ d.mu.Lock()
136+ sessions := make([]*session, 0, len(d.sessions))
137+ for _, sess := range d.sessions {
138+ sessions = append(sessions, sess)
139+ }
140+ d.mu.Unlock()
141+
142+ for _, sess := range sessions {
143+ sess.mu.Lock()
144+ ac := sess.client
145+ sess.mu.Unlock()
146+ var clientTheme theme.Theme
147+ if ac != nil {
148+ clientTheme = ac.getClientTheme()
149+ }
150+ d.applyHostTheme(sess, ac, d.effectiveTheme(clientTheme))
151+ }
152+}
@@ -0,0 +1,152 @@
1+package daemon
2+
3+import (
4+ "fmt"
5+ "regexp"
6+ "sort"
7+ "strings"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/usecase/command"
11+ "github.com/bnema/vev/internal/usecase/keys"
12+ "github.com/bnema/vev/internal/usecase/theme"
13+)
14+
15+var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`)
16+
17+// ApplyConfig validates and atomically swaps daemon runtime configuration.
18+func (d *Daemon) ApplyConfig(cfg domain.Config) {
19+ bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries)
20+ if len(cfg.BindingEntries) == 0 && len(cfg.Bindings) > 0 {
21+ bindings, warnings = keys.BuildBindings(cfg.Bindings)
22+ }
23+ for _, warning := range warnings {
24+ d.logConfigWarning(warning)
25+ }
26+ overrides := d.buildCodeOverrides(cfg.Codes)
27+ d.bindings.Store(bindings)
28+ d.codeOverrides.Store(&overrides)
29+ d.themeMode.Store(uint32(cfg.Theme))
30+ d.reapplyThemeAllSessions()
31+ d.repaintAllAttachedClients()
32+}
33+
34+func (d *Daemon) buildCodeOverrides(configured map[string]string) map[string]string {
35+ commands := command.Registry()
36+ bySlug := make(map[string]command.Command, len(commands))
37+ for _, cmd := range commands {
38+ bySlug[cmd.Slug] = cmd
39+ }
40+
41+ desired := make(map[string]string)
42+ for _, slug := range sortedKeys(configured) {
43+ if _, ok := bySlug[slug]; !ok {
44+ d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("unknown command code slug %q", slug)})
45+ continue
46+ }
47+ code := strings.ToUpper(strings.TrimSpace(configured[slug]))
48+ if !commandCodePattern.MatchString(code) {
49+ d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("invalid command code for %q", slug)})
50+ continue
51+ }
52+ desired[slug] = code
53+ }
54+
55+ used := make(map[string]string, len(commands))
56+ for _, cmd := range commands {
57+ used[cmd.Code] = cmd.Slug
58+ }
59+ overrides := make(map[string]string)
60+ for _, slug := range sortedKeys(desired) {
61+ code := desired[slug]
62+ fallback := bySlug[slug].Code
63+ delete(used, fallback)
64+ if existing, ok := used[code]; ok && existing != slug {
65+ d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("command code %q for %q conflicts with %q", code, slug, existing)})
66+ if fallbackOwner, fallbackUsed := used[fallback]; fallbackUsed && fallbackOwner != slug {
67+ d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("default command code %q for %q conflicts with %q", fallback, slug, fallbackOwner)})
68+ continue
69+ }
70+ used[fallback] = slug
71+ continue
72+ }
73+ used[code] = slug
74+ overrides[slug] = code
75+ }
76+ return overrides
77+}
78+
79+func sortedKeys(values map[string]string) []string {
80+ keys := make([]string, 0, len(values))
81+ for key := range values {
82+ keys = append(keys, key)
83+ }
84+ sort.Strings(keys)
85+ return keys
86+}
87+
88+func (d *Daemon) logConfigWarning(w domain.Warning) {
89+ if w.Line > 0 {
90+ d.log.Warn("config warning", "line", w.Line, "msg", w.Msg)
91+ return
92+ }
93+ d.log.Warn("config warning", "msg", w.Msg)
94+}
95+
96+func (d *Daemon) codeOverrideSnapshot() map[string]string {
97+ overrides := d.codeOverrides.Load()
98+ if overrides == nil {
99+ return nil
100+ }
101+ return *overrides
102+}
103+
104+func commandWithOverrides(cmd command.Command, overrides map[string]string) command.Command {
105+ if code, ok := overrides[cmd.Slug]; ok {
106+ cmd.Code = code
107+ }
108+ return cmd
109+}
110+
111+func (d *Daemon) commandByEffectiveCode(code string) (command.Command, bool) {
112+ code = strings.ToUpper(strings.TrimSpace(code))
113+ overrides := d.codeOverrideSnapshot()
114+ for _, cmd := range command.Registry() {
115+ cmd = commandWithOverrides(cmd, overrides)
116+ if cmd.Code == code {
117+ return cmd, true
118+ }
119+ }
120+ return command.Command{}, false
121+}
122+
123+func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme {
124+ switch domain.ThemeMode(d.themeMode.Load()) {
125+ case domain.ThemeDark:
126+ return theme.BuiltinDark
127+ case domain.ThemeLight:
128+ return theme.BuiltinLight
129+ default:
130+ return clientTheme
131+ }
132+}
133+
134+func (d *Daemon) reapplyThemeAllSessions() {
135+ d.mu.Lock()
136+ sessions := make([]*session, 0, len(d.sessions))
137+ for _, sess := range d.sessions {
138+ sessions = append(sessions, sess)
139+ }
140+ d.mu.Unlock()
141+
142+ for _, sess := range sessions {
143+ sess.mu.Lock()
144+ ac := sess.client
145+ sess.mu.Unlock()
146+ var clientTheme theme.Theme
147+ if ac != nil {
148+ clientTheme = ac.getClientTheme()
149+ }
150+ d.applyHostTheme(sess, ac, d.effectiveTheme(clientTheme))
151+ }
152+}
A internal/usecase/daemon/config_test.go +142 -0
@@ -0,0 +1,142 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/usecase/keys"
10+)
11+
12+type captureKeyHandler struct {
13+ actions []keys.Action
14+ forward [][]byte
15+}
16+
17+func (h *captureKeyHandler) Forward(data []byte) {
18+ h.forward = append(h.forward, append([]byte(nil), data...))
19+}
20+func (h *captureKeyHandler) Action(action keys.Action) { h.actions = append(h.actions, action) }
21+
22+func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
23+ d := newTestDaemon(t, nil, stubClock{})
24+ h := &captureKeyHandler{}
25+ r := keys.NewRouter(stubClock{}, h, &d.bindings)
26+
27+ r.Route([]byte("\x1bh"))
28+ require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft}, h.actions)
29+
30+ d.ApplyConfig(domain.Config{
31+ Theme: domain.ThemeAuto,
32+ Bindings: map[string]string{
33+ "focus-pane-left": "alt+q",
34+ },
35+ Codes: map[string]string{
36+ "new-tab": "nt",
37+ },
38+ })
39+
40+ r.Route([]byte("\x1bh"))
41+ require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft}, h.actions, "old key should no longer be intercepted")
42+ require.Equal(t, [][]byte{{'\x1b', 'h'}}, h.forward)
43+ r.Route([]byte("\x1bq"))
44+ require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft, keys.ActionFocusPaneLeft}, h.actions)
45+
46+ cmd, ok := d.commandByCode("NT")
47+ require.True(t, ok)
48+ require.Equal(t, "new-tab", cmd.Slug)
49+ require.Equal(t, "NT", cmd.Code)
50+}
51+
52+func TestPaletteCommandsApplyOverridesAndResolveByCode(t *testing.T) {
53+ d := newTestDaemon(t, nil, stubClock{})
54+ d.ApplyConfig(domain.Config{
55+ Theme: domain.ThemeAuto,
56+ Codes: map[string]string{
57+ "new-tab": "NN",
58+ },
59+ })
60+
61+ commands := d.paletteCommands()
62+ require.NotEmpty(t, commands)
63+ require.Equal(t, "new-tab", commands[0].Slug)
64+ require.Equal(t, "NN", commands[0].Code)
65+
66+ cmd, ok := d.commandByCode("nn")
67+ require.True(t, ok)
68+ require.Equal(t, "new-tab", cmd.Slug)
69+ require.Equal(t, "NN", cmd.Code)
70+}
71+
72+func TestPaletteCommandsUseOverrideSnapshotForRecentAndListing(t *testing.T) {
73+ d := newTestDaemon(t, nil, stubClock{})
74+ d.ApplyConfig(domain.Config{
75+ Theme: domain.ThemeAuto,
76+ Codes: map[string]string{
77+ "split-right": "SRX",
78+ },
79+ })
80+ d.recordPaletteUse("SRX")
81+
82+ commands := d.paletteCommands()
83+ require.NotEmpty(t, commands)
84+ require.Equal(t, "split-right", commands[0].Slug)
85+ require.Equal(t, "SRX", commands[0].Code)
86+
87+ seen := 0
88+ for _, cmd := range commands {
89+ if cmd.Slug == "split-right" {
90+ seen++
91+ require.Equal(t, "SRX", cmd.Code)
92+ }
93+ }
94+ require.Equal(t, 1, seen)
95+}
96+
97+func TestCodeOverrideConflictRestoresDefaultWithoutDuplicates(t *testing.T) {
98+ d := newTestDaemon(t, nil, stubClock{})
99+ d.ApplyConfig(domain.Config{
100+ Theme: domain.ThemeAuto,
101+ Codes: map[string]string{
102+ "new-session": "CNT",
103+ "new-tab": "DET",
104+ },
105+ })
106+
107+ commands := d.paletteCommands()
108+ byCode := make(map[string]string, len(commands))
109+ for _, cmd := range commands {
110+ if existing, ok := byCode[cmd.Code]; ok {
111+ t.Fatalf("duplicate effective code %q for %q and %q", cmd.Code, existing, cmd.Slug)
112+ }
113+ byCode[cmd.Code] = cmd.Slug
114+ }
115+ require.Equal(t, "new-tab", byCode["CNT"])
116+ require.Equal(t, "new-session", byCode["CNS"])
117+ require.Equal(t, "detach", byCode["DET"])
118+}
119+
120+func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) {
121+ d := newTestDaemon(t, nil, stubClock{})
122+ require.NotPanics(t, func() {
123+ d.ApplyConfig(domain.Config{
124+ Theme: domain.ThemeAuto,
125+ Bindings: map[string]string{
126+ "unknown-action": "alt+x",
127+ "focus-pane-left": "ctrl+x",
128+ },
129+ Codes: map[string]string{
130+ "new-tab": "toolong",
131+ "split-right": "det",
132+ "missing-command": "OK",
133+ },
134+ })
135+ })
136+
137+ cmd, ok := d.commandByCode("CNT")
138+ require.True(t, ok)
139+ require.Equal(t, "new-tab", cmd.Slug)
140+ _, ok = d.commandByCode("TOOLONG")
141+ require.False(t, ok)
142+}
@@ -0,0 +1,142 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/usecase/keys"
10+)
11+
12+type captureKeyHandler struct {
13+ actions []keys.Action
14+ forward [][]byte
15+}
16+
17+func (h *captureKeyHandler) Forward(data []byte) {
18+ h.forward = append(h.forward, append([]byte(nil), data...))
19+}
20+func (h *captureKeyHandler) Action(action keys.Action) { h.actions = append(h.actions, action) }
21+
22+func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
23+ d := newTestDaemon(t, nil, stubClock{})
24+ h := &captureKeyHandler{}
25+ r := keys.NewRouter(stubClock{}, h, &d.bindings)
26+
27+ r.Route([]byte("\x1bh"))
28+ require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft}, h.actions)
29+
30+ d.ApplyConfig(domain.Config{
31+ Theme: domain.ThemeAuto,
32+ Bindings: map[string]string{
33+ "focus-pane-left": "alt+q",
34+ },
35+ Codes: map[string]string{
36+ "new-tab": "nt",
37+ },
38+ })
39+
40+ r.Route([]byte("\x1bh"))
41+ require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft}, h.actions, "old key should no longer be intercepted")
42+ require.Equal(t, [][]byte{{'\x1b', 'h'}}, h.forward)
43+ r.Route([]byte("\x1bq"))
44+ require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft, keys.ActionFocusPaneLeft}, h.actions)
45+
46+ cmd, ok := d.commandByCode("NT")
47+ require.True(t, ok)
48+ require.Equal(t, "new-tab", cmd.Slug)
49+ require.Equal(t, "NT", cmd.Code)
50+}
51+
52+func TestPaletteCommandsApplyOverridesAndResolveByCode(t *testing.T) {
53+ d := newTestDaemon(t, nil, stubClock{})
54+ d.ApplyConfig(domain.Config{
55+ Theme: domain.ThemeAuto,
56+ Codes: map[string]string{
57+ "new-tab": "NN",
58+ },
59+ })
60+
61+ commands := d.paletteCommands()
62+ require.NotEmpty(t, commands)
63+ require.Equal(t, "new-tab", commands[0].Slug)
64+ require.Equal(t, "NN", commands[0].Code)
65+
66+ cmd, ok := d.commandByCode("nn")
67+ require.True(t, ok)
68+ require.Equal(t, "new-tab", cmd.Slug)
69+ require.Equal(t, "NN", cmd.Code)
70+}
71+
72+func TestPaletteCommandsUseOverrideSnapshotForRecentAndListing(t *testing.T) {
73+ d := newTestDaemon(t, nil, stubClock{})
74+ d.ApplyConfig(domain.Config{
75+ Theme: domain.ThemeAuto,
76+ Codes: map[string]string{
77+ "split-right": "SRX",
78+ },
79+ })
80+ d.recordPaletteUse("SRX")
81+
82+ commands := d.paletteCommands()
83+ require.NotEmpty(t, commands)
84+ require.Equal(t, "split-right", commands[0].Slug)
85+ require.Equal(t, "SRX", commands[0].Code)
86+
87+ seen := 0
88+ for _, cmd := range commands {
89+ if cmd.Slug == "split-right" {
90+ seen++
91+ require.Equal(t, "SRX", cmd.Code)
92+ }
93+ }
94+ require.Equal(t, 1, seen)
95+}
96+
97+func TestCodeOverrideConflictRestoresDefaultWithoutDuplicates(t *testing.T) {
98+ d := newTestDaemon(t, nil, stubClock{})
99+ d.ApplyConfig(domain.Config{
100+ Theme: domain.ThemeAuto,
101+ Codes: map[string]string{
102+ "new-session": "CNT",
103+ "new-tab": "DET",
104+ },
105+ })
106+
107+ commands := d.paletteCommands()
108+ byCode := make(map[string]string, len(commands))
109+ for _, cmd := range commands {
110+ if existing, ok := byCode[cmd.Code]; ok {
111+ t.Fatalf("duplicate effective code %q for %q and %q", cmd.Code, existing, cmd.Slug)
112+ }
113+ byCode[cmd.Code] = cmd.Slug
114+ }
115+ require.Equal(t, "new-tab", byCode["CNT"])
116+ require.Equal(t, "new-session", byCode["CNS"])
117+ require.Equal(t, "detach", byCode["DET"])
118+}
119+
120+func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) {
121+ d := newTestDaemon(t, nil, stubClock{})
122+ require.NotPanics(t, func() {
123+ d.ApplyConfig(domain.Config{
124+ Theme: domain.ThemeAuto,
125+ Bindings: map[string]string{
126+ "unknown-action": "alt+x",
127+ "focus-pane-left": "ctrl+x",
128+ },
129+ Codes: map[string]string{
130+ "new-tab": "toolong",
131+ "split-right": "det",
132+ "missing-command": "OK",
133+ },
134+ })
135+ })
136+
137+ cmd, ok := d.commandByCode("CNT")
138+ require.True(t, ok)
139+ require.Equal(t, "new-tab", cmd.Slug)
140+ _, ok = d.commandByCode("TOOLONG")
141+ require.False(t, ok)
142+}
M internal/usecase/daemon/daemon.go +23 -0
@@ -38,6 +38,8 @@ import (
3838 "github.com/bnema/vev/internal/persist"
3939 "github.com/bnema/vev/internal/platform"
4040 "github.com/bnema/vev/internal/ports"
41+ "github.com/bnema/vev/internal/usecase/command"
42+ "github.com/bnema/vev/internal/usecase/keys"
4143 )
4244
4345 // Scheduler debounce bounds. Idle updates use the minimum for low latency;
@@ -105,6 +107,10 @@ type Daemon struct {
105107 persistEnabled bool
106108 procCwd func(int) (string, error)
107109 procComm func(int) (string, error)
110+ commandByCode func(string) (command.Command, bool)
111+ bindings atomic.Pointer[keys.Bindings]
112+ codeOverrides atomic.Pointer[map[string]string]
113+ themeMode atomic.Uint32
108114 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
109115 // (see clipboard.go); empty means use os.TempDir().
110116 tempDir string
@@ -181,6 +187,13 @@ func WithTempDir(dir string) Option {
181187 }
182188 }
183189
190+// WithConfig applies the initial user configuration.
191+func WithConfig(cfg domain.Config) Option {
192+ return func(d *Daemon) {
193+ d.ApplyConfig(cfg)
194+ }
195+}
196+
184197 // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the
185198 // render debounce, and log receives diagnostics (defaults to slog.Default).
186199 func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {
@@ -218,6 +231,16 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
218231 if d.procComm == nil {
219232 d.procComm = platform.ProcessComm
220233 }
234+ if d.commandByCode == nil {
235+ d.commandByCode = d.commandByEffectiveCode
236+ }
237+ if d.bindings.Load() == nil {
238+ d.bindings.Store(keys.DefaultBindings())
239+ }
240+ if d.codeOverrides.Load() == nil {
241+ empty := map[string]string{}
242+ d.codeOverrides.Store(&empty)
243+ }
221244 if records, err := d.persist.LoadAll(); err != nil {
222245 d.log.Warn("loading persisted sessions failed", "err", err)
223246 } else {
@@ -38,6 +38,8 @@ import (
38 "github.com/bnema/vev/internal/persist"38 "github.com/bnema/vev/internal/persist"
39 "github.com/bnema/vev/internal/platform"39 "github.com/bnema/vev/internal/platform"
40 "github.com/bnema/vev/internal/ports"40 "github.com/bnema/vev/internal/ports"
41+ "github.com/bnema/vev/internal/usecase/command"
42+ "github.com/bnema/vev/internal/usecase/keys"
41 )43 )
42 44
43 // Scheduler debounce bounds. Idle updates use the minimum for low latency;45 // Scheduler debounce bounds. Idle updates use the minimum for low latency;
@@ -105,6 +107,10 @@ type Daemon struct {
105 persistEnabled bool107 persistEnabled bool
106 procCwd func(int) (string, error)108 procCwd func(int) (string, error)
107 procComm func(int) (string, error)109 procComm func(int) (string, error)
110+ commandByCode func(string) (command.Command, bool)
111+ bindings atomic.Pointer[keys.Bindings]
112+ codeOverrides atomic.Pointer[map[string]string]
113+ themeMode atomic.Uint32
108 // tempDir overrides os.TempDir() for clipboard-image-transfer writes114 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
109 // (see clipboard.go); empty means use os.TempDir().115 // (see clipboard.go); empty means use os.TempDir().
110 tempDir string116 tempDir string
@@ -181,6 +187,13 @@ func WithTempDir(dir string) Option {
181 }187 }
182 }188 }
183 189
190+// WithConfig applies the initial user configuration.
191+func WithConfig(cfg domain.Config) Option {
192+ return func(d *Daemon) {
193+ d.ApplyConfig(cfg)
194+ }
195+}
196+
184 // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the197 // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the
185 // render debounce, and log receives diagnostics (defaults to slog.Default).198 // render debounce, and log receives diagnostics (defaults to slog.Default).
186 func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {199 func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {
@@ -218,6 +231,16 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
218 if d.procComm == nil {231 if d.procComm == nil {
219 d.procComm = platform.ProcessComm232 d.procComm = platform.ProcessComm
220 }233 }
234+ if d.commandByCode == nil {
235+ d.commandByCode = d.commandByEffectiveCode
236+ }
237+ if d.bindings.Load() == nil {
238+ d.bindings.Store(keys.DefaultBindings())
239+ }
240+ if d.codeOverrides.Load() == nil {
241+ empty := map[string]string{}
242+ d.codeOverrides.Store(&empty)
243+ }
221 if records, err := d.persist.LoadAll(); err != nil {244 if records, err := d.persist.LoadAll(); err != nil {
222 d.log.Warn("loading persisted sessions failed", "err", err)245 d.log.Warn("loading persisted sessions failed", "err", err)
223 } else {246 } else {
M internal/usecase/daemon/palette.go +3 -0
@@ -31,9 +31,11 @@ func (d *Daemon) paletteCommands() []command.Command {
3131 d.paletteRecentMu.Lock()
3232 recent := append([]string(nil), d.paletteRecent...)
3333 d.paletteRecentMu.Unlock()
34+ overrides := d.codeOverrideSnapshot()
3435
3536 byCode := make(map[string]command.Command, len(commands))
3637 for _, cmd := range commands {
38+ cmd = commandWithOverrides(cmd, overrides)
3739 byCode[cmd.Code] = cmd
3840 }
3941 out := make([]command.Command, 0, len(commands))
@@ -47,6 +49,7 @@ func (d *Daemon) paletteCommands() []command.Command {
4749 used[code] = true
4850 }
4951 for _, cmd := range commands {
52+ cmd = commandWithOverrides(cmd, overrides)
5053 if !used[cmd.Code] {
5154 out = append(out, cmd)
5255 }
@@ -31,9 +31,11 @@ func (d *Daemon) paletteCommands() []command.Command {
31 d.paletteRecentMu.Lock()31 d.paletteRecentMu.Lock()
32 recent := append([]string(nil), d.paletteRecent...)32 recent := append([]string(nil), d.paletteRecent...)
33 d.paletteRecentMu.Unlock()33 d.paletteRecentMu.Unlock()
34+ overrides := d.codeOverrideSnapshot()
34 35
35 byCode := make(map[string]command.Command, len(commands))36 byCode := make(map[string]command.Command, len(commands))
36 for _, cmd := range commands {37 for _, cmd := range commands {
38+ cmd = commandWithOverrides(cmd, overrides)
37 byCode[cmd.Code] = cmd39 byCode[cmd.Code] = cmd
38 }40 }
39 out := make([]command.Command, 0, len(commands))41 out := make([]command.Command, 0, len(commands))
@@ -47,6 +49,7 @@ func (d *Daemon) paletteCommands() []command.Command {
47 used[code] = true49 used[code] = true
48 }50 }
49 for _, cmd := range commands {51 for _, cmd := range commands {
52+ cmd = commandWithOverrides(cmd, overrides)
50 if !used[cmd.Code] {53 if !used[cmd.Code] {
51 out = append(out, cmd)54 out = append(out, cmd)
52 }55 }
M internal/usecase/daemon/session.go +2 -2
@@ -210,13 +210,13 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
210210 }
211211 tb := newTab(pty, tbSize)
212212 if client != nil {
213- t := client.getTheme()
213+ t := d.effectiveTheme(client.getClientTheme())
214214 tb.mu.Lock()
215215 p := tb.focusedPane()
216216 tb.mu.Unlock()
217217 if p != nil {
218218 p.mu.Lock()
219- p.screen.SetDefaultColors(t.Foreground, t.Background, t.HasFG && t.HasBG)
219+ applyPaneThemeLocked(p, t, false)
220220 p.mu.Unlock()
221221 }
222222 }
@@ -210,13 +210,13 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
210 }210 }
211 tb := newTab(pty, tbSize)211 tb := newTab(pty, tbSize)
212 if client != nil {212 if client != nil {
213- t := client.getTheme()213+ t := d.effectiveTheme(client.getClientTheme())
214 tb.mu.Lock()214 tb.mu.Lock()
215 p := tb.focusedPane()215 p := tb.focusedPane()
216 tb.mu.Unlock()216 tb.mu.Unlock()
217 if p != nil {217 if p != nil {
218 p.mu.Lock()218 p.mu.Lock()
219- p.screen.SetDefaultColors(t.Foreground, t.Background, t.HasFG && t.HasBG)219+ applyPaneThemeLocked(p, t, false)
220 p.mu.Unlock()220 p.mu.Unlock()
221 }221 }
222 }222 }
M internal/usecase/daemon/status_test.go +161 -3
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "fmt"
45 "strconv"
56 "strings"
67 "sync"
@@ -79,20 +80,177 @@ func TestStatusApplyThemeStoresClientAndPropagatesScreens(t *testing.T) {
7980 d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2)
8081 defer releasePTY1()
8182 defer releasePTY2()
82- msg := ports.Theme{HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}}
83+ msg := ports.Theme{HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, SchemeKnown: true, Light: true}
8384
8485 d.applyTheme(sess, ac, msg)
8586
8687 require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, ac.getTheme().Foreground)
88+ require.True(t, ac.getTheme().SchemeKnown)
89+ require.True(t, ac.getTheme().Light)
90+ assertSessionDefaultColors(t, sess, renderer.RGB{R: 1, G: 2, B: 3}, renderer.RGB{R: 4, G: 5, B: 6})
91+ assertSessionColorScheme(t, sess, true)
92+}
93+
94+func TestApplyThemeForcedBuiltinThemePropagatesToChromeAndPanes(t *testing.T) {
95+ tests := []struct {
96+ name string
97+ mode domain.ThemeMode
98+ want themeui.Theme
99+ }{
100+ {name: "dark", mode: domain.ThemeDark, want: themeui.BuiltinDark},
101+ {name: "light", mode: domain.ThemeLight, want: themeui.BuiltinLight},
102+ }
103+ for _, tc := range tests {
104+ t.Run(tc.name, func(t *testing.T) {
105+ p1, releasePTY1 := newBlockingPTY(t)
106+ p2, releasePTY2 := newBlockingPTY(t)
107+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2)
108+ defer releasePTY1()
109+ defer releasePTY2()
110+ d.ApplyConfig(domain.Config{Theme: tc.mode})
111+
112+ d.applyTheme(sess, ac, ports.Theme{
113+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
114+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
115+ TrueColor: true, SchemeKnown: true, Light: !tc.want.Light,
116+ })
117+
118+ require.Equal(t, tc.want, ac.getTheme())
119+ assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background)
120+ assertSessionColorScheme(t, sess, tc.want.Light)
121+ })
122+ }
123+}
124+
125+func TestAttachClientAppliesForcedThemeBeforeMsgTheme(t *testing.T) {
126+ tests := []struct {
127+ name string
128+ mode domain.ThemeMode
129+ want themeui.Theme
130+ }{
131+ {name: "dark", mode: domain.ThemeDark, want: themeui.BuiltinDark},
132+ {name: "light", mode: domain.ThemeLight, want: themeui.BuiltinLight},
133+ }
134+ for _, tc := range tests {
135+ t.Run(tc.name, func(t *testing.T) {
136+ p, releasePTY := newBlockingPTY(t)
137+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
138+ defer releasePTY()
139+ d.ApplyConfig(domain.Config{Theme: tc.mode})
140+ for _, tb := range sess.tabs {
141+ tb.mu.Lock()
142+ panes := tb.panesSnapshot()
143+ tb.mu.Unlock()
144+ for _, p := range panes {
145+ p.mu.Lock()
146+ p.screen.SetDefaultColors(renderer.RGB{R: 1, G: 2, B: 3}, renderer.RGB{R: 4, G: 5, B: 6}, true)
147+ p.mu.Unlock()
148+ }
149+ }
150+ tr, _ := newCapturingTransport(t)
151+
152+ ac, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
153+
154+ require.Equal(t, tc.want, ac.getTheme())
155+ assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background)
156+ assertSessionColorScheme(t, sess, tc.want.Light)
157+ })
158+ }
159+}
160+
161+func TestAutoThemeDetachClearsPaneColorScheme(t *testing.T) {
162+ p, releasePTY := newBlockingPTY(t)
163+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
164+ defer releasePTY()
165+ d.ApplyConfig(domain.Config{Theme: domain.ThemeAuto})
166+ d.applyTheme(sess, ac, ports.Theme{
167+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
168+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
169+ TrueColor: true, SchemeKnown: true, Light: true,
170+ })
171+ assertSessionColorScheme(t, sess, true)
172+
173+ d.clientGone(sess, ac, ac.transport(), true)
174+
175+ assertSessionColorSchemeUnknown(t, sess)
176+}
177+
178+func TestForcedThemeDetachPreservesBuiltinOnPanes(t *testing.T) {
179+ p, releasePTY := newBlockingPTY(t)
180+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
181+ defer releasePTY()
182+ d.ApplyConfig(domain.Config{Theme: domain.ThemeDark})
183+ d.applyTheme(sess, ac, ports.Theme{
184+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
185+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
186+ TrueColor: true, SchemeKnown: true, Light: true,
187+ })
188+
189+ d.clientGone(sess, ac, ac.transport(), true)
190+
191+ assertSessionDefaultColors(t, sess, themeui.BuiltinDark.Foreground, themeui.BuiltinDark.Background)
192+ assertSessionColorScheme(t, sess, false)
193+}
194+
195+func TestApplyThemeAutoUnknownDoesNotClobberPaneColorScheme(t *testing.T) {
196+ p, releasePTY := newBlockingPTY(t)
197+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
198+ defer releasePTY()
199+
200+ d.applyTheme(sess, ac, ports.Theme{
201+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
202+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
203+ TrueColor: true, SchemeKnown: true, Light: true,
204+ })
205+ d.applyTheme(sess, ac, ports.Theme{
206+ HasForeground: true, Foreground: renderer.RGB{R: 7, G: 8, B: 9},
207+ HasBackground: true, Background: renderer.RGB{R: 10, G: 11, B: 12},
208+ TrueColor: true,
209+ })
210+
211+ assertSessionDefaultColors(t, sess, renderer.RGB{R: 7, G: 8, B: 9}, renderer.RGB{R: 10, G: 11, B: 12})
212+ assertSessionColorScheme(t, sess, true)
213+}
214+
215+func assertSessionDefaultColors(t *testing.T, sess *session, fg, bg renderer.RGB) {
216+ t.Helper()
87217 for _, tb := range sess.tabs {
88218 var got []byte
89219 tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
90220 tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
91- require.True(t, strings.Contains(string(got), "rgb:0101/0202/0303"), string(got))
92- require.True(t, strings.Contains(string(got), "rgb:0404/0505/0606"), string(got))
221+ require.True(t, strings.Contains(string(got), formatOSCColor(fg)), string(got))
222+ require.True(t, strings.Contains(string(got), formatOSCColor(bg)), string(got))
223+ }
224+}
225+
226+func assertSessionColorScheme(t *testing.T, sess *session, light bool) {
227+ t.Helper()
228+ want := "\x1b[?997;1n"
229+ if light {
230+ want = "\x1b[?997;2n"
231+ }
232+ for _, tb := range sess.tabs {
233+ var got []byte
234+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
235+ tb.focusedPane().screen.Write([]byte("\x1b[?996n"))
236+ require.Equal(t, want, string(got))
93237 }
94238 }
95239
240+func assertSessionColorSchemeUnknown(t *testing.T, sess *session) {
241+ t.Helper()
242+ for _, tb := range sess.tabs {
243+ var got []byte
244+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
245+ tb.focusedPane().screen.Write([]byte("\x1b[?996n"))
246+ require.Empty(t, got)
247+ }
248+}
249+
250+func formatOSCColor(rgb renderer.RGB) string {
251+ return fmt.Sprintf("rgb:%02x%02x/%02x%02x/%02x%02x", rgb.R, rgb.R, rgb.G, rgb.G, rgb.B, rgb.B)
252+}
253+
96254 func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) {
97255 p, release := newBlockingPTY(t)
98256 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "fmt"
4 "strconv"5 "strconv"
5 "strings"6 "strings"
6 "sync"7 "sync"
@@ -79,20 +80,177 @@ func TestStatusApplyThemeStoresClientAndPropagatesScreens(t *testing.T) {
79 d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2)80 d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2)
80 defer releasePTY1()81 defer releasePTY1()
81 defer releasePTY2()82 defer releasePTY2()
82- msg := ports.Theme{HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}}83+ msg := ports.Theme{HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, SchemeKnown: true, Light: true}
83 84
84 d.applyTheme(sess, ac, msg)85 d.applyTheme(sess, ac, msg)
85 86
86 require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, ac.getTheme().Foreground)87 require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, ac.getTheme().Foreground)
88+ require.True(t, ac.getTheme().SchemeKnown)
89+ require.True(t, ac.getTheme().Light)
90+ assertSessionDefaultColors(t, sess, renderer.RGB{R: 1, G: 2, B: 3}, renderer.RGB{R: 4, G: 5, B: 6})
91+ assertSessionColorScheme(t, sess, true)
92+}
93+
94+func TestApplyThemeForcedBuiltinThemePropagatesToChromeAndPanes(t *testing.T) {
95+ tests := []struct {
96+ name string
97+ mode domain.ThemeMode
98+ want themeui.Theme
99+ }{
100+ {name: "dark", mode: domain.ThemeDark, want: themeui.BuiltinDark},
101+ {name: "light", mode: domain.ThemeLight, want: themeui.BuiltinLight},
102+ }
103+ for _, tc := range tests {
104+ t.Run(tc.name, func(t *testing.T) {
105+ p1, releasePTY1 := newBlockingPTY(t)
106+ p2, releasePTY2 := newBlockingPTY(t)
107+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2)
108+ defer releasePTY1()
109+ defer releasePTY2()
110+ d.ApplyConfig(domain.Config{Theme: tc.mode})
111+
112+ d.applyTheme(sess, ac, ports.Theme{
113+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
114+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
115+ TrueColor: true, SchemeKnown: true, Light: !tc.want.Light,
116+ })
117+
118+ require.Equal(t, tc.want, ac.getTheme())
119+ assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background)
120+ assertSessionColorScheme(t, sess, tc.want.Light)
121+ })
122+ }
123+}
124+
125+func TestAttachClientAppliesForcedThemeBeforeMsgTheme(t *testing.T) {
126+ tests := []struct {
127+ name string
128+ mode domain.ThemeMode
129+ want themeui.Theme
130+ }{
131+ {name: "dark", mode: domain.ThemeDark, want: themeui.BuiltinDark},
132+ {name: "light", mode: domain.ThemeLight, want: themeui.BuiltinLight},
133+ }
134+ for _, tc := range tests {
135+ t.Run(tc.name, func(t *testing.T) {
136+ p, releasePTY := newBlockingPTY(t)
137+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
138+ defer releasePTY()
139+ d.ApplyConfig(domain.Config{Theme: tc.mode})
140+ for _, tb := range sess.tabs {
141+ tb.mu.Lock()
142+ panes := tb.panesSnapshot()
143+ tb.mu.Unlock()
144+ for _, p := range panes {
145+ p.mu.Lock()
146+ p.screen.SetDefaultColors(renderer.RGB{R: 1, G: 2, B: 3}, renderer.RGB{R: 4, G: 5, B: 6}, true)
147+ p.mu.Unlock()
148+ }
149+ }
150+ tr, _ := newCapturingTransport(t)
151+
152+ ac, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
153+
154+ require.Equal(t, tc.want, ac.getTheme())
155+ assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background)
156+ assertSessionColorScheme(t, sess, tc.want.Light)
157+ })
158+ }
159+}
160+
161+func TestAutoThemeDetachClearsPaneColorScheme(t *testing.T) {
162+ p, releasePTY := newBlockingPTY(t)
163+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
164+ defer releasePTY()
165+ d.ApplyConfig(domain.Config{Theme: domain.ThemeAuto})
166+ d.applyTheme(sess, ac, ports.Theme{
167+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
168+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
169+ TrueColor: true, SchemeKnown: true, Light: true,
170+ })
171+ assertSessionColorScheme(t, sess, true)
172+
173+ d.clientGone(sess, ac, ac.transport(), true)
174+
175+ assertSessionColorSchemeUnknown(t, sess)
176+}
177+
178+func TestForcedThemeDetachPreservesBuiltinOnPanes(t *testing.T) {
179+ p, releasePTY := newBlockingPTY(t)
180+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
181+ defer releasePTY()
182+ d.ApplyConfig(domain.Config{Theme: domain.ThemeDark})
183+ d.applyTheme(sess, ac, ports.Theme{
184+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
185+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
186+ TrueColor: true, SchemeKnown: true, Light: true,
187+ })
188+
189+ d.clientGone(sess, ac, ac.transport(), true)
190+
191+ assertSessionDefaultColors(t, sess, themeui.BuiltinDark.Foreground, themeui.BuiltinDark.Background)
192+ assertSessionColorScheme(t, sess, false)
193+}
194+
195+func TestApplyThemeAutoUnknownDoesNotClobberPaneColorScheme(t *testing.T) {
196+ p, releasePTY := newBlockingPTY(t)
197+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
198+ defer releasePTY()
199+
200+ d.applyTheme(sess, ac, ports.Theme{
201+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
202+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
203+ TrueColor: true, SchemeKnown: true, Light: true,
204+ })
205+ d.applyTheme(sess, ac, ports.Theme{
206+ HasForeground: true, Foreground: renderer.RGB{R: 7, G: 8, B: 9},
207+ HasBackground: true, Background: renderer.RGB{R: 10, G: 11, B: 12},
208+ TrueColor: true,
209+ })
210+
211+ assertSessionDefaultColors(t, sess, renderer.RGB{R: 7, G: 8, B: 9}, renderer.RGB{R: 10, G: 11, B: 12})
212+ assertSessionColorScheme(t, sess, true)
213+}
214+
215+func assertSessionDefaultColors(t *testing.T, sess *session, fg, bg renderer.RGB) {
216+ t.Helper()
87 for _, tb := range sess.tabs {217 for _, tb := range sess.tabs {
88 var got []byte218 var got []byte
89 tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }219 tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
90 tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))220 tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
91- require.True(t, strings.Contains(string(got), "rgb:0101/0202/0303"), string(got))221+ require.True(t, strings.Contains(string(got), formatOSCColor(fg)), string(got))
92- require.True(t, strings.Contains(string(got), "rgb:0404/0505/0606"), string(got))222+ require.True(t, strings.Contains(string(got), formatOSCColor(bg)), string(got))
223+ }
224+}
225+
226+func assertSessionColorScheme(t *testing.T, sess *session, light bool) {
227+ t.Helper()
228+ want := "\x1b[?997;1n"
229+ if light {
230+ want = "\x1b[?997;2n"
231+ }
232+ for _, tb := range sess.tabs {
233+ var got []byte
234+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
235+ tb.focusedPane().screen.Write([]byte("\x1b[?996n"))
236+ require.Equal(t, want, string(got))
93 }237 }
94 }238 }
95 239
240+func assertSessionColorSchemeUnknown(t *testing.T, sess *session) {
241+ t.Helper()
242+ for _, tb := range sess.tabs {
243+ var got []byte
244+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
245+ tb.focusedPane().screen.Write([]byte("\x1b[?996n"))
246+ require.Empty(t, got)
247+ }
248+}
249+
250+func formatOSCColor(rgb renderer.RGB) string {
251+ return fmt.Sprintf("rgb:%02x%02x/%02x%02x/%02x%02x", rgb.R, rgb.R, rgb.G, rgb.G, rgb.B, rgb.B)
252+}
253+
96 func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) {254 func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) {
97 p, release := newBlockingPTY(t)255 p, release := newBlockingPTY(t)
98 d, sess, ac, _ := newManualSessionWithPTYs(t, p)256 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
A internal/usecase/daemon/theme_resolve.go +42 -0
@@ -0,0 +1,42 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/usecase/theme"
4+
5+func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme) bool {
6+ sess.mu.Lock()
7+ if ac != nil {
8+ if sess.client != ac {
9+ sess.mu.Unlock()
10+ return false
11+ }
12+ } else if sess.client != nil {
13+ sess.mu.Unlock()
14+ return false
15+ }
16+ tabs := append([]*tab(nil), sess.tabs...)
17+ sess.mu.Unlock()
18+
19+ if ac != nil {
20+ ac.setTheme(t)
21+ }
22+ for _, tb := range tabs {
23+ tb.mu.Lock()
24+ panes := tb.panesSnapshot()
25+ tb.mu.Unlock()
26+ for _, p := range panes {
27+ p.mu.Lock()
28+ applyPaneThemeLocked(p, t, ac == nil)
29+ p.mu.Unlock()
30+ }
31+ }
32+ return true
33+}
34+
35+func applyPaneThemeLocked(p *pane, t theme.Theme, clearUnknownScheme bool) {
36+ p.screen.SetDefaultColors(t.Foreground, t.Background, t.HasFG && t.HasBG)
37+ if t.SchemeKnown {
38+ p.screen.SetColorScheme(t.Light)
39+ } else if clearUnknownScheme {
40+ p.screen.ClearColorScheme()
41+ }
42+}
@@ -0,0 +1,42 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/usecase/theme"
4+
5+func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme) bool {
6+ sess.mu.Lock()
7+ if ac != nil {
8+ if sess.client != ac {
9+ sess.mu.Unlock()
10+ return false
11+ }
12+ } else if sess.client != nil {
13+ sess.mu.Unlock()
14+ return false
15+ }
16+ tabs := append([]*tab(nil), sess.tabs...)
17+ sess.mu.Unlock()
18+
19+ if ac != nil {
20+ ac.setTheme(t)
21+ }
22+ for _, tb := range tabs {
23+ tb.mu.Lock()
24+ panes := tb.panesSnapshot()
25+ tb.mu.Unlock()
26+ for _, p := range panes {
27+ p.mu.Lock()
28+ applyPaneThemeLocked(p, t, ac == nil)
29+ p.mu.Unlock()
30+ }
31+ }
32+ return true
33+}
34+
35+func applyPaneThemeLocked(p *pane, t theme.Theme, clearUnknownScheme bool) {
36+ p.screen.SetDefaultColors(t.Foreground, t.Background, t.HasFG && t.HasBG)
37+ if t.SchemeKnown {
38+ p.screen.SetColorScheme(t.Light)
39+ } else if clearUnknownScheme {
40+ p.screen.ClearColorScheme()
41+ }
42+}
A internal/usecase/keys/bindings.go +133 -0
@@ -0,0 +1,133 @@
1+package keys
2+
3+import "unicode/utf8"
4+
5+// Bindings maps intercepted Alt keys to actions. A Bindings value is mutable
6+// while being built, then treated as frozen once published through an atomic
7+// pointer; callers must clone before using mutators.
8+type Bindings struct {
9+ altRunes map[rune]Action
10+ altArrows map[byte]Action
11+}
12+
13+var defaultBindings = DefaultBindings()
14+
15+// DefaultBindings returns vev's built-in Alt-key bindings.
16+func DefaultBindings() *Bindings {
17+ altRunes := map[rune]Action{
18+ ' ': ActionOpenPalette,
19+ 'a': ActionJumpAttention,
20+ 'h': ActionFocusPaneLeft,
21+ 'j': ActionFocusPaneDown,
22+ 'k': ActionFocusPaneUp,
23+ 'l': ActionFocusPaneRight,
24+ }
25+ for idx, aliases := range topRowDigitAliases {
26+ for _, alias := range aliases {
27+ altRunes[alias] = ActionSwitchTab1 + Action(idx)
28+ }
29+ }
30+ return &Bindings{
31+ altRunes: altRunes,
32+ altArrows: map[byte]Action{
33+ 'A': ActionFocusPaneUp,
34+ 'B': ActionFocusPaneDown,
35+ 'C': ActionFocusPaneRight,
36+ 'D': ActionFocusPaneLeft,
37+ },
38+ }
39+}
40+
41+func (b *Bindings) clone() *Bindings {
42+ if b == nil {
43+ return DefaultBindings()
44+ }
45+ clone := &Bindings{
46+ altRunes: make(map[rune]Action, len(b.altRunes)),
47+ altArrows: make(map[byte]Action, len(b.altArrows)),
48+ }
49+ for key, action := range b.altRunes {
50+ clone.altRunes[key] = action
51+ }
52+ for key, action := range b.altArrows {
53+ clone.altArrows[key] = action
54+ }
55+ return clone
56+}
57+
58+func (b *Bindings) removeAction(action Action) {
59+ for key, bound := range b.altRunes {
60+ if bound == action {
61+ delete(b.altRunes, key)
62+ }
63+ }
64+ for key, bound := range b.altArrows {
65+ if bound == action {
66+ delete(b.altArrows, key)
67+ }
68+ }
69+}
70+
71+func (b *Bindings) bind(action Action, spec KeySpec) {
72+ for _, key := range spec.altRunes {
73+ b.altRunes[key] = action
74+ }
75+ if spec.altArrow != 0 {
76+ b.altArrows[spec.altArrow] = action
77+ }
78+}
79+
80+func (b *Bindings) restoreDefaultAction(action Action) {
81+ defaults := DefaultBindings()
82+ for key, bound := range defaults.altRunes {
83+ if bound == action {
84+ if _, occupied := b.altRunes[key]; !occupied {
85+ b.altRunes[key] = action
86+ }
87+ }
88+ }
89+ for key, bound := range defaults.altArrows {
90+ if bound == action {
91+ if _, occupied := b.altArrows[key]; !occupied {
92+ b.altArrows[key] = action
93+ }
94+ }
95+ }
96+}
97+
98+func (b *Bindings) conflictingAction(spec KeySpec, action Action) (Action, bool) {
99+ for _, key := range spec.altRunes {
100+ if bound, ok := b.altRunes[key]; ok && bound != action {
101+ return bound, true
102+ }
103+ }
104+ if spec.altArrow != 0 {
105+ if bound, ok := b.altArrows[spec.altArrow]; ok && bound != action {
106+ return bound, true
107+ }
108+ }
109+ return 0, false
110+}
111+
112+func (b *Bindings) actionForAltBytes(data []byte) (Action, int, bool) {
113+ if b == nil {
114+ b = defaultBindings
115+ }
116+ if len(data) == 0 {
117+ return 0, 0, false
118+ }
119+ key, size := utf8.DecodeRune(data)
120+ if key == utf8.RuneError && size == 1 {
121+ return 0, 0, false
122+ }
123+ action, ok := b.altRunes[key]
124+ return action, size, ok
125+}
126+
127+func (b *Bindings) actionForAltArrow(final byte) (Action, bool) {
128+ if b == nil {
129+ b = defaultBindings
130+ }
131+ action, ok := b.altArrows[final]
132+ return action, ok
133+}
@@ -0,0 +1,133 @@
1+package keys
2+
3+import "unicode/utf8"
4+
5+// Bindings maps intercepted Alt keys to actions. A Bindings value is mutable
6+// while being built, then treated as frozen once published through an atomic
7+// pointer; callers must clone before using mutators.
8+type Bindings struct {
9+ altRunes map[rune]Action
10+ altArrows map[byte]Action
11+}
12+
13+var defaultBindings = DefaultBindings()
14+
15+// DefaultBindings returns vev's built-in Alt-key bindings.
16+func DefaultBindings() *Bindings {
17+ altRunes := map[rune]Action{
18+ ' ': ActionOpenPalette,
19+ 'a': ActionJumpAttention,
20+ 'h': ActionFocusPaneLeft,
21+ 'j': ActionFocusPaneDown,
22+ 'k': ActionFocusPaneUp,
23+ 'l': ActionFocusPaneRight,
24+ }
25+ for idx, aliases := range topRowDigitAliases {
26+ for _, alias := range aliases {
27+ altRunes[alias] = ActionSwitchTab1 + Action(idx)
28+ }
29+ }
30+ return &Bindings{
31+ altRunes: altRunes,
32+ altArrows: map[byte]Action{
33+ 'A': ActionFocusPaneUp,
34+ 'B': ActionFocusPaneDown,
35+ 'C': ActionFocusPaneRight,
36+ 'D': ActionFocusPaneLeft,
37+ },
38+ }
39+}
40+
41+func (b *Bindings) clone() *Bindings {
42+ if b == nil {
43+ return DefaultBindings()
44+ }
45+ clone := &Bindings{
46+ altRunes: make(map[rune]Action, len(b.altRunes)),
47+ altArrows: make(map[byte]Action, len(b.altArrows)),
48+ }
49+ for key, action := range b.altRunes {
50+ clone.altRunes[key] = action
51+ }
52+ for key, action := range b.altArrows {
53+ clone.altArrows[key] = action
54+ }
55+ return clone
56+}
57+
58+func (b *Bindings) removeAction(action Action) {
59+ for key, bound := range b.altRunes {
60+ if bound == action {
61+ delete(b.altRunes, key)
62+ }
63+ }
64+ for key, bound := range b.altArrows {
65+ if bound == action {
66+ delete(b.altArrows, key)
67+ }
68+ }
69+}
70+
71+func (b *Bindings) bind(action Action, spec KeySpec) {
72+ for _, key := range spec.altRunes {
73+ b.altRunes[key] = action
74+ }
75+ if spec.altArrow != 0 {
76+ b.altArrows[spec.altArrow] = action
77+ }
78+}
79+
80+func (b *Bindings) restoreDefaultAction(action Action) {
81+ defaults := DefaultBindings()
82+ for key, bound := range defaults.altRunes {
83+ if bound == action {
84+ if _, occupied := b.altRunes[key]; !occupied {
85+ b.altRunes[key] = action
86+ }
87+ }
88+ }
89+ for key, bound := range defaults.altArrows {
90+ if bound == action {
91+ if _, occupied := b.altArrows[key]; !occupied {
92+ b.altArrows[key] = action
93+ }
94+ }
95+ }
96+}
97+
98+func (b *Bindings) conflictingAction(spec KeySpec, action Action) (Action, bool) {
99+ for _, key := range spec.altRunes {
100+ if bound, ok := b.altRunes[key]; ok && bound != action {
101+ return bound, true
102+ }
103+ }
104+ if spec.altArrow != 0 {
105+ if bound, ok := b.altArrows[spec.altArrow]; ok && bound != action {
106+ return bound, true
107+ }
108+ }
109+ return 0, false
110+}
111+
112+func (b *Bindings) actionForAltBytes(data []byte) (Action, int, bool) {
113+ if b == nil {
114+ b = defaultBindings
115+ }
116+ if len(data) == 0 {
117+ return 0, 0, false
118+ }
119+ key, size := utf8.DecodeRune(data)
120+ if key == utf8.RuneError && size == 1 {
121+ return 0, 0, false
122+ }
123+ action, ok := b.altRunes[key]
124+ return action, size, ok
125+}
126+
127+func (b *Bindings) actionForAltArrow(final byte) (Action, bool) {
128+ if b == nil {
129+ b = defaultBindings
130+ }
131+ action, ok := b.altArrows[final]
132+ return action, ok
133+}
A internal/usecase/keys/keyspec.go +154 -0
@@ -0,0 +1,154 @@
1+package keys
2+
3+import (
4+ "fmt"
5+ "sort"
6+ "strings"
7+ "unicode/utf8"
8+
9+ "github.com/bnema/vev/internal/domain"
10+)
11+
12+const altPrefix = "alt+"
13+
14+// KeySpec is one supported user-facing key binding specification.
15+type KeySpec struct {
16+ altRunes []rune
17+ altArrow byte
18+}
19+
20+// ParseKeySpec parses vev's config key grammar.
21+func ParseKeySpec(spec string) (KeySpec, error) {
22+ if !strings.HasPrefix(spec, altPrefix) {
23+ return KeySpec{}, fmt.Errorf("key spec must start with %q", altPrefix)
24+ }
25+ key := strings.TrimPrefix(spec, altPrefix)
26+ switch key {
27+ case "space":
28+ return KeySpec{altRunes: []rune{' '}}, nil
29+ case "left":
30+ return KeySpec{altArrow: 'D'}, nil
31+ case "right":
32+ return KeySpec{altArrow: 'C'}, nil
33+ case "up":
34+ return KeySpec{altArrow: 'A'}, nil
35+ case "down":
36+ return KeySpec{altArrow: 'B'}, nil
37+ }
38+ if utf8.RuneCountInString(key) != 1 {
39+ return KeySpec{}, fmt.Errorf("key spec must be alt+<char>, alt+space, or alt+left/right/up/down")
40+ }
41+ r, _ := utf8.DecodeRuneInString(key)
42+ if idx, ok := topRowDigitIndex(r); ok {
43+ return KeySpec{altRunes: append([]rune(nil), topRowDigitAliases[idx]...)}, nil
44+ }
45+ if r >= '0' && r <= '9' {
46+ return KeySpec{}, fmt.Errorf("only alt+1 through alt+9 are supported digit bindings")
47+ }
48+ return KeySpec{altRunes: []rune{r}}, nil
49+}
50+
51+var actionNames = map[Action]string{
52+ ActionOpenPalette: "open-palette",
53+ ActionJumpAttention: "jump-attention",
54+ ActionFocusPaneLeft: "focus-pane-left",
55+ ActionFocusPaneRight: "focus-pane-right",
56+ ActionFocusPaneUp: "focus-pane-up",
57+ ActionFocusPaneDown: "focus-pane-down",
58+ ActionSwitchTab1: "switch-tab-1",
59+ ActionSwitchTab2: "switch-tab-2",
60+ ActionSwitchTab3: "switch-tab-3",
61+ ActionSwitchTab4: "switch-tab-4",
62+ ActionSwitchTab5: "switch-tab-5",
63+ ActionSwitchTab6: "switch-tab-6",
64+ ActionSwitchTab7: "switch-tab-7",
65+ ActionSwitchTab8: "switch-tab-8",
66+ ActionSwitchTab9: "switch-tab-9",
67+}
68+
69+var actionsByName = func() map[string]Action {
70+ byName := make(map[string]Action, len(actionNames))
71+ for action, name := range actionNames {
72+ if _, dup := byName[name]; dup {
73+ panic(fmt.Sprintf("keys: duplicate action name %q", name))
74+ }
75+ byName[name] = action
76+ }
77+ return byName
78+}()
79+
80+// Name returns the canonical config name for an action.
81+func (a Action) Name() string {
82+ name, ok := actionNames[a]
83+ if !ok {
84+ return "unknown"
85+ }
86+ return name
87+}
88+
89+// ActionByName returns the action for a canonical config name.
90+func ActionByName(name string) (Action, bool) {
91+ action, ok := actionsByName[name]
92+ return action, ok
93+}
94+
95+// BuildBindings starts from vev's defaults and applies config overrides. Map
96+// inputs have no file order, so names are applied lexicographically for stable
97+// behavior. Parsed configs should call BuildBindingEntries to preserve file
98+// order for duplicate-key first-wins semantics.
99+func BuildBindings(overrides map[string]string) (*Bindings, []domain.Warning) {
100+ entries := make([]domain.ConfigEntry, 0, len(overrides))
101+ for _, name := range sortedOverrideNames(overrides) {
102+ entries = append(entries, domain.ConfigEntry{Key: name, Value: overrides[name]})
103+ }
104+ return BuildBindingEntries(entries)
105+}
106+
107+// BuildBindingEntries starts from vev's defaults and applies config overrides
108+// in the order they appeared in the config file. When two actions request the
109+// same key, the first valid override wins and later actions keep their default.
110+func BuildBindingEntries(overrides []domain.ConfigEntry) (*Bindings, []domain.Warning) {
111+ bindings := DefaultBindings().clone()
112+ var warnings []domain.Warning
113+ var desired []bindingOverride
114+ for _, entry := range overrides {
115+ action, ok := ActionByName(entry.Key)
116+ if !ok {
117+ warnings = append(warnings, domain.Warning{Msg: fmt.Sprintf("unknown action %q", entry.Key)})
118+ continue
119+ }
120+ spec, err := ParseKeySpec(entry.Value)
121+ if err != nil {
122+ warnings = append(warnings, domain.Warning{Msg: fmt.Sprintf("invalid key for %q: %v", entry.Key, err)})
123+ continue
124+ }
125+ desired = append(desired, bindingOverride{name: entry.Key, action: action, spec: spec})
126+ }
127+ for _, override := range desired {
128+ bindings.removeAction(override.action)
129+ }
130+ for _, override := range desired {
131+ if conflict, ok := bindings.conflictingAction(override.spec, override.action); ok {
132+ warnings = append(warnings, domain.Warning{Msg: fmt.Sprintf("duplicate key for %q conflicts with %q", override.name, conflict.Name())})
133+ bindings.restoreDefaultAction(override.action)
134+ continue
135+ }
136+ bindings.bind(override.action, override.spec)
137+ }
138+ return bindings, warnings
139+}
140+
141+type bindingOverride struct {
142+ name string
143+ action Action
144+ spec KeySpec
145+}
146+
147+func sortedOverrideNames(overrides map[string]string) []string {
148+ names := make([]string, 0, len(overrides))
149+ for name := range overrides {
150+ names = append(names, name)
151+ }
152+ sort.Strings(names)
153+ return names
154+}
@@ -0,0 +1,154 @@
1+package keys
2+
3+import (
4+ "fmt"
5+ "sort"
6+ "strings"
7+ "unicode/utf8"
8+
9+ "github.com/bnema/vev/internal/domain"
10+)
11+
12+const altPrefix = "alt+"
13+
14+// KeySpec is one supported user-facing key binding specification.
15+type KeySpec struct {
16+ altRunes []rune
17+ altArrow byte
18+}
19+
20+// ParseKeySpec parses vev's config key grammar.
21+func ParseKeySpec(spec string) (KeySpec, error) {
22+ if !strings.HasPrefix(spec, altPrefix) {
23+ return KeySpec{}, fmt.Errorf("key spec must start with %q", altPrefix)
24+ }
25+ key := strings.TrimPrefix(spec, altPrefix)
26+ switch key {
27+ case "space":
28+ return KeySpec{altRunes: []rune{' '}}, nil
29+ case "left":
30+ return KeySpec{altArrow: 'D'}, nil
31+ case "right":
32+ return KeySpec{altArrow: 'C'}, nil
33+ case "up":
34+ return KeySpec{altArrow: 'A'}, nil
35+ case "down":
36+ return KeySpec{altArrow: 'B'}, nil
37+ }
38+ if utf8.RuneCountInString(key) != 1 {
39+ return KeySpec{}, fmt.Errorf("key spec must be alt+<char>, alt+space, or alt+left/right/up/down")
40+ }
41+ r, _ := utf8.DecodeRuneInString(key)
42+ if idx, ok := topRowDigitIndex(r); ok {
43+ return KeySpec{altRunes: append([]rune(nil), topRowDigitAliases[idx]...)}, nil
44+ }
45+ if r >= '0' && r <= '9' {
46+ return KeySpec{}, fmt.Errorf("only alt+1 through alt+9 are supported digit bindings")
47+ }
48+ return KeySpec{altRunes: []rune{r}}, nil
49+}
50+
51+var actionNames = map[Action]string{
52+ ActionOpenPalette: "open-palette",
53+ ActionJumpAttention: "jump-attention",
54+ ActionFocusPaneLeft: "focus-pane-left",
55+ ActionFocusPaneRight: "focus-pane-right",
56+ ActionFocusPaneUp: "focus-pane-up",
57+ ActionFocusPaneDown: "focus-pane-down",
58+ ActionSwitchTab1: "switch-tab-1",
59+ ActionSwitchTab2: "switch-tab-2",
60+ ActionSwitchTab3: "switch-tab-3",
61+ ActionSwitchTab4: "switch-tab-4",
62+ ActionSwitchTab5: "switch-tab-5",
63+ ActionSwitchTab6: "switch-tab-6",
64+ ActionSwitchTab7: "switch-tab-7",
65+ ActionSwitchTab8: "switch-tab-8",
66+ ActionSwitchTab9: "switch-tab-9",
67+}
68+
69+var actionsByName = func() map[string]Action {
70+ byName := make(map[string]Action, len(actionNames))
71+ for action, name := range actionNames {
72+ if _, dup := byName[name]; dup {
73+ panic(fmt.Sprintf("keys: duplicate action name %q", name))
74+ }
75+ byName[name] = action
76+ }
77+ return byName
78+}()
79+
80+// Name returns the canonical config name for an action.
81+func (a Action) Name() string {
82+ name, ok := actionNames[a]
83+ if !ok {
84+ return "unknown"
85+ }
86+ return name
87+}
88+
89+// ActionByName returns the action for a canonical config name.
90+func ActionByName(name string) (Action, bool) {
91+ action, ok := actionsByName[name]
92+ return action, ok
93+}
94+
95+// BuildBindings starts from vev's defaults and applies config overrides. Map
96+// inputs have no file order, so names are applied lexicographically for stable
97+// behavior. Parsed configs should call BuildBindingEntries to preserve file
98+// order for duplicate-key first-wins semantics.
99+func BuildBindings(overrides map[string]string) (*Bindings, []domain.Warning) {
100+ entries := make([]domain.ConfigEntry, 0, len(overrides))
101+ for _, name := range sortedOverrideNames(overrides) {
102+ entries = append(entries, domain.ConfigEntry{Key: name, Value: overrides[name]})
103+ }
104+ return BuildBindingEntries(entries)
105+}
106+
107+// BuildBindingEntries starts from vev's defaults and applies config overrides
108+// in the order they appeared in the config file. When two actions request the
109+// same key, the first valid override wins and later actions keep their default.
110+func BuildBindingEntries(overrides []domain.ConfigEntry) (*Bindings, []domain.Warning) {
111+ bindings := DefaultBindings().clone()
112+ var warnings []domain.Warning
113+ var desired []bindingOverride
114+ for _, entry := range overrides {
115+ action, ok := ActionByName(entry.Key)
116+ if !ok {
117+ warnings = append(warnings, domain.Warning{Msg: fmt.Sprintf("unknown action %q", entry.Key)})
118+ continue
119+ }
120+ spec, err := ParseKeySpec(entry.Value)
121+ if err != nil {
122+ warnings = append(warnings, domain.Warning{Msg: fmt.Sprintf("invalid key for %q: %v", entry.Key, err)})
123+ continue
124+ }
125+ desired = append(desired, bindingOverride{name: entry.Key, action: action, spec: spec})
126+ }
127+ for _, override := range desired {
128+ bindings.removeAction(override.action)
129+ }
130+ for _, override := range desired {
131+ if conflict, ok := bindings.conflictingAction(override.spec, override.action); ok {
132+ warnings = append(warnings, domain.Warning{Msg: fmt.Sprintf("duplicate key for %q conflicts with %q", override.name, conflict.Name())})
133+ bindings.restoreDefaultAction(override.action)
134+ continue
135+ }
136+ bindings.bind(override.action, override.spec)
137+ }
138+ return bindings, warnings
139+}
140+
141+type bindingOverride struct {
142+ name string
143+ action Action
144+ spec KeySpec
145+}
146+
147+func sortedOverrideNames(overrides map[string]string) []string {
148+ names := make([]string, 0, len(overrides))
149+ for name := range overrides {
150+ names = append(names, name)
151+ }
152+ sort.Strings(names)
153+ return names
154+}
A internal/usecase/keys/keyspec_test.go +232 -0
@@ -0,0 +1,232 @@
1+package keys
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestParseKeySpecAcceptsValidSpecs(t *testing.T) {
11+ cases := []struct {
12+ name string
13+ spec string
14+ wantRunes []rune
15+ wantArrow byte
16+ }{
17+ {name: "letter", spec: "alt+x", wantRunes: []rune{'x'}},
18+ {name: "unicode single char", spec: "alt+ñ", wantRunes: []rune{'ñ'}},
19+ {name: "space", spec: "alt+space", wantRunes: []rune{' '}},
20+ {name: "left arrow", spec: "alt+left", wantArrow: 'D'},
21+ {name: "right arrow", spec: "alt+right", wantArrow: 'C'},
22+ {name: "up arrow", spec: "alt+up", wantArrow: 'A'},
23+ {name: "down arrow", spec: "alt+down", wantArrow: 'B'},
24+ {name: "digit includes layout aliases", spec: "alt+2", wantRunes: []rune{'2', 'é'}},
25+ {name: "digit eight includes known aliases", spec: "alt+8", wantRunes: []rune{'8', '_', '!'}},
26+ }
27+
28+ for _, tc := range cases {
29+ t.Run(tc.name, func(t *testing.T) {
30+ got, err := ParseKeySpec(tc.spec)
31+ require.NoError(t, err)
32+ require.Equal(t, tc.wantRunes, got.altRunes)
33+ require.Equal(t, tc.wantArrow, got.altArrow)
34+ })
35+ }
36+}
37+
38+func TestParseKeySpecRejectsMalformedSpecs(t *testing.T) {
39+ cases := []string{
40+ "",
41+ "x",
42+ "ctrl+x",
43+ "alt+",
44+ "alt+xy",
45+ "alt+0",
46+ "alt+10",
47+ "alt+tab",
48+ "alt+Space",
49+ " alt+x",
50+ "alt+x ",
51+ }
52+
53+ for _, spec := range cases {
54+ t.Run(spec, func(t *testing.T) {
55+ _, err := ParseKeySpec(spec)
56+ require.Error(t, err)
57+ })
58+ }
59+}
60+
61+func TestActionNamesAreCanonical(t *testing.T) {
62+ cases := []struct {
63+ a Action
64+ name string
65+ }{
66+ {ActionOpenPalette, "open-palette"},
67+ {ActionJumpAttention, "jump-attention"},
68+ {ActionFocusPaneLeft, "focus-pane-left"},
69+ {ActionFocusPaneRight, "focus-pane-right"},
70+ {ActionFocusPaneUp, "focus-pane-up"},
71+ {ActionFocusPaneDown, "focus-pane-down"},
72+ {ActionSwitchTab1, "switch-tab-1"},
73+ {ActionSwitchTab2, "switch-tab-2"},
74+ {ActionSwitchTab3, "switch-tab-3"},
75+ {ActionSwitchTab4, "switch-tab-4"},
76+ {ActionSwitchTab5, "switch-tab-5"},
77+ {ActionSwitchTab6, "switch-tab-6"},
78+ {ActionSwitchTab7, "switch-tab-7"},
79+ {ActionSwitchTab8, "switch-tab-8"},
80+ {ActionSwitchTab9, "switch-tab-9"},
81+ }
82+
83+ for _, tc := range cases {
84+ t.Run(tc.name, func(t *testing.T) {
85+ require.Equal(t, tc.name, tc.a.Name())
86+ got, ok := ActionByName(tc.name)
87+ require.True(t, ok)
88+ require.Equal(t, tc.a, got)
89+ })
90+ }
91+
92+ require.Equal(t, "unknown", Action(99).Name())
93+ _, ok := ActionByName("unknown-action")
94+ require.False(t, ok)
95+}
96+
97+func TestBuildBindingsAppliesOverrides(t *testing.T) {
98+ bindings, warnings := BuildBindings(map[string]string{
99+ "open-palette": "alt+o",
100+ "focus-pane-left": "alt+left",
101+ "switch-tab-2": "alt+2",
102+ })
103+ require.Empty(t, warnings)
104+
105+ assertAltRuneAction(t, bindings, 'o', ActionOpenPalette)
106+ assertAltRuneUnbound(t, bindings, ' ')
107+ assertAltArrowAction(t, bindings, 'D', ActionFocusPaneLeft)
108+ assertAltRuneUnbound(t, bindings, 'h')
109+ assertAltRuneAction(t, bindings, '2', ActionSwitchTab2)
110+ assertAltRuneAction(t, bindings, 'é', ActionSwitchTab2)
111+}
112+
113+func TestBuildBindingsWarnsAndSkipsInvalidEntries(t *testing.T) {
114+ bindings, warnings := BuildBindings(map[string]string{
115+ "missing": "alt+m",
116+ "open-palette": "ctrl+o",
117+ "jump-attention": "alt+j",
118+ })
119+
120+ require.Len(t, warnings, 3)
121+ warningText := warnings[0].Msg + "\n" + warnings[1].Msg + "\n" + warnings[2].Msg
122+ require.Contains(t, warningText, "unknown action")
123+ require.Contains(t, warningText, "invalid key")
124+ require.Contains(t, warningText, "duplicate key")
125+ assertAltRuneAction(t, bindings, ' ', ActionOpenPalette)
126+ assertAltRuneAction(t, bindings, 'a', ActionJumpAttention)
127+ assertAltRuneAction(t, bindings, 'j', ActionFocusPaneDown)
128+}
129+
130+func TestBuildBindingsAllowsSameActionToKeepDefaultKey(t *testing.T) {
131+ bindings, warnings := BuildBindings(map[string]string{
132+ "focus-pane-left": "alt+h",
133+ })
134+
135+ require.Empty(t, warnings)
136+ assertAltRuneAction(t, bindings, 'h', ActionFocusPaneLeft)
137+}
138+
139+func TestBuildBindingEntriesDuplicateKeyConflictsUseFileOrder(t *testing.T) {
140+ tests := []struct {
141+ name string
142+ overrides []domain.ConfigEntry
143+ wantAltP Action
144+ wantOpenDefault bool
145+ wantFocusDefault bool
146+ wantWarningAction string
147+ }{
148+ {
149+ name: "open palette first wins",
150+ overrides: []domain.ConfigEntry{
151+ {Key: "open-palette", Value: "alt+p"},
152+ {Key: "focus-pane-left", Value: "alt+p"},
153+ },
154+ wantAltP: ActionOpenPalette,
155+ wantFocusDefault: true,
156+ wantWarningAction: "focus-pane-left",
157+ },
158+ {
159+ name: "focus pane left first wins",
160+ overrides: []domain.ConfigEntry{
161+ {Key: "focus-pane-left", Value: "alt+p"},
162+ {Key: "open-palette", Value: "alt+p"},
163+ },
164+ wantAltP: ActionFocusPaneLeft,
165+ wantOpenDefault: true,
166+ wantWarningAction: "open-palette",
167+ },
168+ }
169+
170+ for _, tt := range tests {
171+ t.Run(tt.name, func(t *testing.T) {
172+ bindings, warnings := BuildBindingEntries(tt.overrides)
173+
174+ require.Len(t, warnings, 1)
175+ require.Contains(t, warnings[0].Msg, tt.wantWarningAction)
176+ assertAltRuneAction(t, bindings, 'p', tt.wantAltP)
177+ if tt.wantOpenDefault {
178+ assertAltRuneAction(t, bindings, ' ', ActionOpenPalette)
179+ } else {
180+ assertAltRuneUnbound(t, bindings, ' ')
181+ }
182+ if tt.wantFocusDefault {
183+ assertAltRuneAction(t, bindings, 'h', ActionFocusPaneLeft)
184+ } else {
185+ assertAltRuneUnbound(t, bindings, 'h')
186+ }
187+ })
188+ }
189+}
190+
191+func TestBuildBindingsFreesDefaultsForOverriddenActionsBeforeConflictChecks(t *testing.T) {
192+ bindings, warnings := BuildBindings(map[string]string{
193+ "open-palette": "alt+o",
194+ "focus-pane-down": "alt+space",
195+ })
196+
197+ require.Empty(t, warnings)
198+ assertAltRuneAction(t, bindings, 'o', ActionOpenPalette)
199+ assertAltRuneAction(t, bindings, ' ', ActionFocusPaneDown)
200+ assertAltRuneUnbound(t, bindings, 'j')
201+}
202+
203+func TestBuildBindingEntriesFailedOverrideRestoreDoesNotOverwriteEarlierOverride(t *testing.T) {
204+ bindings, warnings := BuildBindingEntries([]domain.ConfigEntry{
205+ {Key: "open-palette", Value: "alt+a"},
206+ {Key: "jump-attention", Value: "alt+a"},
207+ })
208+
209+ require.Len(t, warnings, 1)
210+ assertAltRuneAction(t, bindings, 'a', ActionOpenPalette)
211+ assertAltRuneUnbound(t, bindings, ' ')
212+}
213+
214+func assertAltRuneAction(t *testing.T, bindings *Bindings, key rune, want Action) {
215+ t.Helper()
216+ got, _, ok := bindings.actionForAltBytes([]byte(string(key)))
217+ require.True(t, ok)
218+ require.Equal(t, want, got)
219+}
220+
221+func assertAltRuneUnbound(t *testing.T, bindings *Bindings, key rune) {
222+ t.Helper()
223+ _, _, ok := bindings.actionForAltBytes([]byte(string(key)))
224+ require.False(t, ok)
225+}
226+
227+func assertAltArrowAction(t *testing.T, bindings *Bindings, final byte, want Action) {
228+ t.Helper()
229+ got, ok := bindings.actionForAltArrow(final)
230+ require.True(t, ok)
231+ require.Equal(t, want, got)
232+}
@@ -0,0 +1,232 @@
1+package keys
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestParseKeySpecAcceptsValidSpecs(t *testing.T) {
11+ cases := []struct {
12+ name string
13+ spec string
14+ wantRunes []rune
15+ wantArrow byte
16+ }{
17+ {name: "letter", spec: "alt+x", wantRunes: []rune{'x'}},
18+ {name: "unicode single char", spec: "alt+ñ", wantRunes: []rune{'ñ'}},
19+ {name: "space", spec: "alt+space", wantRunes: []rune{' '}},
20+ {name: "left arrow", spec: "alt+left", wantArrow: 'D'},
21+ {name: "right arrow", spec: "alt+right", wantArrow: 'C'},
22+ {name: "up arrow", spec: "alt+up", wantArrow: 'A'},
23+ {name: "down arrow", spec: "alt+down", wantArrow: 'B'},
24+ {name: "digit includes layout aliases", spec: "alt+2", wantRunes: []rune{'2', 'é'}},
25+ {name: "digit eight includes known aliases", spec: "alt+8", wantRunes: []rune{'8', '_', '!'}},
26+ }
27+
28+ for _, tc := range cases {
29+ t.Run(tc.name, func(t *testing.T) {
30+ got, err := ParseKeySpec(tc.spec)
31+ require.NoError(t, err)
32+ require.Equal(t, tc.wantRunes, got.altRunes)
33+ require.Equal(t, tc.wantArrow, got.altArrow)
34+ })
35+ }
36+}
37+
38+func TestParseKeySpecRejectsMalformedSpecs(t *testing.T) {
39+ cases := []string{
40+ "",
41+ "x",
42+ "ctrl+x",
43+ "alt+",
44+ "alt+xy",
45+ "alt+0",
46+ "alt+10",
47+ "alt+tab",
48+ "alt+Space",
49+ " alt+x",
50+ "alt+x ",
51+ }
52+
53+ for _, spec := range cases {
54+ t.Run(spec, func(t *testing.T) {
55+ _, err := ParseKeySpec(spec)
56+ require.Error(t, err)
57+ })
58+ }
59+}
60+
61+func TestActionNamesAreCanonical(t *testing.T) {
62+ cases := []struct {
63+ a Action
64+ name string
65+ }{
66+ {ActionOpenPalette, "open-palette"},
67+ {ActionJumpAttention, "jump-attention"},
68+ {ActionFocusPaneLeft, "focus-pane-left"},
69+ {ActionFocusPaneRight, "focus-pane-right"},
70+ {ActionFocusPaneUp, "focus-pane-up"},
71+ {ActionFocusPaneDown, "focus-pane-down"},
72+ {ActionSwitchTab1, "switch-tab-1"},
73+ {ActionSwitchTab2, "switch-tab-2"},
74+ {ActionSwitchTab3, "switch-tab-3"},
75+ {ActionSwitchTab4, "switch-tab-4"},
76+ {ActionSwitchTab5, "switch-tab-5"},
77+ {ActionSwitchTab6, "switch-tab-6"},
78+ {ActionSwitchTab7, "switch-tab-7"},
79+ {ActionSwitchTab8, "switch-tab-8"},
80+ {ActionSwitchTab9, "switch-tab-9"},
81+ }
82+
83+ for _, tc := range cases {
84+ t.Run(tc.name, func(t *testing.T) {
85+ require.Equal(t, tc.name, tc.a.Name())
86+ got, ok := ActionByName(tc.name)
87+ require.True(t, ok)
88+ require.Equal(t, tc.a, got)
89+ })
90+ }
91+
92+ require.Equal(t, "unknown", Action(99).Name())
93+ _, ok := ActionByName("unknown-action")
94+ require.False(t, ok)
95+}
96+
97+func TestBuildBindingsAppliesOverrides(t *testing.T) {
98+ bindings, warnings := BuildBindings(map[string]string{
99+ "open-palette": "alt+o",
100+ "focus-pane-left": "alt+left",
101+ "switch-tab-2": "alt+2",
102+ })
103+ require.Empty(t, warnings)
104+
105+ assertAltRuneAction(t, bindings, 'o', ActionOpenPalette)
106+ assertAltRuneUnbound(t, bindings, ' ')
107+ assertAltArrowAction(t, bindings, 'D', ActionFocusPaneLeft)
108+ assertAltRuneUnbound(t, bindings, 'h')
109+ assertAltRuneAction(t, bindings, '2', ActionSwitchTab2)
110+ assertAltRuneAction(t, bindings, 'é', ActionSwitchTab2)
111+}
112+
113+func TestBuildBindingsWarnsAndSkipsInvalidEntries(t *testing.T) {
114+ bindings, warnings := BuildBindings(map[string]string{
115+ "missing": "alt+m",
116+ "open-palette": "ctrl+o",
117+ "jump-attention": "alt+j",
118+ })
119+
120+ require.Len(t, warnings, 3)
121+ warningText := warnings[0].Msg + "\n" + warnings[1].Msg + "\n" + warnings[2].Msg
122+ require.Contains(t, warningText, "unknown action")
123+ require.Contains(t, warningText, "invalid key")
124+ require.Contains(t, warningText, "duplicate key")
125+ assertAltRuneAction(t, bindings, ' ', ActionOpenPalette)
126+ assertAltRuneAction(t, bindings, 'a', ActionJumpAttention)
127+ assertAltRuneAction(t, bindings, 'j', ActionFocusPaneDown)
128+}
129+
130+func TestBuildBindingsAllowsSameActionToKeepDefaultKey(t *testing.T) {
131+ bindings, warnings := BuildBindings(map[string]string{
132+ "focus-pane-left": "alt+h",
133+ })
134+
135+ require.Empty(t, warnings)
136+ assertAltRuneAction(t, bindings, 'h', ActionFocusPaneLeft)
137+}
138+
139+func TestBuildBindingEntriesDuplicateKeyConflictsUseFileOrder(t *testing.T) {
140+ tests := []struct {
141+ name string
142+ overrides []domain.ConfigEntry
143+ wantAltP Action
144+ wantOpenDefault bool
145+ wantFocusDefault bool
146+ wantWarningAction string
147+ }{
148+ {
149+ name: "open palette first wins",
150+ overrides: []domain.ConfigEntry{
151+ {Key: "open-palette", Value: "alt+p"},
152+ {Key: "focus-pane-left", Value: "alt+p"},
153+ },
154+ wantAltP: ActionOpenPalette,
155+ wantFocusDefault: true,
156+ wantWarningAction: "focus-pane-left",
157+ },
158+ {
159+ name: "focus pane left first wins",
160+ overrides: []domain.ConfigEntry{
161+ {Key: "focus-pane-left", Value: "alt+p"},
162+ {Key: "open-palette", Value: "alt+p"},
163+ },
164+ wantAltP: ActionFocusPaneLeft,
165+ wantOpenDefault: true,
166+ wantWarningAction: "open-palette",
167+ },
168+ }
169+
170+ for _, tt := range tests {
171+ t.Run(tt.name, func(t *testing.T) {
172+ bindings, warnings := BuildBindingEntries(tt.overrides)
173+
174+ require.Len(t, warnings, 1)
175+ require.Contains(t, warnings[0].Msg, tt.wantWarningAction)
176+ assertAltRuneAction(t, bindings, 'p', tt.wantAltP)
177+ if tt.wantOpenDefault {
178+ assertAltRuneAction(t, bindings, ' ', ActionOpenPalette)
179+ } else {
180+ assertAltRuneUnbound(t, bindings, ' ')
181+ }
182+ if tt.wantFocusDefault {
183+ assertAltRuneAction(t, bindings, 'h', ActionFocusPaneLeft)
184+ } else {
185+ assertAltRuneUnbound(t, bindings, 'h')
186+ }
187+ })
188+ }
189+}
190+
191+func TestBuildBindingsFreesDefaultsForOverriddenActionsBeforeConflictChecks(t *testing.T) {
192+ bindings, warnings := BuildBindings(map[string]string{
193+ "open-palette": "alt+o",
194+ "focus-pane-down": "alt+space",
195+ })
196+
197+ require.Empty(t, warnings)
198+ assertAltRuneAction(t, bindings, 'o', ActionOpenPalette)
199+ assertAltRuneAction(t, bindings, ' ', ActionFocusPaneDown)
200+ assertAltRuneUnbound(t, bindings, 'j')
201+}
202+
203+func TestBuildBindingEntriesFailedOverrideRestoreDoesNotOverwriteEarlierOverride(t *testing.T) {
204+ bindings, warnings := BuildBindingEntries([]domain.ConfigEntry{
205+ {Key: "open-palette", Value: "alt+a"},
206+ {Key: "jump-attention", Value: "alt+a"},
207+ })
208+
209+ require.Len(t, warnings, 1)
210+ assertAltRuneAction(t, bindings, 'a', ActionOpenPalette)
211+ assertAltRuneUnbound(t, bindings, ' ')
212+}
213+
214+func assertAltRuneAction(t *testing.T, bindings *Bindings, key rune, want Action) {
215+ t.Helper()
216+ got, _, ok := bindings.actionForAltBytes([]byte(string(key)))
217+ require.True(t, ok)
218+ require.Equal(t, want, got)
219+}
220+
221+func assertAltRuneUnbound(t *testing.T, bindings *Bindings, key rune) {
222+ t.Helper()
223+ _, _, ok := bindings.actionForAltBytes([]byte(string(key)))
224+ require.False(t, ok)
225+}
226+
227+func assertAltArrowAction(t *testing.T, bindings *Bindings, final byte, want Action) {
228+ t.Helper()
229+ got, ok := bindings.actionForAltArrow(final)
230+ require.True(t, ok)
231+ require.Equal(t, want, got)
232+}
M internal/usecase/keys/router.go +32 -48
@@ -6,6 +6,7 @@ import (
66 "bytes"
77 "slices"
88 "sync"
9+ "sync/atomic"
910 "time"
1011 "unicode/utf8"
1112
@@ -49,9 +50,10 @@ type Handler interface {
4950 // ESCDelay so split Alt-key sequences can still be intercepted without delaying
5051 // known terminal control prefixes (ESC [ and ESC O), which pass through.
5152 type Router struct {
52- clock ports.Clock
53- delay time.Duration
54- h Handler
53+ clock ports.Clock
54+ delay time.Duration
55+ h Handler
56+ bindings *atomic.Pointer[Bindings]
5557
5658 mu sync.Mutex
5759 pending bool
@@ -60,8 +62,12 @@ type Router struct {
6062 pendingDone chan struct{}
6163 }
6264
63-func NewRouter(clock ports.Clock, h Handler) *Router {
64- return &Router{clock: clock, delay: ESCDelay, h: h}
65+func NewRouter(clock ports.Clock, h Handler, bindings ...*atomic.Pointer[Bindings]) *Router {
66+ var bindingPtr *atomic.Pointer[Bindings]
67+ if len(bindings) > 0 {
68+ bindingPtr = bindings[0]
69+ }
70+ return &Router{clock: clock, delay: ESCDelay, h: h, bindings: bindingPtr}
6571 }
6672
6773 // Route routes one transport read.
@@ -120,7 +126,7 @@ func (r *Router) route(data []byte) {
120126 continue
121127 }
122128 }
123- if action, size, ok, partial := altArrowCSI(remaining); ok {
129+ if action, size, ok, partial := r.altArrowCSI(remaining); ok {
124130 flush()
125131 r.h.Action(action)
126132 i += 1 + size
@@ -136,7 +142,7 @@ func (r *Router) route(data []byte) {
136142 i += 2
137143 continue
138144 }
139- if action, size, ok := binding(remaining); ok {
145+ if action, size, ok := r.binding(remaining); ok {
140146 flush()
141147 r.h.Action(action)
142148 i += 1 + size
@@ -154,7 +160,7 @@ func (r *Router) route(data []byte) {
154160 }
155161
156162 func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int {
157- if action, size, ok, partial := altArrowCSI(data); ok {
163+ if action, size, ok, partial := r.altArrowCSI(data); ok {
158164 r.h.Action(action)
159165 return size
160166 } else if partial {
@@ -162,7 +168,7 @@ func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int {
162168 return len(data)
163169 }
164170 next := data[0]
165- if action, size, ok := binding(data); ok {
171+ if action, size, ok := r.binding(data); ok {
166172 r.h.Action(action)
167173 return size
168174 }
@@ -238,7 +244,18 @@ func (r *Router) forward(data []byte) {
238244
239245 func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }
240246
241-func altArrowCSI(data []byte) (Action, int, bool, bool) {
247+func (r *Router) currentBindings() *Bindings {
248+ if r.bindings == nil {
249+ return defaultBindings
250+ }
251+ bindings := r.bindings.Load()
252+ if bindings == nil {
253+ return defaultBindings
254+ }
255+ return bindings
256+}
257+
258+func (r *Router) altArrowCSI(data []byte) (Action, int, bool, bool) {
242259 const seqLen = len("[1;3A")
243260 if len(data) < seqLen {
244261 return 0, 0, false, hasAltArrowCSIPrefix(data)
@@ -247,18 +264,10 @@ func altArrowCSI(data []byte) (Action, int, bool, bool) {
247264 if seq[0] != '[' || seq[1] != '1' || seq[2] != ';' || (seq[3] != '3' && seq[3] != '9') {
248265 return 0, 0, false, false
249266 }
250- switch seq[4] {
251- case 'A':
252- return ActionFocusPaneUp, seqLen, true, false
253- case 'B':
254- return ActionFocusPaneDown, seqLen, true, false
255- case 'C':
256- return ActionFocusPaneRight, seqLen, true, false
257- case 'D':
258- return ActionFocusPaneLeft, seqLen, true, false
259- default:
260- return 0, 0, false, false
267+ if action, ok := r.currentBindings().actionForAltArrow(seq[4]); ok {
268+ return action, seqLen, true, false
261269 }
270+ return 0, 0, false, false
262271 }
263272
264273 func hasAltArrowCSIPrefix(data []byte) bool {
@@ -280,33 +289,8 @@ func matchesPrefix(data []byte, want string) bool {
280289 return true
281290 }
282291
283-func binding(data []byte) (Action, int, bool) {
284- if len(data) == 0 {
285- return 0, 0, false
286- }
287- switch data[0] {
288- case ' ':
289- return ActionOpenPalette, 1, true
290- case 'a':
291- return ActionJumpAttention, 1, true
292- case 'h':
293- return ActionFocusPaneLeft, 1, true
294- case 'j':
295- return ActionFocusPaneDown, 1, true
296- case 'k':
297- return ActionFocusPaneUp, 1, true
298- case 'l':
299- return ActionFocusPaneRight, 1, true
300- }
301-
302- key, size := utf8.DecodeRune(data)
303- if key == utf8.RuneError && size == 1 {
304- return 0, 0, false
305- }
306- if idx, ok := topRowDigitIndex(key); ok {
307- return ActionSwitchTab1 + Action(idx), size, true
308- }
309- return 0, 0, false
292+func (r *Router) binding(data []byte) (Action, int, bool) {
293+ return r.currentBindings().actionForAltBytes(data)
310294 }
311295
312296 var topRowDigitAliases = [][]rune{
@@ -6,6 +6,7 @@ import (
6 "bytes"6 "bytes"
7 "slices"7 "slices"
8 "sync"8 "sync"
9+ "sync/atomic"
9 "time"10 "time"
10 "unicode/utf8"11 "unicode/utf8"
11 12
@@ -49,9 +50,10 @@ type Handler interface {
49 // ESCDelay so split Alt-key sequences can still be intercepted without delaying50 // ESCDelay so split Alt-key sequences can still be intercepted without delaying
50 // known terminal control prefixes (ESC [ and ESC O), which pass through.51 // known terminal control prefixes (ESC [ and ESC O), which pass through.
51 type Router struct {52 type Router struct {
52- clock ports.Clock53+ clock ports.Clock
53- delay time.Duration54+ delay time.Duration
54- h Handler55+ h Handler
56+ bindings *atomic.Pointer[Bindings]
55 57
56 mu sync.Mutex58 mu sync.Mutex
57 pending bool59 pending bool
@@ -60,8 +62,12 @@ type Router struct {
60 pendingDone chan struct{}62 pendingDone chan struct{}
61 }63 }
62 64
63-func NewRouter(clock ports.Clock, h Handler) *Router {65+func NewRouter(clock ports.Clock, h Handler, bindings ...*atomic.Pointer[Bindings]) *Router {
64- return &Router{clock: clock, delay: ESCDelay, h: h}66+ var bindingPtr *atomic.Pointer[Bindings]
67+ if len(bindings) > 0 {
68+ bindingPtr = bindings[0]
69+ }
70+ return &Router{clock: clock, delay: ESCDelay, h: h, bindings: bindingPtr}
65 }71 }
66 72
67 // Route routes one transport read.73 // Route routes one transport read.
@@ -120,7 +126,7 @@ func (r *Router) route(data []byte) {
120 continue126 continue
121 }127 }
122 }128 }
123- if action, size, ok, partial := altArrowCSI(remaining); ok {129+ if action, size, ok, partial := r.altArrowCSI(remaining); ok {
124 flush()130 flush()
125 r.h.Action(action)131 r.h.Action(action)
126 i += 1 + size132 i += 1 + size
@@ -136,7 +142,7 @@ func (r *Router) route(data []byte) {
136 i += 2142 i += 2
137 continue143 continue
138 }144 }
139- if action, size, ok := binding(remaining); ok {145+ if action, size, ok := r.binding(remaining); ok {
140 flush()146 flush()
141 r.h.Action(action)147 r.h.Action(action)
142 i += 1 + size148 i += 1 + size
@@ -154,7 +160,7 @@ func (r *Router) route(data []byte) {
154 }160 }
155 161
156 func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int {162 func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int {
157- if action, size, ok, partial := altArrowCSI(data); ok {163+ if action, size, ok, partial := r.altArrowCSI(data); ok {
158 r.h.Action(action)164 r.h.Action(action)
159 return size165 return size
160 } else if partial {166 } else if partial {
@@ -162,7 +168,7 @@ func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int {
162 return len(data)168 return len(data)
163 }169 }
164 next := data[0]170 next := data[0]
165- if action, size, ok := binding(data); ok {171+ if action, size, ok := r.binding(data); ok {
166 r.h.Action(action)172 r.h.Action(action)
167 return size173 return size
168 }174 }
@@ -238,7 +244,18 @@ func (r *Router) forward(data []byte) {
238 244
239 func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }245 func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }
240 246
241-func altArrowCSI(data []byte) (Action, int, bool, bool) {247+func (r *Router) currentBindings() *Bindings {
248+ if r.bindings == nil {
249+ return defaultBindings
250+ }
251+ bindings := r.bindings.Load()
252+ if bindings == nil {
253+ return defaultBindings
254+ }
255+ return bindings
256+}
257+
258+func (r *Router) altArrowCSI(data []byte) (Action, int, bool, bool) {
242 const seqLen = len("[1;3A")259 const seqLen = len("[1;3A")
243 if len(data) < seqLen {260 if len(data) < seqLen {
244 return 0, 0, false, hasAltArrowCSIPrefix(data)261 return 0, 0, false, hasAltArrowCSIPrefix(data)
@@ -247,18 +264,10 @@ func altArrowCSI(data []byte) (Action, int, bool, bool) {
247 if seq[0] != '[' || seq[1] != '1' || seq[2] != ';' || (seq[3] != '3' && seq[3] != '9') {264 if seq[0] != '[' || seq[1] != '1' || seq[2] != ';' || (seq[3] != '3' && seq[3] != '9') {
248 return 0, 0, false, false265 return 0, 0, false, false
249 }266 }
250- switch seq[4] {267+ if action, ok := r.currentBindings().actionForAltArrow(seq[4]); ok {
251- case 'A':268+ return action, seqLen, true, false
252- return ActionFocusPaneUp, seqLen, true, false
253- case 'B':
254- return ActionFocusPaneDown, seqLen, true, false
255- case 'C':
256- return ActionFocusPaneRight, seqLen, true, false
257- case 'D':
258- return ActionFocusPaneLeft, seqLen, true, false
259- default:
260- return 0, 0, false, false
261 }269 }
270+ return 0, 0, false, false
262 }271 }
263 272
264 func hasAltArrowCSIPrefix(data []byte) bool {273 func hasAltArrowCSIPrefix(data []byte) bool {
@@ -280,33 +289,8 @@ func matchesPrefix(data []byte, want string) bool {
280 return true289 return true
281 }290 }
282 291
283-func binding(data []byte) (Action, int, bool) {292+func (r *Router) binding(data []byte) (Action, int, bool) {
284- if len(data) == 0 {293+ return r.currentBindings().actionForAltBytes(data)
285- return 0, 0, false
286- }
287- switch data[0] {
288- case ' ':
289- return ActionOpenPalette, 1, true
290- case 'a':
291- return ActionJumpAttention, 1, true
292- case 'h':
293- return ActionFocusPaneLeft, 1, true
294- case 'j':
295- return ActionFocusPaneDown, 1, true
296- case 'k':
297- return ActionFocusPaneUp, 1, true
298- case 'l':
299- return ActionFocusPaneRight, 1, true
300- }
301-
302- key, size := utf8.DecodeRune(data)
303- if key == utf8.RuneError && size == 1 {
304- return 0, 0, false
305- }
306- if idx, ok := topRowDigitIndex(key); ok {
307- return ActionSwitchTab1 + Action(idx), size, true
308- }
309- return 0, 0, false
310 }294 }
311 295
312 var topRowDigitAliases = [][]rune{296 var topRowDigitAliases = [][]rune{
M internal/usecase/keys/router_test.go +34 -0
@@ -3,6 +3,7 @@ package keys
33 import (
44 "runtime"
55 "strings"
6+ "sync/atomic"
67 "testing"
78 "time"
89
@@ -45,6 +46,39 @@ func (h *captureHandler) Forward(data []byte) {
4546 }
4647 func (h *captureHandler) Action(a Action) { h.actions = append(h.actions, a) }
4748
49+func TestDefaultBindingsParityRoutesBuiltInBindings(t *testing.T) {
50+ cases := []struct {
51+ name string
52+ in []byte
53+ want Action
54+ }{
55+ {name: "palette", in: []byte{ESC, ' '}, want: ActionOpenPalette},
56+ {name: "jump attention", in: []byte{ESC, 'a'}, want: ActionJumpAttention},
57+ {name: "focus left", in: []byte{ESC, 'h'}, want: ActionFocusPaneLeft},
58+ {name: "focus down", in: []byte{ESC, 'j'}, want: ActionFocusPaneDown},
59+ {name: "focus up", in: []byte{ESC, 'k'}, want: ActionFocusPaneUp},
60+ {name: "focus right", in: []byte{ESC, 'l'}, want: ActionFocusPaneRight},
61+ {name: "arrow left", in: []byte{ESC, '[', '1', ';', '3', 'D'}, want: ActionFocusPaneLeft},
62+ {name: "arrow right", in: []byte{ESC, '[', '1', ';', '3', 'C'}, want: ActionFocusPaneRight},
63+ {name: "arrow up", in: []byte{ESC, '[', '1', ';', '3', 'A'}, want: ActionFocusPaneUp},
64+ {name: "arrow down", in: []byte{ESC, '[', '1', ';', '3', 'B'}, want: ActionFocusPaneDown},
65+ {name: "digit", in: []byte{ESC, '1'}, want: ActionSwitchTab1},
66+ {name: "digit alias", in: append([]byte{ESC}, []byte("é")...), want: ActionSwitchTab2},
67+ }
68+
69+ var bindings atomic.Pointer[Bindings]
70+ bindings.Store(DefaultBindings())
71+ for _, tc := range cases {
72+ t.Run(tc.name, func(t *testing.T) {
73+ clk := &fakeClock{}
74+ h := &captureHandler{}
75+ NewRouter(clk, h, &bindings).Route(tc.in)
76+ require.Equal(t, []Action{tc.want}, h.actions)
77+ require.Empty(t, h.forwards)
78+ })
79+ }
80+}
81+
4882 func TestRouterInterceptsAltSpaceForPalette(t *testing.T) {
4983 clk := &fakeClock{}
5084 h := &captureHandler{}
@@ -3,6 +3,7 @@ package keys
3 import (3 import (
4 "runtime"4 "runtime"
5 "strings"5 "strings"
6+ "sync/atomic"
6 "testing"7 "testing"
7 "time"8 "time"
8 9
@@ -45,6 +46,39 @@ func (h *captureHandler) Forward(data []byte) {
45 }46 }
46 func (h *captureHandler) Action(a Action) { h.actions = append(h.actions, a) }47 func (h *captureHandler) Action(a Action) { h.actions = append(h.actions, a) }
47 48
49+func TestDefaultBindingsParityRoutesBuiltInBindings(t *testing.T) {
50+ cases := []struct {
51+ name string
52+ in []byte
53+ want Action
54+ }{
55+ {name: "palette", in: []byte{ESC, ' '}, want: ActionOpenPalette},
56+ {name: "jump attention", in: []byte{ESC, 'a'}, want: ActionJumpAttention},
57+ {name: "focus left", in: []byte{ESC, 'h'}, want: ActionFocusPaneLeft},
58+ {name: "focus down", in: []byte{ESC, 'j'}, want: ActionFocusPaneDown},
59+ {name: "focus up", in: []byte{ESC, 'k'}, want: ActionFocusPaneUp},
60+ {name: "focus right", in: []byte{ESC, 'l'}, want: ActionFocusPaneRight},
61+ {name: "arrow left", in: []byte{ESC, '[', '1', ';', '3', 'D'}, want: ActionFocusPaneLeft},
62+ {name: "arrow right", in: []byte{ESC, '[', '1', ';', '3', 'C'}, want: ActionFocusPaneRight},
63+ {name: "arrow up", in: []byte{ESC, '[', '1', ';', '3', 'A'}, want: ActionFocusPaneUp},
64+ {name: "arrow down", in: []byte{ESC, '[', '1', ';', '3', 'B'}, want: ActionFocusPaneDown},
65+ {name: "digit", in: []byte{ESC, '1'}, want: ActionSwitchTab1},
66+ {name: "digit alias", in: append([]byte{ESC}, []byte("é")...), want: ActionSwitchTab2},
67+ }
68+
69+ var bindings atomic.Pointer[Bindings]
70+ bindings.Store(DefaultBindings())
71+ for _, tc := range cases {
72+ t.Run(tc.name, func(t *testing.T) {
73+ clk := &fakeClock{}
74+ h := &captureHandler{}
75+ NewRouter(clk, h, &bindings).Route(tc.in)
76+ require.Equal(t, []Action{tc.want}, h.actions)
77+ require.Empty(t, h.forwards)
78+ })
79+ }
80+}
81+
48 func TestRouterInterceptsAltSpaceForPalette(t *testing.T) {82 func TestRouterInterceptsAltSpaceForPalette(t *testing.T) {
49 clk := &fakeClock{}83 clk := &fakeClock{}
50 h := &captureHandler{}84 h := &captureHandler{}
M internal/usecase/theme/theme.go +73 -7
@@ -12,14 +12,39 @@ const maxPending = 64
1212
1313 // Theme describes terminal default colors reported by OSC 10/11.
1414 type Theme struct {
15- Foreground renderer.RGB
16- Background renderer.RGB
17- HasFG bool
18- HasBG bool
19- TrueColor bool
20- Known bool
15+ Foreground renderer.RGB
16+ Background renderer.RGB
17+ HasFG bool
18+ HasBG bool
19+ TrueColor bool
20+ Known bool
21+ SchemeKnown bool
22+ Light bool
2123 }
2224
25+var (
26+ BuiltinDark = Theme{
27+ Foreground: renderer.RGB{R: 0xd8, G: 0xd8, B: 0xd8},
28+ Background: renderer.RGB{R: 0x18, G: 0x18, B: 0x18},
29+ HasFG: true,
30+ HasBG: true,
31+ TrueColor: true,
32+ Known: true,
33+ SchemeKnown: true,
34+ Light: false,
35+ }
36+ BuiltinLight = Theme{
37+ Foreground: renderer.RGB{R: 0x20, G: 0x20, B: 0x20},
38+ Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8},
39+ HasFG: true,
40+ HasBG: true,
41+ TrueColor: true,
42+ Known: true,
43+ SchemeKnown: true,
44+ Light: true,
45+ }
46+)
47+
2348 // ParseXColor parses the XParseColor formats commonly returned by OSC 10/11.
2449 func ParseXColor(s string) (renderer.RGB, bool) {
2550 if strings.HasPrefix(s, "#") {
@@ -95,7 +120,7 @@ type Scanner struct {
95120 // callers never see it split. Partial OSC color responses are buffered across
96121 // calls, but the buffer is bounded so an unterminated OSC cannot block
97122 // unrelated input forever.
98-func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onBytes func([]byte)) {
123+func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onScheme func(light bool), onBytes func([]byte)) {
99124 if len(s.pending) > 0 {
100125 combined := make([]byte, 0, len(s.pending)+len(data))
101126 combined = append(combined, s.pending...)
@@ -110,6 +135,22 @@ func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), on
110135 continue
111136 }
112137
138+ if complete, possible, light := schemeCSINotification(data[i:]); complete {
139+ if byteStart < i {
140+ onBytes(data[byteStart:i])
141+ }
142+ onScheme(light)
143+ i += len("\x1b[?997;1n") - 1
144+ byteStart = i + 1
145+ continue
146+ } else if possible {
147+ if byteStart < i {
148+ onBytes(data[byteStart:i])
149+ }
150+ s.bufferOrFlush(data[i:], onBytes)
151+ return
152+ }
153+
113154 completePrefix, possiblePrefix := colorOSCPrefix(data[i:])
114155 if !completePrefix {
115156 if possiblePrefix {
@@ -183,6 +224,31 @@ func colorOSCPrefix(data []byte) (complete bool, possible bool) {
183224 return false, false
184225 }
185226
227+func schemeCSINotification(data []byte) (complete bool, possible bool, light bool) {
228+ if len(data) < 2 {
229+ return false, false, false
230+ }
231+ prefixes := []struct {
232+ seq []byte
233+ light bool
234+ }{
235+ {seq: []byte("\x1b[?997;1n"), light: false},
236+ {seq: []byte("\x1b[?997;2n"), light: true},
237+ }
238+ for _, prefix := range prefixes {
239+ if len(data) >= len(prefix.seq) {
240+ if bytes.Equal(data[:len(prefix.seq)], prefix.seq) {
241+ return true, true, prefix.light
242+ }
243+ continue
244+ }
245+ if bytes.Equal(data, prefix.seq[:len(data)]) {
246+ return false, true, false
247+ }
248+ }
249+ return false, false, false
250+}
251+
186252 func findTerminator(data []byte) (start int, end int) {
187253 for i := range data {
188254 switch data[i] {
@@ -12,14 +12,39 @@ const maxPending = 64
12 12
13 // Theme describes terminal default colors reported by OSC 10/11.13 // Theme describes terminal default colors reported by OSC 10/11.
14 type Theme struct {14 type Theme struct {
15- Foreground renderer.RGB15+ Foreground renderer.RGB
16- Background renderer.RGB16+ Background renderer.RGB
17- HasFG bool17+ HasFG bool
18- HasBG bool18+ HasBG bool
19- TrueColor bool19+ TrueColor bool
20- Known bool20+ Known bool
21+ SchemeKnown bool
22+ Light bool
21 }23 }
22 24
25+var (
26+ BuiltinDark = Theme{
27+ Foreground: renderer.RGB{R: 0xd8, G: 0xd8, B: 0xd8},
28+ Background: renderer.RGB{R: 0x18, G: 0x18, B: 0x18},
29+ HasFG: true,
30+ HasBG: true,
31+ TrueColor: true,
32+ Known: true,
33+ SchemeKnown: true,
34+ Light: false,
35+ }
36+ BuiltinLight = Theme{
37+ Foreground: renderer.RGB{R: 0x20, G: 0x20, B: 0x20},
38+ Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8},
39+ HasFG: true,
40+ HasBG: true,
41+ TrueColor: true,
42+ Known: true,
43+ SchemeKnown: true,
44+ Light: true,
45+ }
46+)
47+
23 // ParseXColor parses the XParseColor formats commonly returned by OSC 10/11.48 // ParseXColor parses the XParseColor formats commonly returned by OSC 10/11.
24 func ParseXColor(s string) (renderer.RGB, bool) {49 func ParseXColor(s string) (renderer.RGB, bool) {
25 if strings.HasPrefix(s, "#") {50 if strings.HasPrefix(s, "#") {
@@ -95,7 +120,7 @@ type Scanner struct {
95 // callers never see it split. Partial OSC color responses are buffered across120 // callers never see it split. Partial OSC color responses are buffered across
96 // calls, but the buffer is bounded so an unterminated OSC cannot block121 // calls, but the buffer is bounded so an unterminated OSC cannot block
97 // unrelated input forever.122 // unrelated input forever.
98-func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onBytes func([]byte)) {123+func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onScheme func(light bool), onBytes func([]byte)) {
99 if len(s.pending) > 0 {124 if len(s.pending) > 0 {
100 combined := make([]byte, 0, len(s.pending)+len(data))125 combined := make([]byte, 0, len(s.pending)+len(data))
101 combined = append(combined, s.pending...)126 combined = append(combined, s.pending...)
@@ -110,6 +135,22 @@ func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), on
110 continue135 continue
111 }136 }
112 137
138+ if complete, possible, light := schemeCSINotification(data[i:]); complete {
139+ if byteStart < i {
140+ onBytes(data[byteStart:i])
141+ }
142+ onScheme(light)
143+ i += len("\x1b[?997;1n") - 1
144+ byteStart = i + 1
145+ continue
146+ } else if possible {
147+ if byteStart < i {
148+ onBytes(data[byteStart:i])
149+ }
150+ s.bufferOrFlush(data[i:], onBytes)
151+ return
152+ }
153+
113 completePrefix, possiblePrefix := colorOSCPrefix(data[i:])154 completePrefix, possiblePrefix := colorOSCPrefix(data[i:])
114 if !completePrefix {155 if !completePrefix {
115 if possiblePrefix {156 if possiblePrefix {
@@ -183,6 +224,31 @@ func colorOSCPrefix(data []byte) (complete bool, possible bool) {
183 return false, false224 return false, false
184 }225 }
185 226
227+func schemeCSINotification(data []byte) (complete bool, possible bool, light bool) {
228+ if len(data) < 2 {
229+ return false, false, false
230+ }
231+ prefixes := []struct {
232+ seq []byte
233+ light bool
234+ }{
235+ {seq: []byte("\x1b[?997;1n"), light: false},
236+ {seq: []byte("\x1b[?997;2n"), light: true},
237+ }
238+ for _, prefix := range prefixes {
239+ if len(data) >= len(prefix.seq) {
240+ if bytes.Equal(data[:len(prefix.seq)], prefix.seq) {
241+ return true, true, prefix.light
242+ }
243+ continue
244+ }
245+ if bytes.Equal(data, prefix.seq[:len(data)]) {
246+ return false, true, false
247+ }
248+ }
249+ return false, false, false
250+}
251+
186 func findTerminator(data []byte) (start int, end int) {252 func findTerminator(data []byte) (start int, end int) {
187 for i := range data {253 for i := range data {
188 switch data[i] {254 switch data[i] {
M internal/usecase/theme/theme_test.go +26 -10
@@ -48,7 +48,7 @@ func TestScannerExtractsBELAndSTColors(t *testing.T) {
4848 kind int
4949 rgb renderer.RGB
5050 }{kind: kind, rgb: rgb})
51- }, func(b []byte) { out.Write(b) })
51+ }, func(light bool) {}, func(b []byte) { out.Write(b) })
5252
5353 if out.String() != "abc" {
5454 t.Fatalf("passthrough=%q want %q", out.String(), "abc")
@@ -71,8 +71,8 @@ func TestScannerHandlesSplitSequences(t *testing.T) {
7171 onColor := func(kind int, rgb renderer.RGB) { got = append(got, rgb) }
7272 onBytes := func(b []byte) { out.Write(b) }
7373
74- s.Scan([]byte("x\x1b]10;#12"), onColor, onBytes)
75- s.Scan([]byte("3456\x07y"), onColor, onBytes)
74+ s.Scan([]byte("x\x1b]10;#12"), onColor, func(light bool) {}, onBytes)
75+ s.Scan([]byte("3456\x07y"), onColor, func(light bool) {}, onBytes)
7676
7777 if out.String() != "xy" {
7878 t.Fatalf("passthrough=%q want xy", out.String())
@@ -82,12 +82,28 @@ func TestScannerHandlesSplitSequences(t *testing.T) {
8282 }
8383 }
8484
85+func TestScannerHandlesSplitSchemeNotifications(t *testing.T) {
86+ var s Scanner
87+ var schemes []bool
88+ var out bytes.Buffer
89+
90+ s.Scan([]byte("a\x1b[?997"), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) })
91+ s.Scan([]byte(";1nb\x1b[?997;2nc"), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) })
92+
93+ if out.String() != "abc" {
94+ t.Fatalf("passthrough=%q want abc", out.String())
95+ }
96+ if len(schemes) != 2 || schemes[0] != false || schemes[1] != true {
97+ t.Fatalf("schemes=%v want [false true]", schemes)
98+ }
99+}
100+
85101 func TestScannerForwardsInterleavedGarbageInOrder(t *testing.T) {
86102 var s Scanner
87103 var out bytes.Buffer
88104 colors := 0
89105
90- s.Scan([]byte("ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef"), func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) { out.Write(b) })
106+ s.Scan([]byte("ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef"), func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) { out.Write(b) })
91107
92108 if colors != 0 {
93109 t.Fatalf("colors=%d want 0", colors)
@@ -103,7 +119,7 @@ func TestScannerForwardsStandaloneEscapeImmediately(t *testing.T) {
103119 var out bytes.Buffer
104120 colors := 0
105121
106- s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) { out.Write(b) })
122+ s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) { out.Write(b) })
107123
108124 if colors != 0 {
109125 t.Fatalf("colors=%d want 0", colors)
@@ -119,7 +135,7 @@ func TestScannerDoesNotSplitSGRMouseReport(t *testing.T) {
119135 colors := 0
120136
121137 in := []byte("\x1b[<0;1;1M")
122- s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) {
138+ s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) {
123139 chunks = append(chunks, append([]byte(nil), b...))
124140 })
125141
@@ -139,7 +155,7 @@ func TestScannerDoesNotSplitArrowKey(t *testing.T) {
139155 var chunks [][]byte
140156
141157 in := []byte("\x1b[A")
142- s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(b []byte) {
158+ s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) {
143159 chunks = append(chunks, append([]byte(nil), b...))
144160 })
145161
@@ -164,7 +180,7 @@ func TestScannerKeepsMouseReportContiguousAroundColorResponse(t *testing.T) {
164180 kind int
165181 rgb renderer.RGB
166182 }{kind: kind, rgb: rgb})
167- }, func(b []byte) {
183+ }, func(light bool) {}, func(b []byte) {
168184 chunks = append(chunks, append([]byte(nil), b...))
169185 })
170186
@@ -189,8 +205,8 @@ func TestScannerFlushesOverflowingPartialQueue(t *testing.T) {
189205 var s Scanner
190206 var out bytes.Buffer
191207
192- s.Scan(append([]byte("\x1b]10;"), bytes.Repeat([]byte("a"), 70)...), func(kind int, rgb renderer.RGB) {}, func(b []byte) { out.Write(b) })
193- s.Scan([]byte("Z"), func(kind int, rgb renderer.RGB) {}, func(b []byte) { out.Write(b) })
208+ s.Scan(append([]byte("\x1b]10;"), bytes.Repeat([]byte("a"), 70)...), func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
209+ s.Scan([]byte("Z"), func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
194210
195211 if out.Len() == 0 || !bytes.Contains(out.Bytes(), []byte("\x1b]10;")) || !bytes.HasSuffix(out.Bytes(), []byte("Z")) {
196212 t.Fatalf("overflow passthrough=%q", out.String())
@@ -48,7 +48,7 @@ func TestScannerExtractsBELAndSTColors(t *testing.T) {
48 kind int48 kind int
49 rgb renderer.RGB49 rgb renderer.RGB
50 }{kind: kind, rgb: rgb})50 }{kind: kind, rgb: rgb})
51- }, func(b []byte) { out.Write(b) })51+ }, func(light bool) {}, func(b []byte) { out.Write(b) })
52 52
53 if out.String() != "abc" {53 if out.String() != "abc" {
54 t.Fatalf("passthrough=%q want %q", out.String(), "abc")54 t.Fatalf("passthrough=%q want %q", out.String(), "abc")
@@ -71,8 +71,8 @@ func TestScannerHandlesSplitSequences(t *testing.T) {
71 onColor := func(kind int, rgb renderer.RGB) { got = append(got, rgb) }71 onColor := func(kind int, rgb renderer.RGB) { got = append(got, rgb) }
72 onBytes := func(b []byte) { out.Write(b) }72 onBytes := func(b []byte) { out.Write(b) }
73 73
74- s.Scan([]byte("x\x1b]10;#12"), onColor, onBytes)74+ s.Scan([]byte("x\x1b]10;#12"), onColor, func(light bool) {}, onBytes)
75- s.Scan([]byte("3456\x07y"), onColor, onBytes)75+ s.Scan([]byte("3456\x07y"), onColor, func(light bool) {}, onBytes)
76 76
77 if out.String() != "xy" {77 if out.String() != "xy" {
78 t.Fatalf("passthrough=%q want xy", out.String())78 t.Fatalf("passthrough=%q want xy", out.String())
@@ -82,12 +82,28 @@ func TestScannerHandlesSplitSequences(t *testing.T) {
82 }82 }
83 }83 }
84 84
85+func TestScannerHandlesSplitSchemeNotifications(t *testing.T) {
86+ var s Scanner
87+ var schemes []bool
88+ var out bytes.Buffer
89+
90+ s.Scan([]byte("a\x1b[?997"), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) })
91+ s.Scan([]byte(";1nb\x1b[?997;2nc"), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) })
92+
93+ if out.String() != "abc" {
94+ t.Fatalf("passthrough=%q want abc", out.String())
95+ }
96+ if len(schemes) != 2 || schemes[0] != false || schemes[1] != true {
97+ t.Fatalf("schemes=%v want [false true]", schemes)
98+ }
99+}
100+
85 func TestScannerForwardsInterleavedGarbageInOrder(t *testing.T) {101 func TestScannerForwardsInterleavedGarbageInOrder(t *testing.T) {
86 var s Scanner102 var s Scanner
87 var out bytes.Buffer103 var out bytes.Buffer
88 colors := 0104 colors := 0
89 105
90- s.Scan([]byte("ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef"), func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) { out.Write(b) })106+ s.Scan([]byte("ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef"), func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) { out.Write(b) })
91 107
92 if colors != 0 {108 if colors != 0 {
93 t.Fatalf("colors=%d want 0", colors)109 t.Fatalf("colors=%d want 0", colors)
@@ -103,7 +119,7 @@ func TestScannerForwardsStandaloneEscapeImmediately(t *testing.T) {
103 var out bytes.Buffer119 var out bytes.Buffer
104 colors := 0120 colors := 0
105 121
106- s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) { out.Write(b) })122+ s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) { out.Write(b) })
107 123
108 if colors != 0 {124 if colors != 0 {
109 t.Fatalf("colors=%d want 0", colors)125 t.Fatalf("colors=%d want 0", colors)
@@ -119,7 +135,7 @@ func TestScannerDoesNotSplitSGRMouseReport(t *testing.T) {
119 colors := 0135 colors := 0
120 136
121 in := []byte("\x1b[<0;1;1M")137 in := []byte("\x1b[<0;1;1M")
122- s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) {138+ s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) {
123 chunks = append(chunks, append([]byte(nil), b...))139 chunks = append(chunks, append([]byte(nil), b...))
124 })140 })
125 141
@@ -139,7 +155,7 @@ func TestScannerDoesNotSplitArrowKey(t *testing.T) {
139 var chunks [][]byte155 var chunks [][]byte
140 156
141 in := []byte("\x1b[A")157 in := []byte("\x1b[A")
142- s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(b []byte) {158+ s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) {
143 chunks = append(chunks, append([]byte(nil), b...))159 chunks = append(chunks, append([]byte(nil), b...))
144 })160 })
145 161
@@ -164,7 +180,7 @@ func TestScannerKeepsMouseReportContiguousAroundColorResponse(t *testing.T) {
164 kind int180 kind int
165 rgb renderer.RGB181 rgb renderer.RGB
166 }{kind: kind, rgb: rgb})182 }{kind: kind, rgb: rgb})
167- }, func(b []byte) {183+ }, func(light bool) {}, func(b []byte) {
168 chunks = append(chunks, append([]byte(nil), b...))184 chunks = append(chunks, append([]byte(nil), b...))
169 })185 })
170 186
@@ -189,8 +205,8 @@ func TestScannerFlushesOverflowingPartialQueue(t *testing.T) {
189 var s Scanner205 var s Scanner
190 var out bytes.Buffer206 var out bytes.Buffer
191 207
192- s.Scan(append([]byte("\x1b]10;"), bytes.Repeat([]byte("a"), 70)...), func(kind int, rgb renderer.RGB) {}, func(b []byte) { out.Write(b) })208+ s.Scan(append([]byte("\x1b]10;"), bytes.Repeat([]byte("a"), 70)...), func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
193- s.Scan([]byte("Z"), func(kind int, rgb renderer.RGB) {}, func(b []byte) { out.Write(b) })209+ s.Scan([]byte("Z"), func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
194 210
195 if out.Len() == 0 || !bytes.Contains(out.Bytes(), []byte("\x1b]10;")) || !bytes.HasSuffix(out.Bytes(), []byte("Z")) {211 if out.Len() == 0 || !bytes.Contains(out.Bytes(), []byte("\x1b]10;")) || !bytes.HasSuffix(out.Bytes(), []byte("Z")) {
196 t.Fatalf("overflow passthrough=%q", out.String())212 t.Fatalf("overflow passthrough=%q", out.String())
M pkg/vt/query_test.go +55 -2
@@ -23,6 +23,10 @@ func TestScreenQueryResponses(t *testing.T) {
2323 {name: "private CPR", input: "\x1b[3;7H\x1b[?6n", want: "\x1b[?3;7R"},
2424 {name: "DECRQM 2026 reset", input: "\x1b[?2026$p", want: "\x1b[?2026;2$y"},
2525 {name: "DECRQM 2026 set", input: "\x1b[?2026h\x1b[?2026$p", want: "\x1b[?2026;1$y"},
26+ {name: "DECRQM 2031 reset", input: "\x1b[?2031$p", want: "\x1b[?2031;2$y"},
27+ {name: "DECRQM 2031 set", input: "\x1b[?2031h\x1b[?2031$p", want: "\x1b[?2031;1$y"},
28+ {name: "color scheme DSR dark", input: "\x1b[?996n", want: "\x1b[?997;1n"},
29+ {name: "color scheme DSR light", input: "\x1b[?996n", want: "\x1b[?997;2n"},
2630 {name: "DECRQM unknown mode", input: "\x1b[?1337$p", want: "\x1b[?1337;0$y"},
2731 {name: "kitty keyboard query unanswered", input: "\x1b[?u", want: ""},
2832 {name: "XTVERSION unanswered", input: "\x1b[>0q", want: ""},
@@ -33,10 +37,17 @@ func TestScreenQueryResponses(t *testing.T) {
3337 s := NewScreen(80, 24)
3438 var got bytes.Buffer
3539 s.OnResponse = func(b []byte) { got.Write(b) }
36- if tc.name == "DA split across writes" {
40+ switch tc.name {
41+ case "DA split across writes":
3742 s.Write([]byte("\x1b["))
3843 s.Write([]byte("0c"))
39- } else {
44+ case "color scheme DSR light":
45+ s.SetColorScheme(true)
46+ s.Write([]byte(tc.input))
47+ case "color scheme DSR dark":
48+ s.SetColorScheme(false)
49+ s.Write([]byte(tc.input))
50+ default:
4051 s.Write([]byte(tc.input))
4152 }
4253 if got.String() != tc.want {
@@ -127,3 +138,45 @@ func TestCSIuDispatch(t *testing.T) {
127138 })
128139 }
129140 }
141+
142+func TestScreenColorSchemeQueriesUnknownAreSilent(t *testing.T) {
143+ s := NewScreen(80, 24)
144+ s.SetColorScheme(true)
145+ s.ClearColorScheme()
146+ var got bytes.Buffer
147+ s.OnResponse = func(b []byte) { got.Write(b) }
148+ s.Write([]byte("\x1b[?996n"))
149+ if got.Len() != 0 {
150+ t.Fatalf("response = %q, want silence", got.String())
151+ }
152+}
153+
154+func TestScreenColorSchemeUnsolicitedOnlySubscribedAndChanged(t *testing.T) {
155+ s := NewScreen(80, 24)
156+ var got bytes.Buffer
157+ s.OnResponse = func(b []byte) { got.Write(b) }
158+
159+ s.SetColorScheme(true)
160+ if got.Len() != 0 {
161+ t.Fatalf("unsubscribed response = %q, want silence", got.String())
162+ }
163+
164+ s.Write([]byte("\x1b[?2031h"))
165+ if !s.ColorSchemeMode() {
166+ t.Fatalf("ColorSchemeMode() = false, want true")
167+ }
168+ s.SetColorScheme(true)
169+ if got.Len() != 0 {
170+ t.Fatalf("unchanged response = %q, want silence", got.String())
171+ }
172+ s.SetColorScheme(false)
173+ if got.String() != "\x1b[?997;1n" {
174+ t.Fatalf("changed response = %q, want dark notification", got.String())
175+ }
176+ got.Reset()
177+ s.Write([]byte("\x1b[?2031l"))
178+ s.SetColorScheme(true)
179+ if got.Len() != 0 {
180+ t.Fatalf("disabled response = %q, want silence", got.String())
181+ }
182+}
@@ -23,6 +23,10 @@ func TestScreenQueryResponses(t *testing.T) {
23 {name: "private CPR", input: "\x1b[3;7H\x1b[?6n", want: "\x1b[?3;7R"},23 {name: "private CPR", input: "\x1b[3;7H\x1b[?6n", want: "\x1b[?3;7R"},
24 {name: "DECRQM 2026 reset", input: "\x1b[?2026$p", want: "\x1b[?2026;2$y"},24 {name: "DECRQM 2026 reset", input: "\x1b[?2026$p", want: "\x1b[?2026;2$y"},
25 {name: "DECRQM 2026 set", input: "\x1b[?2026h\x1b[?2026$p", want: "\x1b[?2026;1$y"},25 {name: "DECRQM 2026 set", input: "\x1b[?2026h\x1b[?2026$p", want: "\x1b[?2026;1$y"},
26+ {name: "DECRQM 2031 reset", input: "\x1b[?2031$p", want: "\x1b[?2031;2$y"},
27+ {name: "DECRQM 2031 set", input: "\x1b[?2031h\x1b[?2031$p", want: "\x1b[?2031;1$y"},
28+ {name: "color scheme DSR dark", input: "\x1b[?996n", want: "\x1b[?997;1n"},
29+ {name: "color scheme DSR light", input: "\x1b[?996n", want: "\x1b[?997;2n"},
26 {name: "DECRQM unknown mode", input: "\x1b[?1337$p", want: "\x1b[?1337;0$y"},30 {name: "DECRQM unknown mode", input: "\x1b[?1337$p", want: "\x1b[?1337;0$y"},
27 {name: "kitty keyboard query unanswered", input: "\x1b[?u", want: ""},31 {name: "kitty keyboard query unanswered", input: "\x1b[?u", want: ""},
28 {name: "XTVERSION unanswered", input: "\x1b[>0q", want: ""},32 {name: "XTVERSION unanswered", input: "\x1b[>0q", want: ""},
@@ -33,10 +37,17 @@ func TestScreenQueryResponses(t *testing.T) {
33 s := NewScreen(80, 24)37 s := NewScreen(80, 24)
34 var got bytes.Buffer38 var got bytes.Buffer
35 s.OnResponse = func(b []byte) { got.Write(b) }39 s.OnResponse = func(b []byte) { got.Write(b) }
36- if tc.name == "DA split across writes" {40+ switch tc.name {
41+ case "DA split across writes":
37 s.Write([]byte("\x1b["))42 s.Write([]byte("\x1b["))
38 s.Write([]byte("0c"))43 s.Write([]byte("0c"))
39- } else {44+ case "color scheme DSR light":
45+ s.SetColorScheme(true)
46+ s.Write([]byte(tc.input))
47+ case "color scheme DSR dark":
48+ s.SetColorScheme(false)
49+ s.Write([]byte(tc.input))
50+ default:
40 s.Write([]byte(tc.input))51 s.Write([]byte(tc.input))
41 }52 }
42 if got.String() != tc.want {53 if got.String() != tc.want {
@@ -127,3 +138,45 @@ func TestCSIuDispatch(t *testing.T) {
127 })138 })
128 }139 }
129 }140 }
141+
142+func TestScreenColorSchemeQueriesUnknownAreSilent(t *testing.T) {
143+ s := NewScreen(80, 24)
144+ s.SetColorScheme(true)
145+ s.ClearColorScheme()
146+ var got bytes.Buffer
147+ s.OnResponse = func(b []byte) { got.Write(b) }
148+ s.Write([]byte("\x1b[?996n"))
149+ if got.Len() != 0 {
150+ t.Fatalf("response = %q, want silence", got.String())
151+ }
152+}
153+
154+func TestScreenColorSchemeUnsolicitedOnlySubscribedAndChanged(t *testing.T) {
155+ s := NewScreen(80, 24)
156+ var got bytes.Buffer
157+ s.OnResponse = func(b []byte) { got.Write(b) }
158+
159+ s.SetColorScheme(true)
160+ if got.Len() != 0 {
161+ t.Fatalf("unsubscribed response = %q, want silence", got.String())
162+ }
163+
164+ s.Write([]byte("\x1b[?2031h"))
165+ if !s.ColorSchemeMode() {
166+ t.Fatalf("ColorSchemeMode() = false, want true")
167+ }
168+ s.SetColorScheme(true)
169+ if got.Len() != 0 {
170+ t.Fatalf("unchanged response = %q, want silence", got.String())
171+ }
172+ s.SetColorScheme(false)
173+ if got.String() != "\x1b[?997;1n" {
174+ t.Fatalf("changed response = %q, want dark notification", got.String())
175+ }
176+ got.Reset()
177+ s.Write([]byte("\x1b[?2031l"))
178+ s.SetColorScheme(true)
179+ if got.Len() != 0 {
180+ t.Fatalf("disabled response = %q, want silence", got.String())
181+ }
182+}
M pkg/vt/screen.go +49 -1
@@ -89,6 +89,9 @@ type Screen struct {
8989 mouseMode int
9090 mouseSGR bool
9191 bracketedPaste bool
92+ colorSchemeMode bool
93+ colorSchemeLight bool
94+ colorSchemeSet bool
9295 }
9396
9497 func NewScreen(width, height int) *Screen {
@@ -156,6 +159,27 @@ func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive }
156159 // by the child process.
157160 func (s *Screen) BracketedPasteMode() bool { return s.bracketedPaste }
158161
162+// ColorSchemeMode reports whether DEC private mode 2031 is currently enabled.
163+func (s *Screen) ColorSchemeMode() bool { return s.colorSchemeMode }
164+
165+// SetColorScheme updates the host color scheme and notifies subscribed child apps.
166+func (s *Screen) SetColorScheme(light bool) {
167+ if s.colorSchemeSet && s.colorSchemeLight == light {
168+ return
169+ }
170+ s.colorSchemeSet = true
171+ s.colorSchemeLight = light
172+ if s.colorSchemeMode {
173+ s.respond(s.colorSchemeReport())
174+ }
175+}
176+
177+// ClearColorScheme marks the host color scheme as unknown. Future child color
178+// scheme queries are silent until SetColorScheme supplies a known value again.
179+func (s *Screen) ClearColorScheme() {
180+ s.colorSchemeSet = false
181+}
182+
159183 // ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update).
160184 // Hosts use this as a safety valve if a child enters synchronized update mode
161185 // and never sends the matching end sequence.
@@ -480,6 +504,16 @@ func (s *Screen) respond(b []byte) {
480504 }
481505 }
482506
507+func (s *Screen) colorSchemeReport() []byte {
508+ if !s.colorSchemeSet {
509+ return nil
510+ }
511+ if s.colorSchemeLight {
512+ return []byte("\x1b[?997;2n")
513+ }
514+ return []byte("\x1b[?997;1n")
515+}
516+
483517 func (s *Screen) scrollDownRegion(top, bottom, n int) {
484518 if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {
485519 return
@@ -759,6 +793,12 @@ func (s *Screen) applyCSI(params string, cmd byte) {
759793 if !private {
760794 s.respond([]byte("\x1b[0n"))
761795 }
796+ case 996:
797+ if private {
798+ if report := s.colorSchemeReport(); report != nil {
799+ s.respond(report)
800+ }
801+ }
762802 case 6:
763803 resp := make([]byte, 0, 16)
764804 resp = append(resp, "\x1b["...)
@@ -778,11 +818,17 @@ func (s *Screen) applyCSI(params string, cmd byte) {
778818 return
779819 }
780820 state := 0
781- if mode == 2026 {
821+ switch mode {
822+ case 2026:
782823 state = 2
783824 if s.syncUpdateActive {
784825 state = 1
785826 }
827+ case 2031:
828+ state = 2
829+ if s.colorSchemeMode {
830+ state = 1
831+ }
786832 }
787833 resp := make([]byte, 0, 16)
788834 resp = append(resp, "\x1b[?"...)
@@ -1041,6 +1087,8 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) {
10411087 }
10421088 case 2026:
10431089 s.syncUpdateActive = enabled
1090+ case 2031:
1091+ s.colorSchemeMode = enabled
10441092 case 25:
10451093 s.cursorVisible = enabled
10461094 case 1000, 1002, 1003:
@@ -89,6 +89,9 @@ type Screen struct {
89 mouseMode int89 mouseMode int
90 mouseSGR bool90 mouseSGR bool
91 bracketedPaste bool91 bracketedPaste bool
92+ colorSchemeMode bool
93+ colorSchemeLight bool
94+ colorSchemeSet bool
92 }95 }
93 96
94 func NewScreen(width, height int) *Screen {97 func NewScreen(width, height int) *Screen {
@@ -156,6 +159,27 @@ func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive }
156 // by the child process.159 // by the child process.
157 func (s *Screen) BracketedPasteMode() bool { return s.bracketedPaste }160 func (s *Screen) BracketedPasteMode() bool { return s.bracketedPaste }
158 161
162+// ColorSchemeMode reports whether DEC private mode 2031 is currently enabled.
163+func (s *Screen) ColorSchemeMode() bool { return s.colorSchemeMode }
164+
165+// SetColorScheme updates the host color scheme and notifies subscribed child apps.
166+func (s *Screen) SetColorScheme(light bool) {
167+ if s.colorSchemeSet && s.colorSchemeLight == light {
168+ return
169+ }
170+ s.colorSchemeSet = true
171+ s.colorSchemeLight = light
172+ if s.colorSchemeMode {
173+ s.respond(s.colorSchemeReport())
174+ }
175+}
176+
177+// ClearColorScheme marks the host color scheme as unknown. Future child color
178+// scheme queries are silent until SetColorScheme supplies a known value again.
179+func (s *Screen) ClearColorScheme() {
180+ s.colorSchemeSet = false
181+}
182+
159 // ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update).183 // ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update).
160 // Hosts use this as a safety valve if a child enters synchronized update mode184 // Hosts use this as a safety valve if a child enters synchronized update mode
161 // and never sends the matching end sequence.185 // and never sends the matching end sequence.
@@ -480,6 +504,16 @@ func (s *Screen) respond(b []byte) {
480 }504 }
481 }505 }
482 506
507+func (s *Screen) colorSchemeReport() []byte {
508+ if !s.colorSchemeSet {
509+ return nil
510+ }
511+ if s.colorSchemeLight {
512+ return []byte("\x1b[?997;2n")
513+ }
514+ return []byte("\x1b[?997;1n")
515+}
516+
483 func (s *Screen) scrollDownRegion(top, bottom, n int) {517 func (s *Screen) scrollDownRegion(top, bottom, n int) {
484 if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {518 if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {
485 return519 return
@@ -759,6 +793,12 @@ func (s *Screen) applyCSI(params string, cmd byte) {
759 if !private {793 if !private {
760 s.respond([]byte("\x1b[0n"))794 s.respond([]byte("\x1b[0n"))
761 }795 }
796+ case 996:
797+ if private {
798+ if report := s.colorSchemeReport(); report != nil {
799+ s.respond(report)
800+ }
801+ }
762 case 6:802 case 6:
763 resp := make([]byte, 0, 16)803 resp := make([]byte, 0, 16)
764 resp = append(resp, "\x1b["...)804 resp = append(resp, "\x1b["...)
@@ -778,11 +818,17 @@ func (s *Screen) applyCSI(params string, cmd byte) {
778 return818 return
779 }819 }
780 state := 0820 state := 0
781- if mode == 2026 {821+ switch mode {
822+ case 2026:
782 state = 2823 state = 2
783 if s.syncUpdateActive {824 if s.syncUpdateActive {
784 state = 1825 state = 1
785 }826 }
827+ case 2031:
828+ state = 2
829+ if s.colorSchemeMode {
830+ state = 1
831+ }
786 }832 }
787 resp := make([]byte, 0, 16)833 resp := make([]byte, 0, 16)
788 resp = append(resp, "\x1b[?"...)834 resp = append(resp, "\x1b[?"...)
@@ -1041,6 +1087,8 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) {
1041 }1087 }
1042 case 2026:1088 case 2026:
1043 s.syncUpdateActive = enabled1089 s.syncUpdateActive = enabled
1090+ case 2031:
1091+ s.colorSchemeMode = enabled
1044 case 25:1092 case 25:
1045 s.cursorVisible = enabled1093 s.cursorVisible = enabled
1046 case 1000, 1002, 1003:1094 case 1000, 1002, 1003:

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