detached 65a9160

Merge pull request #23 from bnema/feature/config-live-reload-theme

feat: config file with live reload and light/dark theme propagation
commit
65a9160b630dd40acf2a54450d9a4abf77b874e5
author
bnema · · 2026-07-05 19:53 UTC
committer
GitHub · · 2026-07-05 19:53 UTC
merge of
6812bac + 973d2af · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
40 files · +2395 -207
A internal/adapters/config/config.go +191 -0
@@ -0,0 +1,191 @@
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+ seenBindingKeys := make(map[string]bool)
26+
27+ scanner := bufio.NewScanner(r)
28+ lineNo := 0
29+ for scanner.Scan() {
30+ lineNo++
31+ line := strings.TrimSpace(strings.TrimSuffix(scanner.Text(), "\r"))
32+ if line == "" || strings.HasPrefix(line, "#") {
33+ continue
34+ }
35+ line = stripInlineComment(line)
36+ if line == "" {
37+ continue
38+ }
39+
40+ key, value, ok := strings.Cut(line, "=")
41+ if !ok {
42+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"})
43+ continue
44+ }
45+ key = strings.TrimSpace(key)
46+ value = strings.TrimSpace(value)
47+ if key == "" {
48+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing key"})
49+ continue
50+ }
51+
52+ switch {
53+ case key == "theme":
54+ mode, ok := parseTheme(value)
55+ if !ok {
56+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid theme %q", value)})
57+ continue
58+ }
59+ cfg.Theme = mode
60+ case strings.HasPrefix(key, "code."):
61+ codeKey := strings.TrimPrefix(key, "code.")
62+ if codeKey == "" {
63+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing code slug"})
64+ continue
65+ }
66+ if _, exists := cfg.Codes[codeKey]; exists {
67+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
68+ }
69+ cfg.Codes[codeKey] = value
70+ default:
71+ if seenBindingKeys[key] {
72+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
73+ updateBindingEntry(cfg.BindingEntries, key, value)
74+ } else {
75+ seenBindingKeys[key] = true
76+ cfg.BindingEntries = append(cfg.BindingEntries, domain.ConfigEntry{Key: key, Value: value})
77+ }
78+ }
79+ }
80+ if err := scanner.Err(); err != nil {
81+ return cfg, warnings, err
82+ }
83+ return cfg, warnings, nil
84+}
85+
86+// Load reads path and parses it. Missing config files are treated as defaults.
87+func Load(path string) (domain.Config, []domain.Warning, error) {
88+ f, err := os.Open(path)
89+ if errors.Is(err, os.ErrNotExist) {
90+ return domain.Defaults(), nil, nil
91+ }
92+ if err != nil {
93+ return domain.Defaults(), nil, err
94+ }
95+ cfg, warnings, parseErr := Parse(f)
96+ if closeErr := f.Close(); parseErr == nil && closeErr != nil {
97+ return cfg, warnings, closeErr
98+ }
99+ return cfg, warnings, parseErr
100+}
101+
102+// Watch polls path for mtime/size changes every two seconds and calls onChange
103+// with each successfully loaded config. Missing files are allowed and reload to
104+// defaults when the watched state changes.
105+func Watch(ctx context.Context, clock ports.Clock, path string, onChange func(domain.Config, []domain.Warning)) error {
106+ last, err := fileStamp(path)
107+ lastErrMsg := ""
108+ if err != nil {
109+ lastErrMsg = err.Error()
110+ onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}})
111+ }
112+ timer := clock.NewTimer(pollInterval)
113+ defer timer.Stop()
114+
115+ for {
116+ select {
117+ case <-ctx.Done():
118+ return ctx.Err()
119+ case <-timer.C():
120+ current, statErr := fileStamp(path)
121+ if statErr != nil {
122+ if statErr.Error() != lastErrMsg {
123+ lastErrMsg = statErr.Error()
124+ onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}})
125+ }
126+ } else if current != last || lastErrMsg != "" {
127+ lastErrMsg = ""
128+ last = current
129+ cfg, warnings, err := Load(path)
130+ if err != nil {
131+ cfg = domain.Defaults()
132+ warnings = append(warnings, domain.Warning{Msg: err.Error()})
133+ }
134+ onChange(cfg, warnings)
135+ }
136+ timer.Reset(pollInterval)
137+ }
138+ }
139+}
140+
141+type stamp struct {
142+ modTime time.Time
143+ size int64
144+ exists bool
145+}
146+
147+func fileStamp(path string) (stamp, error) {
148+ st, err := os.Stat(path)
149+ if errors.Is(err, os.ErrNotExist) {
150+ return stamp{}, nil
151+ }
152+ if err != nil {
153+ return stamp{}, err
154+ }
155+ return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil
156+}
157+
158+func updateBindingEntry(entries []domain.ConfigEntry, key, value string) {
159+ for i := range entries {
160+ if entries[i].Key == key {
161+ entries[i].Value = value
162+ return
163+ }
164+ }
165+}
166+
167+func parseTheme(value string) (domain.ThemeMode, bool) {
168+ switch strings.ToLower(strings.TrimSpace(value)) {
169+ case "auto", "":
170+ return domain.ThemeAuto, true
171+ case "dark":
172+ return domain.ThemeDark, true
173+ case "light":
174+ return domain.ThemeLight, true
175+ default:
176+ return domain.ThemeAuto, false
177+ }
178+}
179+
180+func stripInlineComment(line string) string {
181+ for i, r := range line {
182+ if r == '#' && (i == 0 || isSpace(rune(line[i-1]))) {
183+ return strings.TrimSpace(line[:i])
184+ }
185+ }
186+ return line
187+}
188+
189+func isSpace(r rune) bool {
190+ return r == ' ' || r == '\t' || r == '\r' || r == '\n'
191+}
@@ -0,0 +1,191 @@
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+ seenBindingKeys := make(map[string]bool)
26+
27+ scanner := bufio.NewScanner(r)
28+ lineNo := 0
29+ for scanner.Scan() {
30+ lineNo++
31+ line := strings.TrimSpace(strings.TrimSuffix(scanner.Text(), "\r"))
32+ if line == "" || strings.HasPrefix(line, "#") {
33+ continue
34+ }
35+ line = stripInlineComment(line)
36+ if line == "" {
37+ continue
38+ }
39+
40+ key, value, ok := strings.Cut(line, "=")
41+ if !ok {
42+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"})
43+ continue
44+ }
45+ key = strings.TrimSpace(key)
46+ value = strings.TrimSpace(value)
47+ if key == "" {
48+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing key"})
49+ continue
50+ }
51+
52+ switch {
53+ case key == "theme":
54+ mode, ok := parseTheme(value)
55+ if !ok {
56+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid theme %q", value)})
57+ continue
58+ }
59+ cfg.Theme = mode
60+ case strings.HasPrefix(key, "code."):
61+ codeKey := strings.TrimPrefix(key, "code.")
62+ if codeKey == "" {
63+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing code slug"})
64+ continue
65+ }
66+ if _, exists := cfg.Codes[codeKey]; exists {
67+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
68+ }
69+ cfg.Codes[codeKey] = value
70+ default:
71+ if seenBindingKeys[key] {
72+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
73+ updateBindingEntry(cfg.BindingEntries, key, value)
74+ } else {
75+ seenBindingKeys[key] = true
76+ cfg.BindingEntries = append(cfg.BindingEntries, domain.ConfigEntry{Key: key, Value: value})
77+ }
78+ }
79+ }
80+ if err := scanner.Err(); err != nil {
81+ return cfg, warnings, err
82+ }
83+ return cfg, warnings, nil
84+}
85+
86+// Load reads path and parses it. Missing config files are treated as defaults.
87+func Load(path string) (domain.Config, []domain.Warning, error) {
88+ f, err := os.Open(path)
89+ if errors.Is(err, os.ErrNotExist) {
90+ return domain.Defaults(), nil, nil
91+ }
92+ if err != nil {
93+ return domain.Defaults(), nil, err
94+ }
95+ cfg, warnings, parseErr := Parse(f)
96+ if closeErr := f.Close(); parseErr == nil && closeErr != nil {
97+ return cfg, warnings, closeErr
98+ }
99+ return cfg, warnings, parseErr
100+}
101+
102+// Watch polls path for mtime/size changes every two seconds and calls onChange
103+// with each successfully loaded config. Missing files are allowed and reload to
104+// defaults when the watched state changes.
105+func Watch(ctx context.Context, clock ports.Clock, path string, onChange func(domain.Config, []domain.Warning)) error {
106+ last, err := fileStamp(path)
107+ lastErrMsg := ""
108+ if err != nil {
109+ lastErrMsg = err.Error()
110+ onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}})
111+ }
112+ timer := clock.NewTimer(pollInterval)
113+ defer timer.Stop()
114+
115+ for {
116+ select {
117+ case <-ctx.Done():
118+ return ctx.Err()
119+ case <-timer.C():
120+ current, statErr := fileStamp(path)
121+ if statErr != nil {
122+ if statErr.Error() != lastErrMsg {
123+ lastErrMsg = statErr.Error()
124+ onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}})
125+ }
126+ } else if current != last || lastErrMsg != "" {
127+ lastErrMsg = ""
128+ last = current
129+ cfg, warnings, err := Load(path)
130+ if err != nil {
131+ cfg = domain.Defaults()
132+ warnings = append(warnings, domain.Warning{Msg: err.Error()})
133+ }
134+ onChange(cfg, warnings)
135+ }
136+ timer.Reset(pollInterval)
137+ }
138+ }
139+}
140+
141+type stamp struct {
142+ modTime time.Time
143+ size int64
144+ exists bool
145+}
146+
147+func fileStamp(path string) (stamp, error) {
148+ st, err := os.Stat(path)
149+ if errors.Is(err, os.ErrNotExist) {
150+ return stamp{}, nil
151+ }
152+ if err != nil {
153+ return stamp{}, err
154+ }
155+ return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil
156+}
157+
158+func updateBindingEntry(entries []domain.ConfigEntry, key, value string) {
159+ for i := range entries {
160+ if entries[i].Key == key {
161+ entries[i].Value = value
162+ return
163+ }
164+ }
165+}
166+
167+func parseTheme(value string) (domain.ThemeMode, bool) {
168+ switch strings.ToLower(strings.TrimSpace(value)) {
169+ case "auto", "":
170+ return domain.ThemeAuto, true
171+ case "dark":
172+ return domain.ThemeDark, true
173+ case "light":
174+ return domain.ThemeLight, true
175+ default:
176+ return domain.ThemeAuto, false
177+ }
178+}
179+
180+func stripInlineComment(line string) string {
181+ for i, r := range line {
182+ if r == '#' && (i == 0 || isSpace(rune(line[i-1]))) {
183+ return strings.TrimSpace(line[:i])
184+ }
185+ }
186+ return line
187+}
188+
189+func isSpace(r rune) bool {
190+ return r == ' ' || r == '\t' || r == '\r' || r == '\n'
191+}
A internal/adapters/config/config_test.go +364 -0
@@ -0,0 +1,364 @@
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+ BindingEntries: []domain.ConfigEntry{
34+ {Key: "new-tab", Value: "alt+t"},
35+ {Key: "detach", Value: "alt+#"},
36+ },
37+ Codes: map[string]string{},
38+ },
39+ },
40+ {
41+ name: "crlf and code prefix",
42+ input: "theme = light\r\ncode.detach = dt\r\n",
43+ want: domain.Config{
44+ Theme: domain.ThemeLight,
45+ BindingEntries: []domain.ConfigEntry{},
46+ Codes: map[string]string{"detach": "dt"},
47+ },
48+ },
49+ {
50+ name: "invalid theme warns and keeps default",
51+ input: "theme = blue\n",
52+ want: domain.Config{
53+ Theme: domain.ThemeAuto,
54+ BindingEntries: []domain.ConfigEntry{},
55+ Codes: map[string]string{},
56+ },
57+ wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid theme \"blue\""}},
58+ },
59+ {
60+ name: "unknown binding key stored",
61+ input: "unknown.action = alt+x\n",
62+ want: domain.Config{
63+ Theme: domain.ThemeAuto,
64+ BindingEntries: []domain.ConfigEntry{{Key: "unknown.action", Value: "alt+x"}},
65+ Codes: map[string]string{},
66+ },
67+ },
68+ {
69+ name: "duplicates warn and last wins",
70+ input: "new-tab = alt+t\nnew-tab = alt+n\ncode.detach = DT\ncode.detach = DX\n",
71+ want: domain.Config{
72+ Theme: domain.ThemeAuto,
73+ BindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+n"}},
74+ Codes: map[string]string{"detach": "DX"},
75+ },
76+ wantWarnings: []domain.Warning{
77+ {Line: 2, Msg: "duplicate key \"new-tab\""},
78+ {Line: 4, Msg: "duplicate key \"code.detach\""},
79+ },
80+ },
81+ {
82+ name: "invalid lines report line numbers",
83+ input: "\nno equals\n = value\ntheme = dark\n",
84+ want: domain.Config{
85+ Theme: domain.ThemeDark,
86+ BindingEntries: []domain.ConfigEntry{},
87+ Codes: map[string]string{},
88+ },
89+ wantWarnings: []domain.Warning{
90+ {Line: 2, Msg: "missing '='"},
91+ {Line: 3, Msg: "missing key"},
92+ },
93+ },
94+ }
95+
96+ for _, tt := range tests {
97+ t.Run(tt.name, func(t *testing.T) {
98+ t.Parallel()
99+
100+ got, warnings, err := Parse(strings.NewReader(tt.input))
101+ if err != nil {
102+ t.Fatalf("Parse() error = %v", err)
103+ }
104+ if !reflect.DeepEqual(got, tt.want) {
105+ t.Fatalf("Parse() config = %#v, want %#v", got, tt.want)
106+ }
107+ if !reflect.DeepEqual(warnings, tt.wantWarnings) {
108+ t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
109+ }
110+ })
111+ }
112+}
113+
114+func TestParsePreservesBindingFileOrder(t *testing.T) {
115+ t.Parallel()
116+
117+ cfg, warnings, err := Parse(strings.NewReader("open-palette = alt+p\nfocus-pane-left = alt+p\n"))
118+ if err != nil {
119+ t.Fatalf("Parse() error = %v", err)
120+ }
121+ if len(warnings) != 0 {
122+ t.Fatalf("Parse() warnings = %#v, want none", warnings)
123+ }
124+ want := []domain.ConfigEntry{
125+ {Key: "open-palette", Value: "alt+p"},
126+ {Key: "focus-pane-left", Value: "alt+p"},
127+ }
128+ if !reflect.DeepEqual(cfg.BindingEntries, want) {
129+ t.Fatalf("Parse() BindingEntries = %#v, want %#v", cfg.BindingEntries, want)
130+ }
131+}
132+
133+func TestLoadMissingFileReturnsDefaults(t *testing.T) {
134+ t.Parallel()
135+
136+ cfg, warnings, err := Load(filepath.Join(t.TempDir(), "missing"))
137+ if err != nil {
138+ t.Fatalf("Load() error = %v", err)
139+ }
140+ if len(warnings) != 0 {
141+ t.Fatalf("Load() warnings = %#v, want none", warnings)
142+ }
143+ if !reflect.DeepEqual(cfg, domain.Defaults()) {
144+ t.Fatalf("Load() config = %#v, want defaults", cfg)
145+ }
146+}
147+
148+func TestWatchReloadsChangedFileWithFakeClock(t *testing.T) {
149+ t.Parallel()
150+
151+ path := filepath.Join(t.TempDir(), "config")
152+ if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil {
153+ t.Fatal(err)
154+ }
155+
156+ clk := newFakeClock(time.Unix(0, 0))
157+ ctx, cancel := context.WithCancel(context.Background())
158+ defer cancel()
159+
160+ type change struct {
161+ cfg domain.Config
162+ warnings []domain.Warning
163+ }
164+ changes := make(chan change, 2)
165+ done := make(chan error, 1)
166+ go func() {
167+ done <- Watch(ctx, clk, path, func(cfg domain.Config, warnings []domain.Warning) {
168+ changes <- change{cfg: cfg, warnings: warnings}
169+ })
170+ }()
171+
172+ clk.waitForTimers(t, 1)
173+ if err := os.WriteFile(path, []byte("theme = light\nbad line\n"), 0o600); err != nil {
174+ t.Fatal(err)
175+ }
176+ clk.advance(2 * time.Second)
177+
178+ var got change
179+ select {
180+ case got = <-changes:
181+ case <-time.After(2 * time.Second):
182+ t.Fatal("timed out waiting for config reload")
183+ }
184+ if got.cfg.Theme != domain.ThemeLight {
185+ t.Fatalf("Watch() theme = %v, want %v", got.cfg.Theme, domain.ThemeLight)
186+ }
187+ if !reflect.DeepEqual(got.warnings, []domain.Warning{{Line: 2, Msg: "missing '='"}}) {
188+ t.Fatalf("Watch() warnings = %#v", got.warnings)
189+ }
190+
191+ cancel()
192+ if err := <-done; !errors.Is(err, context.Canceled) {
193+ t.Fatalf("Watch() error = %v, want context.Canceled", err)
194+ }
195+}
196+
197+func TestWatchUsesDefaultsWhenReloadLoadFails(t *testing.T) {
198+ t.Parallel()
199+
200+ path := filepath.Join(t.TempDir(), "config")
201+ if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil {
202+ t.Fatal(err)
203+ }
204+
205+ clk := newFakeClock(time.Unix(0, 0))
206+ ctx, cancel := context.WithCancel(context.Background())
207+ defer cancel()
208+
209+ type change struct {
210+ cfg domain.Config
211+ warnings []domain.Warning
212+ }
213+ changes := make(chan change, 2)
214+ done := make(chan error, 1)
215+ go func() {
216+ done <- Watch(ctx, clk, path, func(cfg domain.Config, warnings []domain.Warning) {
217+ changes <- change{cfg: cfg, warnings: warnings}
218+ })
219+ }()
220+
221+ clk.waitForTimers(t, 1)
222+ tooLong := strings.Repeat("x", 70*1024)
223+ if err := os.WriteFile(path, []byte(tooLong), 0o600); err != nil {
224+ t.Fatal(err)
225+ }
226+ clk.advance(2 * time.Second)
227+
228+ got := <-changes
229+ require.Equal(t, domain.Defaults(), got.cfg)
230+ require.NotEmpty(t, got.warnings)
231+
232+ cancel()
233+ if err := <-done; !errors.Is(err, context.Canceled) {
234+ t.Fatalf("Watch() error = %v, want context.Canceled", err)
235+ }
236+}
237+
238+func TestWatchDoesNotRepeatPersistentStatError(t *testing.T) {
239+ t.Parallel()
240+
241+ path := filepath.Join(t.TempDir(), strings.Repeat("x", 5000))
242+ clk := newFakeClock(time.Unix(0, 0))
243+ ctx, cancel := context.WithCancel(context.Background())
244+ defer cancel()
245+
246+ changes := make(chan []domain.Warning, 4)
247+ done := make(chan error, 1)
248+ go func() {
249+ done <- Watch(ctx, clk, path, func(_ domain.Config, warnings []domain.Warning) {
250+ changes <- warnings
251+ })
252+ }()
253+
254+ first := <-changes
255+ require.Len(t, first, 1)
256+ clk.waitForTimers(t, 1)
257+ clk.advance(2 * time.Second)
258+ clk.advance(2 * time.Second)
259+
260+ select {
261+ case extra := <-changes:
262+ t.Fatalf("Watch() repeated persistent stat error: %#v", extra)
263+ case <-time.After(100 * time.Millisecond):
264+ }
265+
266+ cancel()
267+ if err := <-done; !errors.Is(err, context.Canceled) {
268+ t.Fatalf("Watch() error = %v, want context.Canceled", err)
269+ }
270+}
271+
272+var _ ports.Clock = (*fakeClock)(nil)
273+var _ ports.Timer = (*fakeTimer)(nil)
274+
275+type fakeClock struct {
276+ mu sync.Mutex
277+ now time.Time
278+ timers []*fakeTimer
279+ notify chan struct{}
280+}
281+
282+func newFakeClock(now time.Time) *fakeClock {
283+ return &fakeClock{now: now, notify: make(chan struct{}, 16)}
284+}
285+
286+func (c *fakeClock) Now() time.Time {
287+ c.mu.Lock()
288+ defer c.mu.Unlock()
289+ return c.now
290+}
291+
292+func (c *fakeClock) NewTimer(d time.Duration) ports.Timer {
293+ c.mu.Lock()
294+ defer c.mu.Unlock()
295+
296+ t := &fakeTimer{clock: c, ch: make(chan time.Time, 1), next: c.now.Add(d), active: true}
297+ c.timers = append(c.timers, t)
298+ c.notify <- struct{}{}
299+ return t
300+}
301+
302+func (c *fakeClock) waitForTimers(t *testing.T, n int) {
303+ t.Helper()
304+ deadline := time.After(2 * time.Second)
305+ for c.timerCount() < n {
306+ select {
307+ case <-c.notify:
308+ case <-deadline:
309+ t.Fatalf("timed out waiting for %d timers, got %d", n, c.timerCount())
310+ }
311+ }
312+}
313+
314+func (c *fakeClock) timerCount() int {
315+ c.mu.Lock()
316+ defer c.mu.Unlock()
317+ return len(c.timers)
318+}
319+
320+func (c *fakeClock) advance(d time.Duration) {
321+ c.mu.Lock()
322+ defer c.mu.Unlock()
323+
324+ c.now = c.now.Add(d)
325+ for _, timer := range c.timers {
326+ timer.mu.Lock()
327+ if timer.active && !timer.next.After(c.now) {
328+ timer.ch <- c.now
329+ timer.active = false
330+ }
331+ timer.mu.Unlock()
332+ }
333+}
334+
335+type fakeTimer struct {
336+ clock *fakeClock
337+ mu sync.Mutex
338+ ch chan time.Time
339+ next time.Time
340+ active bool
341+}
342+
343+func (t *fakeTimer) C() <-chan time.Time { return t.ch }
344+
345+func (t *fakeTimer) Reset(d time.Duration) bool {
346+ t.clock.mu.Lock()
347+ defer t.clock.mu.Unlock()
348+ t.mu.Lock()
349+ defer t.mu.Unlock()
350+
351+ wasActive := t.active
352+ t.active = true
353+ t.next = t.clock.now.Add(d)
354+ return wasActive
355+}
356+
357+func (t *fakeTimer) Stop() bool {
358+ t.mu.Lock()
359+ defer t.mu.Unlock()
360+
361+ wasActive := t.active
362+ t.active = false
363+ return wasActive
364+}
@@ -0,0 +1,364 @@
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+ BindingEntries: []domain.ConfigEntry{
34+ {Key: "new-tab", Value: "alt+t"},
35+ {Key: "detach", Value: "alt+#"},
36+ },
37+ Codes: map[string]string{},
38+ },
39+ },
40+ {
41+ name: "crlf and code prefix",
42+ input: "theme = light\r\ncode.detach = dt\r\n",
43+ want: domain.Config{
44+ Theme: domain.ThemeLight,
45+ BindingEntries: []domain.ConfigEntry{},
46+ Codes: map[string]string{"detach": "dt"},
47+ },
48+ },
49+ {
50+ name: "invalid theme warns and keeps default",
51+ input: "theme = blue\n",
52+ want: domain.Config{
53+ Theme: domain.ThemeAuto,
54+ BindingEntries: []domain.ConfigEntry{},
55+ Codes: map[string]string{},
56+ },
57+ wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid theme \"blue\""}},
58+ },
59+ {
60+ name: "unknown binding key stored",
61+ input: "unknown.action = alt+x\n",
62+ want: domain.Config{
63+ Theme: domain.ThemeAuto,
64+ BindingEntries: []domain.ConfigEntry{{Key: "unknown.action", Value: "alt+x"}},
65+ Codes: map[string]string{},
66+ },
67+ },
68+ {
69+ name: "duplicates warn and last wins",
70+ input: "new-tab = alt+t\nnew-tab = alt+n\ncode.detach = DT\ncode.detach = DX\n",
71+ want: domain.Config{
72+ Theme: domain.ThemeAuto,
73+ BindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+n"}},
74+ Codes: map[string]string{"detach": "DX"},
75+ },
76+ wantWarnings: []domain.Warning{
77+ {Line: 2, Msg: "duplicate key \"new-tab\""},
78+ {Line: 4, Msg: "duplicate key \"code.detach\""},
79+ },
80+ },
81+ {
82+ name: "invalid lines report line numbers",
83+ input: "\nno equals\n = value\ntheme = dark\n",
84+ want: domain.Config{
85+ Theme: domain.ThemeDark,
86+ BindingEntries: []domain.ConfigEntry{},
87+ Codes: map[string]string{},
88+ },
89+ wantWarnings: []domain.Warning{
90+ {Line: 2, Msg: "missing '='"},
91+ {Line: 3, Msg: "missing key"},
92+ },
93+ },
94+ }
95+
96+ for _, tt := range tests {
97+ t.Run(tt.name, func(t *testing.T) {
98+ t.Parallel()
99+
100+ got, warnings, err := Parse(strings.NewReader(tt.input))
101+ if err != nil {
102+ t.Fatalf("Parse() error = %v", err)
103+ }
104+ if !reflect.DeepEqual(got, tt.want) {
105+ t.Fatalf("Parse() config = %#v, want %#v", got, tt.want)
106+ }
107+ if !reflect.DeepEqual(warnings, tt.wantWarnings) {
108+ t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
109+ }
110+ })
111+ }
112+}
113+
114+func TestParsePreservesBindingFileOrder(t *testing.T) {
115+ t.Parallel()
116+
117+ cfg, warnings, err := Parse(strings.NewReader("open-palette = alt+p\nfocus-pane-left = alt+p\n"))
118+ if err != nil {
119+ t.Fatalf("Parse() error = %v", err)
120+ }
121+ if len(warnings) != 0 {
122+ t.Fatalf("Parse() warnings = %#v, want none", warnings)
123+ }
124+ want := []domain.ConfigEntry{
125+ {Key: "open-palette", Value: "alt+p"},
126+ {Key: "focus-pane-left", Value: "alt+p"},
127+ }
128+ if !reflect.DeepEqual(cfg.BindingEntries, want) {
129+ t.Fatalf("Parse() BindingEntries = %#v, want %#v", cfg.BindingEntries, want)
130+ }
131+}
132+
133+func TestLoadMissingFileReturnsDefaults(t *testing.T) {
134+ t.Parallel()
135+
136+ cfg, warnings, err := Load(filepath.Join(t.TempDir(), "missing"))
137+ if err != nil {
138+ t.Fatalf("Load() error = %v", err)
139+ }
140+ if len(warnings) != 0 {
141+ t.Fatalf("Load() warnings = %#v, want none", warnings)
142+ }
143+ if !reflect.DeepEqual(cfg, domain.Defaults()) {
144+ t.Fatalf("Load() config = %#v, want defaults", cfg)
145+ }
146+}
147+
148+func TestWatchReloadsChangedFileWithFakeClock(t *testing.T) {
149+ t.Parallel()
150+
151+ path := filepath.Join(t.TempDir(), "config")
152+ if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil {
153+ t.Fatal(err)
154+ }
155+
156+ clk := newFakeClock(time.Unix(0, 0))
157+ ctx, cancel := context.WithCancel(context.Background())
158+ defer cancel()
159+
160+ type change struct {
161+ cfg domain.Config
162+ warnings []domain.Warning
163+ }
164+ changes := make(chan change, 2)
165+ done := make(chan error, 1)
166+ go func() {
167+ done <- Watch(ctx, clk, path, func(cfg domain.Config, warnings []domain.Warning) {
168+ changes <- change{cfg: cfg, warnings: warnings}
169+ })
170+ }()
171+
172+ clk.waitForTimers(t, 1)
173+ if err := os.WriteFile(path, []byte("theme = light\nbad line\n"), 0o600); err != nil {
174+ t.Fatal(err)
175+ }
176+ clk.advance(2 * time.Second)
177+
178+ var got change
179+ select {
180+ case got = <-changes:
181+ case <-time.After(2 * time.Second):
182+ t.Fatal("timed out waiting for config reload")
183+ }
184+ if got.cfg.Theme != domain.ThemeLight {
185+ t.Fatalf("Watch() theme = %v, want %v", got.cfg.Theme, domain.ThemeLight)
186+ }
187+ if !reflect.DeepEqual(got.warnings, []domain.Warning{{Line: 2, Msg: "missing '='"}}) {
188+ t.Fatalf("Watch() warnings = %#v", got.warnings)
189+ }
190+
191+ cancel()
192+ if err := <-done; !errors.Is(err, context.Canceled) {
193+ t.Fatalf("Watch() error = %v, want context.Canceled", err)
194+ }
195+}
196+
197+func TestWatchUsesDefaultsWhenReloadLoadFails(t *testing.T) {
198+ t.Parallel()
199+
200+ path := filepath.Join(t.TempDir(), "config")
201+ if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil {
202+ t.Fatal(err)
203+ }
204+
205+ clk := newFakeClock(time.Unix(0, 0))
206+ ctx, cancel := context.WithCancel(context.Background())
207+ defer cancel()
208+
209+ type change struct {
210+ cfg domain.Config
211+ warnings []domain.Warning
212+ }
213+ changes := make(chan change, 2)
214+ done := make(chan error, 1)
215+ go func() {
216+ done <- Watch(ctx, clk, path, func(cfg domain.Config, warnings []domain.Warning) {
217+ changes <- change{cfg: cfg, warnings: warnings}
218+ })
219+ }()
220+
221+ clk.waitForTimers(t, 1)
222+ tooLong := strings.Repeat("x", 70*1024)
223+ if err := os.WriteFile(path, []byte(tooLong), 0o600); err != nil {
224+ t.Fatal(err)
225+ }
226+ clk.advance(2 * time.Second)
227+
228+ got := <-changes
229+ require.Equal(t, domain.Defaults(), got.cfg)
230+ require.NotEmpty(t, got.warnings)
231+
232+ cancel()
233+ if err := <-done; !errors.Is(err, context.Canceled) {
234+ t.Fatalf("Watch() error = %v, want context.Canceled", err)
235+ }
236+}
237+
238+func TestWatchDoesNotRepeatPersistentStatError(t *testing.T) {
239+ t.Parallel()
240+
241+ path := filepath.Join(t.TempDir(), strings.Repeat("x", 5000))
242+ clk := newFakeClock(time.Unix(0, 0))
243+ ctx, cancel := context.WithCancel(context.Background())
244+ defer cancel()
245+
246+ changes := make(chan []domain.Warning, 4)
247+ done := make(chan error, 1)
248+ go func() {
249+ done <- Watch(ctx, clk, path, func(_ domain.Config, warnings []domain.Warning) {
250+ changes <- warnings
251+ })
252+ }()
253+
254+ first := <-changes
255+ require.Len(t, first, 1)
256+ clk.waitForTimers(t, 1)
257+ clk.advance(2 * time.Second)
258+ clk.advance(2 * time.Second)
259+
260+ select {
261+ case extra := <-changes:
262+ t.Fatalf("Watch() repeated persistent stat error: %#v", extra)
263+ case <-time.After(100 * time.Millisecond):
264+ }
265+
266+ cancel()
267+ if err := <-done; !errors.Is(err, context.Canceled) {
268+ t.Fatalf("Watch() error = %v, want context.Canceled", err)
269+ }
270+}
271+
272+var _ ports.Clock = (*fakeClock)(nil)
273+var _ ports.Timer = (*fakeTimer)(nil)
274+
275+type fakeClock struct {
276+ mu sync.Mutex
277+ now time.Time
278+ timers []*fakeTimer
279+ notify chan struct{}
280+}
281+
282+func newFakeClock(now time.Time) *fakeClock {
283+ return &fakeClock{now: now, notify: make(chan struct{}, 16)}
284+}
285+
286+func (c *fakeClock) Now() time.Time {
287+ c.mu.Lock()
288+ defer c.mu.Unlock()
289+ return c.now
290+}
291+
292+func (c *fakeClock) NewTimer(d time.Duration) ports.Timer {
293+ c.mu.Lock()
294+ defer c.mu.Unlock()
295+
296+ t := &fakeTimer{clock: c, ch: make(chan time.Time, 1), next: c.now.Add(d), active: true}
297+ c.timers = append(c.timers, t)
298+ c.notify <- struct{}{}
299+ return t
300+}
301+
302+func (c *fakeClock) waitForTimers(t *testing.T, n int) {
303+ t.Helper()
304+ deadline := time.After(2 * time.Second)
305+ for c.timerCount() < n {
306+ select {
307+ case <-c.notify:
308+ case <-deadline:
309+ t.Fatalf("timed out waiting for %d timers, got %d", n, c.timerCount())
310+ }
311+ }
312+}
313+
314+func (c *fakeClock) timerCount() int {
315+ c.mu.Lock()
316+ defer c.mu.Unlock()
317+ return len(c.timers)
318+}
319+
320+func (c *fakeClock) advance(d time.Duration) {
321+ c.mu.Lock()
322+ defer c.mu.Unlock()
323+
324+ c.now = c.now.Add(d)
325+ for _, timer := range c.timers {
326+ timer.mu.Lock()
327+ if timer.active && !timer.next.After(c.now) {
328+ timer.ch <- c.now
329+ timer.active = false
330+ }
331+ timer.mu.Unlock()
332+ }
333+}
334+
335+type fakeTimer struct {
336+ clock *fakeClock
337+ mu sync.Mutex
338+ ch chan time.Time
339+ next time.Time
340+ active bool
341+}
342+
343+func (t *fakeTimer) C() <-chan time.Time { return t.ch }
344+
345+func (t *fakeTimer) Reset(d time.Duration) bool {
346+ t.clock.mu.Lock()
347+ defer t.clock.mu.Unlock()
348+ t.mu.Lock()
349+ defer t.mu.Unlock()
350+
351+ wasActive := t.active
352+ t.active = true
353+ t.next = t.clock.now.Add(d)
354+ return wasActive
355+}
356+
357+func (t *fakeTimer) Stop() bool {
358+ t.mu.Lock()
359+ defer t.mu.Unlock()
360+
361+ wasActive := t.active
362+ t.active = false
363+ return wasActive
364+}
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 +44 -0
@@ -0,0 +1,44 @@
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 (in BindingEntries, in file order) so the usecase
23+// layer can decide which actions it understands.
24+type Config struct {
25+ Theme ThemeMode
26+ BindingEntries []ConfigEntry
27+ Codes map[string]string
28+}
29+
30+// Warning describes a non-fatal config problem. Parsers and reloaders should
31+// warn and continue with defaults rather than aborting startup.
32+type Warning struct {
33+ Line int
34+ Msg string
35+}
36+
37+// Defaults returns vev's default configuration.
38+func Defaults() Config {
39+ return Config{
40+ Theme: ThemeAuto,
41+ BindingEntries: []ConfigEntry{},
42+ Codes: map[string]string{},
43+ }
44+}
@@ -0,0 +1,44 @@
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 (in BindingEntries, in file order) so the usecase
23+// layer can decide which actions it understands.
24+type Config struct {
25+ Theme ThemeMode
26+ BindingEntries []ConfigEntry
27+ Codes map[string]string
28+}
29+
30+// Warning describes a non-fatal config problem. Parsers and reloaders should
31+// warn and continue with defaults rather than aborting startup.
32+type Warning struct {
33+ Line int
34+ Msg string
35+}
36+
37+// Defaults returns vev's default configuration.
38+func Defaults() Config {
39+ return Config{
40+ Theme: ThemeAuto,
41+ BindingEntries: []ConfigEntry{},
42+ Codes: map[string]string{},
43+ }
44+}
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 +30 -3
@@ -343,12 +343,25 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
343343 sendCh := make(chan ports.Frame, sendQueueDepth)
344344 sendErrCh := make(chan error, 1)
345345
346+ // colorQueryCh hands an OSC 10/11 re-query request from the stdin pump to
347+ // the main loop, which is the only goroutine allowed to touch term.Out()/
348+ // term.Flush(); QueryColors writes through the same batched writer, so
349+ // calling it directly from the stdin pump would race the main loop's
350+ // output writes.
351+ colorQueryCh := make(chan struct{}, 1)
352+ requestColors := func() {
353+ select {
354+ case colorQueryCh <- struct{}{}:
355+ default:
356+ }
357+ }
358+
346359 var clip ports.ClipboardReader
347360 if remote {
348361 clip = clipboard
349362 }
350363 go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log)
351- go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, clip, log)
364+ go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, requestColors, clip, log)
352365 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)
353366
354367 // 5. Output/main loop: the only goroutine that touches the terminal.
@@ -366,6 +379,10 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
366379 default:
367380 return attachResult{resumeToken: resumeToken, welcomed: true}
368381 }
382+ case <-colorQueryCh:
383+ if err := term.QueryColors(); err != nil {
384+ log.Warn("querying terminal colors", "err", err)
385+ }
369386 case r := <-recvCh:
370387 if r.err != nil {
371388 if errors.Is(r.err, io.EOF) {
@@ -472,7 +489,7 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
472489 // exits. That is harmless here — Attach has already returned and restored
473490 // the terminal — and matches the standard pattern for stdin pumps; a
474491 // 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) {
492+func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, requestColors func(), clipboard ports.ClipboardReader, log *slog.Logger) {
476493 defer log.Debug("stdin pump exited")
477494 buf := make([]byte, stdinBufSize)
478495 var scanner theme.Scanner
@@ -522,6 +539,9 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
522539 for {
523540 n, rerr := in.Read(buf)
524541 if n > 0 {
542+ sendTheme := func() {
543+ send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})
544+ }
525545 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {
526546 switch kind {
527547 case 10:
@@ -533,7 +553,14 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
533553 default:
534554 return
535555 }
536- send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})
556+ sendTheme()
557+ }, func(light bool) {
558+ current.SchemeKnown = true
559+ current.Light = light
560+ if requestColors != nil {
561+ requestColors()
562+ }
563+ sendTheme()
537564 }, sink)
538565 if !sendOK.Load() {
539566 return
@@ -343,12 +343,25 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
343 sendCh := make(chan ports.Frame, sendQueueDepth)343 sendCh := make(chan ports.Frame, sendQueueDepth)
344 sendErrCh := make(chan error, 1)344 sendErrCh := make(chan error, 1)
345 345
346+ // colorQueryCh hands an OSC 10/11 re-query request from the stdin pump to
347+ // the main loop, which is the only goroutine allowed to touch term.Out()/
348+ // term.Flush(); QueryColors writes through the same batched writer, so
349+ // calling it directly from the stdin pump would race the main loop's
350+ // output writes.
351+ colorQueryCh := make(chan struct{}, 1)
352+ requestColors := func() {
353+ select {
354+ case colorQueryCh <- struct{}{}:
355+ default:
356+ }
357+ }
358+
346 var clip ports.ClipboardReader359 var clip ports.ClipboardReader
347 if remote {360 if remote {
348 clip = clipboard361 clip = clipboard
349 }362 }
350 go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log)363 go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log)
351- go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, clip, log)364+ go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, requestColors, clip, log)
352 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)365 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)
353 366
354 // 5. Output/main loop: the only goroutine that touches the terminal.367 // 5. Output/main loop: the only goroutine that touches the terminal.
@@ -366,6 +379,10 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
366 default:379 default:
367 return attachResult{resumeToken: resumeToken, welcomed: true}380 return attachResult{resumeToken: resumeToken, welcomed: true}
368 }381 }
382+ case <-colorQueryCh:
383+ if err := term.QueryColors(); err != nil {
384+ log.Warn("querying terminal colors", "err", err)
385+ }
369 case r := <-recvCh:386 case r := <-recvCh:
370 if r.err != nil {387 if r.err != nil {
371 if errors.Is(r.err, io.EOF) {388 if errors.Is(r.err, io.EOF) {
@@ -472,7 +489,7 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
472 // exits. That is harmless here — Attach has already returned and restored489 // exits. That is harmless here — Attach has already returned and restored
473 // the terminal — and matches the standard pattern for stdin pumps; a490 // the terminal — and matches the standard pattern for stdin pumps; a
474 // closable stdin duplicate could lift it later if ever needed.491 // 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) {492+func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, requestColors func(), clipboard ports.ClipboardReader, log *slog.Logger) {
476 defer log.Debug("stdin pump exited")493 defer log.Debug("stdin pump exited")
477 buf := make([]byte, stdinBufSize)494 buf := make([]byte, stdinBufSize)
478 var scanner theme.Scanner495 var scanner theme.Scanner
@@ -522,6 +539,9 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
522 for {539 for {
523 n, rerr := in.Read(buf)540 n, rerr := in.Read(buf)
524 if n > 0 {541 if n > 0 {
542+ sendTheme := func() {
543+ send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})
544+ }
525 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {545 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {
526 switch kind {546 switch kind {
527 case 10:547 case 10:
@@ -533,7 +553,14 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
533 default:553 default:
534 return554 return
535 }555 }
536- send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})556+ sendTheme()
557+ }, func(light bool) {
558+ current.SchemeKnown = true
559+ current.Light = light
560+ if requestColors != nil {
561+ requestColors()
562+ }
563+ sendTheme()
537 }, sink)564 }, sink)
538 if !sendOK.Load() {565 if !sendOK.Load() {
539 return566 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
@@ -28,6 +28,6 @@ type Context interface {
2828
2929 // Command describes an executable command.
3030 type Command struct {
31- Code, Name, Desc string
32- Run func(Context) error
31+ Slug, Code, Name, Desc string
32+ Run func(Context) error
3333 }
@@ -28,6 +28,6 @@ type Context interface {
28 28
29 // Command describes an executable command.29 // Command describes an executable command.
30 type Command struct {30 type Command struct {
31- Code, Name, Desc string31+ Slug, Code, Name, Desc string
32- Run func(Context) error32+ Run func(Context) error
33 }33 }
M internal/usecase/command/registry.go +22 -22
@@ -5,28 +5,28 @@ 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: "BSK", Name: "Back session", Desc: "Switch back through recent sessions", Run: func(ctx Context) error { return ctx.BackSession() }},
25- {Code: "FSK", Name: "Forward session", Desc: "Switch forward through recent sessions", Run: func(ctx Context) error { return ctx.ForwardSession() }},
26- {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
27- {Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},
28- {Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},
29- {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: "back-session", Code: "BSK", Name: "Back session", Desc: "Switch back through recent sessions", Run: func(ctx Context) error { return ctx.BackSession() }},
25+ {Slug: "forward-session", Code: "FSK", Name: "Forward session", Desc: "Switch forward through recent sessions", Run: func(ctx Context) error { return ctx.ForwardSession() }},
26+ {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
27+ {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},
28+ {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() }},
29+ {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},
3030 }
3131
3232 return commands
@@ -5,28 +5,28 @@ 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: "BSK", Name: "Back session", Desc: "Switch back through recent sessions", Run: func(ctx Context) error { return ctx.BackSession() }},24+ {Slug: "back-session", Code: "BSK", Name: "Back session", Desc: "Switch back through recent sessions", Run: func(ctx Context) error { return ctx.BackSession() }},
25- {Code: "FSK", Name: "Forward session", Desc: "Switch forward through recent sessions", Run: func(ctx Context) error { return ctx.ForwardSession() }},25+ {Slug: "forward-session", Code: "FSK", Name: "Forward session", Desc: "Switch forward through recent sessions", Run: func(ctx Context) error { return ctx.ForwardSession() }},
26- {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},26+ {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
27- {Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},27+ {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},
28- {Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},28+ {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() }},
29- {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},29+ {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},
30 }30 }
31 31
32 return commands32 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", "BSK", "FSK", "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", "BSK", "FSK", "SSP", "VIS", "RNS", "DET"}10 wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "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/attention_test.go +2 -2
@@ -107,7 +107,7 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) {
107107 tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW)
108108 sessW := &session{id: "wedged", name: "wedged", ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}, client: acW}
109109 acW.setSession(sessW)
110- acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW})
110+ acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW}, nil)
111111 d.sessions[sessW.id] = sessW
112112
113113 done := make(chan struct{})
@@ -355,7 +355,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
355355 tbB := newTestTabWithContext(pB, sctxB, cancelB)
356356 sessB := &session{id: "sessB", name: "other", ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}, client: acB}
357357 acB.setSession(sessB)
358- acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB})
358+ acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil)
359359 d.sessions[sessB.id] = sessB
360360
361361 d.paint(sessB, acB, true)
@@ -107,7 +107,7 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) {
107 tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW)107 tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW)
108 sessW := &session{id: "wedged", name: "wedged", ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}, client: acW}108 sessW := &session{id: "wedged", name: "wedged", ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}, client: acW}
109 acW.setSession(sessW)109 acW.setSession(sessW)
110- acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW})110+ acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW}, nil)
111 d.sessions[sessW.id] = sessW111 d.sessions[sessW.id] = sessW
112 112
113 done := make(chan struct{})113 done := make(chan struct{})
@@ -355,7 +355,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
355 tbB := newTestTabWithContext(pB, sctxB, cancelB)355 tbB := newTestTabWithContext(pB, sctxB, cancelB)
356 sessB := &session{id: "sessB", name: "other", ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}, client: acB}356 sessB := &session{id: "sessB", name: "other", ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}, client: acB}
357 acB.setSession(sessB)357 acB.setSession(sessB)
358- acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB})358+ acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil)
359 d.sessions[sessB.id] = sessB359 d.sessions[sessB.id] = sessB
360 360
361 d.paint(sessB, acB, true)361 d.paint(sessB, acB, true)
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 recentNav recentSessionNavigator
6061 linkMu sync.Mutex
@@ -115,12 +116,24 @@ func (ac *attachedClient) getTheme() themeui.Theme {
115116 return ac.theme
116117 }
117118
119+func (ac *attachedClient) getClientTheme() themeui.Theme {
120+ ac.themeMu.Lock()
121+ defer ac.themeMu.Unlock()
122+ return ac.clientTheme
123+}
124+
118125 func (ac *attachedClient) setTheme(t themeui.Theme) {
119126 ac.themeMu.Lock()
120127 ac.theme = t
121128 ac.themeMu.Unlock()
122129 }
123130
131+func (ac *attachedClient) setClientTheme(t themeui.Theme) {
132+ ac.themeMu.Lock()
133+ ac.clientTheme = t
134+ ac.themeMu.Unlock()
135+}
136+
124137 func (ac *attachedClient) transport() ports.Transport {
125138 ac.linkMu.Lock()
126139 defer ac.linkMu.Unlock()
@@ -321,25 +334,15 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
321334 }
322335 ac.initOverlays()
323336 ac.setSession(sess)
324- ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})
337+ ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
325338 sess.mu.Lock()
326339 old := sess.client
327340 sess.client = ac
328341 name := sess.name
329- tabs := append([]*tab(nil), sess.tabs...)
330342 sess.mu.Unlock()
331343 d.touchMRU(sess)
332344 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
333- for _, tb := range tabs {
334- tb.mu.Lock()
335- panes := tb.panesSnapshot()
336- tb.mu.Unlock()
337- for _, p := range panes {
338- p.mu.Lock()
339- p.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false)
340- p.mu.Unlock()
341- }
342- }
345+ d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)
343346 return ac, old
344347 }
345348
@@ -355,23 +358,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
355358 // (and run its own attach-time reset), so this call must leave the tabs
356359 // alone rather than clobbering that client's freshly applied colors.
357360 func (d *Daemon) resetScreenDefaultColors(sess *session) {
358- sess.mu.Lock()
359- if sess.client != nil {
360- sess.mu.Unlock()
361- return
362- }
363- tabs := append([]*tab(nil), sess.tabs...)
364- sess.mu.Unlock()
365- for _, tb := range tabs {
366- tb.mu.Lock()
367- panes := tb.panesSnapshot()
368- tb.mu.Unlock()
369- for _, p := range panes {
370- p.mu.Lock()
371- p.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false)
372- p.mu.Unlock()
373- }
374- }
361+ d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{}), true)
375362 }
376363
377364 func (d *Daemon) detachReplacedClient(old *attachedClient) {
@@ -509,35 +496,22 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
509496 // detachIfCurrent clears the client iff ac is the current one, reporting
510497
511498 func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) {
512- t := themeui.Theme{
513- Foreground: msg.Foreground,
514- Background: msg.Background,
515- HasFG: msg.HasForeground,
516- HasBG: msg.HasBackground,
517- TrueColor: msg.TrueColor,
518- Known: msg.HasForeground && msg.HasBackground,
519- }
520-
521- knownDefaultColors := t.HasFG && t.HasBG
522- sess.mu.Lock()
523- if sess.client != ac {
524- sess.mu.Unlock()
499+ clientTheme := themeui.Theme{
500+ Foreground: msg.Foreground,
501+ Background: msg.Background,
502+ HasFG: msg.HasForeground,
503+ HasBG: msg.HasBackground,
504+ TrueColor: msg.TrueColor,
505+ Known: msg.HasForeground && msg.HasBackground,
506+ SchemeKnown: msg.SchemeKnown,
507+ Light: msg.Light,
508+ }
509+ ac.setClientTheme(clientTheme)
510+ t := d.effectiveTheme(clientTheme)
511+
512+ if !d.applyHostTheme(sess, ac, t, false) {
525513 return
526514 }
527- tabs := append([]*tab(nil), sess.tabs...)
528- sess.mu.Unlock()
529-
530- ac.setTheme(t)
531- for _, tb := range tabs {
532- tb.mu.Lock()
533- panes := tb.panesSnapshot()
534- tb.mu.Unlock()
535- for _, p := range panes {
536- p.mu.Lock()
537- p.screen.SetDefaultColors(t.Foreground, t.Background, knownDefaultColors)
538- p.mu.Unlock()
539- }
540- }
541515 d.paint(sess, ac, true)
542516 }
543517
@@ -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 recentNav recentSessionNavigator60 recentNav recentSessionNavigator
60 linkMu sync.Mutex61 linkMu sync.Mutex
@@ -115,12 +116,24 @@ func (ac *attachedClient) getTheme() themeui.Theme {
115 return ac.theme116 return ac.theme
116 }117 }
117 118
119+func (ac *attachedClient) getClientTheme() themeui.Theme {
120+ ac.themeMu.Lock()
121+ defer ac.themeMu.Unlock()
122+ return ac.clientTheme
123+}
124+
118 func (ac *attachedClient) setTheme(t themeui.Theme) {125 func (ac *attachedClient) setTheme(t themeui.Theme) {
119 ac.themeMu.Lock()126 ac.themeMu.Lock()
120 ac.theme = t127 ac.theme = t
121 ac.themeMu.Unlock()128 ac.themeMu.Unlock()
122 }129 }
123 130
131+func (ac *attachedClient) setClientTheme(t themeui.Theme) {
132+ ac.themeMu.Lock()
133+ ac.clientTheme = t
134+ ac.themeMu.Unlock()
135+}
136+
124 func (ac *attachedClient) transport() ports.Transport {137 func (ac *attachedClient) transport() ports.Transport {
125 ac.linkMu.Lock()138 ac.linkMu.Lock()
126 defer ac.linkMu.Unlock()139 defer ac.linkMu.Unlock()
@@ -321,25 +334,15 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
321 }334 }
322 ac.initOverlays()335 ac.initOverlays()
323 ac.setSession(sess)336 ac.setSession(sess)
324- ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})337+ ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
325 sess.mu.Lock()338 sess.mu.Lock()
326 old := sess.client339 old := sess.client
327 sess.client = ac340 sess.client = ac
328 name := sess.name341 name := sess.name
329- tabs := append([]*tab(nil), sess.tabs...)
330 sess.mu.Unlock()342 sess.mu.Unlock()
331 d.touchMRU(sess)343 d.touchMRU(sess)
332 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)344 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
333- for _, tb := range tabs {345+ d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)
334- tb.mu.Lock()
335- panes := tb.panesSnapshot()
336- tb.mu.Unlock()
337- for _, p := range panes {
338- p.mu.Lock()
339- p.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false)
340- p.mu.Unlock()
341- }
342- }
343 return ac, old346 return ac, old
344 }347 }
345 348
@@ -355,23 +358,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
355 // (and run its own attach-time reset), so this call must leave the tabs358 // (and run its own attach-time reset), so this call must leave the tabs
356 // alone rather than clobbering that client's freshly applied colors.359 // alone rather than clobbering that client's freshly applied colors.
357 func (d *Daemon) resetScreenDefaultColors(sess *session) {360 func (d *Daemon) resetScreenDefaultColors(sess *session) {
358- sess.mu.Lock()361+ d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{}), true)
359- if sess.client != nil {
360- sess.mu.Unlock()
361- return
362- }
363- tabs := append([]*tab(nil), sess.tabs...)
364- sess.mu.Unlock()
365- for _, tb := range tabs {
366- tb.mu.Lock()
367- panes := tb.panesSnapshot()
368- tb.mu.Unlock()
369- for _, p := range panes {
370- p.mu.Lock()
371- p.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false)
372- p.mu.Unlock()
373- }
374- }
375 }362 }
376 363
377 func (d *Daemon) detachReplacedClient(old *attachedClient) {364 func (d *Daemon) detachReplacedClient(old *attachedClient) {
@@ -509,35 +496,22 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
509 // detachIfCurrent clears the client iff ac is the current one, reporting496 // detachIfCurrent clears the client iff ac is the current one, reporting
510 497
511 func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) {498 func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) {
512- t := themeui.Theme{499+ clientTheme := themeui.Theme{
513- Foreground: msg.Foreground,500+ Foreground: msg.Foreground,
514- Background: msg.Background,501+ Background: msg.Background,
515- HasFG: msg.HasForeground,502+ HasFG: msg.HasForeground,
516- HasBG: msg.HasBackground,503+ HasBG: msg.HasBackground,
517- TrueColor: msg.TrueColor,504+ TrueColor: msg.TrueColor,
518- Known: msg.HasForeground && msg.HasBackground,505+ Known: msg.HasForeground && msg.HasBackground,
519- }506+ SchemeKnown: msg.SchemeKnown,
520-507+ Light: msg.Light,
521- knownDefaultColors := t.HasFG && t.HasBG508+ }
522- sess.mu.Lock()509+ ac.setClientTheme(clientTheme)
523- if sess.client != ac {510+ t := d.effectiveTheme(clientTheme)
524- sess.mu.Unlock()511+
512+ if !d.applyHostTheme(sess, ac, t, false) {
525 return513 return
526 }514 }
527- tabs := append([]*tab(nil), sess.tabs...)
528- sess.mu.Unlock()
529-
530- ac.setTheme(t)
531- for _, tb := range tabs {
532- tb.mu.Lock()
533- panes := tb.panesSnapshot()
534- tb.mu.Unlock()
535- for _, p := range panes {
536- p.mu.Lock()
537- p.screen.SetDefaultColors(t.Foreground, t.Background, knownDefaultColors)
538- p.mu.Unlock()
539- }
540- }
541 d.paint(sess, ac, true)515 d.paint(sess, ac, true)
542 }516 }
543 517
A internal/usecase/daemon/config.go +189 -0
@@ -0,0 +1,189 @@
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+ for _, warning := range warnings {
21+ d.logConfigWarning(warning)
22+ }
23+ overrides := d.buildCodeOverrides(cfg.Codes)
24+ d.bindings.Store(bindings)
25+ d.codeOverrides.Store(&overrides)
26+ d.themeMode.Store(uint32(cfg.Theme))
27+ d.reapplyThemeAllSessions()
28+ d.repaintAllAttachedClients()
29+}
30+
31+func (d *Daemon) buildCodeOverrides(configured map[string]string) map[string]string {
32+ commands := command.Registry()
33+ bySlug := make(map[string]command.Command, len(commands))
34+ for _, cmd := range commands {
35+ bySlug[cmd.Slug] = cmd
36+ }
37+
38+ desired := make(map[string]string)
39+ for _, slug := range sortedKeys(configured) {
40+ if _, ok := bySlug[slug]; !ok {
41+ d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("unknown command code slug %q", slug)})
42+ continue
43+ }
44+ code := strings.ToUpper(strings.TrimSpace(configured[slug]))
45+ if !commandCodePattern.MatchString(code) {
46+ d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("invalid command code for %q", slug)})
47+ continue
48+ }
49+ desired[slug] = code
50+ }
51+
52+ // The effective code for a slug is its accepted override if it has one,
53+ // or its registry default otherwise. Resolve to a fixpoint rather than a
54+ // single sorted pass: dropping one slug's override to resolve a conflict
55+ // can free up its default code, which may in turn conflict with a
56+ // *different* slug's override — that must be re-checked regardless of
57+ // where the two slugs fall in sort order.
58+ accepted := make(map[string]string, len(desired))
59+ for slug, code := range desired {
60+ accepted[slug] = code
61+ }
62+ effectiveCode := func(slug string) string {
63+ if code, ok := accepted[slug]; ok {
64+ return code
65+ }
66+ return bySlug[slug].Code
67+ }
68+
69+ for {
70+ claimants := make(map[string][]string, len(commands))
71+ for _, cmd := range commands {
72+ code := effectiveCode(cmd.Slug)
73+ claimants[code] = append(claimants[code], cmd.Slug)
74+ }
75+
76+ dropped := false
77+ for code, slugs := range claimants {
78+ if len(slugs) < 2 {
79+ continue
80+ }
81+ sort.Strings(slugs)
82+
83+ // If one claimant holds this code by default (no accepted
84+ // override), every override claimant loses to it and is
85+ // dropped. Otherwise every claimant is an override; keep the
86+ // lexicographically smallest slug and drop the rest.
87+ keep := ""
88+ for _, slug := range slugs {
89+ if _, isOverride := accepted[slug]; !isOverride {
90+ keep = slug
91+ break
92+ }
93+ }
94+ if keep == "" {
95+ keep = slugs[0]
96+ }
97+ for _, slug := range slugs {
98+ if slug == keep {
99+ continue
100+ }
101+ if _, isOverride := accepted[slug]; !isOverride {
102+ continue
103+ }
104+ d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("command code %q for %q conflicts with %q", code, slug, keep)})
105+ delete(accepted, slug)
106+ dropped = true
107+ }
108+ }
109+ if !dropped {
110+ break
111+ }
112+ }
113+ return accepted
114+}
115+
116+func sortedKeys(values map[string]string) []string {
117+ keys := make([]string, 0, len(values))
118+ for key := range values {
119+ keys = append(keys, key)
120+ }
121+ sort.Strings(keys)
122+ return keys
123+}
124+
125+func (d *Daemon) logConfigWarning(w domain.Warning) {
126+ if w.Line > 0 {
127+ d.log.Warn("config warning", "line", w.Line, "msg", w.Msg)
128+ return
129+ }
130+ d.log.Warn("config warning", "msg", w.Msg)
131+}
132+
133+func (d *Daemon) codeOverrideSnapshot() map[string]string {
134+ overrides := d.codeOverrides.Load()
135+ if overrides == nil {
136+ return nil
137+ }
138+ return *overrides
139+}
140+
141+func commandWithOverrides(cmd command.Command, overrides map[string]string) command.Command {
142+ if code, ok := overrides[cmd.Slug]; ok {
143+ cmd.Code = code
144+ }
145+ return cmd
146+}
147+
148+func (d *Daemon) commandByEffectiveCode(code string) (command.Command, bool) {
149+ code = strings.ToUpper(strings.TrimSpace(code))
150+ overrides := d.codeOverrideSnapshot()
151+ for _, cmd := range command.Registry() {
152+ cmd = commandWithOverrides(cmd, overrides)
153+ if cmd.Code == code {
154+ return cmd, true
155+ }
156+ }
157+ return command.Command{}, false
158+}
159+
160+func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme {
161+ switch domain.ThemeMode(d.themeMode.Load()) {
162+ case domain.ThemeDark:
163+ return theme.BuiltinDark
164+ case domain.ThemeLight:
165+ return theme.BuiltinLight
166+ default:
167+ return clientTheme
168+ }
169+}
170+
171+func (d *Daemon) reapplyThemeAllSessions() {
172+ d.mu.Lock()
173+ sessions := make([]*session, 0, len(d.sessions))
174+ for _, sess := range d.sessions {
175+ sessions = append(sessions, sess)
176+ }
177+ d.mu.Unlock()
178+
179+ for _, sess := range sessions {
180+ sess.mu.Lock()
181+ ac := sess.client
182+ sess.mu.Unlock()
183+ var clientTheme theme.Theme
184+ if ac != nil {
185+ clientTheme = ac.getClientTheme()
186+ }
187+ d.applyHostTheme(sess, ac, d.effectiveTheme(clientTheme), ac == nil)
188+ }
189+}
@@ -0,0 +1,189 @@
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+ for _, warning := range warnings {
21+ d.logConfigWarning(warning)
22+ }
23+ overrides := d.buildCodeOverrides(cfg.Codes)
24+ d.bindings.Store(bindings)
25+ d.codeOverrides.Store(&overrides)
26+ d.themeMode.Store(uint32(cfg.Theme))
27+ d.reapplyThemeAllSessions()
28+ d.repaintAllAttachedClients()
29+}
30+
31+func (d *Daemon) buildCodeOverrides(configured map[string]string) map[string]string {
32+ commands := command.Registry()
33+ bySlug := make(map[string]command.Command, len(commands))
34+ for _, cmd := range commands {
35+ bySlug[cmd.Slug] = cmd
36+ }
37+
38+ desired := make(map[string]string)
39+ for _, slug := range sortedKeys(configured) {
40+ if _, ok := bySlug[slug]; !ok {
41+ d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("unknown command code slug %q", slug)})
42+ continue
43+ }
44+ code := strings.ToUpper(strings.TrimSpace(configured[slug]))
45+ if !commandCodePattern.MatchString(code) {
46+ d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("invalid command code for %q", slug)})
47+ continue
48+ }
49+ desired[slug] = code
50+ }
51+
52+ // The effective code for a slug is its accepted override if it has one,
53+ // or its registry default otherwise. Resolve to a fixpoint rather than a
54+ // single sorted pass: dropping one slug's override to resolve a conflict
55+ // can free up its default code, which may in turn conflict with a
56+ // *different* slug's override — that must be re-checked regardless of
57+ // where the two slugs fall in sort order.
58+ accepted := make(map[string]string, len(desired))
59+ for slug, code := range desired {
60+ accepted[slug] = code
61+ }
62+ effectiveCode := func(slug string) string {
63+ if code, ok := accepted[slug]; ok {
64+ return code
65+ }
66+ return bySlug[slug].Code
67+ }
68+
69+ for {
70+ claimants := make(map[string][]string, len(commands))
71+ for _, cmd := range commands {
72+ code := effectiveCode(cmd.Slug)
73+ claimants[code] = append(claimants[code], cmd.Slug)
74+ }
75+
76+ dropped := false
77+ for code, slugs := range claimants {
78+ if len(slugs) < 2 {
79+ continue
80+ }
81+ sort.Strings(slugs)
82+
83+ // If one claimant holds this code by default (no accepted
84+ // override), every override claimant loses to it and is
85+ // dropped. Otherwise every claimant is an override; keep the
86+ // lexicographically smallest slug and drop the rest.
87+ keep := ""
88+ for _, slug := range slugs {
89+ if _, isOverride := accepted[slug]; !isOverride {
90+ keep = slug
91+ break
92+ }
93+ }
94+ if keep == "" {
95+ keep = slugs[0]
96+ }
97+ for _, slug := range slugs {
98+ if slug == keep {
99+ continue
100+ }
101+ if _, isOverride := accepted[slug]; !isOverride {
102+ continue
103+ }
104+ d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("command code %q for %q conflicts with %q", code, slug, keep)})
105+ delete(accepted, slug)
106+ dropped = true
107+ }
108+ }
109+ if !dropped {
110+ break
111+ }
112+ }
113+ return accepted
114+}
115+
116+func sortedKeys(values map[string]string) []string {
117+ keys := make([]string, 0, len(values))
118+ for key := range values {
119+ keys = append(keys, key)
120+ }
121+ sort.Strings(keys)
122+ return keys
123+}
124+
125+func (d *Daemon) logConfigWarning(w domain.Warning) {
126+ if w.Line > 0 {
127+ d.log.Warn("config warning", "line", w.Line, "msg", w.Msg)
128+ return
129+ }
130+ d.log.Warn("config warning", "msg", w.Msg)
131+}
132+
133+func (d *Daemon) codeOverrideSnapshot() map[string]string {
134+ overrides := d.codeOverrides.Load()
135+ if overrides == nil {
136+ return nil
137+ }
138+ return *overrides
139+}
140+
141+func commandWithOverrides(cmd command.Command, overrides map[string]string) command.Command {
142+ if code, ok := overrides[cmd.Slug]; ok {
143+ cmd.Code = code
144+ }
145+ return cmd
146+}
147+
148+func (d *Daemon) commandByEffectiveCode(code string) (command.Command, bool) {
149+ code = strings.ToUpper(strings.TrimSpace(code))
150+ overrides := d.codeOverrideSnapshot()
151+ for _, cmd := range command.Registry() {
152+ cmd = commandWithOverrides(cmd, overrides)
153+ if cmd.Code == code {
154+ return cmd, true
155+ }
156+ }
157+ return command.Command{}, false
158+}
159+
160+func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme {
161+ switch domain.ThemeMode(d.themeMode.Load()) {
162+ case domain.ThemeDark:
163+ return theme.BuiltinDark
164+ case domain.ThemeLight:
165+ return theme.BuiltinLight
166+ default:
167+ return clientTheme
168+ }
169+}
170+
171+func (d *Daemon) reapplyThemeAllSessions() {
172+ d.mu.Lock()
173+ sessions := make([]*session, 0, len(d.sessions))
174+ for _, sess := range d.sessions {
175+ sessions = append(sessions, sess)
176+ }
177+ d.mu.Unlock()
178+
179+ for _, sess := range sessions {
180+ sess.mu.Lock()
181+ ac := sess.client
182+ sess.mu.Unlock()
183+ var clientTheme theme.Theme
184+ if ac != nil {
185+ clientTheme = ac.getClientTheme()
186+ }
187+ d.applyHostTheme(sess, ac, d.effectiveTheme(clientTheme), ac == nil)
188+ }
189+}
A internal/usecase/daemon/config_test.go +164 -0
@@ -0,0 +1,164 @@
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+ BindingEntries: []domain.ConfigEntry{
33+ {Key: "focus-pane-left", Value: "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.commandByEffectiveCode("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.commandByEffectiveCode("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 TestCodeOverrideVacatedDefaultIsReusableRegardlessOfSlugOrder(t *testing.T) {
121+ d := newTestDaemon(t, nil, stubClock{})
122+ d.ApplyConfig(domain.Config{
123+ Theme: domain.ThemeAuto,
124+ Codes: map[string]string{
125+ "new-tab": "NT",
126+ "new-session": "CNT",
127+ },
128+ })
129+
130+ commands := d.paletteCommands()
131+ byCode := make(map[string]string, len(commands))
132+ for _, cmd := range commands {
133+ if existing, ok := byCode[cmd.Code]; ok {
134+ t.Fatalf("duplicate effective code %q for %q and %q", cmd.Code, existing, cmd.Slug)
135+ }
136+ byCode[cmd.Code] = cmd.Slug
137+ }
138+ require.Equal(t, "new-tab", byCode["NT"])
139+ require.Equal(t, "new-session", byCode["CNT"])
140+}
141+
142+func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) {
143+ d := newTestDaemon(t, nil, stubClock{})
144+ require.NotPanics(t, func() {
145+ d.ApplyConfig(domain.Config{
146+ Theme: domain.ThemeAuto,
147+ BindingEntries: []domain.ConfigEntry{
148+ {Key: "unknown-action", Value: "alt+x"},
149+ {Key: "focus-pane-left", Value: "ctrl+x"},
150+ },
151+ Codes: map[string]string{
152+ "new-tab": "toolong",
153+ "split-right": "det",
154+ "missing-command": "OK",
155+ },
156+ })
157+ })
158+
159+ cmd, ok := d.commandByEffectiveCode("CNT")
160+ require.True(t, ok)
161+ require.Equal(t, "new-tab", cmd.Slug)
162+ _, ok = d.commandByEffectiveCode("TOOLONG")
163+ require.False(t, ok)
164+}
@@ -0,0 +1,164 @@
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+ BindingEntries: []domain.ConfigEntry{
33+ {Key: "focus-pane-left", Value: "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.commandByEffectiveCode("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.commandByEffectiveCode("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 TestCodeOverrideVacatedDefaultIsReusableRegardlessOfSlugOrder(t *testing.T) {
121+ d := newTestDaemon(t, nil, stubClock{})
122+ d.ApplyConfig(domain.Config{
123+ Theme: domain.ThemeAuto,
124+ Codes: map[string]string{
125+ "new-tab": "NT",
126+ "new-session": "CNT",
127+ },
128+ })
129+
130+ commands := d.paletteCommands()
131+ byCode := make(map[string]string, len(commands))
132+ for _, cmd := range commands {
133+ if existing, ok := byCode[cmd.Code]; ok {
134+ t.Fatalf("duplicate effective code %q for %q and %q", cmd.Code, existing, cmd.Slug)
135+ }
136+ byCode[cmd.Code] = cmd.Slug
137+ }
138+ require.Equal(t, "new-tab", byCode["NT"])
139+ require.Equal(t, "new-session", byCode["CNT"])
140+}
141+
142+func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) {
143+ d := newTestDaemon(t, nil, stubClock{})
144+ require.NotPanics(t, func() {
145+ d.ApplyConfig(domain.Config{
146+ Theme: domain.ThemeAuto,
147+ BindingEntries: []domain.ConfigEntry{
148+ {Key: "unknown-action", Value: "alt+x"},
149+ {Key: "focus-pane-left", Value: "ctrl+x"},
150+ },
151+ Codes: map[string]string{
152+ "new-tab": "toolong",
153+ "split-right": "det",
154+ "missing-command": "OK",
155+ },
156+ })
157+ })
158+
159+ cmd, ok := d.commandByEffectiveCode("CNT")
160+ require.True(t, ok)
161+ require.Equal(t, "new-tab", cmd.Slug)
162+ _, ok = d.commandByEffectiveCode("TOOLONG")
163+ require.False(t, ok)
164+}
M internal/usecase/daemon/daemon.go +18 -0
@@ -38,6 +38,7 @@ 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/keys"
4142 )
4243
4344 // Scheduler debounce bounds. Idle updates use the minimum for low latency;
@@ -105,6 +106,9 @@ type Daemon struct {
105106 persistEnabled bool
106107 procCwd func(int) (string, error)
107108 procComm func(int) (string, error)
109+ bindings atomic.Pointer[keys.Bindings]
110+ codeOverrides atomic.Pointer[map[string]string]
111+ themeMode atomic.Uint32
108112 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
109113 // (see clipboard.go); empty means use os.TempDir().
110114 tempDir string
@@ -182,6 +186,13 @@ func WithTempDir(dir string) Option {
182186 }
183187 }
184188
189+// WithConfig applies the initial user configuration.
190+func WithConfig(cfg domain.Config) Option {
191+ return func(d *Daemon) {
192+ d.ApplyConfig(cfg)
193+ }
194+}
195+
185196 // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the
186197 // render debounce, and log receives diagnostics (defaults to slog.Default).
187198 func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {
@@ -219,6 +230,13 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
219230 if d.procComm == nil {
220231 d.procComm = platform.ProcessComm
221232 }
233+ if d.bindings.Load() == nil {
234+ d.bindings.Store(keys.DefaultBindings())
235+ }
236+ if d.codeOverrides.Load() == nil {
237+ empty := map[string]string{}
238+ d.codeOverrides.Store(&empty)
239+ }
222240 if records, err := d.persist.LoadAll(); err != nil {
223241 d.log.Warn("loading persisted sessions failed", "err", err)
224242 } else {
@@ -38,6 +38,7 @@ 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/keys"
41 )42 )
42 43
43 // Scheduler debounce bounds. Idle updates use the minimum for low latency;44 // Scheduler debounce bounds. Idle updates use the minimum for low latency;
@@ -105,6 +106,9 @@ type Daemon struct {
105 persistEnabled bool106 persistEnabled bool
106 procCwd func(int) (string, error)107 procCwd func(int) (string, error)
107 procComm func(int) (string, error)108 procComm func(int) (string, error)
109+ bindings atomic.Pointer[keys.Bindings]
110+ codeOverrides atomic.Pointer[map[string]string]
111+ themeMode atomic.Uint32
108 // tempDir overrides os.TempDir() for clipboard-image-transfer writes112 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
109 // (see clipboard.go); empty means use os.TempDir().113 // (see clipboard.go); empty means use os.TempDir().
110 tempDir string114 tempDir string
@@ -182,6 +186,13 @@ func WithTempDir(dir string) Option {
182 }186 }
183 }187 }
184 188
189+// WithConfig applies the initial user configuration.
190+func WithConfig(cfg domain.Config) Option {
191+ return func(d *Daemon) {
192+ d.ApplyConfig(cfg)
193+ }
194+}
195+
185 // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the196 // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the
186 // render debounce, and log receives diagnostics (defaults to slog.Default).197 // render debounce, and log receives diagnostics (defaults to slog.Default).
187 func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {198 func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {
@@ -219,6 +230,13 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
219 if d.procComm == nil {230 if d.procComm == nil {
220 d.procComm = platform.ProcessComm231 d.procComm = platform.ProcessComm
221 }232 }
233+ if d.bindings.Load() == nil {
234+ d.bindings.Store(keys.DefaultBindings())
235+ }
236+ if d.codeOverrides.Load() == nil {
237+ empty := map[string]string{}
238+ d.codeOverrides.Store(&empty)
239+ }
222 if records, err := d.persist.LoadAll(); err != nil {240 if records, err := d.persist.LoadAll(); err != nil {
223 d.log.Warn("loading persisted sessions failed", "err", err)241 d.log.Warn("loading persisted sessions failed", "err", err)
224 } else {242 } else {
M internal/usecase/daemon/daemon_test.go +1 -1
@@ -253,7 +253,7 @@ func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *sessio
253253 }
254254 sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: tabs, client: ac}
255255 ac.setSession(sess)
256- ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})
256+ ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, nil)
257257 d.sessions[sess.id] = sess
258258 t.Cleanup(cancel)
259259 return d, sess, ac, sends
@@ -253,7 +253,7 @@ func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *sessio
253 }253 }
254 sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: tabs, client: ac}254 sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: tabs, client: ac}
255 ac.setSession(sess)255 ac.setSession(sess)
256- ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})256+ ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, nil)
257 d.sessions[sess.id] = sess257 d.sessions[sess.id] = sess
258 t.Cleanup(cancel)258 t.Cleanup(cancel)
259 return d, sess, ac, sends259 return d, sess, ac, sends
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/picker_test.go +4 -4
@@ -159,8 +159,8 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
159159 sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2}
160160 ac1.setSession(sess1)
161161 ac2.setSession(sess2)
162- ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1})
163- ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2})
162+ ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}, nil)
163+ ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2}, nil)
164164 d.sessions[sess1.id] = sess1
165165 d.sessions[sess2.id] = sess2
166166
@@ -271,8 +271,8 @@ func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) {
271271 sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2}
272272 ac1.setSession(sess1)
273273 ac2.setSession(sess2)
274- ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1})
275- ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2})
274+ ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}, nil)
275+ ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2}, nil)
276276 d.sessions[sess1.id] = sess1
277277 d.sessions[sess2.id] = sess2
278278
@@ -159,8 +159,8 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
159 sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2}159 sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2}
160 ac1.setSession(sess1)160 ac1.setSession(sess1)
161 ac2.setSession(sess2)161 ac2.setSession(sess2)
162- ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1})162+ ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}, nil)
163- ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2})163+ ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2}, nil)
164 d.sessions[sess1.id] = sess1164 d.sessions[sess1.id] = sess1
165 d.sessions[sess2.id] = sess2165 d.sessions[sess2.id] = sess2
166 166
@@ -271,8 +271,8 @@ func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) {
271 sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2}271 sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2}
272 ac1.setSession(sess1)272 ac1.setSession(sess1)
273 ac2.setSession(sess2)273 ac2.setSession(sess2)
274- ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1})274+ ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}, nil)
275- ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2})275+ ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2}, nil)
276 d.sessions[sess1.id] = sess1276 d.sessions[sess1.id] = sess1
277 d.sessions[sess2.id] = sess2277 d.sessions[sess2.id] = sess2
278 278
M internal/usecase/daemon/session.go +2 -2
@@ -235,13 +235,13 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
235235 }
236236 tb := newTab(pty, tbSize)
237237 if client != nil {
238- t := client.getTheme()
238+ t := d.effectiveTheme(client.getClientTheme())
239239 tb.mu.Lock()
240240 p := tb.focusedPane()
241241 tb.mu.Unlock()
242242 if p != nil {
243243 p.mu.Lock()
244- p.screen.SetDefaultColors(t.Foreground, t.Background, t.HasFG && t.HasBG)
244+ applyPaneThemeLocked(p, t, false)
245245 p.mu.Unlock()
246246 }
247247 }
@@ -235,13 +235,13 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
235 }235 }
236 tb := newTab(pty, tbSize)236 tb := newTab(pty, tbSize)
237 if client != nil {237 if client != nil {
238- t := client.getTheme()238+ t := d.effectiveTheme(client.getClientTheme())
239 tb.mu.Lock()239 tb.mu.Lock()
240 p := tb.focusedPane()240 p := tb.focusedPane()
241 tb.mu.Unlock()241 tb.mu.Unlock()
242 if p != nil {242 if p != nil {
243 p.mu.Lock()243 p.mu.Lock()
244- p.screen.SetDefaultColors(t.Foreground, t.Background, t.HasFG && t.HasBG)244+ applyPaneThemeLocked(p, t, false)
245 p.mu.Unlock()245 p.mu.Unlock()
246 }246 }
247 }247 }
M internal/usecase/daemon/status_test.go +179 -3
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "fmt"
45 "strconv"
56 "strings"
67 "sync"
@@ -79,20 +80,195 @@ 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 TestAttachClientClearsStaleColorSchemeOnReplacement(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.ThemeAuto})
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+ assertSessionColorScheme(t, sess, true)
189+
190+ tr, _ := newCapturingTransport(t)
191+ d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
192+
193+ assertSessionColorSchemeUnknown(t, sess)
194+}
195+
196+func TestForcedThemeDetachPreservesBuiltinOnPanes(t *testing.T) {
197+ p, releasePTY := newBlockingPTY(t)
198+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
199+ defer releasePTY()
200+ d.ApplyConfig(domain.Config{Theme: domain.ThemeDark})
201+ d.applyTheme(sess, ac, ports.Theme{
202+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
203+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
204+ TrueColor: true, SchemeKnown: true, Light: true,
205+ })
206+
207+ d.clientGone(sess, ac, ac.transport(), true)
208+
209+ assertSessionDefaultColors(t, sess, themeui.BuiltinDark.Foreground, themeui.BuiltinDark.Background)
210+ assertSessionColorScheme(t, sess, false)
211+}
212+
213+func TestApplyThemeAutoUnknownDoesNotClobberPaneColorScheme(t *testing.T) {
214+ p, releasePTY := newBlockingPTY(t)
215+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
216+ defer releasePTY()
217+
218+ d.applyTheme(sess, ac, ports.Theme{
219+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
220+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
221+ TrueColor: true, SchemeKnown: true, Light: true,
222+ })
223+ d.applyTheme(sess, ac, ports.Theme{
224+ HasForeground: true, Foreground: renderer.RGB{R: 7, G: 8, B: 9},
225+ HasBackground: true, Background: renderer.RGB{R: 10, G: 11, B: 12},
226+ TrueColor: true,
227+ })
228+
229+ assertSessionDefaultColors(t, sess, renderer.RGB{R: 7, G: 8, B: 9}, renderer.RGB{R: 10, G: 11, B: 12})
230+ assertSessionColorScheme(t, sess, true)
231+}
232+
233+func assertSessionDefaultColors(t *testing.T, sess *session, fg, bg renderer.RGB) {
234+ t.Helper()
87235 for _, tb := range sess.tabs {
88236 var got []byte
89237 tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
90238 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))
239+ require.True(t, strings.Contains(string(got), formatOSCColor(fg)), string(got))
240+ require.True(t, strings.Contains(string(got), formatOSCColor(bg)), string(got))
241+ }
242+}
243+
244+func assertSessionColorScheme(t *testing.T, sess *session, light bool) {
245+ t.Helper()
246+ want := "\x1b[?997;1n"
247+ if light {
248+ want = "\x1b[?997;2n"
249+ }
250+ for _, tb := range sess.tabs {
251+ var got []byte
252+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
253+ tb.focusedPane().screen.Write([]byte("\x1b[?996n"))
254+ require.Equal(t, want, string(got))
93255 }
94256 }
95257
258+func assertSessionColorSchemeUnknown(t *testing.T, sess *session) {
259+ t.Helper()
260+ for _, tb := range sess.tabs {
261+ var got []byte
262+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
263+ tb.focusedPane().screen.Write([]byte("\x1b[?996n"))
264+ require.Empty(t, got)
265+ }
266+}
267+
268+func formatOSCColor(rgb renderer.RGB) string {
269+ return fmt.Sprintf("rgb:%02x%02x/%02x%02x/%02x%02x", rgb.R, rgb.R, rgb.G, rgb.G, rgb.B, rgb.B)
270+}
271+
96272 func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) {
97273 p, release := newBlockingPTY(t)
98274 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,195 @@ 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 TestAttachClientClearsStaleColorSchemeOnReplacement(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.ThemeAuto})
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+ assertSessionColorScheme(t, sess, true)
189+
190+ tr, _ := newCapturingTransport(t)
191+ d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
192+
193+ assertSessionColorSchemeUnknown(t, sess)
194+}
195+
196+func TestForcedThemeDetachPreservesBuiltinOnPanes(t *testing.T) {
197+ p, releasePTY := newBlockingPTY(t)
198+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
199+ defer releasePTY()
200+ d.ApplyConfig(domain.Config{Theme: domain.ThemeDark})
201+ d.applyTheme(sess, ac, ports.Theme{
202+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
203+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
204+ TrueColor: true, SchemeKnown: true, Light: true,
205+ })
206+
207+ d.clientGone(sess, ac, ac.transport(), true)
208+
209+ assertSessionDefaultColors(t, sess, themeui.BuiltinDark.Foreground, themeui.BuiltinDark.Background)
210+ assertSessionColorScheme(t, sess, false)
211+}
212+
213+func TestApplyThemeAutoUnknownDoesNotClobberPaneColorScheme(t *testing.T) {
214+ p, releasePTY := newBlockingPTY(t)
215+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
216+ defer releasePTY()
217+
218+ d.applyTheme(sess, ac, ports.Theme{
219+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
220+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
221+ TrueColor: true, SchemeKnown: true, Light: true,
222+ })
223+ d.applyTheme(sess, ac, ports.Theme{
224+ HasForeground: true, Foreground: renderer.RGB{R: 7, G: 8, B: 9},
225+ HasBackground: true, Background: renderer.RGB{R: 10, G: 11, B: 12},
226+ TrueColor: true,
227+ })
228+
229+ assertSessionDefaultColors(t, sess, renderer.RGB{R: 7, G: 8, B: 9}, renderer.RGB{R: 10, G: 11, B: 12})
230+ assertSessionColorScheme(t, sess, true)
231+}
232+
233+func assertSessionDefaultColors(t *testing.T, sess *session, fg, bg renderer.RGB) {
234+ t.Helper()
87 for _, tb := range sess.tabs {235 for _, tb := range sess.tabs {
88 var got []byte236 var got []byte
89 tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }237 tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
90 tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))238 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))239+ 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))240+ require.True(t, strings.Contains(string(got), formatOSCColor(bg)), string(got))
241+ }
242+}
243+
244+func assertSessionColorScheme(t *testing.T, sess *session, light bool) {
245+ t.Helper()
246+ want := "\x1b[?997;1n"
247+ if light {
248+ want = "\x1b[?997;2n"
249+ }
250+ for _, tb := range sess.tabs {
251+ var got []byte
252+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
253+ tb.focusedPane().screen.Write([]byte("\x1b[?996n"))
254+ require.Equal(t, want, string(got))
93 }255 }
94 }256 }
95 257
258+func assertSessionColorSchemeUnknown(t *testing.T, sess *session) {
259+ t.Helper()
260+ for _, tb := range sess.tabs {
261+ var got []byte
262+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
263+ tb.focusedPane().screen.Write([]byte("\x1b[?996n"))
264+ require.Empty(t, got)
265+ }
266+}
267+
268+func formatOSCColor(rgb renderer.RGB) string {
269+ return fmt.Sprintf("rgb:%02x%02x/%02x%02x/%02x%02x", rgb.R, rgb.R, rgb.G, rgb.G, rgb.B, rgb.B)
270+}
271+
96 func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) {272 func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) {
97 p, release := newBlockingPTY(t)273 p, release := newBlockingPTY(t)
98 d, sess, ac, _ := newManualSessionWithPTYs(t, p)274 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, clearUnknownScheme bool) 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, clearUnknownScheme)
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, clearUnknownScheme bool) 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, clearUnknownScheme)
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 +30 -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,10 @@ 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+// NewRouter constructs a Router. bindings may be nil, in which case
66+// currentBindings falls back to defaultBindings.
67+func NewRouter(clock ports.Clock, h Handler, bindings *atomic.Pointer[Bindings]) *Router {
68+ return &Router{clock: clock, delay: ESCDelay, h: h, bindings: bindings}
6569 }
6670
6771 // Route routes one transport read.
@@ -120,7 +124,7 @@ func (r *Router) route(data []byte) {
120124 continue
121125 }
122126 }
123- if action, size, ok, partial := altArrowCSI(remaining); ok {
127+ if action, size, ok, partial := r.altArrowCSI(remaining); ok {
124128 flush()
125129 r.h.Action(action)
126130 i += 1 + size
@@ -136,7 +140,7 @@ func (r *Router) route(data []byte) {
136140 i += 2
137141 continue
138142 }
139- if action, size, ok := binding(remaining); ok {
143+ if action, size, ok := r.binding(remaining); ok {
140144 flush()
141145 r.h.Action(action)
142146 i += 1 + size
@@ -154,7 +158,7 @@ func (r *Router) route(data []byte) {
154158 }
155159
156160 func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int {
157- if action, size, ok, partial := altArrowCSI(data); ok {
161+ if action, size, ok, partial := r.altArrowCSI(data); ok {
158162 r.h.Action(action)
159163 return size
160164 } else if partial {
@@ -162,7 +166,7 @@ func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int {
162166 return len(data)
163167 }
164168 next := data[0]
165- if action, size, ok := binding(data); ok {
169+ if action, size, ok := r.binding(data); ok {
166170 r.h.Action(action)
167171 return size
168172 }
@@ -238,7 +242,18 @@ func (r *Router) forward(data []byte) {
238242
239243 func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }
240244
241-func altArrowCSI(data []byte) (Action, int, bool, bool) {
245+func (r *Router) currentBindings() *Bindings {
246+ if r.bindings == nil {
247+ return defaultBindings
248+ }
249+ bindings := r.bindings.Load()
250+ if bindings == nil {
251+ return defaultBindings
252+ }
253+ return bindings
254+}
255+
256+func (r *Router) altArrowCSI(data []byte) (Action, int, bool, bool) {
242257 const seqLen = len("[1;3A")
243258 if len(data) < seqLen {
244259 return 0, 0, false, hasAltArrowCSIPrefix(data)
@@ -247,18 +262,10 @@ func altArrowCSI(data []byte) (Action, int, bool, bool) {
247262 if seq[0] != '[' || seq[1] != '1' || seq[2] != ';' || (seq[3] != '3' && seq[3] != '9') {
248263 return 0, 0, false, false
249264 }
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
265+ if action, ok := r.currentBindings().actionForAltArrow(seq[4]); ok {
266+ return action, seqLen, true, false
261267 }
268+ return 0, 0, false, false
262269 }
263270
264271 func hasAltArrowCSIPrefix(data []byte) bool {
@@ -280,33 +287,8 @@ func matchesPrefix(data []byte, want string) bool {
280287 return true
281288 }
282289
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
290+func (r *Router) binding(data []byte) (Action, int, bool) {
291+ return r.currentBindings().actionForAltBytes(data)
310292 }
311293
312294 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,10 @@ 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+// NewRouter constructs a Router. bindings may be nil, in which case
64- return &Router{clock: clock, delay: ESCDelay, h: h}66+// currentBindings falls back to defaultBindings.
67+func NewRouter(clock ports.Clock, h Handler, bindings *atomic.Pointer[Bindings]) *Router {
68+ return &Router{clock: clock, delay: ESCDelay, h: h, bindings: bindings}
65 }69 }
66 70
67 // Route routes one transport read.71 // Route routes one transport read.
@@ -120,7 +124,7 @@ func (r *Router) route(data []byte) {
120 continue124 continue
121 }125 }
122 }126 }
123- if action, size, ok, partial := altArrowCSI(remaining); ok {127+ if action, size, ok, partial := r.altArrowCSI(remaining); ok {
124 flush()128 flush()
125 r.h.Action(action)129 r.h.Action(action)
126 i += 1 + size130 i += 1 + size
@@ -136,7 +140,7 @@ func (r *Router) route(data []byte) {
136 i += 2140 i += 2
137 continue141 continue
138 }142 }
139- if action, size, ok := binding(remaining); ok {143+ if action, size, ok := r.binding(remaining); ok {
140 flush()144 flush()
141 r.h.Action(action)145 r.h.Action(action)
142 i += 1 + size146 i += 1 + size
@@ -154,7 +158,7 @@ func (r *Router) route(data []byte) {
154 }158 }
155 159
156 func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int {160 func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int {
157- if action, size, ok, partial := altArrowCSI(data); ok {161+ if action, size, ok, partial := r.altArrowCSI(data); ok {
158 r.h.Action(action)162 r.h.Action(action)
159 return size163 return size
160 } else if partial {164 } else if partial {
@@ -162,7 +166,7 @@ func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int {
162 return len(data)166 return len(data)
163 }167 }
164 next := data[0]168 next := data[0]
165- if action, size, ok := binding(data); ok {169+ if action, size, ok := r.binding(data); ok {
166 r.h.Action(action)170 r.h.Action(action)
167 return size171 return size
168 }172 }
@@ -238,7 +242,18 @@ func (r *Router) forward(data []byte) {
238 242
239 func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }243 func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }
240 244
241-func altArrowCSI(data []byte) (Action, int, bool, bool) {245+func (r *Router) currentBindings() *Bindings {
246+ if r.bindings == nil {
247+ return defaultBindings
248+ }
249+ bindings := r.bindings.Load()
250+ if bindings == nil {
251+ return defaultBindings
252+ }
253+ return bindings
254+}
255+
256+func (r *Router) altArrowCSI(data []byte) (Action, int, bool, bool) {
242 const seqLen = len("[1;3A")257 const seqLen = len("[1;3A")
243 if len(data) < seqLen {258 if len(data) < seqLen {
244 return 0, 0, false, hasAltArrowCSIPrefix(data)259 return 0, 0, false, hasAltArrowCSIPrefix(data)
@@ -247,18 +262,10 @@ func altArrowCSI(data []byte) (Action, int, bool, bool) {
247 if seq[0] != '[' || seq[1] != '1' || seq[2] != ';' || (seq[3] != '3' && seq[3] != '9') {262 if seq[0] != '[' || seq[1] != '1' || seq[2] != ';' || (seq[3] != '3' && seq[3] != '9') {
248 return 0, 0, false, false263 return 0, 0, false, false
249 }264 }
250- switch seq[4] {265+ if action, ok := r.currentBindings().actionForAltArrow(seq[4]); ok {
251- case 'A':266+ 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 }267 }
268+ return 0, 0, false, false
262 }269 }
263 270
264 func hasAltArrowCSIPrefix(data []byte) bool {271 func hasAltArrowCSIPrefix(data []byte) bool {
@@ -280,33 +287,8 @@ func matchesPrefix(data []byte, want string) bool {
280 return true287 return true
281 }288 }
282 289
283-func binding(data []byte) (Action, int, bool) {290+func (r *Router) binding(data []byte) (Action, int, bool) {
284- if len(data) == 0 {291+ 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 }292 }
311 293
312 var topRowDigitAliases = [][]rune{294 var topRowDigitAliases = [][]rune{
M internal/usecase/keys/router_test.go +60 -26
@@ -3,6 +3,7 @@ package keys
33 import (
44 "runtime"
55 "strings"
6+ "sync/atomic"
67 "testing"
78 "time"
89
@@ -45,10 +46,43 @@ 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{}
51- NewRouter(clk, h).Route([]byte{ESC, ' '})
85+ NewRouter(clk, h, nil).Route([]byte{ESC, ' '})
5286 require.Equal(t, []Action{ActionOpenPalette}, h.actions)
5387 require.Empty(t, h.forwards)
5488 require.Empty(t, clk.timers)
@@ -57,7 +91,7 @@ func TestRouterInterceptsAltSpaceForPalette(t *testing.T) {
5791 func TestRouterInterceptsAltDigits(t *testing.T) {
5892 clk := &fakeClock{}
5993 h := &captureHandler{}
60- r := NewRouter(clk, h)
94+ r := NewRouter(clk, h, nil)
6195 for b := byte('1'); b <= '9'; b++ {
6296 r.Route([]byte{ESC, b})
6397 }
@@ -79,7 +113,7 @@ func TestRouterInterceptsAltLayoutDigitAliases(t *testing.T) {
79113 t.Run(tc.name, func(t *testing.T) {
80114 clk := &fakeClock{}
81115 h := &captureHandler{}
82- r := NewRouter(clk, h)
116+ r := NewRouter(clk, h, nil)
83117 for _, key := range tc.keys {
84118 r.Route(append([]byte{ESC}, []byte(key)...))
85119 }
@@ -92,7 +126,7 @@ func TestRouterInterceptsAltLayoutDigitAliases(t *testing.T) {
92126 func TestRouterInterceptsAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
93127 clk := &fakeClock{}
94128 h := &captureHandler{}
95- r := NewRouter(clk, h)
129+ r := NewRouter(clk, h, nil)
96130
97131 r.Route([]byte{ESC, 0xc3})
98132 require.Empty(t, h.actions)
@@ -112,7 +146,7 @@ func TestRouterInterceptsAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
112146 func TestRouterInterceptsRetainedAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
113147 clk := &fakeClock{}
114148 h := &captureHandler{}
115- r := NewRouter(clk, h)
149+ r := NewRouter(clk, h, nil)
116150
117151 r.Route([]byte{ESC})
118152 r.Route([]byte{0xc3})
@@ -133,7 +167,7 @@ func TestRouterInterceptsRetainedAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
133167 func TestRouterForwardsUnboundAltUTF8SplitAcrossReads(t *testing.T) {
134168 clk := &fakeClock{}
135169 h := &captureHandler{}
136- r := NewRouter(clk, h)
170+ r := NewRouter(clk, h, nil)
137171
138172 r.Route([]byte{ESC, 0xc3})
139173 r.Route([]byte{0xb1})
@@ -150,7 +184,7 @@ func TestRouterForwardsUnboundAltUTF8SplitAcrossReads(t *testing.T) {
150184 func TestRouterForwardsInvalidSplitAltUTF8WithoutDroppingBytes(t *testing.T) {
151185 clk := &fakeClock{}
152186 h := &captureHandler{}
153- r := NewRouter(clk, h)
187+ r := NewRouter(clk, h, nil)
154188
155189 r.Route([]byte{ESC, 0xc3})
156190 r.Route([]byte("X"))
@@ -206,7 +240,7 @@ func switchTabActions() []Action {
206240 func TestRouterInterceptsAltAForJumpAttention(t *testing.T) {
207241 clk := &fakeClock{}
208242 h := &captureHandler{}
209- NewRouter(clk, h).Route([]byte{ESC, 'a'})
243+ NewRouter(clk, h, nil).Route([]byte{ESC, 'a'})
210244 require.Equal(t, []Action{ActionJumpAttention}, h.actions)
211245 require.Empty(t, h.forwards)
212246 require.Empty(t, clk.timers)
@@ -227,7 +261,7 @@ func TestRouterInterceptsAltHJKLForPaneFocus(t *testing.T) {
227261 t.Run(tc.name, func(t *testing.T) {
228262 clk := &fakeClock{}
229263 h := &captureHandler{}
230- NewRouter(clk, h).Route([]byte{ESC, tc.key})
264+ NewRouter(clk, h, nil).Route([]byte{ESC, tc.key})
231265 require.Equal(t, []Action{tc.want}, h.actions)
232266 require.Empty(t, h.forwards)
233267 require.Empty(t, clk.timers)
@@ -240,7 +274,7 @@ func TestRouterForwardsRemovedAltLetterBindings(t *testing.T) {
240274 t.Run(string(b), func(t *testing.T) {
241275 clk := &fakeClock{}
242276 h := &captureHandler{}
243- NewRouter(clk, h).Route([]byte{ESC, b})
277+ NewRouter(clk, h, nil).Route([]byte{ESC, b})
244278 require.Empty(t, h.actions)
245279 require.Equal(t, [][]byte{{ESC, b}}, h.forwards)
246280 })
@@ -250,7 +284,7 @@ func TestRouterForwardsRemovedAltLetterBindings(t *testing.T) {
250284 func TestRouterForwardsOnlyNonInterceptedBytes(t *testing.T) {
251285 clk := &fakeClock{}
252286 h := &captureHandler{}
253- NewRouter(clk, h).Route([]byte{'a', ESC, ' ', 'b'})
287+ NewRouter(clk, h, nil).Route([]byte{'a', ESC, ' ', 'b'})
254288 require.Equal(t, []Action{ActionOpenPalette}, h.actions)
255289 require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards)
256290 }
@@ -260,7 +294,7 @@ func TestRouterPassesThroughTerminalEscapePrefixes(t *testing.T) {
260294 for _, in := range cases {
261295 clk := &fakeClock{}
262296 h := &captureHandler{}
263- NewRouter(clk, h).Route(in)
297+ NewRouter(clk, h, nil).Route(in)
264298 require.Empty(t, h.actions)
265299 require.Equal(t, [][]byte{in}, h.forwards)
266300 require.Empty(t, clk.timers)
@@ -287,7 +321,7 @@ func TestRouterInterceptsAltArrowCSIForPaneFocus(t *testing.T) {
287321 t.Run(tc.name, func(t *testing.T) {
288322 clk := &fakeClock{}
289323 h := &captureHandler{}
290- NewRouter(clk, h).Route(tc.in)
324+ NewRouter(clk, h, nil).Route(tc.in)
291325 require.Equal(t, []Action{tc.want}, h.actions)
292326 require.Empty(t, h.forwards)
293327 require.Empty(t, clk.timers)
@@ -312,7 +346,7 @@ func TestRouterAltArrowCSIPartialsAcrossReads(t *testing.T) {
312346 t.Run(tc.name, func(t *testing.T) {
313347 clk := &fakeClock{}
314348 h := &captureHandler{}
315- r := NewRouter(clk, h)
349+ r := NewRouter(clk, h, nil)
316350 for _, read := range tc.reads {
317351 r.Route(read)
318352 }
@@ -358,7 +392,7 @@ func TestRouterAltArrowCSIPrefixRouting(t *testing.T) {
358392 t.Run(tc.name, func(t *testing.T) {
359393 clk := &fakeClock{}
360394 h := &captureHandler{}
361- r := NewRouter(clk, h)
395+ r := NewRouter(clk, h, nil)
362396 for _, read := range tc.reads {
363397 r.Route(read)
364398 }
@@ -378,7 +412,7 @@ func TestRouterPassesThroughBareArrows(t *testing.T) {
378412 t.Run(string(in), func(t *testing.T) {
379413 clk := &fakeClock{}
380414 h := &captureHandler{}
381- NewRouter(clk, h).Route(in)
415+ NewRouter(clk, h, nil).Route(in)
382416 require.Empty(t, h.actions)
383417 require.Equal(t, [][]byte{in}, h.forwards)
384418 require.Empty(t, clk.timers)
@@ -389,7 +423,7 @@ func TestRouterPassesThroughBareArrows(t *testing.T) {
389423 func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) {
390424 clk := &fakeClock{}
391425 h := &captureHandler{}
392- r := NewRouter(clk, h)
426+ r := NewRouter(clk, h, nil)
393427 r.Route([]byte{ESC})
394428 require.Len(t, clk.timers, 1)
395429 require.Equal(t, ESCDelay, clk.timers[0].d)
@@ -404,7 +438,7 @@ func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) {
404438 func TestRouterForwardsBareT(t *testing.T) {
405439 clk := &fakeClock{}
406440 h := &captureHandler{}
407- NewRouter(clk, h).Route([]byte{'t'})
441+ NewRouter(clk, h, nil).Route([]byte{'t'})
408442 require.Empty(t, h.actions)
409443 require.Equal(t, [][]byte{[]byte("t")}, h.forwards)
410444 }
@@ -423,7 +457,7 @@ func TestRouterForwardsCSIAfterRetainedESCAcrossReads(t *testing.T) {
423457 t.Run(tc.name, func(t *testing.T) {
424458 clk := &fakeClock{}
425459 h := &captureHandler{}
426- r := NewRouter(clk, h)
460+ r := NewRouter(clk, h, nil)
427461 r.Route([]byte{ESC})
428462 require.Len(t, clk.timers, 1)
429463 require.Empty(t, h.forwards)
@@ -440,7 +474,7 @@ func TestRouterForwardsCSIAfterRetainedESCAcrossReads(t *testing.T) {
440474 func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) {
441475 clk := &fakeClock{}
442476 h := &captureHandler{}
443- r := NewRouter(clk, h)
477+ r := NewRouter(clk, h, nil)
444478 r.Route([]byte{ESC})
445479
446480 r.Route([]byte{'[', 'A', ESC, ' '})
@@ -453,7 +487,7 @@ func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) {
453487 func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) {
454488 clk := &fakeClock{}
455489 h := &captureHandler{}
456- r := NewRouter(clk, h)
490+ r := NewRouter(clk, h, nil)
457491
458492 before := retainESCWaiters()
459493 r.Route([]byte{ESC})
@@ -475,7 +509,7 @@ func retainESCWaiters() int {
475509 func TestRouterFlushesLoneESCAfterTimer(t *testing.T) {
476510 clk := &fakeClock{}
477511 h := &captureHandler{notify: make(chan struct{}, 1)}
478- r := NewRouter(clk, h)
512+ r := NewRouter(clk, h, nil)
479513 r.Route([]byte{ESC})
480514 clk.timers[0].fire()
481515 select {
@@ -490,7 +524,7 @@ func TestRouterFlushesLoneESCAfterTimer(t *testing.T) {
490524 func TestRouterFlushesPendingESCBeforeOtherByte(t *testing.T) {
491525 clk := &fakeClock{}
492526 h := &captureHandler{}
493- r := NewRouter(clk, h)
527+ r := NewRouter(clk, h, nil)
494528 r.Route([]byte{ESC})
495529 r.Route([]byte{'z'})
496530 require.Equal(t, [][]byte{{ESC}, []byte("z")}, h.forwards)
@@ -504,7 +538,7 @@ var pasteBindingLookalikes = []byte("\x1bj\x1b \x1b1\x1b[1;3A")
504538 func TestRouterForwardsSingleFramePasteVerbatim(t *testing.T) {
505539 clk := &fakeClock{}
506540 h := &captureHandler{}
507- r := NewRouter(clk, h)
541+ r := NewRouter(clk, h, nil)
508542
509543 paste := append(append(append([]byte(nil), ports.BracketedPasteOpenMarker...), pasteBindingLookalikes...), ports.BracketedPasteCloseMarker...)
510544 r.Route(paste)
@@ -517,7 +551,7 @@ func TestRouterForwardsSingleFramePasteVerbatim(t *testing.T) {
517551 func TestRouterFiresBindingsForSameBytesOutsidePaste(t *testing.T) {
518552 clk := &fakeClock{}
519553 h := &captureHandler{}
520- r := NewRouter(clk, h)
554+ r := NewRouter(clk, h, nil)
521555
522556 r.Route(pasteBindingLookalikes)
523557
@@ -533,7 +567,7 @@ func TestRouterFiresBindingsForSameBytesOutsidePaste(t *testing.T) {
533567 func TestRouterPasteMarkerSplitAcrossFramesKeepsCurrentBehavior(t *testing.T) {
534568 clk := &fakeClock{}
535569 h := &captureHandler{}
536- r := NewRouter(clk, h)
570+ r := NewRouter(clk, h, nil)
537571
538572 // Opening marker + content in one frame, closing marker in the next: with
539573 // no closing marker in-frame the router keeps its historical per-byte
@@ -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,10 +46,43 @@ 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{}
51- NewRouter(clk, h).Route([]byte{ESC, ' '})85+ NewRouter(clk, h, nil).Route([]byte{ESC, ' '})
52 require.Equal(t, []Action{ActionOpenPalette}, h.actions)86 require.Equal(t, []Action{ActionOpenPalette}, h.actions)
53 require.Empty(t, h.forwards)87 require.Empty(t, h.forwards)
54 require.Empty(t, clk.timers)88 require.Empty(t, clk.timers)
@@ -57,7 +91,7 @@ func TestRouterInterceptsAltSpaceForPalette(t *testing.T) {
57 func TestRouterInterceptsAltDigits(t *testing.T) {91 func TestRouterInterceptsAltDigits(t *testing.T) {
58 clk := &fakeClock{}92 clk := &fakeClock{}
59 h := &captureHandler{}93 h := &captureHandler{}
60- r := NewRouter(clk, h)94+ r := NewRouter(clk, h, nil)
61 for b := byte('1'); b <= '9'; b++ {95 for b := byte('1'); b <= '9'; b++ {
62 r.Route([]byte{ESC, b})96 r.Route([]byte{ESC, b})
63 }97 }
@@ -79,7 +113,7 @@ func TestRouterInterceptsAltLayoutDigitAliases(t *testing.T) {
79 t.Run(tc.name, func(t *testing.T) {113 t.Run(tc.name, func(t *testing.T) {
80 clk := &fakeClock{}114 clk := &fakeClock{}
81 h := &captureHandler{}115 h := &captureHandler{}
82- r := NewRouter(clk, h)116+ r := NewRouter(clk, h, nil)
83 for _, key := range tc.keys {117 for _, key := range tc.keys {
84 r.Route(append([]byte{ESC}, []byte(key)...))118 r.Route(append([]byte{ESC}, []byte(key)...))
85 }119 }
@@ -92,7 +126,7 @@ func TestRouterInterceptsAltLayoutDigitAliases(t *testing.T) {
92 func TestRouterInterceptsAltAZERTYUTF8SplitAcrossReads(t *testing.T) {126 func TestRouterInterceptsAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
93 clk := &fakeClock{}127 clk := &fakeClock{}
94 h := &captureHandler{}128 h := &captureHandler{}
95- r := NewRouter(clk, h)129+ r := NewRouter(clk, h, nil)
96 130
97 r.Route([]byte{ESC, 0xc3})131 r.Route([]byte{ESC, 0xc3})
98 require.Empty(t, h.actions)132 require.Empty(t, h.actions)
@@ -112,7 +146,7 @@ func TestRouterInterceptsAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
112 func TestRouterInterceptsRetainedAltAZERTYUTF8SplitAcrossReads(t *testing.T) {146 func TestRouterInterceptsRetainedAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
113 clk := &fakeClock{}147 clk := &fakeClock{}
114 h := &captureHandler{}148 h := &captureHandler{}
115- r := NewRouter(clk, h)149+ r := NewRouter(clk, h, nil)
116 150
117 r.Route([]byte{ESC})151 r.Route([]byte{ESC})
118 r.Route([]byte{0xc3})152 r.Route([]byte{0xc3})
@@ -133,7 +167,7 @@ func TestRouterInterceptsRetainedAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
133 func TestRouterForwardsUnboundAltUTF8SplitAcrossReads(t *testing.T) {167 func TestRouterForwardsUnboundAltUTF8SplitAcrossReads(t *testing.T) {
134 clk := &fakeClock{}168 clk := &fakeClock{}
135 h := &captureHandler{}169 h := &captureHandler{}
136- r := NewRouter(clk, h)170+ r := NewRouter(clk, h, nil)
137 171
138 r.Route([]byte{ESC, 0xc3})172 r.Route([]byte{ESC, 0xc3})
139 r.Route([]byte{0xb1})173 r.Route([]byte{0xb1})
@@ -150,7 +184,7 @@ func TestRouterForwardsUnboundAltUTF8SplitAcrossReads(t *testing.T) {
150 func TestRouterForwardsInvalidSplitAltUTF8WithoutDroppingBytes(t *testing.T) {184 func TestRouterForwardsInvalidSplitAltUTF8WithoutDroppingBytes(t *testing.T) {
151 clk := &fakeClock{}185 clk := &fakeClock{}
152 h := &captureHandler{}186 h := &captureHandler{}
153- r := NewRouter(clk, h)187+ r := NewRouter(clk, h, nil)
154 188
155 r.Route([]byte{ESC, 0xc3})189 r.Route([]byte{ESC, 0xc3})
156 r.Route([]byte("X"))190 r.Route([]byte("X"))
@@ -206,7 +240,7 @@ func switchTabActions() []Action {
206 func TestRouterInterceptsAltAForJumpAttention(t *testing.T) {240 func TestRouterInterceptsAltAForJumpAttention(t *testing.T) {
207 clk := &fakeClock{}241 clk := &fakeClock{}
208 h := &captureHandler{}242 h := &captureHandler{}
209- NewRouter(clk, h).Route([]byte{ESC, 'a'})243+ NewRouter(clk, h, nil).Route([]byte{ESC, 'a'})
210 require.Equal(t, []Action{ActionJumpAttention}, h.actions)244 require.Equal(t, []Action{ActionJumpAttention}, h.actions)
211 require.Empty(t, h.forwards)245 require.Empty(t, h.forwards)
212 require.Empty(t, clk.timers)246 require.Empty(t, clk.timers)
@@ -227,7 +261,7 @@ func TestRouterInterceptsAltHJKLForPaneFocus(t *testing.T) {
227 t.Run(tc.name, func(t *testing.T) {261 t.Run(tc.name, func(t *testing.T) {
228 clk := &fakeClock{}262 clk := &fakeClock{}
229 h := &captureHandler{}263 h := &captureHandler{}
230- NewRouter(clk, h).Route([]byte{ESC, tc.key})264+ NewRouter(clk, h, nil).Route([]byte{ESC, tc.key})
231 require.Equal(t, []Action{tc.want}, h.actions)265 require.Equal(t, []Action{tc.want}, h.actions)
232 require.Empty(t, h.forwards)266 require.Empty(t, h.forwards)
233 require.Empty(t, clk.timers)267 require.Empty(t, clk.timers)
@@ -240,7 +274,7 @@ func TestRouterForwardsRemovedAltLetterBindings(t *testing.T) {
240 t.Run(string(b), func(t *testing.T) {274 t.Run(string(b), func(t *testing.T) {
241 clk := &fakeClock{}275 clk := &fakeClock{}
242 h := &captureHandler{}276 h := &captureHandler{}
243- NewRouter(clk, h).Route([]byte{ESC, b})277+ NewRouter(clk, h, nil).Route([]byte{ESC, b})
244 require.Empty(t, h.actions)278 require.Empty(t, h.actions)
245 require.Equal(t, [][]byte{{ESC, b}}, h.forwards)279 require.Equal(t, [][]byte{{ESC, b}}, h.forwards)
246 })280 })
@@ -250,7 +284,7 @@ func TestRouterForwardsRemovedAltLetterBindings(t *testing.T) {
250 func TestRouterForwardsOnlyNonInterceptedBytes(t *testing.T) {284 func TestRouterForwardsOnlyNonInterceptedBytes(t *testing.T) {
251 clk := &fakeClock{}285 clk := &fakeClock{}
252 h := &captureHandler{}286 h := &captureHandler{}
253- NewRouter(clk, h).Route([]byte{'a', ESC, ' ', 'b'})287+ NewRouter(clk, h, nil).Route([]byte{'a', ESC, ' ', 'b'})
254 require.Equal(t, []Action{ActionOpenPalette}, h.actions)288 require.Equal(t, []Action{ActionOpenPalette}, h.actions)
255 require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards)289 require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards)
256 }290 }
@@ -260,7 +294,7 @@ func TestRouterPassesThroughTerminalEscapePrefixes(t *testing.T) {
260 for _, in := range cases {294 for _, in := range cases {
261 clk := &fakeClock{}295 clk := &fakeClock{}
262 h := &captureHandler{}296 h := &captureHandler{}
263- NewRouter(clk, h).Route(in)297+ NewRouter(clk, h, nil).Route(in)
264 require.Empty(t, h.actions)298 require.Empty(t, h.actions)
265 require.Equal(t, [][]byte{in}, h.forwards)299 require.Equal(t, [][]byte{in}, h.forwards)
266 require.Empty(t, clk.timers)300 require.Empty(t, clk.timers)
@@ -287,7 +321,7 @@ func TestRouterInterceptsAltArrowCSIForPaneFocus(t *testing.T) {
287 t.Run(tc.name, func(t *testing.T) {321 t.Run(tc.name, func(t *testing.T) {
288 clk := &fakeClock{}322 clk := &fakeClock{}
289 h := &captureHandler{}323 h := &captureHandler{}
290- NewRouter(clk, h).Route(tc.in)324+ NewRouter(clk, h, nil).Route(tc.in)
291 require.Equal(t, []Action{tc.want}, h.actions)325 require.Equal(t, []Action{tc.want}, h.actions)
292 require.Empty(t, h.forwards)326 require.Empty(t, h.forwards)
293 require.Empty(t, clk.timers)327 require.Empty(t, clk.timers)
@@ -312,7 +346,7 @@ func TestRouterAltArrowCSIPartialsAcrossReads(t *testing.T) {
312 t.Run(tc.name, func(t *testing.T) {346 t.Run(tc.name, func(t *testing.T) {
313 clk := &fakeClock{}347 clk := &fakeClock{}
314 h := &captureHandler{}348 h := &captureHandler{}
315- r := NewRouter(clk, h)349+ r := NewRouter(clk, h, nil)
316 for _, read := range tc.reads {350 for _, read := range tc.reads {
317 r.Route(read)351 r.Route(read)
318 }352 }
@@ -358,7 +392,7 @@ func TestRouterAltArrowCSIPrefixRouting(t *testing.T) {
358 t.Run(tc.name, func(t *testing.T) {392 t.Run(tc.name, func(t *testing.T) {
359 clk := &fakeClock{}393 clk := &fakeClock{}
360 h := &captureHandler{}394 h := &captureHandler{}
361- r := NewRouter(clk, h)395+ r := NewRouter(clk, h, nil)
362 for _, read := range tc.reads {396 for _, read := range tc.reads {
363 r.Route(read)397 r.Route(read)
364 }398 }
@@ -378,7 +412,7 @@ func TestRouterPassesThroughBareArrows(t *testing.T) {
378 t.Run(string(in), func(t *testing.T) {412 t.Run(string(in), func(t *testing.T) {
379 clk := &fakeClock{}413 clk := &fakeClock{}
380 h := &captureHandler{}414 h := &captureHandler{}
381- NewRouter(clk, h).Route(in)415+ NewRouter(clk, h, nil).Route(in)
382 require.Empty(t, h.actions)416 require.Empty(t, h.actions)
383 require.Equal(t, [][]byte{in}, h.forwards)417 require.Equal(t, [][]byte{in}, h.forwards)
384 require.Empty(t, clk.timers)418 require.Empty(t, clk.timers)
@@ -389,7 +423,7 @@ func TestRouterPassesThroughBareArrows(t *testing.T) {
389 func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) {423 func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) {
390 clk := &fakeClock{}424 clk := &fakeClock{}
391 h := &captureHandler{}425 h := &captureHandler{}
392- r := NewRouter(clk, h)426+ r := NewRouter(clk, h, nil)
393 r.Route([]byte{ESC})427 r.Route([]byte{ESC})
394 require.Len(t, clk.timers, 1)428 require.Len(t, clk.timers, 1)
395 require.Equal(t, ESCDelay, clk.timers[0].d)429 require.Equal(t, ESCDelay, clk.timers[0].d)
@@ -404,7 +438,7 @@ func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) {
404 func TestRouterForwardsBareT(t *testing.T) {438 func TestRouterForwardsBareT(t *testing.T) {
405 clk := &fakeClock{}439 clk := &fakeClock{}
406 h := &captureHandler{}440 h := &captureHandler{}
407- NewRouter(clk, h).Route([]byte{'t'})441+ NewRouter(clk, h, nil).Route([]byte{'t'})
408 require.Empty(t, h.actions)442 require.Empty(t, h.actions)
409 require.Equal(t, [][]byte{[]byte("t")}, h.forwards)443 require.Equal(t, [][]byte{[]byte("t")}, h.forwards)
410 }444 }
@@ -423,7 +457,7 @@ func TestRouterForwardsCSIAfterRetainedESCAcrossReads(t *testing.T) {
423 t.Run(tc.name, func(t *testing.T) {457 t.Run(tc.name, func(t *testing.T) {
424 clk := &fakeClock{}458 clk := &fakeClock{}
425 h := &captureHandler{}459 h := &captureHandler{}
426- r := NewRouter(clk, h)460+ r := NewRouter(clk, h, nil)
427 r.Route([]byte{ESC})461 r.Route([]byte{ESC})
428 require.Len(t, clk.timers, 1)462 require.Len(t, clk.timers, 1)
429 require.Empty(t, h.forwards)463 require.Empty(t, h.forwards)
@@ -440,7 +474,7 @@ func TestRouterForwardsCSIAfterRetainedESCAcrossReads(t *testing.T) {
440 func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) {474 func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) {
441 clk := &fakeClock{}475 clk := &fakeClock{}
442 h := &captureHandler{}476 h := &captureHandler{}
443- r := NewRouter(clk, h)477+ r := NewRouter(clk, h, nil)
444 r.Route([]byte{ESC})478 r.Route([]byte{ESC})
445 479
446 r.Route([]byte{'[', 'A', ESC, ' '})480 r.Route([]byte{'[', 'A', ESC, ' '})
@@ -453,7 +487,7 @@ func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) {
453 func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) {487 func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) {
454 clk := &fakeClock{}488 clk := &fakeClock{}
455 h := &captureHandler{}489 h := &captureHandler{}
456- r := NewRouter(clk, h)490+ r := NewRouter(clk, h, nil)
457 491
458 before := retainESCWaiters()492 before := retainESCWaiters()
459 r.Route([]byte{ESC})493 r.Route([]byte{ESC})
@@ -475,7 +509,7 @@ func retainESCWaiters() int {
475 func TestRouterFlushesLoneESCAfterTimer(t *testing.T) {509 func TestRouterFlushesLoneESCAfterTimer(t *testing.T) {
476 clk := &fakeClock{}510 clk := &fakeClock{}
477 h := &captureHandler{notify: make(chan struct{}, 1)}511 h := &captureHandler{notify: make(chan struct{}, 1)}
478- r := NewRouter(clk, h)512+ r := NewRouter(clk, h, nil)
479 r.Route([]byte{ESC})513 r.Route([]byte{ESC})
480 clk.timers[0].fire()514 clk.timers[0].fire()
481 select {515 select {
@@ -490,7 +524,7 @@ func TestRouterFlushesLoneESCAfterTimer(t *testing.T) {
490 func TestRouterFlushesPendingESCBeforeOtherByte(t *testing.T) {524 func TestRouterFlushesPendingESCBeforeOtherByte(t *testing.T) {
491 clk := &fakeClock{}525 clk := &fakeClock{}
492 h := &captureHandler{}526 h := &captureHandler{}
493- r := NewRouter(clk, h)527+ r := NewRouter(clk, h, nil)
494 r.Route([]byte{ESC})528 r.Route([]byte{ESC})
495 r.Route([]byte{'z'})529 r.Route([]byte{'z'})
496 require.Equal(t, [][]byte{{ESC}, []byte("z")}, h.forwards)530 require.Equal(t, [][]byte{{ESC}, []byte("z")}, h.forwards)
@@ -504,7 +538,7 @@ var pasteBindingLookalikes = []byte("\x1bj\x1b \x1b1\x1b[1;3A")
504 func TestRouterForwardsSingleFramePasteVerbatim(t *testing.T) {538 func TestRouterForwardsSingleFramePasteVerbatim(t *testing.T) {
505 clk := &fakeClock{}539 clk := &fakeClock{}
506 h := &captureHandler{}540 h := &captureHandler{}
507- r := NewRouter(clk, h)541+ r := NewRouter(clk, h, nil)
508 542
509 paste := append(append(append([]byte(nil), ports.BracketedPasteOpenMarker...), pasteBindingLookalikes...), ports.BracketedPasteCloseMarker...)543 paste := append(append(append([]byte(nil), ports.BracketedPasteOpenMarker...), pasteBindingLookalikes...), ports.BracketedPasteCloseMarker...)
510 r.Route(paste)544 r.Route(paste)
@@ -517,7 +551,7 @@ func TestRouterForwardsSingleFramePasteVerbatim(t *testing.T) {
517 func TestRouterFiresBindingsForSameBytesOutsidePaste(t *testing.T) {551 func TestRouterFiresBindingsForSameBytesOutsidePaste(t *testing.T) {
518 clk := &fakeClock{}552 clk := &fakeClock{}
519 h := &captureHandler{}553 h := &captureHandler{}
520- r := NewRouter(clk, h)554+ r := NewRouter(clk, h, nil)
521 555
522 r.Route(pasteBindingLookalikes)556 r.Route(pasteBindingLookalikes)
523 557
@@ -533,7 +567,7 @@ func TestRouterFiresBindingsForSameBytesOutsidePaste(t *testing.T) {
533 func TestRouterPasteMarkerSplitAcrossFramesKeepsCurrentBehavior(t *testing.T) {567 func TestRouterPasteMarkerSplitAcrossFramesKeepsCurrentBehavior(t *testing.T) {
534 clk := &fakeClock{}568 clk := &fakeClock{}
535 h := &captureHandler{}569 h := &captureHandler{}
536- r := NewRouter(clk, h)570+ r := NewRouter(clk, h, nil)
537 571
538 // Opening marker + content in one frame, closing marker in the next: with572 // Opening marker + content in one frame, closing marker in the next: with
539 // no closing marker in-frame the router keeps its historical per-byte573 // no closing marker in-frame the router keeps its historical per-byte
M internal/usecase/theme/theme.go +84 -7
@@ -6,20 +6,46 @@ import (
66 "strings"
77
88 "github.com/bnema/vev/pkg/renderer"
9+ "github.com/bnema/vev/pkg/vt"
910 )
1011
1112 const maxPending = 64
1213
1314 // Theme describes terminal default colors reported by OSC 10/11.
1415 type Theme struct {
15- Foreground renderer.RGB
16- Background renderer.RGB
17- HasFG bool
18- HasBG bool
19- TrueColor bool
20- Known bool
16+ Foreground renderer.RGB
17+ Background renderer.RGB
18+ HasFG bool
19+ HasBG bool
20+ TrueColor bool
21+ Known bool
22+ SchemeKnown bool
23+ Light bool
2124 }
2225
26+var (
27+ BuiltinDark = Theme{
28+ Foreground: renderer.RGB{R: 0xd8, G: 0xd8, B: 0xd8},
29+ Background: renderer.RGB{R: 0x18, G: 0x18, B: 0x18},
30+ HasFG: true,
31+ HasBG: true,
32+ TrueColor: true,
33+ Known: true,
34+ SchemeKnown: true,
35+ Light: false,
36+ }
37+ BuiltinLight = Theme{
38+ Foreground: renderer.RGB{R: 0x20, G: 0x20, B: 0x20},
39+ Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8},
40+ HasFG: true,
41+ HasBG: true,
42+ TrueColor: true,
43+ Known: true,
44+ SchemeKnown: true,
45+ Light: true,
46+ }
47+)
48+
2349 // ParseXColor parses the XParseColor formats commonly returned by OSC 10/11.
2450 func ParseXColor(s string) (renderer.RGB, bool) {
2551 if strings.HasPrefix(s, "#") {
@@ -95,7 +121,7 @@ type Scanner struct {
95121 // callers never see it split. Partial OSC color responses are buffered across
96122 // calls, but the buffer is bounded so an unterminated OSC cannot block
97123 // unrelated input forever.
98-func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onBytes func([]byte)) {
124+func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onScheme func(light bool), onBytes func([]byte)) {
99125 if len(s.pending) > 0 {
100126 combined := make([]byte, 0, len(s.pending)+len(data))
101127 combined = append(combined, s.pending...)
@@ -110,6 +136,22 @@ func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), on
110136 continue
111137 }
112138
139+ if complete, possible, light := schemeCSINotification(data[i:]); complete {
140+ if byteStart < i {
141+ onBytes(data[byteStart:i])
142+ }
143+ onScheme(light)
144+ i += len(vt.ColorSchemeReportDark) - 1
145+ byteStart = i + 1
146+ continue
147+ } else if possible {
148+ if byteStart < i {
149+ onBytes(data[byteStart:i])
150+ }
151+ s.bufferOrFlush(data[i:], onBytes)
152+ return
153+ }
154+
113155 completePrefix, possiblePrefix := colorOSCPrefix(data[i:])
114156 if !completePrefix {
115157 if possiblePrefix {
@@ -183,6 +225,41 @@ func colorOSCPrefix(data []byte) (complete bool, possible bool) {
183225 return false, false
184226 }
185227
228+// schemeCSIPrefixes lists the CSI ?997 scheme-notification sequences vev
229+// recognizes. Package-level so schemeCSINotification (called per ESC byte in
230+// the stdin hot path) doesn't allocate on every call.
231+var schemeCSIPrefixes = []struct {
232+ seq []byte
233+ light bool
234+}{
235+ {seq: []byte(vt.ColorSchemeReportDark), light: false},
236+ {seq: []byte(vt.ColorSchemeReportLight), light: true},
237+}
238+
239+func schemeCSINotification(data []byte) (complete bool, possible bool, light bool) {
240+ // A bare trailing ESC (handled elsewhere) or "ESC [" (2 bytes) is
241+ // something a user can type; unlike OSC's "ESC ]" it must never be
242+ // withheld as a partial match with nothing following, or those keystrokes
243+ // would be buffered and never forwarded. Partial buffering only starts
244+ // from "ESC [ ?" onward; a complete match is always >= 9 bytes, so this
245+ // guard cannot reject one.
246+ if len(data) < 3 {
247+ return false, false, false
248+ }
249+ for _, prefix := range schemeCSIPrefixes {
250+ if len(data) >= len(prefix.seq) {
251+ if bytes.Equal(data[:len(prefix.seq)], prefix.seq) {
252+ return true, true, prefix.light
253+ }
254+ continue
255+ }
256+ if bytes.Equal(data, prefix.seq[:len(data)]) {
257+ return false, true, false
258+ }
259+ }
260+ return false, false, false
261+}
262+
186263 func findTerminator(data []byte) (start int, end int) {
187264 for i := range data {
188265 switch data[i] {
@@ -6,20 +6,46 @@ import (
6 "strings"6 "strings"
7 7
8 "github.com/bnema/vev/pkg/renderer"8 "github.com/bnema/vev/pkg/renderer"
9+ "github.com/bnema/vev/pkg/vt"
9 )10 )
10 11
11 const maxPending = 6412 const maxPending = 64
12 13
13 // Theme describes terminal default colors reported by OSC 10/11.14 // Theme describes terminal default colors reported by OSC 10/11.
14 type Theme struct {15 type Theme struct {
15- Foreground renderer.RGB16+ Foreground renderer.RGB
16- Background renderer.RGB17+ Background renderer.RGB
17- HasFG bool18+ HasFG bool
18- HasBG bool19+ HasBG bool
19- TrueColor bool20+ TrueColor bool
20- Known bool21+ Known bool
22+ SchemeKnown bool
23+ Light bool
21 }24 }
22 25
26+var (
27+ BuiltinDark = Theme{
28+ Foreground: renderer.RGB{R: 0xd8, G: 0xd8, B: 0xd8},
29+ Background: renderer.RGB{R: 0x18, G: 0x18, B: 0x18},
30+ HasFG: true,
31+ HasBG: true,
32+ TrueColor: true,
33+ Known: true,
34+ SchemeKnown: true,
35+ Light: false,
36+ }
37+ BuiltinLight = Theme{
38+ Foreground: renderer.RGB{R: 0x20, G: 0x20, B: 0x20},
39+ Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8},
40+ HasFG: true,
41+ HasBG: true,
42+ TrueColor: true,
43+ Known: true,
44+ SchemeKnown: true,
45+ Light: true,
46+ }
47+)
48+
23 // ParseXColor parses the XParseColor formats commonly returned by OSC 10/11.49 // ParseXColor parses the XParseColor formats commonly returned by OSC 10/11.
24 func ParseXColor(s string) (renderer.RGB, bool) {50 func ParseXColor(s string) (renderer.RGB, bool) {
25 if strings.HasPrefix(s, "#") {51 if strings.HasPrefix(s, "#") {
@@ -95,7 +121,7 @@ type Scanner struct {
95 // callers never see it split. Partial OSC color responses are buffered across121 // callers never see it split. Partial OSC color responses are buffered across
96 // calls, but the buffer is bounded so an unterminated OSC cannot block122 // calls, but the buffer is bounded so an unterminated OSC cannot block
97 // unrelated input forever.123 // unrelated input forever.
98-func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onBytes func([]byte)) {124+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 {125 if len(s.pending) > 0 {
100 combined := make([]byte, 0, len(s.pending)+len(data))126 combined := make([]byte, 0, len(s.pending)+len(data))
101 combined = append(combined, s.pending...)127 combined = append(combined, s.pending...)
@@ -110,6 +136,22 @@ func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), on
110 continue136 continue
111 }137 }
112 138
139+ if complete, possible, light := schemeCSINotification(data[i:]); complete {
140+ if byteStart < i {
141+ onBytes(data[byteStart:i])
142+ }
143+ onScheme(light)
144+ i += len(vt.ColorSchemeReportDark) - 1
145+ byteStart = i + 1
146+ continue
147+ } else if possible {
148+ if byteStart < i {
149+ onBytes(data[byteStart:i])
150+ }
151+ s.bufferOrFlush(data[i:], onBytes)
152+ return
153+ }
154+
113 completePrefix, possiblePrefix := colorOSCPrefix(data[i:])155 completePrefix, possiblePrefix := colorOSCPrefix(data[i:])
114 if !completePrefix {156 if !completePrefix {
115 if possiblePrefix {157 if possiblePrefix {
@@ -183,6 +225,41 @@ func colorOSCPrefix(data []byte) (complete bool, possible bool) {
183 return false, false225 return false, false
184 }226 }
185 227
228+// schemeCSIPrefixes lists the CSI ?997 scheme-notification sequences vev
229+// recognizes. Package-level so schemeCSINotification (called per ESC byte in
230+// the stdin hot path) doesn't allocate on every call.
231+var schemeCSIPrefixes = []struct {
232+ seq []byte
233+ light bool
234+}{
235+ {seq: []byte(vt.ColorSchemeReportDark), light: false},
236+ {seq: []byte(vt.ColorSchemeReportLight), light: true},
237+}
238+
239+func schemeCSINotification(data []byte) (complete bool, possible bool, light bool) {
240+ // A bare trailing ESC (handled elsewhere) or "ESC [" (2 bytes) is
241+ // something a user can type; unlike OSC's "ESC ]" it must never be
242+ // withheld as a partial match with nothing following, or those keystrokes
243+ // would be buffered and never forwarded. Partial buffering only starts
244+ // from "ESC [ ?" onward; a complete match is always >= 9 bytes, so this
245+ // guard cannot reject one.
246+ if len(data) < 3 {
247+ return false, false, false
248+ }
249+ for _, prefix := range schemeCSIPrefixes {
250+ if len(data) >= len(prefix.seq) {
251+ if bytes.Equal(data[:len(prefix.seq)], prefix.seq) {
252+ return true, true, prefix.light
253+ }
254+ continue
255+ }
256+ if bytes.Equal(data, prefix.seq[:len(data)]) {
257+ return false, true, false
258+ }
259+ }
260+ return false, false, false
261+}
262+
186 func findTerminator(data []byte) (start int, end int) {263 func findTerminator(data []byte) (start int, end int) {
187 for i := range data {264 for i := range data {
188 switch data[i] {265 switch data[i] {
M internal/usecase/theme/theme_test.go +74 -10
@@ -2,6 +2,7 @@ package theme
22
33 import (
44 "bytes"
5+ "reflect"
56 "testing"
67
78 "github.com/bnema/vev/pkg/renderer"
@@ -48,7 +49,7 @@ func TestScannerExtractsBELAndSTColors(t *testing.T) {
4849 kind int
4950 rgb renderer.RGB
5051 }{kind: kind, rgb: rgb})
51- }, func(b []byte) { out.Write(b) })
52+ }, func(light bool) {}, func(b []byte) { out.Write(b) })
5253
5354 if out.String() != "abc" {
5455 t.Fatalf("passthrough=%q want %q", out.String(), "abc")
@@ -71,8 +72,8 @@ func TestScannerHandlesSplitSequences(t *testing.T) {
7172 onColor := func(kind int, rgb renderer.RGB) { got = append(got, rgb) }
7273 onBytes := func(b []byte) { out.Write(b) }
7374
74- s.Scan([]byte("x\x1b]10;#12"), onColor, onBytes)
75- s.Scan([]byte("3456\x07y"), onColor, onBytes)
75+ s.Scan([]byte("x\x1b]10;#12"), onColor, func(light bool) {}, onBytes)
76+ s.Scan([]byte("3456\x07y"), onColor, func(light bool) {}, onBytes)
7677
7778 if out.String() != "xy" {
7879 t.Fatalf("passthrough=%q want xy", out.String())
@@ -82,12 +83,75 @@ func TestScannerHandlesSplitSequences(t *testing.T) {
8283 }
8384 }
8485
86+func TestScannerHandlesSplitSchemeNotifications(t *testing.T) {
87+ tests := []struct {
88+ name string
89+ chunks []string
90+ wantOut string
91+ wantSchemes []bool
92+ }{
93+ {
94+ name: "split dark then complete light",
95+ chunks: []string{"a\x1b[?997", ";1nb\x1b[?997;2nc"},
96+ wantOut: "abc",
97+ wantSchemes: []bool{false, true},
98+ },
99+ {
100+ name: "split light",
101+ chunks: []string{"x\x1b[?997;", "2ny"},
102+ wantOut: "xy",
103+ wantSchemes: []bool{true},
104+ },
105+ }
106+
107+ for _, tt := range tests {
108+ t.Run(tt.name, func(t *testing.T) {
109+ var s Scanner
110+ var schemes []bool
111+ var out bytes.Buffer
112+
113+ for _, chunk := range tt.chunks {
114+ s.Scan([]byte(chunk), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) })
115+ }
116+
117+ if out.String() != tt.wantOut {
118+ t.Fatalf("passthrough=%q want %q", out.String(), tt.wantOut)
119+ }
120+ if !reflect.DeepEqual(schemes, tt.wantSchemes) {
121+ t.Fatalf("schemes=%v want %v", schemes, tt.wantSchemes)
122+ }
123+ })
124+ }
125+}
126+
127+func TestScannerForwardsBareCSIIntroducerImmediately(t *testing.T) {
128+ var s Scanner
129+ var out bytes.Buffer
130+ schemes := 0
131+
132+ s.Scan([]byte("a\x1b["), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) })
133+
134+ if out.String() != "a\x1b[" {
135+ t.Fatalf("passthrough=%q want %q", out.String(), "a\x1b[")
136+ }
137+ if schemes != 0 {
138+ t.Fatalf("schemes=%d want 0", schemes)
139+ }
140+
141+ out.Reset()
142+ s.Scan([]byte("A"), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) })
143+
144+ if out.String() != "A" {
145+ t.Fatalf("follow-up passthrough=%q want %q (no stale state)", out.String(), "A")
146+ }
147+}
148+
85149 func TestScannerForwardsInterleavedGarbageInOrder(t *testing.T) {
86150 var s Scanner
87151 var out bytes.Buffer
88152 colors := 0
89153
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) })
154+ 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) })
91155
92156 if colors != 0 {
93157 t.Fatalf("colors=%d want 0", colors)
@@ -103,7 +167,7 @@ func TestScannerForwardsStandaloneEscapeImmediately(t *testing.T) {
103167 var out bytes.Buffer
104168 colors := 0
105169
106- s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) { out.Write(b) })
170+ s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) { out.Write(b) })
107171
108172 if colors != 0 {
109173 t.Fatalf("colors=%d want 0", colors)
@@ -119,7 +183,7 @@ func TestScannerDoesNotSplitSGRMouseReport(t *testing.T) {
119183 colors := 0
120184
121185 in := []byte("\x1b[<0;1;1M")
122- s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) {
186+ s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) {
123187 chunks = append(chunks, append([]byte(nil), b...))
124188 })
125189
@@ -139,7 +203,7 @@ func TestScannerDoesNotSplitArrowKey(t *testing.T) {
139203 var chunks [][]byte
140204
141205 in := []byte("\x1b[A")
142- s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(b []byte) {
206+ s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) {
143207 chunks = append(chunks, append([]byte(nil), b...))
144208 })
145209
@@ -164,7 +228,7 @@ func TestScannerKeepsMouseReportContiguousAroundColorResponse(t *testing.T) {
164228 kind int
165229 rgb renderer.RGB
166230 }{kind: kind, rgb: rgb})
167- }, func(b []byte) {
231+ }, func(light bool) {}, func(b []byte) {
168232 chunks = append(chunks, append([]byte(nil), b...))
169233 })
170234
@@ -189,8 +253,8 @@ func TestScannerFlushesOverflowingPartialQueue(t *testing.T) {
189253 var s Scanner
190254 var out bytes.Buffer
191255
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) })
256+ 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) })
257+ s.Scan([]byte("Z"), func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
194258
195259 if out.Len() == 0 || !bytes.Contains(out.Bytes(), []byte("\x1b]10;")) || !bytes.HasSuffix(out.Bytes(), []byte("Z")) {
196260 t.Fatalf("overflow passthrough=%q", out.String())
@@ -2,6 +2,7 @@ package theme
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "reflect"
5 "testing"6 "testing"
6 7
7 "github.com/bnema/vev/pkg/renderer"8 "github.com/bnema/vev/pkg/renderer"
@@ -48,7 +49,7 @@ func TestScannerExtractsBELAndSTColors(t *testing.T) {
48 kind int49 kind int
49 rgb renderer.RGB50 rgb renderer.RGB
50 }{kind: kind, rgb: rgb})51 }{kind: kind, rgb: rgb})
51- }, func(b []byte) { out.Write(b) })52+ }, func(light bool) {}, func(b []byte) { out.Write(b) })
52 53
53 if out.String() != "abc" {54 if out.String() != "abc" {
54 t.Fatalf("passthrough=%q want %q", out.String(), "abc")55 t.Fatalf("passthrough=%q want %q", out.String(), "abc")
@@ -71,8 +72,8 @@ func TestScannerHandlesSplitSequences(t *testing.T) {
71 onColor := func(kind int, rgb renderer.RGB) { got = append(got, rgb) }72 onColor := func(kind int, rgb renderer.RGB) { got = append(got, rgb) }
72 onBytes := func(b []byte) { out.Write(b) }73 onBytes := func(b []byte) { out.Write(b) }
73 74
74- s.Scan([]byte("x\x1b]10;#12"), onColor, onBytes)75+ s.Scan([]byte("x\x1b]10;#12"), onColor, func(light bool) {}, onBytes)
75- s.Scan([]byte("3456\x07y"), onColor, onBytes)76+ s.Scan([]byte("3456\x07y"), onColor, func(light bool) {}, onBytes)
76 77
77 if out.String() != "xy" {78 if out.String() != "xy" {
78 t.Fatalf("passthrough=%q want xy", out.String())79 t.Fatalf("passthrough=%q want xy", out.String())
@@ -82,12 +83,75 @@ func TestScannerHandlesSplitSequences(t *testing.T) {
82 }83 }
83 }84 }
84 85
86+func TestScannerHandlesSplitSchemeNotifications(t *testing.T) {
87+ tests := []struct {
88+ name string
89+ chunks []string
90+ wantOut string
91+ wantSchemes []bool
92+ }{
93+ {
94+ name: "split dark then complete light",
95+ chunks: []string{"a\x1b[?997", ";1nb\x1b[?997;2nc"},
96+ wantOut: "abc",
97+ wantSchemes: []bool{false, true},
98+ },
99+ {
100+ name: "split light",
101+ chunks: []string{"x\x1b[?997;", "2ny"},
102+ wantOut: "xy",
103+ wantSchemes: []bool{true},
104+ },
105+ }
106+
107+ for _, tt := range tests {
108+ t.Run(tt.name, func(t *testing.T) {
109+ var s Scanner
110+ var schemes []bool
111+ var out bytes.Buffer
112+
113+ for _, chunk := range tt.chunks {
114+ s.Scan([]byte(chunk), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) })
115+ }
116+
117+ if out.String() != tt.wantOut {
118+ t.Fatalf("passthrough=%q want %q", out.String(), tt.wantOut)
119+ }
120+ if !reflect.DeepEqual(schemes, tt.wantSchemes) {
121+ t.Fatalf("schemes=%v want %v", schemes, tt.wantSchemes)
122+ }
123+ })
124+ }
125+}
126+
127+func TestScannerForwardsBareCSIIntroducerImmediately(t *testing.T) {
128+ var s Scanner
129+ var out bytes.Buffer
130+ schemes := 0
131+
132+ s.Scan([]byte("a\x1b["), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) })
133+
134+ if out.String() != "a\x1b[" {
135+ t.Fatalf("passthrough=%q want %q", out.String(), "a\x1b[")
136+ }
137+ if schemes != 0 {
138+ t.Fatalf("schemes=%d want 0", schemes)
139+ }
140+
141+ out.Reset()
142+ s.Scan([]byte("A"), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) })
143+
144+ if out.String() != "A" {
145+ t.Fatalf("follow-up passthrough=%q want %q (no stale state)", out.String(), "A")
146+ }
147+}
148+
85 func TestScannerForwardsInterleavedGarbageInOrder(t *testing.T) {149 func TestScannerForwardsInterleavedGarbageInOrder(t *testing.T) {
86 var s Scanner150 var s Scanner
87 var out bytes.Buffer151 var out bytes.Buffer
88 colors := 0152 colors := 0
89 153
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) })154+ 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 155
92 if colors != 0 {156 if colors != 0 {
93 t.Fatalf("colors=%d want 0", colors)157 t.Fatalf("colors=%d want 0", colors)
@@ -103,7 +167,7 @@ func TestScannerForwardsStandaloneEscapeImmediately(t *testing.T) {
103 var out bytes.Buffer167 var out bytes.Buffer
104 colors := 0168 colors := 0
105 169
106- s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) { out.Write(b) })170+ s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) { out.Write(b) })
107 171
108 if colors != 0 {172 if colors != 0 {
109 t.Fatalf("colors=%d want 0", colors)173 t.Fatalf("colors=%d want 0", colors)
@@ -119,7 +183,7 @@ func TestScannerDoesNotSplitSGRMouseReport(t *testing.T) {
119 colors := 0183 colors := 0
120 184
121 in := []byte("\x1b[<0;1;1M")185 in := []byte("\x1b[<0;1;1M")
122- s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) {186+ s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) {
123 chunks = append(chunks, append([]byte(nil), b...))187 chunks = append(chunks, append([]byte(nil), b...))
124 })188 })
125 189
@@ -139,7 +203,7 @@ func TestScannerDoesNotSplitArrowKey(t *testing.T) {
139 var chunks [][]byte203 var chunks [][]byte
140 204
141 in := []byte("\x1b[A")205 in := []byte("\x1b[A")
142- s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(b []byte) {206+ s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) {
143 chunks = append(chunks, append([]byte(nil), b...))207 chunks = append(chunks, append([]byte(nil), b...))
144 })208 })
145 209
@@ -164,7 +228,7 @@ func TestScannerKeepsMouseReportContiguousAroundColorResponse(t *testing.T) {
164 kind int228 kind int
165 rgb renderer.RGB229 rgb renderer.RGB
166 }{kind: kind, rgb: rgb})230 }{kind: kind, rgb: rgb})
167- }, func(b []byte) {231+ }, func(light bool) {}, func(b []byte) {
168 chunks = append(chunks, append([]byte(nil), b...))232 chunks = append(chunks, append([]byte(nil), b...))
169 })233 })
170 234
@@ -189,8 +253,8 @@ func TestScannerFlushesOverflowingPartialQueue(t *testing.T) {
189 var s Scanner253 var s Scanner
190 var out bytes.Buffer254 var out bytes.Buffer
191 255
192- s.Scan(append([]byte("\x1b]10;"), bytes.Repeat([]byte("a"), 70)...), func(kind int, rgb renderer.RGB) {}, func(b []byte) { out.Write(b) })256+ 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) })257+ s.Scan([]byte("Z"), func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
194 258
195 if out.Len() == 0 || !bytes.Contains(out.Bytes(), []byte("\x1b]10;")) || !bytes.HasSuffix(out.Bytes(), []byte("Z")) {259 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())260 t.Fatalf("overflow passthrough=%q", out.String())
M pkg/vt/query_test.go +75 -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: ColorSchemeReportDark},
29+ {name: "color scheme DSR light", input: "\x1b[?996n", want: ColorSchemeReportLight},
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,65 @@ 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 TestScreenResetClearsColorSchemeModeSubscription(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.Write([]byte("\x1b[?2031h"))
160+ s.Write([]byte("\x1bc"))
161+
162+ s.SetColorScheme(true)
163+ if got.Len() != 0 {
164+ t.Fatalf("unsolicited response after reset = %q, want silence", got.String())
165+ }
166+
167+ got.Reset()
168+ s.Write([]byte("\x1b[?2031$p"))
169+ if got.String() != "\x1b[?2031;2$y" {
170+ t.Fatalf("DECRQM 2031 after reset = %q, want reset state", got.String())
171+ }
172+}
173+
174+func TestScreenColorSchemeUnsolicitedOnlySubscribedAndChanged(t *testing.T) {
175+ s := NewScreen(80, 24)
176+ var got bytes.Buffer
177+ s.OnResponse = func(b []byte) { got.Write(b) }
178+
179+ s.SetColorScheme(true)
180+ if got.Len() != 0 {
181+ t.Fatalf("unsubscribed response = %q, want silence", got.String())
182+ }
183+
184+ s.Write([]byte("\x1b[?2031h"))
185+ if !s.ColorSchemeMode() {
186+ t.Fatalf("ColorSchemeMode() = false, want true")
187+ }
188+ s.SetColorScheme(true)
189+ if got.Len() != 0 {
190+ t.Fatalf("unchanged response = %q, want silence", got.String())
191+ }
192+ s.SetColorScheme(false)
193+ if got.String() != ColorSchemeReportDark {
194+ t.Fatalf("changed response = %q, want dark notification", got.String())
195+ }
196+ got.Reset()
197+ s.Write([]byte("\x1b[?2031l"))
198+ s.SetColorScheme(true)
199+ if got.Len() != 0 {
200+ t.Fatalf("disabled response = %q, want silence", got.String())
201+ }
202+}
@@ -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: ColorSchemeReportDark},
29+ {name: "color scheme DSR light", input: "\x1b[?996n", want: ColorSchemeReportLight},
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,65 @@ 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 TestScreenResetClearsColorSchemeModeSubscription(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.Write([]byte("\x1b[?2031h"))
160+ s.Write([]byte("\x1bc"))
161+
162+ s.SetColorScheme(true)
163+ if got.Len() != 0 {
164+ t.Fatalf("unsolicited response after reset = %q, want silence", got.String())
165+ }
166+
167+ got.Reset()
168+ s.Write([]byte("\x1b[?2031$p"))
169+ if got.String() != "\x1b[?2031;2$y" {
170+ t.Fatalf("DECRQM 2031 after reset = %q, want reset state", got.String())
171+ }
172+}
173+
174+func TestScreenColorSchemeUnsolicitedOnlySubscribedAndChanged(t *testing.T) {
175+ s := NewScreen(80, 24)
176+ var got bytes.Buffer
177+ s.OnResponse = func(b []byte) { got.Write(b) }
178+
179+ s.SetColorScheme(true)
180+ if got.Len() != 0 {
181+ t.Fatalf("unsubscribed response = %q, want silence", got.String())
182+ }
183+
184+ s.Write([]byte("\x1b[?2031h"))
185+ if !s.ColorSchemeMode() {
186+ t.Fatalf("ColorSchemeMode() = false, want true")
187+ }
188+ s.SetColorScheme(true)
189+ if got.Len() != 0 {
190+ t.Fatalf("unchanged response = %q, want silence", got.String())
191+ }
192+ s.SetColorScheme(false)
193+ if got.String() != ColorSchemeReportDark {
194+ t.Fatalf("changed response = %q, want dark notification", got.String())
195+ }
196+ got.Reset()
197+ s.Write([]byte("\x1b[?2031l"))
198+ s.SetColorScheme(true)
199+ if got.Len() != 0 {
200+ t.Fatalf("disabled response = %q, want silence", got.String())
201+ }
202+}
M pkg/vt/screen.go +59 -4
@@ -31,6 +31,13 @@ type screenState struct {
3131 // so keep this value in sync if that payload cap changes.
3232 const maxEscapeBufferLen = 128 * 1024
3333
34+const (
35+ // ColorSchemeReportDark is the DEC 2031 dark-scheme report.
36+ ColorSchemeReportDark = "\x1b[?997;1n"
37+ // ColorSchemeReportLight is the DEC 2031 light-scheme report.
38+ ColorSchemeReportLight = "\x1b[?997;2n"
39+)
40+
3441 const (
3542 progressStateClear = iota
3643 progressStateNormal
@@ -89,6 +96,9 @@ type Screen struct {
8996 mouseMode int
9097 mouseSGR bool
9198 bracketedPaste bool
99+ colorSchemeMode bool
100+ colorSchemeLight bool
101+ colorSchemeSet bool
92102 }
93103
94104 func NewScreen(width, height int) *Screen {
@@ -156,6 +166,27 @@ func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive }
156166 // by the child process.
157167 func (s *Screen) BracketedPasteMode() bool { return s.bracketedPaste }
158168
169+// ColorSchemeMode reports whether DEC private mode 2031 is currently enabled.
170+func (s *Screen) ColorSchemeMode() bool { return s.colorSchemeMode }
171+
172+// SetColorScheme updates the host color scheme and notifies subscribed child apps.
173+func (s *Screen) SetColorScheme(light bool) {
174+ if s.colorSchemeSet && s.colorSchemeLight == light {
175+ return
176+ }
177+ s.colorSchemeSet = true
178+ s.colorSchemeLight = light
179+ if s.colorSchemeMode {
180+ s.respond(s.colorSchemeReport())
181+ }
182+}
183+
184+// ClearColorScheme marks the host color scheme as unknown. Future child color
185+// scheme queries are silent until SetColorScheme supplies a known value again.
186+func (s *Screen) ClearColorScheme() {
187+ s.colorSchemeSet = false
188+}
189+
159190 // ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update).
160191 // Hosts use this as a safety valve if a child enters synchronized update mode
161192 // and never sends the matching end sequence.
@@ -480,6 +511,16 @@ func (s *Screen) respond(b []byte) {
480511 }
481512 }
482513
514+func (s *Screen) colorSchemeReport() []byte {
515+ if !s.colorSchemeSet {
516+ return nil
517+ }
518+ if s.colorSchemeLight {
519+ return []byte(ColorSchemeReportLight)
520+ }
521+ return []byte(ColorSchemeReportDark)
522+}
523+
483524 func (s *Screen) scrollDownRegion(top, bottom, n int) {
484525 if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {
485526 return
@@ -720,11 +761,10 @@ func (s *Screen) handleProgress(rest string) {
720761 // clipboard query (data == "?") is always ignored — vev never answers
721762 // clipboard queries. A payload with no second ";" is malformed and ignored.
722763 func (s *Screen) handleOSC52(rest string) {
723- idx := strings.IndexByte(rest, ';')
724- if idx < 0 {
764+ _, data, ok := strings.Cut(rest, ";")
765+ if !ok {
725766 return
726767 }
727- data := rest[idx+1:]
728768 if data == "?" {
729769 return
730770 }
@@ -759,6 +799,12 @@ func (s *Screen) applyCSI(params string, cmd byte) {
759799 if !private {
760800 s.respond([]byte("\x1b[0n"))
761801 }
802+ case 996:
803+ if private {
804+ if report := s.colorSchemeReport(); report != nil {
805+ s.respond(report)
806+ }
807+ }
762808 case 6:
763809 resp := make([]byte, 0, 16)
764810 resp = append(resp, "\x1b["...)
@@ -778,11 +824,17 @@ func (s *Screen) applyCSI(params string, cmd byte) {
778824 return
779825 }
780826 state := 0
781- if mode == 2026 {
827+ switch mode {
828+ case 2026:
782829 state = 2
783830 if s.syncUpdateActive {
784831 state = 1
785832 }
833+ case 2031:
834+ state = 2
835+ if s.colorSchemeMode {
836+ state = 1
837+ }
786838 }
787839 resp := make([]byte, 0, 16)
788840 resp = append(resp, "\x1b[?"...)
@@ -991,6 +1043,7 @@ func (s *Screen) reset() {
9911043 s.mouseMode = 0
9921044 s.mouseSGR = false
9931045 s.bracketedPaste = false
1046+ s.colorSchemeMode = false
9941047 s.resetScrollRegion()
9951048 s.fullRedraw()
9961049 }
@@ -1041,6 +1094,8 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) {
10411094 }
10421095 case 2026:
10431096 s.syncUpdateActive = enabled
1097+ case 2031:
1098+ s.colorSchemeMode = enabled
10441099 case 25:
10451100 s.cursorVisible = enabled
10461101 case 1000, 1002, 1003:
@@ -31,6 +31,13 @@ type screenState struct {
31 // so keep this value in sync if that payload cap changes.31 // so keep this value in sync if that payload cap changes.
32 const maxEscapeBufferLen = 128 * 102432 const maxEscapeBufferLen = 128 * 1024
33 33
34+const (
35+ // ColorSchemeReportDark is the DEC 2031 dark-scheme report.
36+ ColorSchemeReportDark = "\x1b[?997;1n"
37+ // ColorSchemeReportLight is the DEC 2031 light-scheme report.
38+ ColorSchemeReportLight = "\x1b[?997;2n"
39+)
40+
34 const (41 const (
35 progressStateClear = iota42 progressStateClear = iota
36 progressStateNormal43 progressStateNormal
@@ -89,6 +96,9 @@ type Screen struct {
89 mouseMode int96 mouseMode int
90 mouseSGR bool97 mouseSGR bool
91 bracketedPaste bool98 bracketedPaste bool
99+ colorSchemeMode bool
100+ colorSchemeLight bool
101+ colorSchemeSet bool
92 }102 }
93 103
94 func NewScreen(width, height int) *Screen {104 func NewScreen(width, height int) *Screen {
@@ -156,6 +166,27 @@ func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive }
156 // by the child process.166 // by the child process.
157 func (s *Screen) BracketedPasteMode() bool { return s.bracketedPaste }167 func (s *Screen) BracketedPasteMode() bool { return s.bracketedPaste }
158 168
169+// ColorSchemeMode reports whether DEC private mode 2031 is currently enabled.
170+func (s *Screen) ColorSchemeMode() bool { return s.colorSchemeMode }
171+
172+// SetColorScheme updates the host color scheme and notifies subscribed child apps.
173+func (s *Screen) SetColorScheme(light bool) {
174+ if s.colorSchemeSet && s.colorSchemeLight == light {
175+ return
176+ }
177+ s.colorSchemeSet = true
178+ s.colorSchemeLight = light
179+ if s.colorSchemeMode {
180+ s.respond(s.colorSchemeReport())
181+ }
182+}
183+
184+// ClearColorScheme marks the host color scheme as unknown. Future child color
185+// scheme queries are silent until SetColorScheme supplies a known value again.
186+func (s *Screen) ClearColorScheme() {
187+ s.colorSchemeSet = false
188+}
189+
159 // ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update).190 // ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update).
160 // Hosts use this as a safety valve if a child enters synchronized update mode191 // Hosts use this as a safety valve if a child enters synchronized update mode
161 // and never sends the matching end sequence.192 // and never sends the matching end sequence.
@@ -480,6 +511,16 @@ func (s *Screen) respond(b []byte) {
480 }511 }
481 }512 }
482 513
514+func (s *Screen) colorSchemeReport() []byte {
515+ if !s.colorSchemeSet {
516+ return nil
517+ }
518+ if s.colorSchemeLight {
519+ return []byte(ColorSchemeReportLight)
520+ }
521+ return []byte(ColorSchemeReportDark)
522+}
523+
483 func (s *Screen) scrollDownRegion(top, bottom, n int) {524 func (s *Screen) scrollDownRegion(top, bottom, n int) {
484 if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {525 if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {
485 return526 return
@@ -720,11 +761,10 @@ func (s *Screen) handleProgress(rest string) {
720 // clipboard query (data == "?") is always ignored — vev never answers761 // clipboard query (data == "?") is always ignored — vev never answers
721 // clipboard queries. A payload with no second ";" is malformed and ignored.762 // clipboard queries. A payload with no second ";" is malformed and ignored.
722 func (s *Screen) handleOSC52(rest string) {763 func (s *Screen) handleOSC52(rest string) {
723- idx := strings.IndexByte(rest, ';')764+ _, data, ok := strings.Cut(rest, ";")
724- if idx < 0 {765+ if !ok {
725 return766 return
726 }767 }
727- data := rest[idx+1:]
728 if data == "?" {768 if data == "?" {
729 return769 return
730 }770 }
@@ -759,6 +799,12 @@ func (s *Screen) applyCSI(params string, cmd byte) {
759 if !private {799 if !private {
760 s.respond([]byte("\x1b[0n"))800 s.respond([]byte("\x1b[0n"))
761 }801 }
802+ case 996:
803+ if private {
804+ if report := s.colorSchemeReport(); report != nil {
805+ s.respond(report)
806+ }
807+ }
762 case 6:808 case 6:
763 resp := make([]byte, 0, 16)809 resp := make([]byte, 0, 16)
764 resp = append(resp, "\x1b["...)810 resp = append(resp, "\x1b["...)
@@ -778,11 +824,17 @@ func (s *Screen) applyCSI(params string, cmd byte) {
778 return824 return
779 }825 }
780 state := 0826 state := 0
781- if mode == 2026 {827+ switch mode {
828+ case 2026:
782 state = 2829 state = 2
783 if s.syncUpdateActive {830 if s.syncUpdateActive {
784 state = 1831 state = 1
785 }832 }
833+ case 2031:
834+ state = 2
835+ if s.colorSchemeMode {
836+ state = 1
837+ }
786 }838 }
787 resp := make([]byte, 0, 16)839 resp := make([]byte, 0, 16)
788 resp = append(resp, "\x1b[?"...)840 resp = append(resp, "\x1b[?"...)
@@ -991,6 +1043,7 @@ func (s *Screen) reset() {
991 s.mouseMode = 01043 s.mouseMode = 0
992 s.mouseSGR = false1044 s.mouseSGR = false
993 s.bracketedPaste = false1045 s.bracketedPaste = false
1046+ s.colorSchemeMode = false
994 s.resetScrollRegion()1047 s.resetScrollRegion()
995 s.fullRedraw()1048 s.fullRedraw()
996 }1049 }
@@ -1041,6 +1094,8 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) {
1041 }1094 }
1042 case 2026:1095 case 2026:
1043 s.syncUpdateActive = enabled1096 s.syncUpdateActive = enabled
1097+ case 2031:
1098+ s.colorSchemeMode = enabled
1044 case 25:1099 case 25:
1045 s.cursorVisible = enabled1100 s.cursorVisible = enabled
1046 case 1000, 1002, 1003:1101 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