detached fead047

fix: address review findings on config live reload and theme propagation

- fix race between the stdin pump's OSC color re-query and the main
  loop's terminal writes by routing QueryColors through the main loop
- fix theme scanner buffering a bare ESC [ input forever instead of
  forwarding it immediately
- fix command code override resolution depending on slug sort order by
  resolving to a duplicate-free fixpoint
- fix a replacement client inheriting the previous client's stale color
  scheme by always clearing it on attach
- clear vt screen's DEC mode 2031 subscription on RIS (reset)
- make keys.NewRouter's bindings pointer an explicit parameter instead
  of a variadic
- remove the dead Daemon.commandByCode indirection seam
- make domain.Config.BindingEntries the single source of truth for
  bindings, dropping the redundant Bindings map
commit
fead0479a120719d19aaa142decd1a22a8581e12
author
brice · · 2026-07-05 17:22 UTC
parent
dc993a0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
19 files · +237 -111
M internal/adapters/config/config.go +3 -2
@@ -22,6 +22,7 @@ const pollInterval = 2 * time.Second
2222 func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
2323 cfg := domain.Defaults()
2424 var warnings []domain.Warning
25+ seenBindingKeys := make(map[string]bool)
2526
2627 scanner := bufio.NewScanner(r)
2728 lineNo := 0
@@ -67,13 +68,13 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
6768 }
6869 cfg.Codes[codeKey] = value
6970 default:
70- if _, exists := cfg.Bindings[key]; exists {
71+ if seenBindingKeys[key] {
7172 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
7273 updateBindingEntry(cfg.BindingEntries, key, value)
7374 } else {
75+ seenBindingKeys[key] = true
7476 cfg.BindingEntries = append(cfg.BindingEntries, domain.ConfigEntry{Key: key, Value: value})
7577 }
76- cfg.Bindings[key] = value
7778 }
7879 }
7980 if err := scanner.Err(); err != nil {
@@ -22,6 +22,7 @@ const pollInterval = 2 * time.Second
22 func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {22 func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
23 cfg := domain.Defaults()23 cfg := domain.Defaults()
24 var warnings []domain.Warning24 var warnings []domain.Warning
25+ seenBindingKeys := make(map[string]bool)
25 26
26 scanner := bufio.NewScanner(r)27 scanner := bufio.NewScanner(r)
27 lineNo := 028 lineNo := 0
@@ -67,13 +68,13 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
67 }68 }
68 cfg.Codes[codeKey] = value69 cfg.Codes[codeKey] = value
69 default:70 default:
70- if _, exists := cfg.Bindings[key]; exists {71+ if seenBindingKeys[key] {
71 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})72 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
72 updateBindingEntry(cfg.BindingEntries, key, value)73 updateBindingEntry(cfg.BindingEntries, key, value)
73 } else {74 } else {
75+ seenBindingKeys[key] = true
74 cfg.BindingEntries = append(cfg.BindingEntries, domain.ConfigEntry{Key: key, Value: value})76 cfg.BindingEntries = append(cfg.BindingEntries, domain.ConfigEntry{Key: key, Value: value})
75 }77 }
76- cfg.Bindings[key] = value
77 }78 }
78 }79 }
79 if err := scanner.Err(); err != nil {80 if err := scanner.Err(); err != nil {
M internal/adapters/config/config_test.go +0 -9
@@ -30,10 +30,6 @@ func TestParse(t *testing.T) {
3030 input: "# full line\nnew-tab = alt+t # open tab\ndetach = alt+#\n",
3131 want: domain.Config{
3232 Theme: domain.ThemeAuto,
33- Bindings: map[string]string{
34- "new-tab": "alt+t",
35- "detach": "alt+#",
36- },
3733 BindingEntries: []domain.ConfigEntry{
3834 {Key: "new-tab", Value: "alt+t"},
3935 {Key: "detach", Value: "alt+#"},
@@ -46,7 +42,6 @@ func TestParse(t *testing.T) {
4642 input: "theme = light\r\ncode.detach = dt\r\n",
4743 want: domain.Config{
4844 Theme: domain.ThemeLight,
49- Bindings: map[string]string{},
5045 BindingEntries: []domain.ConfigEntry{},
5146 Codes: map[string]string{"detach": "dt"},
5247 },
@@ -56,7 +51,6 @@ func TestParse(t *testing.T) {
5651 input: "theme = blue\n",
5752 want: domain.Config{
5853 Theme: domain.ThemeAuto,
59- Bindings: map[string]string{},
6054 BindingEntries: []domain.ConfigEntry{},
6155 Codes: map[string]string{},
6256 },
@@ -67,7 +61,6 @@ func TestParse(t *testing.T) {
6761 input: "unknown.action = alt+x\n",
6862 want: domain.Config{
6963 Theme: domain.ThemeAuto,
70- Bindings: map[string]string{"unknown.action": "alt+x"},
7164 BindingEntries: []domain.ConfigEntry{{Key: "unknown.action", Value: "alt+x"}},
7265 Codes: map[string]string{},
7366 },
@@ -77,7 +70,6 @@ func TestParse(t *testing.T) {
7770 input: "new-tab = alt+t\nnew-tab = alt+n\ncode.detach = DT\ncode.detach = DX\n",
7871 want: domain.Config{
7972 Theme: domain.ThemeAuto,
80- Bindings: map[string]string{"new-tab": "alt+n"},
8173 BindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+n"}},
8274 Codes: map[string]string{"detach": "DX"},
8375 },
@@ -91,7 +83,6 @@ func TestParse(t *testing.T) {
9183 input: "\nno equals\n = value\ntheme = dark\n",
9284 want: domain.Config{
9385 Theme: domain.ThemeDark,
94- Bindings: map[string]string{},
9586 BindingEntries: []domain.ConfigEntry{},
9687 Codes: map[string]string{},
9788 },
@@ -30,10 +30,6 @@ func TestParse(t *testing.T) {
30 input: "# full line\nnew-tab = alt+t # open tab\ndetach = alt+#\n",30 input: "# full line\nnew-tab = alt+t # open tab\ndetach = alt+#\n",
31 want: domain.Config{31 want: domain.Config{
32 Theme: domain.ThemeAuto,32 Theme: domain.ThemeAuto,
33- Bindings: map[string]string{
34- "new-tab": "alt+t",
35- "detach": "alt+#",
36- },
37 BindingEntries: []domain.ConfigEntry{33 BindingEntries: []domain.ConfigEntry{
38 {Key: "new-tab", Value: "alt+t"},34 {Key: "new-tab", Value: "alt+t"},
39 {Key: "detach", Value: "alt+#"},35 {Key: "detach", Value: "alt+#"},
@@ -46,7 +42,6 @@ func TestParse(t *testing.T) {
46 input: "theme = light\r\ncode.detach = dt\r\n",42 input: "theme = light\r\ncode.detach = dt\r\n",
47 want: domain.Config{43 want: domain.Config{
48 Theme: domain.ThemeLight,44 Theme: domain.ThemeLight,
49- Bindings: map[string]string{},
50 BindingEntries: []domain.ConfigEntry{},45 BindingEntries: []domain.ConfigEntry{},
51 Codes: map[string]string{"detach": "dt"},46 Codes: map[string]string{"detach": "dt"},
52 },47 },
@@ -56,7 +51,6 @@ func TestParse(t *testing.T) {
56 input: "theme = blue\n",51 input: "theme = blue\n",
57 want: domain.Config{52 want: domain.Config{
58 Theme: domain.ThemeAuto,53 Theme: domain.ThemeAuto,
59- Bindings: map[string]string{},
60 BindingEntries: []domain.ConfigEntry{},54 BindingEntries: []domain.ConfigEntry{},
61 Codes: map[string]string{},55 Codes: map[string]string{},
62 },56 },
@@ -67,7 +61,6 @@ func TestParse(t *testing.T) {
67 input: "unknown.action = alt+x\n",61 input: "unknown.action = alt+x\n",
68 want: domain.Config{62 want: domain.Config{
69 Theme: domain.ThemeAuto,63 Theme: domain.ThemeAuto,
70- Bindings: map[string]string{"unknown.action": "alt+x"},
71 BindingEntries: []domain.ConfigEntry{{Key: "unknown.action", Value: "alt+x"}},64 BindingEntries: []domain.ConfigEntry{{Key: "unknown.action", Value: "alt+x"}},
72 Codes: map[string]string{},65 Codes: map[string]string{},
73 },66 },
@@ -77,7 +70,6 @@ func TestParse(t *testing.T) {
77 input: "new-tab = alt+t\nnew-tab = alt+n\ncode.detach = DT\ncode.detach = DX\n",70 input: "new-tab = alt+t\nnew-tab = alt+n\ncode.detach = DT\ncode.detach = DX\n",
78 want: domain.Config{71 want: domain.Config{
79 Theme: domain.ThemeAuto,72 Theme: domain.ThemeAuto,
80- Bindings: map[string]string{"new-tab": "alt+n"},
81 BindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+n"}},73 BindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+n"}},
82 Codes: map[string]string{"detach": "DX"},74 Codes: map[string]string{"detach": "DX"},
83 },75 },
@@ -91,7 +83,6 @@ func TestParse(t *testing.T) {
91 input: "\nno equals\n = value\ntheme = dark\n",83 input: "\nno equals\n = value\ntheme = dark\n",
92 want: domain.Config{84 want: domain.Config{
93 Theme: domain.ThemeDark,85 Theme: domain.ThemeDark,
94- Bindings: map[string]string{},
95 BindingEntries: []domain.ConfigEntry{},86 BindingEntries: []domain.ConfigEntry{},
96 Codes: map[string]string{},87 Codes: map[string]string{},
97 },88 },
M internal/domain/config.go +2 -4
@@ -19,11 +19,10 @@ type ConfigEntry struct {
1919 }
2020
2121 // Config is the user-editable vev configuration after parsing. Unknown binding
22-// keys are preserved here so the usecase layer can decide which actions it
23-// understands.
22+// keys are preserved here (in BindingEntries, in file order) so the usecase
23+// layer can decide which actions it understands.
2424 type Config struct {
2525 Theme ThemeMode
26- Bindings map[string]string
2726 BindingEntries []ConfigEntry
2827 Codes map[string]string
2928 }
@@ -39,7 +38,6 @@ type Warning struct {
3938 func Defaults() Config {
4039 return Config{
4140 Theme: ThemeAuto,
42- Bindings: map[string]string{},
4341 BindingEntries: []ConfigEntry{},
4442 Codes: map[string]string{},
4543 }
@@ -19,11 +19,10 @@ type ConfigEntry struct {
19 }19 }
20 20
21 // Config is the user-editable vev configuration after parsing. Unknown binding21 // Config is the user-editable vev configuration after parsing. Unknown binding
22-// keys are preserved here so the usecase layer can decide which actions it22+// keys are preserved here (in BindingEntries, in file order) so the usecase
23-// understands.23+// layer can decide which actions it understands.
24 type Config struct {24 type Config struct {
25 Theme ThemeMode25 Theme ThemeMode
26- Bindings map[string]string
27 BindingEntries []ConfigEntry26 BindingEntries []ConfigEntry
28 Codes map[string]string27 Codes map[string]string
29 }28 }
@@ -39,7 +38,6 @@ type Warning struct {
39 func Defaults() Config {38 func Defaults() Config {
40 return Config{39 return Config{
41 Theme: ThemeAuto,40 Theme: ThemeAuto,
42- Bindings: map[string]string{},
43 BindingEntries: []ConfigEntry{},41 BindingEntries: []ConfigEntry{},
44 Codes: map[string]string{},42 Codes: map[string]string{},
45 }43 }
M internal/usecase/client/client.go +21 -8
@@ -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, term.QueryColors, 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, queryColors func() error, 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
@@ -540,12 +557,8 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
540557 }, func(light bool) {
541558 current.SchemeKnown = true
542559 current.Light = light
543- if queryColors != nil {
544- go func() {
545- if err := queryColors(); err != nil {
546- log.Warn("querying terminal colors", "err", err)
547- }
548- }()
560+ if requestColors != nil {
561+ requestColors()
549562 }
550563 sendTheme()
551564 }, sink)
@@ -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, term.QueryColors, 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, queryColors func() error, 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
@@ -540,12 +557,8 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
540 }, func(light bool) {557 }, func(light bool) {
541 current.SchemeKnown = true558 current.SchemeKnown = true
542 current.Light = light559 current.Light = light
543- if queryColors != nil {560+ if requestColors != nil {
544- go func() {561+ requestColors()
545- if err := queryColors(); err != nil {
546- log.Warn("querying terminal colors", "err", err)
547- }
548- }()
549 }562 }
550 sendTheme()563 sendTheme()
551 }, sink)564 }, sink)
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 +3 -3
@@ -341,7 +341,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
341341 sess.mu.Unlock()
342342 d.touchMRU(sess)
343343 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
344- d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}))
344+ d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)
345345 return ac, old
346346 }
347347
@@ -357,7 +357,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
357357 // (and run its own attach-time reset), so this call must leave the tabs
358358 // alone rather than clobbering that client's freshly applied colors.
359359 func (d *Daemon) resetScreenDefaultColors(sess *session) {
360- d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{}))
360+ d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{}), true)
361361 }
362362
363363 func (d *Daemon) detachReplacedClient(old *attachedClient) {
@@ -508,7 +508,7 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
508508 ac.setClientTheme(clientTheme)
509509 t := d.effectiveTheme(clientTheme)
510510
511- if !d.applyHostTheme(sess, ac, t) {
511+ if !d.applyHostTheme(sess, ac, t, false) {
512512 return
513513 }
514514 d.paint(sess, ac, true)
@@ -341,7 +341,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
341 sess.mu.Unlock()341 sess.mu.Unlock()
342 d.touchMRU(sess)342 d.touchMRU(sess)
343 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)343 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
344- d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}))344+ d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)
345 return ac, old345 return ac, old
346 }346 }
347 347
@@ -357,7 +357,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
357 // (and run its own attach-time reset), so this call must leave the tabs357 // (and run its own attach-time reset), so this call must leave the tabs
358 // alone rather than clobbering that client's freshly applied colors.358 // alone rather than clobbering that client's freshly applied colors.
359 func (d *Daemon) resetScreenDefaultColors(sess *session) {359 func (d *Daemon) resetScreenDefaultColors(sess *session) {
360- d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{}))360+ d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{}), true)
361 }361 }
362 362
363 func (d *Daemon) detachReplacedClient(old *attachedClient) {363 func (d *Daemon) detachReplacedClient(old *attachedClient) {
@@ -508,7 +508,7 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
508 ac.setClientTheme(clientTheme)508 ac.setClientTheme(clientTheme)
509 t := d.effectiveTheme(clientTheme)509 t := d.effectiveTheme(clientTheme)
510 510
511- if !d.applyHostTheme(sess, ac, t) {511+ if !d.applyHostTheme(sess, ac, t, false) {
512 return512 return
513 }513 }
514 d.paint(sess, ac, true)514 d.paint(sess, ac, true)
M internal/usecase/daemon/config.go +58 -21
@@ -17,9 +17,6 @@ var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`)
1717 // ApplyConfig validates and atomically swaps daemon runtime configuration.
1818 func (d *Daemon) ApplyConfig(cfg domain.Config) {
1919 bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries)
20- if len(cfg.BindingEntries) == 0 && len(cfg.Bindings) > 0 {
21- bindings, warnings = keys.BuildBindings(cfg.Bindings)
22- }
2320 for _, warning := range warnings {
2421 d.logConfigWarning(warning)
2522 }
@@ -52,28 +49,68 @@ func (d *Daemon) buildCodeOverrides(configured map[string]string) map[string]str
5249 desired[slug] = code
5350 }
5451
55- used := make(map[string]string, len(commands))
56- for _, cmd := range commands {
57- used[cmd.Code] = cmd.Slug
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
5867 }
59- overrides := make(map[string]string)
60- for _, slug := range sortedKeys(desired) {
61- code := desired[slug]
62- fallback := bySlug[slug].Code
63- delete(used, fallback)
64- if existing, ok := used[code]; ok && existing != slug {
65- d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("command code %q for %q conflicts with %q", code, slug, existing)})
66- if fallbackOwner, fallbackUsed := used[fallback]; fallbackUsed && fallbackOwner != slug {
67- d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("default command code %q for %q conflicts with %q", fallback, slug, fallbackOwner)})
68+
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 {
6879 continue
6980 }
70- used[fallback] = slug
71- continue
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
72111 }
73- used[code] = slug
74- overrides[slug] = code
75112 }
76- return overrides
113+ return accepted
77114 }
78115
79116 func sortedKeys(values map[string]string) []string {
@@ -147,6 +184,6 @@ func (d *Daemon) reapplyThemeAllSessions() {
147184 if ac != nil {
148185 clientTheme = ac.getClientTheme()
149186 }
150- d.applyHostTheme(sess, ac, d.effectiveTheme(clientTheme))
187+ d.applyHostTheme(sess, ac, d.effectiveTheme(clientTheme), ac == nil)
151188 }
152189 }
@@ -17,9 +17,6 @@ var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`)
17 // ApplyConfig validates and atomically swaps daemon runtime configuration.17 // ApplyConfig validates and atomically swaps daemon runtime configuration.
18 func (d *Daemon) ApplyConfig(cfg domain.Config) {18 func (d *Daemon) ApplyConfig(cfg domain.Config) {
19 bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries)19 bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries)
20- if len(cfg.BindingEntries) == 0 && len(cfg.Bindings) > 0 {
21- bindings, warnings = keys.BuildBindings(cfg.Bindings)
22- }
23 for _, warning := range warnings {20 for _, warning := range warnings {
24 d.logConfigWarning(warning)21 d.logConfigWarning(warning)
25 }22 }
@@ -52,28 +49,68 @@ func (d *Daemon) buildCodeOverrides(configured map[string]string) map[string]str
52 desired[slug] = code49 desired[slug] = code
53 }50 }
54 51
55- used := make(map[string]string, len(commands))52+ // The effective code for a slug is its accepted override if it has one,
56- for _, cmd := range commands {53+ // or its registry default otherwise. Resolve to a fixpoint rather than a
57- used[cmd.Code] = cmd.Slug54+ // 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
58 }67 }
59- overrides := make(map[string]string)68+
60- for _, slug := range sortedKeys(desired) {69+ for {
61- code := desired[slug]70+ claimants := make(map[string][]string, len(commands))
62- fallback := bySlug[slug].Code71+ for _, cmd := range commands {
63- delete(used, fallback)72+ code := effectiveCode(cmd.Slug)
64- if existing, ok := used[code]; ok && existing != slug {73+ claimants[code] = append(claimants[code], cmd.Slug)
65- d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("command code %q for %q conflicts with %q", code, slug, existing)})74+ }
66- if fallbackOwner, fallbackUsed := used[fallback]; fallbackUsed && fallbackOwner != slug {75+
67- d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("default command code %q for %q conflicts with %q", fallback, slug, fallbackOwner)})76+ dropped := false
77+ for code, slugs := range claimants {
78+ if len(slugs) < 2 {
68 continue79 continue
69 }80 }
70- used[fallback] = slug81+ sort.Strings(slugs)
71- continue82+
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
72 }111 }
73- used[code] = slug
74- overrides[slug] = code
75 }112 }
76- return overrides113+ return accepted
77 }114 }
78 115
79 func sortedKeys(values map[string]string) []string {116 func sortedKeys(values map[string]string) []string {
@@ -147,6 +184,6 @@ func (d *Daemon) reapplyThemeAllSessions() {
147 if ac != nil {184 if ac != nil {
148 clientTheme = ac.getClientTheme()185 clientTheme = ac.getClientTheme()
149 }186 }
150- d.applyHostTheme(sess, ac, d.effectiveTheme(clientTheme))187+ d.applyHostTheme(sess, ac, d.effectiveTheme(clientTheme), ac == nil)
151 }188 }
152 }189 }
M internal/usecase/daemon/config_test.go +31 -9
@@ -29,8 +29,8 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
2929
3030 d.ApplyConfig(domain.Config{
3131 Theme: domain.ThemeAuto,
32- Bindings: map[string]string{
33- "focus-pane-left": "alt+q",
32+ BindingEntries: []domain.ConfigEntry{
33+ {Key: "focus-pane-left", Value: "alt+q"},
3434 },
3535 Codes: map[string]string{
3636 "new-tab": "nt",
@@ -43,7 +43,7 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
4343 r.Route([]byte("\x1bq"))
4444 require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft, keys.ActionFocusPaneLeft}, h.actions)
4545
46- cmd, ok := d.commandByCode("NT")
46+ cmd, ok := d.commandByEffectiveCode("NT")
4747 require.True(t, ok)
4848 require.Equal(t, "new-tab", cmd.Slug)
4949 require.Equal(t, "NT", cmd.Code)
@@ -63,7 +63,7 @@ func TestPaletteCommandsApplyOverridesAndResolveByCode(t *testing.T) {
6363 require.Equal(t, "new-tab", commands[0].Slug)
6464 require.Equal(t, "NN", commands[0].Code)
6565
66- cmd, ok := d.commandByCode("nn")
66+ cmd, ok := d.commandByEffectiveCode("nn")
6767 require.True(t, ok)
6868 require.Equal(t, "new-tab", cmd.Slug)
6969 require.Equal(t, "NN", cmd.Code)
@@ -117,14 +117,36 @@ func TestCodeOverrideConflictRestoresDefaultWithoutDuplicates(t *testing.T) {
117117 require.Equal(t, "detach", byCode["DET"])
118118 }
119119
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+
120142 func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) {
121143 d := newTestDaemon(t, nil, stubClock{})
122144 require.NotPanics(t, func() {
123145 d.ApplyConfig(domain.Config{
124146 Theme: domain.ThemeAuto,
125- Bindings: map[string]string{
126- "unknown-action": "alt+x",
127- "focus-pane-left": "ctrl+x",
147+ BindingEntries: []domain.ConfigEntry{
148+ {Key: "unknown-action", Value: "alt+x"},
149+ {Key: "focus-pane-left", Value: "ctrl+x"},
128150 },
129151 Codes: map[string]string{
130152 "new-tab": "toolong",
@@ -134,9 +156,9 @@ func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) {
134156 })
135157 })
136158
137- cmd, ok := d.commandByCode("CNT")
159+ cmd, ok := d.commandByEffectiveCode("CNT")
138160 require.True(t, ok)
139161 require.Equal(t, "new-tab", cmd.Slug)
140- _, ok = d.commandByCode("TOOLONG")
162+ _, ok = d.commandByEffectiveCode("TOOLONG")
141163 require.False(t, ok)
142164 }
@@ -29,8 +29,8 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
29 29
30 d.ApplyConfig(domain.Config{30 d.ApplyConfig(domain.Config{
31 Theme: domain.ThemeAuto,31 Theme: domain.ThemeAuto,
32- Bindings: map[string]string{32+ BindingEntries: []domain.ConfigEntry{
33- "focus-pane-left": "alt+q",33+ {Key: "focus-pane-left", Value: "alt+q"},
34 },34 },
35 Codes: map[string]string{35 Codes: map[string]string{
36 "new-tab": "nt",36 "new-tab": "nt",
@@ -43,7 +43,7 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
43 r.Route([]byte("\x1bq"))43 r.Route([]byte("\x1bq"))
44 require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft, keys.ActionFocusPaneLeft}, h.actions)44 require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft, keys.ActionFocusPaneLeft}, h.actions)
45 45
46- cmd, ok := d.commandByCode("NT")46+ cmd, ok := d.commandByEffectiveCode("NT")
47 require.True(t, ok)47 require.True(t, ok)
48 require.Equal(t, "new-tab", cmd.Slug)48 require.Equal(t, "new-tab", cmd.Slug)
49 require.Equal(t, "NT", cmd.Code)49 require.Equal(t, "NT", cmd.Code)
@@ -63,7 +63,7 @@ func TestPaletteCommandsApplyOverridesAndResolveByCode(t *testing.T) {
63 require.Equal(t, "new-tab", commands[0].Slug)63 require.Equal(t, "new-tab", commands[0].Slug)
64 require.Equal(t, "NN", commands[0].Code)64 require.Equal(t, "NN", commands[0].Code)
65 65
66- cmd, ok := d.commandByCode("nn")66+ cmd, ok := d.commandByEffectiveCode("nn")
67 require.True(t, ok)67 require.True(t, ok)
68 require.Equal(t, "new-tab", cmd.Slug)68 require.Equal(t, "new-tab", cmd.Slug)
69 require.Equal(t, "NN", cmd.Code)69 require.Equal(t, "NN", cmd.Code)
@@ -117,14 +117,36 @@ func TestCodeOverrideConflictRestoresDefaultWithoutDuplicates(t *testing.T) {
117 require.Equal(t, "detach", byCode["DET"])117 require.Equal(t, "detach", byCode["DET"])
118 }118 }
119 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+
120 func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) {142 func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) {
121 d := newTestDaemon(t, nil, stubClock{})143 d := newTestDaemon(t, nil, stubClock{})
122 require.NotPanics(t, func() {144 require.NotPanics(t, func() {
123 d.ApplyConfig(domain.Config{145 d.ApplyConfig(domain.Config{
124 Theme: domain.ThemeAuto,146 Theme: domain.ThemeAuto,
125- Bindings: map[string]string{147+ BindingEntries: []domain.ConfigEntry{
126- "unknown-action": "alt+x",148+ {Key: "unknown-action", Value: "alt+x"},
127- "focus-pane-left": "ctrl+x",149+ {Key: "focus-pane-left", Value: "ctrl+x"},
128 },150 },
129 Codes: map[string]string{151 Codes: map[string]string{
130 "new-tab": "toolong",152 "new-tab": "toolong",
@@ -134,9 +156,9 @@ func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) {
134 })156 })
135 })157 })
136 158
137- cmd, ok := d.commandByCode("CNT")159+ cmd, ok := d.commandByEffectiveCode("CNT")
138 require.True(t, ok)160 require.True(t, ok)
139 require.Equal(t, "new-tab", cmd.Slug)161 require.Equal(t, "new-tab", cmd.Slug)
140- _, ok = d.commandByCode("TOOLONG")162+ _, ok = d.commandByEffectiveCode("TOOLONG")
141 require.False(t, ok)163 require.False(t, ok)
142 }164 }
M internal/usecase/daemon/daemon.go +0 -5
@@ -38,7 +38,6 @@ import (
3838 "github.com/bnema/vev/internal/persist"
3939 "github.com/bnema/vev/internal/platform"
4040 "github.com/bnema/vev/internal/ports"
41- "github.com/bnema/vev/internal/usecase/command"
4241 "github.com/bnema/vev/internal/usecase/keys"
4342 )
4443
@@ -107,7 +106,6 @@ type Daemon struct {
107106 persistEnabled bool
108107 procCwd func(int) (string, error)
109108 procComm func(int) (string, error)
110- commandByCode func(string) (command.Command, bool)
111109 bindings atomic.Pointer[keys.Bindings]
112110 codeOverrides atomic.Pointer[map[string]string]
113111 themeMode atomic.Uint32
@@ -231,9 +229,6 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
231229 if d.procComm == nil {
232230 d.procComm = platform.ProcessComm
233231 }
234- if d.commandByCode == nil {
235- d.commandByCode = d.commandByEffectiveCode
236- }
237232 if d.bindings.Load() == nil {
238233 d.bindings.Store(keys.DefaultBindings())
239234 }
@@ -38,7 +38,6 @@ import (
38 "github.com/bnema/vev/internal/persist"38 "github.com/bnema/vev/internal/persist"
39 "github.com/bnema/vev/internal/platform"39 "github.com/bnema/vev/internal/platform"
40 "github.com/bnema/vev/internal/ports"40 "github.com/bnema/vev/internal/ports"
41- "github.com/bnema/vev/internal/usecase/command"
42 "github.com/bnema/vev/internal/usecase/keys"41 "github.com/bnema/vev/internal/usecase/keys"
43 )42 )
44 43
@@ -107,7 +106,6 @@ type Daemon struct {
107 persistEnabled bool106 persistEnabled bool
108 procCwd func(int) (string, error)107 procCwd func(int) (string, error)
109 procComm func(int) (string, error)108 procComm func(int) (string, error)
110- commandByCode func(string) (command.Command, bool)
111 bindings atomic.Pointer[keys.Bindings]109 bindings atomic.Pointer[keys.Bindings]
112 codeOverrides atomic.Pointer[map[string]string]110 codeOverrides atomic.Pointer[map[string]string]
113 themeMode atomic.Uint32111 themeMode atomic.Uint32
@@ -231,9 +229,6 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
231 if d.procComm == nil {229 if d.procComm == nil {
232 d.procComm = platform.ProcessComm230 d.procComm = platform.ProcessComm
233 }231 }
234- if d.commandByCode == nil {
235- d.commandByCode = d.commandByEffectiveCode
236- }
237 if d.bindings.Load() == nil {232 if d.bindings.Load() == nil {
238 d.bindings.Store(keys.DefaultBindings())233 d.bindings.Store(keys.DefaultBindings())
239 }234 }
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/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/status_test.go +18 -0
@@ -175,6 +175,24 @@ func TestAutoThemeDetachClearsPaneColorScheme(t *testing.T) {
175175 assertSessionColorSchemeUnknown(t, sess)
176176 }
177177
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+
178196 func TestForcedThemeDetachPreservesBuiltinOnPanes(t *testing.T) {
179197 p, releasePTY := newBlockingPTY(t)
180198 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
@@ -175,6 +175,24 @@ func TestAutoThemeDetachClearsPaneColorScheme(t *testing.T) {
175 assertSessionColorSchemeUnknown(t, sess)175 assertSessionColorSchemeUnknown(t, sess)
176 }176 }
177 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+
178 func TestForcedThemeDetachPreservesBuiltinOnPanes(t *testing.T) {196 func TestForcedThemeDetachPreservesBuiltinOnPanes(t *testing.T) {
179 p, releasePTY := newBlockingPTY(t)197 p, releasePTY := newBlockingPTY(t)
180 d, sess, ac, _ := newManualSessionWithPTYs(t, p)198 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
M internal/usecase/daemon/theme_resolve.go +2 -2
@@ -2,7 +2,7 @@ package daemon
22
33 import "github.com/bnema/vev/internal/usecase/theme"
44
5-func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme) bool {
5+func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
66 sess.mu.Lock()
77 if ac != nil {
88 if sess.client != ac {
@@ -25,7 +25,7 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme
2525 tb.mu.Unlock()
2626 for _, p := range panes {
2727 p.mu.Lock()
28- applyPaneThemeLocked(p, t, ac == nil)
28+ applyPaneThemeLocked(p, t, clearUnknownScheme)
2929 p.mu.Unlock()
3030 }
3131 }
@@ -2,7 +2,7 @@ package daemon
2 2
3 import "github.com/bnema/vev/internal/usecase/theme"3 import "github.com/bnema/vev/internal/usecase/theme"
4 4
5-func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme) bool {5+func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
6 sess.mu.Lock()6 sess.mu.Lock()
7 if ac != nil {7 if ac != nil {
8 if sess.client != ac {8 if sess.client != ac {
@@ -25,7 +25,7 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme
25 tb.mu.Unlock()25 tb.mu.Unlock()
26 for _, p := range panes {26 for _, p := range panes {
27 p.mu.Lock()27 p.mu.Lock()
28- applyPaneThemeLocked(p, t, ac == nil)28+ applyPaneThemeLocked(p, t, clearUnknownScheme)
29 p.mu.Unlock()29 p.mu.Unlock()
30 }30 }
31 }31 }
M internal/usecase/keys/router.go +4 -6
@@ -62,12 +62,10 @@ type Router struct {
6262 pendingDone chan struct{}
6363 }
6464
65-func NewRouter(clock ports.Clock, h Handler, bindings ...*atomic.Pointer[Bindings]) *Router {
66- var bindingPtr *atomic.Pointer[Bindings]
67- if len(bindings) > 0 {
68- bindingPtr = bindings[0]
69- }
70- return &Router{clock: clock, delay: ESCDelay, h: h, bindings: bindingPtr}
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}
7169 }
7270
7371 // Route routes one transport read.
@@ -62,12 +62,10 @@ type Router struct {
62 pendingDone chan struct{}62 pendingDone chan struct{}
63 }63 }
64 64
65-func NewRouter(clock ports.Clock, h Handler, bindings ...*atomic.Pointer[Bindings]) *Router {65+// NewRouter constructs a Router. bindings may be nil, in which case
66- var bindingPtr *atomic.Pointer[Bindings]66+// currentBindings falls back to defaultBindings.
67- if len(bindings) > 0 {67+func NewRouter(clock ports.Clock, h Handler, bindings *atomic.Pointer[Bindings]) *Router {
68- bindingPtr = bindings[0]68+ return &Router{clock: clock, delay: ESCDelay, h: h, bindings: bindings}
69- }
70- return &Router{clock: clock, delay: ESCDelay, h: h, bindings: bindingPtr}
71 }69 }
72 70
73 // Route routes one transport read.71 // Route routes one transport read.
M internal/usecase/keys/router_test.go +26 -26
@@ -82,7 +82,7 @@ func TestDefaultBindingsParityRoutesBuiltInBindings(t *testing.T) {
8282 func TestRouterInterceptsAltSpaceForPalette(t *testing.T) {
8383 clk := &fakeClock{}
8484 h := &captureHandler{}
85- NewRouter(clk, h).Route([]byte{ESC, ' '})
85+ NewRouter(clk, h, nil).Route([]byte{ESC, ' '})
8686 require.Equal(t, []Action{ActionOpenPalette}, h.actions)
8787 require.Empty(t, h.forwards)
8888 require.Empty(t, clk.timers)
@@ -91,7 +91,7 @@ func TestRouterInterceptsAltSpaceForPalette(t *testing.T) {
9191 func TestRouterInterceptsAltDigits(t *testing.T) {
9292 clk := &fakeClock{}
9393 h := &captureHandler{}
94- r := NewRouter(clk, h)
94+ r := NewRouter(clk, h, nil)
9595 for b := byte('1'); b <= '9'; b++ {
9696 r.Route([]byte{ESC, b})
9797 }
@@ -113,7 +113,7 @@ func TestRouterInterceptsAltLayoutDigitAliases(t *testing.T) {
113113 t.Run(tc.name, func(t *testing.T) {
114114 clk := &fakeClock{}
115115 h := &captureHandler{}
116- r := NewRouter(clk, h)
116+ r := NewRouter(clk, h, nil)
117117 for _, key := range tc.keys {
118118 r.Route(append([]byte{ESC}, []byte(key)...))
119119 }
@@ -126,7 +126,7 @@ func TestRouterInterceptsAltLayoutDigitAliases(t *testing.T) {
126126 func TestRouterInterceptsAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
127127 clk := &fakeClock{}
128128 h := &captureHandler{}
129- r := NewRouter(clk, h)
129+ r := NewRouter(clk, h, nil)
130130
131131 r.Route([]byte{ESC, 0xc3})
132132 require.Empty(t, h.actions)
@@ -146,7 +146,7 @@ func TestRouterInterceptsAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
146146 func TestRouterInterceptsRetainedAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
147147 clk := &fakeClock{}
148148 h := &captureHandler{}
149- r := NewRouter(clk, h)
149+ r := NewRouter(clk, h, nil)
150150
151151 r.Route([]byte{ESC})
152152 r.Route([]byte{0xc3})
@@ -167,7 +167,7 @@ func TestRouterInterceptsRetainedAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
167167 func TestRouterForwardsUnboundAltUTF8SplitAcrossReads(t *testing.T) {
168168 clk := &fakeClock{}
169169 h := &captureHandler{}
170- r := NewRouter(clk, h)
170+ r := NewRouter(clk, h, nil)
171171
172172 r.Route([]byte{ESC, 0xc3})
173173 r.Route([]byte{0xb1})
@@ -184,7 +184,7 @@ func TestRouterForwardsUnboundAltUTF8SplitAcrossReads(t *testing.T) {
184184 func TestRouterForwardsInvalidSplitAltUTF8WithoutDroppingBytes(t *testing.T) {
185185 clk := &fakeClock{}
186186 h := &captureHandler{}
187- r := NewRouter(clk, h)
187+ r := NewRouter(clk, h, nil)
188188
189189 r.Route([]byte{ESC, 0xc3})
190190 r.Route([]byte("X"))
@@ -240,7 +240,7 @@ func switchTabActions() []Action {
240240 func TestRouterInterceptsAltAForJumpAttention(t *testing.T) {
241241 clk := &fakeClock{}
242242 h := &captureHandler{}
243- NewRouter(clk, h).Route([]byte{ESC, 'a'})
243+ NewRouter(clk, h, nil).Route([]byte{ESC, 'a'})
244244 require.Equal(t, []Action{ActionJumpAttention}, h.actions)
245245 require.Empty(t, h.forwards)
246246 require.Empty(t, clk.timers)
@@ -261,7 +261,7 @@ func TestRouterInterceptsAltHJKLForPaneFocus(t *testing.T) {
261261 t.Run(tc.name, func(t *testing.T) {
262262 clk := &fakeClock{}
263263 h := &captureHandler{}
264- NewRouter(clk, h).Route([]byte{ESC, tc.key})
264+ NewRouter(clk, h, nil).Route([]byte{ESC, tc.key})
265265 require.Equal(t, []Action{tc.want}, h.actions)
266266 require.Empty(t, h.forwards)
267267 require.Empty(t, clk.timers)
@@ -274,7 +274,7 @@ func TestRouterForwardsRemovedAltLetterBindings(t *testing.T) {
274274 t.Run(string(b), func(t *testing.T) {
275275 clk := &fakeClock{}
276276 h := &captureHandler{}
277- NewRouter(clk, h).Route([]byte{ESC, b})
277+ NewRouter(clk, h, nil).Route([]byte{ESC, b})
278278 require.Empty(t, h.actions)
279279 require.Equal(t, [][]byte{{ESC, b}}, h.forwards)
280280 })
@@ -284,7 +284,7 @@ func TestRouterForwardsRemovedAltLetterBindings(t *testing.T) {
284284 func TestRouterForwardsOnlyNonInterceptedBytes(t *testing.T) {
285285 clk := &fakeClock{}
286286 h := &captureHandler{}
287- NewRouter(clk, h).Route([]byte{'a', ESC, ' ', 'b'})
287+ NewRouter(clk, h, nil).Route([]byte{'a', ESC, ' ', 'b'})
288288 require.Equal(t, []Action{ActionOpenPalette}, h.actions)
289289 require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards)
290290 }
@@ -294,7 +294,7 @@ func TestRouterPassesThroughTerminalEscapePrefixes(t *testing.T) {
294294 for _, in := range cases {
295295 clk := &fakeClock{}
296296 h := &captureHandler{}
297- NewRouter(clk, h).Route(in)
297+ NewRouter(clk, h, nil).Route(in)
298298 require.Empty(t, h.actions)
299299 require.Equal(t, [][]byte{in}, h.forwards)
300300 require.Empty(t, clk.timers)
@@ -321,7 +321,7 @@ func TestRouterInterceptsAltArrowCSIForPaneFocus(t *testing.T) {
321321 t.Run(tc.name, func(t *testing.T) {
322322 clk := &fakeClock{}
323323 h := &captureHandler{}
324- NewRouter(clk, h).Route(tc.in)
324+ NewRouter(clk, h, nil).Route(tc.in)
325325 require.Equal(t, []Action{tc.want}, h.actions)
326326 require.Empty(t, h.forwards)
327327 require.Empty(t, clk.timers)
@@ -346,7 +346,7 @@ func TestRouterAltArrowCSIPartialsAcrossReads(t *testing.T) {
346346 t.Run(tc.name, func(t *testing.T) {
347347 clk := &fakeClock{}
348348 h := &captureHandler{}
349- r := NewRouter(clk, h)
349+ r := NewRouter(clk, h, nil)
350350 for _, read := range tc.reads {
351351 r.Route(read)
352352 }
@@ -392,7 +392,7 @@ func TestRouterAltArrowCSIPrefixRouting(t *testing.T) {
392392 t.Run(tc.name, func(t *testing.T) {
393393 clk := &fakeClock{}
394394 h := &captureHandler{}
395- r := NewRouter(clk, h)
395+ r := NewRouter(clk, h, nil)
396396 for _, read := range tc.reads {
397397 r.Route(read)
398398 }
@@ -412,7 +412,7 @@ func TestRouterPassesThroughBareArrows(t *testing.T) {
412412 t.Run(string(in), func(t *testing.T) {
413413 clk := &fakeClock{}
414414 h := &captureHandler{}
415- NewRouter(clk, h).Route(in)
415+ NewRouter(clk, h, nil).Route(in)
416416 require.Empty(t, h.actions)
417417 require.Equal(t, [][]byte{in}, h.forwards)
418418 require.Empty(t, clk.timers)
@@ -423,7 +423,7 @@ func TestRouterPassesThroughBareArrows(t *testing.T) {
423423 func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) {
424424 clk := &fakeClock{}
425425 h := &captureHandler{}
426- r := NewRouter(clk, h)
426+ r := NewRouter(clk, h, nil)
427427 r.Route([]byte{ESC})
428428 require.Len(t, clk.timers, 1)
429429 require.Equal(t, ESCDelay, clk.timers[0].d)
@@ -438,7 +438,7 @@ func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) {
438438 func TestRouterForwardsBareT(t *testing.T) {
439439 clk := &fakeClock{}
440440 h := &captureHandler{}
441- NewRouter(clk, h).Route([]byte{'t'})
441+ NewRouter(clk, h, nil).Route([]byte{'t'})
442442 require.Empty(t, h.actions)
443443 require.Equal(t, [][]byte{[]byte("t")}, h.forwards)
444444 }
@@ -457,7 +457,7 @@ func TestRouterForwardsCSIAfterRetainedESCAcrossReads(t *testing.T) {
457457 t.Run(tc.name, func(t *testing.T) {
458458 clk := &fakeClock{}
459459 h := &captureHandler{}
460- r := NewRouter(clk, h)
460+ r := NewRouter(clk, h, nil)
461461 r.Route([]byte{ESC})
462462 require.Len(t, clk.timers, 1)
463463 require.Empty(t, h.forwards)
@@ -474,7 +474,7 @@ func TestRouterForwardsCSIAfterRetainedESCAcrossReads(t *testing.T) {
474474 func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) {
475475 clk := &fakeClock{}
476476 h := &captureHandler{}
477- r := NewRouter(clk, h)
477+ r := NewRouter(clk, h, nil)
478478 r.Route([]byte{ESC})
479479
480480 r.Route([]byte{'[', 'A', ESC, ' '})
@@ -487,7 +487,7 @@ func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) {
487487 func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) {
488488 clk := &fakeClock{}
489489 h := &captureHandler{}
490- r := NewRouter(clk, h)
490+ r := NewRouter(clk, h, nil)
491491
492492 before := retainESCWaiters()
493493 r.Route([]byte{ESC})
@@ -509,7 +509,7 @@ func retainESCWaiters() int {
509509 func TestRouterFlushesLoneESCAfterTimer(t *testing.T) {
510510 clk := &fakeClock{}
511511 h := &captureHandler{notify: make(chan struct{}, 1)}
512- r := NewRouter(clk, h)
512+ r := NewRouter(clk, h, nil)
513513 r.Route([]byte{ESC})
514514 clk.timers[0].fire()
515515 select {
@@ -524,7 +524,7 @@ func TestRouterFlushesLoneESCAfterTimer(t *testing.T) {
524524 func TestRouterFlushesPendingESCBeforeOtherByte(t *testing.T) {
525525 clk := &fakeClock{}
526526 h := &captureHandler{}
527- r := NewRouter(clk, h)
527+ r := NewRouter(clk, h, nil)
528528 r.Route([]byte{ESC})
529529 r.Route([]byte{'z'})
530530 require.Equal(t, [][]byte{{ESC}, []byte("z")}, h.forwards)
@@ -538,7 +538,7 @@ var pasteBindingLookalikes = []byte("\x1bj\x1b \x1b1\x1b[1;3A")
538538 func TestRouterForwardsSingleFramePasteVerbatim(t *testing.T) {
539539 clk := &fakeClock{}
540540 h := &captureHandler{}
541- r := NewRouter(clk, h)
541+ r := NewRouter(clk, h, nil)
542542
543543 paste := append(append(append([]byte(nil), ports.BracketedPasteOpenMarker...), pasteBindingLookalikes...), ports.BracketedPasteCloseMarker...)
544544 r.Route(paste)
@@ -551,7 +551,7 @@ func TestRouterForwardsSingleFramePasteVerbatim(t *testing.T) {
551551 func TestRouterFiresBindingsForSameBytesOutsidePaste(t *testing.T) {
552552 clk := &fakeClock{}
553553 h := &captureHandler{}
554- r := NewRouter(clk, h)
554+ r := NewRouter(clk, h, nil)
555555
556556 r.Route(pasteBindingLookalikes)
557557
@@ -567,7 +567,7 @@ func TestRouterFiresBindingsForSameBytesOutsidePaste(t *testing.T) {
567567 func TestRouterPasteMarkerSplitAcrossFramesKeepsCurrentBehavior(t *testing.T) {
568568 clk := &fakeClock{}
569569 h := &captureHandler{}
570- r := NewRouter(clk, h)
570+ r := NewRouter(clk, h, nil)
571571
572572 // Opening marker + content in one frame, closing marker in the next: with
573573 // no closing marker in-frame the router keeps its historical per-byte
@@ -82,7 +82,7 @@ func TestDefaultBindingsParityRoutesBuiltInBindings(t *testing.T) {
82 func TestRouterInterceptsAltSpaceForPalette(t *testing.T) {82 func TestRouterInterceptsAltSpaceForPalette(t *testing.T) {
83 clk := &fakeClock{}83 clk := &fakeClock{}
84 h := &captureHandler{}84 h := &captureHandler{}
85- NewRouter(clk, h).Route([]byte{ESC, ' '})85+ NewRouter(clk, h, nil).Route([]byte{ESC, ' '})
86 require.Equal(t, []Action{ActionOpenPalette}, h.actions)86 require.Equal(t, []Action{ActionOpenPalette}, h.actions)
87 require.Empty(t, h.forwards)87 require.Empty(t, h.forwards)
88 require.Empty(t, clk.timers)88 require.Empty(t, clk.timers)
@@ -91,7 +91,7 @@ func TestRouterInterceptsAltSpaceForPalette(t *testing.T) {
91 func TestRouterInterceptsAltDigits(t *testing.T) {91 func TestRouterInterceptsAltDigits(t *testing.T) {
92 clk := &fakeClock{}92 clk := &fakeClock{}
93 h := &captureHandler{}93 h := &captureHandler{}
94- r := NewRouter(clk, h)94+ r := NewRouter(clk, h, nil)
95 for b := byte('1'); b <= '9'; b++ {95 for b := byte('1'); b <= '9'; b++ {
96 r.Route([]byte{ESC, b})96 r.Route([]byte{ESC, b})
97 }97 }
@@ -113,7 +113,7 @@ func TestRouterInterceptsAltLayoutDigitAliases(t *testing.T) {
113 t.Run(tc.name, func(t *testing.T) {113 t.Run(tc.name, func(t *testing.T) {
114 clk := &fakeClock{}114 clk := &fakeClock{}
115 h := &captureHandler{}115 h := &captureHandler{}
116- r := NewRouter(clk, h)116+ r := NewRouter(clk, h, nil)
117 for _, key := range tc.keys {117 for _, key := range tc.keys {
118 r.Route(append([]byte{ESC}, []byte(key)...))118 r.Route(append([]byte{ESC}, []byte(key)...))
119 }119 }
@@ -126,7 +126,7 @@ func TestRouterInterceptsAltLayoutDigitAliases(t *testing.T) {
126 func TestRouterInterceptsAltAZERTYUTF8SplitAcrossReads(t *testing.T) {126 func TestRouterInterceptsAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
127 clk := &fakeClock{}127 clk := &fakeClock{}
128 h := &captureHandler{}128 h := &captureHandler{}
129- r := NewRouter(clk, h)129+ r := NewRouter(clk, h, nil)
130 130
131 r.Route([]byte{ESC, 0xc3})131 r.Route([]byte{ESC, 0xc3})
132 require.Empty(t, h.actions)132 require.Empty(t, h.actions)
@@ -146,7 +146,7 @@ func TestRouterInterceptsAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
146 func TestRouterInterceptsRetainedAltAZERTYUTF8SplitAcrossReads(t *testing.T) {146 func TestRouterInterceptsRetainedAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
147 clk := &fakeClock{}147 clk := &fakeClock{}
148 h := &captureHandler{}148 h := &captureHandler{}
149- r := NewRouter(clk, h)149+ r := NewRouter(clk, h, nil)
150 150
151 r.Route([]byte{ESC})151 r.Route([]byte{ESC})
152 r.Route([]byte{0xc3})152 r.Route([]byte{0xc3})
@@ -167,7 +167,7 @@ func TestRouterInterceptsRetainedAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
167 func TestRouterForwardsUnboundAltUTF8SplitAcrossReads(t *testing.T) {167 func TestRouterForwardsUnboundAltUTF8SplitAcrossReads(t *testing.T) {
168 clk := &fakeClock{}168 clk := &fakeClock{}
169 h := &captureHandler{}169 h := &captureHandler{}
170- r := NewRouter(clk, h)170+ r := NewRouter(clk, h, nil)
171 171
172 r.Route([]byte{ESC, 0xc3})172 r.Route([]byte{ESC, 0xc3})
173 r.Route([]byte{0xb1})173 r.Route([]byte{0xb1})
@@ -184,7 +184,7 @@ func TestRouterForwardsUnboundAltUTF8SplitAcrossReads(t *testing.T) {
184 func TestRouterForwardsInvalidSplitAltUTF8WithoutDroppingBytes(t *testing.T) {184 func TestRouterForwardsInvalidSplitAltUTF8WithoutDroppingBytes(t *testing.T) {
185 clk := &fakeClock{}185 clk := &fakeClock{}
186 h := &captureHandler{}186 h := &captureHandler{}
187- r := NewRouter(clk, h)187+ r := NewRouter(clk, h, nil)
188 188
189 r.Route([]byte{ESC, 0xc3})189 r.Route([]byte{ESC, 0xc3})
190 r.Route([]byte("X"))190 r.Route([]byte("X"))
@@ -240,7 +240,7 @@ func switchTabActions() []Action {
240 func TestRouterInterceptsAltAForJumpAttention(t *testing.T) {240 func TestRouterInterceptsAltAForJumpAttention(t *testing.T) {
241 clk := &fakeClock{}241 clk := &fakeClock{}
242 h := &captureHandler{}242 h := &captureHandler{}
243- NewRouter(clk, h).Route([]byte{ESC, 'a'})243+ NewRouter(clk, h, nil).Route([]byte{ESC, 'a'})
244 require.Equal(t, []Action{ActionJumpAttention}, h.actions)244 require.Equal(t, []Action{ActionJumpAttention}, h.actions)
245 require.Empty(t, h.forwards)245 require.Empty(t, h.forwards)
246 require.Empty(t, clk.timers)246 require.Empty(t, clk.timers)
@@ -261,7 +261,7 @@ func TestRouterInterceptsAltHJKLForPaneFocus(t *testing.T) {
261 t.Run(tc.name, func(t *testing.T) {261 t.Run(tc.name, func(t *testing.T) {
262 clk := &fakeClock{}262 clk := &fakeClock{}
263 h := &captureHandler{}263 h := &captureHandler{}
264- NewRouter(clk, h).Route([]byte{ESC, tc.key})264+ NewRouter(clk, h, nil).Route([]byte{ESC, tc.key})
265 require.Equal(t, []Action{tc.want}, h.actions)265 require.Equal(t, []Action{tc.want}, h.actions)
266 require.Empty(t, h.forwards)266 require.Empty(t, h.forwards)
267 require.Empty(t, clk.timers)267 require.Empty(t, clk.timers)
@@ -274,7 +274,7 @@ func TestRouterForwardsRemovedAltLetterBindings(t *testing.T) {
274 t.Run(string(b), func(t *testing.T) {274 t.Run(string(b), func(t *testing.T) {
275 clk := &fakeClock{}275 clk := &fakeClock{}
276 h := &captureHandler{}276 h := &captureHandler{}
277- NewRouter(clk, h).Route([]byte{ESC, b})277+ NewRouter(clk, h, nil).Route([]byte{ESC, b})
278 require.Empty(t, h.actions)278 require.Empty(t, h.actions)
279 require.Equal(t, [][]byte{{ESC, b}}, h.forwards)279 require.Equal(t, [][]byte{{ESC, b}}, h.forwards)
280 })280 })
@@ -284,7 +284,7 @@ func TestRouterForwardsRemovedAltLetterBindings(t *testing.T) {
284 func TestRouterForwardsOnlyNonInterceptedBytes(t *testing.T) {284 func TestRouterForwardsOnlyNonInterceptedBytes(t *testing.T) {
285 clk := &fakeClock{}285 clk := &fakeClock{}
286 h := &captureHandler{}286 h := &captureHandler{}
287- NewRouter(clk, h).Route([]byte{'a', ESC, ' ', 'b'})287+ NewRouter(clk, h, nil).Route([]byte{'a', ESC, ' ', 'b'})
288 require.Equal(t, []Action{ActionOpenPalette}, h.actions)288 require.Equal(t, []Action{ActionOpenPalette}, h.actions)
289 require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards)289 require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards)
290 }290 }
@@ -294,7 +294,7 @@ func TestRouterPassesThroughTerminalEscapePrefixes(t *testing.T) {
294 for _, in := range cases {294 for _, in := range cases {
295 clk := &fakeClock{}295 clk := &fakeClock{}
296 h := &captureHandler{}296 h := &captureHandler{}
297- NewRouter(clk, h).Route(in)297+ NewRouter(clk, h, nil).Route(in)
298 require.Empty(t, h.actions)298 require.Empty(t, h.actions)
299 require.Equal(t, [][]byte{in}, h.forwards)299 require.Equal(t, [][]byte{in}, h.forwards)
300 require.Empty(t, clk.timers)300 require.Empty(t, clk.timers)
@@ -321,7 +321,7 @@ func TestRouterInterceptsAltArrowCSIForPaneFocus(t *testing.T) {
321 t.Run(tc.name, func(t *testing.T) {321 t.Run(tc.name, func(t *testing.T) {
322 clk := &fakeClock{}322 clk := &fakeClock{}
323 h := &captureHandler{}323 h := &captureHandler{}
324- NewRouter(clk, h).Route(tc.in)324+ NewRouter(clk, h, nil).Route(tc.in)
325 require.Equal(t, []Action{tc.want}, h.actions)325 require.Equal(t, []Action{tc.want}, h.actions)
326 require.Empty(t, h.forwards)326 require.Empty(t, h.forwards)
327 require.Empty(t, clk.timers)327 require.Empty(t, clk.timers)
@@ -346,7 +346,7 @@ func TestRouterAltArrowCSIPartialsAcrossReads(t *testing.T) {
346 t.Run(tc.name, func(t *testing.T) {346 t.Run(tc.name, func(t *testing.T) {
347 clk := &fakeClock{}347 clk := &fakeClock{}
348 h := &captureHandler{}348 h := &captureHandler{}
349- r := NewRouter(clk, h)349+ r := NewRouter(clk, h, nil)
350 for _, read := range tc.reads {350 for _, read := range tc.reads {
351 r.Route(read)351 r.Route(read)
352 }352 }
@@ -392,7 +392,7 @@ func TestRouterAltArrowCSIPrefixRouting(t *testing.T) {
392 t.Run(tc.name, func(t *testing.T) {392 t.Run(tc.name, func(t *testing.T) {
393 clk := &fakeClock{}393 clk := &fakeClock{}
394 h := &captureHandler{}394 h := &captureHandler{}
395- r := NewRouter(clk, h)395+ r := NewRouter(clk, h, nil)
396 for _, read := range tc.reads {396 for _, read := range tc.reads {
397 r.Route(read)397 r.Route(read)
398 }398 }
@@ -412,7 +412,7 @@ func TestRouterPassesThroughBareArrows(t *testing.T) {
412 t.Run(string(in), func(t *testing.T) {412 t.Run(string(in), func(t *testing.T) {
413 clk := &fakeClock{}413 clk := &fakeClock{}
414 h := &captureHandler{}414 h := &captureHandler{}
415- NewRouter(clk, h).Route(in)415+ NewRouter(clk, h, nil).Route(in)
416 require.Empty(t, h.actions)416 require.Empty(t, h.actions)
417 require.Equal(t, [][]byte{in}, h.forwards)417 require.Equal(t, [][]byte{in}, h.forwards)
418 require.Empty(t, clk.timers)418 require.Empty(t, clk.timers)
@@ -423,7 +423,7 @@ func TestRouterPassesThroughBareArrows(t *testing.T) {
423 func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) {423 func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) {
424 clk := &fakeClock{}424 clk := &fakeClock{}
425 h := &captureHandler{}425 h := &captureHandler{}
426- r := NewRouter(clk, h)426+ r := NewRouter(clk, h, nil)
427 r.Route([]byte{ESC})427 r.Route([]byte{ESC})
428 require.Len(t, clk.timers, 1)428 require.Len(t, clk.timers, 1)
429 require.Equal(t, ESCDelay, clk.timers[0].d)429 require.Equal(t, ESCDelay, clk.timers[0].d)
@@ -438,7 +438,7 @@ func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) {
438 func TestRouterForwardsBareT(t *testing.T) {438 func TestRouterForwardsBareT(t *testing.T) {
439 clk := &fakeClock{}439 clk := &fakeClock{}
440 h := &captureHandler{}440 h := &captureHandler{}
441- NewRouter(clk, h).Route([]byte{'t'})441+ NewRouter(clk, h, nil).Route([]byte{'t'})
442 require.Empty(t, h.actions)442 require.Empty(t, h.actions)
443 require.Equal(t, [][]byte{[]byte("t")}, h.forwards)443 require.Equal(t, [][]byte{[]byte("t")}, h.forwards)
444 }444 }
@@ -457,7 +457,7 @@ func TestRouterForwardsCSIAfterRetainedESCAcrossReads(t *testing.T) {
457 t.Run(tc.name, func(t *testing.T) {457 t.Run(tc.name, func(t *testing.T) {
458 clk := &fakeClock{}458 clk := &fakeClock{}
459 h := &captureHandler{}459 h := &captureHandler{}
460- r := NewRouter(clk, h)460+ r := NewRouter(clk, h, nil)
461 r.Route([]byte{ESC})461 r.Route([]byte{ESC})
462 require.Len(t, clk.timers, 1)462 require.Len(t, clk.timers, 1)
463 require.Empty(t, h.forwards)463 require.Empty(t, h.forwards)
@@ -474,7 +474,7 @@ func TestRouterForwardsCSIAfterRetainedESCAcrossReads(t *testing.T) {
474 func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) {474 func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) {
475 clk := &fakeClock{}475 clk := &fakeClock{}
476 h := &captureHandler{}476 h := &captureHandler{}
477- r := NewRouter(clk, h)477+ r := NewRouter(clk, h, nil)
478 r.Route([]byte{ESC})478 r.Route([]byte{ESC})
479 479
480 r.Route([]byte{'[', 'A', ESC, ' '})480 r.Route([]byte{'[', 'A', ESC, ' '})
@@ -487,7 +487,7 @@ func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) {
487 func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) {487 func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) {
488 clk := &fakeClock{}488 clk := &fakeClock{}
489 h := &captureHandler{}489 h := &captureHandler{}
490- r := NewRouter(clk, h)490+ r := NewRouter(clk, h, nil)
491 491
492 before := retainESCWaiters()492 before := retainESCWaiters()
493 r.Route([]byte{ESC})493 r.Route([]byte{ESC})
@@ -509,7 +509,7 @@ func retainESCWaiters() int {
509 func TestRouterFlushesLoneESCAfterTimer(t *testing.T) {509 func TestRouterFlushesLoneESCAfterTimer(t *testing.T) {
510 clk := &fakeClock{}510 clk := &fakeClock{}
511 h := &captureHandler{notify: make(chan struct{}, 1)}511 h := &captureHandler{notify: make(chan struct{}, 1)}
512- r := NewRouter(clk, h)512+ r := NewRouter(clk, h, nil)
513 r.Route([]byte{ESC})513 r.Route([]byte{ESC})
514 clk.timers[0].fire()514 clk.timers[0].fire()
515 select {515 select {
@@ -524,7 +524,7 @@ func TestRouterFlushesLoneESCAfterTimer(t *testing.T) {
524 func TestRouterFlushesPendingESCBeforeOtherByte(t *testing.T) {524 func TestRouterFlushesPendingESCBeforeOtherByte(t *testing.T) {
525 clk := &fakeClock{}525 clk := &fakeClock{}
526 h := &captureHandler{}526 h := &captureHandler{}
527- r := NewRouter(clk, h)527+ r := NewRouter(clk, h, nil)
528 r.Route([]byte{ESC})528 r.Route([]byte{ESC})
529 r.Route([]byte{'z'})529 r.Route([]byte{'z'})
530 require.Equal(t, [][]byte{{ESC}, []byte("z")}, h.forwards)530 require.Equal(t, [][]byte{{ESC}, []byte("z")}, h.forwards)
@@ -538,7 +538,7 @@ var pasteBindingLookalikes = []byte("\x1bj\x1b \x1b1\x1b[1;3A")
538 func TestRouterForwardsSingleFramePasteVerbatim(t *testing.T) {538 func TestRouterForwardsSingleFramePasteVerbatim(t *testing.T) {
539 clk := &fakeClock{}539 clk := &fakeClock{}
540 h := &captureHandler{}540 h := &captureHandler{}
541- r := NewRouter(clk, h)541+ r := NewRouter(clk, h, nil)
542 542
543 paste := append(append(append([]byte(nil), ports.BracketedPasteOpenMarker...), pasteBindingLookalikes...), ports.BracketedPasteCloseMarker...)543 paste := append(append(append([]byte(nil), ports.BracketedPasteOpenMarker...), pasteBindingLookalikes...), ports.BracketedPasteCloseMarker...)
544 r.Route(paste)544 r.Route(paste)
@@ -551,7 +551,7 @@ func TestRouterForwardsSingleFramePasteVerbatim(t *testing.T) {
551 func TestRouterFiresBindingsForSameBytesOutsidePaste(t *testing.T) {551 func TestRouterFiresBindingsForSameBytesOutsidePaste(t *testing.T) {
552 clk := &fakeClock{}552 clk := &fakeClock{}
553 h := &captureHandler{}553 h := &captureHandler{}
554- r := NewRouter(clk, h)554+ r := NewRouter(clk, h, nil)
555 555
556 r.Route(pasteBindingLookalikes)556 r.Route(pasteBindingLookalikes)
557 557
@@ -567,7 +567,7 @@ func TestRouterFiresBindingsForSameBytesOutsidePaste(t *testing.T) {
567 func TestRouterPasteMarkerSplitAcrossFramesKeepsCurrentBehavior(t *testing.T) {567 func TestRouterPasteMarkerSplitAcrossFramesKeepsCurrentBehavior(t *testing.T) {
568 clk := &fakeClock{}568 clk := &fakeClock{}
569 h := &captureHandler{}569 h := &captureHandler{}
570- r := NewRouter(clk, h)570+ r := NewRouter(clk, h, nil)
571 571
572 // Opening marker + content in one frame, closing marker in the next: with572 // Opening marker + content in one frame, closing marker in the next: with
573 // 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 +19 -9
@@ -224,18 +224,28 @@ func colorOSCPrefix(data []byte) (complete bool, possible bool) {
224224 return false, false
225225 }
226226
227+// schemeCSIPrefixes lists the CSI ?997 scheme-notification sequences vev
228+// recognizes. Package-level so schemeCSINotification (called per ESC byte in
229+// the stdin hot path) doesn't allocate on every call.
230+var schemeCSIPrefixes = []struct {
231+ seq []byte
232+ light bool
233+}{
234+ {seq: []byte("\x1b[?997;1n"), light: false},
235+ {seq: []byte("\x1b[?997;2n"), light: true},
236+}
237+
227238 func schemeCSINotification(data []byte) (complete bool, possible bool, light bool) {
228- if len(data) < 2 {
239+ // A bare trailing ESC (handled elsewhere) or "ESC [" (2 bytes) is
240+ // something a user can type; unlike OSC's "ESC ]" it must never be
241+ // withheld as a partial match with nothing following, or those keystrokes
242+ // would be buffered and never forwarded. Partial buffering only starts
243+ // from "ESC [ ?" onward; a complete match is always >= 9 bytes, so this
244+ // guard cannot reject one.
245+ if len(data) < 3 {
229246 return false, false, false
230247 }
231- prefixes := []struct {
232- seq []byte
233- light bool
234- }{
235- {seq: []byte("\x1b[?997;1n"), light: false},
236- {seq: []byte("\x1b[?997;2n"), light: true},
237- }
238- for _, prefix := range prefixes {
248+ for _, prefix := range schemeCSIPrefixes {
239249 if len(data) >= len(prefix.seq) {
240250 if bytes.Equal(data[:len(prefix.seq)], prefix.seq) {
241251 return true, true, prefix.light
@@ -224,18 +224,28 @@ func colorOSCPrefix(data []byte) (complete bool, possible bool) {
224 return false, false224 return false, false
225 }225 }
226 226
227+// schemeCSIPrefixes lists the CSI ?997 scheme-notification sequences vev
228+// recognizes. Package-level so schemeCSINotification (called per ESC byte in
229+// the stdin hot path) doesn't allocate on every call.
230+var schemeCSIPrefixes = []struct {
231+ seq []byte
232+ light bool
233+}{
234+ {seq: []byte("\x1b[?997;1n"), light: false},
235+ {seq: []byte("\x1b[?997;2n"), light: true},
236+}
237+
227 func schemeCSINotification(data []byte) (complete bool, possible bool, light bool) {238 func schemeCSINotification(data []byte) (complete bool, possible bool, light bool) {
228- if len(data) < 2 {239+ // A bare trailing ESC (handled elsewhere) or "ESC [" (2 bytes) is
240+ // something a user can type; unlike OSC's "ESC ]" it must never be
241+ // withheld as a partial match with nothing following, or those keystrokes
242+ // would be buffered and never forwarded. Partial buffering only starts
243+ // from "ESC [ ?" onward; a complete match is always >= 9 bytes, so this
244+ // guard cannot reject one.
245+ if len(data) < 3 {
229 return false, false, false246 return false, false, false
230 }247 }
231- prefixes := []struct {248+ for _, prefix := range schemeCSIPrefixes {
232- seq []byte
233- light bool
234- }{
235- {seq: []byte("\x1b[?997;1n"), light: false},
236- {seq: []byte("\x1b[?997;2n"), light: true},
237- }
238- for _, prefix := range prefixes {
239 if len(data) >= len(prefix.seq) {249 if len(data) >= len(prefix.seq) {
240 if bytes.Equal(data[:len(prefix.seq)], prefix.seq) {250 if bytes.Equal(data[:len(prefix.seq)], prefix.seq) {
241 return true, true, prefix.light251 return true, true, prefix.light
M internal/usecase/theme/theme_test.go +22 -0
@@ -98,6 +98,28 @@ func TestScannerHandlesSplitSchemeNotifications(t *testing.T) {
9898 }
9999 }
100100
101+func TestScannerForwardsBareCSIIntroducerImmediately(t *testing.T) {
102+ var s Scanner
103+ var out bytes.Buffer
104+ schemes := 0
105+
106+ s.Scan([]byte("a\x1b["), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) })
107+
108+ if out.String() != "a\x1b[" {
109+ t.Fatalf("passthrough=%q want %q", out.String(), "a\x1b[")
110+ }
111+ if schemes != 0 {
112+ t.Fatalf("schemes=%d want 0", schemes)
113+ }
114+
115+ out.Reset()
116+ s.Scan([]byte("A"), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) })
117+
118+ if out.String() != "A" {
119+ t.Fatalf("follow-up passthrough=%q want %q (no stale state)", out.String(), "A")
120+ }
121+}
122+
101123 func TestScannerForwardsInterleavedGarbageInOrder(t *testing.T) {
102124 var s Scanner
103125 var out bytes.Buffer
@@ -98,6 +98,28 @@ func TestScannerHandlesSplitSchemeNotifications(t *testing.T) {
98 }98 }
99 }99 }
100 100
101+func TestScannerForwardsBareCSIIntroducerImmediately(t *testing.T) {
102+ var s Scanner
103+ var out bytes.Buffer
104+ schemes := 0
105+
106+ s.Scan([]byte("a\x1b["), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) })
107+
108+ if out.String() != "a\x1b[" {
109+ t.Fatalf("passthrough=%q want %q", out.String(), "a\x1b[")
110+ }
111+ if schemes != 0 {
112+ t.Fatalf("schemes=%d want 0", schemes)
113+ }
114+
115+ out.Reset()
116+ s.Scan([]byte("A"), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) })
117+
118+ if out.String() != "A" {
119+ t.Fatalf("follow-up passthrough=%q want %q (no stale state)", out.String(), "A")
120+ }
121+}
122+
101 func TestScannerForwardsInterleavedGarbageInOrder(t *testing.T) {123 func TestScannerForwardsInterleavedGarbageInOrder(t *testing.T) {
102 var s Scanner124 var s Scanner
103 var out bytes.Buffer125 var out bytes.Buffer
M pkg/vt/query_test.go +20 -0
@@ -151,6 +151,26 @@ func TestScreenColorSchemeQueriesUnknownAreSilent(t *testing.T) {
151151 }
152152 }
153153
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+
154174 func TestScreenColorSchemeUnsolicitedOnlySubscribedAndChanged(t *testing.T) {
155175 s := NewScreen(80, 24)
156176 var got bytes.Buffer
@@ -151,6 +151,26 @@ func TestScreenColorSchemeQueriesUnknownAreSilent(t *testing.T) {
151 }151 }
152 }152 }
153 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+
154 func TestScreenColorSchemeUnsolicitedOnlySubscribedAndChanged(t *testing.T) {174 func TestScreenColorSchemeUnsolicitedOnlySubscribedAndChanged(t *testing.T) {
155 s := NewScreen(80, 24)175 s := NewScreen(80, 24)
156 var got bytes.Buffer176 var got bytes.Buffer
M pkg/vt/screen.go +1 -0
@@ -1037,6 +1037,7 @@ func (s *Screen) reset() {
10371037 s.mouseMode = 0
10381038 s.mouseSGR = false
10391039 s.bracketedPaste = false
1040+ s.colorSchemeMode = false
10401041 s.resetScrollRegion()
10411042 s.fullRedraw()
10421043 }
@@ -1037,6 +1037,7 @@ func (s *Screen) reset() {
1037 s.mouseMode = 01037 s.mouseMode = 0
1038 s.mouseSGR = false1038 s.mouseSGR = false
1039 s.bracketedPaste = false1039 s.bracketedPaste = false
1040+ s.colorSchemeMode = false
1040 s.resetScrollRegion()1041 s.resetScrollRegion()
1041 s.fullRedraw()1042 s.fullRedraw()
1042 }1043 }

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